Skip to content

Layout grammar

What it is

Every page in apps/showcase follows a fixed landmark grammar: a one-to-one mapping from Figma’s structural hierarchy (Page → Header → Section → Container → Column → Component → Footer) to sanctioned HTML landmarks and layout utilities (ADR-011). The grammar is the mandatory first pass of the /layout-generation command — structure is locked before any component selection or spacing happens — and it is enforced deterministically by npm run layout:validate <file> for any layout file, hand-edited or generated.

Why it’s built this way

ADR-011 records three concrete failures that forced the decision:

  1. Improvised structure. The /layout-generation skill worked at primitive-composition level (Stack/Inline/Box/Card) but had no rule for which construct mapped to which Figma wrapper — structure was re-derived from the brief every run, so output was inconsistent across runs.
  2. Wrong output target. The skill emitted Storybook stories, but the actual goal is route pages in apps/showcase. Storybook documents components; it doesn’t render full pages.
  3. Contradictory constraints. The rulebook forbade all inline styles, yet the only existing full-page example used .container, .grid, style={{ flex: 1 }}, and style={{ maxWidth }}. The rulebook and the codebase disagreed.

The Figma Course Overview page (node 96:5854) served as the canonical reference and confirmed both the hierarchy and two distinct column patterns: CSS Grid for equal N-column card grids, wrapping flex for two-panel layouts that stack on mobile. The building blocks already existed unused — Box is polymorphic (it can render as any HTML element via its as prop, typed as?: ElementType — <Box as="main"> → <main>), the .container/.grid utilities live in packages/components/src/styles/grid.css, and device tokens (--ds-grid-columns, --ds-grid-margin, --ds-grid-gutter) handle responsive reflow automatically.

How it works, concretely

The grammar table from ADR-011 — one abstraction level per Figma wrapper:

Figma level Sanctioned code Landmark / role Rule
Page <Box as="main"> main exactly one per route
Header <Box as="header"> or <AppHeader> banner / navigation each <nav> gets a unique aria-label
Section <Box as="section" aria-labelledby={headingId}> region must have an accessible name, aria-labelledby → its Heading
Container .container className presentational max-width + grid margin; not a landmark
Column (N-grid) .grid className (CSS Grid) presentational equal-width card grids (3–4+ items); column count reflows via --ds-grid-columns
Column (two-panel) Inline wrap + grow/minWidth props on each child Box or Stack presentational wrapping flex; minWidth sets the stack breakpoint
Component fixed-set library component, or its declared parts (<CardVertical.Body>) per component leaf
Footer <Box as="footer"> contentinfo one per page

The inline-style allowlist replaced the prior blanket prohibition (the reconciliation of problem 3):

  • Allowed: .container / .grid classNames.
  • Use the prop, not inline style: column fill, wrapping threshold, and content measure go through Box/Stack’s grow (flex fill), minWidth/maxWidth, and minHeight/maxHeight props — never hand-written as style={{ … }}.
  • Forbidden: raw color via inline style (use <Text color=…> or <Heading>); raw token values outside var(); arbitrary CSS that belongs in a component’s CSS Module.

How the allowed props reach the DOM (ADR-020): the enum-valued layout props (gap, align, justify, padding/paddingX/paddingY) render as data-* attributes (data-gap="lg", data-align="start"), and each component’s CSS Module maps them to token values with attribute selectors (.stack[data-gap="lg"] { gap: var(--ds-space-stack-lg); }). They never appear in a style attribute — so if you inspect a <Stack> or <Inline> in devtools and see no inline style, that’s the design, not a missing prop. Only the continuous props (grow, minWidth/maxWidth, minHeight/maxHeight) render as inline style, because they carry per-instance values with no closed set to select against. Any new prop with a small closed set of string options should follow the data-* pattern.

Responsiveness comes from the token layer, never from layout files: device tokens carry the per-breakpoint values, reflow happens via .grid or Inline wrap, and hand-written @media in a layout file is forbidden. Which spacing tokens a layout may touch is governed by ADR-004 (grid.* is consumed only by .container; components use space.*) and ADR-005 (space for gaps, size for element dimensions).

The validator enforces the load-bearing invariants without judgment: exactly one <main>, every <section> named, every extra <nav> uniquely labelled, fixed-set component names only. The fixed set is whatever @upskill/components exports, read from the package’s index.ts; the metadata validator checks part accepts against the same list, so the two can’t disagree (#98).

Dotted names are allowed for subcomponents (ADR-023). <CardVertical.Body> passes only when Body is declared in CardVertical’s metadata composition.parts; <CardVertical.Bogus> fails. For declared parts the validator also checks:

  • each part sits directly inside a parent from its containedBy list;
  • each slot or open part’s direct children are on its accepts list (raw text is rejected);
  • a slot has exactly one child;
  • a required part is present inside its container.

Children rendered from an expression ({items.map(…)}) can’t be counted statically, so the one-child and required checks skip that element.

It also checks Button emphasis by context (ADR-024). Button variants name a fixed weight (accent, neutral, transparent, danger), so which action is “primary” has to come from the container. A literal variant="accent" fails inside CardVertical/CardHorizontal or a .map() callback, where N repeated items would render N primaries, and when a <section> holds more than one.

Terminal window
npm run layout:validate -- apps/showcase/src/pages/CourseOverview.tsx

The /layout-generation command’s default output target is a route page in apps/showcase/src/pages/<Name>.tsx (a --story mode retains the old Storybook format for component-level review). Every structural choice it makes must cite a metadata rule — the composition.accepts/containedBy and usage.patterns fields required by the metadata schema (unpacked field by field in 10 — Machine-readable metadata). It also reads .claude/component-patterns.json, the cross-component pattern aggregate (ADR-013, shape and drift list also in page 10), to pick canonical pattern implementations and state/callback prop names — a consumer input for layout/composition work only, never injected into component scaffolds (the accuracy harness measured a regression there). Whether a layout need justifies a new component at all goes through the ADR-009 three-question test first.

Generated layout code never lands on main directly (ADR-016): once validation and typecheck pass, it goes to a layout/<kebab-name> branch with a PR, reviewed with in-session /code-review by default (the standard path) — the same no-unreviewed-agent-code invariant as component scaffolds, at a cheaper review tier. A deeper pass by the read-only adversarial-reviewer subagent (the full path) is opt-in, for complete route pages only (never --story fragments).

No diagram here: the grammar table is the spatial mapping, and a flowchart would only restate it.