# Layout: agent spec

Twin of `/layout`. Source wins: `app/assets/css/main.css`, `app/pages/[...slug].vue`, `app/app.config.ts`.

## Breakpoints

Tailwind v4 defaults, plus `nav` and `rail` (`main.css`).

| Breakpoint | Width | What changes |
|---|---|---|
| Base | 0–1023px | One column for `::grid`, `::compare-grid`, `::component-grid`, at any `columns`. Top bar and menu (`MobileBar.vue`). |
| `lg` | 1024px / 64rem | Grids take their column count. `::component-grid` is 2 columns (3 from `xl`). Chrome is still the top bar. |
| `nav` | 1124px / 70.25rem (`--breakpoint-nav`) | Sidebar (`UPageAside`) and foot bar replace the top bar. `--page-gutter` 15px → 60px. Type takes its full steps (`/agent/typography.md`). |
| `rail` | 1280px / 80rem (`--breakpoint-rail`) | The report's section navigator (`ReportToc`) shows. |

- Use `nav:` for anything that follows the sidebar. The below-`nav` tokens are one `@media (width < 70.25rem)` block in `main.css`.
- `sm` changes nothing in the guide's layout.

## Spacing

Tailwind's 4px unit. No separate scale.

| Value | Px | Use |
|---|---|---|
| `gap-x-4` | 16 | `::grid` column gap (default `gap="4"`), `::component-grid`, `::compare-grid` (`gap-4`) |
| `gap-y-8` | 32 | `::grid` row gap: twice the column gap |
| `my-6` | 24 | Space above and below a grid |
| `mt-6` | 24 | Page title to lead |
| `my-5` | 20 | Between prose paragraphs (Nuxt UI prose `p`) |
| `p-6` | 24 | Padding in 3:2 tiles (`LogoTile`, `TypeTile`, `MotifSquare`) |
| `p-8` | 32 | Padding in `IconTile`, `IconPairTile`, `ComponentCard`; sidebar top (`pt-8`) |
| none | 0 | `LogoClearspace`, `LogoMarkStage`, `ProfileMockup`, loader preview: art sized in the tile |
| `mt-3` | 12 | Caption under a tile (`TileFrame`) |
| `--sidebar-gap` | 64 (4rem) | Sidebar to content |
| `--prose-h2-above` | 96 (6rem) | Above every `##` |
| `--prose-h2-below` | 32 (2rem) | Below every `##` |
| `--prose-h3-above` | 48 (3rem) | Above every `###` |
| `--prose-h3-below` | 16 (1rem) | Below every `###` |
| `--page-gutter` | 60 (3.75rem); 15 (0.9375rem) below `nav` | Page side margin, both bars |
| `--text-measure` | 56ch (521px) | Prose `p`, callout; `ul`/`ol` take `+ --spacing(6)` (545px) |

## Composition grid

`/layout` shows only this grid; the guide's own web layout lives in this spec. For covers, posts, adverts and slides. `app/utils/composition-grid.ts` (`compositionGrid({ width, height })`); demo `::layout-grid` (`LayoutGrid.vue`), in a 16:9 `bg-elevated` tile, `p-4`.

| Term | Value |
|---|---|
| Cell (`c`) | Square. `width / (4n + 5)` |
| Block | 2 × 2 cells (`COMPOSITION_BLOCK_CELLS`) |
| Columns | 4 (`COMPOSITION_COLUMNS`), each `n` cells, `n` odd: `2 × round(((width / target - 5) / 4 - 1) / 2) + 1`, `target = min(width, height) / 25`. Every column starts on a block edge |
| Margin | 1 cell, every side. Nothing is placed in it |
| Gutter | 1 cell (`COMPOSITION_GUTTER_CELLS`) |
| Column `i` | starts at `c + i × (n + 1) × c`, width `n × c` |
| Rows | `floor((height - 2c) / c)`, from the top margin. The bottom margin takes the remainder |
| Mark | 2 cells high (`COMPOSITION_MARK_CELLS`), bottom right; its foot on the last row line. Y = 1.34c, so the 1-cell margin clears ½Y |
| Title | Two lines, top left. Capitals 2 cells high: font size `2c / 0.6505` (Overused Grotesk cap height). Baselines on rows 2 and 5; row 3 empty. Line 1 `--ui-primary`, line 2 `--ui-text-muted`. Tracking -0.045em |
| Caption | CommitMono 400, uppercase, tracking 0.025em, font size `title × 10 / 72` (label to H1), bottom left, baseline on the last row line |
| Type sizes | ≤ 3 per composition |

Demo formats: 1920 × 1080 (16:9: `n` = 9, 41 cells, `c` = 46.8, 21 rows) and 1080 × 1350 (4:5: `n` = 5, 25 cells, `c` = 43.2, 29 rows).

