Skip to content
Back to skills

Crouton Layout Reference

ASecurity

System reference for the crouton layout engine (@fyit/crouton-layout) — the LayoutTree/layout_configs data model, the croutonLayoutBlocks registry and sizing contract, the viability metric, the deterministic placer (#709), and how layouts persist and seed. Use when reasoning about WHY a layout renders/composes/persists the way it does, when a question is shaped like "what is a LayoutTree / where do layouts live / why did the placer pick this arrangement / why won't this pane shrink / where do...

  • 10 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 12, 2026
developmentrustgoshellbashsqlvuenodespringgitapi

Works with

  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned September 12, 2026

npx -y skills add FriendlyInternet/nuxt-crouton --skill crouton-layout-reference --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Crouton Layout Reference?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Crouton Layout Reference
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/friendlyinternet-crouton-layout-reference/badge)](https://www.skillsdirectory.com/skills/friendlyinternet-crouton-layout-reference)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: crouton-layout-reference
layer: stack
description: System reference for the crouton layout engine (@fyit/crouton-layout) — the LayoutTree/layout_configs data model, the croutonLayoutBlocks registry and sizing contract, the viability metric, the deterministic placer (#709), and how layouts persist and seed. Use when reasoning about WHY a layout renders/composes/persists the way it does, when a question is shaped like "what is a LayoutTree / where do layouts live / why did the placer pick this arrangement / why won't this pane shrink / where does minWidth get read", or when a generated POC boots with the wrong (or no) default layout. Trigger phrases: "layout tree", "layout_configs", "viability check", "composeDefaultLayout", "block sizing contract", "why is my layout not viable". For authoring a block component, use the block-authoring skill instead.
---

# crouton-layout-reference — layout engine theory

One-line purpose: the verified system model of `@fyit/crouton-layout` — data shapes, sizing contract, viability math, placer rules, persistence — so you can reason about layout behaviour without re-deriving it from source.

## When to use / when NOT to use

| You want | Go to |
|---|---|
| Understand/debug how layouts compose, size, persist, seed | **this skill** |
| **Author or fix a block component** (the `@container` rule, list/form playbooks, picking `minWidth`) | `block-authoring` skill |
| The crouton mental model, dependency invariants, registry overview | sibling `crouton-architecture-contract` |
| What the generate pipeline emits (incl. `crouton.layout.json` in context of all artifacts) | sibling `crouton-generation-reference` |
| Full per-file component/composable map of the package | `packages/crouton-layout/CLAUDE.md` (Key Files — the FIRST table; the second "Key Files" heading is a stale leftover shell, ignore it) |
| Boot an app and see a layout live | sibling `crouton-run-and-operate` |

## The 30-second model

**Layout is data, not `.vue`.** A team's admin surface is a `LayoutTree` — a JSON tree of panes — persisted in the `layout_configs` DB table and rendered at runtime by recursive splitter components. Leaves of the tree are **blocks**: components a package registers as placeable via the `croutonLayoutBlocks` app.config registry. Every arrangement is gated by one objective metric — **viability** (every block gets ≥ its declared `minWidth`) — used identically by the deterministic **placer** at generate time and the renderer at runtime. Dependency direction is a HARD RULE: `crouton-layout → crouton-core`, never reverse; feature packages contribute blocks via the registry, never by depending on the layout package (see `crouton-architecture-contract` for why).

The package is **default-on** (`crouton.manifest.ts` has `bundled: true`) — every crouton app gets the engine unless `features.layout: false`.

## 1. The data model (`LayoutTree` / `layout_configs`)

**Types live in `crouton-core`, not `crouton-layout`** — `packages/crouton-core/app/types/layout.ts` and `layout-block.ts` — deliberately, so feature packages (bookings, pages) import the shared contract from core and need no dep on the layout package.

```ts
interface LayoutTree {
  renderer: 'panes'              // the only shipped renderer; 'canvas'/'spatial' are open epics (#855)
  root: LayoutNode
  breakpoints?: LayoutBreakpoint[]  // authored responsive checkpoints (WS5 #874)
}
type LayoutNode = LayoutLeaf | LayoutSplit | LayoutNested
```

| Node | Shape | Meaning |
|---|---|---|
| `leaf` | `{ type:'leaf', blockId, config?, collapse?, defaultSize?, minSize? }` | One placed block. `config.collection` binds it to a generated collection's registry key (e.g. `shopProducts`). `blockId` is resolved against the registry — **allowlisted**: unknown id → safe fallback, never an arbitrary component. |
| `split` | `{ type:'split', direction:'horizontal'\|'vertical', children }` | A reka-ui SplitterGroup. Horizontal divides **width**; vertical divides height (each child keeps full width). |
| `nested` | `{ type:'nested', layout: LayoutTree, label? }` | A whole sub-layout in a pane — "layouts in layouts" (WS2 #871). Carries its own `renderer` + `breakpoints`; renderer/viability/edits recurse into `layout.root`. |

`defaultSize`/`minSize` (on any node) are **percentages of the parent split**, not px. Px floors come from the block sizing contract (section 2).

**Breakpoints** (`LayoutBreakpoint`): min-width checkpoints that **lock upward**, resolved per-field last-wins by `resolveLayoutAtWidth` (`app/utils/layout-responsive.ts`). Each can override `root` (full re-arrangement), `collapsed` (blockIds), `variants` (per-block display variant), `collapseStyle`. This is the *explicit* responsiveness layer; the *intrinsic* layer is each rendered pane being a CSS `@container` (`.croutonpane` class in `LayoutRenderer.vue`) so blocks reflow to their own pane width.

**Collapse vocabulary** (all in `crouton-core/app/types/layout.ts`): `LAYOUT_COLLAPSE_STYLES = ['gutter-tabs','spring-drawer','crt-power-down','iris-portal']` (default `gutter-tabs`, the only out-of-flow one); per-leaf `collapse: { edge, affordance }` recipe (#852, default right-edge tab).

## 2. Blocks: registration + the sizing contract

A block is registered by adding a `CroutonLayoutBlockDefinition` under `croutonLayoutBlocks` in a layer's `app/app.config.ts` (defu-merged across all layers — that's the whole contribution mechanism). `component` is an auto-imported component **NAME string** for `<component :is>` — never a component object, never `resolveComponent()`.

**Snapshot 2026-07-02 — the registry numbers below drift; regenerate with the sizing-mirror grep in the re-verify block before relying on them.**

**The default registry** — `packages/crouton-layout/app/app.config.ts`:

| id | component | minWidth | defaultSize | configSchema |
|---|---|---|---|---|
| `collection-list` | `CroutonLayoutCollection` | 260 | 34 | `collection`, `heading`, `layout` (list/grid/table) |
| `entity-form` | `CroutonLayoutForm` | 320 | 50 | `collection`, `heading` |
| `stats` | `CroutonLayoutSpikeStats` | 200 | 40 | — |

**Bookings additions** — `packages/crouton-bookings/app/app.config.ts`: `bookings-calendar` (`CroutonBookingsLayoutCalendar`, 520/65), `bookings-calendar-only` (460/60), `bookings-list` (300/40), `bookings-locations` (220/25), `bookings-filters` (240/25).

**The sizing contract** (fields on the definition, `crouton-core/app/types/layout-block.ts`; all optional, undeclared ⇒ fully fluid, `minWidth` treated as 0):

| Field | Read by |
|---|---|
| `minWidth` (px) | **The viability floor.** `checkLayoutViability` (gate), `subtreeMinWidth`/`panelMinSizePct` (runtime drag floor), the placer, `deriveSizing` |
| `minHeight` (px) | `deriveSizing` (height fold) |
| `maxWidth`, `aspect`, `resize: 'free'\|'fixed'\|'aspect'`, `density` | declared contract; advisory hints |
| `defaultSize` (% of pane group) | the placer seeds it onto placed leaves; viability's width-share model |
| `sizing: { width, height: 'fill'\|'hug' }` (#986) | `sizingResolver`/`deriveSizing`; the renderer renders a split containing a `hug`-along-axis pane as plain flex (content-sized) instead of a splitter |
| `variants: string[]` (#986) | `blockVariants`/`resolveVariant` — bounded display-variant set; unknown value falls back to the FIRST declared option |

Runtime read side: `useCroutonLayoutBlocks()` (`app/composables/useCroutonLayoutBlocks.ts`) — `getBlock`/`hasBlock`/`resolveComponentName` (allowlisted), `sanitizeConfig` (keeps only declared `configSchema` fields of the right primitive type; wrong/missing → the field's `default`), `checkViability(tree, widths)`, `composeDefault(collections)`.

How to *pick* the values (and the `@container` hard rule) is the `block-authoring` skill's job — don't restate it, load it.

## 3. The viability metric (#710)

File: `packages/crouton-layout/app/utils/layout-viability.ts`. Pure (no Nuxt), unit-tested.

**Definition:** a layout is *viable* iff every placed block gets ≥ its declared `minWidth` (px) at every target container width checked. `checkLayoutViability(root, minWidthResolver(registry), targetWidths)` → `{ viable, violations: [{ blockId, paneWidth, minWidth, containerWidth }] }`.

**Width model** (the only constrained axis is width): a **horizontal** split divides its width among children by normalized `defaultSize` shares (equal when unset); a **vertical** split gives each child the **full** width; a `nested` node recurses into `layout.root` at the same width. Resize-handle pixels are deliberately ignored. Pane widths are rounded to 2 decimals to avoid float dust. There are **no thresholds beyond `minWidth` itself** — the metric is binary per (block, width) pair; the conventional target widths are the placer's `DEFAULT_TARGET_WIDTHS` (`[1280, 768]`, "desktop + tablet").

**Companion exports** (same file):
- `subtreeMinWidth(node, resolver)` — the px floor a whole subtree needs: **SUM** of children for horizontal, **MAX** for vertical. This is the enforcement side: `panelMinSizePct(parentDirection, child, containerWidthPx, resolver)` converts it to a reka-ui SplitterPanel min-size **%** (only under a horizontal parent; falls back to authored `minSize` when unmeasured; capped at 90%) — why a pane "refuses" to be dragged smaller.
- `deriveSizing(node, registry)` (#986) — bottom-up composite fold → `{ hardMinWidth, softMinWidth, minHeight, width, height }`. `hardMinWidth` = widest single leaf (the reflow-to-a-column floor); `softMinWidth` **== `subtreeMinWidth`** (deliberately reused so they can't drift); `minHeight` mirrors width with axes swapped; a composite always `fill`s.
- `sizingResolver`, `blockVariants`, `resolveVariant` — see the contract table above.

## 4. The placer — `composeDefaultLayout` (#709)

File: `packages/crouton-layout/app/utils/layout-compose.ts`. Pure and **deliberately deterministic — no LLM** (the LLM `/layout` pass is #711, gated, not built; an LLM layout "must beat the deterministic default *and* stay viable"). The same function runs in two hosts:
1. **CLI**, post-generation: `packages/crouton-cli/lib/compose-layout.ts` `writeDefaultLayout()` → writes `crouton.layout.json` at the app root.
2. **In-app**: `useCroutonLayoutBlocks().composeDefault(collections)` against the live registry.

Input: `{ collections: [{ key, label?, calendar? }], registry, targetWidths? = DEFAULT_TARGET_WIDTHS, blockIds? }` where `key` is the collection's registry key (`layerCamel(layer) + PascalCasePlural`, e.g. `bookingsBookings` — mirrored by `registryKeyFor()` in the CLI). Default block ids: `collection-list` / `entity-form` / `bookings-calendar`.

**Selection rules** (verified against source, in order):
1. A collection with `calendar: true` + calendar & list blocks registered → **`calendar-primary`**: horizontal `[list 30, calendar 70]`; if not viable, vertical with **calendar on top**.
2. Else list + form both registered → **`master-detail`** on the FIRST collection: `pairOrStack(list 40, form 60)` — horizontal if viable, else vertical stack (vertical keeps full width, so it's viable whenever each block alone fits the narrowest target).
3. Else only form → **`form-centric`** (single leaf); only list → **`stacked`** (single leaf).
4. Every remaining collection → one `collection-list` leaf each, **stacked vertically under** the primary node.
5. No collections → `empty` pattern (bare list leaf, viable); no usable blocks → `empty` with `viable: false`.

Return: `{ tree, pattern, viable, violations }` — the final tree is always re-run through the gate, so a caller can surface a layout that can't satisfy every `minWidth` even after the vertical fallback.

**CLI specifics** (`compose-layout.ts` in crouton-cli): bookings blocks are included when `features.bookings` is truthy OR any collection landed in the `bookings` layer; a collection is `calendar: true` when it's in the `bookings` layer AND its name matches `/booking/i`. The written `crouton.layout.json` payload is `{ id: 'default', renderer, pattern, viable, tree }` — `pattern`/`viable` are advisory; the seed runner reads `{ id, tree }`.

## 5. Persistence: how layouts save, load, and seed

**Table** — `packages/crouton-layout/server/database/schema/layoutConfigs.ts` (verified): `layout_configs (id text PK nanoid-default, teamId notNull + index, name, renderer default 'panes', tree json, createdAt, updatedAt)`. Deliberately separate from crouton-flow's `flow_configs`.

**API** — `server/api/teams/[id]/crouton-layouts/[layoutId].{get,put}.ts`. Both resolve team membership and query by `(id AND teamId)`. PUT runs the body's tree through **`sanitizeLayoutTree`** (`app/utils/layout-tree.ts` — pure shape gate: copies only known fields, clamps sizes to 0–100, `MAX_DEPTH = 12` recursion cap, returns `null` for implausible input) and upserts the **cleaned** tree. Defence in depth: sanitize on write, allowlist blockIds + `sanitizeConfig` at render.

**Client store** — `useCroutonLayoutStore()`: `load(layoutId)` re-sanitizes on the way OUT of storage too (a DB-tampered row can't feed the renderer); `save(layoutId, tree)` is debounced (600ms). The team layout surface `app/pages/admin/[team]/layout.vue` loads/saves the hardcoded row **`LAYOUT_ID = 'default'`**.

**Seeding** — `crouton-seed` (crouton-cli `lib/seed-app.ts`, `collectDefaultLayoutSql`): if `crouton.layout.json` exists at the app root, upsert its tree into `layout_configs` with row id `default` (idempotent `INSERT … ON CONFLICT(id) DO UPDATE`, `createdAt` immutable) for the single seeded team. That's the full generate → booted-laid-out-POC chain: generate → placer → `crouton.layout.json` → seed → `layout_configs[default]` → `/admin/[team]/layout`.

**Known weak point (verified in source comments):** `layout_configs.id` is a **global** primary key — the API reads by `(id, teamId)`, but only ONE team can own the row literally named `default`. Seeding multi-team default layouts is explicitly out of scope (`seed-app.ts` NB comment).

**Serialization out** — `layout-serialize.ts` (#987) gives the canonical diffable string form (stable key order, defaults omitted, sizes rounded); `layout-ticket.ts` (#974) is the GitHub-comment codec for the agent⇄human loop. Both re-validate through `sanitizeLayoutTree` on parse.

## 6. Drift hazards

| Hazard | State |
|---|---|
| **CLI sizing mirror.** `crouton-cli/lib/compose-layout.ts` hardcodes `CORE_BLOCKS` + `BOOKINGS_BLOCKS` (mirrors of the registry's minWidth/defaultSize values) because the CLI has no live app.config at generate time. A package changing `minWidth`/`defaultSize` silently desyncs the generate-time placer from the runtime registry. **The keep-in-sync comment itself has drifted**: it points at `crouton-core/app/app.config.ts`, but the live default registry moved to `crouton-layout/app/app.config.ts` in the #751 extraction. Values matched at the snapshot date — re-check with the sizing-mirror grep before trusting. | live hazard |
| **`crouton-layout/CLAUDE.md` self-contradicts**: marks the #756 server-side extraction pending and has a second empty "Key Files" heading, while the `layout_configs` schema + API exist on disk. Trust the code and the FIRST table. | stale doc |
| **`block-authoring` skill's reference pointer is stale**: it cites `LayoutSpikeList/Form/Stats.vue` in `packages/crouton-core/app/components/` — only `LayoutSpikeStats.vue` survives, and it lives in `packages/crouton-layout/app/components/`. The skill's *rules* are still correct. | stale pointer |
| **Pre-#709 apps have no `crouton.layout.json`**: apps generated before the layout pass never got it and boot without a seeded default layout until regenerated (list who has one: the `find` in the re-verify block). | expected gap |

Trust order when these bite: code first, then `packages/crouton-layout/CLAUDE.md` (first Key Files table); for doc-vs-doc conflicts see `crouton-docs-trust-map` §1.

## 7. Open fronts — the layout epic sprawl

Several **open** epics overlap on `pkg:crouton-layout` scope. **Snapshot 2026-07-02 — issue states are live data; re-check with the `gh issue view` loop in the re-verify block before relying on them.** Read before minting anything new here — the idea probably already has an epic:

| Epic | Scope | Status at snapshot |
|---|---|---|
| [#703](https://github.com/FriendlyInternet/nuxt-crouton/issues/703) | The lean layout engine itself (registry → contract → panes → placer; sprint ladder #713→#704→#710→#706→#709→gated #711) | open — the shipped code documented above IS its output |
| [#855](https://github.com/FriendlyInternet/nuxt-crouton/issues/855) | One block, three renderers (`panes`/`canvas`/`spatial`), magnetic snap, VR | open, unstarted checklist |
| [#868](https://github.com/FriendlyInternet/nuxt-crouton/issues/868) | "Maquette" semantic-zoom builder umbrella (zoom shell, nested layouts, breakpoints, collapse styles — several WS have landed as the #870–#875/#899 code above) | open |
| [#895](https://github.com/FriendlyInternet/nuxt-crouton/issues/895) | Extract the pure engine as a standalone OSS package | open, ecosystem-check first |
| [#905](https://github.com/FriendlyInternet/nuxt-crouton/issues/905) | App-canvas on Vue Flow — drop blocks, snap or ✨magic-arrange (deterministic v1 = `composeDefaultLayout`; AI v2 with viability as guardrail) | open |

Discovery flags this as dedup debt (recent real work reportedly happened under #983 — unverified, from the discovery briefing). The LLM layout pass (#711) remains **gated and unbuilt**; do not present it as existing.

## 8. Utility map (where the theory lives)

The full per-file map is `packages/crouton-layout/CLAUDE.md` — index only, don't re-read it all:

| Concern | File (`packages/crouton-layout/app/`) |
|---|---|
| Placer | `utils/layout-compose.ts` |
| Viability + sizing derivation | `utils/layout-viability.ts` |
| Pure edit transforms (drop/split/remove/nested/pane-drop) | `utils/layout-edit.ts` |
| Sanitizer (shape gate) | `utils/layout-tree.ts` |
| Breakpoint precedence | `utils/layout-responsive.ts` |
| Canonical serialization / ticket codec | `utils/layout-serialize.ts` / `utils/layout-ticket.ts` |
| Snap geometry / FLIP math / pieces↔tree bridge | `utils/layout-snap.ts` / `layout-flip.ts` / `layout-compose-bridge.ts` |
| Registry reader + live compose/viability | `composables/useCroutonLayoutBlocks.ts` |
| Persistence client | `composables/useCroutonLayoutStore.ts` |
| Read-only / editable / responsive renderers | `components/LayoutRenderer.vue` / `LayoutEditableRenderer.vue` / `LayoutResponsiveRenderer.vue` |
| Shared types (in **core**) | `crouton-core/app/types/layout.ts`, `layout-block.ts` |

## Provenance and maintenance

verified: 2026-07-02

```bash
grep -n "minWidth\|defaultSize" packages/crouton-cli/lib/compose-layout.ts packages/crouton-layout/app/app.config.ts packages/crouton-bookings/app/app.config.ts  # sizing-mirror drift + current registry numbers
sed -n '1,80p' packages/crouton-layout/app/utils/layout-compose.ts     # placer rules — composeDefaultLayout is the whole rule set; read the file when in doubt
cat packages/crouton-layout/server/database/schema/layoutConfigs.ts    # table shape
find apps pocs fixtures -maxdepth 2 -name crouton.layout.json -not -path "*/node_modules/*"  # who has a default-layout file
for n in 703 855 868 895 905; do gh issue view $n --json number,state,title -q '"\(.number) \(.state) \(.title)"'; done  # epic states (or mcp__github__issue_read)
pnpm --filter @fyit/crouton-layout test                                # unit truth (vitest suites next to every util)
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…