# Layout: sections, scroll regions, wizards, breakpoints, heights

*Open when a region must scroll under a pinned header, when you are nesting tabs / steps / accordion, when something must be an exact pixel size, or when the form needs `md`/`sm`.*

## Height is two unrelated keys

`layout[bp].height` is a grid ROW SPAN and the only thing that makes an element taller; `containerSettings.size.heightMode` (`'auto' | 'fixed' | 'fill'`) is scroll/fill behaviour only. Both are legal on one element, so nothing flags the mix-up.

Row height is not pixels: `settings.gridAutoRows` defaults to `'min-content'`, so a row is as tall as its content. A workspace that must fill the viewport sets `settings.gridAutoRows: '1fr'`.

An omitted `heightMode` renders `'auto'` — but the builder's palette stamps `'fill'` onto every new `container`, `flexContainer`, `tabs`/`steps`/`accordion`, `entityList`, `menuPanel`, `workspaceViewport`, `recordDetail`. Containers you inherit are already filled. State the mode explicitly on every container you touch.

## The chain rule

A region scrolls INTERNALLY only when every container from it up to a height-BOUNDED ancestor — a `'fixed'` block, or the bounded form viewport at the root — is `'fill'` or `'fixed'`. One `'auto'` ancestor anywhere in that span breaks the chain: the whole subtree grows instead, the outer form scrolls, and the header you pinned rides off-screen. Nothing in the gate checks this chain; it is verified upstream only by an LLM critic this skill does not run.

To pin a header/tab strip/toolbar over a scrolling body: `'fill'` on the body AND every ancestor up to the bound, and `'auto'` written EXPLICITLY on the pinned sibling. Fill exactly one chain — every `'fill'` row is sized `minmax(0, 1fr)`, so a second filled sibling takes an equal share of the leftover height.

A container whose children are only buttons — toolbar, footer, action row — is always `'auto'`. The `ux_fill_on_action_bar` lint and its auto-downgrade fire only when the container has children and EVERY child is a button, so a bar with a heading beside its buttons keeps a wrong `'fill'` silently.

## Wrapper, not panel

Put `'fill'` on the `tabs` / `accordion` / `steps` WRAPPER and its ancestors. Never on the inner `tabsContainer` / `accordionContainer` / `stepsContainer` — a panel force-fills its wrapper already, and setting it there does nothing while the chain above stays broken.

## Exact sizes inside a grid

`containerSettings.size.width` with `widthMode: 'fixed'` DOES apply at the grid and at the form root (`SizeResolver.gridChild` emits it), pinning the element narrower than its column area — that, not a span change, answers "make the tabs widget exactly 400px". `'auto'` and `'fill'` leave width to `layout[bp].width`. The options panel hides the Width control at grid (`visibleWhen`), and the manifest row said "only applies inside a flexContainer" until 2026-09-16, so both the UI and older forms suggest otherwise.

`heightMode: 'fixed'` at grid pairs with `overflow: hidden`, so taller content is CLIPPED, not scrolled. Set `containerSettings.overflow: 'auto'` when a fixed-height block should scroll. `'hidden'` still is a scroll container (sticky descendants work); `'clip'` is not.

## Breakpoints

`settings.breakpoints` holds whole objects, never keys:

```json
{ "key": "md", "label": "formBuilder.breakpoints.medium", "minWidth": 640, "canvasWidth": 768, "kind": "desktop", "removable": true }
```

Presets: `lg` 1024/1024 (`removable: false`), `md` 640/768, `sm` 0/480. Mobile list is `settings.breakpointsMobile`: `mobile-sm` 0/375 `kind: 'touch'`, `mobile-md` 640/768.

A bare string array (`['lg','md']`) is not a breakpoint list: `minWidth` is `undefined`, `BreakpointUtils.ordered()` sorts on NaN, and the form resolves to a breakpoint no element has an entry for — a blank form. The gate makes this an ERROR only when a settings baseline is given; from scratch it is a warning beside `ok: true`. Normalization does not repair it either: a string entry counts as "declared".

There is no fallback between breakpoints — *"кожен елемент завжди має повний `layout[bp]` запис для кожного активного breakpoint форми; fallback chain не використовується"*. An element with an `lg` entry and no `md` entry is unplaced — invisible — at `md`. Repair only happens if you run the gate with `--normalize`, which copies each missing entry from the nearest wider one; that makes `md` a clone of the desktop layout, so a form that "declares md" is not a form that stacks on tablet until you author the `md` coordinates yourself.

**Where the narrow breakpoints actually apply, today.** The builder canvas and the Viewer preview resolve the active breakpoint from the container's width. The production runtime does **not** yet: *"Продакшн-рантайм (FormHost/FormCanvas) поки НЕ авто-резолвить breakpoint за реальною шириною контейнера — `currentLayout` там лишається на seed-значенні (найширший ключ)"* ([responsive layouts](https://docs.modern-expo.com/frontend-fb2/formBuilder/features/responsive-layouts.html)). So `md`/`sm` coordinates are real work with a real effect in the builder, and `lg` is the one that ships. Author the narrow entries when the form is meant to be edited responsively; do not promise a user that production stacks on a phone. (The phone story is `mobileSchema`, a separate presentation — see SKILL rule 7.)

## Array order is not structure

Nesting is `layout[bp].parentId`; placement is the declared `(y, x)`. With `--normalize` each `(breakpoint, parent)` group is shelf-packed gap-free in coordinate reading order, so your coordinates survive. Upstream, the agent's polish packs by SCHEMA-ARRAY order instead when a group is entirely new AND entirely full-width — but only when handed baseline ids, which this skill's gate never passes, so array order never wins here.