Pattern on the grid, in blocks:

1. `generatePatternBlocks({ cols: floor((cells across - 2) / 2), rows })` from the left margin. The live width is odd cells: the last block in the band takes the spare cell (3 or 5 cells wide), so the path ends on the right margin.
2. Band: its bottom row holds the mark and the caption; the row above is the generator's clear row. Path rows: `min(blockRows, max(min(square + 2, blockRows), round(rows / 2 × 0.6)))` blocks, bottom on the last row line.
3. `blockRows`: `floor((rows - 6) / 2)`, so every block stays ≥ 1 cell from the title (`floor((rows - 7) / 2)` under the title). Square rows: a square that starts ≥ 1 block right of the title's measured width may rise to the top margin (`floor(rows / 2)`; blocks are shifted down into the lower band, the rows above open only to the square); a square under the title keeps `floor((rows - 7) / 2)`, ≥ 1 block from it.
4. Square: `--ui-primary`, whole blocks. Landscape 1.1 columns, portrait 1.5 (`squareColumns`): `round((span × n + ceil(span) - 1) / 2)` blocks, clamped to square rows `- 2`. Starts on any block (seed `× 15485863`) among the placements with path on both sides and the largest size, then `placePhoto({ size, edges: [edge] })`. It need not align to a column. Measured: 16:9 gives 10 cells, 4:5 gives 8.
5. Checked over 200 seeds in the browser (100 per format): nothing in the margins, every block ≥ 2 × 2 cells and ≥ 1 cell from the title, caption and mark, the square ≥ 1 block from them, the path touching both sides of the square and ending on the right margin.

Canvas `var(--ui-bg)`, gradients `var(--ui-bg)` to `var(--ui-primary)`. Grid overlay: `--ui-text-dimmed`, dashed 3 2; margins and column edges full, cell lines at 40%.

## Tiles

| Tile | Shape |
|---|---|
| Logo, logo stage, `::profile-mockup`, clearspace, type, component card, pattern, motif, loader preview, prose card (`ui.prose.card`) | 3:2 (`aspect-[3/2]`) |
| `IconTile`, `IconPairTile` | Square |
| Colour chip (`PalettePair`: two square chips, 2:1) | Square |

`MotifSquare` draws a square motif inside its 3:2 tile. A captioned tile wraps `TileFrame` (`as="figure"` gives a `figcaption`).

## Rounding

| Element | Radius |
|---|---|
| Controls (Nuxt UI buttons, inputs, form fields) | `--ui-radius`: 0.125rem / 2px |
| Content containers (cards, tiles, swatches) | 0 (`rounded-none`) |
| `::report-note` | `rounded-3xl`. The only content container that rounds. |

Nested rounding: outer radius = inner radius + padding between them.

## Rules

1. Gap between groups ≥ 2× gap inside a group.
2. A content container is a flat fill (`bg-elevated` or a palette surface): `rounded-none`, no border, no outline, no box inside a box.
3. Borders and `--ui-radius` are for controls only.
4. Do not add a one-off gap or a new breakpoint. Reuse a step or change the content.
5. A caption is body text, left-aligned, `mt-3` under its tile (`TileFrame` `label`). Never a mono `text-caption` label, never under a rule.
6. No rules between sections or under the page title. Section spacing separates.
7. Media spans the content column. Text keeps `--text-measure` (56ch). The lead keeps `max-w-[40rem]`.
8. Navigation, search and the theme switch stay reachable at every width. Below `nav` they live in the menu.
9. On the report, a block that also renders on a slide uses a container query, never a viewport query.
10. Never shrink type to fit a slide. Paginate.
11. Do not add next/previous links at the foot of a page.

## Guide chrome (`[...slug].vue`)

- Page grid from `nav`: full window width, no max-width. `--page-gutter` each side, `--sidebar-width` (26rem) column, `--sidebar-gap`, content `minmax(0,1fr)`.
- Sidebar: navigation only, no logo. Items on the Body step (16/22), `--nav-pitch` 40px apart, only the current page in full ink.
- Foot bar (from `nav`, guide pages only): fixed, `h-16`, `bg-default`, no rule, in a `UContainer`. Left: the search field (icon and a borderless `type="search"` input, placeholder "Search") and `ThemeSwitch`. Right: `LogoMark fit intro="load"`, 30px tall, linking home. Pages reserve `h-16` at the foot. Sidebar takes `pb-24`.
- Top bar (below `nav`, `MobileBar.vue`): sticky, `--mobile-bar-height` 54px (3.375rem), page ground. Menu button (40×40) on the left gutter, `LogoMark fit intro="load"`, 30px, on the right. Hides on scroll down, returns on scroll up, stays while the menu is open.
- Search: no modal and no ⌘K. `useSiteSearch()` holds the query and the sections (`queryCollectionSearchSections`, loaded on first focus). While the query is not empty, or after Search in the menu, `SearchResults` replaces the page header and body in the content column: an H1 "Search", a count line, then results (title, trail, excerpt), at most 30, title matches first. Below `nav` it has its own field. Escape, a result, or a page change clears it.
- Menu: fills the window under the bar. Pages at H3 (`py-1.5`, 42px apart, `pt-2` above the first), left on the gutter. Its foot is `h-16`. Search (a button) left and `ThemeSwitch` right at its foot. Search closes the menu and opens the results view. A disclosure (`aria-expanded`), not a dialog. Escape, the button, a page choice or widening past `nav` closes it. The page under it does not scroll.
- Page title: `pt-11.5` (46px) under the top bar, `pt-8` from `nav`.

