diff --git a/VISION.md b/VISION.md index b9f9435..8d4f45f 100644 --- a/VISION.md +++ b/VISION.md @@ -541,6 +541,20 @@ All five slices are merged, and the acceptance proof has been run: the checkout _Full spec-driven breakdown in [`docs/specs/PHASE-29-SPEC.md`](docs/specs/PHASE-29-SPEC.md). Deliberately out of scope: a checkout archetype and a product-image primitive (craft work that belongs on top of a correct evaluator), and flow-shaped state coverage (a larger design question)._ +### Phase 30 — The mobile surface (v2.4) + +The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and got 100/100, READY TO PRESENT, without a single mobile check running: the touch-target rule only looks at form controls, and the screen had none. Mobile today is a screen size, not a surface. This phase gives a canvas a declared `type` (`web` or `mobile`), separates it from genre and preset so the three declarations stop colliding, and builds the three layers a surface needs: rules that judge a phone, vocabulary to build one, and starting points. Acceptance is literal: the attempt screen rebuilt from a mobile archetype, CLEAN with every tap target a primitive. + +- [ ] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias +- [ ] Slice B — the type axis: `metadata.surface` with one device profile (`iphone-390`), `canvas_create({ type })` and `canvas_set_type`, a ruleset table keyed by type, genre and preset that composes by union and can turn rules on, a `surface` report on every evaluation, and a mobile type scale from `generate_design_system` +- [ ] Slice C — the mobile ruleset: 44px targets as an error on every tappable primitive, safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type +- [ ] Slice D — vocabulary: `fixed` / `sticky` positioning, `safeArea` on the root, and the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives +- [ ] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them +- [ ] Slice F — the viewer: thumbnails at the artboard's real proportions and a device frame with a safe-area overlay +- [ ] Slice G — the acceptance re-run + +_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)._ + ### 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-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md new file mode 100644 index 0000000..80f8698 --- /dev/null +++ b/docs/specs/PHASE-30-SPEC.md @@ -0,0 +1,239 @@ +# Phase 30 — The Mobile Surface (v2.4) + +> Spec-driven breakdown, borrowing spec-kit's flow: **Specify → Clarify → Plan → Tasks → Analyze**. +> This is a planning artifact, not code. File/symbol references verified against `src/` at commit 1b40732 on 2026-10-31. +> Source: the mobile surface attempt (2026-09-07). One iOS phone screen ("Ridgeline — Today", 390×844, 93 nodes) was built end to end against the published v2.2.0 by following the docs literally. It scored 100/100 and READY TO PRESENT with empty and loading states designed, and not one mobile-specific check ran. The four bug-sized defects it found shipped in PR #232. This spec turns the rest into a surface: a canvas that knows it is a phone screen, a ruleset that judges it as one, the vocabulary to build it, starting points, and a viewer that shows it. + +--- + +## 1. SPECIFY + +### Problem + +framesmith treats mobile as a screen size, not a surface. An agent asked for an iOS screen today gets a narrow web page, and the evaluator approves it without looking. Four failure classes, each confirmed in the attempt or in the code: + +1. **The judgment is blind on a phone.** The touch-target rule inspects only the four form-control types (`CONTROL_TYPES` at `src/evaluate.ts:1785`). The attempt screen had 45 frames, 29 texts, 17 icons and zero control nodes, so the 24px error and the 44px touch-first info both evaluated an empty set and `usability` scored a vacuous 100. Even when a control is found, the phone rule is an `info` (`evaluate.ts:1851`), so it never blocks. Nothing checks safe areas, a body-size floor, or tab-bar sanity. +2. **The declarations are tangled.** Genre (what a screen is for) and preset (what style it uses) share one field, `metadata.provenance.preset` (`src/types.ts:333`), so `apply_preset` silently overwrites a genre stamp (`src/index.ts:1549`) and every preset name (`dark`, `light`, `minimal`) reads as a genre. Separately, `dashboard` is both a genre and a structure name, and a running app had to declare itself a "dashboard" to stop its own distances flagging as fabricated. There is no axis at all for what medium a screen is on. +3. **There is no mobile vocabulary.** No status bar, home indicator, nav bar, tab bar, list row, sheet or button node. No safe-area concept. `position` is `'absolute' | 'relative'` only (`src/types.ts:111`), so a tab bar cannot pin over scrolling content. All 25 structures are desktop shapes; stamping `dashboard` onto a 390px canvas produced a 248px sidebar and a 1192px main column. +4. **The viewer assumes a desktop page.** Gallery thumbnails are hardcoded to a 1440×900 iframe at `scale(0.222)` (`src/viewer.ts:604`, `:756`), so a phone canvas fills about a quarter of its card. The detail view's breakpoint buttons hardcode 390 and 768 (`viewer.ts:1350-1352`) and have no device presentation. + +The phase's claim: an agent asked for a phone screen declares `type: "mobile"` once, starts from a mobile archetype built from mobile primitives, and is judged by rules that know a phone. A tap target below 44px on a phone blocks presenting, and the human sees the screen in a device frame in the viewer. + +### The model (agreed 2026-09-08, not reopened here) + +A canvas carries three independent declarations. They compose; none is nested inside another. + +| Axis | Question | Values in this phase | Stored at | +|---|---|---|---| +| **type** | what medium is this on | `web` (default), `mobile` | `metadata.surface.type` (new) | +| **genre** | what is this screen for | `data`, `commerce`, plus legacy aliases | `metadata.provenance.genre` (new; legacy fallback below) | +| **preset** | what style is applied | `dark`, `light`, `material`, `minimal` | `metadata.provenance.preset` (unchanged) | + +Each axis value contributes a **ruleset**. Rulesets compose by union: adding a genre adds one ruleset that applies on every type, and adding a type adds one ruleset that applies to every genre. + +Structures split **anatomy** from **realisation**. Anatomy is the screen kind, defined once and medium-independent (a settings screen: grouped preferences, each with a label and a control). Realisation is per type (web: a centred column of grouped cards; mobile: grouped list rows). The growth rule every later proposal must pass: adding a type costs one realisation per anatomy it wants, one ruleset and one viewer mode; adding a genre costs one ruleset; adding an anatomy costs one anatomy plus one realisation per type it wants. Nothing is quadratic. + +Tappability is a property of primitives, not a flag. A tab item is tappable because it is a tab item, the way `toggle` already carries its own semantics. There is no `tappable: true` property. + +### Goals + +- **Declarations that do not collide**: genre and preset in separate fields, `data` as the purpose-named genre, and a new `type` axis. +- **A ruleset mechanism that can turn rules on**, not only subtract cliché tells, keyed by any axis. +- **A mobile ruleset with teeth**: 44px targets as an error on every tappable primitive, safe areas respected, a body-size floor, and tab-bar sanity, all blocking. +- **Mobile vocabulary**: device chrome, navigation, list rows, sheets, buttons, and pinned positioning. +- **Mobile starting points** through the anatomy/realisation split. +- **A viewer that presents a phone canvas as a phone.** +- **Proof by re-run**: Ridgeline rebuilt on this phase, judged by rules that actually ran. + +### User stories + +- **US1** — As the authoring agent, `canvas_create({ name, type: "mobile" })` gives me a 390×844 artboard whose root reserves the safe-area insets, and `generate_design_system` on it gives me a 17px body (16 for the `data-dense` personality). +- **US2** — As the authoring agent, `list_structures` on a mobile canvas shows mobile realisations, and `apply_structure("settings")` on it stamps grouped list rows, not a sidebar. +- **US3** — As the authoring agent, a 32px icon button I built with `button` on a mobile canvas blocks presenting with an error naming the 44px floor; the same button on a web canvas passes. +- **US4** — As the authoring agent, `apply_preset("dark")` on a canvas I stamped `genre: "commerce"` leaves the genre alone. +- **US5** — As the authoring agent, a `tab-bar` pins to the bottom of the phone viewport over scrolling content, reserves the home-indicator inset itself, and is flagged if it has six tabs or an unlabeled one. +- **US6** — As the reviewing human, the gallery shows phone canvases as portrait cards at their real proportions, and the detail view shows them in a device frame with a safe-area overlay I can toggle. +- **US7** — As the reviewing human, Ridgeline rebuilt on this phase reaches CLEAN with every tap target a primitive, in fewer nodes than the 93 it took by hand, in both themes. + +### Device profile constants + +One profile ships. The mechanism is keyed by profile id so a second device (393×852 Dynamic Island, Android) is one table row later. Values in CSS px (1pt = 1px). + +| Constant | Value | Source | Confidence | +|---|---|---|---| +| Profile id | `iphone-390` | — | — | +| Artboard | 390×844 | iPhone 12/13/14 class | high | +| Safe top inset | 47 | iOS portrait inset for this class | medium (third-party, consistent) | +| Safe bottom inset | 34 | iOS home-indicator inset | medium | +| Touch target floor | 44 | Apple HIG 44pt; WCAG 2.2 SC 2.5.5 (AAA) 44px | high (WCAG verified) | +| Nav bar height | 44 (large title 96) | iOS | medium | +| Tab bar height | 49, plus the 34 bottom inset | iOS | medium | +| Tab count | 2–5 allowed, 3–5 recommended, every tab labeled | Material 3 nav bar 3–5 (verified); Apple (secondary) | high | +| Body size | 17 (floor 16) | iOS Dynamic Type Body 17; 16 is the Material and mobile-web floor | high | +| Smallest text | 11 | iOS Caption 2 | high | +| List row min height | 44 (56 with a subtitle) | iOS table row 44 | medium | +| Side margin | 16 | iOS and Material compact | medium | +| Home indicator | 134×5 pill, centred in the bottom inset | iOS | medium | + +Values marked medium came from secondary sources because Apple's HIG pages are JS-rendered and could not be fetched. Slice B, which lands the profile table, re-checks them against the HIG before they land in code; a corrected value changes this table in the same PR. + +### Terms used below + +- **Content**: text, icons, controls, `button`, `list-row`, and images narrower than the artboard. Everything else is background. +- **Contentless frame**: a frame or rectangle with no content anywhere beneath it. Fill, border and shadow are allowed. +- **Pinned**: `position: "fixed"`, or `"sticky"` while stuck. "Pinned bottom" means fixed with a `bottom` offset. +- **Reservation**: the space the root keeps clear at an edge. Top reservation = the root's authored top padding + `safeArea.top`. Bottom reservation = authored bottom padding + `safeArea.bottom`. The renderer raises each to at least the height of the `fixed` chrome primitive on that edge when one exists: `status-bar` at the top, `tab-bar` or `home-indicator` at the bottom (FR-D1). +- **Fast / detailed mode**: `canvas_evaluate`'s existing modes. Fast is JSON-only and is what agents run in the loop; detailed adds a Chrome layout pass with real rects. +- **Full tier**: `npm run test:full`, the Chrome-dependent tests. + +### Functional requirements + +| ID | Requirement | Acceptance | +|----|-------------|-----------| +| **FR-A1** | **Genre gets its own field.** `canvas_set_genre` writes `metadata.provenance.genre`; `apply_preset` writes `preset`; neither touches the other's field. Genre resolves as: the per-call `genre` option, else `provenance.genre`, else the **legacy read**: `provenance.preset` when that value is not a real preset name (`dark`, `light`, `material`, `minimal`). The legacy read covers every value the old `canvas_set_genre` could have written that is not a preset name, unknown genres included. **Every write migrates first**: before `canvas_set_genre` or `apply_preset` touches a canvas whose `preset` holds a non-preset value, that value moves verbatim to `genre` (a stored `"dashboard"` stays `"dashboard"`; FR-A3's alias resolves it) and `preset` is cleared. `canvas_set_genre(null)` therefore clears a legacy genre too. Stored canvases keep their relaxation and their score. Their genre report changes in two intended ways: it names the canonical genre (`data` for a stored `dashboard`, FR-A3), and a preset name that the old `canvas_set_genre` stored (`material`, `dark`, `light`, `minimal`) now reads as the preset, so `genre.active` becomes null while the relaxation, if any, is kept through the preset ruleset. Every evaluator of a canvas resolves genre through one shared function: `canvas_evaluate`, `canvas_autofix`, and the viewer (`src/viewer.ts:849`, `:872`), which today passes `preset` as an explicit genre. | `apply_preset("dark")` after `canvas_set_genre("commerce")` leaves the genre report at `commerce`. A stored canvas with `preset: "dashboard"` keeps its score and reports `active: "data"`; after `apply_preset("dark")` it stores `genre: "dashboard", preset: "dark"`; after `canvas_set_genre(null)` it has no genre. `apply_preset("material")` on a stored `preset: "commerce"` canvas keeps `commerce`. A stored `preset: "fitness"` still reports `active: "fitness"`. The viewer's score equals `canvas_evaluate`'s for the same canvas. | +| **FR-A2** | **Preset is its own ruleset.** The `material` relaxation (accent-hue, pure-black-white) is keyed to the preset `material`, so it applies whenever the material preset is the canvas's preset, whatever the genre. `material` also stays an accepted genre with the same relaxation (C3). Presets other than `material` contribute nothing. A per-call `genre` option replaces only the genre axis; the stamped preset's ruleset still applies. This is a deliberate change: today an explicit `genre: "commerce"` on a material-preset canvas drops the material relaxation, and after this phase it keeps it, because the material style is still on the canvas. | `apply_preset("material")` + `canvas_set_genre("commerce")` relaxes accent-hue, pure-black-white and honest-content together, and so does the per-call `genre: "commerce"` on that canvas. | +| **FR-A3** | **`data` is the canonical purpose genre.** `data` becomes the canonical key for "the figures are the user's own data" and `dashboard` becomes its alias (today it is the reverse, `evaluate.ts:1045`). The docs scope `data` to any screen whose numbers are the user's real data: dashboards, activity, finance, fitness, logistics. The structure `dashboard` keeps its name and is an anatomy, not a purpose. | `canvas_set_genre("dashboard")` still works and reports `data` as the canonical genre. The genre report's `notRelaxed` names `data`, not `dashboard`. Every agent surface that recommends `genre: "dashboard"` recommends `genre: "data"`. | +| **FR-B1** | **A canvas type.** `metadata.surface = { type: 'web' \| 'mobile', device?: string }`; absent means `web` with source `default`. `canvas_create` accepts `type` (and `device`, default `iphone-390` for mobile). For `mobile` it sets the artboard to the profile's size (an explicit `width`/`height` wins) and writes the profile's insets onto the root as `safeArea` (FR-D1). A new tool `canvas_set_type({ canvasId, type: 'web' \| 'mobile' \| null, device? })` sets the type on an existing canvas (null clears it back to source `default`); it never resizes the artboard and never writes `safeArea`, so a canvas made mobile this way is checked by FR-C3's no-`safeArea` path, and that warning's suggestion names the one op that adds it: `U("document", { safeArea: { top: 47, bottom: 34 } })`. Type is excluded from `versionHash` (metadata already is, `src/version.ts:48-54`). `canvas_add_variant` copies `metadata.surface` onto the variant (today `addVariant` at `src/scene-graph.ts:532` rebuilds metadata from `provenance` and `variant` only). | `canvas_create({ type: "mobile" })` reads back 390×844, `surface.type: "mobile"`, `root.safeArea: { top: 47, bottom: 34 }`. `canvas_set_type` on a 1440 canvas changes the type and leaves the root untouched (FR-C6 then flags it). An empty-state variant of a mobile canvas evaluates as mobile. | +| **FR-B2** | **Rulesets keyed by any axis.** Replace `RELAXED_BY_GENRE` with a ruleset table keyed by axis and value. A ruleset may `relax` cliché tells, `enable` rule ids and `disable` rule ids. The active rules are the union across type, genre and preset. A rule both enabled and disabled is **on**, and the conflict is reported, so a guardrail never disappears through composition. | A unit test composes `type: mobile` + `genre: commerce` + `preset: material` and gets the union of all three. A synthetic enable/disable conflict resolves to on and appears in the report. | +| **FR-B3** | **The ruleset is visible.** `canvas_evaluate` and `canvas_autofix` return a `surface` report next to the existing `genre` report: `{ type, device, source: 'declared' \| 'default', rulesOn: string[], rulesOff: string[], conflicts: string[] }`. The existing `genre` report keeps its shape. The viewer's score cache key (`evalCacheKey`, `src/viewer.ts:848`) covers the resolved genre, the preset and the type, so a `canvas_set_type` or `canvas_set_genre` invalidates the cached score. | Evaluate on a mobile canvas lists every mobile rule id in `rulesOn`. `canvas_set_type` followed by a gallery load shows the re-scored canvas. | +| **FR-B4** | **Type-aware generation.** `generate_design_system` on a mobile canvas raises the type scale base to 17 for every personality except `data-dense`, which uses 16. An explicit `baseSize` wins. The generator never emits a system that its own mobile ruleset flags. | Each of the four personalities generated on a mobile canvas yields `body` ≥ 16 and no role below 11. | +| **FR-C1** | **Touch targets are an error on mobile.** On a `mobile` canvas, every tappable primitive with an effective target below 44px in either dimension is a `usability` **error** (rule id `mobile-touch-target`). The tappable primitives and their targets: the four controls (their box, extended by the existing padded-row rule at `evaluate.ts:1826-1838`, whose loop bound becomes the active floor: 24 on web, 44 on mobile); `button`, `list-row`, each `tab-bar` item, the `tab-bar` FAB, and each `nav-bar` action (their own rendered box, never extended by an ancestor's padding, since these primitives define their own hit area). On `web` canvases the 24px error is unchanged and the 44px touch-first info stays for phone-width web artboards. | A 32px `button` on a mobile canvas is NOT READY, including inside a frame with 12px padding; the same canvas declared `web` is READY. A `toggle` alone in a 44px-tall padded row passes on mobile. | +| **FR-C2** | **Hand-built tap targets are named, not gated.** On a `mobile` canvas, a frame whose only content is one icon, or one icon plus one text of three words or fewer, and whose box is below 44px, is reported as an `info` "possible tap target" naming the `button` primitive as the fix. It never blocks: a frame cannot declare it is decorative, so gating it would produce findings with no honest fix. Text-only taps (links, "See all") are outside the heuristic. Rule id `mobile-handbuilt-target`. | The original 93-node Ridgeline, re-evaluated as `mobile`, reports its icon buttons and hand-built tab items as infos. (It also warns under `mobile-safe-area` and `mobile-type-floor`, which is expected: it was built without the vocabulary.) | +| **FR-C3** | **Safe areas are respected.** On a `mobile` canvas, content (see Terms) must not sit in the top or bottom inset, and must not end up permanently under a pinned bottom element. Positions are always in the artboard's coordinate frame, never a node's local `x`/`y`. **Default path:** a root with `safeArea` (FR-D1) reserves the insets in the renderer, so in-flow content cannot reach them; the rule then checks only positioned (`absolute`, `fixed`, `sticky`) content. **Without `safeArea`** (a canvas made mobile by `canvas_set_type`, or a deliberate full-bleed screen), fast mode checks reservation structurally: walking the tree in document order and descending into frames, the first content must sit below top padding at least the inset, summed over the root and the frames above it, or follow a contentless frame at least that tall (a spacer); the bottom likewise. A `fixed` `status-bar`, `tab-bar` or `home-indicator` satisfies its edge, because the renderer reserves its height (FR-D1); an in-flow `status-bar` counts as a spacer of its height. **Pinned bottom elements** other than `home-indicator` and `tab-bar` (the renderer handles those, FR-D1) and `sheet` (a modal overlay over the content by design) must meet two conditions: their content must clear the inset (`bottom` offset + the element's own bottom padding ≥ `safeArea.bottom`, or 34 without one), and the scroll end must clear them (bottom reservation ≥ the element's height + its `bottom` offset). Fast mode takes the element's explicit height, else its vertical padding plus its tallest child's known height (explicit, or a primitive's fixed height); when the height cannot be resolved it skips the second condition and adds the detailed-mode info. A CTA bar authored as `position: "fixed", bottom: 0, paddingBottom: 46` passes both on a root whose authored bottom padding is at least the bar's height + its offset − `safeArea.bottom`. **Detailed mode** checks rendered rects instead: content intersecting the top inset at scroll position 0 fails; pinned content intersecting the bottom inset fails; non-pinned content intersecting a pinned element's rect at maximum scroll fails. Fast mode cannot place positioned content nested inside positioned frames; when such content exists it adds an `info` under the same rule id naming detailed mode. Severity `warning`. Rule id `mobile-safe-area`. | An `absolute` text node at `y: 10` warns in both modes. A `fixed` CTA frame at `bottom: 0` with no bottom padding, holding a `button`, warns in both modes; the `paddingBottom: 46` version above passes both on a root with `safeArea` whose authored bottom padding is at least the bar's height − 34. Ridgeline rebuilt from the archetype is clean in both modes. A full-width hero image under the status bar, with its text below the inset, is clean. | +| **FR-C4** | **Type floor.** On a `mobile` canvas, using resolved sizes (tokens and explicit `fontSize` alike): any text below 11px warns; any text below 13px holding five or more words warns (prose below iOS Footnote); and every node that resolves the `body` role below 16px warns. A design-system `body` token that no node uses never warns by itself. Severity `warning`. Rule id `mobile-type-floor`. Text of 13–15px holding twelve or more words is an `info` under the same rule id (long copy below body size); 13px secondary lines are standard on iOS, so they are not gated. | A paragraph set in a 12px `$text-sm` warns; a 13px timestamp line does not. A workspace `body` of 14 on a mobile canvas that never references `$body` produces no finding. | +| **FR-C5** | **One column is advised, not gated.** On a `mobile` canvas, a horizontal container that is not a primitive or chart, with two or more children that each hold a text of eight or more words, is an `info` naming one column as the phone default. Rule id `mobile-one-column`. | Two side-by-side article cards on a phone produce the info; a four-tile quick-action row and a two-stat row do not. | +| **FR-C6** | **The declaration matches the artboard.** A `mobile` canvas whose root width is 500px or wider warns; a `mobile` canvas whose root size differs from its device profile in any other way is an `info`. A canvas whose type source is `default` (never declared) warns when its root is narrower than 500px or it contains a mobile chrome primitive (`status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `sheet`): the agent must declare. An explicit `canvas_set_type("web")` turns that warning into an `info` under the same rule id ("a phone app screen should declare `mobile`"); the existing web touch-first info (`evaluate.ts:1851`) is separate and unchanged. Rule id `mobile-artboard`, severity as stated. It belongs to the base ruleset, on for every type, because its job is to catch canvases the mobile ruleset is not on for. | `canvas_create({ width: 390, height: 844 })` with no type is NOT READY until `type` is declared either way. `canvas_set_type("mobile")` on a 1440 canvas warns until the artboard is resized. A 390×844 web canvas explicitly declared `web` gets only the info. | +| **FR-C7** | **Web-shaped rules come off on mobile only on evidence.** The mobile ruleset's `disable` list starts empty. A rule joins it only when it fires on a correct mobile idiom in the Ridgeline rebuild or in a mobile realisation, and the PR records the screen and the finding. | The slice C PR lists every disabled rule with its evidence, or states that none qualified. | +| **FR-C8** | **Stress on mobile.** `StressRecord` (`src/stress.ts:257`) records the canvas type it ran under. Readiness semantics are unchanged: a FRAGILE verdict blocks until a full CLEAN run replaces it, whatever the type is now, and an unstressed canvas is not blocked (stress is advised, not required, on every type). When the recorded type differs from the current type, the record is stale for the directive's note only (the note tells the agent to re-run, exactly as an edit since the run does today, `src/directive.ts:55-57`); the verdict still blocks. Perturbations reach the new primitives through a per-primitive table of perturbable text props: `button.label`, `list-row.title`, `list-row.subtitle`, `list-row.trailing.text`, `tab-bar.items[].label`, `nav-bar.title`, and `nav-bar` action labels. On `mobile`, long-text skips strings of three characters or fewer, `status-bar.time`, and tab and nav labels (fixed vocabulary); i18n still applies to tab and nav labels because those really are localised, and both primitives truncate their labels to one line with an ellipsis so an i18n run reports a designed truncation (`info`) rather than a clip. A run of two or more consecutive sibling `list-row`s is a list for the `empty` and `many` perturbations; this lives in stress only, through a stress-side wrapper, because `extractInventory` (`src/drift.ts:170`) is shared with coverage, typography, the cliché table exclusion and drift, which must not change. Persisting the stress verdict across server restarts stays out of scope. | A FRAGILE run, then `canvas_set_type` to the other value, still reads NOT READY. Long-text on the rebuilt Ridgeline perturbs every `list-row` title but not the status-bar time or the "FM" monogram. A five-tab bar under i18n reports truncation infos, not warnings. | +| **FR-D1** | **Pinned positioning and safe areas in the scene.** `position` gains `'fixed'` and `'sticky'`, plus `bottom` and `right` offsets alongside `x`/`y`. The root's width × height box is the viewport; content may be taller and scrolls. `fixed` pins to the viewport; `sticky` sticks within its scroll container at its `y` offset. Valid on every type, since web wants sticky headers too. The root gains `safeArea?: { top: number, bottom: number }`: when set, the renderer adds those insets to the root's own top and bottom padding, and the root's fill paints the inset bands. Whenever the root contains `position: "fixed"` device chrome, with or without `safeArea`, the renderer raises that edge's reservation to at least the chrome's rendered height plus its `y` (top) or `bottom` offset, taking the maximum when several sit on one edge: a `status-bar` at the top (`safeArea.top`, or 47 without one), a `tab-bar` at the bottom (49 + `safeArea.bottom`, or 49 + 34 without one), a `home-indicator` at the bottom (`safeArea.bottom`, or 34). Content can therefore never sit under pinned chrome at scroll position 0 or scroll end. **Render depends only on the hashed scene**: nothing in `metadata.surface` changes what renders, so `versionHash` stays a faithful render key. `screenshot` with `fullPage: true` documents that fixed elements are captured at their viewport position. | A fixed `bottom: 0` frame on an 844px-tall root with 1600px of content renders at the bottom of the viewport. A root with `safeArea` renders its first child 47px down. A mobile root with no `safeArea` and no padding, a fixed `status-bar`, then a text: the text renders at y 47 and `mobile-safe-area` is silent. With `safeArea` and a fixed `tab-bar`, the bottom reservation is 83, not 117. Changing only `metadata.surface` leaves every screenshot byte-identical. | +| **FR-D2** | **Device chrome primitives.** `status-bar` (`time`, default `"9:41"`; `appearance: 'dark' \| 'light'`), full width, height = the root's `safeArea.top` (47 without one); `home-indicator` (`appearance`), height = `safeArea.bottom` (34 without one), with the centred pill. Both default to `position: "fixed"` (top and bottom respectively), render from bundled SVG, and never take authored children. | Both render at 390 in light and dark; `status-bar` reads 47 tall at y 0 in `snapshot_layout` on a mobile canvas. | +| **FR-D3** | **Navigation primitives.** `nav-bar` (`title`, `leading?` back action `{ icon, label? }`, `trailing?` up to two actions `{ icon } \| { label }`, `largeTitle?`), height 44 or 96, each action rendering a 44×44 hit area. `tab-bar` (`items: [{ icon, label, active?, badge? }]`, `variant: 'standard' \| 'floating' \| 'fab'`, `fab?: { icon }` for the centre action; `standard` is a full-width bar, `floating` an inset rounded pill above the home indicator, `fab` a standard bar with a raised centre action), height 49 plus `safeArea.bottom` (34 without one); it defaults to `position: "fixed"`, `bottom: 0`. The FAB is not a tab: it is excluded from the item count and the label rule, and it is a tappable target (FR-C1) rendered at 56. On `mobile`, a `tab-bar` with fewer than 2 or more than 5 items, an item with an empty label, or a `position` overridden to anything but fixed-bottom is a `warning` (rule id `mobile-tab-bar`). | A five-tab bar with a centre FAB renders pinned and passes; a six-tab bar warns; an icon-only tab warns; a tab bar moved into flow warns. | +| **FR-D4** | **Content primitives.** `button` (`label?`, `icon?`, `iconPosition?`, `variant: 'primary' \| 'secondary' \| 'ghost' \| 'destructive'`, `size: 'sm' \| 'md' \| 'lg'`, `disabled?`), valid on every type, heights 32 / 44 / 52 on every type (render never depends on type, FR-D1); an icon-only button is square. `sm` is for pointer surfaces and fails FR-C1 on mobile by design, which the docstring states. `list-row` (`title`, `subtitle?`, `leading?: { icon } \| { initials } \| { image }`, `trailing?: { text } \| { chevron: true } \| { toggle: boolean } \| { badge }`, `divider?`), valid on every type, min height 44 (56 with a subtitle), leading inset 16, title and subtitle truncating to one line with an ellipsis. `sheet` (a container: `detent: 'medium' \| 'large' \| 'auto'`, `grabber?` default true), pinned to the viewport bottom with top corner radius from `$radius-lg`, a grabber, and a scrim over the content behind it. All colours default through `applyControlDefaults` (`src/variables.ts:112`) so an unthemed canvas never crashes. Repeated primitives are reuse: the structure category's "no component instances found" advisory (`src/evaluate.ts:739`) counts `button`, `list-row` and `tab-bar` nodes toward reuse, so a screen built from primitives is not told to extract components. | `snapshot_layout` reads button heights 32 / 44 / 52 and list-row heights 44 / 56 at 390 and 1440, in both themes, with no console errors. A default `button` passes FR-C1 on mobile. `list-row` survives long-text and i18n with designed truncation only (FR-C8). A canvas of eight `list-row`s and no components gets no reuse advisory. | +| **FR-D5** | **Viewport meta.** `renderToHtml` (`src/renderer.ts:70`) emits ``, so the viewer's `/canvas/:id/html` page opened on a phone is not zoomed out. | Present in `renderToHtml` output and the viewer route; screenshots of every existing fixture are byte-identical. | +| **FR-E1** | **Anatomy and realisation on structures.** `Structure` (`src/types.ts:316`) gains `anatomy?: string` (defaults to `name`) and `surface?: 'web' \| 'mobile' \| 'any'`, defaulting to `web` for every existing structure, page and component alike. A component scaffold is marked `any` only once FR-E4 passes it on a mobile canvas (several would not, such as `data-table`, whose 28px hand-built pager buttons raise `mobile-handbuilt-target`). Two structures may share an anatomy if their surfaces differ; the pair (anatomy, surface) is unique. | The registry rejects a duplicate (anatomy, surface) pair at load. | +| **FR-E2** | **Resolution by canvas type.** Structure names stay unique: a mobile realisation is named `-mobile` (e.g. `dashboard-mobile`, `anatomy: "dashboard"`), and existing names are unchanged. `apply_structure({ structure })` resolves an anatomy name to the canvas type's realisation (so `dashboard` on a mobile canvas means `dashboard-mobile`). A name that is not an anatomy name (such as `dashboard-mobile`) is used as is, subject to the same surface check; `realisation: "web"` forces the web realisation. If no realisation exists for the canvas type, it refuses with an error naming the anatomies that do have one. `list_structures` accepts `canvasId` (or `type`), defaults to the web listing, and returns each entry's `anatomy` and the surfaces it is realised on. | `apply_structure("dashboard")` on a mobile canvas stamps the mobile home realisation; `apply_structure("pricing")` on a mobile canvas refuses and lists the mobile anatomies. | +| **FR-E3** | **Mobile realisations.** Six, named `-mobile` (FR-E2), built only from primitives and $tokens: `dashboard` (summary home), `settings` (grouped list rows), `auth` (sign-in), `onboarding` (paged), and two new mobile-first anatomies, `feed` (a list of rows) and `detail` (an item with a pinned primary action). Each starts with `status-bar` and ends with `home-indicator` or a pinned `tab-bar`. | See FR-E4. | +| **FR-E4** | **The pattern gate covers mobile.** `test-patterns.ts` evaluates every mobile realisation on a canvas created with `type: "mobile"`. In fast mode, across its five themes, with the same categories and the same `> 95` bar as the web set, plus zero findings from any `mobile-*` rule. Once per realisation in the full tier: detailed mode with zero `mobile-*` findings, and a CLEAN full `canvas_stress` run (both are layout checks and theme-independent). Every component scaffold marked `surface: "any"` is stamped onto a mobile canvas and held to the same zero-mobile-findings rule in both modes. | The gate fails if any mobile realisation scores 95 or below in any theme, raises any mobile finding in either mode, or stresses FRAGILE, or if any `any` component raises a mobile finding. | +| **FR-F1** | **Artboard-aware thumbnails.** Gallery and archive cards size their iframe from the canvas's root width and height and scale it to the card width; the card's aspect ratio is the root's width / height, so phone canvases are portrait cards. | A 390×844 canvas gets a 390×844 iframe and a card aspect of 390/844; a 1440×900 canvas still gets a 1440×900 iframe at `scale(0.222)`. | +| **FR-F2** | **Device presentation.** The detail view shows a `mobile` canvas at 1:1 inside a CSS device frame (rounded screen, bezel; no images), with a toggle that overlays the root's `safeArea` bands. The breakpoint buttons are hidden for mobile canvases (a phone canvas has one device). For web canvases the buttons read their widths from the device profile table instead of hardcoded 390/768. | A mobile canvas opens framed; the overlay shows bands of 47 and 34; a web canvas's phone button uses 390 from the table. | +| **FR-G1** | **Acceptance re-run.** Ridgeline Today is rebuilt from the same brief on this phase: `canvas_create` with `type: "mobile"`, `generate_design_system`, `apply_structure("dashboard")`, then adaptation. It must reach CLEAN in fast and detailed mode in both themes, with one full `canvas_stress` run CLEAN (stress renders one theme, `src/index.ts:2145`; its findings are layout, not colour), with every tap target a primitive (zero `mobile-handbuilt-target` infos) and no hand-built device chrome, in fewer than 93 nodes. | The PR quotes both themes' scores in both modes, the stress verdict, the node count, the zero handbuilt-target count, and the `surface` report showing every mobile rule ran. | + +### Non-goals (explicit scope cuts) + +- **No Android profile or Material realisations.** One device profile proves the mechanism. Android's 48dp target and its own bars are one profile row and one realisation set later. +- **No tablet, watch or desktop-app types.** The type axis has room for them; this phase ships `web` and `mobile`. +- **No `tappable` or `role` flag on frames.** Settled 2026-09-08: tappability belongs to primitives. FR-C2 is the deliberate, non-blocking answer for hand-built frames. +- **No thumb-reach rule.** The only citable data (Hoober 2013) describes grip, not placement, so there is no defensible threshold to enforce. +- **No interaction, gestures, navigation transitions or multi-screen flows.** A phone canvas is one static screen, as a web canvas is. +- **No mobile import.** `canvas_import_html` / `canvas_import_url`, drift and `canvas_sync_from_url` stay web. +- **No eCommerce mobile archetypes.** Cart, checkout and product detail come after this phase, on the anatomy/realisation split it creates: one anatomy each plus two realisations. +- **No static PNG thumbnails.** PR #208 found the gallery's slowness was server-side cache-key cost, not the iframes, and deferred static thumbnails to the artboard-aware thumbnail work. This phase declines them: artboard sizing alone fixes what mobile needs, and no measurement shows a performance need. +- **No new micro-primitives beyond the seven.** Stepper, rating, avatar-with-edit and currency pill (gap catalog V10–V12) stay hand-built; badges are covered by `tab-bar` items and `list-row` trailing. +- **The open #232 follow-ups stay out**: `project_evaluate` ignoring stress, the MCP SDK stripping unknown params, and the highlight index check. + +--- + +## 2. CLARIFY (forks — recommendations) + +- **C1 — Where the type lives.** *Recommend:* `metadata.surface`, not `provenance`. Provenance records what produced a canvas; type is a declaration of what it is, and it carries a device id that provenance has no reason to know about. Keeping it in metadata also keeps it out of `versionHash`, matching genre. +- **C2 — Does `canvas_set_type` resize the artboard or write `safeArea`?** *Recommend:* no to both. Either would reflow every node the agent placed. FR-C6 warns until the agent resizes deliberately, FR-C3's structural check covers a root without `safeArea`, and `canvas_create({ type })` is where the defaults apply. +- **C3 — `material` as genre or preset.** *Recommend:* both, deliberately. The relaxation is keyed to the preset, so applying the material style relaxes it whatever the genre. `material` also stays a genre with the same relaxation, because the `init` GOTCHAS (`src/index.ts:135`) recommend `genre: "material"` for editors and admin forms and agents follow that advice today. Dropping it from the genre table would change their verdicts, and the duplicate costs one table row. +- **C4 — `data` or `dashboard` as the canonical genre.** *Recommend:* `data`. The genre describes what the numbers are, not the layout, and the attempt showed the layout word pushing a fitness app into the wrong label. `dashboard` stays an alias permanently because stored canvases carry it. +- **C5 — Hand-built frames: gate, advise, or ignore.** *Recommend:* advise (FR-C2). Gating needs a way for a frame to say "I am decorative", which is the tappability flag the model rejected. Ignoring repeats J1's silence. Advising names the fix, and the archetypes make primitives the default path, so the gate covers what the default path produces. +- **C6 — Safe area: rule or renderer.** *Recommend:* the renderer reserves the insets (`root.safeArea`, FR-D1) and the rule polices what can escape the reservation: positioned content, pinned bottom bars, and roots without `safeArea`. This mirrors iOS, where the system insets scroll content automatically and only explicit layout can collide with the bars. Fast mode has no rendered rects and is what agents run in the loop, so the default path must be correct by construction rather than by a check that only detailed mode can make. The modes still disagree on positioned content nested in positioned frames; fast mode says so with an info, and FR-E4 and FR-G1 require both modes. +- **C7 — Tab bar pinned by default.** *Recommend:* yes, `position: "fixed"`, `bottom: 0` unless overridden. A tab bar in flow falls below the fold at artboard height, which is the attempt's V9 finding; making every author remember to pin it re-creates the defect. +- **C8 — Button heights.** *Recommend:* 32 / 44 / 52 on every type, so `md`, the default, passes the 44 floor on a phone. Type-dependent heights would make the render depend on `metadata.surface`, which `versionHash` does not cover (C10). `sm` stays available for pointer surfaces and errors on mobile, which the docstring says. +- **C9 — Short-string skip in stress.** *Recommend:* mobile only. Web stress results stay byte-identical, and the attempt's evidence is mobile-specific. +- **C10 — What the type is allowed to change.** *Recommend:* judgment and presentation only, never the render. The type selects rules, the generator's defaults when it is called, the structures offered, and the viewer frame. Everything that changes pixels (insets, primitive sizes, pinning) lives in the scene graph, where `versionHash` covers it, so a CI verify of a mobile canvas means the same thing it means for a web one. +- **C11 — Undeclared phone-width canvases.** *Recommend:* make them warn (FR-C6). The attempt itself was built with `width: 390` and no type, so leaving that path silent repeats J1 for every agent that reads only the `canvas_create` signature. One explicit call in either direction clears it, so a responsive web page designed at 390 pays one call, not a rule it cannot satisfy. + +--- + +## 3. PLAN (technical — mapped to real symbols) + +**Slice A — untangle the declarations (FR-A1, FR-A3)** +- `src/types.ts:330-346`: add `genre?: string` to `Provenance`. +- `src/scene-graph.ts:176-190` (`setCanvasGenre`): write `prov.genre`, leave `prov.preset`. +- `src/index.ts:1544-1549` (`apply_preset`): still writes only `preset`, after the migration below; genre is no longer clobbered because resolution reads `genre` first. +- One exported `resolveGenre(canvas, explicit?)`: `explicit ?? prov.genre ?? (isPresetName(prov.preset) ? undefined : prov.preset)`. Callers: `src/evaluate.ts:2114`, the `canvas_autofix` path at `index.ts:2075-2086`, and the viewer at `src/viewer.ts:872` (which must stop passing `preset` as an explicit genre). `evalCacheKey` at `viewer.ts:848-849` keys on resolved genre, preset and type. +- `setCanvasGenre` and the `apply_preset` write both call one `migrateLegacyGenre(prov)` first (FR-A1's migrate-on-write). +- `src/evaluate.ts:1018-1052`: swap canonical and alias for `data`/`dashboard`. FR-A2 (the material relaxation behind the preset key) lands in slice B with the ruleset table. +- Surfaces: every `"dashboard"` genre recommendation: `src/index.ts:135` and `:146` (GOTCHAS), `:1966` and `:1971` (`canvas_set_genre` docstring and param), `:2000` (`canvas_evaluate`), and the honest-content suggestion at `src/evaluate.ts:1391`; plus the genre passages of `docs/GUIDELINES.md` and README. `test-discoverability.ts:118-130` parses only the relax-genre keys, so it will not catch these; the slice adds a check that no agent surface recommends `genre: "dashboard"`. +- Tests: `test-set-genre.ts` and `test-genre-report.ts` (preset no longer clobbers genre; legacy read for a known genre, an unknown genre and `material`; migrate-on-write; clearing a legacy genre), `test-cliche.ts` (alias swap, report names `data`), a viewer-vs-evaluate score parity case. + +**Slice B — the type axis and rulesets (FR-A2, FR-B1..B4)** +- Re-check the medium-confidence constants against Apple's HIG before the profile table lands. +- `src/types.ts:411-424`: `metadata.surface`; `safeArea?: { top, bottom }` on the document node, written by `canvas_create`, and the renderer's inset padding for it in the shell at `src/renderer.ts:101` (the root fill is already hoisted to `` at `:82`, so it paints the inset bands). It lands here, not in D, so slice C's default path is true from the first slice that can produce a mobile canvas. New `src/surfaces.ts`: the device profile table (one row) and the ruleset table keyed `{ axis, value }` with `relax` / `enable` / `disable`, plus the union and conflict logic. `RELAXED_BY_GENRE`, `relaxedByGenre`, `knownGenres` become views over it so their callers keep working. +- `src/scene-graph.ts:130-146` (`createCanvas`) and `canvas_create` at `src/index.ts:170-200`: accept `type` and `device`; size and `root.safeArea` from the profile. `addVariant` at `scene-graph.ts:532` copies `metadata.surface`. +- New tool `canvas_set_type` next to `canvas_set_genre` (`index.ts:1961-1990`). +- `src/evaluate.ts:102-116, 1061-1077, 2172`: add the `SurfaceReport` beside `GenreReport`. +- `generate_design_system` (`src/index.ts`, base/ratio at :1113-1114; `src/design-language.ts` role mapping at :184-218): mobile base per FR-B4. +- Surfaces: `canvas_create` / `canvas_set_type` / `canvas_evaluate` docstrings, `INSTRUCTIONS`, GOTCHAS, GUIDELINES; `test-discoverability.ts` gains a types list and a device-profile list. +- Tests: new `test-surface.ts` (creation defaults, set-type, union, conflict, report, `safeArea` shell padding as an HTML-string assertion with no Chrome, and `metadata.surface` render invariance), `test-design-language.ts` mobile cases. + +**Slice C — the mobile ruleset (FR-C1..C8)** +- Each check is registered by rule id in the ruleset table and runs only when on, except `mobile-artboard`, which is in the base ruleset (FR-C6). +- `src/evaluate.ts:1785-1900` (`checkUsability`): take the active ruleset instead of `rootWidth` alone; extend the tappable set with the slice D primitives as they land (the rule id exists from slice C, tested against controls first); add `mobile-handbuilt-target`. +- New checks for `mobile-safe-area` (fast: structural; detailed: shares the layout computed once at `evaluate.ts:2075-2079`), `mobile-type-floor`, `mobile-one-column`, `mobile-artboard`. Slice C tests safe areas with root padding, `safeArea` reservation arithmetic and `absolute` nodes; the `fixed`, pinned-CTA and chrome-primitive cases run once slice D lands. +- `src/stress.ts:257-268` (`StressRecord`, `recordStressRun`) and `src/directive.ts:53-59`: record and compare the type. `walkTexts` (`stress.ts:36-41`) today visits only `text` nodes' `content`; it gains the per-primitive prop table from FR-C8 and the mobile skips. A stress-side wrapper treats a run of sibling `list-row`s as a list, leaving `extractInventory` (`src/drift.ts:170`) untouched. +- Tests: `test-usability.ts` mobile cases, new `test-mobile-rules.ts` (each rule's positive and negative fixtures from the FR table), `test-stress.ts` type cases. + +**Slice D — vocabulary (FR-D1..D5)** +- `src/types.ts:1-8` (`NodeType`) and `:108-111` (`position`, plus `bottom`/`right`; the `safeArea` half of FR-D1 lands in B); `src/renderer.ts:682-684` (position emission; note x/y are inert without `position` today), `:520` / `:563-573` (dispatch), new builders beside `renderToggle` (:820), the pinned-chrome reservations for `status-bar`, `tab-bar` and `home-indicator` (FR-D1); `src/variables.ts:112-125` (`applyControlDefaults`) for colour defaults; viewport meta at `renderer.ts:97`. +- Status-bar and home-indicator glyphs bundled as SVG strings in the renderer (no new dependency). +- Every new node type lands on the `batch_design` docstring (`src/index.ts:543-544`), README `**Node types:**`, GUIDELINES, and `test-discoverability.ts:63-71` picks them up from the union. +- May split D1 (positioning + chrome + navigation) and D2 (button, list-row, sheet) if the diff is large. +- Tests: `test-primitives.ts` (render, defaults, unthemed, the pinned-chrome reservations with and without `safeArea`), `test-mobile-rules.ts` extended to the new tappables and `mobile-tab-bar`, new `test-positioning.ts` for fixed/sticky (needs Chrome, full tier). + +**Slice E — mobile starting points (FR-E1..E4)** +- `src/types.ts:316-326` (`Structure`), `src/structures.ts:1466-1468` (`listStructures`), `:1618` (`applyStructure`), `list_structures` at `src/index.ts:1449-1452` (whose docstring also misses the four state scaffolds today; fix it in passing). +- Six mobile realisations in `src/structures.ts`, referencing type roles and $tokens only. +- `test-patterns.ts` (`BAR = 95` at :14, five themes at :18; it runs fast mode only today) extended per FR-E4 with a detailed-mode and stress pass in the full tier; `test-discoverability.ts:75-83` extended to anatomies and surfaces. + +**Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). +- `src/viewer.ts:603-604` and `:756` (thumb sizing), `:515` / `:679` (card iframes), `:1107-1109` and `:1350-1385` (detail sizing, breakpoint buttons, compare grid), `:1474-1476` (active-state detection). Profile table imported from `src/surfaces.ts`. +- Tests: `test-viewer-perf.ts` must stay green (thumbnail change cannot cost render time); a new viewer route test for card sizing and the framed detail markup. + +**Slice G — acceptance (FR-G1).** Rebuild Ridgeline from the attempt brief (`.framesmith/mobile-surface-attempt/`, untracked) on the merged phase, quote the results in the final PR. + +Slice order: A → B → C → D → E → F → G. A untangles the field B's ruleset table reads. B gives C a type to key on. C's target rule exists before D so each D primitive is born under it. E needs D's primitives. F needs only B, and can move earlier if convenient. G needs everything. + +--- + +## 4. TASKS + +| # | Slice | Task | Test | +|---|-------|------|------| +| 1 | A | `provenance.genre` + resolution order + legacy read | test-set-genre | +| 2 | A | `data` canonical, `dashboard` alias; agent surfaces updated | test-cliche, test-discoverability | +| 3 | B | `metadata.surface`, profile table (constants re-checked), `root.safeArea` field and renderer reservation, `canvas_create` type, `canvas_set_type`, variant copies surface | test-surface | +| 4 | B | Ruleset table keyed by axis; union + conflict; preset `material` (FR-A2) | test-surface, test-cliche | +| 5 | B | `surface` report on evaluate/autofix | test-surface | +| 6 | B | Mobile type scale in `generate_design_system` | test-design-language | +| 7 | C | `mobile-touch-target` error + `mobile-handbuilt-target` info | test-usability, test-mobile-rules | +| 8 | C | `mobile-safe-area` fast + detailed (fixed/pinned cases after D) | test-mobile-rules | +| 9 | C | `mobile-type-floor`, `mobile-one-column`, `mobile-artboard` | test-mobile-rules | +| 10 | C | Stress record carries type; primitive prop table; mobile skips; list-row lists | test-stress | +| 10b | C | FR-C7: record each disabled web rule with evidence, or none | slice C PR | +| 11 | D | `fixed` / `sticky` + `bottom` / `right`; viewport meta | new test-positioning | +| 12 | D | `status-bar`, `home-indicator`, `nav-bar`, `tab-bar` + `mobile-tab-bar` + fixed-chrome edge reservations (FR-D1) | test-primitives, test-mobile-rules | +| 13 | D | `button`, `list-row`, `sheet`; primitives count as reuse | test-primitives, test-stress, test-evaluate | +| 14 | E | `anatomy` / `surface` on structures; resolution and listing | test-structures | +| 15 | E | Six mobile realisations; pattern gate extended | test-patterns | +| 16 | F | Artboard-aware thumbnails; device frame + overlay; profile-driven buttons | viewer route test, test-viewer-perf | +| 17 | G | Ridgeline re-run, light + dark, fast + detailed, CLEAN, < 93 nodes, zero handbuilt targets | manual + PR quote | + +--- + +## 5. ANALYZE (risks & checks) + +- **A declaration is still on the honour system.** FR-C6 closes the silent path (an undeclared phone-width canvas warns), but an agent can still declare `web` explicitly on a phone app screen to avoid the mobile errors, as it could borrow a genre before Phase 29. The remaining mitigation is visibility: the `surface` report is in every evaluate result, and the viewer frames only declared mobile canvases, so a misdeclared phone screen looks wrong to the human. Preventing it outright would need width-based inference, which would make a responsive web page designed at 390 impossible to express. +- **The gate covers primitives, not every pixel.** FR-C1 blocks on every tappable primitive; FR-C2 only advises on hand-built frames. A screen hand-built entirely from frames can still pass with small targets. This is a deliberate trade (C5), and it holds only if the archetypes really are the default path, which is why FR-E4 gates them and FR-G1 counts primitives. +- **Moving the genre field touches stored data.** The legacy read in FR-A1 is defined negatively (anything that is not a real preset name is a genre), so unknown genres written by the old `canvas_set_genre` survive. Preset names stored by the old `canvas_set_genre` (`material`, `dark`, `light`, `minimal`) are read as presets: `material` keeps its relaxation through the preset ruleset and the others relaxed nothing anyway, so no verdict changes; only `genre.active` goes to null, which is the untangling working. One accepted cost: a `material` stamp written by the old `canvas_set_genre` is now a preset, so `canvas_set_genre(null)` no longer removes its relaxation; applying another preset does. +- **Stress stays advisory and in memory.** A phone canvas that was never stressed is not blocked, and a server restart forgets a FRAGILE verdict. Both are true on web today; FR-C8 does not make them worse, and persisting the verdict is a follow-up, not this phase. +- **Fast and detailed modes can disagree on safe areas.** With `safeArea` on the root the default path is correct in both, and the disagreement narrows to positioned content nested in positioned frames, which fast mode cannot place. Fast mode flags that it cannot see it, detailed is authoritative, and the docs say so in the sentence that introduces the rule. +- **The coverage category does not see list-row lists.** A `feed` realisation's empty state matters as much as a table's, but coverage detection stays table-shaped in this phase (the Phase 29 non-goal on flow-shaped coverage still holds). Stress does treat sibling list-rows as a list (FR-C8). +- **Constants from secondary sources.** Several device values could not be read from Apple's own pages. Slice B re-checks them when it lands the profile table; a wrong inset would make the safe-area rule wrong on every mobile canvas. +- **Seven new node types widen the renderer.** Each is a leaf or a fixed-shape container with its own builder, following the Phase 16 controls. The risk is the `sheet` scrim and `fixed` positioning interacting with `fullPage` capture and `snapshot_layout`; FR-D1's acceptance pins the screenshot case. +- **The pattern gate grows by 30 fast assertions** (six realisations × five themes, Chrome-free), plus six detailed-mode and six stress checks in the full tier, plus one stamp check per `any` component. +- **The proof is the re-run.** If Ridgeline needs a hand-built tab bar, a borrowed type, or a rewritten scaffold to reach CLEAN, the phase is not done.