# CMS Grid
> Source: /cms/grid
> The page-scoped 12-column layout tree — node hierarchy, placement contract, responsive behavior, and styling allowlist

# CMS grid

Every CMS page is a single drag-and-drop area that owns one layout tree. The
tree sits *above* module content: `cms_page_blocks` rows hold what a module
says, and the layout holds where it sits. The two are stored and validated
separately, so reordering a page never rewrites block content.

The column count is a platform invariant. Every section and row creates a
12-column child grid, and that number is not theme-configurable.

## Composition hierarchy [#composition-hierarchy]

The hierarchy is strict, and modules are always leaves. A page is one drag-and-drop
area whose containers nest to produce the rendered layout:

<div className="not-prose my-6 rounded-xl border border-dashed border-gray-400/60 dark:border-gray-600/60 p-4 text-[11px]">

  <div className="mb-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-gray-600 dark:text-gray-400">
    <span className="flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border border-blue-500 bg-blue-500/20" />section</span>
    <span className="flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border border-emerald-500 bg-emerald-500/20" />column</span>
    <span className="flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border border-amber-500 bg-amber-500/20" />row</span>
    <span className="flex items-center gap-1.5"><span className="h-3 w-3 rounded-sm border border-violet-400 bg-violet-400/30" />module</span>
    <span className="ml-auto text-gray-500">page</span>
  </div>

  <div className="space-y-3">

    <div className="rounded-lg border border-blue-500/50 bg-blue-500/5 p-3">
      <div className="mb-2"><span className="rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-medium text-white">section</span></div>
      <div className="rounded border border-violet-400/60 bg-violet-400/20 py-3 text-center">module · span 12</div>
    </div>

    <div className="rounded-lg border border-blue-500/50 bg-blue-500/5 p-3">
      <div className="mb-2"><span className="rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-medium text-white">section</span></div>
      <div className="flex flex-col gap-3 sm:flex-row">

        <div className="rounded-lg border border-emerald-500/50 bg-emerald-500/5 p-3 sm:w-7/12">
          <div className="mb-2"><span className="rounded bg-emerald-500 px-1.5 py-0.5 text-[10px] font-medium text-white">column · start 0 · span 7</span></div>
          <div className="space-y-3">
            <div className="rounded border border-amber-500/50 bg-amber-500/5 p-2.5">
              <div className="mb-2"><span className="rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white">row</span></div>
              <div className="flex gap-2.5">
                <div className="w-1/2 rounded border border-violet-400/60 bg-violet-400/20 py-2 text-center">module · span 6</div>
                <div className="w-1/2 rounded border border-violet-400/60 bg-violet-400/20 py-2 text-center">module · span 6</div>
              </div>
            </div>
            <div className="rounded border border-amber-500/50 bg-amber-500/5 p-2.5">
              <div className="mb-2"><span className="rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white">row</span></div>
              <div className="rounded border border-violet-400/60 bg-violet-400/20 py-2 text-center">module · span 12</div>
            </div>
          </div>
        </div>

        <div className="rounded-lg border border-emerald-500/50 bg-emerald-500/5 p-3 sm:w-5/12">
          <div className="mb-2"><span className="rounded bg-emerald-500 px-1.5 py-0.5 text-[10px] font-medium text-white">column · start 7 · span 5</span></div>
          <div className="rounded border border-amber-500/50 bg-amber-500/5 p-2.5">
            <div className="mb-2"><span className="rounded bg-amber-500 px-1.5 py-0.5 text-[10px] font-medium text-white">row</span></div>
            <div className="flex h-[124px] items-center justify-center rounded border border-violet-400/60 bg-violet-400/20 text-center">module · span 12</div>
          </div>
        </div>

      </div>
    </div>

    <div className="rounded-lg border border-blue-500/50 bg-blue-500/5 p-3">
      <div className="mb-2"><span className="rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-medium text-white">section</span></div>
      <div className="flex flex-col gap-3 sm:flex-row">
        <div className="rounded border border-violet-400/60 bg-violet-400/20 py-2.5 text-center sm:w-1/3">module · span 4</div>
        <div className="rounded border border-violet-400/60 bg-violet-400/20 py-2.5 text-center sm:w-1/3">module · span 4</div>
        <div className="rounded border border-violet-400/60 bg-violet-400/20 py-2.5 text-center sm:w-1/3">module · span 4</div>
      </div>
    </div>

  </div>
