diff --git a/VISION.md b/VISION.md index 36c961b..cce5541 100644 --- a/VISION.md +++ b/VISION.md @@ -557,6 +557,22 @@ All seven slices are merged and the acceptance re-run passed. The phase is close _Full spec-driven breakdown in [`docs/specs/PHASE-30-SPEC.md`](docs/specs/PHASE-30-SPEC.md). Deliberately out of scope: an Android profile, tablet and other types, a tappable flag on frames, a thumb-reach rule, interaction and flows, mobile import, and mobile commerce archetypes (those come next, on the anatomy/realisation split this phase creates)._ +### Phase 31 — The desktop-app surface (v2.5) + +Run on its own viewer (2026-11-08), framesmith scored every page's usability 100 with zero findings: the viewer has a 17px Back link and 21 click targets that are plain spans, but the web hit-target rule only measures form controls. Meanwhile page rules misfired on the app: the sidebar project list read as a data table, the dense 13/14px type steps failed the scale ratio, and the projects' identity dots counted as competing accents. framesmith judges an application as if it were a marketing page, and has no words for what makes it an application. This phase adds a third type, `desktop-app` (a tool you work in, in a browser or a native window), with the vocabulary, rules, layouts, overlays and element states an app needs. Acceptance is literal: the viewer itself rebuilt as canvases, CLEAN, with every click target a primitive. + +- [ ] Slice A — the type: `desktop-app` with a 1440×900 window profile, a ruleset that can switch off the type-scale ratio, the WCAG 2.5.8 spacing exception on the 24px floor, navigation lists that never read as tables and identity colors that never count as accents (both on every type), evaluate and import results an agent can read, type-aware generation with state tokens, and the baseline canvases committed as fixtures +- [ ] Slice B — vocabulary: a 24px `button` with an accessible name, `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row` and `divider` +- [ ] Slice C — app rules: every click target measured, hand-built targets named, icon-only actions named, one current item per navigation group, a dense type ladder, row rhythm, honest truncation, one primary per region, and the declaration matching the window +- [ ] Slice D — panes: resolution for any number of types and six desktop-app realisations (workbench, list-detail, editor-inspector, browser, dashboard, settings) under the pattern gate +- [ ] Slice E — overlays: one overlay layer at the root with a fixed ladder, and the `tooltip`, `popover`, `menu`, `modal`, `toast` and `drawer` primitives, with overlay screen states as variants +- [ ] Slice F — overlay rules: one modal, text-only tooltips, no large tables in modals, overlays inside the viewport, and advisories on stacked toasts and error toasts +- [ ] Slice G — element states: hover, focus, pressed and disabled as a primitive property, drawn from state tokens +- [ ] Slice H — state checks: focus rings, active indicators, disabled renders and control boundaries checked analytically on every interactive primitive, without being drawn +- [ ] Slice I — the acceptance re-run: the viewer's gallery, detail with inspector and archive rebuilt as canvases, with the comment popover and delete-confirm states + +_Full spec-driven breakdown in [`docs/specs/PHASE-31-SPEC.md`](docs/specs/PHASE-31-SPEC.md). Deliberately out of scope: porting the viewer's code, anchored overlay rendering, keyboard behaviour, narrow-window collapse, the command palette and bulk-selection variants, importer work, and desktop-app rules on web._ + ### Continuous integration (infrastructure) Nothing runs on a pull request today: no workflow, no branch protection, no checks. That held because a single disciplined maintainer ran things by hand, but it has already failed silently — the repository has 102 test files and no `test` script to run them as a suite, and two tests were found failing at the v2.0.0 tag itself. The missing runner is the root cause; a pipeline that is red on its first run only teaches you to ignore the red X. Running the suite together for the first time turned up three failing tests, two of them red at the v2.0.0 tag itself; all three are fixed. diff --git a/docs/specs/PHASE-31-SPEC.md b/docs/specs/PHASE-31-SPEC.md new file mode 100644 index 0000000..df6d00b --- /dev/null +++ b/docs/specs/PHASE-31-SPEC.md @@ -0,0 +1,251 @@ +# Phase 31: The Desktop-App Surface (v2.5) + +> Spec-driven breakdown, borrowing spec-kit's flow: **Specify → Clarify → Plan → Tasks → Analyze**. +> This is a planning artifact, not code. File and symbol references verified against `src/` at commit 9cf8f42 on 2026-11-09. +> Sources: two local working documents, not committed. (1) A cited research report on what application UIs need (design-system inventories, NN/g, WCAG 2.2, automated-checking prior art), 2026-11-08. (2) A baseline run of framesmith 2.4.0 on its own viewer: four live viewer pages imported with `canvas_import_url`, scored with `canvas_evaluate` and `canvas_stress`, and the claims about the real viewer checked in the live page, 2026-11-08. One baseline finding, the contrast check ignoring alpha, already shipped as PR #243. The baseline's imported canvases are committed as fixtures in slice A (FR-A9), so every acceptance below that names them is reproducible. + +--- + +## 1. SPECIFY + +### Problem + +framesmith judges an application screen as if it were a marketing page, and cannot see most of what makes it an application. Four failure classes, each confirmed in the baseline or in the code: + +1. **The judgment is blind on an app.** The web hit-target floor inspects only the four form controls (`CONTROL_TYPES`, `src/evaluate.ts:1888`); the tappable primitives join the census only when the mobile touch floor is on (`evaluate.ts:1988`, `:1999`). The viewer's detail page has 31 click targets, among them a 17px-tall "← Back" link and 21 spans or divs with click handlers (the inspector tabs, every issue card), none keyboard reachable. All four imported viewer pages scored `usability` 100 with zero findings: buttons, links and tabs import as frame + text, and no rule looks at frames. +2. **Page rules misfire on app screens.** The table detector (`detectTable`, `src/drift.ts:106`) reads the viewer's sidebar project list as a data table, so coverage demands empty and loading variants for the navigation on every page. The type-scale check warns on any adjacent pair below a 1.1 ratio or above 2.0 (`evaluate.ts:572`), which flags the 13/14 and 14/15px steps the viewer uses and its page-title-to-body jumps of 2.4 to 2.7. The accent-consistency tell (`evaluate.ts:1757-1806`) counts the projects' identity dots as four competing accents. The ratio check has no rule id, so no type can switch it off; the ruleset table (`RULESETS`, `src/surfaces.ts`) turns only rule ids on and off, plus the cliché tells it can relax. +3. **There is no app vocabulary.** No link, text input, tab, navigation item, tree row, menu row or inspector row. `button` comes in 32 / 44 / 52 (`BUTTON_HEIGHT`, `src/primitives.ts:8`), so the 24px icon buttons of a dense toolbar cannot be built. There are no overlays: no tooltip, popover, menu, modal or toast, and no layer model (the renderer hard-codes `z-index` 10 for `fixed` and 5 for `sticky`, `src/renderer.ts:1457-1458`). Nothing renders or checks hover, focus, pressed or disabled states. The viewer itself uses three native `confirm()` dialogs (`src/viewer.ts:846`, `:1617`, `:1679`), which a canvas cannot express. +4. **There are no app starting points.** The web set has an app shell (`split-workbench`), `dashboard` and `settings`, but no list-detail, editor-with-inspector or browser layout, and structure resolution assumes exactly two types (`resolveStructure`, `src/structures.ts:1863`, whose fallback is `type === 'web' ? 'mobile' : 'web'`). + +The phase's claim: an agent asked for an application screen declares `type: "desktop-app"` once, starts from an app layout built from app primitives, and is judged by rules that know an app. Every click target is measured, the current location is unambiguous, overlays stack in a fixed order, every interactive primitive's focus, active and disabled renders are checked without being drawn by hand, and the misfiring page rules stay quiet. + +### The model (Phase 30's, extended) + +Phase 30's model holds unchanged: three independent declarations (type, genre, preset), each contributing a ruleset that composes by union; structures split into anatomy and realisation; clickability is a property of primitives, not a flag. This phase adds a third type and two ideas. + +**The third type.** `desktop-app` joins `web` and `mobile`. The boundary, stated once in every agent surface: `web` is a page you read and scroll (marketing, docs, content, a pricing page); `desktop-app` is a tool you work in (persistent chrome, panes, dense controls, a current selection), whether it runs in a browser tab or a native window. An admin console, an editor, a file browser and the framesmith viewer are `desktop-app`. A phone screen stays `mobile`. + +**Element states and screen states are different things.** An *element state* (hover, focus, pressed, disabled, plus the `active` and `expanded` markers) belongs to one primitive. It is a property of that primitive, drawn by the renderer from state tokens, so the evaluator can compute the relevant states of every interactive primitive analytically, in fast mode, without the agent drawing them. A *screen state* (a menu open, a confirm dialog up, a toast showing) changes what is on screen. It is a variant canvas made with the existing `canvas_add_variant`, holding the open overlay as ordinary nodes, and it is evaluated like any canvas, so every rule (targets included) applies to what the open overlay shows. + +**Layers are fixed by kind, and overlays live at the root.** Every overlay primitive belongs to one layer, and the layer fixes its stacking order. The renderer lifts every overlay out of the content tree into one overlay layer after it, whatever its position in the tree, so a menu authored inside a sticky toolbar or a scrolling pane is never clipped or out-stacked. Authors never set a `z-index`. The order follows Atlassian's published ladder: popover and menu, then the modal's scrim, then the modal, then toast, then tooltip. + +### Goals + +- **A third type** with a window profile, its own ruleset, and a boundary rule agents can apply. +- **Corrections that hold on every type**: the WCAG 2.5.8 spacing exception on the 24px floor, navigation lists that never read as tables, and identity dots that never count as accents. +- **A page rule that can be switched off** because it has a rule id, and a desktop-app ruleset that replaces it. +- **App vocabulary**: links, text inputs, tabs, navigation items, tree rows, menu rows, inspector rows, dividers, and a 24px button with an accessible name. +- **App rules with teeth**: every click target measured, the current location unambiguous, a dense type ladder, consistent table rows, and honest truncation. +- **Panes**: workbench, list-detail, editor-with-inspector and browser layouts, plus desktop-app realisations of dashboard and settings, under the existing pattern gate. +- **Overlays** on a fixed layer ladder, with the single-frame rules that keep them honest. +- **Element states** rendered from tokens and checked analytically on every interactive primitive. +- **Results an agent can read** on a 240-node app screen. +- **Proof by re-run**: the viewer's gallery, detail-with-inspector and archive rebuilt as canvases on this phase, with the overlays it uses, judged by rules that actually ran. + +### User stories + +- **US1**: As the authoring agent, `canvas_create({ name, type: "desktop-app" })` gives me a 1440×900 artboard, and `generate_design_system` on it gives me a 14px body (13 for `data-dense`), state tokens and a focus-ring color. +- **US2**: As the authoring agent, `apply_structure("editor-inspector")` on a desktop-app canvas stamps a work area and a 70/30 inspector built from `tabs`, `property-row`, `text-input` and `button` primitives. +- **US3**: As the authoring agent, a 20px icon button with no other target inside its 24px circle passes in detailed mode; the same button touching another one is an error naming the spacing exception. +- **US4**: As the authoring agent, a sidebar with two active `nav-item`s warns, and so does one with none; a sectioned sidebar with one active item in one section passes; a sidebar list never asks me for an empty or loading variant. +- **US5**: As the authoring agent, I add a `confirm-delete` variant and drop a `modal` into it. Its scrim, header close button and right-aligned actions come from the primitive, and the evaluator checks that it is the only modal and sits inside the window, without my touching a z-index. +- **US6**: As the authoring agent, a `$focus-ring` that falls below 3:1 against the inspector panel is one error listing every primitive on that panel, though I never drew a focused state. +- **US7**: As the authoring agent, `canvas_evaluate` on a 240-node app screen returns a result I can read in one tool call. +- **US8**: As the reviewing human, the viewer rebuilt on this phase reaches CLEAN with every click target a primitive, including its comment-popover and delete-confirm states, in both themes. + +### Window profile constants + +One profile ships, keyed by id so a second size is one table row later. Values in CSS px. Confidence follows the research: *high* means a primary source was read; *house value* means framesmith picks it and a better source changes one row. + +| Constant | Value | Source | Confidence | +|---|---|---|---| +| Profile id | `window-1440` | n/a | n/a | +| Artboard | 1440×900 | framesmith's render default (`src/screenshot.ts:121`); Android "large" width class 1200 to 1599 | high | +| Target floor | 24, with the spacing exception | WCAG 2.2 SC 2.5.8 Understanding doc | high | +| Density ladder | 24 / 32 / 40 / 48 (64 for two-line rows) | Carbon table rows; Primer controls are 24 / 28 / 32 / 40 / 48 (28 dropped here) | high for Carbon; Primer heights from secondary sources | +| Default control and row height | 32 | Carbon and Primer dense desktop default | high | +| Body size | 14 | Carbon table row and header text, 14 | high | +| Body size, `data-dense` | 13 | house value | house value | +| Smallest text | 11 | house value | house value | +| Prose floor | 12 for text of five or more words | the research proposes 12 for secondary and meta text in compact density; the five-word cutoff is a house value | proposal | +| Overlay widths | 192 / 320 / 480 / 640 / 960 | Primer size primitives | high | +| Layer order | popover/menu 400, scrim 500, modal and drawer 510, toast 600, tooltip 800 | Atlassian elevation (its navigation 200, inline-dialog 300 and spotlight 700 rungs are not used) | high | +| State layers | hover 8%, focus 10%, pressed 10% of the ink over the fill; disabled content 38%, container 12% | Material 3 state token files (Flutter `gen_defaults`); an older M3 set used 12% for focus and pressed | high (version-dependent) | +| Focus ring | 2px outset; 3:1 against what is adjacent | 3:1 from WCAG 2.2 SC 1.4.11 (AA); 2px outset from SC 2.4.13 (AAA, advisory) and Fluent | high | +| Inspector split | 70 / 30 at expanded width | Android canonical layouts, supporting pane | high | +| Navigation depth | 2 levels | Microsoft NavigationView ("ideal"); NN/g ("practical norm") | high | +| Sidebar width | 240 expanded, 48 as a rail | house values (Carbon's 256 / 48 were not verified) | house value | +| Tree indent | 16 per level | house value | house value | +| Modal widths | `sm` 480, `md` 640, `lg` 960 | house values drawn from Primer's overlay widths (Atlassian and Material modal widths were not verified) | house value | +| Menu min width, tooltip max width | 192, 240 | Primer's smallest overlay width; tooltip a house value | high, house value | + +### Terms used below + +- **Clickable primitive**: `button`, `link`, `text-input`, `nav-item`, each tab of a `tabs` node, `tree-row`, `menu-row`, `list-row`, the four controls, Phase 30's `tab-bar` items and `nav-bar` actions, and the header close button and `actions` of a `modal` and the `action` of a `toast`. A node of any other type is never a target, even with `state` set. +- **Target circle**: WCAG 2.5.8's test. An undersized target (below 24 in either dimension) passes when a 24px-diameter circle centred on its box intersects no other target's box and no other undersized target's circle. Circles that only touch do not intersect. A target nested inside another target (ancestor or descendant) is not its neighbour. Disabled targets are neither measured nor neighbours. +- **Pane**: a frame with `overflow: "auto"`, a `modal`, a `drawer`, or the root. +- **Navigation group**: within one pane, all `nav-item`s at depth 0 together, wherever their section frames sit; one `tabs` node is its own group. +- **Region**: the nearest pane. Primary buttons inside three or more sibling frames with the same shape hash (`shapeHash`, `src/project-evaluate.ts:80`), such as a grid of cards, are not counted toward their region. +- **Element state**: a primitive's `state` property (FR-G1). **Screen state**: a variant canvas (`canvas_add_variant`). +- **Fast / detailed mode, full tier**: as in Phase 30. Fast mode has no rendered rects; detailed mode adds `computeLayout`. + +### Functional requirements + +| ID | Requirement | Acceptance | +|----|-------------|-----------| +| **FR-A1** | **A third type.** `SurfaceType` becomes `'web' \| 'mobile' \| 'desktop-app'` (`src/surfaces.ts:9-10`), and every place that names the types follows: the tool enums (`src/index.ts:183`, `:1495`, `:1533`, `:2052`), the stored literals (`metadata.surface` at `src/types.ts:503`, `Structure.surface` at `:391`, the build-log entry at `src/structures.ts:2012`), and the code that branches on type (`resolveSurface` and `surfaceFor` in `src/surfaces.ts`, `src/stress.ts:147`, `src/mobile-rules.ts:663`). Profiles are typed by surface: the existing `DeviceProfile` table stays the phone table, and a `WindowProfile` table holds `window-1440`. `surfaceFor` accepts a `device` only from the table matching the type, and a stored pair that does not match resolves to the type's default. `canvas_create({ type: "desktop-app" })` sets a 1440×900 artboard in `src/scene-graph.ts:142` (explicit `width`/`height` win) and writes no `safeArea`. `canvas_set_type` behaves as on mobile: it never resizes and never writes `safeArea`. Render never depends on type (Phase 30 C10). | `canvas_create({ type: "desktop-app" })` reads back 1440×900 and `surface: { type: "desktop-app", device: "window-1440" }`. `canvas_set_type({ type: "mobile", device: "window-1440" })` is refused. Changing only `metadata.surface` leaves every screenshot byte-identical. | +| **FR-A2** | **A page rule gets a rule id.** The adjacent-ratio check (`evaluate.ts:572`) becomes the default rule `type-scale-ratio`: it runs unless an active ruleset disables it, which `composeRulesets` already supports (`src/surfaces.ts`). The other two misfires are wrong on every type, so they are corrected instead (FR-A5, FR-A6). On `web` and `mobile` nothing changes. | The desktop-app row disabling `type-scale-ratio` silences the 1.07 warning on a desktop-app canvas, and the same canvas declared `web` still warns. | +| **FR-A3** | **The desktop-app ruleset.** `RULESETS.type['desktop-app']` enables every `desktop-app-*` rule this phase adds (except `desktop-app-artboard`, which sits in the base ruleset, FR-C9) and disables `type-scale-ratio` (replaced by `desktop-app-type-ladder`, FR-C5). The mobile rules stay off on desktop-app, and the desktop-app rules stay off on web and mobile. | Evaluate on a desktop-app canvas lists every `desktop-app-*` id registered so far in `surface.rulesOn`, and `type-scale-ratio` in `rulesOff`. | +| **FR-A4** | **Targets on web and desktop-app, with the spacing exception.** The 24px floor (`checkUsability`, `evaluate.ts:1979`) keeps its existing first step for the four controls: a lone control grows to its padded row (`evaluate.ts:2016-2043`), and a control that is the only one inside a `property-row`, `nav-item`, `menu-row` or `list-row` owns that primitive's box. It then measures every clickable primitive on `web` and `desktop-app`, not only the four controls, and applies the target circle to whatever is still below 24. **Detailed mode is authoritative**: it runs the circle test on rendered rects and reports an error when it fails. **Fast mode** has no rects, so a target still below 24 after the row step is a `warning` naming the spacing exception and detailed mode; it blocks presenting until detailed mode clears or confirms it. For the four controls this downgrades today's error in both modes (`evaluate.ts:2054-2063`) to a fast-mode warning and a detailed-mode error unless the circle clears it. Instances are expanded for the census, as on mobile (`expandForRules`, `evaluate.ts:2235`). Inline links (FR-B2) are exempt, as WCAG exempts inline targets. Mobile's 44px floor is unchanged. | Two 20px icon buttons with centres 24px apart pass in detailed mode; the same buttons touching fail with a message naming the 24px circle; both warn in fast mode. A lone 18px checkbox in a padded row still passes. The slice A PR lists every existing fixture whose usability verdict moved, and why. | +| **FR-A5** | **Navigation lists are not tables, on every type.** `detectTable` (`src/drift.ts:106`) already rejects primitive rows, since it requires frame rows. The misfire is the hand-built list, so it gains a positive discriminator: a frame is not a table when it has three or more rows, every row's first child is an icon, a dot (an `ellipse`, or a frame or rectangle of 12px or less on both axes with no children), or an image of 24px or less, and its first row is not a header row (a row whose cells are all text). Stress's own row reader (`tableRows`, `src/stress.ts:129`) applies the same rule. The sidebar's texts then enter the inventory as texts, which changes what stress perturbs on such a canvas. | The baseline sidebar fixture (FR-A9) yields no table and no coverage finding. A Region / Revenue table with a header row, the checkout totals rows, and a users table whose rows start with a 24px avatar under a text header row all still detect, as does every existing table fixture. | +| **FR-A6** | **Identity dots are not accents, on every type.** `nav-item` gains `dotColor` for an identity dot (FR-B3); `color` keeps its existing meaning, the text color (`src/types.ts:183`). A color value that appears in the tree only as `nav-item` `dotColor` is excluded from the accent-consistency census (`evaluate.ts:1757-1806`); the same value used as a literal anywhere else still counts. Hand-built dots keep counting, because nothing marks them as identity. | The baseline gallery rebuilt with `nav-item dotColor` dots does not trip `accent-consistency`; a canvas with two literal accent fills on buttons still does; a hand-built dot with an identity color still counts. | +| **FR-A7** | **Results an agent can read.** The serialised `canvas_evaluate` result folds `info` findings that share a key of category, `rule` or `tell` (empty when neither is set), and message template (the message with quoted text and numbers stripped) into one entry carrying `nodeIds[]` and `count`, once three or more share a key. Errors, warnings, and infos that carry a `fix` are never folded. The fold runs on the serialised result only: `canvas_autofix` and the directive read the unfolded list. `canvas_import_url` and `canvas_import_html` return `snapped`, `scaleMatches` and `literals` (`src/import.ts:157-162`) as per-token counts with the first five node ids each, and the full lists under a new `verbose: true` input. | On the baseline detail fixture (FR-A9), the default import report and the fast `canvas_evaluate` result are each at most half their 2.4.0 size (about 60,000 and 77,000 characters), measured in the slice A PR. `canvas_autofix` returns the same ops before and after. | +| **FR-A8** | **Type-aware generation and state tokens.** `typeBase` (`src/design-language.ts:169-177`) gains a desktop-app branch: base 14 for every personality except `data-dense` (13); an explicit `baseSize` wins; its `source` union gains `'desktop-app'`. `DesignVariables` gains a numeric `state` section (`hover`, `focus`, `pressed`, `disabledContent`, `disabledContainer`, as opacities from the constants table), alongside the existing `motion` and `elevation` sections (`src/types.ts:334-343`), with the same workspace, project and canvas layering. The color engine (`src/color-system.ts`) emits `focus-ring`, picked to clear 3:1 against every generated `bg-*` and `*-tint` token in both themes (a filled primitive's own fill is covered by FR-G1's inner ring, since no single color can clear 3:1 against both the dark surfaces and a light primary fill), and `border-strong`, picked to clear 3:1 against `bg-primary` and `bg-surface` in both themes. | Each personality generated on a desktop-app canvas yields `body` 13 or 14 and nothing below 11; `focus-ring` measures 3:1 or more against every `bg-*` and `*-tint` token, and `border-strong` against `bg-primary` and `bg-surface`, in both themes. | +| **FR-A9** | **The baseline is reproducible.** The four imported viewer canvases (gallery, web detail, mobile detail, archive) are committed under `test-fixtures/viewer-baseline/` in the slice A PR, with their sha256 recorded in the PR. Every acceptance in this spec that names a baseline fixture runs against those files. | The files exist, and a test loads each one. | +| **FR-B1** | **A 24px button with a name.** `button` gains `size: 'xs'` (24 tall, 8px horizontal padding, 12px label; icon-only `xs` is 24×24) and `ariaLabel?`, which renders `aria-label` and `title` and never renders text. A button is icon-only when `label` is blank and `icon` is set (`buttonBox`, `src/primitives.ts:62`). `size` widens to `'xs' \| 'sm' \| 'md' \| 'lg'` (`src/types.ts:303`). Heights stay type-independent. `xs` fails the mobile 44px floor by design, which the docstring states. | `snapshot_layout` reads an icon-only `xs` button at 24×24; with `ariaLabel` it renders `aria-label` and `title` and no text. | +| **FR-B2** | **`link`.** `link` (`label`, `icon?`, `iconPosition?`, `external?`): a standalone text action, at least 24 tall (its line box padded to 24), underlined in its hover state (FR-G1), measured as a target. A `text` node may also hold inline links through `links: [{ start, end }]` character ranges; those are the inline targets FR-A4 exempts. The `breadcrumb` component is rebuilt from `link`s. | A standalone `link` is in the usability census; an inline range is not; the `breadcrumb` component contains no hand-built click targets. | +| **FR-B3** | **`nav-item`.** `nav-item` (`label`, `icon?`, `count?`, `dotColor?` for an identity dot, `active?`, `depth?: 0 \| 1`, `collapsed?` for the rail form): 32 tall, full width of its container. `active` renders two cues by construction: a fill from `$accent-tint` and a 2px leading indicator bar in `$accent`, plus a heavier label weight. `collapsed` hides the label, renders 32×32 icon-only, and uses the label as `aria-label` and `title`. The renderer emits `aria-current="page"` on the active item. | A sidebar of `nav-item`s renders one item with fill, bar and weight; `collapsed` renders 32×32 with `title`. | +| **FR-B4** | **`tabs`.** `tabs` (`items: TabsItem[]` where `TabsItem` is `{ label, count?, active?, disabled? }`, a type of its own, distinct from the `tab-bar`'s `TabItem` whose `icon` is required, `src/types.ts:18`, with `SceneNode.items` (`:294`) widened to `TabItem[] | TabsItem[]`; `variant: 'line' \| 'contained'`; `size: 'sm' \| 'md'` for 32 / 40). The active tab renders an indicator and a heavier weight and `aria-selected`. The `variant` union widens accordingly (`src/types.ts:296`). | `tabs` renders at 32 and 40 with exactly the marked tab indicated. | +| **FR-B5** | **`tree-row`.** `tree-row` (`label`, `depth`, `expanded?: boolean` (absent for a leaf), `icon?`, `active?`): 32 tall, 16px indent per level, a disclosure chevron when `expanded` is set. | Rows at depth 0, 1 and 2 indent 0 / 16 / 32; a leaf has no chevron. | +| **FR-B6** | **`menu-row`.** `menu-row` (`label`, `icon?`, `shortcut?`, `checked?`, `destructive?`, `disabled?`, `submenu?`): 32 tall; `shortcut` renders right-aligned in a muted `kbd` style; `destructive` uses `$danger`; `submenu` renders a trailing chevron. | A menu of rows with shortcuts right-aligns every shortcut to one column. | +| **FR-B7** | **`property-row`.** `property-row` (`label`, `labelWidth?` default 96): a horizontal row, 32 tall, whose single child is the value (a control, a `text-input`, a `button`, a text). The label truncates with an ellipsis and `title`. | An inspector of `property-row`s aligns every value to one left edge. | +| **FR-B8** | **`text-input`.** `text-input` (`value?`, `placeholder?`, `icon?`, `size: 'sm' \| 'md'` for 32 / 40, `rows?` for a multi-line field, `error?: string`, `disabled?`): a clickable primitive. Its border is its only boundary cue, so it renders in `$border-strong` when the token exists (FR-A8) and FR-H4 checks it. The checkbox, radio and toggle defaults use `$border-strong` the same way. Canvases without the token keep their current renders, since defaults never depend on type. `error` renders the message below the field with an icon, so the error is never carried by color alone. | A search field and a three-row comment field render in both themes; an `error` renders icon and message. | +| **FR-B9** | **`divider`.** `divider` (`orientation: 'horizontal' \| 'vertical'`, `inset?`): a 1px hairline in `$border`; vertical dividers separate panes. Not clickable. | A vertical divider between two panes renders 1px wide and full height. | +| **FR-B10** | **Defaults, reuse, stress, titles.** Every new primitive's colors default through `applyControlDefaults` (`src/variables.ts:112`) in a branch of its own (today it branches on `MOBILE_PRIMITIVES`), so an unthemed canvas never crashes. The structure category's reuse advisory (`evaluate.ts:798`) counts the new primitives as reuse. Stress reaches their text through `PRIMITIVE_TEXT_PROPS` (`src/stress.ts:44`): `link.label`, `nav-item.label`, `tabs.items[].label`, `tree-row.label`, `menu-row.label`, `menu-row.shortcut`, `property-row.label`, `text-input.value`. Their labels truncate to one line with an ellipsis and a `title`, so stress reports designed truncation. `text` nodes gain `title?` (today `title` is documented for `nav-bar` and `list-row` only, `src/types.ts:283`), rendered as the `title` attribute. | Every new primitive renders unthemed in both themes with no console errors; long-text on each yields designed-truncation infos only; a `text` with `title` renders the attribute. | +| **FR-C1** | **Every click target is measured on desktop-app.** Rule `desktop-app-target`: FR-A4's floor, error in detailed mode and warning in fast mode. | The baseline's "← Back", rebuilt as a `link` with no other target inside its circle, passes; packed against a neighbouring `link` it fails in detailed mode. | +| **FR-C2** | **Hand-built click targets are named, not gated.** Rule `desktop-app-handbuilt-target`, info: a frame whose only content is one icon, or one icon plus a label of three words or fewer, below 32px in either dimension, is a possible click target; the finding names `button` (icon-only, `xs` or `sm`) or `link`. It never blocks, mirroring Phase 30 FR-C2. A frame inside a clickable primitive is never hand-built. | The baseline detail fixture reports its hand-built toolbar icons as infos. | +| **FR-C3** | **Icon-only actions have names.** Rule `desktop-app-icon-label`, warning: an icon-only `button` with no `ariaLabel`, or a `collapsed` `nav-item` with a blank `label`. (The `modal` close button carries a default name, FR-E2.) | An icon-only button without `ariaLabel` warns; adding one clears it. | +| **FR-C4** | **Wayfinding.** Rule `desktop-app-current`, warning: a navigation group (see Terms) with more than one `active` `nav-item`, or with none; a `tabs` node with no active item or more than one; more than one `active` row in one list or tree. A sidebar split into sections is one group per pane, so one active item in one section passes. Navigation deeper than depth 1 (two levels) is out of reach by construction (`depth: 0 \| 1`); a `tree-row` group used for navigation deeper than 2 is an info. | A sidebar with two active items warns, one with none warns, a sectioned sidebar with one active item passes. | +| **FR-C5** | **A dense type ladder.** Rule `desktop-app-type-ladder`, replacing `type-scale-ratio` on desktop-app. For each adjacent pair of distinct sizes a < b: it passes when a ≤ 16 and b − a ≤ 4; otherwise it passes when 1.1 ≤ b / a ≤ 2.5; otherwise it warns. Pinned typography tokens skip the pair check, as today. Separately: any text below 11 warns; text of five or more words below 12 warns; a node resolving the `body` role below 13 warns. The 2.5 cap is a house value chosen to admit a dense app's 14→32 heading (2.29) while flagging a 13→36 jump (2.77). | 12/13/14 passes; 14→32 passes; 13→36 warns; a 10px label warns. | +| **FR-C6** | **Table rows.** Rule `desktop-app-row-rhythm`, applied to detected tables: a header row whose height differs from the body rows by more than 1px warns (Carbon). Body rows that differ from each other by more than 1px are an info, compared within their class (rows holding two stacked text lines, a title and a subtitle, are compared only with each other), because the ±1px threshold is a proposal no source validated. Detailed mode adds column-edge alignment across rows as an info, for the same reason. Fast mode compares authored heights only. | A table whose header is 40 over 32px rows warns; one 40px body row among 32px rows is an info; one cell 3px off is an info in detailed mode only. | +| **FR-C7** | **Honest truncation.** Rule `desktop-app-truncation`: a table header text with `textOverflow: "ellipsis"` warns (column headers are never truncated; PatternFly and EUI, secondary sources); a hand-built text with an ellipsis and no `title` is an info (the full text must be reachable; primitives add `title` themselves); in detailed mode, an ellipsis that leaves fewer than 4 visible characters is an info (Maersk, a secondary source). | A truncating header warns; a truncating `nav-item` label is silent; a 3-character truncation is an info in detailed mode. | +| **FR-C8** | **One primary per region.** Rule `desktop-app-primary-count`, info: more than one `button` with `variant: "primary"` in one region, counting a modal's primary `actions` in the modal's region. Info, because "one primary per region" is a convention no source validated as a threshold. | Two primary buttons in one toolbar produce the info; one primary in a toolbar and one in a modal's actions do not; a grid of six cards each with a primary button does not. | +| **FR-C9** | **The declaration matches the window.** Rule `desktop-app-artboard`, in the base ruleset like `mobile-artboard`, so it runs on every type: a desktop-app canvas narrower than 1024 warns; an undeclared canvas containing a desktop-app primitive (`nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row`) or an overlay primitive warns until a type is declared; an explicit `web` canvas containing a desktop-app primitive gets an info; an explicit `mobile` canvas containing one warns. Overlays alone never trip the web or mobile cases, since pages and phones use them too. | A 900px desktop-app canvas warns; an undeclared canvas with a `nav-item` is NOT READY until `canvas_set_type`; a mobile canvas with a `nav-item` warns, and one with only a `modal` does not. | +| **FR-D1** | **Resolution for any number of types.** `resolveStructure` and `listStructures` (`src/structures.ts:1834`, `:1863`) stop assuming two types: an anatomy resolves to the realisation for the canvas type; with none, the error names every type that has one and the `realisation` value that forces it. `Structure.surface` accepts `'desktop-app'`; `'any'` keeps meaning every type. | `apply_structure("pricing")` on a desktop-app canvas refuses and names `web`; `apply_structure("feed")` refuses and names `mobile`. | +| **FR-D2** | **Desktop-app realisations.** Six, built only from primitives and $tokens, named `-app`: `split-workbench-app` (the `split-workbench` anatomy: header, sidebar of `nav-item`s, content, optional right panel, a pane toggle as an icon-only `button`), `list-detail-app` (new anatomy `list-detail`: navigation, list and detail panes, with a designed "nothing selected" placeholder in the detail pane), `editor-inspector-app` (new anatomy `editor-inspector`: a work area and a 70/30 inspector of `tabs` and `property-row`s), `browser-app` (new anatomy `browser`: a filter bar of `text-input` and `tabs` over an adaptive grid of item cards with a minimum column width), `dashboard-app` and `settings-app`. Panes fill the window and scroll on their own (`overflow: "auto"` on each pane, the root at window height); stress already treats a scrolling pane's overflow as scrolling (`src/stress.ts:259`). A component `data-table-app` (`surface: 'desktop-app'`) is the ladder version of `data-table`: header row height equals body row height and the toolbar height equals the row height. The web `data-table` is unchanged. | See FR-D3. | +| **FR-D3** | **The pattern gate covers desktop-app.** `test-patterns.ts` evaluates every desktop-app realisation on a canvas created with `type: "desktop-app"`, fast mode across its five themes, at the web set's `> 95` bar with zero `desktop-app-*` warnings or errors. Once per realisation in the full tier: detailed mode with zero `desktop-app-*` warnings or errors, and a CLEAN full `canvas_stress` run. `data-table-app` and every component scaffold marked `any` are stamped onto a desktop-app canvas and held to the same rule. | The gate fails if any desktop-app realisation scores 95 or below in any theme, raises a desktop-app warning or error in either mode, or stresses FRAGILE. | +| **FR-E1** | **The overlay layer and its ladder.** The renderer lifts every overlay primitive out of the content tree into one overlay layer, a sibling after the content, whatever the overlay's position in the tree. Authored `x`/`y` are root coordinates. Within the layer, `z-index` comes from the kind: popover and menu 400, scrim 500, modal and drawer 510, toast 600, tooltip 800. Content keeps its own stacking (`fixed` 10, `sticky` 5, `src/renderer.ts:1457-1458`), all below the layer. There is no `zIndex` property. | A `menu` authored inside a `sticky` toolbar inside a scrolling pane renders above a `fixed` sibling and is not clipped by the pane. A tooltip and a modal render tooltip above modal above scrim above content, whatever their tree order. | +| **FR-E2** | **Overlay primitives.** Each is positioned by authored `x`/`y` in root coordinates and may name its trigger with `anchor: nodeId`, which rules use (FR-F1) and the renderer ignores. `tooltip` (`text`, max width 240; text only). `popover` (a container; width from the overlay width set; the overlay elevation shadow and `$radius`). `menu` (a container of `menu-row`s and `divider`s, min width 192). `modal` (`title`, `size: 'sm' \| 'md' \| 'lg'` for 480 / 640 / 960, `actions: [{ label, variant }]` rendered right-aligned with the primary rightmost, a header close button named "Close" unless `closeLabel` says otherwise, body children; centred in the viewport by default, and it always renders its own full-viewport scrim). `toast` (`text`, `tone: 'neutral' \| 'success' \| 'warning' \| 'danger'`, `action?: { label }`, bottom-centre by default). `drawer` (a container pinned to the right edge, width 320 or 480, optional scrim). All colors default through `applyControlDefaults`. | Each renders in both themes with no console errors; `snapshot_layout` reads a `md` modal 640 wide and centred with a scrim covering the viewport. | +| **FR-E3** | **Screen states.** `canvas_add_variant`'s docstring and GUIDELINES recommend the overlay states by name (`menu-open`, `comment-open`, `confirm-delete`, `toast`). Coverage reports which ones exist and demands none: an overlay is a design choice, not a data obligation. | `canvas_list` shows a base canvas with a `confirm-delete` variant; coverage raises no finding for a missing overlay state. | +| **FR-F1** | **Overlay rules.** Rule `desktop-app-overlay`. Warnings: more than one `modal` on a canvas; a `tooltip` with any child; a detected table (`detectTable`) of more than ten rows, or a frame with `overflow: "auto"` wider than the modal, inside a `modal` (Atlassian: no large tables or horizontal scrolling in a modal; the ten-row line is a house value); an overlay whose box leaves the viewport (fast mode checks only overlays with authored `x`, `y`, `width` and `height`, and a centred modal's width against the viewport; detailed mode checks every overlay from rendered rects). Infos: more than one `toast`; a `toast` with `tone: "danger"` (NN/g: errors in transient toasts get missed; the suggestion names an inline error or a banner); an `anchor` that names no node, or a node that is not a clickable primitive. | Each condition produces its finding on a fixture; the viewer rebuild's `confirm-delete` and `comment-open` variants are clean. | +| **FR-G1** | **Element states as a property.** Every clickable primitive takes `state?: 'rest' \| 'hover' \| 'focus' \| 'pressed' \| 'disabled'` (default `rest`); `disabled: true` on any clickable primitive maps to `state: 'disabled'`. The renderer draws each from the state tokens (FR-A8): hover and pressed as a layer of the ink over the fill at the token opacity; focus as a 2px outset `$focus-ring`, plus a 1px inner ring in the fill color on brand-filled buttons (Fluent's double ring, extended here to every filled clickable primitive); disabled at the disabled opacities when the canvas has `state` tokens, and at today's 0.5 opacity (`src/variables.ts:127`, `:161`) when it does not, so existing canvases keep their renders. `active` and `expanded` combine with `state`. A canvas can therefore show a hover or focus state statically, for a states sheet or a screenshot; the evaluator does not need it to. | A `button` at each state renders deterministically and distinctly in both themes; `state` on a non-clickable node has no effect and makes it no target. | +| **FR-H1** | **Focus is checked on every interactive primitive, without being drawn.** Rule `desktop-app-focus-visible`, error: for every clickable primitive, the evaluator computes its focus render analytically, the `$focus-ring` color against the backdrop composited behind the primitive with PR #243's layer walk, and fails it below 3:1 (WCAG 1.4.11). One error per distinct pair of ring color and composited backdrop, carrying `nodeIds[]` and `count`. A backdrop that is not a parseable color (a gradient, an image, an unreadable fill) is skipped with one info. With no `$focus-ring` token, the renderer and the check both use the accent chain `applyControlDefaults` already uses (`$accent`, then `$primary`, then `#2563EB`), so the two never disagree. Fast mode. | A `$focus-ring` at 2.4:1 against the inspector panel produces one error listing every primitive on that panel; at 3.2:1 it passes. | +| **FR-H2** | **Active states are visible without hue.** The primitives guarantee a non-hue cue by construction (FR-B3 to FR-B5: an indicator plus a heavier weight), so what can still fail is the indicator's contrast. Rule `desktop-app-state-contrast`, warning: an `active` primitive whose indicator is below 3:1 against the primitive's own active fill composited over its backdrop, computed in the rest state and again in the hover state (hover must not hide selection, WCAG 1.4.11). | A `nav-item` whose indicator resolves to 2.5:1 against its active fill warns; the default primitive on a generated design system passes in both themes. | +| **FR-H3** | **Disabled is exempt from contrast, not from visibility.** Text rendered inside a disabled clickable primitive is excluded from the text-contrast error on every type, a correction, since WCAG exempts it everywhere. Rule `desktop-app-disabled`, info: `state` tokens whose disabled render of a primitive is within 1.1:1 of its rest render (a house threshold), so disabled looks the same as enabled. | A disabled `button` at the generated disabled opacities raises nothing; a canvas whose `state.disabledContent` is 1.0 raises the info. | +| **FR-H4** | **Boundaries that are the only cue reach 3:1.** Rule `desktop-app-boundary`, warning: a `text-input` border, a checkbox or radio outline, and an unchecked toggle track, each computed against the fill behind it, below 3:1 (WCAG 1.4.11; an input's border needs 3:1 only when nothing else shows the field, which for these primitives is always). | A `text-input` with a `$border` at 1.8:1 against its panel warns; the generated system passes. | +| **FR-I1** | **The viewer knows the type.** The score cache key already covers type and device (`src/viewer.ts:893`). The detail view (`renderDetailPage`, `viewer.ts:1171`) shows a desktop-app canvas at its window size with breakpoint buttons read from the window profile (1024, 1280, 1440), not the phone buttons. | A desktop-app canvas opens at 1440 with three window-width buttons. | +| **FR-J1** | **Acceptance re-run.** The viewer is rebuilt as canvases on this phase, in one project, from `split-workbench-app`, `browser-app` and `editor-inspector-app`: the gallery, the detail page with its inspector, and the archive; two screen-state variants of the detail page (`comment-open`, with the comment popover and a `text-input`, and `confirm-delete`, with the delete modal); and the archive's empty state. Each reaches CLEAN in fast and detailed mode in both themes, with a full `canvas_stress` run CLEAN and zero `desktop-app-handbuilt-target` infos. The rebuild fixes each baseline viewer defect, checked by name in the PR: the "← Back" target (a `link` passing FR-C1), the inspector tabs (a `tabs` node), the issue cards (plain content with a clickable primitive inside, or a clickable primitive), the muted labels (4.5:1), repeated identical issues (grouped in one card with a count), and Delete beside Archive at the same weight (Delete a `destructive` action set apart from Archive). | The PR quotes every canvas's scores in both themes and modes, the stress verdicts, the `surface` reports showing every desktop-app rule ran, the zero handbuilt-target count, and the six-defect checklist with a screenshot of each. | + +### Non-goals (explicit scope cuts) + +- **No code port of the viewer.** The acceptance proves the design; reimplementing `src/viewer.ts` from it is separate work after this phase. +- **No anchored rendering.** Overlays are positioned by authored `x`/`y`; `anchor` feeds rules only. CSS anchor positioning is not in every browser the viewer serves, and a layout pass before render would make render depend on layout. +- **No keyboard behaviour.** Focus order, Esc, focus trapping and arrow keys need interaction; static checking cannot see them, and the research found no mainstream tool that scores them automatically. +- **No narrow-window behaviour.** The sidebar-to-rail collapse at 641 to 1007px, checks at 1024 and 1280, pane minimum widths and resizable panes are later work; this phase ships one window profile. +- **No "exactly one selected row when the detail pane is populated" rule.** Knowing that a detail pane is populated needs a link between the two panes that the scene graph does not have; FR-C4 flags more than one active row only. +- **No density snapping for hand-built content.** Primitives sit on the ladder by construction, and `data-table-app` carries the Carbon header and toolbar rules; checking that every hand-built control snaps to a rung is not in this phase. +- **No spacing-scale change.** The baseline's off-scale spacing came from an imported page with no design system of its own; a desktop-app canvas built with `generate_design_system` has a declared scale, and the spacing check is right to judge against it. +- **Should-have items left for later**: the command palette, bulk selection, filter-active, inline-edit, undo-toast and rows-selected variants (#14, #15, #18); typed empty and loading states (#19); the destructive-confirm copy rule (#16); an axe-core run (#20); occlusion for arbitrary positioned content and sticky chrome covering a focused node (#21); the inactive-button variant and the "disabled primary with no helper text" advisory (#22); the 1.4.12 text-spacing stress pass (#23); table hygiene (#24); sending the scene graph to the LLM critique (#25). Numbers refer to the research report's prioritized list. +- **Later items**: drag and drop, the AAA focus-appearance check, focus-order heuristics, clutter metrics, shell micro-rules, reflow at 320px, a learned quality model, timing (tooltip delays and toast durations, which no official source gave in milliseconds). +- **No importer work.** The baseline found the importer cannot rebuild an app shell (iframes dropped, pinned and positioned panes flattened, content-free bars lost, a lost width constraint producing a false FRAGILE). Import stays as is; only its report shape changes (FR-A7). +- **No `zIndex` or `role` property, no clickable flag on frames.** Layers come from overlay kinds and clickability from primitives, as Phase 30 settled. +- **No desktop-app rules on web.** Only the corrections (FR-A4, FR-A5, FR-A6, and FR-H3's disabled-text exclusion), the output change (FR-A7), `desktop-app-artboard`, and the new primitives' type-independent renders reach other types. + +--- + +## 2. CLARIFY (forks: recommendations) + +- **C1: The type name.** *Recommend:* `desktop-app` (Victor's proposal, 2026-11-09). `app` is ambiguous because phone screens are apps too; `desktop` reads as a wide web page. `desktop-app` names a tool you work in at a desktop-class window, whether in a browser or native. The cost is an asymmetry with `mobile`, which is minor. +- **C2: The boundary between `web` and `desktop-app`.** *Recommend:* by what the user does, not where it runs: read and scroll is `web`, work in it is `desktop-app`. The existing web `dashboard` and `settings` stay web realisations, since analytics and account pages exist on marketing sites; their `-app` realisations are the app versions. The `init` GOTCHAS, `GENRE_PARAM_DOC` and the `canvas_set_genre` docstring send editors and admin forms to `genre: "material"` today (`src/index.ts:139`, `:152`, `:2019`); they change to `type: "desktop-app"`, and `material` stays a genre and a preset for the Material style only. +- **C3: Realisation names.** *Recommend:* `-app` (`list-detail-app`), shorter than `-desktop-app`. Agents pass anatomy names and resolution picks the realisation, so the suffix is rarely typed. +- **C4: Element states: a property or variant canvases?** *Recommend:* a property (FR-G1) with analytical checks (FR-H1 to FR-H4). The research proposed state variants, the Storybook pattern. Variants would make the agent draw every state of every control by hand, and the checks would run only where it did. A property lets the renderer draw states from tokens, so every interactive primitive is checked on every canvas. Screen states, where the content changes, stay variants (FR-E3). +- **C5: Overlay positioning.** *Recommend:* authored `x`/`y` in root coordinates, with the renderer lifting overlays into one layer (FR-E1) and `anchor` feeding rules only. +- **C6: Which changes reach `web` and `mobile`.** *Recommend:* only corrections that are wrong everywhere: the spacing exception (WCAG applies on every surface), navigation lists not being tables, and identity dots not being accents; plus the output folding. The accent tell could instead be relaxed by the desktop-app ruleset alone, since `relax` already accepts cliché tells, but identity dots are identity on every type, and relaxing the whole tell would also hide real competing accents on an app. +- **C7: `button` heights and names.** *Recommend:* add `xs` (24) and keep 32 / 44 / 52, since making `md` 32 on desktop-app would make render depend on type (Phase 30 C10). Add `ariaLabel` instead of overloading `label`, which is visible text and decides whether a button is icon-only. +- **C8: `active`, not `current`.** *Recommend:* `active`, matching `TabItem.active` (`src/types.ts:18`), so one word means "this is where you are" across every primitive. The renderer emits `aria-current` for navigation and `aria-selected` for tabs either way. +- **C9: Severities for unvalidated thresholds.** *Recommend:* info. Row heights and column alignment within ±1px, one primary per region, the 4-character truncation floor, more than one toast, and a danger toast are proposals or judgment calls; they inform until the gate or the acceptance run gives evidence that they should block. Header-equals-body (Carbon) and the WCAG-derived rules warn or error. +- **C10: Target checks in fast mode.** *Recommend:* warn, not skip. The spacing exception needs rendered rects, which fast mode does not have. Skipping would let an undersized target through the loop agents actually run; erroring would be wrong whenever the spacing saves it. A blocking warning that names detailed mode keeps the gate honest at the cost of one extra detailed run. +- **C11: Output folding scope.** *Recommend:* fold infos only, from three repeats, never one with a `fix`. Errors and warnings are what agents act on node by node, and `canvas_autofix` emits per-node ops; infos are where the baseline's volume came from. +- **C12: Should-have items pulled in.** *Recommend:* `property-row` (#13) and the primary-count info (#17) come in because the acceptance's inspector and toolbars need them, and `text-input` because the comment popover and the browser filter bar do; `browser`, `dashboard-app` and `settings-app` come in because the acceptance needs `browser` and an app type without its two most common screens would push agents back to the web realisations. + +--- + +## 3. PLAN (technical: mapped to real symbols) + +**Slice A: the type, a rule id, corrections, readable output, fixtures (FR-A1 to FR-A9, FR-I1)** +- `src/surfaces.ts:9-10`: widen `SurfaceType` and `SURFACE_TYPES`; add `WindowProfile` and `WINDOW_PROFILES`; type-checked `device` in `surfaceFor`; a desktop-app branch in `resolveSurface`. +- The other type sites: `src/types.ts:391`, `:503`; `src/structures.ts:2012`; `src/stress.ts:147`; `src/mobile-rules.ts:663`; the four enums in `src/index.ts`; the artboard in `src/scene-graph.ts:142`. +- `RULESETS`: a `desktop-app` type row and `DESKTOP_APP_RULES`, each id registered in the same change as its check (slices C, F, H); `desktop-app-artboard` in the base row. +- `src/evaluate.ts`: gate the ratio check (`:572`) on `type-scale-ratio` not being off (`:2276-2278`). FR-A6 in the accent census (`:1757-1806`). FR-A4 in `checkUsability` (`:1979`): keep the padded-row step (`:2016-2043`), add the clickable primitives to the census on web and desktop-app, expand instances, then the target circle (detailed) or the warning (fast). +- `src/drift.ts:106` and `src/stress.ts:129`: the leading-dot discriminator. +- Output folding on the serialised evaluate result (the handler near `src/index.ts:2422`); the import report summary in `src/import.ts` (`:157-162`) with `verbose`. +- `src/design-language.ts:169-177` (`typeBase`): the desktop-app branch. `src/types.ts:334-343`: the `state` section of `DesignVariables`, layered like `motion`. `src/color-system.ts`: `focus-ring` and `border-strong`. +- `test-fixtures/viewer-baseline/`: the four baseline canvases. +- `src/viewer.ts:1171` (`renderDetailPage`): window-width buttons. + +**Slice B: vocabulary (FR-B1 to FR-B10)** +- `src/types.ts:1-12`: `NodeType` gains `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row`, `divider`. New props on `SceneNode`: `ariaLabel`, `links`, `count`, `dotColor`, `active` (already on `TabItem`), `depth`, `collapsed`, `expanded`, `shortcut`, `destructive`, `submenu`, `labelWidth`, `placeholder`, `rows`, `error`, `orientation`, `inset`, and a `TabsItem` type for `tabs.items`, with `SceneNode.items` (`:294`) widened to `TabItem[] | TabsItem[]`. The `variant` union (`:296`) and `size` union (`:303`) widen; `title` (`:283`) extends to `text`. +- `src/primitives.ts`: `BUTTON_HEIGHT.xs = 24` and its padding and font; the new primitives' heights; `CLICKABLE_PRIMITIVES`, a superset of `TAPPABLE_PRIMITIVES` (`:24`), used by the web and desktop-app censuses. +- `src/renderer.ts`: one render function per primitive beside `renderButton` (`:1147`, dispatched at `:1000`), each emitting `tabindex`, `data-focusable`, its ARIA attributes, and `title` where a label can truncate. +- `src/variables.ts:112`: a branch of `applyControlDefaults` for the new primitives, and `$border-strong` for control boundaries when the token exists. +- `src/stress.ts:44`: the new entries in `PRIMITIVE_TEXT_PROPS`. +- The `breadcrumb` component rebuilt from `link`. + +**Slice C: the app rules (FR-C1 to FR-C9)** +- `src/evaluate.ts`: one check per rule id, wired in the rule-dispatch block (`:2355-2388`, where the typography, structure and usability checks pick up their type rules) the way the mobile checks are. +- Row rhythm and truncation reuse the inventory's table detection; column alignment and the 4-character floor use detailed-mode layout (`computeLayout`, as `mobile-safe-area` does at `:2353`). + +**Slice D: panes (FR-D1 to FR-D3)** +- `src/structures.ts:1834`, `:1863`: N-type listing and resolution; `covers()` for `desktop-app`. +- Six realisations, `data-table-app`, and `test-patterns.ts`'s desktop-app set. + +**Slice E: overlays (FR-E1 to FR-E3)** +- `src/types.ts`: `tooltip`, `popover`, `menu`, `modal`, `toast`, `drawer`; `anchor`, `closeLabel`, `actions`, `tone`, `action`. +- `src/renderer.ts`: the overlay layer (a pass that collects overlay nodes and renders them after the content), the layer constant table, the modal's own scrim. +- `canvas_add_variant` docstring and GUIDELINES: the overlay states. + +**Slice F: overlay rules (FR-F1)** +- `src/evaluate.ts`: `desktop-app-overlay`; fast bounds from authored rects, detailed bounds from rendered rects. + +**Slice G: element states (FR-G1)** +- `src/types.ts`: `state`. `src/renderer.ts`: state layers from the `state` tokens, the focus ring and the double ring. + +**Slice H: state checks (FR-H1 to FR-H4)** +- `src/evaluate.ts`: factor `checkColorContrast`'s layer walk (`:413-473`) into a shared helper; the analytical focus, active, disabled and boundary renders; the disabled exclusion in `checkColorContrast`. +- A full-tier test that renders each state and compares the computed colors with the rendered ones, so the analytical model and the renderer cannot drift apart. + +**Slice I: acceptance (FR-J1)** +- `test-viewer-rebuild.ts` in the full tier, building the viewer canvases from the realisations and asserting FR-J1's numbers, the way `test-ridgeline.ts` pins Phase 30's re-run. + +**Every slice** touches the shared agent surfaces in the same PR (the CLAUDE.md discoverability rule): the tool docstrings, `INSTRUCTIONS`, the `init` GOTCHAS where behaviour changes, `docs/GUIDELINES.md`, and `test-discoverability.ts`'s lists (new node types, rule ids, structures). + +--- + +## 4. TASKS + +1. Slice A: type, window profile, every type site, `canvas_create` artboard, device validation. +2. Slice A: the `type-scale-ratio` rule id and the desktop-app ruleset row. +3. Slice A: the target census, the padded-row step kept, the spacing exception (detailed) and the fast-mode warning; navigation lists not tables; identity dots not accents. +4. Slice A: output folding and the import report summary; type-aware generation, the `state` token section and `focus-ring`; the baseline fixtures; viewer window buttons. +5. Slice B: `button` `xs` and `ariaLabel`, `link` and inline links, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row`, `text-input`, `divider`, `title` on `text`, with defaults, stress props, render tests, and the `breadcrumb` rebuild. +6. Slice C: the nine app rules and their fixtures, including the baseline fixtures. +7. Slice D: N-type resolution, six realisations, `data-table-app`, the pattern gate. +8. Slice E: the overlay layer, the ladder and the six overlay primitives; the overlay states in the docs. +9. Slice F: the overlay rules. +10. Slice G: element states rendered from tokens. +11. Slice H: the four analytical state checks and the render-versus-model test. +12. Slice I: the viewer rebuild and its pinned test. + +--- + +## 5. ANALYZE (risks and checks) + +- **Scope.** Nine slices, more than Phase 30's seven, chosen deliberately (2026-11-09) over a foundation-only phase. Slices E to H cover what the baseline could not test, since the viewer's static pages show no open overlay. If the acceptance run in slice I finds an overlay or state rule misfiring on a correct idiom, the fix lands in slice I with its evidence, as Phase 30 FR-C7 handled mobile. +- **Web verdicts.** FR-A4, FR-A5 and FR-A6 change web behaviour. The padded-row step is kept so lone controls keep their verdicts, but the census grows: a `button` with an explicit `height` below 24 (`buttonHeight`, `src/primitives.ts:56-58`) is measured on web for the first time, and in fast mode an undersized target now warns. The slice A PR lists every existing fixture whose verdict moved and why. +- **Renders on other types.** FR-G1 keeps today's 0.5 disabled opacity unless the canvas has `state` tokens, and FR-B8 uses `$border-strong` only when the token exists, so no existing canvas changes pixels; a canvas that runs `generate_design_system` after this phase gets both tokens and re-renders with them on any type. +- **Fast-mode friction.** FR-A4 and FR-C1 make an undersized target block in fast mode until a detailed run. Primitives are 24 or more by construction, so this lands only on explicit small sizes and hand-made controls, which is where the check matters. +- **Output shape.** FR-A7 changes the shape of folded `info` findings for every caller. A CI gate that counts infos sees fewer entries; the folded entry carries `count`, and the docs state the change. Errors and warnings are untouched. +- **House values.** The smallest text, the prose floor, the `data-dense` body, the type-ladder cap, the sidebar width, the tree indent, the modal widths, the ten-row modal-table line and the disabled-visibility threshold are house values or proposals. They are constants, each in one place, so a better source changes one row. +- **Analytical states versus the renderer.** FR-H1 to FR-H4 compute renders the renderer must actually produce. The slice H render-versus-model test keeps the two from drifting. +- **Overlay layer.** Lifting overlays out of the tree (FR-E1) means an overlay's authored position no longer follows its parent's layout. That is the point (root coordinates), and the docstring says so. +- **Discoverability.** Eight new content primitives, six overlay primitives, a type, rule ids and six structures all land on the agent surfaces in their own slices; `test-discoverability.ts` gains each list as it lands.