## Report layout

- Artboard: 1280×720, scaled with `transform: scale()` on `.deck-canvas` (`transform-origin: top left`). Print: `@page { size: 1280px 720px }`.
- Containers: `.report-paper` and `.deck-canvas`. Container queries: `@5xl` in `ReportSection`, `@3xl` in `ReportTimeline`, `@container` on `.report-stats`.
- Gutter: `--report-gutter` 1rem, 3.75rem from 40rem, 2rem on a slide. Each block's `*-frame` element (`.report-cover-frame`, `.report-quote-frame`, `.report-section-frame`) holds the padding the deck re-insets.
- Column: `--report-column` and `--report-column-start` (from `--report-measure`, `--report-gutter`, `--report-rail`). `.breakout-grid`, bands, covers and the quote read them.
- `::report-section`: header column `--report-header-width` (19rem) beside the body, `items-start`. A body that opens on text drops so its first line sits on the title's last baseline (measured in `ReportSection`).
- `.breakout-grid` places every top-level block in the column. `full-bleed` escapes it, only as a direct child of the grid.
- On a slide, a body holding `.report-slide-foot` or a table goes to the slide's foot, full width (`main.css`).
- `::report-stats`: 1 column, 2 from a 48rem container, 4 on a slide (`.report-deck .report-stats`). The only mode-based layout. Use CSS, not JavaScript.
- A section too long for a slide paginates (`paginate` in `ReportDeck.vue`) with the same eyebrow, title and icon.

## Craft: `better-layout`

- Group with space first, a background shape second, a line last.
- Make every control distinct from content: a fill, a border or a fixed place.
- Use logical properties (`ps-*`, `pe-*`, `ms-*`, `me-*`, `inset-inline`). Physical `left`/`right` only for the deck's two paging zones.
- DOM order is reading order. A visual move (`order`, `grid-area`) does not change it.
- Give hidden content a visible cue: a disclosure control, or the next item peeking 16–32px.
- Space controls 12px apart when bordered or filled, 24px when text- or icon-only. Hit areas never overlap.
- Backgrounds and media may bleed. Controls and text stay inside the margins and `env(safe-area-inset-*)`.
- Break where the content stops fitting. Prefer a container query.
- No fixed width or height on a text container. Use `max-width`.

## Craft: `better-accessibility`

1. Use the native element: `<button>` for an action, `<a href>` for navigation.
2. Show focus on `:focus-visible`. A custom ring has ≥2px solid perimeter and survives forced colours. Never `outline: none` without a replacement.
3. Hit area ≥ 24×24px. Aim for 44×44 on touch. Extend with a pseudo-element. Decorative layers take `pointer-events: none`.
4. Every pointer action has a keyboard path. Escape closes an overlay. Use only `tabindex="0"` or `"-1"`.
5. Every control has an accessible name. An icon-only button takes `aria-label`. A decorative element takes `aria-hidden="true"`, never a focusable one.
6. Colour is never the only signal. Add an icon, a word or an underline.
7. Alt text by purpose: describe an informative image, `alt=""` for a decorative one. An icon beside its own text takes `aria-hidden`.
8. Reduced motion: `/agent/motion.md`.

## Related

- `/agent/typography.md`: type steps, `--text-measure`.
- `/agent/motion.md`: motion tokens, reduced motion.
- `/agent/icons.md`, `/agent/pattern.md`: tile and full-bleed uses.
- `/agent/colour.md`: contrast floors.

## Content components

- `::compare-grid` takes `mt-16` (64px) straight after a grid: twice the grid's 32px row gap.
- `::compare-card{variant label}`: no fill. The label and the check or cross icon are in the ink (`text-highlighted`); the text is body. No red or green.
- `::placeholder-image{src label alt}`: `src` defaults to `/placeholder.jpg` (1920×1280), 3:2, full width, in `TileFrame`. A stand-in for an image that does not exist yet; with `src`, a real 3:2 image from `public/images/`.
- Icon tiles (`IconTile`, `IconPairTile`): the icon sits absolutely inside the `aspect-square` box (`inset-8`), so Safari cannot stretch the box to the icon. Icons are at most 128px: a single tile is capped at `max-w-48` (192px), a pair tile at `max-w-80` (320px; no gap, each icon moved 8px inward, since each glyph has padding inside its 512 frame). The box shrinks with the icon. Their grids are `::grid{columns="icons"}` (columns 8–12rem) and `columns="icon-pairs"` (10–20rem), packed from the left, not stretched to the content width.