</div>

A page is built from three sections. The middle section splits into two columns,
and the left column stacks two rows. A module may sit directly in a section or
inside a row — both appear above. Only `column` and `module` nodes carry
placement; sections and rows are always full width within their parent.

| Node | May contain | Carries placement |
| --- | --- | --- |
| root | `section` | No |
| `section` | `column`, `module` | No — always a root container |
| `column` | `row` | Yes |
| `row` | `column`, `module` | No — always full width in its column |
| `module` | nothing | Yes |

Sections and rows do not carry placement relative to their own children. A row
is always full width within its parent column, and a section is always a root
container. This prevents two competing width concepts from existing at once.

A module node references exactly one existing `cms_page_blocks` row, and every
page block appears exactly once in the layout. A page may contain many module
instances of the same `block_type` — each is its own block row with independent
content and its own module node. The one-placement rule constrains block rows,
not block types.

## Placement contract [#placement-contract]

Direct `column` and `module` children of sections and rows declare a zero-based
`start` and a `span`:

```ts
interface GridPlacement {
  desktop: { start: number; span: number };
  tablet?: { start: number; span: number };
  mobile?: { start: number; span: number };
}
```

- `start` is `0..11`, `span` is `1..12`, and `start + span <= 12`.
- Sibling intervals must not overlap at any breakpoint. Gaps are allowed.
- Tablet inherits desktop when omitted.
- Mobile defaults to `{ start: 0, span: 12 }` when omitted, so desktop columns
  stack safely on small screens.

<div className="not-prose my-6 text-[11px]">

  <div className="mb-1.5 flex items-center gap-3">
    <div className="w-16 shrink-0" />
    <div className="grid flex-1 grid-cols-12 text-gray-500 dark:text-gray-400">
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">0</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">1</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">2</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">3</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">4</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">5</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">6</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">7</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">8</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">9</div>
      <div className="border-l border-gray-300 pl-1 dark:border-gray-700">10</div>
      <div className="border-l border-r border-gray-300 pl-1 dark:border-gray-700">11</div>
    </div>
  </div>

  <div className="flex items-center gap-3">
    <div className="w-16 shrink-0 text-right text-gray-600 dark:text-gray-400">desktop</div>
    <div className="grid flex-1 grid-cols-12 gap-1">
      <div className="col-span-7 rounded border border-violet-400/60 bg-violet-400/20 px-2 py-2">start 0 · span 7</div>
      <div className="col-span-5 rounded border border-violet-400/60 bg-violet-400/20 px-2 py-2">start 7 · span 5</div>
    </div>
  </div>

  <div className="mt-2 flex items-start gap-3">
    <div className="w-16 shrink-0 pt-2 text-right text-gray-600 dark:text-gray-400">mobile</div>
    <div className="grid flex-1 grid-cols-12 gap-1">
      <div className="col-span-12 rounded border border-violet-400/60 bg-violet-400/20 px-2 py-2">start 0 · span 12</div>
      <div className="col-span-12 rounded border border-violet-400/60 bg-violet-400/20 px-2 py-2">start 0 · span 12</div>
    </div>
  </div>

</div>

The same two children at two breakpoints. Each becomes full width on mobile and
they stack in document order.

The API uses `start`/`span` rather than `offset`/`width` because `start` is
unambiguously an absolute grid line, which makes overlap validation explicit.

## Responsive behavior [#responsive-behavior]

Sections and rows take an optional `columnBehavior` at tablet and mobile:

