From f7204ef51ca9b9a59572969bf13ad9b2d5a5fb3b Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 23:28:08 +0100 Subject: [PATCH 1/5] Freeze the slice C build contract Pins rule ids, severities and the measurement rules the FR rows leave open, and corrects FR-C2's acceptance (Ridgeline's targets meet the floor). Records the root-render gap for slice D. --- docs/specs/PHASE-30-SPEC.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index afa9588..1dfc114 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -195,10 +195,42 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - `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. +- **Build contract (slice C, frozen before build).** Pins what the FR rows leave open. Where it changes an FR, it says so. + - *Rule ids and where they report.* Every finding from a ruleset rule carries a new optional `rule` field on `EvaluationIssue` (the rule id). Ids, category, severity and score effect: + + | Rule id | Ruleset row | Category | Severity | Score | + |---|---|---|---|---| + | `mobile-touch-target` | type `mobile` | usability | error | −25 each | + | `mobile-handbuilt-target` | type `mobile` | usability | info | none | + | `mobile-safe-area` | type `mobile` | usability | warning (info for the "fast mode can't place this" note) | −10 per warning | + | `mobile-type-floor` | type `mobile` | typography | warning (info for 13–15px long copy) | −10 per warning | + | `mobile-one-column` | type `mobile` | usability | info | none | + | `mobile-artboard` | `base` | structure | warning or info per FR-C6 | −10 per warning | + + A rule runs only when its id is in the composed `rulesOn` (the category must also be active, as for every check). `mobile-tab-bar` registers in slice D with the `tab-bar` primitive. + - *Constants.* Floors and insets come from the canvas's device profile (`deviceProfile(surface.device)`, falling back to `iphone-390`): `touchTarget` 44, `smallestText` 11, `bodyFloor` 16, `safeArea` 47 / 34, artboard 390×844. The FR's other thresholds are house constants beside the checks: 13px prose floor, 5 / 8 / 12 words, 3-word tab label, 500px phone width. Root width and height read as numbers, else 1440 and 900, the defaults every render path already uses. + - *Content* follows the Terms definition. Fast mode: `text` with non-blank content, `icon`, the four controls, `button`, `list-row`, `instance` (opaque: it may hold content, so it counts as content), and `image` unless it spans the artboard (numeric width ≥ the root width, or a non-numeric width with zero horizontal padding on every ancestor). Detailed mode classifies an image by its rect width (≥ root width − 1 is background). + - *Rendered padding.* The renderer scales any padding of 32px or more fluidly (`responsivePadding`: `clamp(max(8, v/4), v/1440·vw, v)`), so on a 390 artboard a 48px padding renders at about 13px. Every mobile computation that adds padding uses the rendered value at the root width, through a helper exported from `renderer.ts` (`renderedPaddingPx`), never the authored number. Web computations are unchanged. + - *Insets.* Every mobile-safe-area check takes each side from `usableSafeArea(root)` when the root is a `document`, else the profile's inset (47 / 34). + - *Text sizes.* A text's size is its resolved `fontSize`: a number, a `px` string, or a `rem`/`em` string × 16; an unresolved `$ref` or any other string renders at the renderer's 14px fallback and is read as 14. Texts with no `fontSize` are skipped (they render at the document default). + - *FR-C1.* On a canvas with `mobile-touch-target` on, a control's target is its own box extended through up to two ancestors, using an ancestor only when it is a row: a frame with `layout: "horizontal"` holding exactly one control. A qualifying row's box is, per dimension, its numeric size or `minHeight`/`minWidth` when set, a full width (`"100%"`, `fill_container`) counting as the artboard width, else the control plus the row's rendered padding. A target below 44 in either dimension is the error (the 24px error is subsumed; the touch-first info does not fire). This replaces "the padded-row loop bound becomes the active floor" on mobile, because the authored-padding loop both passes a lone checkbox in a padded card and fails a toggle in a 56px row. The slice D primitives join the tappable set as they land. Web is unchanged. + - *FR-C2 (spec change: the acceptance, not the rule).* The rule stays as written: a `frame` whose content (Terms) is exactly one icon, or one icon plus one text of three words or fewer, with a known box below 44 in either dimension. A dimension is known when it is numeric, or for an icon-only frame when it is the icon's `iconSize` (24 if unset) plus the frame's rendered padding; an unknown dimension is never judged. Only the outermost qualifying frame of a nested pair reports. The FR's acceptance was wrong: Ridgeline's icon buttons are 44×44 and its tab items 56×48, so they meet the floor. Corrected acceptance: on Ridgeline evaluated as `mobile` the rule reports the three 40×40 run-row icon tiles and nothing else. Dropping the size condition was considered and rejected: it fires on every decorative icon chip, which would make FR-G1's and FR-E4's zero-finding gates demand contortions with no honest fix. Until slice D lands `button`, the suggestion names the 44×44 floor instead of the primitive; slice D rewrites it. + - *FR-C3, positioned content.* In this slice positioned means `position: "absolute"` (D adds `fixed` and `sticky`). Fast mode places an absolute node in the artboard frame only through a chain of absolute ancestors with numeric `y` up to the root (otherwise its containing block is an in-flow frame whose position fast mode does not know). A placed text's height is its numeric `height`, else its size × its numeric `lineHeight` (a value ≤ 3 is a multiplier, larger is px) or × 1.2; other placed leaves use numeric `height`, else 0. A placed content leaf warns when its box reaches above the top inset or into the bottom inset of the artboard (`root height − bottom inset`). A placed frame runs the structural walk from its own `y`, and checks the bottom only when its height is numeric; its finding's suggestion names padding inside that frame, not the root `safeArea`. Unplaceable positioned content adds one `info` per canvas naming detailed mode. + - *FR-C3, the structural walk* (fast mode, each side without a usable root `safeArea` value). Top: start at the root's rendered top padding; walk children in order, skipping positioned ones. In a vertical (or layout-less) container, a content child must sit at an offset ≥ the inset; a frame holding content adds its rendered top padding and is walked; anything else is a spacer adding `max(numeric height, numeric minHeight, rendered vertical padding)` plus the container's numeric gap. In a horizontal or grid container every in-flow child starts at the same offset. The first content decides; no content, no finding. Bottom: the mirror image, children in reverse order, bottom padding. Alignment inside fixed-height frames is ignored, so fast mode can warn where detailed mode would not; detailed mode is authoritative. The bottom check warns on short screens too: its remedy (`safeArea` on the root) is correct on every phone screen, so a false block costs one op that improves the canvas. + - *FR-C3, detailed mode* (spec extension, so both modes check the same things). Content whose rect intersects the top inset fails. Positioned content intersecting the artboard's bottom inset fails. In-flow content whose rect intersects the last bottom-inset px of the document fails **whether or not the root has `safeArea`** (document bottom = max of the artboard height and every rect's bottom): a fixed-height root does not carry its end padding past overflowing content, so a tall screen on the default path really does end in the home-indicator inset (pre-freeze verifier, rendered). Detailed mode computes the layout once at depth 25 on a canvas with `mobile-safe-area` on (depth 10 otherwise, unchanged), shared with consistency and typography, and computes it when usability is active too. + - *Known gap until slice D (recorded, not fixed here).* Fast mode trusts the `safeArea` reservation, so a tall screen on the default path passes fast and fails detailed. The cause is the renderer: the document root renders `height: `, so overflowing content ignores its end padding, and explicit-height children flex-shrink (sixteen 56px rows rendered 48px tall). FR-D1's "the root's box is the viewport; content may be taller and scrolls" requires slice D to render the root so content past the artboard keeps the end reservation and children keep their sizes; slice D's acceptance adds both cases. FR-E4 and FR-G1 require detailed mode, so no gated screen ships through the gap. + - *FR-C4.* One finding per text node, first match wins: size < 11 (warning); raw `fontSize` is `"$body"` (read from the unresolved tree by node id) and the size is below 16 (warning); size < 13 with ≥ 5 words (warning); 13 ≤ size < 16 with ≥ 12 words (info). Words count prose, not data: a whitespace token counts when it contains a letter, and a run of Han, Hiragana or Katakana characters counts one word per two characters, so `5.2 mi · 1h 12m · 340 ft` is two words and CJK prose is not one word. + - *FR-C5.* A `frame` with `layout: "horizontal"` or `"grid"`, two or more children that each hold (anywhere beneath) a text of eight or more words → one info on the frame. + - *FR-C6.* Mobile: root width ≥ 500 → warning; any other mismatch with the profile's width × height → info. Source `default`: width < 500 or any node of type `status-bar` / `home-indicator` / `nav-bar` / `tab-bar` / `sheet` → warning. Declared `web` with the same condition → info. Each finding names `canvas_set_type`. Variants carry their own `metadata.surface` (FR-B1 copies it at creation), so a later `canvas_set_type` on the base does not reach them: `canvas_set_type`'s result lists the variants whose type now differs (`variantsOutOfSync`), and its docstring says to declare each. + - *Rule gating wording.* Every slice C rule is opt-in: it runs when its id is in the composed `rulesOn`. Slice B's "a default rule runs unless a ruleset disables it" stays the semantics for the existing checks, which have no ids yet. + - *FR-C7.* Evidence comes from the original Ridgeline canvas (`.framesmith/mobile-surface-attempt/ridgeline-today-ios.json`, untracked) evaluated as `mobile`. An existing check can only be disabled by id, and none has one, so a check that misfires on a correct mobile idiom gets an id in this slice and joins the mobile `disable` list with its evidence; the PR lists each, or states that none qualified. + - *FR-C8.* `StressRecord` gains `type` (the resolved type, `resolveSurface(canvas).type`, never the source, so clearing a declared `web` is no change). The evaluate result's `stress` field gains `type`, and `current` is true only when both the version hash and the type match now; readiness is unchanged (a FRAGILE record blocks whatever `current` says). The directive's stale note says which changed: an edit since the run, or the canvas type. `applyPerturbation(name, root, { type })`: on `mobile`, long-text skips strings of three characters or fewer (FR-C8 accepts that short labels such as "Add" get only i18n). The per-primitive prop table (`button.label`, `list-row.title`, `list-row.subtitle`, `list-row.trailing.text`, `tab-bar.items[].label`, `nav-bar.title`, `nav-bar` action labels) is data in `stress.ts`, read generically, so slice D only adds node types; on mobile long-text skips `tab-bar` and `nav-bar` props, and i18n perturbs them. Two or more consecutive sibling `list-row`s are a list for `empty` (all rows of the run removed) and `many` (the run ×3), touched = their parent; tables still come from `extractInventory`, which does not change. Web runs are byte-identical to today. + **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. +- From slice C: the document root renders `height: `, so content taller than the artboard ignores the root's end padding (the `safeArea` bottom included) and explicit-height children flex-shrink. FR-D1 fixes the root render so overflowing content keeps the end reservation and children keep their authored sizes, and its acceptance adds both cases (sixty in-flow rows on a `safeArea` root end 34px above the document bottom; sixteen 56px rows render 56px tall). Then fast mode's trust in the reservation (slice C) becomes true. - 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). From bfde4192a1f12241d6cc8e295210b65d67ea3a26 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 23:42:07 +0100 Subject: [PATCH 2/5] Add the mobile ruleset: touch targets, safe areas, type floor Phase 30 slice C. Six rule ids register in the ruleset table and run when on: mobile-touch-target (error), mobile-safe-area and mobile-type-floor (warnings), mobile-handbuilt-target and mobile-one-column (infos), and mobile-artboard on every type. Findings carry a rule field. Stress records the canvas type; canvas_set_type names variants left on the old type. --- README.md | 19 +- VISION.md | 2 +- docs/GUIDELINES.md | 24 +- docs/specs/PHASE-30-SPEC.md | 8 + src/directive.ts | 9 +- src/evaluate.ts | 151 +++++++++---- src/index.ts | 33 ++- src/mobile-rules.ts | 411 ++++++++++++++++++++++++++++++++++ src/renderer.ts | 9 + src/stress.ts | 113 ++++++++-- src/surfaces.ts | 22 +- test-discoverability.ts | 16 ++ test-mobile-rules-detailed.ts | 122 ++++++++++ test-mobile-rules.ts | 355 +++++++++++++++++++++++++++++ test-surface.ts | 6 +- 15 files changed, 1218 insertions(+), 82 deletions(-) create mode 100644 src/mobile-rules.ts create mode 100644 test-mobile-rules-detailed.ts create mode 100644 test-mobile-rules.ts diff --git a/README.md b/README.md index e2dc7a5..d73abff 100644 --- a/README.md +++ b/README.md @@ -809,7 +809,7 @@ Declare what medium an existing canvas is on. Writes `metadata.surface` only: it | `type` | `"web"` \| `"mobile"` \| null | The medium; `null` clears the declaration (the canvas reads as undeclared web again) | | `device` | string? | Device profile for `"mobile"` (default `"iphone-390"`). Only valid with `"mobile"`. | -Returns `{ canvasId, type, device, source, previous, artboard, hint?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. Clearing the type leaves the root, including any `safeArea`, as it is. On a mobile result whose root doesn't reserve both insets or has a size other than the device's, `hint` names the ops that finish the job: `U("document", { safeArea: { top: 47, bottom: 34 } })` and `U("document", { width: 390, height: 844 })`. Those ops change the root, so they move the `versionHash`; the declaration alone is metadata and doesn't. It changes the evaluate result's `surface` report (and, as mobile checks register, which rules run), so re-evaluate after it. +Returns `{ canvasId, type, device, source, previous, artboard, hint?, variantsOutOfSync?, versionHashUnchanged }`. `previous` is the declaration it replaced (or `null`); `artboard` is the root's unchanged size. State variants keep the type they were created with, so `variantsOutOfSync` lists each variant (`{ state, canvasId, type }`) whose type now differs from the base's; declare those too. Clearing the type leaves the root, including any `safeArea`, as it is. On a mobile result whose root doesn't reserve both insets or has a size other than the device's, `hint` names the ops that finish the job: `U("document", { safeArea: { top: 47, bottom: 34 } })` and `U("document", { width: 390, height: 844 })`. Those ops change the root, so they move the `versionHash`; the declaration alone is metadata and doesn't. It changes the evaluate result's `surface` report and which rules run, so re-evaluate after it. ### `canvas_evaluate` @@ -819,11 +819,20 @@ The **`directive`** is the operating contract that keeps unfinished work off the Open point-and-tell comments block too: when the canvas has any, the result carries `openFeedback: n` and the directive stays blocking even at a READY score — the human's note outranks the heuristics. Read them with `get_feedback`, address each, close them with `resolve_feedback`. -A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, current }`, where `current` says whether that run was on the canvas's present `versionHash`. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When `current` is false the directive tells you to re-run stress to confirm your fix. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. +A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, type, current }`, where `type` is the canvas type the run used and `current` says whether that run was on the canvas's present `versionHash` and type. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When `current` is false the directive tells you to re-run stress, naming whether an edit or a type change made the run stale; the verdict still blocks. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed, byPreset? }` — the genre in effect (canonical: a `"dashboard"` stamp reports `"data"`, a `"checkout"` one `"commerce"`), whether it came from the `genre` param or the canvas's provenance stamp, the tells relaxed, `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax — and `byPreset` (`{ preset, relaxed }`), present when the applied preset relaxed tells on its own (the material preset relaxes accent-hue and pure-black-white whatever the genre). A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. -Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"` or `"mobile"`), its device profile (`null` on web), whether the type was `"declared"` (by `canvas_create` or `canvas_set_type`) or is the `"default"` web of an undeclared canvas, and the rule ids the composed type, genre and preset rulesets turned on (`rulesOn`) and off (`rulesOff`). A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`. No rule ids are registered yet, so both lists are empty today. `canvas_autofix` returns the same field. +Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"` or `"mobile"`), its device profile (`null` on web), whether the type was `"declared"` (by `canvas_create` or `canvas_set_type`) or is the `"default"` web of an undeclared canvas, and the rule ids the composed type, genre and preset rulesets turned on (`rulesOn`) and off (`rulesOff`). A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`. `canvas_autofix` returns the same field. Every finding from a rule carries its id in a **`rule`** field. On a web canvas `rulesOn` is `["mobile-artboard"]`; a mobile canvas runs the whole mobile ruleset: + +| Rule | Category | Severity | Fires on | +|---|---|---|---| +| `mobile-touch-target` | usability | error | A control whose touch target is below 44px. The target extends to its row only when the row is a horizontal frame holding that one control. | +| `mobile-handbuilt-target` | usability | info | A frame shaped like a tap target (one icon, or an icon plus a label of up to three words) whose box is below 44px. | +| `mobile-safe-area` | usability | warning | Content under the 47px status-bar or 34px home-indicator inset. With the root's `safeArea` only positioned content can get there; without it, the first and last content must clear the insets. Detailed mode checks rendered positions and also catches a long screen that ends in the bottom inset. | +| `mobile-type-floor` | typography | warning | Text below 11px, `$body` below 16px, or prose of five or more words below 13px (twelve-word copy at 13–15px is an info). | +| `mobile-one-column` | usability | info | Prose set side by side in a horizontal or grid frame. | +| `mobile-artboard` | structure | warning / info | Runs on every type. A mobile canvas 500px or wider warns. An undeclared canvas narrower than 500px warns until `canvas_set_type` declares it. | Whenever the `coverage` category runs on a **base** canvas, the result also carries a **`coverage`** field: `{ dataBearing, states, missing }` — whether the screen carries data-bearing content, which state variants (`canvas_add_variant`) already exist, and which demanded states are missing. It's absent on variant canvases (a variant *is* a designed state, so it doesn't demand states of itself). @@ -845,7 +854,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `consistency` | 20 | Frames missing `layout`, sibling overlap (detailed mode) — plus score-neutral advisories: **token detachment** (a literal `fill`/`stroke`/`color`/`cornerRadius` that exactly equals a token's value flags with a mechanical `$ref` autofix; ambiguous matches list candidates and never guess) and **ad-hoc motion** (3+ distinct duration/easing combos with no motion tokens declared) | | `cliche` | 15 | Machine-made tells: default purple/indigo accent, gradient/glow overuse, fake browser/OS chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated metrics/testimonials/logos, eyebrow rhythm (an eyebrow above nearly every section), slop copy (stock AI phrasing — filler verbs, scroll cues, placeholder names, hype labels), radius consistency (too many distinct corner radii), pure black/white (`#000000` ink / `#ffffff` page vs off-black/off-white), accent consistency (multiple competing accent hues — the `$success`/`$warning`/`$danger` status vocabulary is exempt when referenced as tokens, since those are semantics rather than a second accent; the same hues as literals still count). Each issue carries a `tell` discriminator. Tells are warning or info severity, but **every tell blocks the directive**, whatever its severity (see the `directive` above). Relaxable per `genre`. The tells know dashboard vocabulary: a violet reached only through a `$chart-*` or `$*-tint` token doesn't count as the default-purple tell (a literal violet, or one through any other token, still does), a gradient reached through a `$token` doesn't count toward gradient overuse, thin rounded sparkline bars aren't mistaken for traffic-light dots (fake-chrome now requires a near-square shape), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric, not an eyebrow. It also knows retail copy: a numeral bound to a unit noun by a tight hyphen (`"30-day returns"`, `"2-year warranty"`, `"24/7 support"`) is a compound modifier, not a numbered section eyebrow. | | `coverage` | 10 | State coverage on **base** canvases with data-bearing content: a detected table demands designed `empty` + `loading` variants, a form (3+ input controls) demands `error` — each missing state is a directive-blocking warning whose suggestion names the `canvas_add_variant` + scaffold path. Variant canvases and non-data screens get no findings. Table detection needs three matching horizontal rows to read as data (two stacked rows are treated as a layout, e.g. a topbar over a greeting); an import-named `Table` frame keeps the looser two-row shape. On a **`loading`** variant itself, a warning fires per data surface (a detected table, or a frame with 2+ large tabular/numeric texts) that still shows live values while a sibling region is skeletoned — "Loading state is half-designed" — naming the region and suggesting the matching skeleton scaffold. The result's `coverage` field reports `{ dataBearing, states, missing }`. | -| `usability` | 20 | The mechanical UX floors beyond contrast. A control below the **24px hit-target floor** is an **error** (WCAG 2.5.8) — the effective target counts a lone control's padded row, so a well-padded settings row passes while two small controls sharing a row are measured bare; phone-width artboards additionally get a 44px touch-first info. A control with **no text label** in its row or group is a warning (selects carry their own value text). **Vague action copy** — "Click here", "Learn more", "Read more", or "Submit" as the exact label — is an info: name the action. Controls also render with accent-derived `:focus-visible` rings in the live viewer; screenshots stay byte-identical (nothing is focused in a static capture). | +| `usability` | 20 | The mechanical UX floors beyond contrast. A control below the **24px hit-target floor** is an **error** (WCAG 2.5.8) — the effective target counts a lone control's padded row, so a well-padded settings row passes while two small controls sharing a row are measured bare; phone-width web artboards additionally get a 44px touch-first info, and a canvas declared `mobile` makes 44px an error instead (`mobile-touch-target`, see the mobile ruleset above). A control with **no text label** in its row or group is a warning (selects carry their own value text). **Vague action copy** — "Click here", "Learn more", "Read more", or "Submit" as the exact label — is an info: name the action. Controls also render with accent-derived `:focus-visible` rings in the live viewer; screenshots stay byte-identical (nothing is focused in a static capture). | The weights are relative, not points out of 100. Each category scores 0–100, and `overallScore` is their weighted mean: the sum of score × weight over the categories that ran, divided by the sum of their weights. @@ -956,7 +965,7 @@ Apply the ops by joining them with newlines and passing to `batch_design`, then ### `canvas_stress` -Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. The verdict is remembered for `canvas_evaluate`: any run with warnings records `FRAGILE` (its directive then stays `NOT READY`), and only a run of the full default set records `CLEAN`, so a passing subset never clears an earlier `FRAGILE`. +Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. The verdict is remembered for `canvas_evaluate`, with the canvas type it ran under: any run with warnings records `FRAGILE` (its directive then stays `NOT READY`), and only a run of the full default set records `CLEAN`, so a passing subset never clears an earlier `FRAGILE`. On a `mobile` canvas `long-text` leaves strings of three characters or fewer alone (monograms, short labels). | Perturbation | What it does | |---|---| diff --git a/VISION.md b/VISION.md index 8078ecc..9b75352 100644 --- a/VISION.md +++ b/VISION.md @@ -547,7 +547,7 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [x] 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 - [x] 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 +- [x] 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 diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index ae3e598..8b47771 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -43,9 +43,27 @@ A canvas declares what medium it is on, separately from its genre (what it is fo - **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`, the only profile today) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. An explicit `width` or `height` wins for that side; the insets and the device stay the profile's. Passing `device` without `type: "mobile"`, or an unknown device, is an error. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. - **The renderer adds the insets to the root's own top and bottom padding**, at exact pixels even when the authored padding scales fluidly (padding of 32px or more), and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. - **`type: "web"` is the default.** An undeclared canvas is judged as web and its evaluate result says `source: "default"`. -- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root doesn't reserve both insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. +- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root doesn't reserve both insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. State variants keep the type they were created with, so re-declaring a base does not reach them: the result's `variantsOutOfSync` lists each variant whose type now differs (`{ state, canvasId, type }`); declare those too. - **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"` for both mobile pivots, `"explicit"`, or `"personality"` on web). A `$body` inherited from a workspace or project design system is preserved by default and listed in `designSystemConflicts`; pass `preserveInherited: false` to take the mobile body. -- **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off. A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`, so composing declarations can never switch a guardrail off. No rule ids are registered yet, so both lists are empty today; the mobile checks register theirs as they land. +- **Every evaluate result carries a `surface` report**: `{ type, device, source, rulesOn, rulesOff, conflicts }`. Each axis value (type, genre, preset) contributes a ruleset, and the active rules are their union. `rulesOn` and `rulesOff` name the rule ids the rulesets turned on and off. A rule both enabled and disabled stays on: it is listed in `rulesOn` and in `conflicts`, so composing declarations can never switch a guardrail off. On a web canvas `rulesOn` is `["mobile-artboard"]`; on a mobile canvas it lists every rule in [the mobile ruleset](#the-mobile-ruleset). No ruleset turns a rule off today. + +## The mobile ruleset + +On a `mobile` canvas these rules run. Each finding names its rule in a `rule` field, and the `surface` report lists the rules in `rulesOn`. Errors and warnings block the directive like any other finding; infos never do. + +- **`mobile-touch-target`** (usability, **error**). A control (toggle, checkbox, radio, select) whose touch target is below 44px in either dimension. The target extends to the control's row only when that row is a horizontal frame holding this one control: a toggle in a 56px settings row passes, and a lone checkbox inside a padded card is measured at its own size. On web canvases the 24px error and the 44px phone-width info are unchanged. +- **`mobile-handbuilt-target`** (usability, info). A frame shaped like a tap target (one icon, or one icon and a label of three words or fewer) whose box is below 44px. It never blocks, because a frame can't say it is decorative. If it is tappable, make it at least 44×44. +- **`mobile-safe-area`** (usability, warning). Content under the status bar or the home indicator. + - With `safeArea` on the root, the renderer reserves both insets, so the rule checks positioned (`absolute`) content only. + - Without it (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding or spacer frames. + - Padding of 32px or more scales down on a phone (48 renders about 13), so a spacer frame of the inset's height is the exact way to clear an inset by hand. + - Detailed mode checks rendered positions and is the authority. Fast mode cannot place absolute content inside an in-flow frame; it adds an info that says to run detailed mode. + - A screen taller than its artboard ends in the home-indicator inset even with `safeArea`, because the root's bottom padding does not extend past overflowing content. Only detailed mode catches this. End long screens with a 34px spacer frame. +- **`mobile-type-floor`** (typography, warning). Text below 11px; a text using `$body` below 16px; prose of five or more words below 13px. Long copy of twelve or more words at 13–15px is an info. Figures and their units are not words, so a metrics line such as `5.2 mi · 1h 12m` is fine at 12px. +- **`mobile-one-column`** (usability, info). A horizontal or grid frame that sets prose side by side: two or more children each holding a text of eight or more words. +- **`mobile-artboard`** (structure; on for every type). A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px, or one holding mobile chrome, warns until you declare it with `canvas_set_type`, either way. A canvas declared `web` with that shape gets an info instead. + +None of these rules has a mechanical fix, so `canvas_autofix` never proposes one. On a mobile canvas `canvas_stress` leaves strings of three characters or fewer (monograms, short labels) out of `long-text`. It records the type it ran under; after a type change the evaluate result's `stress.current` is false and the directive asks for a re-run, but a `FRAGILE` verdict still blocks. ## Responsive hints @@ -273,7 +291,7 @@ The happy path of one static frame with ideal data is half a design. Real UX liv **The evaluator demands them.** The `coverage` category warns — directive-blocking — when a base canvas carries data-bearing content with no designed states: a detected table demands `empty` + `loading`; a form (3+ input controls) demands `error`. (Table detection requires three matching horizontal rows — two stacked rows are a layout, not data — so the rare header-plus-one-row table goes undetected; name the frame `Table` to claim the shape explicitly.) Variant canvases and non-data screens are silent. The result's `coverage: { dataBearing, states, missing }` tells you where you stand, and `canvas_list` shows the designed states per screen. -**Then stress it.** `canvas_stress` re-renders the canvas under hostile-but-realistic content — `long-text` (×2.2 + an unbroken German compound), `i18n` (×1.4), `big-numbers` (`9` → `999+`, `$1.5M` → `$1,520,847.33`), `empty`, `many` (rows ×3) — and reports what broke, by node id: `clip` (info when a designed ellipsis is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; warning otherwise), `overflow-x`, `layout-shift` (an untouched node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent, like a full-height sidebar next to a growing table, are all exempt). Only *new* breakage counts. Fix findings with the [width strategies](#width-strategies) above — fluid widths, `minWidth` or `minHeight` floors, wrapping — or give a must-stay-single-line label `textOverflow: "ellipsis"` (the designed-truncation property: the clip downgrades to info because truncating is the design) — and re-run until `CLEAN`. A data screen goes to the user with its states designed and a `CLEAN` stress verdict, the same way it goes with a >95 score. `canvas_evaluate` holds you to it: once stress has recorded a verdict, the evaluate result carries a `stress` field (`{ verdict, warnings, current }`), and a `FRAGILE` verdict keeps the directive `NOT READY` until a full `CLEAN` run (all five perturbations) replaces it — a subset run can record `FRAGILE` but never clear it. The verdict lives in server memory, so a restart forgets it. +**Then stress it.** `canvas_stress` re-renders the canvas under hostile-but-realistic content — `long-text` (×2.2 + an unbroken German compound), `i18n` (×1.4), `big-numbers` (`9` → `999+`, `$1.5M` → `$1,520,847.33`), `empty`, `many` (rows ×3) — and reports what broke, by node id: `clip` (info when a designed ellipsis is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; warning otherwise), `overflow-x`, `layout-shift` (an untouched node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent, like a full-height sidebar next to a growing table, are all exempt). Only *new* breakage counts. Fix findings with the [width strategies](#width-strategies) above — fluid widths, `minWidth` or `minHeight` floors, wrapping — or give a must-stay-single-line label `textOverflow: "ellipsis"` (the designed-truncation property: the clip downgrades to info because truncating is the design) — and re-run until `CLEAN`. A data screen goes to the user with its states designed and a `CLEAN` stress verdict, the same way it goes with a >95 score. `canvas_evaluate` holds you to it: once stress has recorded a verdict, the evaluate result carries a `stress` field (`{ verdict, warnings, type, current }` — `current` is false after an edit or a type change since the run), and a `FRAGILE` verdict keeps the directive `NOT READY` until a full `CLEAN` run (all five perturbations) replaces it — a subset run can record `FRAGILE` but never clear it. The verdict lives in server memory, so a restart forgets it. ## Reviewing the set diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 1dfc114..2dcdc24 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -226,6 +226,14 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *FR-C7.* Evidence comes from the original Ridgeline canvas (`.framesmith/mobile-surface-attempt/ridgeline-today-ios.json`, untracked) evaluated as `mobile`. An existing check can only be disabled by id, and none has one, so a check that misfires on a correct mobile idiom gets an id in this slice and joins the mobile `disable` list with its evidence; the PR lists each, or states that none qualified. - *FR-C8.* `StressRecord` gains `type` (the resolved type, `resolveSurface(canvas).type`, never the source, so clearing a declared `web` is no change). The evaluate result's `stress` field gains `type`, and `current` is true only when both the version hash and the type match now; readiness is unchanged (a FRAGILE record blocks whatever `current` says). The directive's stale note says which changed: an edit since the run, or the canvas type. `applyPerturbation(name, root, { type })`: on `mobile`, long-text skips strings of three characters or fewer (FR-C8 accepts that short labels such as "Add" get only i18n). The per-primitive prop table (`button.label`, `list-row.title`, `list-row.subtitle`, `list-row.trailing.text`, `tab-bar.items[].label`, `nav-bar.title`, `nav-bar` action labels) is data in `stress.ts`, read generically, so slice D only adds node types; on mobile long-text skips `tab-bar` and `nav-bar` props, and i18n perturbs them. Two or more consecutive sibling `list-row`s are a list for `empty` (all rows of the run removed) and `many` (the run ×3), touched = their parent; tables still come from `extractInventory`, which does not change. Web runs are byte-identical to today. +- **As built (slice C).** Where the build departs from the contract above, or settles what it left open: + - *Words.* A whitespace token counts as a prose word when it holds a letter and no digit (the contract said "a letter", which counts `1h` and `12m` and makes `5.2 mi · 1h 12m · 340 ft` four words, not the two it promised). Figures with glued units are data. + - *Sizes.* `fontPx` reads a number, a `px` / `rem` / `em` string, and a `clamp()` as its minimum (the size a phone renders, so a fluid `$body` is not read as 14). A string the renderer refuses (it fails the renderer's safe-length pattern, an unresolved `$ref` among them) reads as the 14px fallback. Any other CSS expression the renderer passes through cannot be read, and the text is skipped. + - *FR-C2 on Ridgeline.* Evaluated as `mobile`, the original Ridgeline reports exactly its three 40×40 run-row icon tiles, as the corrected acceptance says. It reports no `mobile-type-floor` finding: its 11px tab labels are single words and its 13px meta lines are under five words, so FR-C2's parenthetical expecting one was wrong. Its hand-built status bar text warns under `mobile-safe-area` in both modes. + - *FR-C7.* None qualified. On Ridgeline evaluated as `mobile`, no existing check fires on a correct mobile idiom: the findings are APCA advisories, the coverage demands for the run list (a real list with no empty or loading variant), and the "no component instances" advisory, which FR-D4 resolves by counting primitives as reuse. The mobile `disable` list stays empty. + - *FR-C8.* The directive's `StressState` gains `ranAsType`, set when the type changed since the run, so the stale note names the earlier type instead of an edit. `nav-bar.title` is perturbed by long-text on mobile (a screen title can be long); only the tab labels and the nav bar's action labels are the fixed vocabulary. The primitive prop table and the list-row runs are in `stress.ts` and tested with synthetic nodes; they are documented with the primitives in slice D. + - *FR-C6.* `canvas_set_type` returns `variantsOutOfSync: [{ state, canvasId, type }]` when a variant's resolved type differs from the base's after the change. + **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). diff --git a/src/directive.ts b/src/directive.ts index 5b19f72..a5f04bf 100644 --- a/src/directive.ts +++ b/src/directive.ts @@ -14,10 +14,13 @@ import { appendFeedbackDirective } from './feedback.js'; export const SCORE_BAR = 95; /** The last canvas_stress verdict as the directive sees it. `current` = it ran - * on the canvas's present versionHash. */ + * on the canvas's present versionHash AND under its present type. */ export interface StressState { warnings: number; current: boolean; + /** Set when the canvas type changed since the run (Phase 30), so the note + * says so instead of blaming an edit. */ + ranAsType?: string; } export interface DirectiveParts { @@ -66,7 +69,9 @@ export function buildEvaluateDirective(result: EvaluationResult, openFeedback = ? '' : fragile.current ? ` The last canvas_stress run on this exact version was FRAGILE (${fragile.warnings} layout break(s)) — fix them with fluid widths / minWidth or minHeight floors / wrapping / textOverflow: "ellipsis", then re-run canvas_stress until CLEAN.` - : ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) on an earlier version — re-run canvas_stress to confirm your changes fixed them; only a CLEAN run clears this.`; + : fragile.ranAsType + ? ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) under the canvas's earlier type ("${fragile.ranAsType}") — re-run canvas_stress under the current type; only a CLEAN run clears this.` + : ` The last canvas_stress run was FRAGILE (${fragile.warnings} layout break(s)) on an earlier version — re-run canvas_stress to confirm your changes fixed them; only a CLEAN run clears this.`; let base: string; if (ready) { diff --git a/src/evaluate.ts b/src/evaluate.ts index e984698..742ec69 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -6,7 +6,8 @@ import { computeLayout, type LayoutRect } from './screenshot.js'; import { extractInventory, expandInstances, isDataLike } from './drift.js'; import { getIconSvg, parseIconRef } from './icons.js'; import { storedGenre } from './genre.js'; -import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, type SurfaceType } from './surfaces.js'; +import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, type SurfaceType } from './surfaces.js'; +import { checkArtboard, checkHandbuiltTargets, checkOneColumn, checkSafeAreaDetailed, checkSafeAreaFast, checkTypeFloor, paddingSides, type MobileContext } from './mobile-rules.js'; // --- Types --- @@ -31,6 +32,10 @@ export interface EvaluationIssue { category: string; /** Present only on `cliche` issues — which machine-made tell fired. */ tell?: ClicheTell; + /** Phase 30 — the ruleset rule that produced this issue (e.g. + * "mobile-touch-target"); one of the surface report's rulesOn. Absent on + * the checks that have no rule id. */ + rule?: string; /** Phase 25 slice D — set on issues from the DARK-theme evaluation pass * (color category dual-runs when a dark token layer exists). Absent = * light/default. */ @@ -1824,7 +1829,36 @@ function paddingBox(n: SceneNode): { v: number; h: number } { return { v: 0, h: 0 }; } -function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult { +/** Phase 30 slice C — a control's touch target on a mobile canvas: its own + * box, extended through up to two ancestors that are ROWS (horizontal frames + * holding exactly one control). A row's box per dimension is its numeric size + * or min size, a full width counting as the artboard, else the control plus + * the row's rendered padding. Unlike the web rule this never extends through + * a card that merely holds one control. */ +function mobileTarget( + node: SceneNode, w: number, h: number, parentOf: Map, + countControls: (n: SceneNode) => number, rootWidth: number, +): { w: number; h: number } { + let tw = w; + let th = h; + let cur = parentOf.get(node.id) ?? null; + for (let hop = 0; hop < 2 && cur; hop++) { + if (cur.type === 'frame' && cur.layout === 'horizontal' && countControls(cur) === 1) { + const pad = paddingSides(cur, rootWidth); + const side = (v: SceneNode['width'], min: SceneNode['minWidth'], own: number, padding: number): number => { + const full = v === '100%' || v === 'fill_container'; + const sized = typeof v === 'number' ? v : full ? rootWidth : own + padding; + return Math.max(sized, typeof min === 'number' ? min : 0); + }; + tw = Math.max(tw, side(cur.width, cur.minWidth, w, pad.left + pad.right)); + th = Math.max(th, side(cur.height, cur.minHeight, h, pad.top + pad.bottom)); + } + cur = parentOf.get(cur.id) ?? null; + } + return { w: tw, h: th }; +} + +function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor: number }): CheckResult { const issues: EvaluationIssue[] = []; let score = 100; @@ -1847,37 +1881,53 @@ function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult { let w = typeof node.width === 'number' ? node.width : def.w; let h = typeof node.height === 'number' ? node.height : def.h; - // The effective target extends to a padded ancestor row when the - // control is the only control inside it (the whole row is tappable by - // convention) — the WCAG 2.5.8 spacing model, applied structurally. - let cur = parentOf.get(node.id) ?? null; - for (let hop = 0; hop < 2 && cur && Math.min(w, h) < 24; hop++) { - if (countControls(cur) === 1) { - const pad = paddingBox(cur); - w += pad.h; - h += pad.v; + if (touch) { + const t = mobileTarget(node, w, h, parentOf, countControls, rootWidth); + if (Math.min(t.w, t.h) < touch.floor) { + score -= 25; + issues.push({ + category: 'usability', + rule: 'mobile-touch-target', + severity: 'error', + nodeId: node.id, + nodeName: node.name, + message: `${node.type} has a ${Math.round(t.w)}×${Math.round(t.h)}px touch target — below the ${touch.floor}px floor on a phone (Apple HIG; WCAG 2.5.5).`, + suggestion: `Grow the control, or put it alone in a row (a horizontal frame) at least ${touch.floor}px tall — a lone control's row is its target.`, + }); + } + } else { + // The effective target extends to a padded ancestor row when the + // control is the only control inside it (the whole row is tappable by + // convention) — the WCAG 2.5.8 spacing model, applied structurally. + let cur = parentOf.get(node.id) ?? null; + for (let hop = 0; hop < 2 && cur && Math.min(w, h) < 24; hop++) { + if (countControls(cur) === 1) { + const pad = paddingBox(cur); + w += pad.h; + h += pad.v; + } + cur = parentOf.get(cur.id) ?? null; } - cur = parentOf.get(cur.id) ?? null; - } - if (Math.min(w, h) < 24) { - score -= 25; - issues.push({ - category: 'usability', - severity: 'error', - nodeId: node.id, - nodeName: node.name, - message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — below the 24px floor (WCAG 2.5.8).`, - suggestion: 'Grow the control, or give its row enough padding that the whole row is the target (a lone control inherits its padded row\'s box).', - }); - } else if (touchFirst && Math.min(w, h) < 44) { - issues.push({ - category: 'usability', - severity: 'info', - nodeId: node.id, - nodeName: node.name, - message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — fine for pointer input, but this looks like a touch-first screen (44px is the comfortable floor there).`, - }); + if (Math.min(w, h) < 24) { + score -= 25; + issues.push({ + category: 'usability', + severity: 'error', + nodeId: node.id, + nodeName: node.name, + message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — below the 24px floor (WCAG 2.5.8).`, + suggestion: 'Grow the control, or give its row enough padding that the whole row is the target (a lone control inherits its padded row\'s box).', + }); + } else if (touchFirst && Math.min(w, h) < 44) { + issues.push({ + category: 'usability', + severity: 'info', + nodeId: node.id, + nodeName: node.name, + message: `${node.type} has a ${Math.round(w)}×${Math.round(h)}px effective hit target — fine for pointer input, but this looks like a touch-first screen (44px is the comfortable floor there).`, + }); + } } // Label association: a control the user can't name is a guess. The @@ -1924,6 +1974,14 @@ function checkUsability(entries: NodeEntry[], rootWidth: number): CheckResult { return { score: Math.max(0, score), issues }; } +/** Fold ruleset findings into a category result: −10 per warning (errors + * score themselves inside their check), infos free. */ +function withRuleIssues(result: CheckResult, extra: EvaluationIssue[]): CheckResult { + if (extra.length === 0) return result; + const warnings = extra.filter((i) => i.severity === 'warning').length; + return { score: Math.max(0, result.score - warnings * 10), issues: [...result.issues, ...extra] }; +} + const CATEGORY_WEIGHTS = new Map([ ['spacing', 20], ['color', 25], @@ -2051,6 +2109,13 @@ export async function evaluateCanvas( const preset = canvas.metadata?.provenance?.preset; const rules = composeRulesets(activeRulesets({ type: declared.type, genre: resolvedGenre.genre, preset })); const surface: SurfaceReport = { ...declared, rulesOn: rules.on, rulesOff: rules.off, conflicts: rules.conflicts }; + const ruleOn = new Set(rules.on); + const mobile: MobileContext = { + root: resolvedRoot, + profile: deviceProfile(declared.device ?? DEFAULT_DEVICE) ?? DEVICE_PROFILES[DEFAULT_DEVICE], + width: typeof resolvedRoot.width === 'number' ? resolvedRoot.width : 1440, + height: typeof resolvedRoot.height === 'number' ? resolvedRoot.height : 900, + }; const entries = buildTreeContext(resolvedRoot); // Also walk the unresolved tree for token usage stats const rawEntries = buildTreeContext(canvas.root); @@ -2101,22 +2166,26 @@ export async function evaluateCanvas( } // Phase 25 slice A — detailed-mode layout is computed ONCE and shared by // the consistency and typography (measure) checks. + // Phase 30 slice C — mobile-safe-area reads rendered rects too, deeper. let detailedLayout: LayoutRect[] | undefined; - if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography'))) { + const safeAreaDetailed = activeCategories.includes('usability') && ruleOn.has('mobile-safe-area'); + if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography') || safeAreaDetailed)) { const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; const html = renderToHtml(resolvedRoot, w, h, canvas); - detailedLayout = await computeLayout(html, undefined, 10, { width: w, height: h }); + detailedLayout = await computeLayout(html, undefined, ruleOn.has('mobile-safe-area') ? 25 : 10, { width: w, height: h }); } if (activeCategories.includes('typography')) { - results.set('typography', checkTypography(entries, mergedTokens, { + const typography = checkTypography(entries, mergedTokens, { rootWidth: typeof canvas.root.width === 'number' ? canvas.root.width : undefined, layout: detailedLayout, tables: extractInventory(resolvedRoot).tables, - })); + }); + const rawById = new Map(rawEntries.map((en) => [en.node.id, en.node])); + results.set('typography', withRuleIssues(typography, ruleOn.has('mobile-type-floor') ? checkTypeFloor(mobile, rawById) : [])); } if (activeCategories.includes('structure')) { - results.set('structure', checkStructure(rawEntries, canvas)); + results.set('structure', withRuleIssues(checkStructure(rawEntries, canvas), ruleOn.has('mobile-artboard') ? checkArtboard(mobile, declared) : [])); } if (activeCategories.includes('consistency')) { const consistencyCtx = { rawEntries, tokens: mergedTokens }; @@ -2127,8 +2196,14 @@ export async function evaluateCanvas( } } if (activeCategories.includes('usability')) { - const rootWidth = typeof resolvedRoot.width === 'number' ? resolvedRoot.width : 1440; - results.set('usability', checkUsability(entries, rootWidth)); + const touch = ruleOn.has('mobile-touch-target') ? { floor: mobile.profile.touchTarget } : undefined; + const usability = checkUsability(entries, mobile.width, touch); + const extra = [ + ...(ruleOn.has('mobile-handbuilt-target') ? checkHandbuiltTargets(mobile) : []), + ...(ruleOn.has('mobile-safe-area') ? (detailedLayout ? checkSafeAreaDetailed(mobile, detailedLayout) : checkSafeAreaFast(mobile)) : []), + ...(ruleOn.has('mobile-one-column') ? checkOneColumn(mobile) : []), + ]; + results.set('usability', withRuleIssues(usability, extra)); } let coverageReport: EvaluationResult['coverage']; diff --git a/src/index.ts b/src/index.ts index 5b0f6c5..715368a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render (no rule ids are registered yet, so the surface report's rulesOn is empty today; the mobile checks register theirs as they land): generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: prose side by side), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -140,7 +140,7 @@ const GOTCHAS = [ 'Every state, every string: a data screen is not done at one static frame with ideal data. Tables demand designed "empty" + "loading" state variants, forms demand "error" (the coverage category warns until they exist — each is one canvas_add_variant + one scaffold stamp: empty-state / skeleton-table / skeleton-card / skeleton-stat-card); skeleton blocks pulse only in the live viewer, never in screenshots. Skeleton EVERY data surface on a loading variant, not just the table — coverage flags a "half-designed" loading state when a region still shows live values beside skeletoned ones. Then canvas_stress before presenting: it renders hostile-but-realistic content (long-text / i18n / big-numbers / empty / many) and reports clips and overflows by node id — fix with fluid widths / minWidth or minHeight floors / wrapping, or textOverflow: "ellipsis" on a label that must stay single-line (that downgrades its clip to info instead of warning), and re-run until CLEAN. canvas_evaluate remembers the last stress verdict: after a FRAGILE run the directive stays NOT READY until a full CLEAN run (all five perturbations) replaces it — a subset run can record FRAGILE but never clear it.', 'Derive, don\'t hand-pick: generate_design_system (seed + REQUIRED personality: technical/editorial/soft/data-dense) writes the whole design language in one call — including $display/$heading/$title/$body/$label typography roles, $elevation.* shadow tokens (reference as shadow: "$elevation.raised"; the dark layer re-states depth), and $motion defaults. For targeted regeneration only: generate_scale gives a modular type + space scale from a ratio (craft defaults baked in; fluid clamp() optional); generate_color_system gives OKLCH ramps + a matched neutral + status colors + the semantic tokens the structures use, AA by construction, WITH a dark theme (sparse dark.colors layer) AND the color range — chart-1…chart-6 for series/segments/legend dots, plus the accent/success/warning/danger/neutral -tint layer for chips/tiles/badges (pair tint as fill with its base color as ink; AA by construction, dark included). Render dark via theme: "dark" on screenshot/screenshot_responsive/export; canvas_evaluate contrast-checks both themes automatically (dark failures point at the dark layer — never write a literal fix; APCA Lc is info-only, WCAG 2.2 gates). Literals that equal a token get re-attached by autofix; timing belongs in $motion tokens.', 'Beyond one canvas: bento/editorial compositions are REAL grid — layout: "grid" + gridColumns (count / fr-weight array / template string) + gridColumn/gridRow spans (a number means span N); responsive: "stack" collapses to one column on mobile. Never approximate tiles with nested flex. When a multi-screen module feels done, project_evaluate reviews the SET — radius-drift / accent-drift / token-adoption / copied-chrome / state-coverage findings, each naming its canvases — and mode "llm" judges up to 8 screens together for flow consistency (flowSkipped lists anything past the cap; keyless → full roll-up + a note). ADVISORY: only the per-canvas directives gate presenting.', - 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense; no mobile rule ids are registered yet), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed.', + 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. On mobile a control below 44px is an ERROR (mobile-touch-target) and content in the status-bar / home-indicator insets is a WARNING (mobile-safe-area): keep root safeArea, and end a screen taller than its artboard with a 34px spacer frame (detailed mode checks the scroll end). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', 'Gate integrity: a canvas describing a SHIPPED view is a contract — run canvas_check_drift against the live route BEFORE designing on it (findings: missing-in-page / missing-in-canvas / control-mismatch / table-mismatch), and reconcile deliberately: update the canvas, flag the implementation, or ask — never silently annotate a difference. canvas_sync_from_url answers "how much does it LOOK different" (pixel %); canvas_version makes approvals falsifiable — record { canvasId, versionHash } at approval time and check with expectedHash later (metadata/feedback never moves the hash; ANY inherited workspace/project token edit DOES, even to a token the canvas does not use). A legacy sha256: hash still checks but carries a warning — re-record it as the current sha256r: versionHash. CI can demand both: npx framesmith check-drift / verify exit 1 on failure.', ]; @@ -2019,7 +2019,7 @@ Stamping a genre does NOT move the canvas's versionHash (the hash covers design // --- canvas_set_type (Phase 30 slice B) --- server.tool( 'canvas_set_type', - `Declare what MEDIUM an existing canvas is on: "web" (a web page) or "mobile" (a phone app screen: it selects the mobile ruleset — the evaluate result's surface report lists the rule ids that run — and generate_design_system then gives it a 17px body). Writes metadata.surface only — it NEVER resizes the artboard and NEVER writes the root's safeArea, because either would reflow what you already placed. So a canvas made mobile here usually needs one more op: when the result carries a "hint", it names it — U("document", { safeArea: { top: 47, bottom: 34 } }) to reserve the device insets, and the device artboard size (390×844 for "iphone-390") if the root differs. For a NEW phone screen, prefer canvas_create({ type: "mobile" }), which does all of that at once. Pass type: null to clear the declaration (the canvas reads as an undeclared web canvas again, typeSource "default"). device ("iphone-390", the default) is only valid with "mobile". Clearing leaves the root (including any safeArea) as it is. The result echoes { canvasId, type, device, source, previous ({ type, device } or null), artboard { width, height }, hint?, versionHashUnchanged }. Like canvas_set_genre it does NOT move the versionHash (metadata is outside the hash; the hint's ops do move it, since they change the root) — but the type changes the surface report and the ruleset, so re-evaluate after it.`, + `Declare what MEDIUM an existing canvas is on: "web" (a web page) or "mobile" (a phone app screen: it selects the mobile ruleset — the evaluate result's surface report lists the rule ids that run — and generate_design_system then gives it a 17px body). Writes metadata.surface only — it NEVER resizes the artboard and NEVER writes the root's safeArea, because either would reflow what you already placed. So a canvas made mobile here usually needs one more op: when the result carries a "hint", it names it — U("document", { safeArea: { top: 47, bottom: 34 } }) to reserve the device insets, and the device artboard size (390×844 for "iphone-390") if the root differs. For a NEW phone screen, prefer canvas_create({ type: "mobile" }), which does all of that at once. Pass type: null to clear the declaration (the canvas reads as an undeclared web canvas again, typeSource "default"). device ("iphone-390", the default) is only valid with "mobile". Clearing leaves the root (including any safeArea) as it is. The result echoes { canvasId, type, device, source, previous ({ type, device } or null), artboard { width, height }, hint?, variantsOutOfSync?, versionHashUnchanged }. Each state variant keeps its own type (canvas_add_variant copies it at creation), so re-declaring a base does not reach its variants: variantsOutOfSync lists [{ state, canvasId, type }] for every variant whose type now differs — declare each one too. Like canvas_set_genre it does NOT move the versionHash (metadata is outside the hash; the hint's ops do move it, since they change the root) — but the type changes the surface report and the ruleset, so re-evaluate after it.`, { canvasId: z.string().describe('Canvas ID'), type: z.enum(['web', 'mobile']).nullable().describe('"web", "mobile", or null to clear the declaration'), @@ -2037,6 +2037,11 @@ server.tool( const surface = resolveSurface(canvas); const artboard = { width: canvas.root.width, height: canvas.root.height }; const profile = surface.device ? deviceProfile(surface.device) : undefined; + // Variants carry their own surface (copied at canvas_add_variant), so a + // re-declared base leaves them as they were: name the ones that now differ. + const variantsOutOfSync = (listCanvases().find((r) => r.id === canvasId)?.variants ?? []) + .map((v) => ({ ...v, type: resolveSurface(getCanvas(v.canvasId) ?? canvas).type })) + .filter((v) => v.type !== surface.type); const steps: string[] = []; const insets = usableSafeArea(canvas.root.safeArea); if (profile && (insets.top === undefined || insets.bottom === undefined)) { @@ -2054,6 +2059,7 @@ server.tool( previous: result.previous, artboard, ...(steps.length ? { hint: `canvas_set_type never touches the root. To finish making this a ${surface.device} screen: ${steps.join('; ')}.` } : {}), + ...(variantsOutOfSync.length ? { variantsOutOfSync } : {}), versionHashUnchanged: true, }, null, 2) }], }; @@ -2063,17 +2069,17 @@ server.tool( // --- canvas_evaluate --- server.tool( 'canvas_evaluate', - `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are warning/info severity — but EVERY tell blocks the directive regardless of severity. The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width artboards additionally get a 44px touch-first info), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node whose name resolves in neither bundled set — it would render as an empty slot, invisible in a screenshot: the suggestion names the spellings that do resolve (hyphen/underscore swap, or the missing material: prefix), and when exactly one does — or several do but only one matches the icon set the rest of the canvas uses — the issue carries a fix canvas_autofix applies. Icons inside component instances are checked too; they point at the component definition and carry no fix op. Modes: + `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are warning/info severity — but EVERY tell blocks the directive regardless of severity. The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width WEB artboards additionally get a 44px touch-first info; on a canvas declared mobile, 44px is an ERROR instead — see the surface paragraph), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node whose name resolves in neither bundled set — it would render as an empty slot, invisible in a screenshot: the suggestion names the spellings that do resolve (hyphen/underscore swap, or the missing material: prefix), and when exactly one does — or several do but only one matches the icon set the rest of the canvas uses — the issue carries a fix canvas_autofix applies. Icons inside component instances are checked too; they point at the component definition and carry no fix op. Modes: - "fast": JSON-only, <100ms, deterministic heuristics only. - "detailed": adds Puppeteer-based pixel overlap detection in the consistency category, laid out at the canvas's artboard size. - "llm": fast-mode heuristics plus a vision-model critique against a FIXED rubric (provider picked from FRAMESMITH_LLM_PROVIDER env var, or whichever of ANTHROPIC_API_KEY / OPENAI_API_KEY is set). Adds an "llmCritique" field: { rubric: { hierarchy, execution, specificity, restraint, variety } each {score 1-5, rationale}, score (0-100 derived), summary, suggestions, needsRevision, failingAxes }. The verdict is stamped on the canvas (metadata.critique) + the per-project build log for auditability. Cost: one paid API call per invocation. To CLOSE the loop and auto-fix failing axes, use canvas_revise. -Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, current }) appears once canvas_stress has recorded a verdict for this canvas — current says whether that run was on the canvas's present versionHash. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when current is false the directive says to re-run stress to confirm your fix. The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. +Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, type, current }) appears once canvas_stress has recorded a verdict for this canvas — type is the canvas type the run used; current says whether that run was on the canvas's present versionHash AND type. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when current is false the directive says to re-run stress, naming whether an edit or a type change made the run stale (the FRAGILE verdict still blocks either way). The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional depth (a vision-model rubric critique — composition, hierarchy, polish) on top; it requires an ANTHROPIC_API_KEY or OPENAI_API_KEY and fails gracefully without one — the FULL heuristic result still returns with an llmNote explaining what's missing, and the heuristic directive alone decides. With a key it still decides: a needsRevision verdict never blocks presenting — run canvas_revise, and if it stops with axes still failing, present and name them. Screens whose figures are the user's own data: declare genre: "data" so the design's own realistic figures aren't flagged as fabricated; transactional screens (cart/checkout/order/billing) pass genre: "commerce" for the same reason (see the genre param). The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. -The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }.`, +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame; fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end long screens with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px or holding mobile chrome WARNS until canvas_set_type declares it; declared web with that shape gets an info). None has an autofix.`, { canvasId: z.string().describe('Canvas ID to evaluate'), mode: z.enum(['fast', 'detailed', 'llm']).default('fast').describe('"fast" = JSON-only (<100ms), "detailed" = + Puppeteer layout checks, "llm" = fast + vision-model rubric critique'), @@ -2124,12 +2130,14 @@ The result's "surface" field (always present) reports the canvas TYPE and the ru // testable rather than inline here. const openFeedback = openFeedbackCount(canvas); const stressRun = lastStressRun(canvasId); + const sameType = stressRun?.type === result.surface.type; const stress = stressRun && { verdict: stressRun.warnings === 0 ? 'CLEAN' : 'FRAGILE', warnings: stressRun.warnings, - current: stressRun.versionHash === canvasVersionHash(canvas), + type: stressRun.type, + current: sameType && stressRun.versionHash === canvasVersionHash(canvas), }; - const { directive } = buildEvaluateDirective(result, openFeedback, stress); + const { directive } = buildEvaluateDirective(result, openFeedback, stress && { ...stress, ...(sameType ? {} : { ranAsType: stressRun!.type }) }); return { content: [{ type: 'text', text: JSON.stringify({ ...result, ...(llmNote ? { llmNote } : {}), ...(openFeedback > 0 ? { openFeedback } : {}), ...(stress ? { stress } : {}), directive }, null, 2) }], @@ -2140,7 +2148,7 @@ The result's "surface" field (always present) reports the canvas TYPE and the ru // --- canvas_autofix --- server.tool( 'canvas_autofix', - `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Also: an unknown icon name with exactly one spelling that resolves (hyphens ↔ underscores, or a missing material: prefix — ties broken by the icon set the canvas already uses) → rewritten to that name. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate. The result carries the same "genre" and "surface" reports as canvas_evaluate.`, + `Run canvas_evaluate in fast mode and return the subset of issues that have a mechanically derived fix (off-scale spacing — gap, scalar AND array-form padding (the fix writes the complete snapped array) — → snap to scale; missing layout on multi-child frame → set vertical; recoverable WCAG contrast failure → switch text to #000 or #FFF, whichever wins; default-purple accent → swap to a neutral accent; fake-chrome strip → delete; pure-black ink → soften to off-black; a literal fill/stroke/color/cornerRadius that exactly equals a SINGLE token's value → re-pointed to the \$ref — a value shared by multiple tokens is reported with candidates but never auto-fixed). By default this PROPOSES: each fix carries a ready-to-paste \`batch_design\` op string and a one-line rationale. Also: an unknown icon name with exactly one spelling that resolves (hyphens ↔ underscores, or a missing material: prefix — ties broken by the icon set the canvas already uses) → rewritten to that name. Pass apply: true to also WRITE the fixes to the canvas in the same call — the result then reports applied/failed per op. Taste-dependent cliche tells (gradient/glow overuse, the hanging header, fabricated content, eyebrow-rhythm overuse, slop copy, mixed radius systems, competing accents) carry a suggestion but no auto-fix — call canvas_evaluate to see those. Coverage warnings (missing empty/loading/error state variants) also have no mechanical fix: designing a state is judgment — the suggestion names the canvas_add_variant + scaffold path. Usability findings (hit-target size, label association, vague action copy) and the mobile ruleset's findings (touch targets, safe areas, type floor, artboard) likewise have no mechanical fix — growing a control, writing a label, or renaming a button is a judgment call; call canvas_evaluate and fix them via batch_design. Closes the generator-evaluator loop: generate with batch_design → autofix (apply: true) → re-evaluate. The result carries the same "genre" and "surface" reports as canvas_evaluate.`, { canvasId: z.string().describe('Canvas ID to autofix'), categories: z.array(z.enum(['spacing', 'color', 'typography', 'structure', 'consistency', 'cliche', 'coverage', 'usability'])) @@ -2212,7 +2220,7 @@ server.tool( Perturbations (default: all): long-text (non-data text ×2.2 plus one long unbroken token — the wrap-breaker), i18n (×1.4 — the German/Finnish expansion rule), big-numbers (data-like text to its widest realistic form: "9" → "999+", "$1.5M" → "$1,520,847.33"), empty (detected tables lose their data rows), many (data rows ×3). Tables are found with the same inventory drift and coverage use. -Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job, or when it is just the page growing taller than its fixed artboard, which scrolls on a real page; WARNING otherwise), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. The verdict is remembered for canvas_evaluate: any run with warnings records FRAGILE (the directive stays NOT READY), and only a run of the full default set records CLEAN — a passing subset never clears an earlier FRAGILE. Chrome required; ~1 render per perturbation.`, +Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job, or when it is just the page growing taller than its fixed artboard, which scrolls on a real page; WARNING otherwise), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. The verdict is remembered for canvas_evaluate, with the canvas type it ran under: any run with warnings records FRAGILE (the directive stays NOT READY), and only a run of the full default set records CLEAN — a passing subset never clears an earlier FRAGILE. On a mobile canvas, long-text leaves strings of three characters or fewer alone (monograms, short labels). Chrome required; ~1 render per perturbation.`, { canvasId: z.string().describe('Canvas to stress'), perturbations: z.array(z.enum(['long-text', 'i18n', 'big-numbers', 'empty', 'many'])).optional() @@ -2232,6 +2240,7 @@ Finding kinds: clip (content cut off — INFO when a designed text-overflow elli // Instance-expanded so stamped components stress like everything else; // the expanded tree is the base for BOTH sides so node ids align. const expandedBase = expandInstances(canvas.root, canvas); + const { type } = resolveSurface(canvas); const baselineHtml = renderToHtml(resolveVariables(structuredClone(expandedBase), merged), w, h, canvas, renderOpts); const baselineLayout = await computeLayout(baselineHtml, undefined, 25, { width: w, height: h }); @@ -2242,7 +2251,7 @@ Finding kinds: clip (content cut off — INFO when a designed text-overflow elli let infos = 0; for (const name of names) { - const { root, touched } = applyPerturbation(name, expandedBase); + const { root, touched } = applyPerturbation(name, expandedBase, { type }); if (touched.length === 0) { perturbationResults.push({ name, skipped: 'nothing to perturb (no matching content)' }); continue; @@ -2259,7 +2268,7 @@ Finding kinds: clip (content cut off — INFO when a designed text-overflow elli } const versionHash = canvasVersionHash(canvas); - recordStressRun(canvasId, { versionHash, warnings }, PERTURBATION_NAMES.every((n) => names.includes(n))); + recordStressRun(canvasId, { versionHash, warnings, type }, PERTURBATION_NAMES.every((n) => names.includes(n))); const verdict = warnings === 0 ? `CLEAN — the design held its layout under ${names.length} content perturbation(s).${infos ? ` ${infos} designed truncation(s) engaged (info).` : ''}` : `FRAGILE — ${warnings} layout break(s) under hostile content. Fix with fluid widths / minWidth or minHeight floors / wrapping (see width strategies), then re-run.`; diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts new file mode 100644 index 0000000..0c1e7ff --- /dev/null +++ b/src/mobile-rules.ts @@ -0,0 +1,411 @@ +// Phase 30 slice C — the mobile ruleset's checks. Each runs only when its rule +// id is in the composed rulesOn (src/surfaces.ts RULESETS): the type `mobile` +// row turns most of them on, the `base` row turns on mobile-artboard for every +// type. Everything here measures what the renderer draws on a phone: padding +// at its rendered (fluid) size, insets from the root's safeArea or the device +// profile, sizes from the resolved tree. + +import type { SceneNode } from './types.js'; +import type { EvaluationIssue } from './evaluate.js'; +import type { LayoutRect } from './screenshot.js'; +import { renderedPaddingPx } from './renderer.js'; +import { usableSafeArea, type DeviceProfile, type SurfaceDeclaration } from './surfaces.js'; + +/** Fixed-vocabulary mobile chrome primitives (slice D adds the node types). */ +const CHROME_TYPES = new Set(['status-bar', 'home-indicator', 'nav-bar', 'tab-bar', 'sheet']); +const CONTENT_TYPES = new Set(['icon', 'toggle', 'checkbox', 'radio', 'select', 'button', 'list-row', 'instance']); +const POSITIONED = new Set(['absolute']); + +/** A phone-width artboard: narrower than this reads as a phone screen. */ +export const PHONE_WIDTH = 500; +const PROSE_FLOOR = 13; +const PROSE_WORDS = 5; +const LONG_COPY_WORDS = 12; +const COLUMN_WORDS = 8; +const LABEL_WORDS = 3; +/** What the renderer draws for a font-size it can't use (responsiveFontSize). */ +const FALLBACK_FONT_PX = 14; +const SAFE_CSS_LENGTH = /^[a-zA-Z0-9\s().,%*+\/-]+$/; + +export interface MobileContext { + /** The resolved tree ($tokens substituted). */ + root: SceneNode; + profile: DeviceProfile; + /** Artboard size (root width × height, else the 1440 × 900 render defaults). */ + width: number; + height: number; +} + +const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function issue(category: string, rule: string, severity: EvaluationIssue['severity'], node: SceneNode, message: string, suggestion?: string): EvaluationIssue { + return { category, rule, severity, nodeId: node.id, nodeName: node.name, message, ...(suggestion ? { suggestion } : {}) }; +} + +// ── measurement ──────────────────────────────────────────────────────────── + +/** Rendered padding per side at the artboard width (CSS shorthand order). */ +export function paddingSides(node: SceneNode, viewportWidth: number): { top: number; right: number; bottom: number; left: number } { + const p = node.padding; + const px = (v: unknown) => (isNum(v) ? renderedPaddingPx(v, viewportWidth) : 0); + if (isNum(p)) { const v = px(p); return { top: v, right: v, bottom: v, left: v }; } + if (!Array.isArray(p) || p.length === 0) return { top: 0, right: 0, bottom: 0, left: 0 }; + const [a, b = a, c = a, d = b] = p; + return { top: px(a), right: px(b), bottom: px(c), left: px(d) }; +} + +/** A text's rendered size in px, or undefined when it has none (the document + * default applies). Unusable strings — an unresolved $ref among them — render + * at the renderer's 14px fallback; a clamp() reads as its minimum, the size it + * takes on a phone; any other CSS expression can't be read. */ +export function fontPx(size: SceneNode['fontSize']): number | undefined { + if (size === undefined) return undefined; + if (isNum(size)) return size; + const s = String(size).trim(); + if (!SAFE_CSS_LENGTH.test(s)) return FALLBACK_FONT_PX; + const clamp = /^clamp\(\s*([^,]+),/.exec(s); + const length = (clamp ? clamp[1] : s).trim(); + const m = /^(-?[\d.]+)(px|rem|em)?$/.exec(length); + if (!m) return undefined; + return m[2] === 'rem' || m[2] === 'em' ? Number(m[1]) * 16 : Number(m[1]); +} + +/** Prose words: whitespace tokens holding a letter and no digit, plus one word + * per two CJK characters (CJK prose has no spaces). Figures and their glued + * units ("1h", "12m") are data, so "5.2 mi · 1h 12m · 340 ft" is two words. */ +export function proseWords(content: string): number { + let words = 0; + for (const token of content.trim().split(/\s+/)) { + const cjk = token.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu)?.length ?? 0; + if (cjk > 0) words += Math.ceil(cjk / 2); + else if (/\p{L}/u.test(token) && !/\d/.test(token)) words += 1; + } + return words; +} + +const textOf = (n: SceneNode): string | undefined => + n.type === 'text' && typeof n.content === 'string' && n.content.trim() ? n.content : undefined; + +const isPositioned = (n: SceneNode): boolean => n.position !== undefined && POSITIONED.has(n.position); + +/** Content per the spec's Terms: text, icons, controls, the tappable + * primitives, instances (opaque — they may hold content), and images narrower + * than the artboard. Everything else is background. */ +function isContent(n: SceneNode, ancestors: SceneNode[], ctx: MobileContext): boolean { + if (n.type === 'text') return textOf(n) !== undefined; + if (CONTENT_TYPES.has(n.type)) return true; + if (n.type !== 'image') return false; + if (isNum(n.width)) return n.width < ctx.width; + // A non-numeric width spans the artboard only if nothing above it insets it. + return ancestors.some((a) => { const p = paddingSides(a, ctx.width); return p.left + p.right > 0; }); +} + +/** Whether any in-flow content sits beneath `n` (positioned subtrees are + * placed on their own). */ +function holdsFlowContent(n: SceneNode, ancestors: SceneNode[], ctx: MobileContext): boolean { + const next = [...ancestors, n]; + return (n.children ?? []).some((c) => !isPositioned(c) && (isContent(c, next, ctx) || holdsFlowContent(c, next, ctx))); +} + +// ── mobile-safe-area ─────────────────────────────────────────────────────── + +interface Insets { top: number; bottom: number; topReserved: boolean; bottomReserved: boolean } + +function insetsFor(ctx: MobileContext): Insets { + const usable = ctx.root.type === 'document' ? usableSafeArea(ctx.root.safeArea) : {}; + return { + top: usable.top ?? ctx.profile.safeArea.top, + bottom: usable.bottom ?? ctx.profile.safeArea.bottom, + topReserved: usable.top !== undefined, + bottomReserved: usable.bottom !== undefined, + }; +} + +interface Hit { node: SceneNode; offset: number } + +/** The structural walk: the first in-flow content from one edge of + * `container`, and its offset from that edge. `offset` is where the + * container's content box starts (padding already added). Alignment inside + * fixed-height frames is ignored, so offsets are under-counted, never over. */ +function firstContent(container: SceneNode, offset: number, edge: 'top' | 'bottom', ancestors: SceneNode[], ctx: MobileContext): Hit | null { + const next = [...ancestors, container]; + const children = (container.children ?? []).filter((c) => !isPositioned(c)); + const ordered = edge === 'top' ? children : [...children].reverse(); + const visit = (child: SceneNode, at: number): Hit | null => { + if (isContent(child, next, ctx)) return { node: child, offset: at }; + if (!holdsFlowContent(child, next, ctx)) return null; + const pad = paddingSides(child, ctx.width); + return firstContent(child, at + (edge === 'top' ? pad.top : pad.bottom), edge, next, ctx); + }; + + if (container.layout === 'horizontal' || container.layout === 'grid') { + let best: Hit | null = null; + for (const child of ordered) { + const hit = visit(child, offset); + if (hit && (!best || hit.offset < best.offset)) best = hit; + } + return best; + } + + const gap = isNum(container.gap) ? container.gap : 0; + let at = offset; + for (const child of ordered) { + const hit = visit(child, at); + if (hit) return hit; + // A spacer: background, or a frame with nothing in flow beneath it. + const pad = paddingSides(child, ctx.width); + at += Math.max(isNum(child.height) ? child.height : 0, isNum(child.minHeight) ? child.minHeight : 0, pad.top + pad.bottom) + gap; + } + return null; +} + +function textHeight(n: SceneNode): number { + if (isNum(n.height)) return n.height; + const size = fontPx(n.fontSize) ?? 16; + const lh = isNum(n.lineHeight) ? (n.lineHeight <= 3 ? n.lineHeight * size : n.lineHeight) : 1.2 * size; + return lh; +} + +const SAFE_AREA_OP = (p: DeviceProfile) => `U("document", { safeArea: { top: ${p.safeArea.top}, bottom: ${p.safeArea.bottom} } })`; + +/** Fast mode: the root's reservation (structural walk on each side without a + * usable safeArea) plus every positioned node fast mode can place. */ +export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const insets = insetsFor(ctx); + const rootPad = paddingSides(ctx.root, ctx.width); + const rule = 'mobile-safe-area'; + + for (const edge of ['top', 'bottom'] as const) { + if (edge === 'top' ? insets.topReserved : insets.bottomReserved) continue; + const inset = insets[edge]; + const hit = firstContent(ctx.root, edge === 'top' ? rootPad.top : rootPad.bottom, edge, [], ctx); + if (hit && hit.offset < inset) { + issues.push(issue('usability', rule, 'warning', hit.node, + `"${hit.node.name ?? hit.node.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, + `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)} (the renderer adds them to the root's padding). For a deliberate full-bleed screen, pad the ${edge} so content clears ${inset}px — padding of 32px or more scales down on a phone, so use a spacer frame of that height.`)); + } + } + + // Positioned content, placed through a chain of absolute ancestors with a + // numeric y up to the root. Anything else has an in-flow containing block + // whose position fast mode doesn't know. + let unplaceable = false; + const visit = (n: SceneNode, ancestors: SceneNode[], placedY: Map): void => { + if (isPositioned(n)) { + const parent = ancestors[ancestors.length - 1]; + const base = parent === ctx.root ? 0 : placedY.get(parent); + const y = base !== undefined && isNum(n.y) ? base + n.y : undefined; + if (y === undefined) { + if (isContent(n, ancestors, ctx) || holdsFlowContent(n, ancestors, ctx)) unplaceable = true; + } else { + placedY.set(n, y); + checkPlaced(n, y, ancestors); + } + } + for (const c of n.children ?? []) visit(c, [...ancestors, n], placedY); + }; + + const checkPlaced = (n: SceneNode, y: number, ancestors: SceneNode[]): void => { + const bottomEdge = ctx.height - insets.bottom; + if (isContent(n, ancestors, ctx)) { + const h = n.type === 'text' ? textHeight(n) : isNum(n.height) ? n.height : 0; + if (y < insets.top || y + h > bottomEdge) { + const edge = y < insets.top ? 'top' : 'bottom'; + issues.push(issue('usability', rule, 'warning', n, + `Positioned "${n.name ?? n.id}" sits in the ${edge} ${edge === 'top' ? insets.top : insets.bottom}px inset (y ${Math.round(y)}${h ? `, ${Math.round(h)}px tall` : ''} on a ${ctx.height}px screen) — under the ${edge === 'top' ? 'status bar' : 'home indicator'}.`, + `Move it to y ≥ ${insets.top}${edge === 'bottom' ? ` and keep its bottom above ${bottomEdge}` : ''}.`)); + } + return; + } + if (!holdsFlowContent(n, ancestors, ctx)) return; + const pad = paddingSides(n, ctx.width); + const top = firstContent(n, y + pad.top, 'top', ancestors, ctx); + const bottom = isNum(n.height) ? firstContent(n, ctx.height - (y + n.height) + pad.bottom, 'bottom', ancestors, ctx) : null; + for (const [hit, edge, inset] of [[top, 'top', insets.top], [bottom, 'bottom', insets.bottom]] as const) { + if (hit && hit.offset < inset) { + issues.push(issue('usability', rule, 'warning', hit.node, + `"${hit.node.name ?? hit.node.id}" inside positioned "${n.name ?? n.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, + `Pad "${n.name ?? n.id}" so its content clears ${inset}px from the ${edge} (a spacer frame keeps its size; padding of 32px or more scales down on a phone).`)); + } + } + }; + + visit(ctx.root, [], new Map()); + if (unplaceable) { + issues.push(issue('usability', rule, 'info', ctx.root, + 'Some positioned content sits inside an in-flow frame, so fast mode cannot place it against the safe areas.', + 'Run canvas_evaluate with mode: "detailed" — it checks rendered positions.')); + } + return issues; +} + +/** Detailed mode: the rendered rects. Content in the top inset at scroll 0, + * positioned content in the artboard's bottom inset, and in-flow content in + * the last bottom-inset px of the document (a fixed-height root does not + * carry its end padding past overflowing content). */ +export function checkSafeAreaDetailed(ctx: MobileContext, layout: LayoutRect[]): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const insets = insetsFor(ctx); + const rects = new Map(); + (function flat(list: LayoutRect[]) { for (const r of list) { rects.set(r.nodeId, r); if (r.children) flat(r.children); } })(layout); + let docBottom = ctx.height; + for (const r of rects.values()) docBottom = Math.max(docBottom, r.y + r.height); + + const visit = (n: SceneNode, positioned: boolean): void => { + const r = rects.get(n.id); + const content = n.type === 'image' + ? r !== undefined && r.width < ctx.width - 1 + : n.type === 'text' ? textOf(n) !== undefined : CONTENT_TYPES.has(n.type); + if (content) { + if (r && r.width > 0 && r.height > 0) { + const bottom = r.y + r.height; + let where: string | undefined; + if (r.y < insets.top && bottom > 0) where = `the top ${insets.top}px inset (y ${r.y})`; + else if (positioned && bottom > ctx.height - insets.bottom && r.y < ctx.height) where = `the bottom ${insets.bottom}px inset of the screen (bottom edge ${bottom} on a ${ctx.height}px screen)`; + else if (!positioned && bottom > docBottom - insets.bottom) where = `the bottom ${insets.bottom}px inset at the end of the scroll (bottom edge ${bottom} of a ${docBottom}px document)`; + if (where) { + issues.push(issue('usability', 'mobile-safe-area', 'warning', n, + `"${n.name ?? n.id}" renders in ${where} — under the ${where.startsWith('the top') ? 'status bar' : 'home indicator'}.`, + insets.topReserved && insets.bottomReserved + ? 'Keep positioned content inside the safe area, and end long screens with bottom padding or a spacer of the inset height.' + : `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)}.`)); + } + } + return; // a content node's own descendants are part of it + } + for (const c of n.children ?? []) visit(c, positioned || isPositioned(c)); + }; + visit(ctx.root, false); + return issues; +} + +// ── mobile-type-floor ────────────────────────────────────────────────────── + +/** `rawById` maps node id → unresolved node, so a `$body` reference is + * visible after resolution substituted it. */ +export function checkTypeFloor(ctx: MobileContext, rawById: Map): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const { smallestText, bodyFloor } = ctx.profile; + const rule = 'mobile-type-floor'; + (function walk(n: SceneNode) { + const content = textOf(n); + const size = content !== undefined ? fontPx(n.fontSize) : undefined; + if (content !== undefined && size !== undefined) { + const words = proseWords(content); + const label = `"${content.length > 40 ? `${content.slice(0, 37)}…` : content}"`; + if (size < smallestText) { + issues.push(issue('typography', rule, 'warning', n, `${label} is ${size}px — below the ${smallestText}px smallest text on a phone.`, `Raise it to at least ${smallestText}px.`)); + } else if (rawById.get(n.id)?.fontSize === '$body' && size < bodyFloor) { + issues.push(issue('typography', rule, 'warning', n, `${label} uses the body role at ${size}px — below the ${bodyFloor}px body floor on a phone.`, `Raise $body to at least ${bodyFloor}px (generate_design_system on a mobile canvas gives 17, or 16 for data-dense).`)); + } else if (size < PROSE_FLOOR && words >= PROSE_WORDS) { + issues.push(issue('typography', rule, 'warning', n, `${label} sets ${words} words at ${size}px — prose below ${PROSE_FLOOR}px (iOS Footnote) is hard to read on a phone.`, `Set prose at ${PROSE_FLOOR}px or more; keep sub-${PROSE_FLOOR}px sizes for short labels.`)); + } else if (size < bodyFloor && words >= LONG_COPY_WORDS) { + issues.push(issue('typography', rule, 'info', n, `${label} sets ${words} words at ${size}px — long copy below the ${bodyFloor}px body size.`, 'Long copy reads better at body size.')); + } + } + n.children?.forEach(walk); + })(ctx.root); + return issues; +} + +// ── mobile-one-column ────────────────────────────────────────────────────── + +export function checkOneColumn(ctx: MobileContext): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const holdsProse = (n: SceneNode): boolean => + (textOf(n) !== undefined && proseWords(n.content as string) >= COLUMN_WORDS) || (n.children ?? []).some(holdsProse); + (function walk(n: SceneNode) { + if (n.type === 'frame' && (n.layout === 'horizontal' || n.layout === 'grid') + && (n.children ?? []).filter(holdsProse).length >= 2) { + issues.push(issue('usability', 'mobile-one-column', 'info', n, + `"${n.name ?? n.id}" sets prose side by side — a phone screen reads as one column.`, + 'Stack these blocks vertically (layout: "vertical").')); + } + n.children?.forEach(walk); + })(ctx.root); + return issues; +} + +// ── mobile-handbuilt-target ──────────────────────────────────────────────── + +/** Frames shaped like a tap target (one icon, or one icon plus a short label) + * whose known box is below the touch floor. Info only: a frame cannot say it + * is decorative, so gating it would leave no honest fix. */ +export function checkHandbuiltTargets(ctx: MobileContext): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const floor = ctx.profile.touchTarget; + const shape = (n: SceneNode): { icon: SceneNode } | null => { + const icons: SceneNode[] = []; + const texts: SceneNode[] = []; + let other = false; + (function walk(x: SceneNode, ancestors: SceneNode[]) { + for (const c of x.children ?? []) { + if (c.type === 'icon') icons.push(c); + else if (textOf(c) !== undefined) texts.push(c); + else if (isContent(c, [...ancestors, x], ctx)) other = true; + walk(c, [...ancestors, x]); + } + })(n, []); + if (other || icons.length !== 1 || texts.length > 1) return null; + if (texts.length === 1 && proseWords(texts[0].content as string) > LABEL_WORDS) return null; + return { icon: icons[0] }; + }; + const dim = (n: SceneNode, axis: 'width' | 'height', icon: SceneNode, iconOnly: boolean): number | undefined => { + const v = n[axis]; + if (isNum(v)) return v; + if (v !== undefined || !iconOnly) return undefined; + const pad = paddingSides(n, ctx.width); + return (isNum(icon.iconSize) ? icon.iconSize : 24) + (axis === 'width' ? pad.left + pad.right : pad.top + pad.bottom); + }; + (function walk(n: SceneNode) { + if (n.type === 'frame' && n !== ctx.root) { + const s = shape(n); + if (s) { + const iconOnly = !(n.children ?? []).some(function hasText(c: SceneNode): boolean { return textOf(c) !== undefined || (c.children ?? []).some(hasText); }); + const w = dim(n, 'width', s.icon, iconOnly); + const h = dim(n, 'height', s.icon, iconOnly); + if ((w !== undefined && w < floor) || (h !== undefined && h < floor)) { + issues.push(issue('usability', 'mobile-handbuilt-target', 'info', n, + `"${n.name ?? n.id}" looks like a hand-built tap target (${w ?? '?'}×${h ?? '?'}px) — below the ${floor}px touch floor if it is tappable, and the touch-target rule can't check a frame.`, + `If it is tappable, make it at least ${floor}×${floor}px. If it is decorative, ignore this.`)); + } + return; // the outermost tap-shaped frame is the target + } + } + n.children?.forEach(walk); + })(ctx.root); + return issues; +} + +// ── mobile-artboard ──────────────────────────────────────────────────────── + +export function checkArtboard(ctx: MobileContext, surface: SurfaceDeclaration): EvaluationIssue[] { + const { width, height, profile, root } = ctx; + const rule = 'mobile-artboard'; + if (surface.type === 'mobile') { + if (width >= PHONE_WIDTH) { + return [issue('structure', rule, 'warning', root, + `This canvas is declared mobile but its artboard is ${width}px wide — a phone screen is ${profile.width}px.`, + `Resize it: U("document", { width: ${profile.width}, height: ${profile.height} }) — or canvas_set_type({ type: "web" }) if it is a web page.`)]; + } + if (width !== profile.width || height !== profile.height) { + return [issue('structure', rule, 'info', root, + `The artboard is ${width}×${height}; the ${profile.id} device is ${profile.width}×${profile.height}.`, + `U("document", { width: ${profile.width}, height: ${profile.height} }) matches the device.`)]; + } + return []; + } + let chrome: SceneNode | undefined; + (function walk(n: SceneNode) { if (!chrome && CHROME_TYPES.has(n.type)) chrome = n; n.children?.forEach(walk); })(root); + if (width >= PHONE_WIDTH && !chrome) return []; + const why = chrome ? `it contains mobile chrome ("${chrome.name ?? chrome.type}")` : `its artboard is ${width}px wide`; + if (surface.source === 'default') { + return [issue('structure', rule, 'warning', root, + `This canvas looks like a phone screen (${why}) but never declared its type, so it is judged as a web page.`, + 'Declare it: canvas_set_type({ type: "mobile" }) for a phone app screen (then add the safe-area insets its hint names), or canvas_set_type({ type: "web" }) for a responsive web page designed at this width.')]; + } + return [issue('structure', rule, 'info', root, + `Declared web, but ${why}. A phone app screen should declare mobile so the mobile ruleset judges it.`, + 'canvas_set_type({ type: "mobile" }) if this is a phone app screen.')]; +} diff --git a/src/renderer.ts b/src/renderer.ts index 29bc26b..251940c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1064,6 +1064,15 @@ function paddingSides(padding: SceneNode['padding']): { top: string; bottom: str return { top: '0px', bottom: '0px' }; } +/** The px a padding value renders at on a viewport `viewportWidth` wide — + * responsivePadding's clamp() evaluated. Phase 30 slice C: the mobile rules + * measure rendered padding, since a 48px padding on a 390 artboard renders 13. */ +export function renderedPaddingPx(value: number, viewportWidth: number): number { + if (value < PADDING_SCALE_MIN) return value; + const min = Math.max(8, Math.round(value * 0.25)); + return Math.min(value, Math.max(min, (value / DESIGN_WIDTH) * viewportWidth)); +} + function responsivePadding(value: number): string { if (value < PADDING_SCALE_MIN) return `${value}px`; const min = Math.max(8, Math.round(value * 0.25)); diff --git a/src/stress.ts b/src/stress.ts index e8074f1..c6e9b63 100644 --- a/src/stress.ts +++ b/src/stress.ts @@ -11,6 +11,7 @@ import type { SceneNode } from './types.js'; import type { LayoutRect } from './screenshot.js'; +import type { SurfaceType } from './surfaces.js'; import { extractInventory, isDataLike } from './drift.js'; export type PerturbationName = 'long-text' | 'i18n' | 'big-numbers' | 'empty' | 'many'; @@ -35,11 +36,77 @@ export interface StressFinding { /** A long unbroken token — the classic wrap-breaker (German compound). */ const LONG_TOKEN = 'Rindfleischetikettierungsüberwachung'; -function walkTexts(root: SceneNode, visit: (node: SceneNode) => void): void { - if (root.type === 'text' && typeof root.content === 'string' && root.content.trim()) visit(root); +/** Phase 30 slice C — the text props of primitives that carry their own copy, + * as paths into the node (`[]` walks an array). `fixed` marks a fixed + * vocabulary (tab and nav action labels): on mobile, long-text leaves it alone + * while i18n still perturbs it, since those labels really are localised. Read + * generically, so a new primitive is one row. */ +export const PRIMITIVE_TEXT_PROPS: { type: string; path: string; fixed?: boolean }[] = [ + { type: 'button', path: 'label' }, + { type: 'list-row', path: 'title' }, + { type: 'list-row', path: 'subtitle' }, + { type: 'list-row', path: 'trailing.text' }, + { type: 'tab-bar', path: 'items[].label', fixed: true }, + { type: 'nav-bar', path: 'title' }, + { type: 'nav-bar', path: 'leading.label', fixed: true }, + { type: 'nav-bar', path: 'trailing[].label', fixed: true }, +]; + +/** One perturbable string: a text node's content or a primitive's prop. */ +interface TextSlot { + node: SceneNode; + value: string; + fixed: boolean; + set: (value: string) => void; +} + +/** Every non-blank string at `path` under `obj` (`a.b`, `items[].label`). */ +function propSlots(obj: unknown, path: string[], out: Array<{ value: string; set: (v: string) => void }>): void { + if (!obj || typeof obj !== 'object' || path.length === 0) return; + const [head, ...rest] = path; + const isArray = head.endsWith('[]'); + const key = isArray ? head.slice(0, -2) : head; + const record = obj as Record; + const value = record[key]; + if (isArray) { + if (Array.isArray(value)) value.forEach((item) => propSlots(item, rest, out)); + return; + } + if (rest.length > 0) { propSlots(value, rest, out); return; } + if (typeof value === 'string' && value.trim()) out.push({ value, set: (v) => { record[key] = v; } }); +} + +function walkTexts(root: SceneNode, visit: (slot: TextSlot) => void): void { + if (root.type === 'text' && typeof root.content === 'string' && root.content.trim()) { + visit({ node: root, value: root.content, fixed: false, set: (v) => { root.content = v; } }); + } + for (const prop of PRIMITIVE_TEXT_PROPS) { + if (prop.type !== root.type) continue; + const found: Array<{ value: string; set: (v: string) => void }> = []; + propSlots(root, prop.path.split('.'), found); + for (const f of found) visit({ node: root, value: f.value, fixed: prop.fixed === true, set: f.set }); + } root.children?.forEach((c) => walkTexts(c, visit)); } +/** Runs of two or more consecutive sibling `list-row`s — a list, for the + * empty and many perturbations. Lives here, not in the drift inventory, which + * coverage, typography and drift share and which must not change. */ +function listRuns(root: SceneNode): Array<{ container: SceneNode; start: number; length: number }> { + const runs: Array<{ container: SceneNode; start: number; length: number }> = []; + (function walk(n: SceneNode) { + const kids = n.children ?? []; + for (let i = 0; i < kids.length;) { + let j = i; + while (j < kids.length && (kids[j].type as string) === 'list-row') j++; // slice D adds the node type + if (j - i >= 2) runs.push({ container: n, start: i, length: j - i }); + i = j > i ? j : i + 1; + } + kids.forEach(walk); + })(root); + return runs; +} + /** Repeat `base` (space-joined) until it reaches `factor` × its own length. */ function stretch(base: string, factor: number): string { const target = Math.ceil(base.length * factor); @@ -70,32 +137,37 @@ function cloneWithSuffix(node: SceneNode, suffix: string): SceneNode { } /** Apply one named perturbation to a COPY of the tree. A perturbation that - * finds nothing to touch returns touched: [] — the caller skips rendering it. */ -export function applyPerturbation(name: PerturbationName, root: SceneNode): PerturbationResult { + * finds nothing to touch returns touched: [] — the caller skips rendering it. + * On a `mobile` canvas long-text leaves alone strings of three characters or + * fewer (monograms, short labels) and fixed-vocabulary primitive labels. */ +export function applyPerturbation(name: PerturbationName, root: SceneNode, opts: { type?: SurfaceType } = {}): PerturbationResult { const clone = structuredClone(root); const touched: string[] = []; + const touch = (id: string) => { if (!touched.includes(id)) touched.push(id); }; + const mobile = opts.type === 'mobile'; if (name === 'long-text' || name === 'i18n') { - walkTexts(clone, (node) => { - if (isDataLike(node.content as string)) return; // live figures get big-numbers instead + walkTexts(clone, (slot) => { + if (isDataLike(slot.value)) return; // live figures get big-numbers instead if (name === 'long-text') { - node.content = `${stretch(node.content as string, 2.2)} ${LONG_TOKEN}`; + if (mobile && (slot.fixed || slot.value.trim().length <= 3)) return; + slot.set(`${stretch(slot.value, 2.2)} ${LONG_TOKEN}`); } else { // ~1.4× — the German/Finnish expansion rule, no unbroken token. - node.content = stretch(node.content as string, 1.4); + slot.set(stretch(slot.value, 1.4)); } - touched.push(node.id); + touch(slot.node.id); }); return { root: clone, touched }; } if (name === 'big-numbers') { - walkTexts(clone, (node) => { - if (!isDataLike(node.content as string)) return; - const widened = widenNumber(node.content as string); - if (widened === null || widened === node.content) return; - node.content = widened; - touched.push(node.id); + walkTexts(clone, (slot) => { + if (!isDataLike(slot.value)) return; + const widened = widenNumber(slot.value); + if (widened === null || widened === slot.value) return; + slot.set(widened); + touch(slot.node.id); }); return { root: clone, touched }; } @@ -118,6 +190,14 @@ export function applyPerturbation(name: PerturbationName, root: SceneNode): Pert } touched.push(container.id); } + // Runs of list-rows, last run first so earlier indices stay valid. + for (const run of listRuns(clone).reverse()) { + const kids = run.container.children!; + const rows = kids.slice(run.start, run.start + run.length); + if (name === 'empty') kids.splice(run.start, run.length); + else kids.splice(run.start + run.length, 0, ...[1, 2].flatMap((i) => rows.map((r) => cloneWithSuffix(r, `-x${i}`)))); + touch(run.container.id); + } return { root: clone, touched }; } @@ -256,6 +336,9 @@ export function compareLayouts( export interface StressRecord { versionHash: string; warnings: number; + /** The canvas type the run used (Phase 30): a type change makes the + * record stale for the directive's note, never for the verdict. */ + type: SurfaceType; } const lastStressRuns = new Map(); diff --git a/src/surfaces.ts b/src/surfaces.ts index 3a56a7e..8563296 100644 --- a/src/surfaces.ts +++ b/src/surfaces.ts @@ -117,12 +117,26 @@ export interface Ruleset { export type RulesetAxis = 'base' | 'type' | 'genre' | 'preset'; +/** The mobile ruleset's rule ids (Phase 30 slice C), each registered in the + * same change as the check that honours it. */ +export const MOBILE_RULES = [ + 'mobile-touch-target', + 'mobile-handbuilt-target', + 'mobile-safe-area', + 'mobile-type-floor', + 'mobile-one-column', +] as const; + /** Keyed by axis, then value. Genre rows are canonical names only; aliases - * fold through GENRE_ALIASES first. The enable/disable lists start empty: a - * rule id lands here in the same change as the check that honours it. */ + * fold through GENRE_ALIASES first. A rule id lands here in the same change as + * the check that honours it. */ export const RULESETS: Record> = { - base: { all: {} }, - type: { web: {}, mobile: {} }, + // mobile-artboard is on for every type: its job is to catch phone-shaped + // canvases the mobile ruleset is not on for. + base: { all: { enable: ['mobile-artboard'] } }, + // The mobile disable list stays empty until a web-shaped rule is shown to + // misfire on a correct mobile idiom (FR-C7). + type: { web: {}, mobile: { enable: [...MOBILE_RULES] } }, genre: { // Material Design legitimately uses a purple accent AND white elevated // surfaces (cards on an off-white background), so both are intentional diff --git a/test-discoverability.ts b/test-discoverability.ts index abc905e..367efcf 100644 --- a/test-discoverability.ts +++ b/test-discoverability.ts @@ -379,6 +379,22 @@ const guidelines = readFileSync('docs/GUIDELINES.md', 'utf-8'); } } +// ── Phase 30 slice C: every registered rule id reaches the agent surfaces ──── +// Rule ids appear on findings (`rule`) and in the surface report (rulesOn). An +// id an agent can't look up is a finding it can't act on. +{ + const ruleIds = new Set(); + for (const axis of Object.values(RULESETS)) { + for (const r of Object.values(axis)) [...(r.enable ?? []), ...(r.disable ?? [])].forEach((id) => ruleIds.add(id)); + } + expect('the ruleset table registers rule ids', ruleIds.size > 0); + for (const [label, src] of [['src/index.ts', indexSrc], ['GUIDELINES', guidelines], ['README', readme]] as const) { + const missing = [...ruleIds].filter((id) => !src.includes(id)); + expect(`every rule id named in ${label}`, missing.length === 0, missing.join(', ')); + } + expect('the issue `rule` field is documented in src/index.ts and GUIDELINES', /"rule" field/.test(indexSrc) && /`rule` field/.test(guidelines)); +} + let allPass = true; for (const c of checks) { if (!c.ok) allPass = false; diff --git a/test-mobile-rules-detailed.ts b/test-mobile-rules-detailed.ts new file mode 100644 index 0000000..aa43426 --- /dev/null +++ b/test-mobile-rules-detailed.ts @@ -0,0 +1,122 @@ +// Phase 30 slice C — the mobile ruleset where it needs Chrome: mobile-safe-area +// in detailed mode (rendered rects), and the canvas_stress record carrying the +// canvas type through the real tool handlers. +// +// Usage: npx tsx test-mobile-rules-detailed.ts + +import './test-env.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { createCanvas } from './src/scene-graph.js'; +import { parseAndExecute } from './src/operations.js'; +import { evaluateCanvas, type EvaluationIssue } from './src/evaluate.js'; +import { shutdown } from './src/screenshot.js'; +import type { Canvas } from './src/types.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +function phone(ops: string, opts: { bare?: boolean } = {}): Canvas { + const c = createCanvas('phone', undefined, {}, { type: 'mobile' }); + if (opts.bare) delete c.root.safeArea; + parseAndExecute(c.root, ops, c); + return c; +} + +async function safeArea(c: Canvas, mode: 'fast' | 'detailed'): Promise { + const r = await evaluateCanvas(c, { mode }); + return r.issues.filter((i) => i.rule === 'mobile-safe-area'); +} +const show = (list: EvaluationIssue[]) => JSON.stringify(list.map((i) => `${i.severity} ${i.nodeId}: ${i.message.slice(0, 80)}`)); + +const flow = `f=I("document", { type: "frame", layout: "vertical", gap: 12, padding: [0, 16] }) +I(f, { type: "text", content: "Today" }) +I(f, { type: "text", content: "Nothing planned." })`; + +try { + console.log('── mobile-safe-area (detailed) ──'); + const clean = await safeArea(phone(flow), 'detailed'); + check('default path, content that fits → clean', clean.length === 0, show(clean)); + + const abs = phone(`${flow} +I("document", { type: "text", content: "Offline", position: "absolute", x: 16, y: 10 })`); + const absI = await safeArea(abs, 'detailed'); + check('absolute text at y 10 → warning in detailed too', absI.length === 1 && absI[0].severity === 'warning' && absI[0].message.includes('top'), show(absI)); + + const low = await safeArea(phone(`${flow} +I("document", { type: "text", content: "Saved", position: "absolute", x: 16, y: 820 })`), 'detailed'); + check('absolute text in the bottom inset → warning', low.length === 1 && low[0].message.includes('bottom'), show(low)); + + const bare = await safeArea(phone(flow, { bare: true }), 'detailed'); + check('no safeArea, content at y 0 → top warning', bare.some((i) => i.message.includes('top')), show(bare)); + + const nested = phone(`${flow} +w=I("document", { type: "frame", layout: "vertical", height: 60 }) +I(w, { type: "text", content: "Badge", position: "absolute", x: 0, y: -80 })`); + const nestedFast = await safeArea(nested, 'fast'); + const nestedDetailed = await safeArea(nested, 'detailed'); + check('positioned content fast mode cannot place: fast says info, detailed places it', nestedFast.length === 1 && nestedFast[0].severity === 'info' && nestedDetailed.length === 1 && nestedDetailed[0].severity === 'warning', `${show(nestedFast)} / ${show(nestedDetailed)}`); + + // The known gap until slice D: a fixed-height root does not carry its end + // padding past overflowing content, so a long screen ends in the inset. + const rows = Array.from({ length: 60 }, (_, k) => `I(f, { type: "text", content: "Row ${k + 1}" })`).join('\n'); + const tall = phone(`f=I("document", { type: "frame", layout: "vertical", gap: 8, padding: [0, 16] })\n${rows}`); + const tallFast = await safeArea(tall, 'fast'); + const tallDetailed = await safeArea(tall, 'detailed'); + check('tall screen on the default path: detailed catches the document end in the inset', tallDetailed.length > 0 && tallDetailed.every((i) => i.message.includes('end of the scroll')), show(tallDetailed)); + check('…which fast mode trusts the reservation for (recorded gap until slice D)', tallFast.length === 0, show(tallFast)); +} finally { + await shutdown(); +} + +// ── canvas_stress records the type ──────────────────────────────────────── +{ + console.log('\n── stress record carries the type (MCP) ──'); + const home = mkdtempSync(join(tmpdir(), 'framesmith-mobile-stress-')); + const cwd = mkdtempSync(join(tmpdir(), 'framesmith-mobile-stress-cwd-')); + const transport = new StdioClientTransport({ + command: resolve('node_modules/.bin/tsx'), + args: [resolve('src/index.ts')], + cwd, + env: { ...process.env, FRAMESMITH_HOME: home, CANVAS_MCP_PROJECT_DIR: cwd, FRAMESMITH_VIEWER_URL: 'http://localhost:1' } as Record, + stderr: 'ignore', + }); + const client = new Client({ name: 'test-mobile-rules-detailed', version: '0' }); + await client.connect(transport); + async function tool(name: string, args: Record) { + const r = await client.callTool({ name, arguments: args }) as { content: { text: string }[]; isError?: boolean }; + const text = r.content[0]?.text ?? ''; + try { return { ...JSON.parse(text), isError: r.isError }; } catch { return { text, isError: r.isError }; } + } + try { + const m = await tool('canvas_create', { name: 'Fragile phone', type: 'mobile' }); + // A fixed 300×20 box holding one line: long copy wraps out of it → a clip. + await tool('batch_design', { canvasId: m.canvasId, operations: `b=I("document", { type: "frame", width: 300, height: 20 }) +I(b, { type: "text", content: "Morning run" })` }); + const run = await tool('canvas_stress', { canvasId: m.canvasId }); + check('stress run is FRAGILE', run.counts?.warnings > 0, JSON.stringify(run.counts)); + const before = await tool('canvas_evaluate', { canvasId: m.canvasId }); + check('evaluate reports the run under its type, current', before.stress?.type === 'mobile' && before.stress?.current === true, JSON.stringify(before.stress)); + + await tool('canvas_set_type', { canvasId: m.canvasId, type: 'web' }); + const after = await tool('canvas_evaluate', { canvasId: m.canvasId }); + check('after canvas_set_type: same version hash, but the record is no longer current', after.stress?.current === false && after.stress?.type === 'mobile', JSON.stringify(after.stress)); + check('…the FRAGILE verdict still blocks, and the note names the earlier type', after.directive.startsWith('NOT READY') && after.directive.includes('earlier type ("mobile")'), after.directive); + + await tool('canvas_stress', { canvasId: m.canvasId }); + await tool('canvas_set_type', { canvasId: m.canvasId, type: null }); + const cleared = await tool('canvas_evaluate', { canvasId: m.canvasId }); + check('declared web → cleared (default web) is no type change: the record stays current', cleared.stress?.type === 'web' && cleared.stress?.current === true, JSON.stringify(cleared.stress)); + } finally { + await client.close(); + } +} + +console.log(allPass ? '\nAll detailed mobile checks passed.' : '\nSome detailed mobile checks FAILED.'); +process.exit(allPass ? 0 : 1); diff --git a/test-mobile-rules.ts b/test-mobile-rules.ts new file mode 100644 index 0000000..086dfd9 --- /dev/null +++ b/test-mobile-rules.ts @@ -0,0 +1,355 @@ +// Phase 30 slice C — the mobile ruleset, fast mode: each rule's positive and +// negative fixtures from the spec's FR-C1..C8 rows and the slice C build +// contract, the stress engine's mobile behaviour, the directive's type note, +// and the real tool handlers over MCP stdio. Detailed mode and canvas_stress +// need Chrome and live in test-mobile-rules-detailed.ts. +// +// Usage: npx tsx test-mobile-rules.ts + +import './test-env.js'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { createCanvas } from './src/scene-graph.js'; +import { parseAndExecute } from './src/operations.js'; +import { evaluateCanvas, type EvaluationIssue } from './src/evaluate.js'; +import { buildEvaluateDirective } from './src/directive.js'; +import { applyPerturbation } from './src/stress.js'; +import { fontPx, proseWords } from './src/mobile-rules.js'; +import { renderedPaddingPx } from './src/renderer.js'; +import type { Canvas, SceneNode } from './src/types.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +/** A phone canvas: declared mobile, 390×844, root safeArea unless `bare`. */ +function phone(ops: string, opts: { bare?: boolean; type?: 'web' | 'mobile' | 'none'; width?: number; height?: number; variables?: Canvas['variables'] } = {}): Canvas { + const type = opts.type ?? 'mobile'; + const c = type === 'mobile' + ? createCanvas('phone', undefined, { ...(opts.width ? { width: opts.width } : {}), ...(opts.height ? { height: opts.height } : {}) }, { type: 'mobile' }) + : createCanvas('phone', undefined, { width: opts.width ?? 390, height: opts.height ?? 844 }, type === 'web' ? { type: 'web' } : undefined); + if (opts.bare) delete c.root.safeArea; + if (opts.variables) c.variables = { ...c.variables, ...opts.variables }; + parseAndExecute(c.root, ops, c); + return c; +} + +async function issues(c: Canvas, rule?: string, mode: 'fast' | 'detailed' = 'fast'): Promise { + const r = await evaluateCanvas(c, { mode }); + return r.issues.filter((i) => (rule ? i.rule === rule : i.rule !== undefined)); +} +const show = (list: EvaluationIssue[]) => JSON.stringify(list.map((i) => `${i.severity} ${i.rule} ${i.nodeId}: ${i.message.slice(0, 70)}`)); + +// ── measurement helpers ─────────────────────────────────────────────────── +{ + console.log('── measurement ──'); + check('padding under 32 renders as authored', renderedPaddingPx(24, 390) === 24); + check('48px padding renders 13px on a 390 artboard', Math.round(renderedPaddingPx(48, 390)) === 13); + check('48px padding renders 48 at 1440', renderedPaddingPx(48, 1440) === 48); + check('fontPx: number, px, rem, clamp min, unresolved ref', fontPx(12) === 12 && fontPx('12px') === 12 && fontPx('1rem') === 16 + && fontPx('clamp(1rem, 0.9rem + 0.5vw, 1.25rem)') === 16 && fontPx('$missing') === 14 && fontPx(undefined) === undefined); + check('proseWords: a metrics line is not prose', proseWords('5.2 mi · 1h 12m · 340 ft') === 2, String(proseWords('5.2 mi · 1h 12m · 340 ft'))); + check('proseWords: CJK counts one word per two characters', proseWords('今日は良い天気ですね') === 5); +} + +// ── FR-C1 mobile-touch-target ───────────────────────────────────────────── +{ + console.log('\n── mobile-touch-target ──'); + const card = `f=I("document", { type: "frame", layout: "vertical", padding: 12 }) +I(f, { type: "text", content: "Remember me" }) +I(f, { type: "checkbox", width: 32, height: 32 })`; + const m = await issues(phone(card), 'mobile-touch-target'); + check('32px control in a 12px-padded frame on mobile → error', m.length === 1 && m[0].severity === 'error' && m[0].category === 'usability' && m[0].message.includes('44px'), show(m)); + const w = await evaluateCanvas(phone(card, { type: 'web' }), { mode: 'fast', categories: ['usability'] }); + check('same canvas declared web → only the unchanged touch-first info', w.issues.length === 1 && w.issues[0].severity === 'info' && w.issues[0].rule === undefined && buildEvaluateDirective(w).ready, JSON.stringify(w.issues.map((i) => i.message))); + + const row44 = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 44, width: "100%" }) +I(r, { type: "text", content: "Wi-Fi" }) +I(r, { type: "toggle", checked: true })`), 'mobile-touch-target'); + check('toggle alone in a 44px row → passes', row44.length === 0, show(row44)); + + const row56 = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 56, padding: [0, 16], alignItems: "center", width: "100%" }) +I(r, { type: "text", content: "Notifications" }) +I(r, { type: "toggle" })`), 'mobile-touch-target'); + check('toggle in a 56px settings row → passes (the row is the target)', row56.length === 0, show(row56)); + + const login = await issues(phone(`c=I("document", { type: "frame", layout: "vertical", padding: 20 }) +I(c, { type: "text", content: "Sign in", fontSize: 22 }) +I(c, { type: "text", content: "Use the email you signed up with." }) +I(c, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('lone checkbox in a padded card → error (a card is not its row)', login.length === 1, show(login)); + + const scaled = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", padding: [32, 16] }) +I(r, { type: "text", content: "Sync" }) +I(r, { type: "toggle" })`), 'mobile-touch-target'); + check('row padding 32 renders ~9px on a phone → toggle row is 41px → error', scaled.length === 1, show(scaled)); + + const two = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 56 }) +I(r, { type: "text", content: "Pair" }) +I(r, { type: "checkbox", width: 24, height: 24 }) +I(r, { type: "checkbox", width: 24, height: 24 })`), 'mobile-touch-target'); + check('two controls sharing a row → both measured bare', two.length === 2, show(two)); +} + +// ── FR-C2 mobile-handbuilt-target ───────────────────────────────────────── +{ + console.log('\n── mobile-handbuilt-target ──'); + const c = phone(`a=I("document", { type: "frame", width: 40, height: 40, alignItems: "center", justifyContent: "center" }) +I(a, { type: "icon", icon: "bell" }) +b=I("document", { type: "frame", width: 44, height: 44 }) +I(b, { type: "icon", icon: "bell" }) +t=I("document", { type: "frame", layout: "vertical", width: 56, height: 48 }) +I(t, { type: "icon", icon: "house" }) +I(t, { type: "text", content: "Home" }) +s=I("document", { type: "frame", layout: "horizontal", height: 32 }) +I(s, { type: "icon", icon: "plus" }) +I(s, { type: "text", content: "Add item" }) +col=I("document", { type: "frame", layout: "vertical", width: 74 }) +circ=I(col, { type: "frame", width: 40, height: 40 }) +I(circ, { type: "icon", icon: "play" }) +I(col, { type: "text", content: "Start" }) +chip=I("document", { type: "frame", layout: "horizontal", height: 24 }) +I(chip, { type: "icon", icon: "flame" }) +I(chip, { type: "text", content: "Five day streak so far" }) +hug=I("document", { type: "frame", padding: 8 }) +I(hug, { type: "icon", icon: "x", iconSize: 20 })`); + const found = await issues(c, 'mobile-handbuilt-target'); + const byIndex = (k: number) => found.some((i) => i.nodeId === c.root.children![k].id); + check('40×40 icon-only frame → info', byIndex(0) && found.every((i) => i.severity === 'info'), show(found)); + check('44×44 icon frame and 56×48 tab item → nothing', !byIndex(1) && !byIndex(2)); + check('32px-tall icon + short label → info', byIndex(3)); + check('outermost tap-shaped frame only: a column with unknown height reports nothing, its 40px circle stays silent', !byIndex(4) && !found.some((i) => i.nodeId === c.root.children![4].children![0].id)); + check('icon + a five-word label is not tap-shaped', !byIndex(5)); + check('hug-sized icon frame: 20 + 2×8 = 36 → info', byIndex(6), show(found)); + check('exactly three infos', found.length === 3, show(found)); + const web = await issues(phone(`a=I("document", { type: "frame", width: 40, height: 40 }) +I(a, { type: "icon", icon: "bell" })`, { type: 'web' }), 'mobile-handbuilt-target'); + check('web canvas → no handbuilt infos', web.length === 0); +} + +// ── FR-C3 mobile-safe-area (fast) ───────────────────────────────────────── +{ + console.log('\n── mobile-safe-area (fast) ──'); + const flow = `f=I("document", { type: "frame", layout: "vertical", gap: 12 }) +I(f, { type: "text", content: "Today" }) +I(f, { type: "text", content: "Nothing planned." })`; + const safe = await issues(phone(flow), 'mobile-safe-area'); + check('default path (root safeArea): in-flow content → clean', safe.length === 0, show(safe)); + + const abs = phone(`${flow} +I("document", { type: "text", content: "Offline", position: "absolute", x: 16, y: 10 })`); + const absI = await issues(abs, 'mobile-safe-area'); + check('absolute text at y 10 → warning', absI.length === 1 && absI[0].severity === 'warning' && absI[0].category === 'usability', show(absI)); + + const low = await issues(phone(`${flow} +I("document", { type: "text", content: "Saved", position: "absolute", x: 16, y: 820 })`), 'mobile-safe-area'); + check('absolute text reaching the bottom inset → warning', low.length === 1 && low[0].message.includes('bottom'), show(low)); + + const bare = await issues(phone(flow, { bare: true }), 'mobile-safe-area'); + check('no safeArea, content at the top and the end → two warnings naming the safeArea op', bare.length === 2 && bare.every((i) => i.suggestion?.includes('safeArea: { top: 47, bottom: 34 }')), show(bare)); + + const spaced = await issues(phone(`I("document", { type: "frame", height: 47 }) +${flow} +I("document", { type: "frame", height: 34 })`, { bare: true }), 'mobile-safe-area'); + check('no safeArea, spacer frames of the inset heights → clean', spaced.length === 0, show(spaced)); + + const padSpacer = await issues(phone(`I("document", { type: "frame", padding: [24, 0] }) +${flow} +I("document", { type: "frame", minHeight: 34 })`, { bare: true }), 'mobile-safe-area'); + check('spacers sized by padding (24+24) and minHeight → clean', padSpacer.length === 0, show(padSpacer)); + + const bigPad = phone(flow, { bare: true }); + bigPad.root.padding = [64, 0, 64, 0]; + const bigPadI = await issues(bigPad, 'mobile-safe-area'); + check('root padding 64 renders ~17px on a phone → still warns', bigPadI.length === 2, show(bigPadI)); + + const hero = await issues(phone(`I("document", { type: "image", src: "https://example.com/hero.jpg", width: "100%", height: 220 }) +${flow} +I("document", { type: "frame", height: 34 })`, { bare: true }), 'mobile-safe-area'); + check('full-width hero image under the status bar, text below the inset → clean', hero.length === 0, show(hero)); + + const row = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", padding: [8, 16] }) +I(r, { type: "text", content: "Back" }) +I(r, { type: "text", content: "Edit" }) +${flow} +I("document", { type: "frame", height: 34 })`, { bare: true }), 'mobile-safe-area'); + check('horizontal row at the top: its content sits at the row padding → warning', row.length === 1 && row[0].message.includes('top'), show(row)); + + const overlay = phone(`${flow} +o=I("document", { type: "frame", layout: "vertical", position: "absolute", x: 0, y: 0, width: 390, height: 844 }) +I(o, { type: "text", content: "Welcome back" })`); + const overlayI = await issues(overlay, 'mobile-safe-area'); + check('absolute full-screen overlay with unpadded content → warning naming padding inside the overlay', overlayI.length === 2 && overlayI.every((i) => i.suggestion?.startsWith('Pad')), show(overlayI)); + + const nested = await issues(phone(`${flow} +w=I("document", { type: "frame", layout: "vertical" }) +I(w, { type: "text", content: "Badge", position: "absolute", x: 0, y: 0 })`), 'mobile-safe-area'); + check('absolute content inside an in-flow frame → one info naming detailed mode', nested.length === 1 && nested[0].severity === 'info' && nested[0].suggestion?.includes('detailed'), show(nested)); +} + +// ── FR-C4 mobile-type-floor ─────────────────────────────────────────────── +{ + console.log('\n── mobile-type-floor ──'); + const typo = (entries: Record) => Object.fromEntries(Object.entries(entries).map(([k, v]) => [k, { fontSize: v }])); + const c = phone(`I("document", { type: "text", content: "Your run synced and your weekly summary is ready to read.", fontSize: "$text-sm" }) +I("document", { type: "text", content: "Updated 2 minutes ago", fontSize: 13 }) +I("document", { type: "text", content: "Mon", fontSize: 10 }) +I("document", { type: "text", content: "5.2 mi · 1h 12m · 340 ft", fontSize: 12 }) +I("document", { type: "text", content: "This is a long caption line that runs on for twelve words or so.", fontSize: 14 })`, + { variables: { typography: typo({ 'text-sm': 12, body: 14 }) } }); + const found = await issues(c, 'mobile-type-floor'); + const at = (k: number) => found.find((i) => i.nodeId === c.root.children![k].id); + check('12px paragraph via $text-sm → warning', at(0)?.severity === 'warning' && at(0)?.category === 'typography', show(found)); + check('13px timestamp line → nothing', !at(1)); + check('10px label → warning (below 11)', at(2)?.severity === 'warning' && at(2)!.message.includes('11px')); + check('12px metrics line → nothing (two prose words)', !at(3)); + check('14px twelve-word copy → info', at(4)?.severity === 'info'); + check('workspace body 14 that no node references → no finding of its own', found.length === 3, show(found)); + + const body = phone(`I("document", { type: "text", content: "Hi", fontSize: "$body" })`, { variables: { typography: typo({ body: 14 }) } }); + const bodyI = await issues(body, 'mobile-type-floor'); + check('a node using $body at 14 → warning naming the 16px floor', bodyI.length === 1 && bodyI[0].message.includes('16px'), show(bodyI)); + const ok = await issues(phone(`I("document", { type: "text", content: "Hi", fontSize: "$body" })`, { variables: { typography: typo({ body: 17 }) } }), 'mobile-type-floor'); + check('$body at 17 → clean', ok.length === 0); +} + +// ── FR-C5 mobile-one-column ─────────────────────────────────────────────── +{ + console.log('\n── mobile-one-column ──'); + const c = phone(`r=I("document", { type: "frame", layout: "horizontal", gap: 12 }) +a=I(r, { type: "frame", layout: "vertical" }) +I(a, { type: "text", content: "How we rebuilt the route planner from the ground up" }) +b=I(r, { type: "frame", layout: "vertical" }) +I(b, { type: "text", content: "Five habits that keep long runs feeling easy and fun" }) +q=I("document", { type: "frame", layout: "horizontal" }) +I(q, { type: "text", content: "Start" }) +I(q, { type: "text", content: "Routes" }) +I(q, { type: "text", content: "Plan" }) +I(q, { type: "text", content: "Gear" }) +s=I("document", { type: "frame", layout: "horizontal" }) +I(s, { type: "text", content: "42.8 km" }) +I(s, { type: "text", content: "5:12 /km" })`); + const found = await issues(c, 'mobile-one-column'); + check('two side-by-side article cards → one info on the row', found.length === 1 && found[0].nodeId === c.root.children![0].id && found[0].severity === 'info', show(found)); +} + +// ── FR-C6 mobile-artboard ───────────────────────────────────────────────── +{ + console.log('\n── mobile-artboard ──'); + const content = 'I("document", { type: "text", content: "Hello" })'; + const undeclared = await issues(phone(content, { type: 'none' }), 'mobile-artboard'); + check('390 canvas, no type → warning (structure)', undeclared.length === 1 && undeclared[0].severity === 'warning' && undeclared[0].category === 'structure' && undeclared[0].suggestion!.includes('canvas_set_type'), show(undeclared)); + const web = await issues(phone(content, { type: 'web' }), 'mobile-artboard'); + check('390 canvas declared web → info only', web.length === 1 && web[0].severity === 'info', show(web)); + const wide = await issues(phone(content, { width: 1440, height: 900 }), 'mobile-artboard'); + check('mobile canvas at 1440 → warning', wide.length === 1 && wide[0].severity === 'warning', show(wide)); + const odd = await issues(phone(content, { width: 393, height: 852 }), 'mobile-artboard'); + check('mobile canvas at 393×852 → info', odd.length === 1 && odd[0].severity === 'info', show(odd)); + const exact = await issues(phone(content), 'mobile-artboard'); + check('mobile canvas at 390×844 → nothing', exact.length === 0); + const desktop = await issues(phone(content, { type: 'none', width: 1440, height: 900 }), 'mobile-artboard'); + check('undeclared 1440 canvas → nothing', desktop.length === 0); + const chrome = phone(content, { type: 'none', width: 1440, height: 900 }); + chrome.root.children!.push({ id: 'tb', type: 'tab-bar' as SceneNode['type'] }); + const chromeI = await issues(chrome, 'mobile-artboard'); + check('undeclared wide canvas holding a mobile chrome primitive → warning', chromeI.length === 1 && chromeI[0].severity === 'warning', show(chromeI)); + + const r = await evaluateCanvas(phone(content, { type: 'none' }), { mode: 'fast' }); + check('undeclared phone canvas: directive NOT READY', !buildEvaluateDirective(r).ready); +} + +// ── rule gating ─────────────────────────────────────────────────────────── +{ + console.log('\n── gating ──'); + const c = phone(`I("document", { type: "text", content: "Mon", fontSize: 9, position: "absolute", x: 0, y: 4 })`, { type: 'web' }); + const web = await issues(c); + check('web canvas: only mobile-artboard can fire', web.every((i) => i.rule === 'mobile-artboard'), show(web)); + const filtered = await evaluateCanvas(phone('I("document", { type: "text", content: "x", fontSize: 9 })'), { mode: 'fast', categories: ['usability'] }); + check('a rule runs only with its category: no type-floor under categories [usability]', !filtered.issues.some((i) => i.rule === 'mobile-type-floor')); +} + +// ── FR-C8 stress on mobile ──────────────────────────────────────────────── +{ + console.log('\n── stress ──'); + let n = 0; + const id = () => `s${++n}`; + const text = (content: string): SceneNode => ({ id: id(), type: 'text', content }); + const row = (title: string): SceneNode => ({ id: id(), type: 'list-row' as SceneNode['type'], title, subtitle: 'Yesterday afternoon', trailing: { text: 'Open' } } as unknown as SceneNode); + const tabs = { id: id(), type: 'tab-bar', items: [{ icon: 'house', label: 'Home' }, { icon: 'map', label: 'Routes' }] } as unknown as SceneNode; + const list = { id: id(), type: 'frame', children: [row('Cedar Ridge loop'), row('Riverside tempo'), row('Old Mill recovery')] } as SceneNode; + const root: SceneNode = { id: 'document', type: 'document', children: [text('FM'), text('Good morning'), list, tabs] } as SceneNode; + const props = (r: SceneNode, k: number): Record => (r.children![2].children![k] as unknown as Record); + + const mob = applyPerturbation('long-text', root, { type: 'mobile' }); + check('mobile long-text: "FM" (≤3 chars) untouched', mob.root.children![0].content === 'FM'); + check('mobile long-text: list-row titles, subtitles and trailing text perturbed', String(props(mob.root, 0).title).length > 40 && String(props(mob.root, 1).subtitle).length > 40 && String((props(mob.root, 2).trailing as { text: string }).text).length > 20); + const tabLabels = (r: SceneNode) => (r.children![3] as unknown as { items: { label: string }[] }).items.map((i) => i.label); + check('mobile long-text: tab labels (fixed vocabulary) untouched', JSON.stringify(tabLabels(mob.root)) === '["Home","Routes"]'); + const i18n = applyPerturbation('i18n', root, { type: 'mobile' }); + check('mobile i18n: tab labels perturbed', tabLabels(i18n.root).every((l) => l.length > 6), JSON.stringify(tabLabels(i18n.root))); + const web = applyPerturbation('long-text', root, { type: 'web' }); + check('web long-text: "FM" perturbed as before', (web.root.children![0].content as string).length > 10); + check('the source tree is never mutated', root.children![0].content === 'FM' && props(root, 0).title === 'Cedar Ridge loop'); + + const empty = applyPerturbation('empty', root, { type: 'mobile' }); + check('empty: a run of list-rows is removed, touched = its parent', empty.root.children![2].children!.length === 0 && empty.touched.includes(list.id)); + const many = applyPerturbation('many', root, { type: 'mobile' }); + check('many: the run ×3 with suffixed ids', many.root.children![2].children!.length === 9 && many.root.children![2].children!.some((r) => r.id.endsWith('-x2'))); + const lone = { id: 'document', type: 'document', children: [row('Only row')] } as SceneNode; + check('a single list-row is not a list', applyPerturbation('empty', lone).touched.length === 0); + + // The directive: a type change makes the record stale for the note only. + const clean = { overallScore: 100, issues: [], categories: [], summary: '', stats: {} } as never; + const fragileOtherType = buildEvaluateDirective(clean, 0, { warnings: 2, current: false, ranAsType: 'mobile' }); + check('FRAGILE then a type change: still NOT READY, the note names the earlier type', !fragileOtherType.ready && fragileOtherType.directive.includes('earlier type ("mobile")'), fragileOtherType.directive); + const fragileEdit = buildEvaluateDirective(clean, 0, { warnings: 2, current: false }); + check('FRAGILE then an edit: the note says earlier version', fragileEdit.directive.includes('earlier version')); +} + +// ── the real tool handlers over MCP stdio ───────────────────────────────── +{ + console.log('\n── MCP tools ──'); + const home = mkdtempSync(join(tmpdir(), 'framesmith-mobile-mcp-')); + const cwd = mkdtempSync(join(tmpdir(), 'framesmith-mobile-mcp-cwd-')); + const transport = new StdioClientTransport({ + command: resolve('node_modules/.bin/tsx'), + args: [resolve('src/index.ts')], + cwd, + env: { ...process.env, FRAMESMITH_HOME: home, CANVAS_MCP_PROJECT_DIR: cwd, FRAMESMITH_VIEWER_URL: 'http://localhost:1' } as Record, + stderr: 'ignore', + }); + const client = new Client({ name: 'test-mobile-rules', version: '0' }); + await client.connect(transport); + async function tool(name: string, args: Record) { + const r = await client.callTool({ name, arguments: args }) as { content: { text: string }[]; isError?: boolean }; + const text = r.content[0]?.text ?? ''; + try { return { ...JSON.parse(text), isError: r.isError }; } catch { return { text, isError: r.isError }; } + } + try { + const bare = await tool('canvas_create', { name: 'Bare phone', width: 390, height: 844 }); + await tool('batch_design', { canvasId: bare.canvasId, operations: 'I("document", { type: "text", content: "Hello" })' }); + const ev = await tool('canvas_evaluate', { canvasId: bare.canvasId }); + const art = ev.issues.filter((i: EvaluationIssue) => i.rule === 'mobile-artboard'); + check('canvas_create({ width: 390 }) with no type → NOT READY with a mobile-artboard warning', art.length === 1 && art[0].severity === 'warning' && ev.directive.startsWith('NOT READY'), JSON.stringify(art)); + check('the issue carries its rule id and the surface report lists it', ev.surface.rulesOn.includes('mobile-artboard')); + + const m = await tool('canvas_create', { name: 'Phone', type: 'mobile' }); + await tool('batch_design', { canvasId: m.canvasId, operations: 'I("document", { type: "text", content: "Hello" })' }); + const v = await tool('canvas_add_variant', { canvasId: m.canvasId, state: 'empty' }); + const set = await tool('canvas_set_type', { canvasId: m.canvasId, type: 'web' }); + check('canvas_set_type names the variant left on the old type', Array.isArray(set.variantsOutOfSync) && set.variantsOutOfSync.length === 1 && set.variantsOutOfSync[0].type === 'mobile' && set.variantsOutOfSync[0].state === 'empty', JSON.stringify(set.variantsOutOfSync)); + const back = await tool('canvas_set_type', { canvasId: m.canvasId, type: 'mobile' }); + check('…and nothing once they agree again', back.variantsOutOfSync === undefined); + void v; + } finally { + await client.close(); + } +} + +console.log(allPass ? '\nAll mobile ruleset checks passed.' : '\nSome mobile ruleset checks FAILED.'); +process.exit(allPass ? 0 : 1); diff --git a/test-surface.ts b/test-surface.ts index 84fdc28..1af84f1 100644 --- a/test-surface.ts +++ b/test-surface.ts @@ -22,7 +22,7 @@ import { renderToHtml } from './src/renderer.js'; import { evalCacheKey } from './src/viewer.js'; import { activeRulesets, composeRulesets, resolveSurface, surfaceFor, knownGenres, relaxedByGenre, - RULESETS, DEVICE_PROFILES, DEFAULT_DEVICE, type Ruleset, + RULESETS, DEVICE_PROFILES, DEFAULT_DEVICE, MOBILE_RULES, type Ruleset, } from './src/surfaces.js'; import type { Canvas } from './src/types.js'; @@ -179,7 +179,8 @@ function rootStyle(canvas: Canvas): string { const ev = await evaluateCanvas(m, { mode: 'fast' }); check('surface report present with the documented shape', JSON.stringify(Object.keys(ev.surface).sort()) === JSON.stringify(['conflicts', 'device', 'rulesOff', 'rulesOn', 'source', 'type'])); check('mobile report', ev.surface.type === 'mobile' && ev.surface.device === 'iphone-390' && ev.surface.source === 'declared'); - check('slice B: no rule ids registered yet', ev.surface.rulesOn.length === 0 && ev.surface.rulesOff.length === 0 && ev.surface.conflicts.length === 0); + // Slice C registered the mobile ruleset (FR-B3: every mobile rule id is listed). + check('mobile rulesOn lists every mobile rule id plus the base mobile-artboard', JSON.stringify(ev.surface.rulesOn) === JSON.stringify([...MOBILE_RULES, 'mobile-artboard'].sort()) && ev.surface.rulesOff.length === 0 && ev.surface.conflicts.length === 0, JSON.stringify(ev.surface.rulesOn)); const filtered = await evaluateCanvas(m, { mode: 'fast', categories: ['spacing'] }); check('surface present even without the cliche category', filtered.surface?.type === 'mobile' && filtered.genre === undefined); const empty = createCanvas('Empty', undefined, {}, { type: 'mobile' }); @@ -189,6 +190,7 @@ function rootStyle(canvas: Canvas): string { addContent(plain); const evPlain = await evaluateCanvas(plain, { mode: 'fast' }); check('undeclared canvas reports web / default', evPlain.surface.type === 'web' && evPlain.surface.source === 'default' && evPlain.surface.device === null); + check('web rulesOn is only the base mobile-artboard', JSON.stringify(evPlain.surface.rulesOn) === JSON.stringify(['mobile-artboard']), JSON.stringify(evPlain.surface.rulesOn)); // FR-A2 survives the table: material preset + commerce genre relax together. m.metadata = { ...m.metadata, provenance: { preset: 'material', genre: 'commerce' } as never }; From ec3de29c5e95149ff2efaccb0a09fc7a4c6290b4 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 31 Oct 2026 23:43:32 +0100 Subject: [PATCH 3/5] Point spacer advice inside the content frame A spacer directly under the fixed-height root shrinks to nothing on a screen taller than its artboard; one inside the content frame keeps its height. --- docs/GUIDELINES.md | 4 ++-- docs/specs/PHASE-30-SPEC.md | 1 + src/index.ts | 6 +++--- src/mobile-rules.ts | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 8b47771..8f835e5 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -56,9 +56,9 @@ On a `mobile` canvas these rules run. Each finding names its rule in a `rule` fi - **`mobile-safe-area`** (usability, warning). Content under the status bar or the home indicator. - With `safeArea` on the root, the renderer reserves both insets, so the rule checks positioned (`absolute`) content only. - Without it (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding or spacer frames. - - Padding of 32px or more scales down on a phone (48 renders about 13), so a spacer frame of the inset's height is the exact way to clear an inset by hand. + - Padding of 32px or more scales down on a phone (48 renders about 13), so a spacer frame of the inset's height is the exact way to clear an inset by hand. Put spacers inside the frame that holds your content: a spacer directly under the root shrinks when the screen is taller than its artboard, because the root has a fixed height. - Detailed mode checks rendered positions and is the authority. Fast mode cannot place absolute content inside an in-flow frame; it adds an info that says to run detailed mode. - - A screen taller than its artboard ends in the home-indicator inset even with `safeArea`, because the root's bottom padding does not extend past overflowing content. Only detailed mode catches this. End long screens with a 34px spacer frame. + - A screen taller than its artboard ends in the home-indicator inset even with `safeArea`, because the root's bottom padding does not extend past overflowing content. Only detailed mode catches this. End the frame that holds a long screen's content with a 34px spacer frame. - **`mobile-type-floor`** (typography, warning). Text below 11px; a text using `$body` below 16px; prose of five or more words below 13px. Long copy of twelve or more words at 13–15px is an info. Figures and their units are not words, so a metrics line such as `5.2 mi · 1h 12m` is fine at 12px. - **`mobile-one-column`** (usability, info). A horizontal or grid frame that sets prose side by side: two or more children each holding a text of eight or more words. - **`mobile-artboard`** (structure; on for every type). A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px, or one holding mobile chrome, warns until you declare it with `canvas_set_type`, either way. A canvas declared `web` with that shape gets an info instead. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 2dcdc24..1000f8a 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -232,6 +232,7 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *FR-C2 on Ridgeline.* Evaluated as `mobile`, the original Ridgeline reports exactly its three 40×40 run-row icon tiles, as the corrected acceptance says. It reports no `mobile-type-floor` finding: its 11px tab labels are single words and its 13px meta lines are under five words, so FR-C2's parenthetical expecting one was wrong. Its hand-built status bar text warns under `mobile-safe-area` in both modes. - *FR-C7.* None qualified. On Ridgeline evaluated as `mobile`, no existing check fires on a correct mobile idiom: the findings are APCA advisories, the coverage demands for the run list (a real list with no empty or loading variant), and the "no component instances" advisory, which FR-D4 resolves by counting primitives as reuse. The mobile `disable` list stays empty. - *FR-C8.* The directive's `StressState` gains `ranAsType`, set when the type changed since the run, so the stale note names the earlier type instead of an edit. `nav-bar.title` is perturbed by long-text on mobile (a screen title can be long); only the tab labels and the nav bar's action labels are the fixed vocabulary. The primitive prop table and the list-row runs are in `stress.ts` and tested with synthetic nodes; they are documented with the primitives in slice D. + - *Spacers on long screens.* A spacer frame placed directly under the fixed-height root shrinks to nothing when the content is taller than the artboard (flex-shrink), while one inside the content frame keeps its height (rendered: 60 rows plus a root-level 34px spacer still end in the inset; with the spacer inside the rows' frame they clear it). The docs and the rule's suggestions say to put spacers inside the content frame. Fast mode's structural walk still counts a root-level spacer; detailed mode is authoritative, and slice D's root fix removes the shrink. - *FR-C6.* `canvas_set_type` returns `variantsOutOfSync: [{ state, canvasId, type }]` when a variant's resolved type differs from the base's after the change. **Slice D — vocabulary (FR-D1..D5)** diff --git a/src/index.ts b/src/index.ts index 715368a..443fc7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: prose side by side), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame inside your content frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: prose side by side), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -140,7 +140,7 @@ const GOTCHAS = [ 'Every state, every string: a data screen is not done at one static frame with ideal data. Tables demand designed "empty" + "loading" state variants, forms demand "error" (the coverage category warns until they exist — each is one canvas_add_variant + one scaffold stamp: empty-state / skeleton-table / skeleton-card / skeleton-stat-card); skeleton blocks pulse only in the live viewer, never in screenshots. Skeleton EVERY data surface on a loading variant, not just the table — coverage flags a "half-designed" loading state when a region still shows live values beside skeletoned ones. Then canvas_stress before presenting: it renders hostile-but-realistic content (long-text / i18n / big-numbers / empty / many) and reports clips and overflows by node id — fix with fluid widths / minWidth or minHeight floors / wrapping, or textOverflow: "ellipsis" on a label that must stay single-line (that downgrades its clip to info instead of warning), and re-run until CLEAN. canvas_evaluate remembers the last stress verdict: after a FRAGILE run the directive stays NOT READY until a full CLEAN run (all five perturbations) replaces it — a subset run can record FRAGILE but never clear it.', 'Derive, don\'t hand-pick: generate_design_system (seed + REQUIRED personality: technical/editorial/soft/data-dense) writes the whole design language in one call — including $display/$heading/$title/$body/$label typography roles, $elevation.* shadow tokens (reference as shadow: "$elevation.raised"; the dark layer re-states depth), and $motion defaults. For targeted regeneration only: generate_scale gives a modular type + space scale from a ratio (craft defaults baked in; fluid clamp() optional); generate_color_system gives OKLCH ramps + a matched neutral + status colors + the semantic tokens the structures use, AA by construction, WITH a dark theme (sparse dark.colors layer) AND the color range — chart-1…chart-6 for series/segments/legend dots, plus the accent/success/warning/danger/neutral -tint layer for chips/tiles/badges (pair tint as fill with its base color as ink; AA by construction, dark included). Render dark via theme: "dark" on screenshot/screenshot_responsive/export; canvas_evaluate contrast-checks both themes automatically (dark failures point at the dark layer — never write a literal fix; APCA Lc is info-only, WCAG 2.2 gates). Literals that equal a token get re-attached by autofix; timing belongs in $motion tokens.', 'Beyond one canvas: bento/editorial compositions are REAL grid — layout: "grid" + gridColumns (count / fr-weight array / template string) + gridColumn/gridRow spans (a number means span N); responsive: "stack" collapses to one column on mobile. Never approximate tiles with nested flex. When a multi-screen module feels done, project_evaluate reviews the SET — radius-drift / accent-drift / token-adoption / copied-chrome / state-coverage findings, each naming its canvases — and mode "llm" judges up to 8 screens together for flow consistency (flowSkipped lists anything past the cap; keyless → full roll-up + a note). ADVISORY: only the per-canvas directives gate presenting.', - 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. On mobile a control below 44px is an ERROR (mobile-touch-target) and content in the status-bar / home-indicator insets is a WARNING (mobile-safe-area): keep root safeArea, and end a screen taller than its artboard with a 34px spacer frame (detailed mode checks the scroll end). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', + 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. On mobile a control below 44px is an ERROR (mobile-touch-target) and content in the status-bar / home-indicator insets is a WARNING (mobile-safe-area): keep root safeArea, and end the content frame of a screen taller than its artboard with a 34px spacer frame — not a spacer directly under the root, which shrinks (detailed mode checks the scroll end). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', 'Gate integrity: a canvas describing a SHIPPED view is a contract — run canvas_check_drift against the live route BEFORE designing on it (findings: missing-in-page / missing-in-canvas / control-mismatch / table-mismatch), and reconcile deliberately: update the canvas, flag the implementation, or ask — never silently annotate a difference. canvas_sync_from_url answers "how much does it LOOK different" (pixel %); canvas_version makes approvals falsifiable — record { canvasId, versionHash } at approval time and check with expectedHash later (metadata/feedback never moves the hash; ANY inherited workspace/project token edit DOES, even to a token the canvas does not use). A legacy sha256: hash still checks but carries a warning — re-record it as the current sha256r: versionHash. CI can demand both: npx framesmith check-drift / verify exit 1 on failure.', ]; @@ -2079,7 +2079,7 @@ THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional dept The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. -The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame; fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end long screens with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px or holding mobile chrome WARNS until canvas_set_type declares it; declared web with that shape gets an info). None has an autofix.`, +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px or holding mobile chrome WARNS until canvas_set_type declares it; declared web with that shape gets an info). None has an autofix.`, { canvasId: z.string().describe('Canvas ID to evaluate'), mode: z.enum(['fast', 'detailed', 'llm']).default('fast').describe('"fast" = JSON-only (<100ms), "detailed" = + Puppeteer layout checks, "llm" = fast + vision-model rubric critique'), diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts index 0c1e7ff..70181f6 100644 --- a/src/mobile-rules.ts +++ b/src/mobile-rules.ts @@ -183,7 +183,7 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, `"${hit.node.name ?? hit.node.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, - `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)} (the renderer adds them to the root's padding). For a deliberate full-bleed screen, pad the ${edge} so content clears ${inset}px — padding of 32px or more scales down on a phone, so use a spacer frame of that height.`)); + `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)} (the renderer adds them to the root's padding). For a deliberate full-bleed screen, put a ${inset}px spacer frame at the ${edge} of your content frame (padding of 32px or more scales down on a phone, and a spacer directly under the fixed-height root shrinks on a long screen).`)); } } @@ -226,7 +226,7 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, `"${hit.node.name ?? hit.node.id}" inside positioned "${n.name ?? n.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, - `Pad "${n.name ?? n.id}" so its content clears ${inset}px from the ${edge} (a spacer frame keeps its size; padding of 32px or more scales down on a phone).`)); + `Pad "${n.name ?? n.id}" so its content clears ${inset}px from the ${edge} (a spacer frame of that height is exact; padding of 32px or more scales down on a phone).`)); } } }; @@ -268,7 +268,7 @@ export function checkSafeAreaDetailed(ctx: MobileContext, layout: LayoutRect[]): issues.push(issue('usability', 'mobile-safe-area', 'warning', n, `"${n.name ?? n.id}" renders in ${where} — under the ${where.startsWith('the top') ? 'status bar' : 'home indicator'}.`, insets.topReserved && insets.bottomReserved - ? 'Keep positioned content inside the safe area, and end long screens with bottom padding or a spacer of the inset height.' + ? 'Keep positioned content inside the safe area. On a screen taller than its artboard, end the frame that holds the content with a spacer frame of the inset height (not a spacer directly under the root, which shrinks).' : `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)}.`)); } } From 6f357a97b637c58387c761c405b41f80c510e2d9 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 00:21:37 +0100 Subject: [PATCH 4/5] Close slice C verifier findings on targets, safe areas and docs Stretching rows count as full width and full-height rows don't; positioned images are background; the safe-area walks cache per tree instead of copying ancestors. Docs name detailed mode, the padding formula and the fixes. Tests gain the mutation round's boundary fixtures. --- README.md | 17 ++-- VISION.md | 2 +- docs/GUIDELINES.md | 20 ++--- docs/specs/PHASE-30-SPEC.md | 4 + src/evaluate.ts | 15 ++-- src/index.ts | 10 +-- src/mobile-rules.ts | 85 +++++++++++++------- test-mobile-rules-detailed.ts | 33 ++++++++ test-mobile-rules.ts | 141 ++++++++++++++++++++++++++++++++++ 9 files changed, 272 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index d73abff..af4adb3 100644 --- a/README.md +++ b/README.md @@ -819,7 +819,7 @@ The **`directive`** is the operating contract that keeps unfinished work off the Open point-and-tell comments block too: when the canvas has any, the result carries `openFeedback: n` and the directive stays blocking even at a READY score — the human's note outranks the heuristics. Read them with `get_feedback`, address each, close them with `resolve_feedback`. -A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, type, current }`, where `type` is the canvas type the run used and `current` says whether that run was on the canvas's present `versionHash` and type. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When `current` is false the directive tells you to re-run stress, naming whether an edit or a type change made the run stale; the verdict still blocks. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. +A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict for the canvas, the result carries **`stress`**: `{ verdict, warnings, type, current }`, where `type` is the canvas type the run used and `current` says whether that run was on the canvas's present `versionHash` and type. A `FRAGILE` verdict keeps the directive `NOT READY` even with zero evaluate issues, until a full `CLEAN` run (all five perturbations) replaces it; a subset run can record `FRAGILE` but never clear it. When a `FRAGILE` run's `current` is false the directive tells you to re-run stress, naming whether an edit or a type change made it stale; the verdict still blocks. A stale `CLEAN` run only reads `current: false`. The verdict lives in server memory, so a restart forgets it. `canvas_evaluate` never runs stress itself. Whenever the `cliche` category runs, the result also carries a **`genre`** field auditing the genre decision: `{ active, source, relaxed, notRelaxed, byPreset? }` — the genre in effect (canonical: a `"dashboard"` stamp reports `"data"`, a `"checkout"` one `"commerce"`), whether it came from the `genre` param or the canvas's provenance stamp, the tells relaxed, `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax — and `byPreset` (`{ preset, relaxed }`), present when the applied preset relaxed tells on its own (the material preset relaxes accent-hue and pure-black-white whatever the genre). A score pinned by tells listed in `notRelaxed` usually means the wrong genre was declared. @@ -829,10 +829,10 @@ Every result also carries a **`surface`** field: `{ type, device, source, rulesO |---|---|---|---| | `mobile-touch-target` | usability | error | A control whose touch target is below 44px. The target extends to its row only when the row is a horizontal frame holding that one control. | | `mobile-handbuilt-target` | usability | info | A frame shaped like a tap target (one icon, or an icon plus a label of up to three words) whose box is below 44px. | -| `mobile-safe-area` | usability | warning | Content under the 47px status-bar or 34px home-indicator inset. With the root's `safeArea` only positioned content can get there; without it, the first and last content must clear the insets. Detailed mode checks rendered positions and also catches a long screen that ends in the bottom inset. | -| `mobile-type-floor` | typography | warning | Text below 11px, `$body` below 16px, or prose of five or more words below 13px (twelve-word copy at 13–15px is an info). | -| `mobile-one-column` | usability | info | Prose set side by side in a horizontal or grid frame. | -| `mobile-artboard` | structure | warning / info | Runs on every type. A mobile canvas 500px or wider warns. An undeclared canvas narrower than 500px warns until `canvas_set_type` declares it. | +| `mobile-safe-area` | usability | warning | Content under the 47px status-bar or 34px home-indicator inset. In fast mode, with the root's `safeArea` only positioned content can get there; without it, the first and last content must clear the insets. Padding of 32px or more scales down on a phone, so clear an inset with a spacer frame inside the content frame. Detailed mode checks rendered positions and also catches a long screen that ends in the bottom inset. | +| `mobile-type-floor` | typography | warning | Text below 11px, `$body` below 16px, or prose of five or more words below 13px (copy of twelve or more words at 13–15px is an info). | +| `mobile-one-column` | usability | info | Prose set side by side in a horizontal or grid frame: two or more children each holding eight or more words. | +| `mobile-artboard` | structure | warning / info | Runs on every type. A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px warns until `canvas_set_type` declares it; one declared `web` gets an info. | Whenever the `coverage` category runs on a **base** canvas, the result also carries a **`coverage`** field: `{ dataBearing, states, missing }` — whether the screen carries data-bearing content, which state variants (`canvas_add_variant`) already exist, and which demanded states are missing. It's absent on variant canvases (a variant *is* a designed state, so it doesn't demand states of itself). @@ -849,8 +849,8 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr |----------|--------|--------| | `spacing` | 20 | Off-scale padding/gap values (`gap: 0`, a flush stack, is never flagged), plus spacing variety measured against the canvas's **declared** scale (`set_variables`/`generate_scale` spacing tokens) — only values *not* on that scale count toward sprawl, so a design built entirely from its own multi-step scale (framesmith's generated scale ships nine: `space-3xs`…`space-3xl`) isn't penalized for using it. No declared scale falls back to the old total-unique-values count, with a suggestion to declare one | | `color` | 25 | WCAG AA contrast ratios for text against nearest background — checked in BOTH themes when a `dark` token layer exists (dark-run issues carry `theme: "dark"`, score takes the worse theme, and dark failures point at the dark layer instead of carrying a literal fix). WCAG-passing pairs that are perceptually weak by **APCA** get an info advisory (Lc vs ~75 body / ~60 large) — APCA is a candidate method, not a standard; it never blocks | -| `typography` | 20 | Type-scale ratios (1.1–2.0; adjacent sizes both declared as typography tokens are pinned and skip the check), font-family count, weight variation — plus score-neutral craft advisories: measure (prose past ~75 characters per line; warning past 90), tracking (display text ≥28px without negative letter-spacing, autofixable), and tabular numerals (numeric table columns without `tabularNums`, autofixable) | -| `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable when one spelling resolves) | +| `typography` | 20 | Type-scale ratios (1.1–2.0; adjacent sizes both declared as typography tokens are pinned and skip the check), font-family count, weight variation — plus score-neutral craft advisories: measure (prose past ~75 characters per line; warning past 90), tracking (display text ≥28px without negative letter-spacing, autofixable), and tabular numerals (numeric table columns without `tabularNums`, autofixable) On a mobile canvas `mobile-type-floor` adds −10 per warning. | +| `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable when one spelling resolves) `mobile-artboard` adds −10 per warning, on every type. | | `consistency` | 20 | Frames missing `layout`, sibling overlap (detailed mode) — plus score-neutral advisories: **token detachment** (a literal `fill`/`stroke`/`color`/`cornerRadius` that exactly equals a token's value flags with a mechanical `$ref` autofix; ambiguous matches list candidates and never guess) and **ad-hoc motion** (3+ distinct duration/easing combos with no motion tokens declared) | | `cliche` | 15 | Machine-made tells: default purple/indigo accent, gradient/glow overuse, fake browser/OS chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated metrics/testimonials/logos, eyebrow rhythm (an eyebrow above nearly every section), slop copy (stock AI phrasing — filler verbs, scroll cues, placeholder names, hype labels), radius consistency (too many distinct corner radii), pure black/white (`#000000` ink / `#ffffff` page vs off-black/off-white), accent consistency (multiple competing accent hues — the `$success`/`$warning`/`$danger` status vocabulary is exempt when referenced as tokens, since those are semantics rather than a second accent; the same hues as literals still count). Each issue carries a `tell` discriminator. Tells are warning or info severity, but **every tell blocks the directive**, whatever its severity (see the `directive` above). Relaxable per `genre`. The tells know dashboard vocabulary: a violet reached only through a `$chart-*` or `$*-tint` token doesn't count as the default-purple tell (a literal violet, or one through any other token, still does), a gradient reached through a `$token` doesn't count toward gradient overuse, thin rounded sparkline bars aren't mistaken for traffic-light dots (fake-chrome now requires a near-square shape), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric, not an eyebrow. It also knows retail copy: a numeral bound to a unit noun by a tight hyphen (`"30-day returns"`, `"2-year warranty"`, `"24/7 support"`) is a compound modifier, not a numbered section eyebrow. | | `coverage` | 10 | State coverage on **base** canvases with data-bearing content: a detected table demands designed `empty` + `loading` variants, a form (3+ input controls) demands `error` — each missing state is a directive-blocking warning whose suggestion names the `canvas_add_variant` + scaffold path. Variant canvases and non-data screens get no findings. Table detection needs three matching horizontal rows to read as data (two stacked rows are treated as a layout, e.g. a topbar over a greeting); an import-named `Table` frame keeps the looser two-row shape. On a **`loading`** variant itself, a warning fires per data surface (a detected table, or a frame with 2+ large tabular/numeric texts) that still shows live values while a sibling region is skeletoned — "Loading state is half-designed" — naming the region and suggesting the matching skeleton scaffold. The result's `coverage` field reports `{ dataBearing, states, missing }`. | @@ -876,12 +876,15 @@ The weights are relative, not points out of 100. Each category scores 0–100, a "summary": "Overall quality: Good (87/100). Strongest: spacing (90/100). Weakest: color (75/100)...", "stats": { "totalNodes": 14, "textNodes": 5, "frameNodes": 8, "maxDepth": 4, "tokenUsagePercent": 61, "componentReusePercent": 0 }, "mode": "fast", + "surface": { "type": "web", "device": null, "source": "default", "rulesOn": ["mobile-artboard"], "rulesOff": [], "conflicts": [] }, "genre": { "active": null, "source": null, "relaxed": [], "notRelaxed": [{ "tell": "accent-hue", "relaxedBy": ["material"] }] }, "coverage": { "dataBearing": true, "states": [], "missing": ["empty", "loading"] }, "directive": "NOT READY — 87/100 with 1 issue(s) to resolve. Fix them now: canvas_autofix for the mechanical subset, batch_design for the rest (cliché tells included), then re-run canvas_evaluate. Repeat until there are zero warnings and zero cliché tells. Do NOT show this design to the user yet." } ``` +An issue produced by a ruleset rule also carries `rule` (its id, e.g. `"mobile-touch-target"`). + **With `mode: "llm"`**, the vision model scores a **fixed rubric** — five axes, each 1–5 with a rationale — instead of one opaque number. The verdict is stamped on the canvas (`metadata.critique`) and the per-project build log so quality is auditable over time. Add `floor` (1–5, default 3, or `FRAMESMITH_CRITIQUE_FLOOR`) to set the per-axis threshold that trips `needsRevision`. **Without an `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, the call doesn't fail** — the full heuristic result still returns with an `llmNote` explaining the critique was skipped, and the heuristic directive alone decides whether the design is ready. **The critique never gates presenting, with or without a key.** The heuristic `directive` is the only presentation gate. With a key, the critique loop is the standard last step: judge, run `canvas_revise` on the failing axes, and re-judge. If `canvas_revise` stops (`max-iterations` or `no-improvement`) and `needsRevision` is still `true`, present the design anyway and tell the user which axes still fail. A judge the agent can't satisfy by improving the design would strand it, and a model verdict varies from run to run. diff --git a/VISION.md b/VISION.md index 9b75352..33bd00b 100644 --- a/VISION.md +++ b/VISION.md @@ -547,7 +547,7 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [x] 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 - [x] 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` -- [x] 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 +- [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives join the set as they land), 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 diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 8f835e5..3692ed5 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -41,7 +41,7 @@ Pick the right `width` per node — this is the single biggest lever for respons A canvas declares what medium it is on, separately from its genre (what it is for) and its preset (what style it uses). The declaration selects a ruleset and the defaults the generators use. The declaration itself never changes a pixel: what renders comes only from the scene, so the artboard size and `safeArea` that `canvas_create` writes into the root are what change the render. - **A phone app screen is `canvas_create({ type: "mobile" })`**, not a 390px-wide web canvas. It gets the device artboard (390×844 for the default `device: "iphone-390"`, the only profile today) and the root reserves the device's insets as `safeArea: { top: 47, bottom: 34 }`. An explicit `width` or `height` wins for that side; the insets and the device stay the profile's. Passing `device` without `type: "mobile"`, or an unknown device, is an error. `canvas_create` reports `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea`. -- **The renderer adds the insets to the root's own top and bottom padding**, at exact pixels even when the authored padding scales fluidly (padding of 32px or more), and the root's fill paints the bands, so in-flow content never sits under the status bar or the home indicator. A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. +- **The renderer adds the insets to the root's own top and bottom padding**, at exact pixels even when the authored padding scales fluidly (padding of 32px or more), and the root's fill paints the bands, so on a screen that fits its artboard in-flow content never sits under the status bar or the home indicator. A screen taller than its artboard still ends in the bottom inset; see [the mobile ruleset](#the-mobile-ruleset). A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. - **`type: "web"` is the default.** An undeclared canvas is judged as web and its evaluate result says `source: "default"`. - **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, or `null` to clear). It writes the declaration only, so it never moves the `versionHash`. It never resizes the artboard and never writes or removes `safeArea`, because either would reflow what you placed; clearing the type leaves the root as it is. When a mobile result has a `hint`, follow it: it names `U("document", { safeArea: { top: 47, bottom: 34 } })` when the root doesn't reserve both insets, and `U("document", { width: 390, height: 844 })` when the root is another size. Both ops change the render, so re-check the layout after them. State variants keep the type they were created with, so re-declaring a base does not reach them: the result's `variantsOutOfSync` lists each variant whose type now differs (`{ state, canvasId, type }`); declare those too. - **`generate_design_system` on a mobile canvas** pivots the type scale on the device body size: 17px for every personality except `data-dense`, which uses 16px. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"` for both mobile pivots, `"explicit"`, or `"personality"` on web). A `$body` inherited from a workspace or project design system is preserved by default and listed in `designSystemConflicts`; pass `preserveInherited: false` to take the mobile body. @@ -49,21 +49,21 @@ A canvas declares what medium it is on, separately from its genre (what it is fo ## The mobile ruleset -On a `mobile` canvas these rules run. Each finding names its rule in a `rule` field, and the `surface` report lists the rules in `rulesOn`. Errors and warnings block the directive like any other finding; infos never do. +On a `mobile` canvas these rules run. Each finding names its rule in a `rule` field, and the `surface` report lists the rules in `rulesOn`. Errors and warnings from these rules block the directive like any other finding, on every type; infos never do. Fast mode (`canvas_evaluate` with the default `mode: "fast"`) is what you run in the loop; before presenting a phone screen, also run `canvas_evaluate({ canvasId, mode: "detailed" })`, which checks rendered positions. -- **`mobile-touch-target`** (usability, **error**). A control (toggle, checkbox, radio, select) whose touch target is below 44px in either dimension. The target extends to the control's row only when that row is a horizontal frame holding this one control: a toggle in a 56px settings row passes, and a lone checkbox inside a padded card is measured at its own size. On web canvases the 24px error and the 44px phone-width info are unchanged. +- **`mobile-touch-target`** (usability, **error**). A control (toggle, checkbox, radio, select) whose touch target is below 44px in either dimension. The target extends to the control's row only when that row is a horizontal frame holding this one control: a toggle in a 56px settings row passes, and a lone checkbox inside a padded card is measured at its own size. To fix one, grow the control or put it alone in a horizontal row at least 44px tall. On web canvases the 24px error and the 44px phone-width info are unchanged. - **`mobile-handbuilt-target`** (usability, info). A frame shaped like a tap target (one icon, or one icon and a label of three words or fewer) whose box is below 44px. It never blocks, because a frame can't say it is decorative. If it is tappable, make it at least 44×44. - **`mobile-safe-area`** (usability, warning). Content under the status bar or the home indicator. - - With `safeArea` on the root, the renderer reserves both insets, so the rule checks positioned (`absolute`) content only. - - Without it (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding or spacer frames. - - Padding of 32px or more scales down on a phone (48 renders about 13), so a spacer frame of the inset's height is the exact way to clear an inset by hand. Put spacers inside the frame that holds your content: a spacer directly under the root shrinks when the screen is taller than its artboard, because the root has a fixed height. + - With `safeArea` on the root, the renderer reserves both insets, so fast mode checks positioned (`absolute`) content only. An absolute node's `y` is measured from the top of the artboard when its parent is the root: keep it at 47 or more, and its bottom edge above 810 (844 − 34). + - Without it (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding or spacer frames. Content means text, icons, controls, component instances, and images narrower than the artboard; a full-width image or a filled frame can sit under the status bar. So a full-bleed header is a frame whose fill runs to the top and whose first child is a 47px spacer frame. With root `safeArea` the root's fill paints the inset bands instead, so a header colour can't reach under the status bar. + - Padding of 32px or more scales down on a phone: it renders at its value × 390 / 1440, but never below a quarter of its value (min 8). So 48 renders 13 and 64 renders 17. A spacer frame of the inset's height is the exact way to clear an inset by hand. Put spacers inside the frame that holds your content: a spacer directly under the root shrinks when the screen is taller than its artboard, because the root has a fixed height. - Detailed mode checks rendered positions and is the authority. Fast mode cannot place absolute content inside an in-flow frame; it adds an info that says to run detailed mode. - A screen taller than its artboard ends in the home-indicator inset even with `safeArea`, because the root's bottom padding does not extend past overflowing content. Only detailed mode catches this. End the frame that holds a long screen's content with a 34px spacer frame. -- **`mobile-type-floor`** (typography, warning). Text below 11px; a text using `$body` below 16px; prose of five or more words below 13px. Long copy of twelve or more words at 13–15px is an info. Figures and their units are not words, so a metrics line such as `5.2 mi · 1h 12m` is fine at 12px. +- **`mobile-type-floor`** (typography, warning). Text below 11px; a text whose `fontSize` is `$body` and resolves below 16px; prose of five or more words below 13px. Long copy of twelve or more words at 13–15px is an info. Each text gets at most one finding, the first of these that applies. Words hold letters and no digits: figures and their units are not words, so a metrics line such as `5.2 mi · 1h 12m` is fine at 12px. - **`mobile-one-column`** (usability, info). A horizontal or grid frame that sets prose side by side: two or more children each holding a text of eight or more words. -- **`mobile-artboard`** (structure; on for every type). A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px, or one holding mobile chrome, warns until you declare it with `canvas_set_type`, either way. A canvas declared `web` with that shape gets an info instead. +- **`mobile-artboard`** (structure; on for every type). A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px warns until you declare it, either way: `canvas_set_type({ type: "mobile" })` for a phone app screen (then follow its `hint`, which adds the root `safeArea`; for a new screen, `canvas_create({ type: "mobile" })` does both at once), or `canvas_set_type({ type: "web" })` for a web page designed at that width. A canvas declared `web` and narrower than 500px gets an info instead. -None of these rules has a mechanical fix, so `canvas_autofix` never proposes one. On a mobile canvas `canvas_stress` leaves strings of three characters or fewer (monograms, short labels) out of `long-text`. It records the type it ran under; after a type change the evaluate result's `stress.current` is false and the directive asks for a re-run, but a `FRAGILE` verdict still blocks. +None of these rules has a mechanical fix, so `canvas_autofix` never proposes one. On a mobile canvas `canvas_stress` leaves strings of three characters or fewer (monograms, short labels) out of `long-text`. It records the type it ran under; after a type change the evaluate result's `stress.current` is false. A `FRAGILE` verdict keeps blocking and the directive asks for a re-run; a stale `CLEAN` one just reads `current: false`. ## Responsive hints @@ -269,7 +269,7 @@ Exit codes are gate-friendly for both commands: `0` pass, `1` drift/mismatch, `2 ## After designing -- **`canvas_evaluate`** scores the design on 8 categories (spacing, color, typography, structure, consistency, cliche, coverage, usability) and surfaces actionable issues with `nodeId` references — see "Design every state" below for what `coverage` demands. The `usability` category holds the mechanical UX floors beyond contrast: a control below the 24px hit-target floor is an **error** (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass), an unlabeled control is a warning, and vague action copy ("Click here") is an info. The `structure` category also warns — directive-blocking — on unknown icon names (an empty slot in the render). Controls also render with accent-derived `:focus-visible` rings in the live viewer — screenshots stay byte-identical because nothing is focused in a static capture. Use it in a generator-evaluator loop: `batch_design` → `canvas_evaluate` → fix the returned nodeIds. +- **`canvas_evaluate`** scores the design on 8 categories (spacing, color, typography, structure, consistency, cliche, coverage, usability) and surfaces actionable issues with `nodeId` references — see "Design every state" below for what `coverage` demands. The `usability` category holds the mechanical UX floors beyond contrast: a control below the 24px hit-target floor is an **error** (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass; on a canvas declared mobile the floor is 44px instead, `mobile-touch-target` in [the mobile ruleset](#the-mobile-ruleset)), an unlabeled control is a warning, and vague action copy ("Click here") is an info. The `structure` category also warns — directive-blocking — on unknown icon names (an empty slot in the render). Controls also render with accent-derived `:focus-visible` rings in the live viewer — screenshots stay byte-identical because nothing is focused in a static capture. Use it in a generator-evaluator loop: `batch_design` → `canvas_evaluate` → fix the returned nodeIds. - **`canvas_autofix`** runs `canvas_evaluate` internally and returns just the subset of issues that have a mechanically derived fix — off-scale spacing snaps to scale (`gap`, scalar padding, and array padding via a whole-array snap), missing layout becomes `vertical`, recoverable WCAG contrast failures get `#000` or `#FFF` based on background luminance, and an unknown icon name with exactly one resolving spelling is rewritten to it. Pass `apply: true` to write every fix in the same call (the result reports applied/failed per op), or run the returned ops via `batch_design` yourself — either way, re-evaluate after. Closes the loop without judgment calls on your part. - **`canvas_evaluate` with `mode: "llm"`** runs fast-mode heuristics plus a vision-model critique against a **fixed rubric** (Claude or GPT-4.1, picked from env). Returns the heuristic result with an extra `llmCritique` field: five axes — **hierarchy, execution, specificity, restraint, variety** — each scored 1–5 with a rationale, plus a derived overall, `summary`, `suggestions`, and `needsRevision` / `failingAxes` (any axis below the `floor`, default 3). The verdict is stamped on the canvas + build log so quality is auditable over time. Use this for the "is this visually well-designed?" question heuristics can't answer — composition, hierarchy, polish. Costs one API call per run; reach for it after the heuristic score plateaus. - **`canvas_revise`** closes the loop: it judges, and for any failing axis asks the model for targeted `batch_design` ops, applies them, and re-judges — up to `maxIterations` passes (1–3). It **mutates the canvas**, reverts any pass that doesn't improve the overall, and stops on pass / cap / no-improvement. Opt-in and costly (≥2 API calls per pass); reach for it when you want the model to act on its own critique instead of you hand-translating it. It never gates presenting: if it stops with axes still failing, present and name them. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 1000f8a..b81392c 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -233,6 +233,10 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *FR-C7.* None qualified. On Ridgeline evaluated as `mobile`, no existing check fires on a correct mobile idiom: the findings are APCA advisories, the coverage demands for the run list (a real list with no empty or loading variant), and the "no component instances" advisory, which FR-D4 resolves by counting primitives as reuse. The mobile `disable` list stays empty. - *FR-C8.* The directive's `StressState` gains `ranAsType`, set when the type changed since the run, so the stale note names the earlier type instead of an edit. `nav-bar.title` is perturbed by long-text on mobile (a screen title can be long); only the tab labels and the nav bar's action labels are the fixed vocabulary. The primitive prop table and the list-row runs are in `stress.ts` and tested with synthetic nodes; they are documented with the primitives in slice D. - *Spacers on long screens.* A spacer frame placed directly under the fixed-height root shrinks to nothing when the content is taller than the artboard (flex-shrink), while one inside the content frame keeps its height (rendered: 60 rows plus a root-level 34px spacer still end in the inset; with the spacer inside the rows' frame they clear it). The docs and the rule's suggestions say to put spacers inside the content frame. Fast mode's structural walk still counts a root-level spacer; detailed mode is authoritative, and slice D's root fix removes the shrink. + - *FR-C1 rows.* A full width (`"100%"`, `fill_container`) counts as the artboard width on the width axis only; a full height says nothing and falls back to the control plus padding. A row with no `width` inside a parent that is neither horizontal nor grid and does not set `alignItems` (or sets `stretch`) stretches, so it counts as full width; without this, a 44px row built exactly as the suggestion says still failed at 18×44. + - *Positioned images.* An absolute image with a non-numeric width is background: its percentage width is of its containing block's padding box, so ancestor padding does not inset it (fast mode had called a full-bleed absolute hero in a padded root content, where detailed mode did not). + - *Words in FR-C2.* "Three words or fewer" counts prose words (the FR-C4 definition), so `Add 2 more items` is three words and tap-shaped. Info only. + - *Cost.* The safe-area and hand-built-target walks share one parent map and one in-flow-content cache per evaluated tree, so they stay near-linear in nesting depth (an 800-deep chain took 1.4–2s before). - *FR-C6.* `canvas_set_type` returns `variantsOutOfSync: [{ state, canvasId, type }]` when a variant's resolved type differs from the base's after the change. **Slice D — vocabulary (FR-D1..D5)** diff --git a/src/evaluate.ts b/src/evaluate.ts index 742ec69..9f52641 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -1845,13 +1845,18 @@ function mobileTarget( for (let hop = 0; hop < 2 && cur; hop++) { if (cur.type === 'frame' && cur.layout === 'horizontal' && countControls(cur) === 1) { const pad = paddingSides(cur, rootWidth); - const side = (v: SceneNode['width'], min: SceneNode['minWidth'], own: number, padding: number): number => { - const full = v === '100%' || v === 'fill_container'; - const sized = typeof v === 'number' ? v : full ? rootWidth : own + padding; + // A width-less row in a vertical (or layout-less) parent that doesn't + // set cross-axis alignment stretches to the parent's width. + const host = parentOf.get(cur.id) ?? null; + const stretches = cur.width === undefined && host !== null && host.layout !== 'horizontal' && host.layout !== 'grid' + && (host.alignItems === undefined || host.alignItems === 'stretch'); + // A full width spans the artboard; a full height says nothing about it. + const side = (v: SceneNode['width'], min: SceneNode['minWidth'], own: number, padding: number, full: number | undefined): number => { + const sized = typeof v === 'number' ? v : full !== undefined && (v === '100%' || v === 'fill_container') ? full : own + padding; return Math.max(sized, typeof min === 'number' ? min : 0); }; - tw = Math.max(tw, side(cur.width, cur.minWidth, w, pad.left + pad.right)); - th = Math.max(th, side(cur.height, cur.minHeight, h, pad.top + pad.bottom)); + tw = Math.max(tw, stretches ? rootWidth : side(cur.width, cur.minWidth, w, pad.left + pad.right, rootWidth)); + th = Math.max(th, side(cur.height, cur.minHeight, h, pad.top + pad.bottom, undefined)); } cur = parentOf.get(cur.id) ?? null; } diff --git a/src/index.ts b/src/index.ts index 443fc7f..656e978 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame inside your content frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: prose side by side), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so on a screen that fits its artboard in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame inside your content frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -112,7 +112,7 @@ const WORKFLOW_CHEATSHEET = [ 'Final polish when an LLM API key is present: canvas_evaluate mode "llm" (the five-axis vision rubric) → canvas_revise on failing axes → re-judge until it passes or canvas_revise stops. The heuristic gate is the floor; the critique loop is the ceiling — make it the standard last step before presenting, not an exception. It never blocks: still-failing axes after canvas_revise stops get named to the user, not looped on. Keyless sessions skip it (a note says so), nothing else changes.', 'One canvas per screen / state; let the per-project build log nudge you to vary structure.', 'Data screens: design every state — canvas_add_variant for "empty" / "loading" ("error" for forms) + the empty-state / skeleton-table / skeleton-stat-card scaffolds (coverage warns until they exist) — and run canvas_stress before presenting (fix clips/overflows with fluid widths, then re-run until CLEAN — a FRAGILE run keeps canvas_evaluate NOT READY until a full CLEAN run).', - 'Usability gates like contrast does: a control under the 24px hit-target floor is an ERROR (WCAG 2.5.8; a lone control inherits its padded row\'s box), an unlabeled control is a warning, "Click here"-style copy is an info. The stock scaffolds already pass — hand-built controls are where these bite.', + 'Usability gates like contrast does: a control under the 24px hit-target floor is an ERROR (WCAG 2.5.8; a lone control inherits its padded row\'s box; on a canvas declared mobile the floor is 44px, mobile-touch-target), an unlabeled control is a warning, "Click here"-style copy is an info. The stock scaffolds already pass — hand-built controls are where these bite.', 'Derive the system before drawing: generate_design_system (seed + personality → the full design language: colors, font pairing, type roles, radii, $elevation depth, $motion) is the headline call; generate_scale / generate_color_system regenerate just the scale or just the palette — then reference $tokens everywhere and let the lint re-attach any literal that drifts.', 'Multi-screen module? Finish as a SET: project_evaluate rolls up per-screen scores + cross-screen drift (radius / accent / token adoption / copied chrome / states) — advisory, the per-canvas directives still gate; mode "llm" adds the flow critique. Compose bento/editorial layouts with real grid (layout "grid" + spans), never nested flex.', 'Picking up an existing canvas? get_feedback first — point-and-tell comments may be waiting (node-anchored or canvas-level). Address every open item, then resolve_feedback with a note saying what changed.', @@ -173,7 +173,7 @@ server.resource( // --- canvas_create --- server.tool( 'canvas_create', - 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, the artboard `width`/`height`, viewer URL, and a `diversification` signal — the recently-built structures in this project plus a hint to differ on at least one taxonomy axis, so successive canvases don\'t converge on the same layout. Always share the viewer URL with the user so they can see the design live in their browser. If `projectId` is omitted, the canvas lands in the default Untitled project. The artboard defaults to 1440×900; pass `width`/`height` for another size. Building a PHONE APP screen? Pass `type: "mobile"` — it declares the canvas a phone screen (it selects the mobile ruleset, and generate_design_system gives it a 17px body) and gives you the device artboard (390×844 for the default `device: "iphone-390"`) with the root reserving the device\'s safe-area insets as `safeArea: { top: 47, bottom: 34 }` (the renderer adds them to the root\'s top/bottom padding, so in-flow content never sits under the status bar or home indicator). `type: "web"` (the default) declares a web page. The result reports `type`, `typeSource` ("declared" | "default"), and for mobile `device` + `safeArea`. Change it later with canvas_set_type.', + 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, the artboard `width`/`height`, viewer URL, and a `diversification` signal — the recently-built structures in this project plus a hint to differ on at least one taxonomy axis, so successive canvases don\'t converge on the same layout. Always share the viewer URL with the user so they can see the design live in their browser. If `projectId` is omitted, the canvas lands in the default Untitled project. The artboard defaults to 1440×900; pass `width`/`height` for another size. Building a PHONE APP screen? Pass `type: "mobile"` — it declares the canvas a phone screen (it selects the mobile ruleset, and generate_design_system gives it a 17px body) and gives you the device artboard (390×844 for the default `device: "iphone-390"`) with the root reserving the device\'s safe-area insets as `safeArea: { top: 47, bottom: 34 }` (the renderer adds them to the root\'s top/bottom padding, so on a screen that fits its artboard in-flow content never sits under the status bar or home indicator). `type: "web"` (the default) declares a web page. The result reports `type`, `typeSource` ("declared" | "default"), and for mobile `device` + `safeArea`. Change it later with canvas_set_type.', { name: z.string().optional().describe('Name for the canvas'), projectId: z.string().optional().describe('Project to create the canvas in. Defaults to the built-in Untitled project. Use project_list to see available projects.'), @@ -2073,13 +2073,13 @@ server.tool( - "fast": JSON-only, <100ms, deterministic heuristics only. - "detailed": adds Puppeteer-based pixel overlap detection in the consistency category, laid out at the canvas's artboard size. - "llm": fast-mode heuristics plus a vision-model critique against a FIXED rubric (provider picked from FRAMESMITH_LLM_PROVIDER env var, or whichever of ANTHROPIC_API_KEY / OPENAI_API_KEY is set). Adds an "llmCritique" field: { rubric: { hierarchy, execution, specificity, restraint, variety } each {score 1-5, rationale}, score (0-100 derived), summary, suggestions, needsRevision, failingAxes }. The verdict is stamped on the canvas (metadata.critique) + the per-project build log for auditability. Cost: one paid API call per invocation. To CLOSE the loop and auto-fix failing axes, use canvas_revise. -Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, type, current }) appears once canvas_stress has recorded a verdict for this canvas — type is the canvas type the run used; current says whether that run was on the canvas's present versionHash AND type. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when current is false the directive says to re-run stress, naming whether an edit or a type change made the run stale (the FRAGILE verdict still blocks either way). The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. +Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, type, current }) appears once canvas_stress has recorded a verdict for this canvas — type is the canvas type the run used; current says whether that run was on the canvas's present versionHash AND type. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when a FRAGILE run's current is false the directive says to re-run stress, naming whether an edit or a type change made it stale (the verdict still blocks); a stale CLEAN run just reads current: false. The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional depth (a vision-model rubric critique — composition, hierarchy, polish) on top; it requires an ANTHROPIC_API_KEY or OPENAI_API_KEY and fails gracefully without one — the FULL heuristic result still returns with an llmNote explaining what's missing, and the heuristic directive alone decides. With a key it still decides: a needsRevision verdict never blocks presenting — run canvas_revise, and if it stops with axes still failing, present and name them. Screens whose figures are the user's own data: declare genre: "data" so the design's own realistic figures aren't flagged as fabricated; transactional screens (cart/checkout/order/billing) pass genre: "commerce" for the same reason (see the genre param). The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. -The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px or holding mobile chrome WARNS until canvas_set_type declares it; declared web with that shape gets an info). None has an autofix.`, +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding; a row with no width inside a vertical parent stretches, so it counts as full width), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it; declared web and narrower than 500px gets an info). None has an autofix.`, { canvasId: z.string().describe('Canvas ID to evaluate'), mode: z.enum(['fast', 'detailed', 'llm']).default('fast').describe('"fast" = JSON-only (<100ms), "detailed" = + Puppeteer layout checks, "llm" = fast + vision-model rubric critique'), diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts index 70181f6..2c146a6 100644 --- a/src/mobile-rules.ts +++ b/src/mobile-rules.ts @@ -88,23 +88,53 @@ const textOf = (n: SceneNode): string | undefined => const isPositioned = (n: SceneNode): boolean => n.position !== undefined && POSITIONED.has(n.position); +/** Parent links and the in-flow-content answer per node, built once per + * evaluated tree: the walks below ask about the same subtrees repeatedly, and + * recomputing would be cubic in nesting depth. */ +interface TreeIndex { parentOf: Map; flow: Map } +const indexes = new WeakMap(); + +function indexFor(ctx: MobileContext): TreeIndex { + let index = indexes.get(ctx.root); + if (!index) { + const parentOf = new Map(); + (function link(n: SceneNode) { for (const c of n.children ?? []) { parentOf.set(c, n); link(c); } })(ctx.root); + index = { parentOf, flow: new Map() }; + indexes.set(ctx.root, index); + } + return index; +} + /** Content per the spec's Terms: text, icons, controls, the tappable * primitives, instances (opaque — they may hold content), and images narrower * than the artboard. Everything else is background. */ -function isContent(n: SceneNode, ancestors: SceneNode[], ctx: MobileContext): boolean { +function isContent(n: SceneNode, ctx: MobileContext): boolean { if (n.type === 'text') return textOf(n) !== undefined; if (CONTENT_TYPES.has(n.type)) return true; if (n.type !== 'image') return false; if (isNum(n.width)) return n.width < ctx.width; // A non-numeric width spans the artboard only if nothing above it insets it. - return ancestors.some((a) => { const p = paddingSides(a, ctx.width); return p.left + p.right > 0; }); + // A positioned image's percentage width is of its containing block's padding + // box, so ancestor padding never insets it. + if (isPositioned(n)) return false; + const { parentOf } = indexFor(ctx); + for (let a = parentOf.get(n); a; a = parentOf.get(a)) { + const p = paddingSides(a, ctx.width); + if (p.left + p.right > 0) return true; + } + return false; } /** Whether any in-flow content sits beneath `n` (positioned subtrees are * placed on their own). */ -function holdsFlowContent(n: SceneNode, ancestors: SceneNode[], ctx: MobileContext): boolean { - const next = [...ancestors, n]; - return (n.children ?? []).some((c) => !isPositioned(c) && (isContent(c, next, ctx) || holdsFlowContent(c, next, ctx))); +function holdsFlowContent(n: SceneNode, ctx: MobileContext): boolean { + const { flow } = indexFor(ctx); + let held = flow.get(n); + if (held === undefined) { + held = (n.children ?? []).some((c) => !isPositioned(c) && (isContent(c, ctx) || holdsFlowContent(c, ctx))); + flow.set(n, held); + } + return held; } // ── mobile-safe-area ─────────────────────────────────────────────────────── @@ -127,15 +157,14 @@ interface Hit { node: SceneNode; offset: number } * `container`, and its offset from that edge. `offset` is where the * container's content box starts (padding already added). Alignment inside * fixed-height frames is ignored, so offsets are under-counted, never over. */ -function firstContent(container: SceneNode, offset: number, edge: 'top' | 'bottom', ancestors: SceneNode[], ctx: MobileContext): Hit | null { - const next = [...ancestors, container]; +function firstContent(container: SceneNode, offset: number, edge: 'top' | 'bottom', ctx: MobileContext): Hit | null { const children = (container.children ?? []).filter((c) => !isPositioned(c)); const ordered = edge === 'top' ? children : [...children].reverse(); const visit = (child: SceneNode, at: number): Hit | null => { - if (isContent(child, next, ctx)) return { node: child, offset: at }; - if (!holdsFlowContent(child, next, ctx)) return null; + if (isContent(child, ctx)) return { node: child, offset: at }; + if (!holdsFlowContent(child, ctx)) return null; const pad = paddingSides(child, ctx.width); - return firstContent(child, at + (edge === 'top' ? pad.top : pad.bottom), edge, next, ctx); + return firstContent(child, at + (edge === 'top' ? pad.top : pad.bottom), edge, ctx); }; if (container.layout === 'horizontal' || container.layout === 'grid') { @@ -179,7 +208,7 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { for (const edge of ['top', 'bottom'] as const) { if (edge === 'top' ? insets.topReserved : insets.bottomReserved) continue; const inset = insets[edge]; - const hit = firstContent(ctx.root, edge === 'top' ? rootPad.top : rootPad.bottom, edge, [], ctx); + const hit = firstContent(ctx.root, edge === 'top' ? rootPad.top : rootPad.bottom, edge, ctx); if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, `"${hit.node.name ?? hit.node.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, @@ -191,24 +220,24 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { // numeric y up to the root. Anything else has an in-flow containing block // whose position fast mode doesn't know. let unplaceable = false; - const visit = (n: SceneNode, ancestors: SceneNode[], placedY: Map): void => { - if (isPositioned(n)) { - const parent = ancestors[ancestors.length - 1]; + const placedY = new Map(); + const visit = (n: SceneNode, parent: SceneNode | undefined): void => { + if (parent && isPositioned(n)) { const base = parent === ctx.root ? 0 : placedY.get(parent); const y = base !== undefined && isNum(n.y) ? base + n.y : undefined; if (y === undefined) { - if (isContent(n, ancestors, ctx) || holdsFlowContent(n, ancestors, ctx)) unplaceable = true; + if (isContent(n, ctx) || holdsFlowContent(n, ctx)) unplaceable = true; } else { placedY.set(n, y); - checkPlaced(n, y, ancestors); + checkPlaced(n, y); } } - for (const c of n.children ?? []) visit(c, [...ancestors, n], placedY); + for (const c of n.children ?? []) visit(c, n); }; - const checkPlaced = (n: SceneNode, y: number, ancestors: SceneNode[]): void => { + const checkPlaced = (n: SceneNode, y: number): void => { const bottomEdge = ctx.height - insets.bottom; - if (isContent(n, ancestors, ctx)) { + if (isContent(n, ctx)) { const h = n.type === 'text' ? textHeight(n) : isNum(n.height) ? n.height : 0; if (y < insets.top || y + h > bottomEdge) { const edge = y < insets.top ? 'top' : 'bottom'; @@ -218,10 +247,10 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { } return; } - if (!holdsFlowContent(n, ancestors, ctx)) return; + if (!holdsFlowContent(n, ctx)) return; const pad = paddingSides(n, ctx.width); - const top = firstContent(n, y + pad.top, 'top', ancestors, ctx); - const bottom = isNum(n.height) ? firstContent(n, ctx.height - (y + n.height) + pad.bottom, 'bottom', ancestors, ctx) : null; + const top = firstContent(n, y + pad.top, 'top', ctx); + const bottom = isNum(n.height) ? firstContent(n, ctx.height - (y + n.height) + pad.bottom, 'bottom', ctx) : null; for (const [hit, edge, inset] of [[top, 'top', insets.top], [bottom, 'bottom', insets.bottom]] as const) { if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, @@ -231,7 +260,7 @@ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { } }; - visit(ctx.root, [], new Map()); + visit(ctx.root, undefined); if (unplaceable) { issues.push(issue('usability', rule, 'info', ctx.root, 'Some positioned content sits inside an in-flow frame, so fast mode cannot place it against the safe areas.', @@ -339,14 +368,16 @@ export function checkHandbuiltTargets(ctx: MobileContext): EvaluationIssue[] { const icons: SceneNode[] = []; const texts: SceneNode[] = []; let other = false; - (function walk(x: SceneNode, ancestors: SceneNode[]) { + // Stops as soon as the frame can no longer be tap-shaped. + (function walk(x: SceneNode) { for (const c of x.children ?? []) { + if (other || icons.length > 1 || texts.length > 1) return; if (c.type === 'icon') icons.push(c); else if (textOf(c) !== undefined) texts.push(c); - else if (isContent(c, [...ancestors, x], ctx)) other = true; - walk(c, [...ancestors, x]); + else if (isContent(c, ctx)) other = true; + walk(c); } - })(n, []); + })(n); if (other || icons.length !== 1 || texts.length > 1) return null; if (texts.length === 1 && proseWords(texts[0].content as string) > LABEL_WORDS) return null; return { icon: icons[0] }; diff --git a/test-mobile-rules-detailed.ts b/test-mobile-rules-detailed.ts index aa43426..a11f318 100644 --- a/test-mobile-rules-detailed.ts +++ b/test-mobile-rules-detailed.ts @@ -35,6 +35,11 @@ async function safeArea(c: Canvas, mode: 'fast' | 'detailed'): Promise JSON.stringify(list.map((i) => `${i.severity} ${i.nodeId}: ${i.message.slice(0, 80)}`)); +async function iss(c: Canvas, rule: string, mode: 'fast' | 'detailed' = 'fast', categories?: string[]): Promise { + const r = await evaluateCanvas(c, { mode, ...(categories ? { categories: categories as never } : {}) }); + return r.issues.filter((i) => i.rule === rule); +} + const flow = `f=I("document", { type: "frame", layout: "vertical", gap: 12, padding: [0, 16] }) I(f, { type: "text", content: "Today" }) I(f, { type: "text", content: "Nothing planned." })`; @@ -71,6 +76,11 @@ I(w, { type: "text", content: "Badge", position: "absolute", x: 0, y: -80 })`); const tallDetailed = await safeArea(tall, 'detailed'); check('tall screen on the default path: detailed catches the document end in the inset', tallDetailed.length > 0 && tallDetailed.every((i) => i.message.includes('end of the scroll')), show(tallDetailed)); check('…which fast mode trusts the reservation for (recorded gap until slice D)', tallFast.length === 0, show(tallFast)); + + console.log('\n── boundaries (detailed) ──'); + check('G-D1 detailed + categories [usability] only still checks rendered rects (E21)', (await iss(tall, 'mobile-safe-area', 'detailed', ['usability'])).length > 0); + let ops = 'p0=I("document", { type: "frame" })\n'; for (let k = 1; k < 14; k++) ops += `p${k}=I(p${k - 1}, { type: "frame" })\n`; ops += 'I(p13, { type: "text", content: "Deep" })'; + check('G-D2 text 15 levels deep in the top inset (bare root) is seen in detailed (E22)', (await iss(phone(ops, { bare: true }), 'mobile-safe-area', 'detailed')).some((i) => i.message.includes('top'))); } finally { await shutdown(); } @@ -118,5 +128,28 @@ I(b, { type: "text", content: "Morning run" })` }); } } +// ── stress wiring boundaries (MCP) ── +{ + const home = mkdtempSync(join(tmpdir(), 'fm-gap-')); const cwd = mkdtempSync(join(tmpdir(), 'fm-gap-cwd-')); + const transport = new StdioClientTransport({ command: resolve('node_modules/.bin/tsx'), args: [resolve('src/index.ts')], cwd, + env: { ...process.env, FRAMESMITH_HOME: home, CANVAS_MCP_PROJECT_DIR: cwd, FRAMESMITH_VIEWER_URL: 'http://localhost:1' } as Record, stderr: 'ignore' }); + const client = new Client({ name: 'gap', version: '0' }); await client.connect(transport); + const tool = async (name: string, args: Record) => { const r = await client.callTool({ name, arguments: args }) as { content: { text: string }[] }; try { return JSON.parse(r.content[0].text); } catch { return { text: r.content[0]?.text }; } }; + try { + const m = await tool('canvas_create', { name: 'Mono', type: 'mobile' }); + await tool('batch_design', { canvasId: m.canvasId, operations: 'b=I("document", { type: "frame", width: 40, height: 20 })\nI(b, { type: "text", content: "FM" })' }); + const s = await tool('canvas_stress', { canvasId: m.canvasId, perturbations: ['long-text'] }); + check('G-M1 canvas_stress on a mobile canvas leaves a 2-char monogram alone (X05)', s.counts?.warnings === 0, JSON.stringify(s.counts) + JSON.stringify(s.perturbations ?? s.results ?? '').slice(0, 120)); + const w = await tool('canvas_create', { name: 'Web', type: 'web', width: 600, height: 400 }); + await tool('batch_design', { canvasId: w.canvasId, operations: 'b=I("document", { type: "frame", width: 300, height: 20 })\nI(b, { type: "text", content: "Morning run" })' }); + await tool('canvas_stress', { canvasId: w.canvasId }); + const cur = await tool('canvas_evaluate', { canvasId: w.canvasId }); + check('G-M2 directive says "this exact version" when the run is current (D02)', cur.stress?.current === true && cur.directive.includes('this exact version'), cur.directive.slice(0, 200)); + await tool('batch_design', { canvasId: w.canvasId, operations: 'I("document", { type: "text", content: "Edited" })' }); + const aft = await tool('canvas_evaluate', { canvasId: w.canvasId }); + check('G-M3 after an edit the same-type record is no longer current (X04)', aft.stress?.current === false && aft.directive.includes('earlier version'), JSON.stringify(aft.stress)); + } finally { await client.close(); } +} + console.log(allPass ? '\nAll detailed mobile checks passed.' : '\nSome detailed mobile checks FAILED.'); process.exit(allPass ? 0 : 1); diff --git a/test-mobile-rules.ts b/test-mobile-rules.ts index 086dfd9..fb92883 100644 --- a/test-mobile-rules.ts +++ b/test-mobile-rules.ts @@ -89,6 +89,16 @@ I(r, { type: "text", content: "Sync" }) I(r, { type: "toggle" })`), 'mobile-touch-target'); check('row padding 32 renders ~9px on a phone → toggle row is 41px → error', scaled.length === 1, show(scaled)); + const stretched = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 44 }) +I(r, { type: "text", content: "Remember me" }) +I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('a width-less 44px row in a vertical parent stretches full width → passes (the hint works verbatim)', stretched.length === 0, show(stretched)); + + const fullHeight = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", width: "100%", height: "100%" }) +I(r, { type: "text", content: "Remember me" }) +I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('a full-HEIGHT row says nothing about its height → still an error', fullHeight.length === 1 && fullHeight[0].message.includes('390×18'), show(fullHeight)); + const two = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 56 }) I(r, { type: "text", content: "Pair" }) I(r, { type: "checkbox", width: 24, height: 24 }) @@ -173,6 +183,12 @@ ${flow} I("document", { type: "frame", height: 34 })`, { bare: true }), 'mobile-safe-area'); check('full-width hero image under the status bar, text below the inset → clean', hero.length === 0, show(hero)); + const absHero = phone(`I("document", { type: "image", src: "https://example.com/hero.jpg", position: "absolute", x: 0, y: 0, width: "100%", height: 200 }) +${flow}`); + absHero.root.padding = 16; + const absHeroI = await issues(absHero, 'mobile-safe-area'); + check('absolute full-width hero in a padded root is background, not content → clean', absHeroI.length === 0, show(absHeroI)); + const row = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", padding: [8, 16] }) I(r, { type: "text", content: "Back" }) I(r, { type: "text", content: "Edit" }) @@ -192,6 +208,31 @@ I(w, { type: "text", content: "Badge", position: "absolute", x: 0, y: 0 })`), 'm check('absolute content inside an in-flow frame → one info naming detailed mode', nested.length === 1 && nested[0].severity === 'info' && nested[0].suggestion?.includes('detailed'), show(nested)); } +// ── cost: the walks stay near-linear in nesting depth ───────────────────── +{ + console.log('\n── cost ──'); + const chain = (depth: number): Canvas => { + const c = phone('', { bare: true }); + let parent = c.root; + for (let k = 0; k < depth; k++) { + const f = { id: `d${depth}-${k}`, type: 'frame', layout: 'vertical', children: [] } as unknown as SceneNode; + parent.children = [...(parent.children ?? []), f]; + parent = f; + } + parent.children = [{ id: `d${depth}-leaf`, type: 'icon', icon: 'bell' } as SceneNode]; + return c; + }; + const time = async (c: Canvas) => { + const t = performance.now(); + await evaluateCanvas(c, { mode: 'fast', categories: ['usability'] }); + return performance.now() - t; + }; + await time(chain(50)); // warm up + const small = Math.min(await time(chain(150)), await time(chain(150))); + const large = Math.min(await time(chain(600)), await time(chain(600))); + check('a 4× deeper chain costs well under 16× (no cubic walk)', large < small * 16, `${small.toFixed(1)}ms → ${large.toFixed(1)}ms`); +} + // ── FR-C4 mobile-type-floor ─────────────────────────────────────────────── { console.log('\n── mobile-type-floor ──'); @@ -311,6 +352,106 @@ I(s, { type: "text", content: "5:12 /km" })`); check('FRAGILE then an edit: the note says earlier version', fragileEdit.directive.includes('earlier version')); } +// ── boundaries (from the slice C mutation round) ─────────────────────────── +// Each check below killed a mutant the rule fixtures above let survive. +async function iss(c: Canvas, rule: string, mode: 'fast' | 'detailed' = 'fast', categories?: string[]): Promise { + const r = await evaluateCanvas(c, { mode, ...(categories ? { categories: categories as never } : {}) }); + return r.issues.filter((i) => i.rule === rule); +} +const T = (content: string, fs: number) => `I("document", { type: "text", content: "${content}", fontSize: ${fs} })`; +const WORDS = ['alpha', 'bravo', 'cedar', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima', 'mike']; +const W = (n: number) => WORDS.slice(0, n).join(' '); +{ + console.log('\n── boundaries ──'); +// --- type floor / prose boundaries +check('G-F1 11px label: no type-floor issue (M31)', (await iss(phone(T('Mon', 11)), 'mobile-type-floor')).length === 0); +check('G-F2 $body at exactly 16: clean (M32)', (await iss((() => { const c = phone('I("document", { type: "text", content: "Hi", fontSize: "$body" })'); c.variables = { ...c.variables, typography: { body: { fontSize: 16 } } } as never; return c; })(), 'mobile-type-floor')).length === 0); +check('G-F3 13px 6-word prose: no issue (M04,M34)', (await iss(phone(T(W(6), 13)), 'mobile-type-floor')).length === 0); +check('G-F4 12px 5-word prose: warning (M06)', (await iss(phone(T(W(5), 12)), 'mobile-type-floor')).length === 1); +check('G-F5 12px 4-word prose: none (M05)', (await iss(phone(T(W(4), 12)), 'mobile-type-floor')).length === 0); +check('G-F6 14px exactly 12 words: info (M07)', (await iss(phone(T(W(12), 14)), 'mobile-type-floor')).length === 1); +check('G-F7 14px 11 words: none (M08)', (await iss(phone(T(W(11), 14)), 'mobile-type-floor')).length === 0); + +// --- one-column +const row2 = (a: string, b: string, layout = 'horizontal') => `r=I("document", { type: "frame", layout: "${layout}" })\na=I(r, { type: "frame" })\nI(a, { type: "text", content: "${a}" })\nb=I(r, { type: "frame" })\nI(b, { type: "text", content: "${b}" })`; +check('G-C1 avatar + one prose block row: no one-column (M35)', (await iss(phone(row2('JD', W(10))), 'mobile-one-column')).length === 0); +check('G-C2 grid with two prose blocks: info (M36)', (await iss(phone(row2(W(10), W(10), 'grid')), 'mobile-one-column')).length === 1); +check('G-C3 two blocks of exactly 8 words: info (M09)', (await iss(phone(row2(W(8), W(8))), 'mobile-one-column')).length === 1); +check('G-C4 web canvas with two prose columns: no one-column (E17)', (await iss(phone(row2(W(10), W(10)), { type: 'web' }), 'mobile-one-column')).length === 0); + +// --- handbuilt +check('G-H1 icon + exactly 3-word label, 32 tall: info (M10)', (await iss(phone('s=I("document", { type: "frame", layout: "horizontal", height: 32 })\nI(s, { type: "icon", icon: "plus" })\nI(s, { type: "text", content: "Add new item" })'), 'mobile-handbuilt-target')).length === 1); +check('G-H2 icon + 4-word label: not tap shaped (M39-ish)', (await iss(phone('s=I("document", { type: "frame", layout: "horizontal", height: 32 })\nI(s, { type: "icon", icon: "plus" })\nI(s, { type: "text", content: "Add a new item" })'), 'mobile-handbuilt-target')).length === 0); +check('G-H5 icon + two short texts: not tap shaped (M39)', (await iss(phone('s=I("document", { type: "frame", layout: "horizontal", height: 32 })\nI(s, { type: "icon", icon: "plus" })\nI(s, { type: "text", content: "Add" })\nI(s, { type: "text", content: "item" })'), 'mobile-handbuilt-target')).length === 0); +check('G-H3 hug icon frame, default icon 24 + padding 10 = 44: none (M38)', (await iss(phone('h=I("document", { type: "frame", padding: 10 })\nI(h, { type: "icon", icon: "x" })'), 'mobile-handbuilt-target')).length === 0); +check('G-H4 3-value padding [0,12,0] height 44 icon-only: none (M17)', await (async () => { const c = phone('h=I("document", { type: "frame", height: 44 })\nI(h, { type: "icon", icon: "x" })'); (c.root.children![0] as SceneNode).padding = [0, 12, 0] as never; return (await iss(c, 'mobile-handbuilt-target')).length === 0; })()); + +// --- artboard +check('G-A1 mobile 390x900: info (M40)', (await iss(phone(T('x', 16), { height: 900 }), 'mobile-artboard')).length === 1); +check('G-A2 mobile at exactly 500: warning (M41)', (await iss(phone(T('x', 16), { width: 500, height: 844 }), 'mobile-artboard')).some((i) => i.severity === 'warning')); +check('G-A3 mobile at 480 (<500): info only (M02)', (await iss(phone(T('x', 16), { width: 480, height: 844 }), 'mobile-artboard')).every((i) => i.severity === 'info')); + +// --- touch target +const tt = async (ops: string) => (await iss(phone(ops), 'mobile-touch-target')).length; +check('G-T1 toggle > vertical wrapper > 44px row: passes via hop 2 (E01)', await tt('r=I("document", { type: "frame", layout: "horizontal", height: 44, width: "100%" })\nw=I(r, { type: "frame", layout: "vertical" })\nI(w, { type: "toggle" })') === 0); +check('G-T2 3 levels deep: row out of reach → error (E02)', await tt('r=I("document", { type: "frame", layout: "horizontal", height: 44, width: "100%" })\na=I(r, { type: "frame", layout: "vertical" })\nb=I(a, { type: "frame", layout: "vertical" })\nI(b, { type: "checkbox", width: 24, height: 24 })') === 1); +check('G-T3 row minHeight 44, no height: passes (E06)', await tt('r=I("document", { type: "frame", layout: "horizontal", minHeight: 44, width: "100%" })\nI(r, { type: "checkbox", width: 24, height: 24 })') === 0); +check('G-T4 row width fill_container height 44: passes (E05)', await tt('r=I("document", { type: "frame", layout: "horizontal", height: 44, width: "fill_container" })\nI(r, { type: "checkbox", width: 24, height: 24 })') === 0); +check('G-T5 two checkboxes in a wide 56 row: both error (E04)', await tt('r=I("document", { type: "frame", layout: "horizontal", height: 56, width: "100%" })\nI(r, { type: "checkbox", width: 24, height: 24 })\nI(r, { type: "checkbox", width: 24, height: 24 })') === 2); +{ + const r = await evaluateCanvas(phone('f=I("document", { type: "frame", layout: "vertical", padding: 12 })\nI(f, { type: "text", content: "Remember me" })\nI(f, { type: "checkbox", width: 32, height: 32 })'), { mode: 'fast', categories: ['usability'] }); + const u = r.categories.find((c) => c.name === 'usability')!; + check('G-T6 touch error costs 25 points: usability 75 (E10)', u.score === 75, String(u.score)); +} +{ + const r = await evaluateCanvas(phone('a=I("document", { type: "frame", width: 40, height: 40 })\nI(a, { type: "icon", icon: "bell" })'), { mode: 'fast', categories: ['usability'] }); + check('G-T7 info-only handbuilt finding costs 0: usability 100 (E13)', r.categories.find((c) => c.name === 'usability')!.score === 100); +} +{ + const r = await evaluateCanvas(phone(T('Mon', 9)), { mode: 'fast', categories: ['typography'] }); + const t = r.categories.find((c) => c.name === 'typography')!; + const base = await evaluateCanvas(phone(T('Mon', 16)), { mode: 'fast', categories: ['typography'] }); + check('G-T8 type-floor warning costs 10 vs the same text at 16 (E12)', base.categories[0].score - t.score >= 10, `${base.categories[0].score} vs ${t.score}`); +} + +// --- safe-area fast +const sa = async (ops: string, o: Parameters[1] = {}) => (await iss(phone(ops, o), 'mobile-safe-area')).length; +const flow1 = 'f=I("document", { type: "frame", layout: "vertical", gap: 12 })\nI(f, { type: "text", content: "Today" })'; +check('G-S1 bare root + spacers + positioned text at y 100: clean (M21)', await sa(`I("document", { type: "frame", height: 47 })\n${flow1}\nI("document", { type: "frame", height: 34 })\nI("document", { type: "text", content: "Pin", position: "absolute", x: 0, y: 100 })`, { bare: true }) === 0); +check('G-S2 numeric image width 390 on bare root: background (M19)', await sa(`I("document", { type: "image", src: "https://example.com/h.jpg", width: 390, height: 220 })\n${flow1}\nI("document", { type: "frame", height: 34 })`, { bare: true }) === 0); +check('G-S3 positioned text, y 800, lineHeight 1.5 @20px: bottom warning (M25)', await sa(`${flow1}\nI("document", { type: "text", content: "Saved", fontSize: 20, lineHeight: 1.5, position: "absolute", x: 0, y: 800 })`) === 1); +check('G-S4 positioned text height 20 ending exactly at 810: clean (M26)', await sa(`${flow1}\nI("document", { type: "text", content: "Saved", height: 20, position: "absolute", x: 0, y: 790 })`) === 0); +check('G-S5 explicit safeArea {0,0} reserved: no walk warning (S07)', await (async () => { const c = phone(flow1); (c.root as SceneNode).safeArea = { top: 0, bottom: 0 } as never; return (await iss(c, 'mobile-safe-area')).length === 0; })()); +// --- stress pure +{ + let n = 0; const id = () => `g${++n}`; + const row = (title: string) => ({ id: id(), type: 'list-row', title } as unknown as SceneNode); + const mk = (kids: SceneNode[]) => ({ id: 'document', type: 'document', children: kids } as SceneNode); + const sp = { id: id(), type: 'frame' } as SceneNode; + const root = mk([{ id: id(), type: 'frame', children: [row('A1'), row('A2'), sp, row('B1'), row('B2')] } as SceneNode]); + const e = applyPerturbation('empty', root, { type: 'mobile' }); + check('G-L1 two runs in one container: empty removes both (T14)', e.root.children![0].children!.length === 1, String(e.root.children![0].children!.length)); + const m = applyPerturbation('many', root, { type: 'mobile' }); + check('G-L2 two runs: many triples both (T14)', m.root.children![0].children!.length === 1 + 12, String(m.root.children![0].children!.length)); + const sm = mk([{ id: id(), type: 'text', content: 'Mon' } as SceneNode, { id: id(), type: 'text', content: 'Home' } as SceneNode]); + const lt = applyPerturbation('long-text', sm, { type: 'mobile' }); + check('G-L3 mobile long-text: 3-char "Mon" untouched, 4-char "Home" perturbed (T01)', lt.root.children![0].content === 'Mon' && (lt.root.children![1].content as string).length > 10); + const prim = mk([ + { id: 'b', type: 'button', label: 'Start run' }, { id: 'n', type: 'nav-bar', title: 'Routes', leading: { label: 'Back' }, trailing: [{ label: 'Edit' }] }, + ] as unknown as SceneNode[]); + const L = (r: SceneNode, i: number) => r.children![i] as unknown as { label?: string; title?: string; leading?: { label: string }; trailing?: { label: string }[] }; + const mob = applyPerturbation('long-text', prim, { type: 'mobile' }); + check('G-P1 mobile long-text: button label perturbed (T05)', (L(mob.root, 0).label ?? '').length > 15); + check('G-P2 mobile long-text: nav-bar title perturbed (T06)', (L(mob.root, 1).title ?? '').length > 10); + check('G-P3 mobile long-text: nav-bar leading/trailing labels fixed (T07,T08)', L(mob.root, 1).leading!.label === 'Back' && L(mob.root, 1).trailing![0].label === 'Edit'); + const i18 = applyPerturbation('i18n', prim, { type: 'mobile' }); + check('G-P4 mobile i18n: nav-bar leading/trailing labels perturbed (T07,T08)', L(i18.root, 1).leading!.label !== 'Back' && L(i18.root, 1).trailing![0].label !== 'Edit'); + const blank = mk([{ id: 'b2', type: 'button', label: ' ' }] as unknown as SceneNode[]); + check('G-P5 whitespace-only label is not touched (T11)', applyPerturbation('long-text', blank, { type: 'web' }).touched.length === 0); +} + +} + // ── the real tool handlers over MCP stdio ───────────────────────────────── { console.log('\n── MCP tools ──'); From 248638cd2625377b1496de55ae5e4abd03fcd988 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sun, 1 Nov 2026 00:27:53 +0100 Subject: [PATCH 5/5] Bound stretching rows by their host and narrow full-bleed images A stretching row takes its host's known width, not the artboard's, and a positioned image is full-bleed only when its containing block spans the artboard. Both first fixes passed small targets. --- docs/specs/PHASE-30-SPEC.md | 4 ++-- src/evaluate.ts | 44 +++++++++++++++++++++++-------------- src/index.ts | 2 +- src/mobile-rules.ts | 8 +++++-- test-mobile-rules.ts | 28 +++++++++++++++++++++++ 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index b81392c..4ab8f05 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -233,8 +233,8 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *FR-C7.* None qualified. On Ridgeline evaluated as `mobile`, no existing check fires on a correct mobile idiom: the findings are APCA advisories, the coverage demands for the run list (a real list with no empty or loading variant), and the "no component instances" advisory, which FR-D4 resolves by counting primitives as reuse. The mobile `disable` list stays empty. - *FR-C8.* The directive's `StressState` gains `ranAsType`, set when the type changed since the run, so the stale note names the earlier type instead of an edit. `nav-bar.title` is perturbed by long-text on mobile (a screen title can be long); only the tab labels and the nav bar's action labels are the fixed vocabulary. The primitive prop table and the list-row runs are in `stress.ts` and tested with synthetic nodes; they are documented with the primitives in slice D. - *Spacers on long screens.* A spacer frame placed directly under the fixed-height root shrinks to nothing when the content is taller than the artboard (flex-shrink), while one inside the content frame keeps its height (rendered: 60 rows plus a root-level 34px spacer still end in the inset; with the spacer inside the rows' frame they clear it). The docs and the rule's suggestions say to put spacers inside the content frame. Fast mode's structural walk still counts a root-level spacer; detailed mode is authoritative, and slice D's root fix removes the shrink. - - *FR-C1 rows.* A full width (`"100%"`, `fill_container`) counts as the artboard width on the width axis only; a full height says nothing and falls back to the control plus padding. A row with no `width` inside a parent that is neither horizontal nor grid and does not set `alignItems` (or sets `stretch`) stretches, so it counts as full width; without this, a 44px row built exactly as the suggestion says still failed at 18×44. - - *Positioned images.* An absolute image with a non-numeric width is background: its percentage width is of its containing block's padding box, so ancestor padding does not inset it (fast mode had called a full-bleed absolute hero in a padded root content, where detailed mode did not). + - *FR-C1 rows.* A full width (`"100%"`, `fill_container`) counts as the artboard width on the width axis only; a full height says nothing and falls back to the control plus padding. A row with no `width` inside a parent that is neither horizontal nor grid and does not set `alignItems` (or sets `stretch`) stretches to that parent's content width, walking up through numeric, `"100%"` and stretching widths to the root (`knownWidth`); a shrink-wrapped host, a positioned row, or an unknown width up the chain falls back to the control plus padding. Without the stretch, a 44px row built exactly as the suggestion says failed at 18×44; the first version of the fix used the artboard width for every stretching row and passed a checkbox row inside a 40px column (the 3b verifier's catch). + - *Positioned images.* An absolute image with a non-numeric width is background only when its containing block spans the artboard: the root, or a positioned frame at least the artboard's width. Its percentage width is of that block's padding box, so ancestor padding does not inset it (fast mode had called a full-bleed absolute hero in a padded root content, where detailed mode did not). Inside any other block it is content, so a small absolute avatar image still counts. - *Words in FR-C2.* "Three words or fewer" counts prose words (the FR-C4 definition), so `Add 2 more items` is three words and tap-shaped. Info only. - *Cost.* The safe-area and hand-built-target walks share one parent map and one in-flow-content cache per evaluated tree, so they stay near-linear in nesting depth (an 800-deep chain took 1.4–2s before). - *FR-C6.* `canvas_set_type` returns `variantsOutOfSync: [{ state, canvasId, type }]` when a variant's resolved type differs from the base's after the change. diff --git a/src/evaluate.ts b/src/evaluate.ts index 9f52641..d41a3a8 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -1829,12 +1829,33 @@ function paddingBox(n: SceneNode): { v: number; h: number } { return { v: 0, h: 0 }; } +/** Phase 30 slice C — the width a node renders at, when the tree alone says: + * a numeric width; or a full or stretching width, which takes its parent's + * content width (parent width minus its rendered padding), up to the root. + * A stretch needs a vertical (or layout-less) parent that does not set + * cross-axis alignment, and an in-flow node. Undefined when the width hugs + * content or depends on something the tree doesn't fix. */ +function knownWidth(n: SceneNode, parentOf: Map, rootWidth: number): number | undefined { + if (typeof n.width === 'number') return n.width; + const parent = parentOf.get(n.id) ?? null; + if (!parent) return rootWidth; + if (n.position === 'absolute') return undefined; + const columnParent = parent.layout !== 'horizontal' && parent.layout !== 'grid'; + const stretches = n.width === undefined && columnParent && (parent.alignItems === undefined || parent.alignItems === 'stretch'); + const full = n.width === '100%' || (n.width === 'fill_container' && columnParent); + if (!stretches && !full) return undefined; + const outer = knownWidth(parent, parentOf, rootWidth); + if (outer === undefined) return undefined; + const pad = paddingSides(parent, rootWidth); + return outer - pad.left - pad.right; +} + /** Phase 30 slice C — a control's touch target on a mobile canvas: its own * box, extended through up to two ancestors that are ROWS (horizontal frames - * holding exactly one control). A row's box per dimension is its numeric size - * or min size, a full width counting as the artboard, else the control plus - * the row's rendered padding. Unlike the web rule this never extends through - * a card that merely holds one control. */ + * holding exactly one control). A row's box per dimension is its known size + * or min size (a full or stretching width takes its host's width), else the + * control plus the row's rendered padding. Unlike the web rule this never + * extends through a card that merely holds one control. */ function mobileTarget( node: SceneNode, w: number, h: number, parentOf: Map, countControls: (n: SceneNode) => number, rootWidth: number, @@ -1845,18 +1866,9 @@ function mobileTarget( for (let hop = 0; hop < 2 && cur; hop++) { if (cur.type === 'frame' && cur.layout === 'horizontal' && countControls(cur) === 1) { const pad = paddingSides(cur, rootWidth); - // A width-less row in a vertical (or layout-less) parent that doesn't - // set cross-axis alignment stretches to the parent's width. - const host = parentOf.get(cur.id) ?? null; - const stretches = cur.width === undefined && host !== null && host.layout !== 'horizontal' && host.layout !== 'grid' - && (host.alignItems === undefined || host.alignItems === 'stretch'); - // A full width spans the artboard; a full height says nothing about it. - const side = (v: SceneNode['width'], min: SceneNode['minWidth'], own: number, padding: number, full: number | undefined): number => { - const sized = typeof v === 'number' ? v : full !== undefined && (v === '100%' || v === 'fill_container') ? full : own + padding; - return Math.max(sized, typeof min === 'number' ? min : 0); - }; - tw = Math.max(tw, stretches ? rootWidth : side(cur.width, cur.minWidth, w, pad.left + pad.right, rootWidth)); - th = Math.max(th, side(cur.height, cur.minHeight, h, pad.top + pad.bottom, undefined)); + const min = (v: SceneNode['minWidth']) => (typeof v === 'number' ? v : 0); + tw = Math.max(tw, knownWidth(cur, parentOf, rootWidth) ?? w + pad.left + pad.right, min(cur.minWidth)); + th = Math.max(th, typeof cur.height === 'number' ? cur.height : h + pad.top + pad.bottom, min(cur.minHeight)); } cur = parentOf.get(cur.id) ?? null; } diff --git a/src/index.ts b/src/index.ts index 656e978..53c19e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2079,7 +2079,7 @@ THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional dept The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. -The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding; a row with no width inside a vertical parent stretches, so it counts as full width), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it; declared web and narrower than 500px gets an info). None has an autofix.`, +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding; a row with no width inside a vertical parent that sets no alignItems stretches to that parent's width when the parent's own width is known), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it; declared web and narrower than 500px gets an info). None has an autofix.`, { canvasId: z.string().describe('Canvas ID to evaluate'), mode: z.enum(['fast', 'detailed', 'llm']).default('fast').describe('"fast" = JSON-only (<100ms), "detailed" = + Puppeteer layout checks, "llm" = fast + vision-model rubric critique'), diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts index 2c146a6..344ed45 100644 --- a/src/mobile-rules.ts +++ b/src/mobile-rules.ts @@ -115,9 +115,13 @@ function isContent(n: SceneNode, ctx: MobileContext): boolean { if (isNum(n.width)) return n.width < ctx.width; // A non-numeric width spans the artboard only if nothing above it insets it. // A positioned image's percentage width is of its containing block's padding - // box, so ancestor padding never insets it. - if (isPositioned(n)) return false; + // box: the root's, or a positioned frame's, spans the artboard; anything + // else fast mode can't size, so it counts as content. const { parentOf } = indexFor(ctx); + if (isPositioned(n)) { + const block = parentOf.get(n); + return !(block === ctx.root || (block && isPositioned(block) && isNum(block.width) && block.width >= ctx.width)); + } for (let a = parentOf.get(n); a; a = parentOf.get(a)) { const p = paddingSides(a, ctx.width); if (p.left + p.right > 0) return true; diff --git a/test-mobile-rules.ts b/test-mobile-rules.ts index fb92883..f092431 100644 --- a/test-mobile-rules.ts +++ b/test-mobile-rules.ts @@ -94,6 +94,27 @@ I(r, { type: "text", content: "Remember me" }) I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); check('a width-less 44px row in a vertical parent stretches full width → passes (the hint works verbatim)', stretched.length === 0, show(stretched)); + // A stretching row takes its host's width, never more (rendered widths in + // parentheses: all below 44, so detailed rendering agrees). + const narrowHost = await issues(phone(`c=I("document", { type: "frame", layout: "vertical", width: 40 }) +r=I(c, { type: "frame", layout: "horizontal", height: 44 }) +I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('row stretching inside a 40px column is 40 wide → error (40×44)', narrowHost.length === 1 && narrowHost[0].message.includes('40×44'), show(narrowHost)); + const hugHost = phone(`c=I("document", { type: "frame", layout: "vertical" }) +r=I(c, { type: "frame", layout: "horizontal", height: 44 }) +I(r, { type: "checkbox", width: 18, height: 18 })`); + hugHost.root.alignItems = 'center'; + const hugI = await issues(hugHost, 'mobile-touch-target'); + check('row in a host shrink-wrapped by a centring root → error (18×44)', hugI.length === 1, show(hugI)); + const inRow = await issues(phone(`o=I("document", { type: "frame", layout: "horizontal" }) +c=I(o, { type: "frame", layout: "vertical" }) +r=I(c, { type: "frame", layout: "horizontal", height: 44 }) +I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('row in a width-less column inside a horizontal parent → error (18×44)', inRow.length === 1, show(inRow)); + const absRow = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", height: 44, position: "absolute", x: 16, y: 100 }) +I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); + check('an absolute row never stretches → error (18×44)', absRow.length === 1, show(absRow)); + const fullHeight = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", width: "100%", height: "100%" }) I(r, { type: "text", content: "Remember me" }) I(r, { type: "checkbox", width: 18, height: 18 })`), 'mobile-touch-target'); @@ -189,6 +210,13 @@ ${flow}`); const absHeroI = await issues(absHero, 'mobile-safe-area'); check('absolute full-width hero in a padded root is background, not content → clean', absHeroI.length === 0, show(absHeroI)); + const smallAbs = phone(`${flow} +a=I("document", { type: "frame", position: "absolute", x: 16, y: 10, width: 40, height: 40 }) +I(a, { type: "image", src: "https://example.com/avatar.jpg", position: "absolute", x: 0, y: 0, width: "100%", height: 40 })`); + smallAbs.root.padding = [0, 16]; + const smallAbsI = await issues(smallAbs, 'mobile-safe-area'); + check('a small absolute image inside a 40px absolute frame is content → warning', smallAbsI.length === 1 && smallAbsI[0].message.includes('top'), show(smallAbsI)); + const row = await issues(phone(`r=I("document", { type: "frame", layout: "horizontal", padding: [8, 16] }) I(r, { type: "text", content: "Back" }) I(r, { type: "text", content: "Edit" })