## Website components

`app/components/site/*.vue` (`Site*`), shown on `/components` under Website (`::website-components`). Each wraps one Nuxt UI component and restyles it through `ui`: no radius, border, ring or shadow on content; type from `utils/type-steps.ts` (sizes repeat at `sm:` and `lg:`, so they replace Nuxt UI's breakpoint sizes).

| Component | Nuxt UI | Notes |
|---|---|---|
| `SiteHeader{links}` | `UHeader`, `UNavigationMenu` | Links left, `LogoMark` right, 30px. Below `lg`: menu button left, a full-window menu at H3 with children listed first, flat. |
| `SiteFooter{columns}` | `UFooter`, `UFooterColumns` | `bg-elevated`. Columns, `LogoMark` right (rolls in when in view), copyright. Social links are text. |
| `SiteCookieNotice` | `UBanner` | Two verb buttons: "Accept analytics", "Reject analytics". Emits `choose`. |
| `SitePageHeader{title description}` | `UPageHeader` | H1, lead at H3 to 40rem. |
| `SiteHero{title description}` | `UPageHero` | Display title, 16ch, left. `PianoRoll` under it. |
| `SiteBreadcrumb{items}` | `UBreadcrumb` | Body text, `/` separator, current page in the ink. |
| `SiteImage{src alt label}` | none | 3:2, `object-cover`, caption through `TileFrame`. |
| `SiteCarousel{images}` | `UCarousel` | Loops, drags, scrolls slowly (Embla auto scroll, 0.6), stops under the pointer. The viewport fades to transparent over 12% each side (`mask-image`). No scale, no 3D. A Pause / Play button under it. Reduced motion: no scroll, no button. |
| `SiteClientGrid{clients}` | `UPageGrid` | 3:2 grey tiles. A logo in the tile with the name as caption, or the name in the tile. |
| `SiteClientList{clients label}` | `UAccordion` | Closed. Names in 2–3 columns. |
| `SiteVerticalTeaser{title description to linkLabel image}` | `UPageSection` | Image beside H2, text and a link that names its page. |
| `SiteServiceCard{title description icon to}` | `UPageCard` | Square icon tile (max 192px), name, one line. |
| `SiteCaseStudyCard{title description image tags to}` | `UBlogPost` | 3:2 image, `BrandBadge` tags, title, line. |
| `SiteQuote{quote name role}` | `UUser` | Quote at H3, name and role under it. |
| `SiteContactPrompt{title description email}` | `UPageCTA` | `bg-elevated`, H2, a mailto button. |
| `SiteFormSuccess{title description}` | `UAlert` | `bg-elevated`, check icon, `role="status"`. |
| `SiteNewsletterSignup{title description}` | `BrandInput`, `BrandButton` | Emits `subscribe` with the email. |
| `SitePostList{posts}` | `UBlogPosts`, `UBlogPost` | Date, title, excerpt, newest first. |
| `SiteJobListItem{title description to}` | `UPageCard` | Title at H3, one line. |
| `SiteLinkList{title links}` | `UPageLinks` | Title in the ink, links muted. |
| `SiteAnchors{links}` | `UPageAnchors` | One wrapping row of in-page links. |
| `SiteNotFound` | `UError` | 404, H1 message, a button home. |

Also `BrandTextarea` (`UTextarea`) and `BrandFileUpload` (`UFileUpload`, on the grey, no border).

Every Nuxt UI control sets its text on the Body step (16/22), at every size and from `md` too (`app.config.ts`: button, form field, input, textarea, select, file upload, checkbox, switch, tabs, accordion, banner, alert, footer columns, page links, user). The scale has no 12 or 14 outside mono badges. A breadcrumb's current page is weight 500, like all type.

`/components` puts the large components first, one per row (`::component-grid`, 6rem apart). `::component-card{stage}` shows one on the page ground, centred in a 3:2 grey tile, as wide as its content up to `80cqi` (`{stage full}` takes the full `80cqi`, for components that size by their container) (10% padding top and bottom). A taller component grows the tile. Small components (buttons, inputs, breadcrumb and the like) come after, in `::component-scroller{label}`: one row, 384px cards, scroll snap, keyboard focusable.

`SiteHero` sets `PianoRoll` with `my-0!`. `SiteHero`'s title is Display, capped at `12cqi` of its own width.