- **Stack** renders every direct child full width. It is authoritative and
  non-destructive — saved child widths are preserved for later use.
- **Keep columns** uses the child widths saved for the active breakpoint,
  inheriting missing widths from the next wider breakpoint. Widths remain
  editable and always form one packed 12-column line.

An explicit tablet choice cascades into mobile until mobile owns its own choice.
Stacking order always equals document order; there is no breakpoint-specific
reordering, offsets, or custom mode.

<Note>
  Layouts saved before this setting existed infer their behavior rather than
  being migrated. Ambiguous legacy arrangements render unchanged until an author
  explicitly picks Stack or Keep columns.
</Note>

Padding and margin are editable per breakpoint on every node type. A narrower
breakpoint inherits from the wider one; on first edit the complete effective
four-sided value is copied into the active breakpoint, and the override can be
reset back to inheritance. The admin inspector shows whether a value is
automatic, inherited, or overridden.

## Styling [#styling]

`styles` is a responsive object — `desktop` plus optional `tablet` and `mobile`.
Values are typed DTOs. Raw CSS, class names, style strings, and scripts are
never accepted.

| Property | Applies to | Value |
| --- | --- | --- |
| `background` | section, row, column | `color`, `linearGradient` (direction + exactly two colors), or `image` (HTTPS CMS-hosted URL, position, `cover \| contain \| auto`) |
| `margin`, `padding` | all | top/right/bottom/left `CssLength` |
| `maxWidth` | section, row, column | pixel integer |
| `verticalAlignment` | section, row, column | `top \| middle \| bottom` |
| `fullWidth` | section only | boolean |
| `horizontalAlignment` | module only | `left \| center \| right` |

`CssLength` is `{ value: number, unit: 'px' | 'rem' | 'em' | '%' | 'vw' | 'vh' }`,
with finite-number and conservative range validation. Only one background
variant may be present at a time.

Responsive style objects use shallow property inheritance: an omitted breakpoint
inherits the next wider one, and a supplied property replaces that property as a
whole. This is deterministic for renderers and avoids CSS-string merge rules in
the API.

Block-specific visual options stay in the block schema rather than becoming
unvalidated layout styles.

## Theme defaults [#theme-defaults]

`cms_page_themes` carries a `grid` object holding page-wide defaults — content
max width, row and column gap, and the mobile and tablet breakpoints. The column
count is deliberately excluded; it is always 12.

## Writes and delivery [#writes-and-delivery]

Layout is read and replaced atomically through the Journey-admin endpoints under
`/admin/pages/:pageGroupId/layout`, gated by the existing `PAGES_READ` and
`PAGES_MANAGE` permissions. Replacement carries an `expectedRevision` so one
editor cannot silently overwrite another's drag-and-drop changes.

Block mutations stay independently useful. Creating a block automatically places
it in a new full-width section, deleting a block removes its module placement,
and the flat reorder operation reassigns blocks across the existing module slots
in depth-first visual order rather than discarding the tree.

Public page delivery returns the resolved layout tree, whose module nodes carry
the referenced block's public `blockKey`, `blockType`, and content. The flat
`blocks` array is retained as a compatibility projection in depth-first visual
order. No internal numeric identifiers are exposed — layout nodes use ULID
`nodeKey` values and modules use ULID `blockKey` values.

Clone-on-edit preserves `nodeKey` values and remaps internal IDs, and publishing
promotes the draft row transactionally, so layout changes cannot leak into a live
page.

## Storage [#storage]

| Table | Holds |
| --- | --- |
| `core.cms_page_layouts` | one layout per page row, with its revision |
| `core.cms_page_layout_nodes` | the section/column/row/module tree, placement, and styles |
| `core.cms_page_themes` | `grid` JSONB defaults |

Source: `api/src/core/cms/pages/layout/`, `api/src/core/cms/pages/schema/cms-page-layout-nodes.schema.ts`.
