diff --git a/README.md b/README.md index 7c0d800..c7db08a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Left to itself, an AI agent tends to produce UI that *looks* AI-generated — ge ## Viewer -Run `npx -p framesmith framesmith-viewer` to start the standalone browser viewer (it takes the first free port from 3001 up; pass a port to pin one). Open a web canvas to review it at multiple breakpoints and compare them side-by-side; open any canvas to inspect its JSON, leave comments, or archive / delete it. Cards keep each artboard's proportions (between a phone held upright and 2:1; a longer page shows its top), so phone screens are portrait cards. A canvas declared `mobile` opens at 1:1 in a device frame instead of the breakpoint buttons, with a **Safe area** toggle that shades the status-bar and home-indicator insets (47 and 34 on the default `iphone-390`). The overlay is viewer-only: it never changes a screenshot. +Run `npx -p framesmith framesmith-viewer` to start the standalone browser viewer (it takes the first free port from 3001 up; pass a port to pin one). Open a web canvas to review it at multiple breakpoints and compare them side-by-side (a canvas declared `desktop-app` offers the narrower windows of its profile instead, 1024 and 1280 under a 1440 artboard); open any canvas to inspect its JSON, leave comments, or archive / delete it. Cards keep each artboard's proportions (between a phone held upright and 2:1; a longer page shows its top), so phone screens are portrait cards. A canvas declared `mobile` opens at 1:1 in a device frame instead of the breakpoint buttons, with a **Safe area** toggle that shades the status-bar and home-indicator insets (47 and 34 on the default `iphone-390`). The overlay is viewer-only: it never changes a screenshot. ![framesmith canvas detail view — the dashboard pattern rendered (grouped sidebar navigation with an account row, KPI cards with sparklines and delta pills, a real multi-series line chart with an area fill and a dashed reference line beside a recent-activity feed) with the Quality inspector open on the right: a 99/100 "Excellent" score, per-category bars (spacing, color, typography, structure, consistency, usability, coverage, cliche), and honest APCA advisories in the issue list. The toolbar shows the canvas's designed states (default / loading / empty), theme and viewport toggles, and a Comment toggle.](https://raw.githubusercontent.com/vicmaster/framesmith/master/docs/framesmith-canvas.png?v=2.0) @@ -183,10 +183,10 @@ Create a new canvas. If `projectId` is omitted, it lands in the built-in `Untitl |-------|------|-------------| | `name` | string? | Canvas name | | `projectId` | string? | Target project. Defaults to the built-in Untitled project. See `project_list`. | -| `width` | number? | Artboard width in px, a positive integer (default 1440, or the device width for `type: "mobile"`). | -| `height` | number? | Artboard height in px, a positive integer (default 900, or the device height for `type: "mobile"`). Each dimension defaults on its own. | -| `type` | `"web"` \| `"mobile"`? | What medium the canvas is on. `"mobile"` declares a phone app screen: the artboard defaults to the device size and the root gets the device's safe-area insets as `safeArea: { top, bottom }`, which the renderer adds to the root's top and bottom padding. Omitted, the canvas is undeclared and judged as web. On a mobile canvas `apply_structure` stamps the mobile realisations (`dashboard`, `settings`, `auth`, `onboarding`, `feed`, `detail`). | -| `device` | string? | Device profile for `type: "mobile"` (default `"iphone-390"`, the only profile today: 390×844, insets 47 top and 34 bottom). Passing it without `type: "mobile"`, or naming an unknown device, is an error. An explicit `width`/`height` overrides the profile's size for that side; the insets stay the profile's. | +| `width` | number? | Artboard width in px, a positive integer (default 1440, or the device or window width for `type: "mobile"` / `"desktop-app"`). | +| `height` | number? | Artboard height in px, a positive integer (default 900, or the device or window height for `type: "mobile"` / `"desktop-app"`). Each dimension defaults on its own. | +| `type` | `"web"` \| `"mobile"` \| `"desktop-app"`? | What medium the canvas is on. `"desktop-app"` declares an application screen, a tool you work in (an editor, an admin console, a file browser) in a browser tab or a native window: the artboard defaults to the window (1440×900), and it runs the desktop-app ruleset; a page you read and scroll stays `"web"`. `"mobile"` declares a phone app screen: the artboard defaults to the device size and the root gets the device's safe-area insets as `safeArea: { top, bottom }`, which the renderer adds to the root's top and bottom padding. Omitted, the canvas is undeclared and judged as web. On a mobile canvas `apply_structure` stamps the mobile realisations (`dashboard`, `settings`, `auth`, `onboarding`, `feed`, `detail`). | +| `device` | string? | The type's profile: a phone for `type: "mobile"` (default `"iphone-390"`, the only one today: 390×844, insets 47 top and 34 bottom) or a window for `type: "desktop-app"` (default `"window-1440"`: 1440×900, no insets). Passing it without a type, with `"web"`, or naming a profile from the other type's table, is an error. An explicit `width`/`height` overrides the profile's size for that side; the insets stay the profile's. | The response also carries the artboard `width`/`height`, `type`, `typeSource` (`"declared"` or `"default"`), `device` and `safeArea` for a mobile canvas, and a `diversification` signal for the target project: the recently-built structures (newest first) and a hint to differ on at least one taxonomy axis, so successive canvases don't converge on the same layout. It's advisory — never blocking. @@ -495,6 +495,8 @@ The headline call for a new project's look: **one seed color + one personality - **A radius stance** (`radius-sm/md/lg` — e.g. 6/10/14 for `technical`, 12/16/20 for `soft`) and a **density stance** (the space scale pivots on the personality). - **`chart-1`…`chart-6`** — a categorical series palette hue-walked from the seed (≥ 30° apart, purple band avoided unless the seed itself is purple, each ≥ 3:1 on both themes' surfaces per WCAG 1.4.11) for chart segments, series, and legend dots. Near-neutral seeds anchor at a stable blue and say so in the result (`rangeNote`). - **`accent-tint` / `success-tint` / `warning-tint` / `danger-tint` / `neutral-tint`** — the tint layer: soft same-hue surfaces for status chips, icon tiles, pill badges, and initials avatars. One pairing rule: tint as the fill, base color as the ink (`fill: "$success-tint"` + `color: "$success"`) — AA by construction, dark layer included. +- **`focus-ring` / `border-strong`** — the control colors, each at 3:1 or more (WCAG 1.4.11) in both themes: `focus-ring` against every `bg-*` and `-tint` token, `border-strong` against `bg-primary` and `bg-surface`, for a control whose border is its only boundary. +- **`state`** — element-state opacities (`hover` 0.08, `focus` 0.10, `pressed` 0.10, `disabledContent` 0.38, `disabledContainer` 0.12; Material 3's values), written to the layer's `state` section. The result reports them as `state`, and the two control colors as `controls`. - **`$elevation.flat` / `raised` / `floating` / `overlay`** — layered soft shadow tokens referenced from any node as `shadow: "$elevation.raised"`. The dark layer **re-states** each depth (stronger black ink) so elevation reads on dark surfaces instead of vanishing. - **`$motion` defaults** (`fast` / `base` / `slow`) tuned to the personality's temperament. @@ -502,7 +504,7 @@ The headline call for a new project's look: **one seed color + one personality |-------|------|-------------| | `seed` | string | The brand color everything derives from (`#RRGGBB`) | | `personality` | string | **Required** — `technical`, `editorial`, `soft`, or `data-dense`. Dashboards → `data-dense`/`technical`, marketing/content → `editorial`, consumer → `soft` | -| `baseSize` / `ratio` | number? | Override the type pivot / scale ratio. On a canvas declared `type: "mobile"` the pivot defaults to the device body size (17px; 16px for `data-dense`); `baseSize` still wins. The result reports the pivot as `baseSize` + `baseSizeSource` (`"explicit"`, `"personality"` or `"mobile"`). | +| `baseSize` / `ratio` | number? | Override the type pivot / scale ratio. On a canvas declared `type: "mobile"` the pivot defaults to the device body size (17px; 16px for `data-dense`), on one declared `type: "desktop-app"` to 14px (13px for `data-dense`); `baseSize` still wins. The result reports the pivot as `baseSize` + `baseSizeSource` (`"explicit"`, `"personality"`, `"mobile"` or `"desktop-app"`). | | `canvasId` / `projectId` / `workspaceId` | string? | Exactly one — the token layer written to | | `preserveInherited` | boolean? | Canvas scope only. Default `true` — see "Inherited tokens" below. Pass `false` to write the generated language whole. | @@ -526,6 +528,7 @@ One seed color → a full perceptual color system, written to the workspace / pr - **Semantic tokens** — `bg-primary`, `bg-surface`, `bg-elevated`, `text-primary`, `text-secondary`, `border`, `accent`: the same vocabulary the structure scaffolds already use. Text and accent steps are picked by **measured contrast** — every semantic text/surface pair clears WCAG AA *by construction*. - **`chart-1` … `chart-6`** — the categorical series palette: a deterministic hue walk from the seed, ≥ 30° apart pairwise, purple band skipped unless the seed itself is purple, each color pushed until it clears the 3:1 graphical-object floor (WCAG 1.4.11) on both themes' surfaces. A near-neutral seed (no meaningful hue) anchors the walk at a stable blue instead of amplifying noise, and the response's `rangeNote` says so. - **`accent-tint` / `success-tint` / `warning-tint` / `danger-tint` / `neutral-tint`** — the tint layer: soft same-hue washes for status chips, icon tiles, pill badges, and initials avatars, each backed off until its paired base color (`accent`, `success`, …) clears AA text contrast on it. Pairing rule: tint as the fill, base color as the ink. +- **`focus-ring` / `border-strong`** — the control colors, each at 3:1 or more (WCAG 1.4.11) in both themes: `focus-ring` against every `bg-*` and `-tint` token, `border-strong` against `bg-primary` and `bg-surface`, for a control whose border is its only boundary. | Param | Type | Description | |-------|------|-------------| @@ -537,7 +540,7 @@ The **dark theme ships in the same call**: the semantic dark mapping (Radix patt ### `get_variables` / `set_variables` -Read and write design tokens (colors, spacing, radius, typography — plus `elevation` shadow tokens, a `dark` override layer, and `motion` tokens: `{ duration, easing }` pairs referenced from nodes as `transition: "$motion."`). Use `$tokenName` in node properties to reference variables. The `dark` layer is SPARSE, keyed by token name: anything not overridden inherits the light value — colors AND elevation can both carry a dark override; it drives `theme: "dark"` renders and the dual-theme contrast check. +Read and write design tokens (colors, spacing, radius, typography — plus `elevation` shadow tokens, a `dark` override layer, `state` opacities (`{ hover, focus, pressed, disabledContent, disabledContainer }`, each 0–1; `generate_design_system` writes 0.08 / 0.10 / 0.10 / 0.38 / 0.12), and `motion` tokens: `{ duration, easing }` pairs referenced from nodes as `transition: "$motion."`). Use `$tokenName` in node properties to reference variables. The `dark` layer is SPARSE, keyed by token name: anything not overridden inherits the light value — colors AND elevation can both carry a dark override; it drives `theme: "dark"` renders and the dual-theme contrast check. ```json { @@ -632,7 +635,7 @@ List available layout structures — named scaffolds you stamp onto a canvas and | Param | Type | Description | |-------|------|-------------| | `canvasId` | string? | List what stamps on this canvas: its declared type picks the realisations (an undeclared canvas lists web). Passing it with `type` is an error. | -| `type` | `"web"` \| `"mobile"`? | List for this canvas type instead. With neither, the web listing. | +| `type` | `"web"` \| `"mobile"` \| `"desktop-app"`? | List for this canvas type instead. With neither, the web listing. | | `projectId` | string? | If given, also return a `diversification` signal for the project (recently-built structures + a hint to differ on ≥ 1 axis), so you pick a shape that contrasts with recent work. Omit it to get just the structure list. | Two kinds: @@ -656,7 +659,7 @@ Stamp a layout structure onto a canvas and return the placeholder node IDs to po | `structure` | string | Structure name (use `list_structures`, e.g. `"marquee-hero"`, `"data-table"`) | | `replace` | boolean? | Page scaffolds: if the root already has children, clear them before stamping. Default `false` (refuses on a non-empty canvas) | | `targetId` | string? | Component scaffolds: node to stamp under (default `"document"`) | -| `realisation` | `"web"` \| `"mobile"`? | Stamp the realisation for this type instead of the canvas's own (e.g. `"web"` for the web dashboard on a phone canvas) | +| `realisation` | `"web"` \| `"mobile"` \| `"desktop-app"`? | Stamp the realisation for this type instead of the canvas's own (e.g. `"web"` for the web dashboard on a phone canvas) | ### `canvas_import_html` @@ -688,6 +691,7 @@ Import an HTML snippet (+ optional CSS) as an editable canvas — the reverse of | `flatten` | object? | `{ collapseWrappers, mergeTextRuns, dropInvisible, maxDepth }` | | `tokenMatch` | object? | `{ source: "workspace" \| "designMd" \| "tailwind" \| "none", tolerance?, designMd? }` — snap concrete values back to `$token` refs (default: the target project's merged design system) | | `tailwind` | object? | `{ theme: { name: value } }` — the project's `@theme` map; widens which class names map to `$tokens` | +| `verbose` | boolean? | Return `snapped` / `literals` / `scaleMatches` as full per-node lists. Default `false`: each comes grouped (`{ token, count, nodeIds }`, literals by `value`) with the first five node ids per group, and `report.summarized` is `true`. | **Token re-mapping** makes the import a token-driven design instead of a pile of hex: @@ -708,7 +712,7 @@ Import a **live page** as an editable, token-mapped canvas — point at a runnin | `selector` | string? | Import one component instead of the whole page (default `body`) | | `waitFor` | string \| number? | CSS selector to await, or a delay in ms — for client-rendered UI | | `auth` | object? | `{ headers?, cookies? }` for gated pages — used in a **throwaway browser context**, never persisted to the canvas, provenance, or report | -| `projectId` / `name` / `flatten` / `tokenMatch` / `tailwind` | — | Same as `canvas_import_html` | +| `projectId` / `name` / `flatten` / `tokenMatch` / `tailwind` / `verbose` | — | Same as `canvas_import_html` | Relative image URLs resolve against the page; fonts seen in computed styles load through the font-by-name resolver so the canvas renders in the same faces. The source URL (never auth) is recorded in `metadata.provenance.importedFrom`. @@ -846,10 +850,10 @@ Declare what medium an existing canvas is on. Writes `metadata.surface` only: it | Param | Type | Description | |-------|------|-------------| | `canvasId` | string | Canvas to declare | -| `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"`. | +| `type` | `"web"` \| `"mobile"` \| `"desktop-app"` \| null | The medium; `null` clears the declaration (the canvas reads as undeclared web again) | +| `device` | string? | The type's profile: `"iphone-390"` (default) for `"mobile"`, `"window-1440"` (default) for `"desktop-app"`. Not valid with `"web"` or `null`. | -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. +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 })`. On a desktop-app result whose root is narrower than 1024px, `hint` names `U("document", { width: 1440, height: 900 })`. 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` @@ -863,7 +867,7 @@ A `FRAGILE` stress run blocks too. Once `canvas_stress` has recorded a verdict f 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`. `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: +Every result also carries a **`surface`** field: `{ type, device, source, rulesOn, rulesOff, conflicts }` — the canvas type (`"web"`, `"mobile"` or `"desktop-app"`), its profile (a phone or a window; `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 `["desktop-app-artboard", "mobile-artboard"]`, the two base rules; a desktop-app canvas turns `type-scale-ratio` off (see the last rows); a mobile canvas runs the whole mobile ruleset: | Rule | Category | Severity | Fires on | |---|---|---|---| @@ -874,6 +878,10 @@ Every result also carries a **`surface`** field: `{ type, device, source, rulesO | `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. | +| `desktop-app-artboard` | structure | warning | Runs on every type. A desktop-app canvas narrower than 1024px, the narrowest window the desktop-app layouts are built for. | +| `type-scale-ratio` | typography | warning | On by default: adjacent font sizes stepping by a ratio below 1.1 or above 2.0 (pinned typography tokens skip it). The desktop-app ruleset turns it off, since dense apps step 12 / 13 / 14 on purpose; it then appears in `rulesOff`. | + +Repeated findings fold in this result: three or more warnings or infos with the same severity, category, rule (or tell) and theme come back as one entry when they also share their message — on its shape for members that carry a fix (each fix op holds its node's specifics), word for word apart from quoted node text for members without one. The entry keeps the first one's message and adds `nodeIds` (distinct), `count` and, when they had fixes, `fixes: [{ nodeId, op }]`. Errors never fold, and `canvas_autofix` works from the unfolded list. 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). @@ -882,7 +890,7 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `canvasId` | string | Canvas ID to evaluate | | `mode` | `"fast"` \| `"detailed"` \| `"llm"` | `"fast"` = JSON-tree analysis only (<100ms). `"detailed"` adds Puppeteer-based pixel-level overlap checks, laid out at the canvas's artboard size. `"llm"` runs fast-mode heuristics plus a vision-model critique (provider picked from `FRAMESMITH_LLM_PROVIDER` or whichever of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` is set — costs one paid API call per invocation). Default `"fast"`. | | `categories` | string[]? | Subset of `spacing`, `color`, `typography`, `structure`, `consistency`, `cliche`, `coverage`, `usability`. Defaults to all. | -| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"data"` (alias `"dashboard"`) allows realistic figures on a screen whose numbers are the user's own data (dashboards, activity, finance, fitness, logistics); `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's stored genre (`canvas_set_genre` stamps it durably, no token churn; `apply_preset` never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. Pick by 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`; a pricing page is marketing and gets none of them), not what it contains — the result's `genre` field audits the choice. | +| `genre` | string? | Style that relaxes specific `cliche` gates — `"material"` allows purple and white elevated surfaces; `"data"` (alias `"dashboard"`) allows realistic figures on a screen whose numbers are the user's own data (dashboards, activity, finance, fitness, logistics); `"commerce"` (alias `"checkout"`) allows the money on a transactional screen — cart, checkout, order confirmation, billing history. Defaults to the canvas's stored genre (`canvas_set_genre` stamps it durably, no token churn; `apply_preset` never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. Pick by what the screen is *for* (read screens with the user's own figures → `data`; a screen you complete a purchase on → `commerce`; the Material style → `material` (an editor or admin console is type `"desktop-app"`, not a genre); a pricing page is marketing and gets none of them), not what it contains — the result's `genre` field audits the choice. | **Categories and what they check** @@ -894,8 +902,8 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon node, or a mobile primitive's icon prop — `button.icon`, `leading.icon`, nav-bar `trailing[].icon`, `tab-bar` `items[].icon` (required) and `fab.icon` — that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable for icon nodes and `button.icon` when one spelling resolves) `mobile-artboard` adds −10 per warning, on every type. | | `consistency` | 20 | Frames missing `layout`, sibling overlap (detailed mode; positioned siblings that content scrolls under — absolute, sticky, pinned chrome, a sheet, a bar pinned to the bottom — are exempt, while a bar pinned to the top still counts) — 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 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). | +| `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. A list whose every row leads with an icon, a dot or a small image (a sidebar of projects) is navigation and never reads as a table, on every type. 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. On web and desktop-app canvases every pointer target (the four controls and the clickable primitives) meets the **24px floor** (WCAG 2.5.8) or its **spacing exception**, a smaller target with nothing else inside a 24px circle around it: detailed mode runs that test and makes a failure an **error**, fast mode **warns** on any target below 24 and names detailed mode — 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. @@ -917,7 +925,7 @@ 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": [] }, + "surface": { "type": "web", "device": null, "source": "default", "rulesOn": ["desktop-app-artboard", "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." diff --git a/VISION.md b/VISION.md index cce5541..81cb23d 100644 --- a/VISION.md +++ b/VISION.md @@ -561,8 +561,8 @@ _Full spec-driven breakdown in [`docs/specs/PHASE-30-SPEC.md`](docs/specs/PHASE- Run on its own viewer (2026-11-08), framesmith scored every page's usability 100 with zero findings: the viewer has a 17px Back link and 21 click targets that are plain spans, but the web hit-target rule only measures form controls. Meanwhile page rules misfired on the app: the sidebar project list read as a data table, the dense 13/14px type steps failed the scale ratio, and the projects' identity dots counted as competing accents. framesmith judges an application as if it were a marketing page, and has no words for what makes it an application. This phase adds a third type, `desktop-app` (a tool you work in, in a browser or a native window), with the vocabulary, rules, layouts, overlays and element states an app needs. Acceptance is literal: the viewer itself rebuilt as canvases, CLEAN, with every click target a primitive. -- [ ] Slice A — the type: `desktop-app` with a 1440×900 window profile, a ruleset that can switch off the type-scale ratio, the WCAG 2.5.8 spacing exception on the 24px floor, navigation lists that never read as tables and identity colors that never count as accents (both on every type), evaluate and import results an agent can read, type-aware generation with state tokens, and the baseline canvases committed as fixtures -- [ ] Slice B — vocabulary: a 24px `button` with an accessible name, `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row` and `divider` +- [x] Slice A — the type: `desktop-app` with a 1440×900 window profile, a ruleset that switches off the type-scale ratio, the WCAG 2.5.8 spacing exception on the 24px floor (web and desktop-app; mobile keeps its 44px rule) and navigation lists that never read as tables (every type), evaluate and import results an agent can read (repeated findings fold, keeping every fix), type-aware generation with state and control-color tokens, and the baseline canvases committed as fixtures +- [ ] Slice B — vocabulary: a 24px `button` with an accessible name, `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row` and `divider`, plus identity dots that never count as accents (moved here from slice A: it keys on `nav-item`) - [ ] Slice C — app rules: every click target measured, hand-built targets named, icon-only actions named, one current item per navigation group, a dense type ladder, row rhythm, honest truncation, one primary per region, and the declaration matching the window - [ ] Slice D — panes: resolution for any number of types and six desktop-app realisations (workbench, list-detail, editor-inspector, browser, dashboard, settings) under the pattern gate - [ ] Slice E — overlays: one overlay layer at the root with a fixed ladder, and the `tooltip`, `popover`, `menu`, `modal`, `toast` and `drawer` primitives, with overlay screen states as variants diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index b53db00..79f4dac 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -8,7 +8,7 @@ Canvases live inside a `Workspace > Project > Canvas` hierarchy. The built-in `P - **`workspace_create({ name })`** — top-level container (e.g. "Client work", "Personal"). - **`project_create({ workspaceId, name })`** — group related canvases inside a workspace. -- **`canvas_create({ name, projectId, width?, height?, type?, device? })`** — drops the canvas in the given project (defaults to Untitled). The artboard is 1440×900 unless you pass `width`/`height` (positive integers, each defaulting on its own). For a phone app screen pass `type: "mobile"` instead of sizing it by hand (see [Declaring the medium](#declaring-the-medium-web-or-mobile)). +- **`canvas_create({ name, projectId, width?, height?, type?, device? })`** — drops the canvas in the given project (defaults to Untitled). The artboard is 1440×900 unless you pass `width`/`height` (positive integers, each defaulting on its own). For a phone app screen pass `type: "mobile"` instead of sizing it by hand (see [Declaring the medium](#declaring-the-medium-web-mobile-or-desktop-app)). - **`canvas_move({ canvasId, projectId })`** — reassign a canvas between projects. - **`canvas_archive` / `canvas_unarchive`** — soft-delete: canvas stays on disk but hides from default listings. Reach for this when iterating; reach for `canvas_delete` (permanent) only when sure. @@ -36,17 +36,18 @@ Pick the right `width` per node — this is the single biggest lever for respons **Artboard height is a viewport, not a limit.** The root's `height` sizes the default `screenshot` capture, but content taller than it is normal: it scrolls on a real page, and the root grows to hold it, keeping its own bottom padding past the last child and leaving every child its authored size. A root child with `height: "100%"` fills whatever its siblings leave of the artboard, and grows past it with taller content. (A root that sets `overflow` keeps the plain fixed height and clips or scrolls inside itself, per its value; a root `minHeight` is your own floor and replaces the growth.) Because the root grows, an `absolute` node with `bottom` set against the root measures from the end of the document, not the screen: use `position: "fixed"` to pin to the screen. To actually see past the artboard (a long page, a design that grew under stress content), pass `fullPage: true` to `screenshot` — or to `export`, which takes the same option — instead of editing the root height by hand. -## Declaring the medium (web or mobile) +## Declaring the medium (web, mobile or desktop-app) 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, on a short screen or a long one (the root grows with its content and keeps its bottom padding past it). 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`. +- **An application screen is `canvas_create({ type: "desktop-app" })`.** Use it for a tool you work in: an editor, an admin console, a file browser, a viewer, anything with persistent chrome, panes, dense controls and a current selection, whether it runs in a browser tab or a native window. The rule is what the user does, not where it runs: a page you read and scroll (marketing, docs, content, a pricing page) is `web`; a tool you work in is `desktop-app`. It gets the window artboard (1440×900 for the default `device: "window-1440"`, the only window profile today) and no insets. `device` takes the profile table that matches the type: a phone for `mobile`, a window for `desktop-app`, nothing for `web`. - **`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. -- **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 viewer shows the declaration to the human.** Gallery cards take the artboard's proportions, between a phone held upright (390:844) and 2:1 (a longer page shows its top), so a phone screen is a portrait card. A canvas declared `mobile` opens at 1:1 in a device frame, without the breakpoint, Compare and Fit buttons, and with a **Safe area** toggle that shades the top and bottom insets: each side is the root's `safeArea` when it sets one, else the device's (47 and 34 on `iphone-390`), so a canvas made mobile with `canvas_set_type` shows the bands its content must clear. The overlay is presentation only and never reaches a screenshot or the `versionHash`. An undeclared 390px canvas opens as a web page with breakpoint buttons, so a phone screen that was never declared looks wrong to the reviewer as well as to `mobile-artboard`; `canvas_set_type({ canvasId, type: "mobile" })` fixes both, and the open page switches to the device frame on its own. +- **`canvas_set_type` re-declares an existing canvas** (`"web"`, `"mobile"`, `"desktop-app"`, or `null` to clear). A desktop-app canvas narrower than its window's 1024px minimum gets a `hint` naming `U("document", { width: 1440, height: 900 })`. 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. **On a desktop-app canvas** it pivots on 14px (13px for `data-dense`), the size dense apps read at. An explicit `baseSize` wins. The result reports the generated pivot as `baseSize` and `baseSizeSource` (`"mobile"`, `"desktop-app"`, `"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. On a web canvas `rulesOn` is `["desktop-app-artboard", "mobile-artboard"]` (the two base rules, on for every type); on a mobile canvas it adds every rule in [the mobile ruleset](#the-mobile-ruleset); on a desktop-app canvas `rulesOff` lists `type-scale-ratio`, the one rule a type turns off today (see [the desktop-app ruleset](#the-desktop-app-ruleset)). +- **The viewer shows the declaration to the human.** Gallery cards take the artboard's proportions, between a phone held upright (390:844) and 2:1 (a longer page shows its top), so a phone screen is a portrait card. A canvas declared `mobile` opens at 1:1 in a device frame, without the breakpoint, Compare and Fit buttons, and with a **Safe area** toggle that shades the top and bottom insets: each side is the root's `safeArea` when it sets one, else the device's (47 and 34 on `iphone-390`), so a canvas made mobile with `canvas_set_type` shows the bands its content must clear. The overlay is presentation only and never reaches a screenshot or the `versionHash`. A canvas declared `desktop-app` opens at its window size with breakpoint buttons for the narrower window widths of its profile (1024 and 1280 under a 1440 artboard, plus the artboard's own width), and Compare shows them side by side. An undeclared 390px canvas opens as a web page with breakpoint buttons, so a phone screen that was never declared looks wrong to the reviewer as well as to `mobile-artboard`; `canvas_set_type({ canvasId, type: "mobile" })` fixes both, and the open page switches to the device frame on its own. ## The mobile ruleset @@ -56,7 +57,7 @@ On a `mobile` canvas these rules run. Each finding names its rule in a `rule` fi - A control (toggle, checkbox, radio, select): the target extends to the control's row only when that row is a horizontal frame holding this one tappable (no other control or tappable primitive). A toggle in a 56px settings row passes; 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. - The tappable primitives, each by its own box (an ancestor's padding never counts): a `button` (32 / 44 / 52 by `size`, or a larger `height` / `minHeight`; never narrower than tall), so `size: "sm"` is an error on a phone by design; a `list-row` (44, or 56 with a subtitle; a trailing toggle or chevron is part of the row, so the row is the target); each `tab-bar` tab (bar width ÷ slots × 49, where the bar width is its `width` or the artboard's, less 32 for `floating`, and the slots are the tabs plus one for a FAB) and the FAB (56); each `nav-bar` action (44×44). The defaults all pass. - Nodes inside a component instance are judged as they render, and the finding names the instance. - - On web canvases the 24px error and the 44px phone-width info are unchanged, and primitives are not measured. + - On web and desktop-app canvases the 24px floor applies instead, with WCAG's spacing exception (see [the 24px floor](#the-24px-floor-web-and-desktop-app)). - **`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, rebuild it as a `button` (or a `list-row` / `tab-bar` item when that is what it is). - **`mobile-tab-bar`** (usability, warning, −10 per condition, one finding each). A `tab-bar` with fewer than 2 or more than 5 tabs (the FAB is not a tab; 2 is allowed, 3–5 is the comfortable range), a tab with no label, or a bar not pinned to the bottom (a `position` override, or any `y`). - **`mobile-safe-area`** (usability, warning). Content under the status bar or the home indicator, or left under a pinned bar. @@ -72,6 +73,28 @@ On a `mobile` canvas these rules run. Each finding names its rule in a `rule` fi 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) and the tab and nav-bar action labels (fixed vocabulary) out of `long-text`; `i18n` still lengthens tab and action labels, and a label hitting its designed ellipsis reports an `info`. Stress perturbs the primitives' own copy (`button.label`, `list-row.title` / `subtitle` / `trailing.text`, `nav-bar.title` and action labels, `tab-bar.items[].label`), and treats two or more consecutive sibling `list-row`s as a list for `empty` and `many`. 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`. +## The desktop-app ruleset + +A `desktop-app` canvas is judged as an application, not a page. Today its ruleset does two things; the app rules, the app vocabulary, overlays and element states come in later slices of Phase 31. + +- **`type-scale-ratio` is off.** On web and mobile, adjacent font sizes must step by a ratio between 1.1 and 2.0, and a pair outside that warns with `rule: "type-scale-ratio"`. Dense apps step 12 / 13 / 14 on purpose, so the desktop-app ruleset turns that rule off; it is listed in the `surface` report's `rulesOff`. Pinned typography tokens still skip the check on every type. +- **`desktop-app-artboard`** (structure, warning; a base rule, so it runs on every type). A desktop-app canvas narrower than 1024px, the narrowest window the desktop-app layouts are built for. Resize the root to the window (`U("document", { width: 1440, height: 900 })`), or declare the type the canvas really is. + +There are no desktop-app realisations yet: `apply_structure` on a desktop-app canvas refuses every page anatomy and names the types it is realised on — `realisation: "web"`, plus `"mobile"` for the six mobile anatomies — so `realisation: "web"` stamps the web `split-workbench`, `dashboard` or `settings` onto it. Component scaffolds that stamp on every type stamp as usual. + +### The 24px floor (web and desktop-app) + +On web and desktop-app canvases every pointer target is measured against WCAG 2.5.8's 24px floor: the four controls (toggle, checkbox, radio, select) and the clickable primitives (`button`, `list-row`, each `tab-bar` tab and the FAB, each `nav-bar` action). A lone control first grows to its padded row, two levels up, as before; a disabled target is neither measured nor in anyone's way; nodes inside a component instance are judged as rendered and reported on the instance. What is still below 24px passes only by WCAG's **spacing exception**: a 24px circle centred on it must reach no other target's box and no other undersized target's circle (circles that only touch pass; a target nested inside another is not its neighbour). Spacing needs rendered positions, so: + +- **Detailed mode** (`canvas_evaluate({ canvasId, mode: "detailed" })`) runs the circle test and is the authority: a failure is an **error** that names the neighbour it reaches. +- **Fast mode** cannot see spacing, so a target below 24px is a **warning** that names the exception and detailed mode. It blocks presenting until you grow the target or a detailed run clears it. + +Mobile keeps its own 44px rule (`mobile-touch-target`), which has no spacing exception. + +### Lists that lead with a mark are not tables (every type) + +A list whose every row starts with a mark (an icon, a dot, or a small image) is navigation, not data: a sidebar of projects with an identity dot, a name and a count. Table detection skips it, so `coverage` never asks for an empty or loading state for your navigation, and `canvas_stress` perturbs its labels as ordinary text. A table with a text header row still detects, even when every data row leads with an avatar. + ## Mobile vocabulary (primitives and pinning) Build phone screens from the mobile primitives, not hand-built frames. Each one carries its own size, hit area and colours (from `$accent`, `$border`, `$bg-surface`, `$text-primary`, `$text-secondary` and `$danger`, with neutral fallbacks on an unthemed canvas; `fill`, `stroke`, `color`, `iconColor`, `secondaryColor` and `badgeColor` override). None takes children except `sheet`. Labels truncate to one line with an ellipsis. @@ -103,7 +126,7 @@ The bar is 12 + 44 + 34 + 12 = 102px tall; the root's `safeArea` reserves 34, so ## Starting a phone screen (mobile realisations) -A structure realises an **anatomy** (a screen kind) for one canvas type. A canvas has the type it was declared with (`canvas_create({ type: "mobile" })` or `canvas_set_type`, see [Declaring the medium](#declaring-the-medium-web-or-mobile)); an undeclared canvas is web whatever its width, so declare first. On a mobile canvas, `apply_structure` resolves an anatomy name to its mobile realisation, so `apply_structure({ canvasId, structure: "dashboard" })` stamps `dashboard-mobile`; the result's `applied`, `anatomy` and `surface` say what was stamped. Six anatomies have a mobile realisation; the other web pages (`marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `pricing`) have none: +A structure realises an **anatomy** (a screen kind) for one canvas type. A canvas has the type it was declared with (`canvas_create({ type: "mobile" })` or `canvas_set_type`, see [Declaring the medium](#declaring-the-medium-web-mobile-or-desktop-app)); an undeclared canvas is web whatever its width, so declare first. On a mobile canvas, `apply_structure` resolves an anatomy name to its mobile realisation, so `apply_structure({ canvasId, structure: "dashboard" })` stamps `dashboard-mobile`; the result's `applied`, `anatomy` and `surface` say what was stamped. Six anatomies have a mobile realisation; the other web pages (`marquee-hero`, `bento-grid`, `stat-led`, `editorial-longform`, `split-workbench`, `catalogue`, `pricing`) have none: | Anatomy | Mobile realisation | Shape | |---|---|---| @@ -216,9 +239,9 @@ Authoring rules: - **Project layer is for sub-brand overrides.** A `Coide → Marketing` project might override `primary` with the marketing accent while inheriting everything else from the workspace. - **Canvas-level variables are escape hatches**, not the primary surface. Use them when one canvas legitimately diverges from the design system; otherwise leave them empty and let the workspace tokens flow through. - **Presets work at every layer.** `workspace_apply_preset({ workspaceId, preset: "dark" })` copies the dark-preset tokens into the workspace; `project_apply_preset` and the existing `apply_preset` (canvas-level) do the same at their respective layers. -- **Start the look with one call — `generate_design_system`.** `generate_design_system({ seed: "#0E7490", personality: "technical", workspaceId })` writes the complete design language: the color system, a curated font pairing (real faces load from the first screenshot), typography **roles** (`$display` / `$heading` / `$title` / `$body` / `$label` / `$caption`, plus `$figures` with a mono face), a radius and density stance, `$elevation.flat|raised|floating|overlay` depth tokens (the dark layer re-states each depth so it reads on dark surfaces — reference as `shadow: "$elevation.raised"`), and `$motion` defaults. The personality is **required** and is a real design stance: `technical` (crisp product tool), `editorial` (serif voice, generous air), `soft` (rounded, warm), `data-dense` (instrument-panel density). Same seed, different personality = visibly different product. The two generators below regenerate just one part. +- **Start the look with one call — `generate_design_system`.** `generate_design_system({ seed: "#0E7490", personality: "technical", workspaceId })` writes the complete design language: the color system, a curated font pairing (real faces load from the first screenshot), typography **roles** (`$display` / `$heading` / `$title` / `$body` / `$label` / `$caption`, plus `$figures` with a mono face), a radius and density stance, `$elevation.flat|raised|floating|overlay` depth tokens (the dark layer re-states each depth so it reads on dark surfaces — reference as `shadow: "$elevation.raised"`), `$motion` defaults, and the element-state tokens: a `state` section of opacities (`hover` 0.08, `focus` 0.10, `pressed` 0.10 — the share of the ink over a control's fill — and `disabledContent` 0.38 / `disabledContainer` 0.12, Material 3's values; set them by hand with `set_variables({ state: { … } })`). The personality is **required** and is a real design stance: `technical` (crisp product tool), `editorial` (serif voice, generous air), `soft` (rounded, warm), `data-dense` (instrument-panel density). Same seed, different personality = visibly different product. The two generators below regenerate just one part. - **Don't hand-pick a type scale — generate one.** `generate_scale({ ratio: "major-third", workspaceId })` derives a full `text-xs`…`text-3xl` scale plus a paired `space-3xs`…`space-3xl` scale from a named ratio (`minor-second` 1.125, `major-second` 1.2, `minor-third` 1.25, `major-third` 1.333, `perfect-fourth` 1.5, `golden` 1.618 — or any number), with the craft defaults (line-height bands, display tracking) baked in and sizes pinned against the ratio check. Pass `fluid: {}` for Utopia-style `clamp()` sizes that interpolate between viewports instead of static px. -- **Don't eyeball a palette from ten hexes — generate one.** `generate_color_system({ seed: "#2563EB", workspaceId })` derives OKLCH `primary-50`…`900` + a matched neutral ramp + status colors + the `bg-surface`/`text-primary`/`accent`/… semantic tokens the structures already speak, with every text/surface pair AA-clean by construction. Status colors (`success`/`warning`/`danger`) are tuned as TEXT — each is darkened from its hue's band until it clears AA (4.5:1) against white, so a `$danger` validation message is legible, not just legible-adjacent. It also derives the color range — a `$chart-1`…`$chart-6` categorical series palette and the `-tint` layer for chips/tiles/badges — see "Designing with character" below. +- **Don't eyeball a palette from ten hexes — generate one.** `generate_color_system({ seed: "#2563EB", workspaceId })` derives OKLCH `primary-50`…`900` + a matched neutral ramp + status colors + the `bg-surface`/`text-primary`/`accent`/… semantic tokens the structures already speak, with every text/surface pair AA-clean by construction. Status colors (`success`/`warning`/`danger`) are tuned as TEXT — each is darkened from its hue's band until it clears AA (4.5:1) against white, so a `$danger` validation message is legible, not just legible-adjacent. It also derives the color range — a `$chart-1`…`$chart-6` categorical series palette and the `-tint` layer for chips/tiles/badges — see "Designing with character" below — and two control colors, each at 3:1 or more (WCAG 1.4.11) in both themes: `$focus-ring`, against every `bg-*` and `-tint` token, and `$border-strong`, against `bg-primary` and `bg-surface`, for a control whose border is its only boundary. `generate_design_system` writes them too. - **It ships a dark theme too.** The dark mapping lands in a sparse `dark.colors` override layer alongside the light tokens (or set one by hand via `set_variables`, which also accepts `dark.elevation` for depth-token overrides) — status colors get a second, dark-specific pass here so they clear AA against the dark surface as well. Render it with `theme: "dark"` on `screenshot` / `screenshot_responsive` / `export`; `canvas_evaluate`'s `color` category then contrast-checks both themes automatically and scores the worse one — dark-run failures point at the dark token layer (a node literal would break light). Contrast is measured as rendered: translucent fills and text (`rgba` / `#RRGGBBAA`) composite over what is behind them, so a tinted selected or hover row is judged by its real color. WCAG 2.2 is the gate; **APCA** Lc values appear as info-only advisories on perceptually weak pairs (APCA is a candidate method, not a standard — it never blocks). - **Give motion the same treatment as color and type.** Declare `motion` tokens via `set_variables` (`{ fast: { duration: 150, easing: "ease-out" } }`) and reference them as `transition: "$motion."` — ad-hoc duration/easing pairs sprawling across a canvas with no declared motion tokens get an advisory. @@ -278,7 +301,7 @@ A screen that already ships doesn't need redrawing — `canvas_import_html` (sni **The report is the contract.** Imports are lossy by design; what makes them trustworthy is that they say exactly what happened. After every import, read: -- `report.snapped` — values rewritten to `$token` refs (Tailwind class intent like `bg-surface`, plus nearest-color matches against your design system). `literals` lists colors that found no token; near-ties are reported and left literal, never guessed. `scaleMatches` notes numbers that equal a scale token (gap 16 ≙ `$md`) — informational, since number-typed props can't hold refs. +- `report.snapped` — values rewritten to `$token` refs (Tailwind class intent like `bg-surface`, plus nearest-color matches against your design system). `literals` lists colors that found no token; near-ties are reported and left literal, never guessed. `scaleMatches` notes numbers that equal a scale token (gap 16 ≙ `$md`) — informational, since number-typed props can't hold refs. By default each of the three comes grouped, `{ token, count, nodeIds }` (literals by `value`), with the first five node ids per group and `report.summarized: true`, so a large page's report stays readable; pass `verbose: true` to the import for the full per-node lists. - `report.layout` — how each container's **structure** was reconstructed: `table` (rows of proportional columns), `grid` (rows from the computed track template), `centered` (auto-margin/max-width content kept centered at its real width), `geometry` (multi-column CSS clustered from bounding boxes). A **`stack-fallback`** entry is your to-do list: that one container looked multi-column but couldn't be reconstructed confidently — fix it by hand; everything else arrived structurally correct, so **don't rebuild imported tables or grids node-by-node**. - `report.warnings` / `unmatchedFonts` / `unmatchedIcons` — dropped background images, truncations, fonts and SVGs that didn't resolve. @@ -325,7 +348,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; 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_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 pointer target below the 24px floor fails (WCAG 2.5.8 — a lone control inherits its padded row's box, so real settings rows pass, and a smaller target passes by the spacing exception; fast mode warns, detailed mode errors, see [the 24px floor](#the-24px-floor-web-and-desktop-app); 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. **Repeated findings fold**: three or more warnings or infos with the same rule (or tell), category and theme come back as ONE entry carrying `nodeIds`, `count` and, when they had fixes, `fixes: [{ nodeId, op }]` — act on every node it lists. Members with a fix fold on the message's shape (the fix op holds each node's numbers); members without one fold only when they read the same apart from quoted node text, so two type-ratio warnings with different sizes stay separate. Errors never fold. `canvas_autofix` works from the unfolded list, so it still returns one op per node. - **`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. @@ -345,7 +368,7 @@ The happy path of one static frame with ideal data is half a design. Real UX liv | `loading` | `skeleton-table` / `skeleton-card` / `skeleton-stat-card` | Real header + `skeleton` blocks matching the loaded geometry, so content lands without layout shift. Skeletons pulse only in the live viewer — screenshots stay static | | `error` | — | The form's validation-failure treatment: field messages + a summary. Design it; there's no generic scaffold because error copy is the design | -**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. +**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. A list whose every row leads with an icon, a dot or a small image is navigation and never detects.) 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 a primitive's label hitting its designed ellipsis, or a root with an authored `overflow` clipping or scrolling inside itself; a sheet's own vertical overflow scrolls and is never a clip; 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. @@ -420,7 +443,7 @@ Polishing the design to the bar is **your** job, not the user's — they should - **Genre and preset are separate fields.** `canvas_set_genre` writes `metadata.provenance.genre`; `apply_preset` writes `metadata.provenance.preset` and never changes the genre, so styling a declared canvas can't undo its declaration. Aliases fold: `genre.active` and `canvas_set_genre`'s `canonical` report `"data"` for a `"dashboard"` stamp and `"commerce"` for `"checkout"` (the stored value stays as you wrote it). - **The material preset relaxes on its own.** A canvas styled with `apply_preset("material")` skips the accent-hue and pure-black-white tells whatever its genre, even under an explicit `genre` param (which replaces only the genre). The evaluate result's `genre.byPreset` (`{ preset, relaxed }`) shows when that happened, and `canvas_set_genre`'s `presetRelaxes` says so at stamp time. Clearing the genre doesn't remove a preset's relaxation; applying another preset does. - **Older canvases migrate on the first write.** A canvas stamped by an older framesmith (genre stored in the preset field) keeps its score; the first `canvas_set_genre` or `apply_preset` moves the genre to its own field. Run one framesmith version per repo: an older version writes the genre into the preset field, which this one ignores once a canvas has been migrated. -- **Genre follows what the screen is FOR, not what it contains.** "It shows figures → `data`" is the intuitive heuristic and it's wrong: a *targets editor* stamped `data` clears one figure tell while leaving the white-surface tells that `material` would have relaxed. Read screens presenting the user's own figures → `data`; a screen you complete a purchase on → `commerce`; editors and admin forms → `material`. A **pricing page is marketing**, not commerce: its numbers are claims about a product, not the arithmetic of a transaction, so it keeps the fabricated-content gate. The evaluate result's `genre` field makes this checkable: it reports the active genre (canonical), its source (`explicit` param vs `provenance` stamp), the tells it `relaxed`, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. If your score is pinned by tells listed in `notRelaxed`, you likely declared the wrong genre. (`honest-content` is effectively the ceiling for any financial UI — declare `data` on read screens, `commerce` on transactional ones, or the design's own figures cap the score.) +- **Genre follows what the screen is FOR, not what it contains.** "It shows figures → `data`" is the intuitive heuristic and it's wrong: a *targets editor* stamped `data` clears one figure tell while leaving the white-surface tells that `material` would have relaxed. Read screens presenting the user's own figures → `data`; a screen you complete a purchase on → `commerce`; the Material style → `material` (an editor or admin console is a type, not a genre: declare it `type: "desktop-app"`). A **pricing page is marketing**, not commerce: its numbers are claims about a product, not the arithmetic of a transaction, so it keeps the fabricated-content gate. The evaluate result's `genre` field makes this checkable: it reports the active genre (canonical), its source (`explicit` param vs `provenance` stamp), the tells it `relaxed`, and `notRelaxed` (`[{ tell, relaxedBy }]`) — tells still flagging that a *different* genre would relax. If your score is pinned by tells listed in `notRelaxed`, you likely declared the wrong genre. (`honest-content` is effectively the ceiling for any financial UI — declare `data` on read screens, `commerce` on transactional ones, or the design's own figures cap the score.) ## Sharp edges diff --git a/docs/specs/PHASE-31-SPEC.md b/docs/specs/PHASE-31-SPEC.md index df6d00b..8ba945c 100644 --- a/docs/specs/PHASE-31-SPEC.md +++ b/docs/specs/PHASE-31-SPEC.md @@ -99,7 +99,7 @@ One profile ships, keyed by id so a second size is one table row later. Values i | **FR-A4** | **Targets on web and desktop-app, with the spacing exception.** The 24px floor (`checkUsability`, `evaluate.ts:1979`) keeps its existing first step for the four controls: a lone control grows to its padded row (`evaluate.ts:2016-2043`), and a control that is the only one inside a `property-row`, `nav-item`, `menu-row` or `list-row` owns that primitive's box. It then measures every clickable primitive on `web` and `desktop-app`, not only the four controls, and applies the target circle to whatever is still below 24. **Detailed mode is authoritative**: it runs the circle test on rendered rects and reports an error when it fails. **Fast mode** has no rects, so a target still below 24 after the row step is a `warning` naming the spacing exception and detailed mode; it blocks presenting until detailed mode clears or confirms it. For the four controls this downgrades today's error in both modes (`evaluate.ts:2054-2063`) to a fast-mode warning and a detailed-mode error unless the circle clears it. Instances are expanded for the census, as on mobile (`expandForRules`, `evaluate.ts:2235`). Inline links (FR-B2) are exempt, as WCAG exempts inline targets. Mobile's 44px floor is unchanged. | Two 20px icon buttons with centres 24px apart pass in detailed mode; the same buttons touching fail with a message naming the 24px circle; both warn in fast mode. A lone 18px checkbox in a padded row still passes. The slice A PR lists every existing fixture whose usability verdict moved, and why. | | **FR-A5** | **Navigation lists are not tables, on every type.** `detectTable` (`src/drift.ts:106`) already rejects primitive rows, since it requires frame rows. The misfire is the hand-built list, so it gains a positive discriminator: a frame is not a table when it has three or more rows, every row's first child is an icon, a dot (an `ellipse`, or a frame or rectangle of 12px or less on both axes with no children), or an image of 24px or less, and its first row is not a header row (a row whose cells are all text). Stress's own row reader (`tableRows`, `src/stress.ts:129`) applies the same rule. The sidebar's texts then enter the inventory as texts, which changes what stress perturbs on such a canvas. | The baseline sidebar fixture (FR-A9) yields no table and no coverage finding. A Region / Revenue table with a header row, the checkout totals rows, and a users table whose rows start with a 24px avatar under a text header row all still detect, as does every existing table fixture. | | **FR-A6** | **Identity dots are not accents, on every type.** `nav-item` gains `dotColor` for an identity dot (FR-B3); `color` keeps its existing meaning, the text color (`src/types.ts:183`). A color value that appears in the tree only as `nav-item` `dotColor` is excluded from the accent-consistency census (`evaluate.ts:1757-1806`); the same value used as a literal anywhere else still counts. Hand-built dots keep counting, because nothing marks them as identity. | The baseline gallery rebuilt with `nav-item dotColor` dots does not trip `accent-consistency`; a canvas with two literal accent fills on buttons still does; a hand-built dot with an identity color still counts. | -| **FR-A7** | **Results an agent can read.** The serialised `canvas_evaluate` result folds `info` findings that share a key of category, `rule` or `tell` (empty when neither is set), and message template (the message with quoted text and numbers stripped) into one entry carrying `nodeIds[]` and `count`, once three or more share a key. Errors, warnings, and infos that carry a `fix` are never folded. The fold runs on the serialised result only: `canvas_autofix` and the directive read the unfolded list. `canvas_import_url` and `canvas_import_html` return `snapped`, `scaleMatches` and `literals` (`src/import.ts:157-162`) as per-token counts with the first five node ids each, and the full lists under a new `verbose: true` input. | On the baseline detail fixture (FR-A9), the default import report and the fast `canvas_evaluate` result are each at most half their 2.4.0 size (about 60,000 and 77,000 characters), measured in the slice A PR. `canvas_autofix` returns the same ops before and after. | +| **FR-A7** | **Results an agent can read.** The serialised `canvas_evaluate` result folds repeated findings into one entry carrying `nodeIds[]`, `count` and `fixes[]` (as changed by spec change S1, see "Slice A — as built"): warnings and infos fold once three or more share a key of severity, category, `rule` or `tell`, theme and message (fix-bearing members on the message's shape, members without a fix only when message and suggestion read the same); errors never fold. The fold runs on the serialised result only: `canvas_autofix` and the directive read the unfolded list. `canvas_import_url` and `canvas_import_html` return `snapped`, `scaleMatches` and `literals` (`src/import.ts:157-162`) as per-token counts with the first five node ids each, and the full lists under a new `verbose: true` input. | On the baseline detail fixture (FR-A9), the default import report is at most half its 2.4.0 size (about 60,000 characters), and the fast `canvas_evaluate` result is at most 58% of its unfolded size (amended under spec change S1: folding without losing any member's message reaches about 55%), measured in the slice A PR. `canvas_autofix` returns the same ops before and after. | | **FR-A8** | **Type-aware generation and state tokens.** `typeBase` (`src/design-language.ts:169-177`) gains a desktop-app branch: base 14 for every personality except `data-dense` (13); an explicit `baseSize` wins; its `source` union gains `'desktop-app'`. `DesignVariables` gains a numeric `state` section (`hover`, `focus`, `pressed`, `disabledContent`, `disabledContainer`, as opacities from the constants table), alongside the existing `motion` and `elevation` sections (`src/types.ts:334-343`), with the same workspace, project and canvas layering. The color engine (`src/color-system.ts`) emits `focus-ring`, picked to clear 3:1 against every generated `bg-*` and `*-tint` token in both themes (a filled primitive's own fill is covered by FR-G1's inner ring, since no single color can clear 3:1 against both the dark surfaces and a light primary fill), and `border-strong`, picked to clear 3:1 against `bg-primary` and `bg-surface` in both themes. | Each personality generated on a desktop-app canvas yields `body` 13 or 14 and nothing below 11; `focus-ring` measures 3:1 or more against every `bg-*` and `*-tint` token, and `border-strong` against `bg-primary` and `bg-surface`, in both themes. | | **FR-A9** | **The baseline is reproducible.** The four imported viewer canvases (gallery, web detail, mobile detail, archive) are committed under `test-fixtures/viewer-baseline/` in the slice A PR, with their sha256 recorded in the PR. Every acceptance in this spec that names a baseline fixture runs against those files. | The files exist, and a test loads each one. | | **FR-B1** | **A 24px button with a name.** `button` gains `size: 'xs'` (24 tall, 8px horizontal padding, 12px label; icon-only `xs` is 24×24) and `ariaLabel?`, which renders `aria-label` and `title` and never renders text. A button is icon-only when `label` is blank and `icon` is set (`buttonBox`, `src/primitives.ts:62`). `size` widens to `'xs' \| 'sm' \| 'md' \| 'lg'` (`src/types.ts:303`). Heights stay type-independent. `xs` fails the mobile 44px floor by design, which the docstring states. | `snapshot_layout` reads an icon-only `xs` button at 24×24; with `ariaLabel` it renders `aria-label` and `title` and no text. | @@ -183,6 +183,22 @@ One profile ships, keyed by id so a second size is one table row later. Values i - `test-fixtures/viewer-baseline/`: the four baseline canvases. - `src/viewer.ts:1171` (`renderDetailPage`): window-width buttons. +**Slice A — as built (recorded 2026-11-09; read this before slice B).** +- **Spec change S1 (FR-A7, approved by Victor).** Info-only folding could not reach the size acceptance: on the baseline detail page most of the volume is repeated spacing *warnings* that carry fixes. The serialised `canvas_evaluate` result now folds repeated **warnings and infos** once three or more share a key of severity, category, rule or tell, theme and message: members that carry a fix match on the message's shape (quoted text and numbers stripped, since the fix op holds each node's specifics); members without one match only when the message reads the same apart from quoted node text and the suggestion is identical (verify round 1: a looser key folded a 1.07 ratio and a 2.67 one under one message). The folded entry keeps the first member's message and suggestion and carries distinct `nodeIds`, `count`, and `fixes: [{ nodeId, op }]` for every member that had a fix. Errors never fold. `canvas_autofix` and the directive read the unfolded list. The shipped function is `foldIssues` (`src/evaluate.ts`). Measured on the baseline detail fixture with the final key: 74,609 → 41,008 characters pretty-printed (55%; the looser first key reached 52% by losing members' messages); the acceptance is amended to ≤ 58% and the test asserts it. Other fixtures: Ridgeline 55%, gallery 56%, archive 74%. The import report summary measured 61,102 → 11,916 on a live re-import of the same page. +- **D1: FR-A6 moves to slice B.** The identity-dot exclusion keys on `nav-item` `dotColor`, which does not exist until FR-B3. It lands with `nav-item`. +- **D2: `desktop-app-artboard` is partial.** Slice A ships the window-width clause (a desktop-app canvas narrower than 1024 warns); the clauses about desktop-app and overlay primitives on other types land with those primitives (slices B and E). The rule lives in `src/desktop-app-rules.ts`. +- **D3: the slice A census** is the four controls plus the existing tappable primitives (`button`, `list-row`, `tab-bar` tabs and FAB, `nav-bar` actions). New primitives join it in slice B. Web's row step still counts controls only, so a checkbox beside a button keeps its lone-control row (the existing verdict). +- **D4: stress needed no change for FR-A5.** `stress.ts` reads rows only from tables the shared inventory already detected, so the `detectTable` discriminator covers it. +- **D5: the dot test tolerates a missing dimension.** Imported dots carry one size (the baseline's sidebar dot has `width: 6` and no height), so a dot is a childless frame or rectangle with at least one known dimension, every known one 12px or less. +- **D6: two baseline tables remain, both importer shapes.** The gallery's card grid (imported as rows of frames whose score badges read as a header) and the inspector's category list still detect on the fixtures. Neither is a navigation list; the importer is out of scope (Non-goals). +- **D7: the fixtures carry their tokens.** Each `test-fixtures/viewer-baseline/*.json` embeds the repo workspace design system in `variables`, so the canvases resolve in the isolated test store exactly as they did in the baseline run. +- **D8: detailed rects come from a second render only when needed.** The circle test renders the instance-expanded tree once, and only when a target is undersized, so web's detailed consistency check keeps its existing render. `computeLayout` returns a tree; the census indexes it. A target the layout walker cannot place (inside an element with no node id) keeps the blocking warning, which then says detailed mode could not place it. +- **D9: a table whose header row also leads with a mark reads as a list.** The discriminator asks whether every row leads with a mark; a header with a blank spacer or a select-all checkbox frame in its first cell matches, so such a table loses detection. The spec's own definition ("its first row is not a header row, a row whose cells are all text") leaves that case where the code puts it. Accepted for slice A; revisit if a real table hits it. +- **D11: an import-named `Table` keeps detection.** The FR-A5 discriminator skips frames named `Table`, the trusted shape the importer emits (Phase 18), so an imported table whose rows lead with a dot or avatar keeps coverage and stress handling. +- **D12: a primitive's sub-targets are never placed individually.** A `tab-bar`'s tabs and a `nav-bar`'s actions render under the primitive's one node id, so in detailed mode an undersized tab keeps the blocking warning instead of a circle test against the whole bar (verify round 1 found the bar's centre standing in for every tab). +- **D10: an unplaced neighbour keeps the small target blocking.** The layout walker stops at depth 25 and at elements without a node id; when a neighbour of an undersized target has no rect, the circle test cannot be proven, so the target keeps a blocking warning naming the neighbour (verify round 1). +- Structure resolution now names every type a refused anatomy is realised on (`realisation: "web"` or `"mobile"`), the first part of FR-D1. + **Slice B: vocabulary (FR-B1 to FR-B10)** - `src/types.ts:1-12`: `NodeType` gains `link`, `text-input`, `nav-item`, `tabs`, `tree-row`, `menu-row`, `property-row`, `divider`. New props on `SceneNode`: `ariaLabel`, `links`, `count`, `dotColor`, `active` (already on `TabItem`), `depth`, `collapsed`, `expanded`, `shortcut`, `destructive`, `submenu`, `labelWidth`, `placeholder`, `rows`, `error`, `orientation`, `inset`, and a `TabsItem` type for `tabs.items`, with `SceneNode.items` (`:294`) widened to `TabItem[] | TabsItem[]`. The `variant` union (`:296`) and `size` union (`:303`) widen; `title` (`:283`) extends to `text`. - `src/primitives.ts`: `BUTTON_HEIGHT.xs = 24` and its padding and font; the new primitives' heights; `CLICKABLE_PRIMITIVES`, a superset of `TAPPABLE_PRIMITIVES` (`:24`), used by the web and desktop-app censuses. diff --git a/src/color-system.ts b/src/color-system.ts index 42afdc0..4187be0 100644 --- a/src/color-system.ts +++ b/src/color-system.ts @@ -299,6 +299,30 @@ export function tintLayer( export type StatusColors = Record<'success' | 'warning' | 'danger', string>; +/** Phase 31 FR-A8 — the control colors an app needs: a focus ring that reads + * against every surface and tint it can sit on, and a border strong enough + * to be a control's only boundary cue (both 3:1, WCAG 1.4.11). */ +export type ControlColors = Record<'focus-ring' | 'border-strong', string>; + +/** First ramp step clearing `min` against EVERY background. Falls back to + * the extreme step (900 for light themes, 50 for dark), which clears against + * the generated surfaces. */ +function pickStepAll(ramp: Ramp, bgs: string[], min: number, direction: 'darker' | 'lighter'): string { + const steps = direction === 'darker' ? [...RAMP_STEPS] : [...RAMP_STEPS].reverse(); + for (const s of steps) { + if (bgs.every((bg) => contrastRatio(rgbOf(ramp[s]), rgbOf(bg)) >= min)) return ramp[s]; + } + return direction === 'darker' ? ramp[900] : ramp[50]; +} + +function controlColors(primary: Ramp, neutral: Ramp, theme: SemanticTheme, tints: Record, direction: 'darker' | 'lighter'): ControlColors { + const surfaces = [theme['bg-primary'], theme['bg-surface'], theme['bg-elevated']]; + return { + 'focus-ring': pickStepAll(primary, [...surfaces, ...Object.values(tints)], 3, direction), + 'border-strong': pickStepAll(neutral, [theme['bg-primary'], theme['bg-surface']], 3, direction), + }; +} + /** The full system from one seed — what generate_color_system writes/reports. * The dark layer includes STATUS overrides (dogfood fix): the light-tuned * status colors are re-lit for AA against the dark surface. */ @@ -313,8 +337,10 @@ export function generateColorSystem(seedHex: string): { categorical: CategoricalPalette; /** Phase 28 — the tint layer (accent/success/warning/danger/neutral-tint), light values. */ tints: TintLayer; - /** Dark counterparts for categorical + tints (merged into dark.colors by callers). */ - darkRange: CategoricalPalette & TintLayer; + /** Phase 31 — focus-ring + border-strong, light values. */ + controls: ControlColors; + /** Dark counterparts for categorical + tints + controls (merged into dark.colors by callers). */ + darkRange: CategoricalPalette & TintLayer & ControlColors; /** Present when the palette degraded deliberately (near-neutral seed, fewer hues). */ rangeNote?: string; } { @@ -347,12 +373,15 @@ export function generateColorSystem(seedHex: string): { 'danger-tint': darkStatus.danger, 'neutral-tint': darkFull['text-secondary'], }, 'dark'); + const controls = controlColors(primary, neutral, light, tints, 'darker'); + const darkControls = controlColors(primary, neutral, darkFull, darkTints, 'lighter'); return { seed: { hex: seedHex, oklch: hexToOklch(seedHex) }, primary, neutral, status, light, dark: darkFull, categorical: cat.palette, tints, - darkRange: { ...cat.darkPalette, ...darkTints }, + controls, + darkRange: { ...cat.darkPalette, ...darkTints, ...darkControls }, ...(cat.note ? { rangeNote: cat.note } : {}), }; } diff --git a/src/design-language.ts b/src/design-language.ts index bf5df59..e8e700e 100644 --- a/src/design-language.ts +++ b/src/design-language.ts @@ -15,7 +15,7 @@ import { generateColorSystem } from './color-system.js'; import { generateTypeScale, generateSpaceScale, type RatioName } from './scales.js'; import type { DesignVariables, ShadowSpec } from './types.js'; -import { DEVICE_PROFILES, DEFAULT_DEVICE, deviceProfile, type SurfaceType } from './surfaces.js'; +import { DEVICE_PROFILES, DEFAULT_DEVICE, deviceProfile, WINDOW_PROFILES, DEFAULT_WINDOW, windowProfile, type SurfaceType } from './surfaces.js'; export type PersonalityName = 'technical' | 'editorial' | 'soft' | 'data-dense'; export const PERSONALITY_NAMES: PersonalityName[] = ['technical', 'editorial', 'soft', 'data-dense']; @@ -165,13 +165,23 @@ export interface DesignSystemOptions { device?: string; } +/** Phase 31 FR-A8 — Material 3's current state-layer and disabled + * opacities (Flutter gen_defaults token files; an older M3 set used 0.12 for + * focus and pressed). */ +export const STATE_LAYERS = { hover: 0.08, focus: 0.10, pressed: 0.10, disabledContent: 0.38, disabledContainer: 0.12 } as const; + /** Where a generated system's type pivot came from. */ -export function typeBase(personalityName: PersonalityName, opts: DesignSystemOptions = {}): { size: number; source: 'explicit' | 'personality' | 'mobile' } { +export function typeBase(personalityName: PersonalityName, opts: DesignSystemOptions = {}): { size: number; source: 'explicit' | 'personality' | 'mobile' | 'desktop-app' } { if (opts.baseSize !== undefined) return { size: opts.baseSize, source: 'explicit' }; if (opts.surface === 'mobile') { const profile = deviceProfile(opts.device ?? DEFAULT_DEVICE) ?? DEVICE_PROFILES[DEFAULT_DEVICE]; return { size: personalityName === 'data-dense' ? profile.bodyFloor : profile.bodySize, source: 'mobile' }; } + // Phase 31 — a desktop app reads at 14 (Carbon), 13 for data-dense. + if (opts.surface === 'desktop-app') { + const window = windowProfile(opts.device ?? DEFAULT_WINDOW) ?? WINDOW_PROFILES[DEFAULT_WINDOW]; + return { size: personalityName === 'data-dense' ? window.bodyDense : window.bodySize, source: 'desktop-app' }; + } return { size: PERSONALITIES[personalityName].type.baseSize, source: 'personality' }; } @@ -194,7 +204,7 @@ export function generateDesignSystem( for (const [step, hex] of Object.entries(colorSystem.neutral)) colors[`neutral-${step}`] = hex; Object.assign(colors, colorSystem.status, colorSystem.light); // Phase 28 — the color range: categorical series + tint layer ride along. - Object.assign(colors, colorSystem.categorical, colorSystem.tints); + Object.assign(colors, colorSystem.categorical, colorSystem.tints, colorSystem.controls); // Type scale — the existing engine, pivoted by the personality. const baseSize = typeBase(personalityName, opts).size; @@ -248,6 +258,7 @@ export function generateDesignSystem( radius, elevation: p.elevation.light, motion: p.motion, + state: { ...STATE_LAYERS }, dark: { colors: { ...colorSystem.dark, ...colorSystem.darkRange }, elevation: p.elevation.dark }, }; diff --git a/src/desktop-app-rules.ts b/src/desktop-app-rules.ts new file mode 100644 index 0000000..db1b8f7 --- /dev/null +++ b/src/desktop-app-rules.ts @@ -0,0 +1,26 @@ +// Phase 31 — the desktop-app rules. Each check lands in the same change as +// the rule id it honours (see DESKTOP_APP_RULES in surfaces.ts). + +import type { EvaluationIssue } from './evaluate.js'; +import type { SceneNode } from './types.js'; +import { windowProfile, DEFAULT_WINDOW, WINDOW_PROFILES, type SurfaceDeclaration } from './surfaces.js'; + +/** `desktop-app-artboard` (base ruleset, every type). Slice A: a desktop-app + * canvas narrower than its window's minimum warns. The clauses about + * desktop-app and overlay primitives on other types land with those + * primitives. */ +export function checkDesktopArtboard(root: SceneNode, surface: SurfaceDeclaration): EvaluationIssue[] { + if (surface.type !== 'desktop-app') return []; + const profile = windowProfile(surface.device ?? DEFAULT_WINDOW) ?? WINDOW_PROFILES[DEFAULT_WINDOW]; + const width = typeof root.width === 'number' ? root.width : profile.width; + if (width >= profile.minWidth) return []; + return [{ + category: 'structure', + rule: 'desktop-app-artboard', + severity: 'warning', + nodeId: root.id, + nodeName: root.name, + message: `This canvas is declared desktop-app but its artboard is ${width}px wide — the desktop-app layouts are built for windows of ${profile.minWidth}px and up.`, + suggestion: `Resize it: U("document", { width: ${profile.width}, height: ${profile.height} }) — or canvas_set_type({ type: "mobile" }) / ({ type: "web" }) if it is a phone screen or a narrow web page.`, + }]; +} diff --git a/src/drift.ts b/src/drift.ts index 384515d..d531fcd 100644 --- a/src/drift.ts +++ b/src/drift.ts @@ -122,6 +122,11 @@ function detectTable(node: SceneNode): { columnCount: number; headers: string[]; // rare real header-plus-one-row table loses detection — accepted trade, // recorded in the drift docs. Import-named Tables keep the loose shape. if (rows.length < (named0 ? 2 : 3)) return null; + // Phase 31 FR-A5 — a list whose every row LEADS with a mark (an icon, a + // dot, a small image) is navigation, not data: a sidebar of projects with + // an identity dot, a name and a count. A table with a text header row never + // matches, since its header row leads with text. + if (!named0 && rows.every((r) => isLeadingMark(r.children![0]))) return null; const named = named0; // Modal cell count across candidate rows; require it to dominate unless the // frame is an import-named Table (trusted shape). @@ -140,6 +145,20 @@ function detectTable(node: SceneNode): { columnCount: number; headers: string[]; return { columnCount: modalCount, headers: headers.map((h) => h.trim()), rowCount: rows.length }; } +/** An icon, a dot (an ellipse, or a childless frame or rectangle whose known + * dimensions are all 12px or less — import can drop one of them), or an image + * of 24px or less. */ +function isLeadingMark(node: SceneNode | undefined): boolean { + if (!node) return false; + if (node.type === 'icon' || node.type === 'ellipse') return true; + const dims = [node.width, node.height].filter((d): d is number => typeof d === 'number'); + if ((node.type === 'frame' || node.type === 'rectangle') && !(node.children?.length)) { + return dims.length > 0 && dims.every((d) => d <= 12); + } + if (node.type === 'image') return dims.length > 0 && dims.every((d) => d <= 24); + return false; +} + function firstText(node: SceneNode): string | null { if (node.type === 'text' && typeof node.content === 'string' && node.content.trim()) return node.content; for (const c of node.children ?? []) { diff --git a/src/evaluate.ts b/src/evaluate.ts index 8f11d8e..3da4728 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -3,6 +3,7 @@ import { resolveVariables } from './variables.js'; import { getCanvasTokens } from './workspaces.js'; import { renderToHtml, resolveInstance } from './renderer.js'; import { computeLayout, type LayoutRect } from './screenshot.js'; +import { checkDesktopArtboard } from './desktop-app-rules.js'; import { extractInventory, expandInstances, isDataLike } from './drift.js'; import { getIconSvg, parseIconRef } from './icons.js'; import { storedGenre } from './genre.js'; @@ -44,6 +45,13 @@ export interface EvaluationIssue { severity: IssueSeverity; nodeId: string; nodeName?: string; + /** Phase 31 FR-A7 — on a FOLDED finding (canvas_evaluate's serialised result + * only): every node the repeated finding names, nodeId being the first. */ + nodeIds?: string[]; + /** Phase 31 FR-A7 — on a folded finding: how many findings it stands for. */ + count?: number; + /** Phase 31 FR-A7 — on a folded finding: each member's fix op, by node. */ + fixes?: { nodeId: string; op: string }[]; message: string; suggestion?: string; /** @@ -540,6 +548,8 @@ function checkColorContrast(entries: NodeEntry[], theme?: 'dark'): CheckResult { * with contrast/coverage; these raise craft, not gates (measure's >90ch case * is the one warning, and warnings block the directive on their own). */ interface TypographyContext { + /** Phase 31 — false when an active ruleset disables `type-scale-ratio`. */ + ratioCheck?: boolean; rootWidth?: number; layout?: LayoutRect[]; tables?: { nodeId: string }[]; @@ -564,8 +574,8 @@ function checkTypography(entries: NodeEntry[], variables: DesignVariables = {}, let score = 100; - // Check type scale ratios - if (uniqueSizes.length >= 2) { + // Check type scale ratios (rule `type-scale-ratio`; a ruleset can switch it off) + if (uniqueSizes.length >= 2 && ctx.ratioCheck !== false) { for (let i = 0; i < uniqueSizes.length - 1; i++) { if (pinnedSizes.has(uniqueSizes[i]) && pinnedSizes.has(uniqueSizes[i + 1])) continue; const ratio = uniqueSizes[i] / uniqueSizes[i + 1]; @@ -573,6 +583,7 @@ function checkTypography(entries: NodeEntry[], variables: DesignVariables = {}, score -= 10; issues.push({ category: 'typography', + rule: 'type-scale-ratio', severity: 'warning', nodeId: textNodes.find((e) => e.node.fontSize === uniqueSizes[i])?.node.id ?? entries[0].node.id, message: `Font sizes ${uniqueSizes[i]}px → ${uniqueSizes[i + 1]}px have ratio ${ratio.toFixed(2)} (expected 1.1-2.0).`, @@ -1976,6 +1987,145 @@ function primitiveTargets( return []; } +/** One pointer target for the 24px census: its id, what to call it, the + * effective size after the padded-row step, and whether it is disabled. */ +interface PointerTarget { node: SceneNode; what: string; w: number; h: number; disabled: boolean } + +/** Phase 31 FR-A4 — the 24px floor on web and desktop-app canvases. The four + * controls keep their padded-row step (a lone control grows to its padded + * row, two hops); the clickable primitives are measured by their own box. + * What is still below 24 passes only by WCAG 2.5.8's spacing exception, which + * needs rendered rects: detailed mode runs the circle test (error when it + * fails), fast mode warns and names detailed mode. Reads the tree with + * instances expanded; callers map findings back to instances. */ +export function pointerTargets(entries: NodeEntry[], rootWidth: number): PointerTarget[] { + const parentOf = new Map(); + for (const en of entries) parentOf.set(en.node.id, en.parent); + const countControls = (n: SceneNode): number => { + let c = CONTROL_TYPES.has(n.type) ? 1 : 0; + for (const ch of n.children ?? []) c += countControls(ch); + return c; + }; + const targets: PointerTarget[] = []; + for (const { node } of entries) { + const disabled = node.disabled === true; + if (CONTROL_TYPES.has(node.type)) { + const def = CONTROL_DEFAULT_SIZE[node.type]; + let w = typeof node.width === 'number' ? node.width : def.w; + let h = typeof node.height === 'number' ? node.height : def.h; + 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; + } + targets.push({ node, what: node.type, w, h, disabled }); + } else if (TAPPABLE_PRIMITIVES.has(node.type)) { + for (const t of primitiveTargets(node, parentOf, rootWidth)) { + targets.push({ node, what: t.what, w: t.w ?? Infinity, h: t.h, disabled }); + } + } + } + return targets; +} + +/** The live targets still below 24px after the padded-row step — the ones + * the circle test must place; [] means no detailed render is needed. */ +export function undersizedTargets(targets: PointerTarget[]): PointerTarget[] { + return targets.filter((t) => !t.disabled && Math.min(t.w, t.h) < 24); +} + +function checkPointerTargets( + targets: PointerTarget[], parentOf: Map, rootWidth: number, rects?: Map, +): CheckResult { + const issues: EvaluationIssue[] = []; + let score = 100; + const touchFirst = rootWidth > 0 && rootWidth < 500; // a phone-width artboard + const live = targets.filter((t) => !t.disabled); + const small = undersizedTargets(targets); + const isAncestor = (a: SceneNode, b: SceneNode): boolean => { + for (let cur = parentOf.get(b.id) ?? null; cur; cur = parentOf.get(cur.id) ?? null) if (cur.id === a.id) return true; + return false; + }; + const nested = (a: SceneNode, b: SceneNode) => a.id === b.id || isAncestor(a, b) || isAncestor(b, a); + const centre = (r: LayoutRect) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 }); + // Distance from a point to a box; 0 inside it. + const toBox = (p: { x: number; y: number }, r: LayoutRect) => + Math.hypot(Math.max(r.x - p.x, 0, p.x - (r.x + r.width)), Math.max(r.y - p.y, 0, p.y - (r.y + r.height))); + + // A primitive with several targets (a tab-bar's tabs) renders them under + // ONE node id, so a rect places the whole primitive, not each target: + // its undersized sub-targets cannot be placed individually. + const shared = new Set(targets.map((t) => t.node.id).filter((id, i, all) => all.indexOf(id) !== i)); + for (const t of small) { + const size = `${Number.isFinite(t.w) ? Math.round(t.w) : '?'}×${Math.round(t.h)}px`; + const rect = shared.has(t.node.id) ? undefined : rects?.get(t.node.id); + if (!rects || !rect) { + score -= 10; + issues.push({ + category: 'usability', + severity: 'warning', + nodeId: t.node.id, + nodeName: t.node.name, + message: `${t.what} has a ${size} effective hit target — below the 24px floor (WCAG 2.5.8). It passes only by the spacing exception: when a 24px circle centred on it reaches no other target. ${rects ? 'Detailed mode could not place it in the render, so spacing is unmeasured.' : 'Fast mode cannot measure spacing.'}`, + suggestion: 'Grow it to 24px (or give a lone control a padded row), or run canvas_evaluate with mode "detailed" to apply the spacing exception.', + }); + continue; + } + const c = centre(rect); + const unplaced = live.find((o) => !nested(o.node, t.node) && !rects.has(o.node.id)); + const blocker = live.find((o) => { + if (nested(o.node, t.node)) return false; + const r = rects.get(o.node.id); + if (!r) return false; + // Its circle reaches the other target's box, or (both undersized) the + // two circles overlap. Touching (exactly 12 / 24 away) is not overlap. + if (toBox(c, r) < 12) return true; + return small.includes(o) && Math.hypot(centre(r).x - c.x, centre(r).y - c.y) < 24; + }); + if (!blocker && unplaced) { + // A neighbour the layout walker could not place might sit inside the + // circle: the exception is unproven, so the target stays blocking. + score -= 10; + issues.push({ + category: 'usability', + severity: 'warning', + nodeId: t.node.id, + nodeName: t.node.name, + message: `${t.what} has a ${size} hit target, below the 24px floor, and the spacing exception cannot be confirmed: detailed mode could not place ${unplaced.what} "${unplaced.node.name ?? unplaced.node.id}", which may sit inside its 24px circle (WCAG 2.5.8).`, + suggestion: 'Grow it to 24px — the exception needs every neighbour placed.', + }); + continue; + } + if (blocker) { + score -= 25; + issues.push({ + category: 'usability', + severity: 'error', + nodeId: t.node.id, + nodeName: t.node.name, + message: `${t.what} has a ${size} hit target, below the 24px floor, and the spacing exception does not save it: a 24px circle centred on it reaches ${blocker.what} "${blocker.node.name ?? blocker.node.id}" (WCAG 2.5.8).`, + suggestion: 'Grow it to 24px, or move its neighbours out of its 24px circle.', + }); + } + } + for (const t of live) { + if (touchFirst && CONTROL_TYPES.has(t.node.type) && Math.min(t.w, t.h) >= 24 && Math.min(t.w, t.h) < 44) { + issues.push({ + category: 'usability', + severity: 'info', + nodeId: t.node.id, + nodeName: t.node.name, + message: `${t.what} has a ${Math.round(t.w)}×${Math.round(t.h)}px effective hit target — fine for pointer input, but this looks like a touch-first screen (44px is the comfortable floor there).`, + }); + } + } + return { score: Math.max(0, score), issues }; +} + function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor: number }): CheckResult { const issues: EvaluationIssue[] = []; let score = 100; @@ -1993,8 +2143,6 @@ function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor (n.type === 'text' && typeof n.content === 'string' && n.content.trim().length > 0) || (n.children ?? []).some(hasText); - const touchFirst = rootWidth > 0 && rootWidth < 500; // a phone-width artboard - for (const { node } of entries) { if (touch && TAPPABLE_PRIMITIVES.has(node.type)) { for (const t of primitiveTargets(node, parentOf, rootWidth)) { @@ -2032,40 +2180,9 @@ function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor 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; - } - - 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).`, - }); - } } + // Web and desktop-app targets are judged by checkPointerTargets + // (Phase 31 FR-A4): the padded-row step, then the spacing exception. // Label association: a control the user can't name is a guess. The // label may sit anywhere in the padded row/group around the control @@ -2111,6 +2228,52 @@ function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor return { score: Math.max(0, score), issues }; } +/** Phase 31 FR-A7 — fold repeated findings for the serialised result. + * Warnings and infos that share a severity, a category, a rule or tell and a + * theme fold into ONE entry once three or more also share their message: + * members that carry a fix match on its shape (quoted text and numbers + * stripped, since the fix op holds the specifics); members without one must + * read the same apart from quoted node text, suggestion included. The entry + * sits where the first one did, keeps the first one's message and + * suggestion, and carries nodeIds (distinct), count, and `fixes` + * ({ nodeId, op } per member fix) so nothing an agent could act on node by + * node is lost. Errors never fold. canvas_autofix + * and the directive read the unfolded list; only canvas_evaluate's output + * folds. */ +export function foldIssues(issues: EvaluationIssue[]): EvaluationIssue[] { + // A member with a fix carries its node's specifics in its fix op, so it may + // fold on the message SHAPE (numbers stripped). A member without one folds + // only with members whose message (quoted node text aside) and suggestion + // are identical — otherwise the folded entry would show one member's + // numbers for all of them (a 1.07 ratio and a 2.67 one, Lc 60 and Lc 37). + const keyOf = (i: EvaluationIssue): string | null => { + if (i.severity === 'error') return null; + const head = `${i.severity}|${i.category}|${i.rule ?? i.tell ?? ''}|${i.theme ?? ''}`; + const unquoted = i.message.replace(/"[^"]*"/g, '"…"'); + return i.fix + ? `fix|${head}|${unquoted.replace(/\d+(\.\d+)?/g, 'N')}` + : `same|${head}|${unquoted}|${i.suggestion ?? ''}`; + }; + const groups = new Map(); + for (const i of issues) { + const k = keyOf(i); + if (k) groups.set(k, [...(groups.get(k) ?? []), i]); + } + const out: EvaluationIssue[] = []; + const emitted = new Set(); + for (const i of issues) { + const k = keyOf(i); + const group = k ? groups.get(k)! : undefined; + if (!group || group.length < 3) { out.push(i); continue; } + if (emitted.has(k!)) continue; + emitted.add(k!); + const { nodeName: _name, fix: _fix, ...first } = group[0]; + const fixes = group.filter((g) => g.fix).map((g) => ({ nodeId: g.nodeId, op: g.fix!.op })); + out.push({ ...first, nodeIds: [...new Set(group.map((g) => g.nodeId))], count: group.length, ...(fixes.length ? { fixes } : {}) }); + } + return out; +} + /** 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 { @@ -2354,6 +2517,7 @@ export async function evaluateCanvas( } if (activeCategories.includes('typography')) { const typography = checkTypography(entries, mergedTokens, { + ratioCheck: !rules.off.includes('type-scale-ratio'), rootWidth: typeof canvas.root.width === 'number' ? canvas.root.width : undefined, layout: detailedLayout, tables: extractInventory(resolvedRoot).tables, @@ -2362,7 +2526,10 @@ export async function evaluateCanvas( results.set('typography', withRuleIssues(typography, ruleOn.has('mobile-type-floor') ? reportOnInstances(checkTypeFloor(mobile, rawById), owners) : [])); } if (activeCategories.includes('structure')) { - results.set('structure', withRuleIssues(checkStructure(rawEntries, canvas), ruleOn.has('mobile-artboard') ? reportOnInstances(checkArtboard(mobile, declared), owners) : [])); + results.set('structure', withRuleIssues(checkStructure(rawEntries, canvas), [ + ...(ruleOn.has('mobile-artboard') ? reportOnInstances(checkArtboard(mobile, declared), owners) : []), + ...(ruleOn.has('desktop-app-artboard') ? checkDesktopArtboard(resolvedRoot, declared) : []), + ])); } if (activeCategories.includes('consistency')) { const consistencyCtx = { rawEntries, tokens: mergedTokens }; @@ -2376,9 +2543,30 @@ export async function evaluateCanvas( const touch = ruleOn.has('mobile-touch-target') ? { floor: mobile.profile.touchTarget } : undefined; // On a mobile canvas the whole usability check reads the expanded tree; // web keeps the unexpanded one. - const usability = mobileTypeRules + let usability = mobileTypeRules ? (({ score, issues }) => ({ score, issues: reportOnInstances(issues, owners) }))(checkUsability(buildTreeContext(mobileRoot), mobile.width, touch)) : checkUsability(entries, mobile.width, touch); + if (!touch) { + // Phase 31 FR-A4 — web and desktop-app: the pointer-target census reads + // the tree with instances expanded. Detailed mode renders that tree + // only when something is undersized, so the circle test has rects. + const targetEntries = buildTreeContext(mobileRoot); + const targets = pointerTargets(targetEntries, mobile.width); + let rects: Map | undefined; + if (options.mode === 'detailed' && undersizedTargets(targets).length > 0) { + const layout = await computeLayout(renderToHtml(mobileRoot, mobile.width, mobile.height, canvas), undefined, 25, { width: mobile.width, height: mobile.height }); + // computeLayout returns a tree (each rect nests its children). + rects = new Map(); + const index = (r: LayoutRect) => { rects!.set(r.nodeId, r); r.children?.forEach(index); }; + layout.forEach(index); + } + const parentOf = new Map(targetEntries.map((en) => [en.node.id, en.parent] as const)); + const pointer = checkPointerTargets(targets, parentOf, mobile.width, rects); + usability = { + score: Math.max(0, usability.score - (100 - pointer.score)), + issues: [...usability.issues, ...reportOnInstances(pointer.issues, owners)], + }; + } const extra = reportOnInstances([ ...(ruleOn.has('mobile-handbuilt-target') ? checkHandbuiltTargets(mobile) : []), ...(ruleOn.has('mobile-safe-area') ? (detailedLayout ? checkSafeAreaDetailed(mobile, detailedLayout) : checkSafeAreaFast(mobile)) : []), diff --git a/src/import.ts b/src/import.ts index 093ff39..dd42e79 100644 --- a/src/import.ts +++ b/src/import.ts @@ -168,6 +168,39 @@ export interface ImportReport { warnings: string[]; } +/** Phase 31 FR-A7 — the report an agent reads by default: snapped and + * scaleMatches grouped per token, literals per value, each with a count and + * the first five node ids. The full per-node lists come back under + * `verbose: true`. */ +export interface ImportReportGroup { token?: string; value?: string; count: number; nodeIds: string[] } +export type SummarizedImportReport = Omit & { + snapped: ImportReportGroup[]; + literals: ImportReportGroup[]; + scaleMatches: ImportReportGroup[]; + /** Always true here — pass verbose: true for the per-node lists. */ + summarized: true; +}; + +export function summarizeImportReport(report: ImportReport): SummarizedImportReport { + const group = (rows: T[], keyOf: (r: T) => string, field: 'token' | 'value'): ImportReportGroup[] => { + const groups = new Map(); + for (const r of rows) { + const g = groups.get(keyOf(r)) ?? { count: 0, nodeIds: [] }; + g.count++; + if (g.nodeIds.length < 5 && !g.nodeIds.includes(r.nodeId)) g.nodeIds.push(r.nodeId); + groups.set(keyOf(r), g); + } + return [...groups].map(([key, g]) => ({ [field]: key, ...g })); + }; + return { + ...report, + snapped: group(report.snapped, (r) => r.token, 'token'), + literals: group(report.literals, (r) => r.value, 'value'), + scaleMatches: group(report.scaleMatches, (r) => r.token, 'token'), + summarized: true, + }; +} + function emptyReport(): ImportReport { return { counts: { nodes: 0, frames: 0, text: 0, maxDepth: 0, dropped: 0 }, snapped: [], literals: [], scaleMatches: [], layout: [], unmatchedFonts: [], unmatchedIcons: [], warnings: [] }; } diff --git a/src/index.ts b/src/index.ts index 3c35fe4..5503aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { createCanvas, getCanvas, listCanvases, findNode, touchCanvas, loadPersistedCanvases, archiveCanvas, unarchiveCanvas, moveCanvas, deleteCanvas, countCanvasesInProject, ensureFresh, collectMatchingNodes, replaceMatchingProperties, findNodesDetailed, setCanvasGenre, setCanvasType, addVariant } from './scene-graph.js'; -import { deviceProfile, resolveSurface, surfaceFor, usableSafeArea, type SurfaceType } from './surfaces.js'; +import { deviceProfile, windowProfile, resolveSurface, surfaceFor, usableSafeArea, type SurfaceType } from './surfaces.js'; import { canvasVersionHash, checkVersionHash } from './version.js'; import { loadPersistedWorkspaces, ensureDefaultWorkspaceAndProject, createWorkspace, listWorkspaces, renameWorkspace, deleteWorkspace, createProject, getProject, getWorkspace, listProjects, renameProject, deleteProject, setWorkspaceDesignSystem, getWorkspaceDesignSystem, setProjectDesignSystem, getProjectDesignSystem, getCanvasTokens, getInheritedTokens, loadRepoWorkspace } from './workspaces.js'; import { DEFAULT_PROJECT_ID, DEFAULT_WORKSPACE_ID } from './types.js'; @@ -23,7 +23,7 @@ import { listPresets, getPreset, registerPreset } from './presets.js'; import { listStructures, applyStructure, computeDiversificationHint } from './structures.js'; import { parseDesignMd } from './design-md-parser.js'; import { listFeedback, resolveFeedback, openFeedbackCount, appendFeedbackDirective } from './feedback.js'; -import { importHtml, importUrl, renderImportedTree, snapToTokens } from './import.js'; +import { importHtml, importUrl, renderImportedTree, snapToTokens, summarizeImportReport } from './import.js'; import { computeStructuralDrift, expandInstances } from './drift.js'; import { applyPerturbation, compareLayouts, lastStressRun, PERTURBATION_NAMES, recordStressRun, type PerturbationName } from './stress.js'; import { buildEvaluateDirective } from './directive.js'; @@ -32,7 +32,7 @@ import { generateColorSystem } from './color-system.js'; import { generateDesignSystem, typeBase, PERSONALITY_NAMES, type PersonalityName } from './design-language.js'; import { evaluateProject } from './project-evaluate.js'; import { startViewer, getViewerUrl, setExternalViewerUrl } from './viewer.js'; -import { evaluateCanvas, describeGenreStamp, knownGenres } from './evaluate.js'; +import { evaluateCanvas, describeGenreStamp, knownGenres, foldIssues } from './evaluate.js'; import { stampPreset } from './genre.js'; import { judgeCanvas, judgeFlow, LLMJudgeUnavailableError } from './llm-judge.js'; import { reviseCanvas } from './reviser.js'; @@ -85,6 +85,8 @@ Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks 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, and the root grows with long content keeping that padding past it, 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. Start a phone screen from a mobile realisation: apply_structure on a mobile canvas resolves the anatomy name to it — dashboard → dashboard-mobile, settings → settings-mobile, auth → auth-mobile, onboarding → onboarding-mobile, and the mobile-first feed → feed-mobile (a list of rows, pinned tab bar) and detail → detail-mobile (an item with a primary action pinned over the home indicator) — each built from the primitives and gated to pass the mobile ruleset in fast and detailed mode and canvas_stress CLEAN (unthemed and under every personality); stamp it after generate_design_system (the dark, light and material presets carry a 14–15px body that mobile-type-floor flags on a phone). Resolution reads the DECLARED type: an undeclared 390px canvas stamps the web structure, so declare type "mobile" first. Its placeholders include every copy field of its primitives, with prop the field's path (title, subtitle, label, trailing.text, leading.initials, leading.label, items[0].label — a path, not an op key: set a nested one by writing its whole object or array, siblings included). An anatomy with no mobile realisation (the other web pages such as pricing, catalogue or bento-grid; web-only components such as data-table) is refused with the list of those that have one; realisation: "web" stamps the web one anyway. Of the components, form-field, stat-card, empty-state, skeleton-card, skeleton-stat-card, kpi-card, status-chip and initials-avatar stamp on every type. Build phone screens from the mobile primitives (batch_design lists them): status-bar and home-indicator (device chrome), nav-bar, tab-bar, button, list-row, sheet — never hand-built frames — and pin bars with position "fixed" (the renderer reserves room for pinned chrome, so content never starts or ends under it). A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a tap target below 44px — a control (its row counts only when the row is a horizontal frame holding that one tappable), a button (size "sm" is 32), a list-row, each tab-bar tab and FAB, each nav-bar action; primitives are measured by their own box, inside instances too), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar at the start or end of the scroll; with root safeArea or pinned chrome the renderer reserves the insets and only positioned content (absolute / fixed / sticky) and pinned bars are checked — padding ≥32px scales down on a phone, so clear a pinned bar with a spacer frame inside your content frame; detailed mode is authoritative), mobile-tab-bar (WARNING: a tab bar with fewer than 2 or more than 5 tabs, an unlabeled tab, or one not pinned to the bottom), 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 — rebuild it as a button), 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). +Application screens: canvas_create({ type: "desktop-app" }) for a tool you work in — an editor, an admin console, a file browser, a viewer: persistent chrome, panes, dense controls, a current selection, in a browser tab or a native window. The test is what the user does: a page you read and scroll is "web", a tool you work in is "desktop-app". It gets the window artboard (1440×900 for the default device "window-1440", no insets); generate_design_system pivots its type on 14px (13 for data-dense); its ruleset turns OFF type-scale-ratio (the adjacent font-size ratio check — dense apps step 12/13/14 on purpose), and desktop-app-artboard (on every type) WARNS when a desktop-app canvas is narrower than 1024px. There are no desktop-app realisations yet: apply_structure refuses every page anatomy and names the types it is realised on (realisation: "web", plus "mobile" for the six mobile anatomies) — realisation: "web" stamps the web split-workbench, dashboard or settings; component scaffolds stamp as usual. On web and desktop-app every pointer target — the four controls and the clickable primitives — meets the 24px WCAG 2.5.8 floor or its spacing exception (nothing else within a 24px circle around it): detailed mode runs that circle test (ERROR on failure), fast mode WARNS on anything under 24 and names detailed mode. + 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. - Prefer STRUCTURED gradient / shadows ({ stops: [...] } and [{ x, y, blur, color }]); a raw CSS string on those fields is accepted too. The plural \`shadows\` always beats the singular \`shadow\` — writing \`shadow\` on a node that has \`shadows\` is a no-op (batch_design warns when this happens). @@ -112,7 +114,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; on a canvas declared mobile the floor is 44px for controls AND the tappable primitives — button (sm is 32: an error there), list-row, tab-bar tabs, nav-bar actions — 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.', + 'Usability gates like contrast does: on web and desktop-app a pointer target (a control or a clickable primitive) under the 24px floor fails — WCAG 2.5.8, with its spacing exception (a smaller target passes when nothing else sits within a 24px circle around it): detailed mode runs that test and ERRORS, fast mode WARNS until you grow it or run mode "detailed" (a lone control inherits its padded row\'s box; on a canvas declared mobile the floor is 44px for controls AND the tappable primitives — button (sm is 32: an error there), list-row, tab-bar tabs, nav-bar actions — 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.', @@ -136,7 +138,7 @@ const GOTCHAS = [ 'Binding (canvas_bind, or init on first run) re-keys the workspace + every project ID to repo-* form (canvas IDs are kept) — retarget from canvas_bind\'s idMap or the IDs init returns, never cache pre-bind workspace/project IDs.', 'Point-and-tell feedback: the user clicks elements in the viewer (Comment mode) to leave node-anchored or whole-page comments, stored on the canvas at metadata.feedback. get_feedback returns them (with a node snapshot; orphaned: true = the node is gone but the concern likely still applies); open feedback blocks presenting, same as open inspector comments — address each item, then resolve_feedback with a one-line note of what changed (shown as your reply in the viewer\'s Feedback tab). canvas_list rows and canvas_evaluate results carry an openFeedback count (and the evaluate directive stays blocking) while comments are open.', 'Cliché tells (canvas_evaluate "cliche" category): avoid default purple/indigo accents, gradient/glow overuse, fake window chrome, fabricated metrics, slop copy (filler verbs / scroll cues / "Jane Doe" / hype labels), an eyebrow above every section (keep to ~1 per 3 sections), mixed radius systems (one radius scale), pure black/white (use off-black/off-white), and competing accents (one accent hue + neutrals; the $success/$warning/$danger status vocabulary is exempt when referenced as tokens). The tells KNOW dashboard vocabulary (Phase 28): violets referenced via \$chart-*/\$*-tint tokens, token-referenced gradients, thin rounded sparkline bars, and uppercase KPI labels beside big tabular figures are all sanctioned — the literal versions still flag.', - 'Genre calibration: declare the genre the screen actually IS — stamp it durably with canvas_set_genre (no token churn; null clears), or pass genre: "data" (alias "dashboard") per call on canvas_evaluate/canvas_autofix. "data" stops a screen whose figures are the user\'s own data (dashboards, activity, finance, fitness, logistics) from having its realistic figures flagged as fabricated; "commerce" (alias "checkout") does the same for the money on a TRANSACTIONAL screen — cart, checkout, order confirmation, billing history, where the line prices and the total are the design; "material" allows purple + white surfaces. Genre follows what the screen is FOR, not what it contains: read screens with the user\'s own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material". Genre and preset are separate fields: apply_preset never changes the genre, and the material PRESET relaxes accent-hue and pure-black-white on its own, whatever the genre (even under an explicit genre param, which replaces only the genre). The evaluate result\'s genre field audits the choice ({ active, source, relaxed, notRelaxed, byPreset? }) — a score pinned by tells in notRelaxed means the genre is probably wrong. Matching an existing app\'s type scale? Declare the sizes as typography tokens — pinned sizes skip the adjacent-ratio check. Never use genre to dodge flags on a marketing page — a PRICING page is marketing, not "commerce".', + 'Genre calibration: declare the genre the screen actually IS — stamp it durably with canvas_set_genre (no token churn; null clears), or pass genre: "data" (alias "dashboard") per call on canvas_evaluate/canvas_autofix. "data" stops a screen whose figures are the user\'s own data (dashboards, activity, finance, fitness, logistics) from having its realistic figures flagged as fabricated; "commerce" (alias "checkout") does the same for the money on a TRANSACTIONAL screen — cart, checkout, order confirmation, billing history, where the line prices and the total are the design; "material" allows purple + white surfaces. Genre follows what the screen is FOR, not what it contains: read screens with the user\'s own figures → "data"; a screen you complete a purchase on → "commerce"; the Material style → "material" (an editor or admin console is a type, not a genre: declare it with canvas_create / canvas_set_type type "desktop-app"). Genre and preset are separate fields: apply_preset never changes the genre, and the material PRESET relaxes accent-hue and pure-black-white on its own, whatever the genre (even under an explicit genre param, which replaces only the genre). The evaluate result\'s genre field audits the choice ({ active, source, relaxed, notRelaxed, byPreset? }) — a score pinned by tells in notRelaxed means the genre is probably wrong. Matching an existing app\'s type scale? Declare the sizes as typography tokens — pinned sizes skip the adjacent-ratio check. Never use genre to dodge flags on a marketing page — a PRICING page is marketing, not "commerce".', '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.', @@ -149,7 +151,7 @@ const GOTCHAS = [ * string, because two near-identical copies is how a genre ends up * documented on one tool and not the other (Phase 29 slice A shipped that * bug into the README; test-discoverability now guards the doc surfaces). */ -const GENRE_PARAM_DOC = 'Genre/style that relaxes specific cliche gates — "material" allows purple accents and white elevated surfaces; "data" (alias "dashboard") allows realistic figures on a screen whose numbers are the user\'s own data — dashboards, activity, finance, fitness, logistics; "commerce" (alias "checkout") allows the money on a transactional screen — cart, checkout, order confirmation, billing history (both relax honest-content). Defaults to the canvas\'s stored genre (canvas_set_genre stamps it durably, no token churn; apply_preset never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. Pick by what the screen is FOR, not what it contains: read screens presenting the user\'s own figures are "data"; a screen you complete a purchase on is "commerce"; editors and admin forms are "material"; a PRICING page is marketing and gets none of them. The result\'s genre field shows what the choice did.'; +const GENRE_PARAM_DOC = 'Genre/style that relaxes specific cliche gates — "material" allows purple accents and white elevated surfaces; "data" (alias "dashboard") allows realistic figures on a screen whose numbers are the user\'s own data — dashboards, activity, finance, fitness, logistics; "commerce" (alias "checkout") allows the money on a transactional screen — cart, checkout, order confirmation, billing history (both relax honest-content). Defaults to the canvas\'s stored genre (canvas_set_genre stamps it durably, no token churn; apply_preset never changes it). Replaces only the genre: a material-preset canvas keeps the material relaxation. Pick by what the screen is FOR, not what it contains: read screens presenting the user\'s own figures are "data"; a screen you complete a purchase on is "commerce"; the Material style is "material" (an editor or admin console is type "desktop-app", not a genre); a PRICING page is marketing and gets none of them. The result\'s genre field shows what the choice did.'; const GUIDELINES_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'GUIDELINES.md'); @@ -174,21 +176,21 @@ 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, and the root grows with long content keeping that 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. Then start it from a pattern: on a mobile canvas apply_structure stamps the mobile realisations (dashboard, settings, auth, onboarding, feed, detail).', + '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, and the root grows with long content keeping that padding, so in-flow content never sits under the status bar or home indicator). Building an APPLICATION screen (a tool you work in: an editor, an admin console, a file browser — persistent chrome, panes, dense controls, a current selection, in a browser tab or a native window)? Pass `type: "desktop-app"` — it selects the desktop-app ruleset, generate_design_system gives it a 14px body (13 for data-dense) plus state tokens, and the artboard is the window (1440×900 for the default `device: "window-1440"`, no insets). `type: "web"` (the default) declares a web page — a page you read and scroll (marketing, docs, content, pricing). The result reports `type`, `typeSource` ("declared" | "default"), and for mobile and desktop-app the `device` (plus `safeArea` on mobile). Change it later with canvas_set_type. Then start it from a pattern: on a mobile canvas apply_structure stamps the mobile realisations (dashboard, settings, auth, onboarding, feed, detail).', { 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.'), - width: z.number().int().positive().optional().describe('Artboard width in px (default 1440, or the device width for type "mobile")'), - height: z.number().int().positive().optional().describe('Artboard height in px (default 900, or the device height for type "mobile")'), - type: z.enum(['web', 'mobile']).optional().describe('What medium the canvas is on: "web" (default) or "mobile" (a phone app screen: device artboard + safe-area insets on the root)'), - device: z.string().optional().describe('Device profile for type "mobile" (default "iphone-390": 390×844, insets 47/34). Only valid with type "mobile".'), + width: z.number().int().positive().optional().describe('Artboard width in px (default 1440, or the device/window width for type "mobile" / "desktop-app")'), + height: z.number().int().positive().optional().describe('Artboard height in px (default 900, or the device/window height for type "mobile" / "desktop-app")'), + type: z.enum(['web', 'mobile', 'desktop-app']).optional().describe('What medium the canvas is on: "web" (default; a page you read and scroll), "mobile" (a phone app screen: device artboard + safe-area insets on the root), or "desktop-app" (a tool you work in, in a browser or a native window: the window artboard)'), + device: z.string().optional().describe('Profile for the type: a phone for "mobile" (default "iphone-390": 390×844, insets 47/34) or a window for "desktop-app" (default "window-1440": 1440×900). Not valid with "web".'), }, async ({ name, projectId, width, height, type, device }) => { if (projectId && !getProject(projectId)) { return { content: [{ type: 'text', text: `Error: Project "${projectId}" not found. Use project_list to see available projects.` }], isError: true }; } if (device !== undefined && type === undefined) { - return { content: [{ type: 'text', text: 'Error: device only applies to type "mobile" — pass type: "mobile" with it.' }], isError: true }; + return { content: [{ type: 'text', text: 'Error: device only applies with a type — pass type: "mobile" (a phone) or "desktop-app" (a window) with it.' }], isError: true }; } const declared = type === undefined ? undefined : surfaceFor(type, device); if (declared && 'error' in declared) return { content: [{ type: 'text', text: `Error: ${declared.error}` }], isError: true }; @@ -943,6 +945,13 @@ server.tool( duration: z.number().describe('ms'), easing: z.string().describe('Named easing (ease, ease-out, …) or cubic-bezier(…)'), })).optional().describe('Motion tokens — reference from nodes as transition: "$motion.". Declaring them also quiets the ad-hoc-timing consistency nudge.'), + state: z.object({ + hover: z.number().min(0).max(1).optional(), + focus: z.number().min(0).max(1).optional(), + pressed: z.number().min(0).max(1).optional(), + disabledContent: z.number().min(0).max(1).optional(), + disabledContainer: z.number().min(0).max(1).optional(), + }).optional().describe('Element-state opacities (0–1): hover / focus / pressed are the share of the ink layered over a control\'s fill; disabledContent / disabledContainer are the opacities a disabled control renders at. generate_design_system writes Material 3\'s values (0.08 / 0.10 / 0.10 / 0.38 / 0.12).'), }).describe('Design variables to set'), }, async ({ canvasId, variables }) => { @@ -1034,7 +1043,7 @@ server.tool( 'generate_color_system', `One seed color → a full perceptual color system: OKLCH ramps primary-50…900 (even lightness steps, chroma tapered at the extremes, gamut-clipped toward lower chroma — never hue-shifted), a matched neutral ramp (a whisper of the seed's hue — never dead grey, never visibly tinted), status colors (success / warning / danger, hue held but darkened until each clears WCAG AA as TEXT on the generated light page surface — the invariant is shared contrast, not shared lightness, and tuning against that off-white surface rather than pure white is the stricter target, so white passes too), and the SEMANTIC tokens the structures already speak: bg-primary, bg-surface, bg-elevated, text-primary, text-secondary, border, accent. Text and accent steps are picked by MEASURED contrast — every semantic text/surface pair clears WCAG AA by construction, not by hope. -The DARK theme ships in the same call (the Radix pattern: a reversed walk of the same ramps, not inverted hex): the semantic dark mapping is WRITTEN to the layer's dark.colors override — so theme: "dark" on screenshot/export renders it and canvas_evaluate contrast-checks BOTH themes from then on. Status colors get a SECOND pass for dark: the light-tuned success/warning/danger are re-lit (hue held) until each clears AA against the lightest dark surface (bg-elevated) too, so a $danger message reads in both themes. Phase 28 adds THE COLOR RANGE in the same call: chart-1…chart-6 categorical series tokens (a deterministic hue walk from the seed, ≥ 30° apart, each clearing the 3:1 graphical-object floor on BOTH themes' surfaces — reference them from chart segments/series and legend dots) and the TINT LAYER — accent-tint / success-tint / warning-tint / danger-tint / neutral-tint: soft same-hue surfaces for status chips, icon tiles, pill badges, and initials avatars. The pairing rule is one sentence: tint as the FILL, its base color as the INK (fill: "$success-tint" + color: "$success") — that pair is AA by construction in both themes, and the dark layer re-states every tint. Writes the light system to exactly ONE of canvasId / projectId / workspaceId. Canvas scope honors the inherited-design-system contract: tokens the canvas inherits from workspace/project are PRESERVED and reported — ordinary ones under \`preservedFromDesignSystem\`, and ones in the semantic vocabulary this call defines under \`designSystemConflicts\` (an inherited \`border\` or \`text-primary\` on a freshly generated palette means two color languages on one screen; each entry names the fix). Take the generated value with set_variables, or pass \`preserveInherited: false\` to write the palette whole. Raise your palette floor: generate first, then adjust individual tokens — don't eyeball ten hexes.`, +Phase 31 adds two CONTROL colors, each at 3:1 or more (WCAG 1.4.11) in both themes: focus-ring (against every bg-* and *-tint token) and border-strong (against bg-primary and bg-surface — for a control whose border is its only boundary cue); generate_design_system writes them too. The DARK theme ships in the same call (the Radix pattern: a reversed walk of the same ramps, not inverted hex): the semantic dark mapping is WRITTEN to the layer's dark.colors override — so theme: "dark" on screenshot/export renders it and canvas_evaluate contrast-checks BOTH themes from then on. Status colors get a SECOND pass for dark: the light-tuned success/warning/danger are re-lit (hue held) until each clears AA against the lightest dark surface (bg-elevated) too, so a $danger message reads in both themes. Phase 28 adds THE COLOR RANGE in the same call: chart-1…chart-6 categorical series tokens (a deterministic hue walk from the seed, ≥ 30° apart, each clearing the 3:1 graphical-object floor on BOTH themes' surfaces — reference them from chart segments/series and legend dots) and the TINT LAYER — accent-tint / success-tint / warning-tint / danger-tint / neutral-tint: soft same-hue surfaces for status chips, icon tiles, pill badges, and initials avatars. The pairing rule is one sentence: tint as the FILL, its base color as the INK (fill: "$success-tint" + color: "$success") — that pair is AA by construction in both themes, and the dark layer re-states every tint. Writes the light system to exactly ONE of canvasId / projectId / workspaceId. Canvas scope honors the inherited-design-system contract: tokens the canvas inherits from workspace/project are PRESERVED and reported — ordinary ones under \`preservedFromDesignSystem\`, and ones in the semantic vocabulary this call defines under \`designSystemConflicts\` (an inherited \`border\` or \`text-primary\` on a freshly generated palette means two color languages on one screen; each entry names the fix). Take the generated value with set_variables, or pass \`preserveInherited: false\` to write the palette whole. Raise your palette floor: generate first, then adjust individual tokens — don't eyeball ten hexes.`, { seed: z.string().describe('The brand color to derive everything from (#RRGGBB)'), canvasId: z.string().optional().describe('Write to this canvas\'s variables'), @@ -1052,7 +1061,7 @@ The DARK theme ships in the same call (the Radix pattern: a reversed walk of the const colors: Record = {}; for (const [step, hex] of Object.entries(system.primary)) colors[`primary-${step}`] = hex; for (const [step, hex] of Object.entries(system.neutral)) colors[`neutral-${step}`] = hex; - Object.assign(colors, system.status, system.light, system.categorical, system.tints); + Object.assign(colors, system.status, system.light, system.categorical, system.tints, system.controls); let preserved: PresetApplyResult['preserved'] = []; let overwrote: string[] = []; @@ -1093,6 +1102,7 @@ The DARK theme ships in the same call (the Radix pattern: a reversed walk of the ...Object.keys(system.light), ...Object.keys(system.status ?? {}), ...Object.keys(system.tints ?? {}), + ...Object.keys(system.controls ?? {}), ...Object.keys(system.categorical ?? {}), ]); const csConflicts = preserved.filter((e) => e.category === 'colors' && ownedColors.has(e.key)).map((e) => ({ @@ -1111,6 +1121,7 @@ The DARK theme ships in the same call (the Radix pattern: a reversed walk of the semantics: system.light, categorical: system.categorical, tints: system.tints, + controls: system.controls, dark: system.dark, ...(system.rangeNote ? { rangeNote: system.rangeNote } : {}), ...(csPlain.length ? { preservedFromDesignSystem: csPlain } : {}), @@ -1135,6 +1146,8 @@ Personalities (required — pick a stance, don't default into sameness): - "soft" — warm, rounded, human (Plus Jakarta Sans + Inter, 12/16/20 radii, springy motion). For consumer apps, onboarding, anything friendly. - "data-dense" — instrument-panel density (Inter + a JetBrains Mono "figures" role, 13px pivot, minimal radii, near-flat depth). For dashboards, tables, monitoring. +On a canvas declared "mobile" the type pivot is the phone body (17px; 16 for data-dense); on a canvas declared "desktop-app" it is 14px (13 for data-dense) — the result reports baseSize and baseSizeSource ("mobile" | "desktop-app" | "explicit" | "personality"). Every system also writes the element-STATE tokens: a state section of opacities ({ hover: 0.08, focus: 0.10, pressed: 0.10, disabledContent: 0.38, disabledContainer: 0.12 } — Material 3's values; hover/focus/pressed are the share of the ink over a control's fill) plus the focus-ring and border-strong control colors; the result reports them as state and controls. + The typography layer ships ROLE tokens the structures speak — $display / $heading / $title (page/screen titles, one step below $display — every archetype's page title references it) / $body / $label / $caption (+ $figures when a mono face exists) — alongside the text-xs…text-3xl steps. Depth: reference elevation from any node as shadow: "$elevation.flat|raised|floating|overlay" — the dark layer re-states each depth so it reads on dark surfaces. The COLOR RANGE rides along (Phase 28): $chart-1…$chart-6 categorical series tokens for dataviz, and the tint layer ($accent-tint / $success-tint / … — tint as fill, base color as ink, AA by construction both themes) for chips, icon tiles, pills, and avatars. Writes to exactly ONE of canvasId / projectId / workspaceId. Canvas scope keeps tokens the canvas resolves through an inherited design system rather than clobbering them — but preservation is FIELD-WISE for typography, so an inherited role that carries only a size keeps that size and still picks up this personality's family/weight/tracking (before v2.1 it swallowed the whole spec and the role silently rendered in the fallback stack). What was kept is reported two ways: \`preservedFromDesignSystem\` for ordinary tokens, and \`designSystemConflicts\` for tokens in the semantic vocabulary THIS system defines (the surface/ink/border/accent semantics, the STATUS colours and their paired -tint layer, the \$chart-* range, and the type roles — an inherited success keeps pairing with the GENERATED success-tint and fails contrast) — a conflict means the screen is running two design languages at once, and each entry names the fix. \`preserveInherited: false\` writes the generated language whole. The response's typographyRoles reports what the canvas ACTUALLY resolves, not what was generated — the two differ exactly when preservation fired. The two single-purpose generators stay available for targeted regeneration (just the palette, just the scale). On a canvas declared type: "mobile" (canvas_create / canvas_set_type) the type scale pivots on the device body size — 17px for every personality except data-dense, which uses 16px — so the generated $body meets the phone body floor; an explicit baseSize still wins. The result reports the GENERATED pivot as baseSize + baseSizeSource ("explicit" | "personality" | "mobile"); if the canvas inherits a $body from a workspace/project design system, that body is preserved by default and shows up in designSystemConflicts (typographyRoles.body is what renders) — pass preserveInherited: false to take the mobile body.`, { seed: z.string().describe('The brand color to derive everything from (#RRGGBB)'), @@ -1171,7 +1184,7 @@ The typography layer ships ROLE tokens the structures speak — $display / $head if (!canvas) return { content: [{ type: 'text', text: 'Error: Canvas not found' }], isError: true }; const merge = applyPresetTokens(canvas, { colors: vars.colors, spacing: vars.spacing, radius: vars.radius, typography: vars.typography }, getInheritedTokens(canvas), { preserveInherited }); preserved = merge.preserved; - setVariables(canvas, { elevation: vars.elevation, motion: vars.motion, dark: vars.dark }); + setVariables(canvas, { elevation: vars.elevation, motion: vars.motion, state: vars.state, dark: vars.dark }); touchCanvas(canvasId); wroteTo = { layer: 'canvas', id: canvasId }; } else if (projectId) { @@ -1206,6 +1219,7 @@ The typography layer ships ROLE tokens the structures speak — $display / $head ...Object.keys(system.colorSystem.light), ...Object.keys(system.colorSystem.status ?? {}), ...Object.keys(system.colorSystem.tints ?? {}), + ...Object.keys(system.colorSystem.controls ?? {}), ...Object.keys(system.colorSystem.categorical ?? {}), ]); const ownedRoles = new Set(['display', 'heading', 'title', 'body', 'label', 'caption', 'figures']); @@ -1239,6 +1253,8 @@ The typography layer ships ROLE tokens the structures speak — $display / $head radius: vars.radius, elevation: Object.keys(vars.elevation ?? {}), motion: Object.keys(vars.motion ?? {}), + state: vars.state, + controls: system.colorSystem.controls, ...(plain.length ? { preservedFromDesignSystem: plain } : {}), ...(conflicts.length ? { designSystemConflicts: conflicts } : {}), note: 'Full design language written: reference $display/$heading/$title/$body/$label typography roles, $bg-surface/$accent/… colors, shadow: "$elevation.raised" for depth, transition: "$motion.base" for timing. Render dark with theme: "dark" — colors AND elevation re-state themselves. Same seed with a different personality gives a visibly different product.', @@ -1492,7 +1508,7 @@ Each structure realises an ANATOMY (a screen kind such as dashboard or settings) Distinct from presets: structures define layout skeleton, presets define color/token theme. Pass projectId to also get a diversification signal (recently-built page structures + a hint to differ). Apply one with apply_structure, then screenshot and verify before populating.`, { canvasId: z.string().optional().describe('List what stamps on this canvas (its declared type picks the realisations). Not with type.'), - type: z.enum(['web', 'mobile']).optional().describe('List for this canvas type instead of a canvas. Default "web". Not with canvasId.'), + type: z.enum(['web', 'mobile', 'desktop-app']).optional().describe('List for this canvas type instead of a canvas. Default "web". Not with canvasId.'), projectId: z.string().optional().describe('If given, also return a diversification signal for this project: the recently-built structures and a hint to differ on >= 1 taxonomy axis. Use project_list to see projects.'), }, async ({ canvasId, type, projectId }) => { @@ -1530,7 +1546,7 @@ Scaffolds reference $space-*/$radius-*/$elevation.*/type-role tokens ($display/$ structure: z.string().describe('Structure name (use list_structures, e.g. marquee-hero, data-table)'), replace: z.boolean().optional().describe('Page scaffolds only: if the root already has children, clear them before stamping. Default false (refuses on a non-empty canvas).'), targetId: z.string().optional().describe('Component scaffolds only: node to stamp under (default "document"). Page scaffolds always stamp at the root.'), - realisation: z.enum(['web', 'mobile']).optional().describe('Stamp the realisation for this canvas type instead of the canvas\'s own type (e.g. "web" to put the web dashboard on a mobile canvas).'), + realisation: z.enum(['web', 'mobile', 'desktop-app']).optional().describe('Stamp the realisation for this canvas type instead of the canvas\'s own type (e.g. "web" to put the web dashboard on a mobile canvas, or the web split-workbench / dashboard / settings on a desktop-app canvas, which has no realisations of its own yet).'), }, async ({ canvasId, structure, replace, targetId, realisation }) => { ensureFresh(canvasId); @@ -1633,7 +1649,7 @@ function validateImportArgs(projectId: string | undefined, tokenMatch: ImportTok * never auth material). */ async function finishImport( imported: { root: SceneNode; report: import('./import.js').ImportReport; contentHeight: number }, - opts: { name: string; projectId?: string; width: number; importedFrom: string; tokenMatch?: ImportTokenMatch; tailwindTheme?: Record }, + opts: { name: string; projectId?: string; width: number; importedFrom: string; tokenMatch?: ImportTokenMatch; tailwindTheme?: Record; verbose?: boolean }, ): Promise<{ content: { type: 'text'; text: string }[] }> { const { root, report, contentHeight } = imported; const canvas = createCanvas(opts.name, opts.projectId); @@ -1661,7 +1677,7 @@ async function finishImport( return { content: [{ type: 'text' as const, text: JSON.stringify({ canvasId: canvas.id, rootId: root.id, - report, + report: opts.verbose ? report : summarizeImportReport(report), instruction: 'Screenshot the canvas to review fidelity, then check report.warnings and report.literals for what needs hand-finishing.', }, null, 2) }] }; } @@ -1673,7 +1689,7 @@ server.tool( Token re-mapping: Tailwind utility classes map to INTENT directly (bg-surface → fill: "$surface", gap-4 → 16, custom utilities via tailwind.theme); remaining literal colors snap to the matched design system (nearest within tolerance — near-ties are reported, never guessed). report.snapped / report.literals / report.scaleMatches tell you exactly what happened; report.warnings flags $refs the design system doesn't define yet. -LOSSY BY DESIGN — read the returned report: snapped (values → $tokens), literals (colors with no token), scaleMatches (numbers equal to a scale token, informational), layout (per-container reconstruction), unmatchedFonts, unmatchedIcons, warnings (dropped pseudo-elements / background images / truncations). The import is an editable starting point that honestly tells you where it degraded, not a pixel-perfect clone. +LOSSY BY DESIGN — read the returned report: snapped (values → $tokens), literals (colors with no token), scaleMatches (numbers equal to a scale token, informational) — these three come GROUPED by default ({ token, count, nodeIds } per token, literals per value, first five node ids each, report.summarized: true; pass verbose: true for the per-node lists) — layout (per-container reconstruction), unmatchedFonts, unmatchedIcons, warnings (dropped pseudo-elements / background images / truncations). The import is an editable starting point that honestly tells you where it degraded, not a pixel-perfect clone. Note: a bare Tailwind snippet has no Tailwind runtime — the class intent mapper covers the common utilities; pass the compiled CSS via \`css\` for everything else.`, { @@ -1697,13 +1713,14 @@ Note: a bare Tailwind snippet has no Tailwind runtime — the class intent mappe tailwind: z.object({ theme: z.record(z.string()).optional().describe('Flat { name: value } map from the project\'s @theme — widens which class names map to $tokens (e.g. { surface: "#1e1e1e" })'), }).optional().describe('Tailwind-specific import options'), + verbose: z.boolean().optional().describe('Return the full per-node snapped / literals / scaleMatches lists. Default false: each comes grouped per token (literals per value) with a count and the first five node ids.'), }, - async ({ html, css, projectId, name, selector, width, flatten, tokenMatch, tailwind }) => { + async ({ html, css, projectId, name, selector, width, flatten, tokenMatch, tailwind, verbose }) => { const invalid = validateImportArgs(projectId, tokenMatch); if (invalid) return invalid; try { const imported = await importHtml(html, { css, selector, width, flatten, tailwindTheme: tailwind?.theme }); - return finishImport(imported, { name: name ?? 'Imported HTML', projectId, width: width ?? 1440, importedFrom: 'html', tokenMatch, tailwindTheme: tailwind?.theme }); + return finishImport(imported, { name: name ?? 'Imported HTML', projectId, width: width ?? 1440, importedFrom: 'html', tokenMatch, tailwindTheme: tailwind?.theme, verbose }); } catch (err) { return { content: [{ type: 'text', text: `Error: ${(err as Error).message}` }], isError: true }; } @@ -1719,7 +1736,7 @@ server.tool( - waitFor — a CSS selector to await or a delay in ms, for client-rendered UI - auth — headers/cookies for gated pages; they live ONLY in a throwaway browser context and are never persisted to the canvas, provenance, or report -LOSSY BY DESIGN — read the returned report (snapped/literals/scaleMatches/layout/unmatchedFonts/unmatchedIcons/warnings). Fonts seen on the page load through the font-by-name resolver so the canvas renders in the same faces.`, +LOSSY BY DESIGN — read the returned report (snapped/literals/scaleMatches/layout/unmatchedFonts/unmatchedIcons/warnings; snapped/literals/scaleMatches come grouped per token or value with counts unless verbose: true). Fonts seen on the page load through the font-by-name resolver so the canvas renders in the same faces.`, { url: z.string().regex(/^https?:\/\//i).describe('The page to import (http/https)'), projectId: z.string().optional().describe('Project to create the canvas in (default: the default project)'), @@ -1749,8 +1766,9 @@ LOSSY BY DESIGN — read the returned report (snapped/literals/scaleMatches/layo tailwind: z.object({ theme: z.record(z.string()).optional(), }).optional().describe('Tailwind @theme map for class-intent mapping'), + verbose: z.boolean().optional().describe('Return the full per-node snapped / literals / scaleMatches lists (default: grouped per token or value, with counts and the first five node ids).'), }, - async ({ url, projectId, name, viewport, selector, waitFor, auth, flatten, tokenMatch, tailwind }) => { + async ({ url, projectId, name, viewport, selector, waitFor, auth, flatten, tokenMatch, tailwind, verbose }) => { const invalid = validateImportArgs(projectId, tokenMatch); if (invalid) return invalid; try { @@ -1763,6 +1781,7 @@ LOSSY BY DESIGN — read the returned report (snapped/literals/scaleMatches/layo importedFrom: url, // the URL is recorded; auth never is tokenMatch, tailwindTheme: tailwind?.theme, + verbose, }); } catch (err) { return { content: [{ type: 'text', text: `Error: ${(err as Error).message}` }], isError: true }; @@ -2016,7 +2035,7 @@ server.tool( 'canvas_set_genre', `Durably declare what a canvas IS — writes the genre to metadata.provenance.genre WITHOUT the token/component churn of apply_preset, so an already-styled canvas can be calibrated in one call. Genre and preset are separate fields: apply_preset never touches the genre, and this never touches the preset (a genre stored in the preset field by an older framesmith moves to the genre field on the first write). The stamp is what canvas_evaluate / canvas_autofix / the viewer's quality panel read when no explicit genre param is passed (the evaluate result's genre.source shows "provenance" for a stamp vs "explicit" for a param). Pass genre: null to clear the stamp; other provenance facts (preset, structure, importedFrom) are preserved either way. -Genres that relax cliche tells: "material" (accent-hue, pure-black-white), "data" / alias "dashboard" (honest-content), "commerce" / alias "checkout" (honest-content). The result echoes the genre you passed plus "canonical" (the alias folded: "dashboard" → "data", "checkout" → "commerce" — what evaluate reports as genre.active), "previous" (the stamp it replaced), and "relaxes" (the tells this genre relaxes). Any other string is stored but relaxes nothing — "relaxes" (and a note) tells you immediately, instead of a silently ineffective stamp. When the canvas's preset relaxes tells on its own (the material preset), "presetRelaxes" ({ preset, relaxed }) says so — clearing the genre does not remove those. Genre follows what the screen is FOR, not what it contains: read screens with the user's own figures (dashboards, activity, finance, fitness, logistics) → "data"; a screen you complete a purchase on (cart, checkout, order confirmation, billing history) → "commerce"; editors/admin forms → "material". NEVER use a genre to dodge flags on a marketing page — declaring the wrong genre doesn't make the design better, it makes the evaluator blind. A PRICING page is marketing: its numbers are claims, not a transaction, so it does not get "commerce". +Genres that relax cliche tells: "material" (accent-hue, pure-black-white), "data" / alias "dashboard" (honest-content), "commerce" / alias "checkout" (honest-content). The result echoes the genre you passed plus "canonical" (the alias folded: "dashboard" → "data", "checkout" → "commerce" — what evaluate reports as genre.active), "previous" (the stamp it replaced), and "relaxes" (the tells this genre relaxes). Any other string is stored but relaxes nothing — "relaxes" (and a note) tells you immediately, instead of a silently ineffective stamp. When the canvas's preset relaxes tells on its own (the material preset), "presetRelaxes" ({ preset, relaxed }) says so — clearing the genre does not remove those. Genre follows what the screen is FOR, not what it contains: read screens with the user's own figures (dashboards, activity, finance, fitness, logistics) → "data"; a screen you complete a purchase on (cart, checkout, order confirmation, billing history) → "commerce"; the Material style → "material" (an editor or admin console is a type, not a genre: declare it with canvas_create / canvas_set_type type "desktop-app"). NEVER use a genre to dodge flags on a marketing page — declaring the wrong genre doesn't make the design better, it makes the evaluator blind. A PRICING page is marketing: its numbers are claims, not a transaction, so it does not get "commerce". Stamping a genre does NOT move the canvas's versionHash (the hash covers design content only, never metadata) — recorded approvals stay valid.`, { @@ -2046,15 +2065,15 @@ 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?, 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.`, + `Declare what MEDIUM an existing canvas is on: "web" (a page you read and scroll), "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), or "desktop-app" (a tool you work in — an editor, an admin console, a file browser — in a browser tab or a native window: it selects the desktop-app ruleset, and generate_design_system gives it a 14px body, 13 for data-dense, plus state tokens). 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 is the type's profile: a phone for "mobile" ("iphone-390", the default) or a window for "desktop-app" ("window-1440", the default); it is not valid with "web". A desktop-app canvas narrower than its window's 1024px minimum gets a hint to resize. 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'), - device: z.string().optional().describe('Device profile for "mobile" (default "iphone-390"). Only valid with "mobile".'), + type: z.enum(['web', 'mobile', 'desktop-app']).nullable().describe('"web", "mobile", "desktop-app", or null to clear the declaration'), + device: z.string().optional().describe('Profile for the type: a phone for "mobile" (default "iphone-390") or a window for "desktop-app" (default "window-1440"). Not valid with "web" or null.'), }, async ({ canvasId, type, device }) => { if (type === null && device !== undefined) { - return { content: [{ type: 'text', text: 'Error: device only applies to type "mobile".' }], isError: true }; + return { content: [{ type: 'text', text: 'Error: device only applies with a type ("mobile" or "desktop-app").' }], isError: true }; } const declared = type === null ? null : surfaceFor(type, device); if (declared && 'error' in declared) return { content: [{ type: 'text', text: `Error: ${declared.error}` }], isError: true }; @@ -2077,6 +2096,10 @@ server.tool( if (profile && (artboard.width !== profile.width || artboard.height !== profile.height)) { steps.push(`resize the artboard to ${profile.width}×${profile.height} (U("document", { width: ${profile.width}, height: ${profile.height} })) — this canvas is ${artboard.width}×${artboard.height}`); } + const window = surface.type === 'desktop-app' && surface.device ? windowProfile(surface.device) : undefined; + if (window && typeof artboard.width === 'number' && artboard.width < window.minWidth) { + steps.push(`resize the artboard to the ${window.width}×${window.height} window (U("document", { width: ${window.width}, height: ${window.height} })) — this canvas is ${artboard.width}px wide, under the ${window.minWidth}px the desktop-app layouts are built for`); + } return { content: [{ type: 'text', text: JSON.stringify({ canvasId, @@ -2096,7 +2119,7 @@ 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). Contrast is measured as rendered: translucent fills (rgba / #RRGGBBAA) composite over the fills behind them (over the white page when no opaque fill is behind), translucent text blends into that, and "transparent" is see-through — so a tinted selected or hover fill is judged by its real color, and the message names the composited one. 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, and every mobile primitive icon prop (button.icon, list-row / nav-bar leading.icon, nav-bar trailing[].icon, tab-bar items[].icon — required, so a tab with no icon warns too — and tab-bar fab.icon), 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 — an icon node or button.icon carries a fix canvas_autofix applies (the nested props name the spelling to write). 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). Contrast is measured as rendered: translucent fills (rgba / #RRGGBBAA) composite over the fills behind them (over the white page when no opaque fill is behind), translucent text blends into that, and "transparent" is see-through — so a tinted selected or hover fill is judged by its real color, and the message names the composited one. 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 — on web and desktop-app canvases every pointer target (the four controls and the clickable primitives: button, list-row, each tab-bar tab and FAB, each nav-bar action) is held to the 24px WCAG 2.5.8 floor with its SPACING EXCEPTION: a target still below 24 after the padded-row step passes only when a 24px circle centred on it reaches no other target's box and no other undersized target's circle (touching passes; nested targets and disabled ones never count). Spacing needs rendered rects, so DETAILED mode runs the circle test (ERROR when it fails, naming the neighbour) and FAST mode WARNS on any target below 24, naming detailed mode — grow it or run mode "detailed" before presenting. Instances are expanded for this census and findings report on the instance. (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 the four controls; 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, and every mobile primitive icon prop (button.icon, list-row / nav-bar leading.icon, nav-bar trailing[].icon, tab-bar items[].icon — required, so a tab with no icon warns too — and tab-bar fab.icon), 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 — an icon node or button.icon carries a fix canvas_autofix applies (the nested props name the spelling to write). 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 (siblings that content scrolls under — absolute, sticky, pinned chrome, a sheet, a bar pinned to the bottom — are exempt; a bar pinned to the top over in-flow content still counts). - "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. @@ -2104,9 +2127,11 @@ Designed for generator-evaluator loops: generate with batch_design, evaluate wit 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 "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"; the Material style → "material" (an editor or admin console is a type, not a genre: declare it with canvas_create / canvas_set_type type "desktop-app")), not what it contains. + +The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile" | "desktop-app"), device (the profile: a phone such as "iphone-390" on mobile, a window such as "window-1440" on desktop-app, 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 ["desktop-app-artboard", "mobile-artboard"] (the base rules, on for every type). On a desktop-app canvas rulesOff lists "type-scale-ratio" (the adjacent font-size ratio check, which tags its warnings with that rule id on web and mobile; dense apps step 12/13/14 on purpose), and desktop-app-artboard (structure, every type) WARNS when a desktop-app canvas is narrower than its window's 1024px minimum. 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 per target: 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 tappable (no other control or tappable primitive), 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. The tappable primitives are measured by their own box, never an ancestor's: a button (height by size, 32/44/52, or a larger height/minHeight; never narrower than tall), a list-row (44, 56 with a subtitle; its width when known), each tab-bar tab (bar width / slots × 49) and the FAB (56), each nav-bar action (44×44). Nodes inside an instance are judged as rendered and reported on the instance), 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 — rebuild it as a button if tappable), mobile-tab-bar (usability WARNING, −10 per condition: fewer than 2 or more than 5 tabs (the FAB is not a tab); a tab with no label; not pinned to the bottom — a position override or any y), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset, or left under a pinned bar. With root safeArea the renderer reserves the insets; pinned chrome (a fixed status-bar, tab-bar or home-indicator) raises the reservation to its height; so only positioned content is checked: absolute, fixed, and sticky at its stuck y. Without safeArea the first content must clear 47px and the last 34px through padding, spacer frames, pinned chrome, or an in-flow status-bar — 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 pinned bar (fixed, not chrome) must keep its own content out of the inset, and the scroll must clear it: the last content's clearance from the bottom must reach the bar's height + its bottom offset (for a top bar, the first content's offset must reach its y + height) — the suggestion names the spacer height to add at the end (or start) of your content frame. A sheet's content is never judged (it is a padded overlay). Fast mode adds an info when it cannot place positioned content (inside an in-flow frame, or a fixed node under a blurred or animated ancestor) or cannot work out a pinned bar's height; detailed mode checks rendered rects and is authoritative: content in the top inset, positioned content in the screen's bottom inset, in-flow content in the last 34px of the document, sticky content where it sticks, and in-flow content under a bottom bar at the end of the scroll or a top bar at the start), 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 per target: 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 tappable (no other control or tappable primitive), 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. The tappable primitives are measured by their own box, never an ancestor's: a button (height by size, 32/44/52, or a larger height/minHeight; never narrower than tall), a list-row (44, 56 with a subtitle; its width when known), each tab-bar tab (bar width / slots × 49) and the FAB (56), each nav-bar action (44×44). Nodes inside an instance are judged as rendered and reported on the instance), 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 — rebuild it as a button if tappable), mobile-tab-bar (usability WARNING, −10 per condition: fewer than 2 or more than 5 tabs (the FAB is not a tab); a tab with no label; not pinned to the bottom — a position override or any y), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset, or left under a pinned bar. With root safeArea the renderer reserves the insets; pinned chrome (a fixed status-bar, tab-bar or home-indicator) raises the reservation to its height; so only positioned content is checked: absolute, fixed, and sticky at its stuck y. Without safeArea the first content must clear 47px and the last 34px through padding, spacer frames, pinned chrome, or an in-flow status-bar — 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 pinned bar (fixed, not chrome) must keep its own content out of the inset, and the scroll must clear it: the last content's clearance from the bottom must reach the bar's height + its bottom offset (for a top bar, the first content's offset must reach its y + height) — the suggestion names the spacer height to add at the end (or start) of your content frame. A sheet's content is never judged (it is a padded overlay). Fast mode adds an info when it cannot place positioned content (inside an in-flow frame, or a fixed node under a blurred or animated ancestor) or cannot work out a pinned bar's height; detailed mode checks rendered rects and is authoritative: content in the top inset, positioned content in the screen's bottom inset, in-flow content in the last 34px of the document, sticky content where it sticks, and in-flow content under a bottom bar at the end of the scroll or a top bar at the start), 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.`, +REPEATED FINDINGS FOLD in this result: three or more warnings or infos sharing a severity, category, rule (or tell) and theme come back as ONE entry when they also share their message — members that carry a fix match on its shape (quoted text and numbers aside, since each fix op holds its node's specifics), members without a fix only when they read the same apart from quoted node text, suggestion included. The entry keeps the first one's message and suggestion and adds nodeIds (distinct, nodeId first), count, and fixes ([{ nodeId, op }] for each member that had a fix) — act on every node it lists. Errors never fold. canvas_autofix and the directive work from the unfolded list.`, { 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'), @@ -2167,7 +2192,7 @@ The result's "surface" field (always present) reports the canvas TYPE and the ru 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) }], + content: [{ type: 'text', text: JSON.stringify({ ...result, issues: foldIssues(result.issues), ...(llmNote ? { llmNote } : {}), ...(openFeedback > 0 ? { openFeedback } : {}), ...(stress ? { stress } : {}), directive }, null, 2) }], }; } ); diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts index 2ceaf84..926791e 100644 --- a/src/mobile-rules.ts +++ b/src/mobile-rules.ts @@ -683,6 +683,6 @@ export function checkArtboard(ctx: MobileContext, surface: SurfaceDeclaration): '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.`, + `Declared ${surface.type}, 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/scene-graph.ts b/src/scene-graph.ts index 2a85cfd..cc8290d 100644 --- a/src/scene-graph.ts +++ b/src/scene-graph.ts @@ -6,7 +6,7 @@ import { DEFAULT_PROJECT_ID, type Canvas, type SceneNode } from './types.js'; import { isRepoBound, repoDir, writeCanvasToDir, removeCanvasFromDir, loadCanvasesFromDir, externallyModified, readCanvasFile } from './repo-store.js'; import { canvasVersionHash } from './version.js'; import { migrateProvenance } from './genre.js'; -import { deviceProfile, DEFAULT_DEVICE, type SurfaceType } from './surfaces.js'; +import { deviceProfile, windowProfile, DEFAULT_DEVICE, DEFAULT_WINDOW, type SurfaceType } from './surfaces.js'; const store = new Map(); @@ -140,6 +140,8 @@ export function createCanvas( // explicit width/height wins per side) and reserves the device insets on // the root, so in-flow content can never reach them. const profile = surface?.type === 'mobile' ? deviceProfile(surface.device ?? DEFAULT_DEVICE) : undefined; + // Phase 31 — a desktop-app canvas takes its window's artboard; no insets. + const window = surface?.type === 'desktop-app' ? windowProfile(surface.device ?? DEFAULT_WINDOW) : undefined; const canvas: Canvas = { id, name: name ?? `Canvas ${store.size + 1}`, @@ -148,8 +150,8 @@ export function createCanvas( type: 'document', name: 'Document', children: [], - width: size.width ?? profile?.width ?? 1440, - height: size.height ?? profile?.height ?? 900, + width: size.width ?? profile?.width ?? window?.width ?? 1440, + height: size.height ?? profile?.height ?? window?.height ?? 900, fill: '#FFFFFF', ...(profile ? { safeArea: { ...profile.safeArea } } : {}), }, diff --git a/src/structures.ts b/src/structures.ts index 78fdafe..7fca638 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -1862,7 +1862,6 @@ export function registerStructure(structure: Structure): void { * @throws naming what is available when nothing fits. */ export function resolveStructure(name: string, type: SurfaceType): Structure { const all = Array.from(structureMap.values()); - const other: SurfaceType = type === 'web' ? 'mobile' : 'web'; const available = (kind: Structure['kind']): string => { const k = kind ?? 'page'; const names = [...new Set(all.filter((s) => (s.kind ?? 'page') === k && covers(structureSurface(s), type)).map(structureAnatomy))]; @@ -1875,8 +1874,10 @@ export function resolveStructure(name: string, type: SurfaceType): Structure { if (realisations.length > 0) { const fit = realisations.find((s) => covers(structureSurface(s), type)); if (fit) return fit; - // Every realisation of it covers the other type, or one would fit. - throw new Error(`"${name}" has no ${type} realisation. ${available(realisations[0].kind)} Pass realisation: "${other}" to stamp its ${other} realisation anyway.`); + // Name every type it IS realised on (Phase 31: there are three types). + const realisedOn = SURFACE_TYPES.filter((t) => realisations.some((s) => covers(structureSurface(s), t))); + const force = realisedOn.map((t) => `realisation: "${t}"`).join(' or '); + throw new Error(`"${name}" has no ${type} realisation. ${available(realisations[0].kind)} Pass ${force} to stamp its ${realisedOn.join(' / ')} realisation anyway.`); } const structure = structureMap.get(name); @@ -2009,7 +2010,7 @@ export interface ApplyStructureResult { kind: 'page' | 'component'; /** Phase 30 — the anatomy stamped and the surface of its realisation. */ anatomy: string; - surface: 'web' | 'mobile' | 'any'; + surface: 'web' | 'mobile' | 'desktop-app' | 'any'; axes?: StructureAxes; /** Top-level node ids inserted under the canvas root (page) or target (component). */ insertedNodeIds: string[]; diff --git a/src/surfaces.ts b/src/surfaces.ts index 8baee9a..e0a8a31 100644 --- a/src/surfaces.ts +++ b/src/surfaces.ts @@ -6,8 +6,11 @@ import type { Canvas } from './types.js'; import type { ClicheTell } from './evaluate.js'; -export type SurfaceType = 'web' | 'mobile'; -export const SURFACE_TYPES: readonly SurfaceType[] = ['web', 'mobile']; +// Phase 31: `desktop-app` is a tool you work in (persistent chrome, panes, +// dense controls, a current selection), in a browser tab or a native window. +// `web` is a page you read and scroll. +export type SurfaceType = 'web' | 'mobile' | 'desktop-app'; +export const SURFACE_TYPES: readonly SurfaceType[] = ['web', 'mobile', 'desktop-app']; /** One device a `mobile` canvas can target. CSS px (1pt = 1px). Apple's HIG * confirms the 44pt target and the 17/11 type sizes; the insets come from @@ -56,6 +59,48 @@ export const DEVICE_PROFILES: Record = { export const DEFAULT_DEVICE = 'iphone-390'; +/** Phase 31: one window a `desktop-app` canvas targets. CSS px. The research + * sources and confidence per value are in the Phase 31 spec's window profile + * table; the smallest-text, prose-floor and data-dense body values are house + * values. */ +export interface WindowProfile { + id: string; + width: number; + height: number; + /** The WCAG 2.5.8 floor; smaller targets pass only by the spacing exception. */ + targetFloor: number; + /** Control and row heights a desktop app stacks on (Carbon, Primer). */ + densityLadder: readonly number[]; + bodySize: number; + /** The `data-dense` personality's body. */ + bodyDense: number; + smallestText: number; + /** Text of five or more words below this warns. */ + proseFloor: number; + /** The narrowest window the desktop-app layouts are built for. */ + minWidth: number; + /** Detail-view breakpoint widths. */ + breakpoints: readonly number[]; +} + +export const WINDOW_PROFILES: Record = { + 'window-1440': { + id: 'window-1440', + width: 1440, + height: 900, + targetFloor: 24, + densityLadder: [24, 32, 40, 48, 64], + bodySize: 14, + bodyDense: 13, + smallestText: 11, + proseFloor: 12, + minWidth: 1024, + breakpoints: [1024, 1280, 1440], + }, +}; + +export const DEFAULT_WINDOW = 'window-1440'; + export function isSurfaceType(value: unknown): value is SurfaceType { return typeof value === 'string' && (SURFACE_TYPES as readonly string[]).includes(value); } @@ -64,6 +109,16 @@ export function deviceProfile(id: string): DeviceProfile | undefined { return Object.hasOwn(DEVICE_PROFILES, id) ? DEVICE_PROFILES[id] : undefined; } +export function windowProfile(id: string): WindowProfile | undefined { + return Object.hasOwn(WINDOW_PROFILES, id) ? WINDOW_PROFILES[id] : undefined; +} + +/** The profile table each type draws its `device` from; web has none. */ +const PROFILE_TABLES: Partial; fallback: string }>> = { + mobile: { table: DEVICE_PROFILES, fallback: DEFAULT_DEVICE }, + 'desktop-app': { table: WINDOW_PROFILES, fallback: DEFAULT_WINDOW }, +}; + export interface SurfaceDeclaration { type: SurfaceType; device: string | null; @@ -71,13 +126,16 @@ export interface SurfaceDeclaration { } /** The canvas's type. Absent (or unreadable) means `web`, source `default` — - * a canvas nobody declared. A mobile canvas with no stored device targets the + * a canvas nobody declared. A mobile or desktop-app canvas whose stored device + * is missing, or is not in that type's profile table, targets the type's * default profile. */ export function resolveSurface(canvas: Canvas): SurfaceDeclaration { const stored = canvas.metadata?.surface; if (!stored || !isSurfaceType(stored.type)) return { type: 'web', device: null, source: 'default' }; - if (stored.type === 'web') return { type: 'web', device: null, source: 'declared' }; - return { type: 'mobile', device: typeof stored.device === 'string' ? stored.device : DEFAULT_DEVICE, source: 'declared' }; + const profiles = PROFILE_TABLES[stored.type]; + if (!profiles) return { type: stored.type, device: null, source: 'declared' }; + const known = typeof stored.device === 'string' && Object.hasOwn(profiles.table, stored.device); + return { type: stored.type, device: known ? stored.device! : profiles.fallback, source: 'declared' }; } /** The root safeArea sides the renderer honours: each side only when it is a @@ -93,15 +151,16 @@ export function usableSafeArea(value: unknown): { top?: number; bottom?: number /** Validate a type + device pair for canvas_create / canvas_set_type. Returns * the metadata to store, or an error message. */ export function surfaceFor(type: SurfaceType, device?: string): { surface: { type: SurfaceType; device?: string } } | { error: string } { - if (device !== undefined && type !== 'mobile') { - return { error: `device only applies to type "mobile" (got type "${type}").` }; + const profiles = PROFILE_TABLES[type]; + if (!profiles) { + if (device !== undefined) return { error: `device does not apply to type "${type}" (got "${device}").` }; + return { surface: { type } }; } - if (type === 'web') return { surface: { type: 'web' } }; - const id = device ?? DEFAULT_DEVICE; - if (!deviceProfile(id)) { - return { error: `Unknown device "${id}". Known devices: ${Object.keys(DEVICE_PROFILES).join(', ')}.` }; + const id = device ?? profiles.fallback; + if (!Object.hasOwn(profiles.table, id)) { + return { error: `Unknown ${type} device "${id}". Known for "${type}": ${Object.keys(profiles.table).join(', ')}.` }; } - return { surface: { type: 'mobile', device: id } }; + return { surface: { type, device: id } }; } // --- Rulesets --- @@ -128,16 +187,27 @@ export const MOBILE_RULES = [ 'mobile-tab-bar', ] as const; +/** Phase 31: the desktop-app ruleset's rule ids, each registered in the same + * change as the check that honours it. `desktop-app-artboard` lives in the + * base row (it must run on every type), so it is not listed here. */ +export const DESKTOP_APP_RULES: readonly string[] = []; + +/** Phase 31: default rules — they run unless an active ruleset disables them. + * A rule id appears here when some type replaces the check. */ +export const DEFAULT_RULES = ['type-scale-ratio'] as const; + /** Keyed by axis, then value. Genre rows are canonical names only; aliases * fold through GENRE_ALIASES first. A rule id lands here in the same change as * the check that honours it. */ export const RULESETS: Record> = { - // 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'] } }, + // mobile-artboard and desktop-app-artboard are on for every type: their job + // is to catch canvases whose declaration does not match their artboard. + base: { all: { enable: ['mobile-artboard', 'desktop-app-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] } }, + // desktop-app replaces the adjacent-ratio check (dense apps step 12/13/14 + // on purpose); its own type ladder lands with the app rules. + type: { web: {}, mobile: { enable: [...MOBILE_RULES] }, 'desktop-app': { enable: [...DESKTOP_APP_RULES], disable: ['type-scale-ratio'] } }, 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/src/types.ts b/src/types.ts index 55f5f5f..dcf77b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -337,12 +337,26 @@ export interface DesignVariables { * duration + easing packaged under a name, referenced from `transition` * as the string "$motion.". */ motion?: Record; + /** Phase 31 FR-A8 — element-state opacities: hover/focus/pressed are the + * share of the ink layered over a control's fill, disabledContent and + * disabledContainer the opacities a disabled control renders at (Material 3 + * state tokens). Layered workspace → project → canvas like motion. */ + state?: StateTokens; /** Phase 27 — elevation (shadow) tokens, referenced from a node as * `shadow: "$elevation."` (the `$motion` dotted pattern). Semantic * names by convention: flat / raised / floating / overlay. */ elevation?: Record; } +/** Phase 31 FR-A8 — see DesignVariables.state. Each value is 0–1. */ +export interface StateTokens { + hover?: number; + focus?: number; + pressed?: number; + disabledContent?: number; + disabledContainer?: number; +} + /** Custom font face declaration. Renderer emits a single `@font-face` rule * per entry plus a `` per unique origin so the browser * can warm the connection before the first font request fires. */ @@ -388,7 +402,7 @@ export interface Structure { anatomy?: string; /** Phase 30 — the canvas type this realisation is for (default `web`); * `any` stamps on every type. (anatomy, surface) is unique in the registry. */ - surface?: 'web' | 'mobile' | 'any'; + surface?: 'web' | 'mobile' | 'desktop-app' | 'any'; /** Placeholder children inserted under `canvas.root` by `apply_structure`. */ nodes: SceneNode[]; } @@ -500,7 +514,7 @@ export interface Canvas { * / canvas_set_type). Absent means `web`, never declared. It selects * rules, generator defaults and presentation, never the render — the * insets a phone needs live on the root as `safeArea`. */ - surface?: { type: 'web' | 'mobile'; device?: string }; + surface?: { type: 'web' | 'mobile' | 'desktop-app'; device?: string }; [key: string]: unknown; }; } diff --git a/src/variables.ts b/src/variables.ts index 14a27f8..ab512eb 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -258,6 +258,7 @@ export function setVariables(canvas: Canvas, vars: Partial): De } if (vars.motion) canvas.variables.motion = { ...canvas.variables.motion, ...vars.motion }; if (vars.elevation) canvas.variables.elevation = { ...canvas.variables.elevation, ...vars.elevation }; + if (vars.state) canvas.variables.state = { ...canvas.variables.state, ...vars.state }; return canvas.variables; } @@ -379,6 +380,7 @@ export function mergeDesignTokens(...layers: Array) } if (layer.motion) out.motion = { ...(out.motion ?? {}), ...layer.motion }; if (layer.elevation) out.elevation = { ...(out.elevation ?? {}), ...layer.elevation }; + if (layer.state) out.state = { ...(out.state ?? {}), ...layer.state }; } return out; } diff --git a/src/viewer.ts b/src/viewer.ts index 99a1f34..59617b6 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -6,7 +6,7 @@ import { getProject, listProjects, listWorkspaces, getCanvasTokens, getProjectDe import { getRepoLocation, archiveRepoCanvas, deleteRepoCanvas, updateRepoCanvas } from './aggregate.js'; import { DEFAULT_PROJECT_ID, type Canvas, type SceneNode } from './types.js'; import { evaluateCanvas, resolveGenre, type EvaluationResult, type EvaluationIssue } from './evaluate.js'; -import { resolveSurface, usableSafeArea, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, type DeviceProfile } from './surfaces.js'; +import { resolveSurface, usableSafeArea, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, windowProfile, WINDOW_PROFILES, DEFAULT_WINDOW, type DeviceProfile } from './surfaces.js'; import { isPresetName } from './genre.js'; import { addFeedback, listFeedback, resolveFeedback, deleteFeedback, openFeedbackCount } from './feedback.js'; @@ -1174,6 +1174,10 @@ export async function renderDetailPage(canvas: Canvas, port: number): Promise bw < w) : []; const deviceId = surface.device ?? DEFAULT_DEVICE; const profile = deviceProfile(deviceId) ?? PHONE; @@ -1424,7 +1428,7 @@ ${FAVICON_HTML}
← Back ${esc(canvas.name)} - ${w} x ${h}${isMobile ? ` · ${esc(deviceId)}` : ''} + ${w} x ${h}${isMobile ? ` · ${esc(deviceId)}` : window ? ` · ${esc(window.id)}` : ''} ${stateChips} ${provChip} ${verdictChip} @@ -1433,6 +1437,10 @@ ${FAVICON_HTML} ${hasDark ? '' : ''} ${isMobile ? '' + : window + ? `${windowWidths.map((bw) => ``).join('\n ')} + + ` : ` @@ -1458,16 +1466,19 @@ ${FAVICON_HTML} ${isMobile ? renderDevice(canvas, w, h, deviceId, profile) : `
-
+ ${window ? windowWidths.map((bw) => `
+
Window · ${bw}×${h}
+
+
`).join('\n ') : `
Mobile · ${PHONE.width}×${PHONE.height}
Tablet · 768×1024
-
+
`}
-
Desktop · ${w}×${h}
+
${window ? "Window" : "Desktop"} · ${w}×${h}
diff --git a/test-desktop-app.ts b/test-desktop-app.ts new file mode 100644 index 0000000..9231d3b --- /dev/null +++ b/test-desktop-app.ts @@ -0,0 +1,267 @@ +// Phase 31 slice A — the desktop-app type, the type-scale-ratio rule id, the +// web/desktop-app 24px census (fast mode), navigation lists that are not +// tables, folded results, the import report summary, type-aware generation +// with state + control tokens, the baseline fixtures, and the viewer's window +// breakpoints. The detailed-mode circle test lives in test-pointer-targets.ts +// (it needs Chrome). +// +// Usage: npx tsx test-desktop-app.ts + +import './test-env.js'; +import { readFileSync, readdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createCanvas } from './src/scene-graph.js'; +import { surfaceFor, resolveSurface, RULESETS } from './src/surfaces.js'; +import { evaluateCanvas, foldIssues, contrastRatio, parseColor, type EvaluationIssue } from './src/evaluate.js'; +import { extractInventory } from './src/drift.js'; +import { summarizeImportReport, type ImportReport } from './src/import.js'; +import { generateDesignSystem, typeBase, STATE_LAYERS, PERSONALITY_NAMES } from './src/design-language.js'; +import { mergeDesignTokens, setVariables } from './src/variables.js'; +import { renderToHtml } from './src/renderer.js'; +import { renderDetailPage } from './src/viewer.js'; +import { resolveStructure } from './src/structures.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}` : ''}`); +} + +function canvasWith(children: SceneNode[], surface?: { type: 'web' | 'mobile' | 'desktop-app'; device?: string }, width = 1440): Canvas { + return { + id: 'c1', name: 'c', projectId: 'p', components: {}, variables: {}, createdAt: '', lastModified: '', + root: { id: 'document', type: 'document', name: 'Document', width, height: 900, fill: '#FFFFFF', layout: 'vertical', padding: 24, children }, + ...(surface ? { metadata: { surface } } : {}), + } as Canvas; +} +const text = (id: string, content: string, fontSize: number): SceneNode => ({ id, name: id, type: 'text', content, fontSize, color: '#111111' }); + +// ── FR-A1: the type ──────────────────────────────────────────────────────── +{ + const c = createCanvas('app', undefined, {}, surfaceFor('desktop-app').hasOwnProperty('surface') ? (surfaceFor('desktop-app') as { surface: { type: 'desktop-app'; device?: string } }).surface : undefined); + const s = resolveSurface(c); + check('canvas_create desktop-app reads back 1440×900', c.root.width === 1440 && c.root.height === 900 && c.root.safeArea === undefined, `${c.root.width}×${c.root.height}`); + check('desktop-app surface defaults to window-1440', s.type === 'desktop-app' && s.device === 'window-1440' && s.source === 'declared', JSON.stringify(s)); + check('a phone profile is refused on desktop-app', 'error' in surfaceFor('desktop-app', 'iphone-390')); + check('a window profile is refused on mobile', 'error' in surfaceFor('mobile', 'window-1440')); + check('web takes no device', 'error' in surfaceFor('web', 'window-1440')); + const stray = canvasWith([], { type: 'desktop-app', device: 'iphone-390' }); + check('a stored mismatched device resolves to the type default', resolveSurface(stray).device === 'window-1440', JSON.stringify(resolveSurface(stray))); + const plain = canvasWith([text('t', 'Hello', 16)]); + const declared = canvasWith([text('t', 'Hello', 16)], { type: 'desktop-app', device: 'window-1440' }); + check('declaring the type never changes the render', renderToHtml(plain.root, 1440, 900, plain) === renderToHtml(declared.root, 1440, 900, declared)); +} + +// ── FR-A2 / FR-A3: type-scale-ratio and the desktop-app row ──────────────── +{ + const dense = [text('a', 'Label', 13), text('b', 'Body copy here', 14), text('c', 'Title', 15)]; + const web = await evaluateCanvas(canvasWith(dense, { type: 'web' }), { mode: 'fast', categories: ['typography'] }); + const app = await evaluateCanvas(canvasWith(dense, { type: 'desktop-app', device: 'window-1440' }), { mode: 'fast', categories: ['typography'] }); + const ratio = (r: typeof web) => r.issues.filter((i) => i.rule === 'type-scale-ratio'); + check('web keeps the ratio check, tagged type-scale-ratio', ratio(web).length >= 1, JSON.stringify(ratio(web).map((i) => i.message))); + check('desktop-app turns type-scale-ratio off', ratio(app).length === 0 && app.surface.rulesOff.includes('type-scale-ratio'), JSON.stringify(app.surface)); + check('desktop-app rulesOn carries the base artboard rules', JSON.stringify(app.surface.rulesOn) === JSON.stringify(['desktop-app-artboard', 'mobile-artboard']), JSON.stringify(app.surface.rulesOn)); + check('the desktop-app row registers only what has a check today', JSON.stringify(RULESETS.type['desktop-app']) === JSON.stringify({ enable: [], disable: ['type-scale-ratio'] })); +} + +// ── desktop-app-artboard ──────────────────────────────────────────────────── +{ + const narrow = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }, 900), { mode: 'fast', categories: ['structure'] }); + const wide = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }), { mode: 'fast', categories: ['structure'] }); + const web900 = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'web' }, 900), { mode: 'fast', categories: ['structure'] }); + const art = (r: typeof wide) => r.issues.filter((i) => i.rule === 'desktop-app-artboard'); + check('a 900px desktop-app canvas warns (desktop-app-artboard)', art(narrow).length === 1 && art(narrow)[0].severity === 'warning'); + check('a 1440 desktop-app canvas is silent', art(wide).length === 0); + check('a 900px web canvas is silent', art(web900).length === 0); + const at1024 = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }, 1024), { mode: 'fast', categories: ['structure'] }); + const at1023 = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }, 1023), { mode: 'fast', categories: ['structure'] }); + const at1280 = await evaluateCanvas(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }, 1280), { mode: 'fast', categories: ['structure'] }); + check('the 1024 minimum is inclusive (1024 silent, 1023 warns)', art(at1024).length === 0 && art(at1023).length === 1); + check('a 1280 window is not judged against the 1440 artboard', art(at1280).length === 0); +} + +// ── FR-A4: the 24px census, fast mode ────────────────────────────────────── +{ + const small = (id: string): SceneNode => ({ id, name: id, type: 'button', icon: 'x', height: 20, width: 20 }); + const row: SceneNode = { id: 'row', name: 'row', type: 'frame', layout: 'horizontal', gap: 4, children: [small('b1'), small('b2')] }; + for (const type of ['web', 'desktop-app'] as const) { + const r = await evaluateCanvas(canvasWith([row], { type, ...(type === 'desktop-app' ? { device: 'window-1440' } : {}) }), { mode: 'fast', categories: ['usability'] }); + const t = r.issues.filter((i) => /below the 24px floor/.test(i.message)); + check(`${type}: a 20px button warns in fast mode, naming the exception and detailed mode`, t.length === 2 && t.every((i) => i.severity === 'warning' && /spacing exception/.test(i.message) && /detailed/.test(i.suggestion ?? '')), JSON.stringify(t.map((i) => [i.severity, i.message]))); + } + const disabled = await evaluateCanvas(canvasWith([{ ...row, children: [{ ...small('b1'), disabled: true }] }], { type: 'web' }), { mode: 'fast', categories: ['usability'] }); + check('a disabled target is not measured', !disabled.issues.some((i) => /24px floor/.test(i.message))); + const padded: SceneNode = { id: 'r2', name: 'row', type: 'frame', layout: 'horizontal', padding: 12, children: [{ id: 'cb', name: 'cb', type: 'checkbox' }, text('l', 'Remember me', 14)] }; + const lone = await evaluateCanvas(canvasWith([padded]), { mode: 'fast', categories: ['usability'] }); + check('a lone 18px checkbox in a padded row still passes', !lone.issues.some((i) => /24px floor/.test(i.message)), JSON.stringify(lone.issues.map((i) => i.message))); + const bare: SceneNode = { id: 'r3', name: 'row', type: 'frame', layout: 'horizontal', children: [{ id: 'cb1', name: 'cb1', type: 'checkbox' }, { id: 'cb2', name: 'cb2', type: 'checkbox' }, text('l', 'Pick', 14)] }; + const two = await evaluateCanvas(canvasWith([bare]), { mode: 'fast', categories: ['usability'] }); + check('two bare checkboxes warn in fast mode (was an error in both modes)', two.issues.filter((i) => /24px floor/.test(i.message)).every((i) => i.severity === 'warning') && two.issues.filter((i) => /24px floor/.test(i.message)).length === 2); + const ok: SceneNode = { id: 'b', name: 'b', type: 'button', label: 'Save', size: 'sm' }; + const fine = await evaluateCanvas(canvasWith([ok], { type: 'desktop-app', device: 'window-1440' }), { mode: 'fast', categories: ['usability'] }); + check('a 32px button passes', !fine.issues.some((i) => /24px floor/.test(i.message))); + const comp: Canvas = canvasWith([{ id: 'inst', name: 'inst', type: 'instance', componentId: 'tiny' } as SceneNode], { type: 'web' }); + comp.components = { tiny: { id: 'tiny', name: 'Tiny', type: 'component', layout: 'horizontal', children: [small('t1'), small('t2')] } as SceneNode }; + const inInstance = await evaluateCanvas(comp, { mode: 'fast', categories: ['usability'] }); + const hits = inInstance.issues.filter((i) => /24px floor/.test(i.message)); + check('targets inside an instance are measured and reported on the instance', hits.length >= 1 && hits.every((i) => i.nodeId === 'inst'), JSON.stringify(hits.map((i) => i.nodeId))); +} + +// ── FR-A5: lists that lead with a mark are not tables ────────────────────── +{ + const fixture = JSON.parse(readFileSync('test-fixtures/viewer-baseline/gallery-pattern-library.json', 'utf-8')) as Canvas; + const find = (n: SceneNode, id: string): SceneNode | undefined => n.id === id ? n : n.children?.map((c) => find(c, id)).find(Boolean); + const sidebar = find(fixture.root, 'imp-frame-33')!; + check('the baseline sidebar subtree is present', !!sidebar && (sidebar.children?.length ?? 0) >= 6); + check('the baseline sidebar yields no table', extractInventory(sidebar).tables.length === 0); + const sidebarCanvas = canvasWith([sidebar]); + const cov = await evaluateCanvas(sidebarCanvas, { mode: 'fast', categories: ['coverage'] }); + check('the baseline sidebar raises no coverage finding', cov.issues.length === 0, JSON.stringify(cov.issues.map((i) => i.message))); + const hrow = (id: string, cells: SceneNode[]): SceneNode => ({ id, name: id, type: 'frame', layout: 'horizontal', children: cells }); + const region: SceneNode = { id: 'tbl', name: 'tbl', type: 'frame', layout: 'vertical', children: [ + hrow('h', [text('h1', 'Region', 12), text('h2', 'Revenue', 12)]), + hrow('r1', [text('a1', 'North', 14), text('a2', '$40', 14)]), + hrow('r2', [text('b1', 'South', 14), text('b2', '$12', 14)]), + hrow('r3', [text('c1', 'West', 14), text('c2', '$9', 14)]), + ] }; + check('a Region / Revenue table with a header row still detects', extractInventory(region).tables.length === 1); + const avatar = (id: string): SceneNode => ({ id, name: id, type: 'image', width: 24, height: 24, src: 'data:image/png;base64,' }); + const users: SceneNode = { id: 'users', name: 'users', type: 'frame', layout: 'vertical', children: [ + hrow('uh', [text('u0', 'Name', 12), text('u1', 'Role', 12), text('u2', 'Seen', 12)]), + hrow('u1r', [avatar('av1'), text('n1', 'Ana', 14), text('r1x', 'Admin', 14)]), + hrow('u2r', [avatar('av2'), text('n2', 'Bo', 14), text('r2x', 'Editor', 14)]), + hrow('u3r', [avatar('av3'), text('n3', 'Cy', 14), text('r3x', 'Viewer', 14)]), + ] }; + check('an avatar-led users table under a text header still detects', extractInventory(users).tables.length === 1); + const icons: SceneNode = { id: 'nav', name: 'nav', type: 'frame', layout: 'vertical', children: ['Home', 'Files', 'Shared'].map((l, i) => hrow(`n${i}`, [{ id: `i${i}`, name: 'i', type: 'icon', icon: 'home' }, text(`l${i}`, l, 14), text(`k${i}`, String(i + 1), 12)])) }; + check('an icon-led navigation list is not a table', extractInventory(icons).tables.length === 0); + const ledBy = (lead: (i: number) => SceneNode, name = 'list'): SceneNode => ({ id: name, name, type: 'frame', layout: 'vertical', children: ['Alpha', 'Beta', 'Gamma'].map((l, i) => hrow(`${name}${i}`, [lead(i), text(`${name}t${i}`, l, 14), text(`${name}c${i}`, String(i + 1), 12)])) }); + check('an ellipse-led list is not a table', extractInventory(ledBy((i) => ({ id: `e${i}`, name: 'e', type: 'ellipse', width: 8, height: 8 }))).tables.length === 0); + check('a 30px childless frame is not a mark: the list still reads as a table', extractInventory(ledBy((i) => ({ id: `f${i}`, name: 'f', type: 'frame', width: 30, height: 30 }))).tables.length === 1); + check('a 13px dot is not a mark', extractInventory(ledBy((i) => ({ id: `d${i}`, name: 'd', type: 'frame', width: 13, height: 13 }))).tables.length === 1); + check('a 40px image is not a mark', extractInventory(ledBy((i) => ({ id: `g${i}`, name: 'g', type: 'image', width: 40, height: 40, src: 'data:image/png;base64,' }))).tables.length === 1); + check('a dot with no known size is not a mark', extractInventory(ledBy((i) => ({ id: `n${i}`, name: 'n', type: 'frame' }))).tables.length === 1); + check('an import-named Table keeps detection even when its rows lead with dots', extractInventory(ledBy((i) => ({ id: `t${i}`, name: 'dot', type: 'frame', width: 6, height: 6 }), 'Table')).tables.length === 1); +} + +// ── FR-A7: folding and the import summary ────────────────────────────────── +{ + const w = (id: string, n: number, fix = true): EvaluationIssue => ({ category: 'spacing', severity: 'warning', nodeId: id, message: `gap: ${n}px is not on the spacing scale.`, ...(fix ? { fix: { op: `U("${id}", { gap: 8 })`, rationale: 'snap' } } : {}) }); + const issues: EvaluationIssue[] = [ + { category: 'color', severity: 'error', nodeId: 'e1', message: 'Text "a" has contrast ratio 2:1' }, + { category: 'color', severity: 'error', nodeId: 'e2', message: 'Text "b" has contrast ratio 2:1' }, + { category: 'color', severity: 'error', nodeId: 'e3', message: 'Text "c" has contrast ratio 2:1' }, + w('a', 11), w('b', 13), w('c', 15), + w('x', 11, false), w('y', 13, false), + { category: 'color', severity: 'info', nodeId: 'i1', message: 'APCA advisory: Lc 60.8' }, + { category: 'color', severity: 'info', nodeId: 'i2', message: 'APCA advisory: Lc 59.1' }, + { category: 'color', severity: 'info', nodeId: 'i3', message: 'APCA advisory: Lc 57.0' }, + ]; + const folded = foldIssues(issues); + check('errors never fold', folded.filter((i) => i.severity === 'error').length === 3); + const spacing = folded.filter((i) => i.category === 'spacing'); + check('fixable warnings fold on their shape, keeping every fix; unfixed ones with different numbers stay apart', spacing.length === 3 && spacing[0].count === 3 && JSON.stringify(spacing[0].nodeIds) === '["a","b","c"]' && spacing[0].fixes?.length === 3 && spacing[0].fix === undefined && spacing[1].nodeId === 'x' && spacing[2].nodeId === 'y', JSON.stringify(spacing)); + check('two of a kind do not fold', foldIssues([w('p', 11), w('q', 13)]).length === 2); + const apca = folded.filter((i) => i.category === 'color' && i.severity === 'info'); + check('unfixed infos with different numbers never fold (Lc 60.8 / 59.1 / 57.0)', apca.length === 3 && apca.every((i) => i.count === undefined)); + const same = foldIssues(['p', 'q', 'r'].map((id) => ({ category: 'color', severity: 'info' as const, nodeId: id, message: `APCA advisory on "${id}": Lc 60.8`, suggestion: 'A step more contrast.' }))); + check('unfixed infos that read the same (quoted text aside) fold, with no fixes field', same.length === 1 && same[0].count === 3 && same[0].fixes === undefined); + const ratios = foldIssues([ + { category: 'typography', rule: 'type-scale-ratio', severity: 'warning', nodeId: 't1', message: 'Font sizes 40px → 15px have ratio 2.67 (expected 1.1-2.0).' }, + { category: 'typography', rule: 'type-scale-ratio', severity: 'warning', nodeId: 't2', message: 'Font sizes 15px → 14px have ratio 1.07 (expected 1.1-2.0).' }, + { category: 'typography', rule: 'type-scale-ratio', severity: 'warning', nodeId: 't3', message: 'Font sizes 14px → 13px have ratio 1.08 (expected 1.1-2.0).' }, + ]); + check('distinct type-scale-ratio warnings never fold into one message', ratios.length === 3); + const dup = foldIssues(['n', 'n', 'm'].map((id, k) => ({ category: 'spacing', severity: 'warning' as const, nodeId: id, message: `gap: ${11 + k}px is off`, fix: { op: `U("${id}", {})`, rationale: '' } }))); + const mk = (over: Partial, n: number): EvaluationIssue => ({ category: 'spacing', severity: 'warning', nodeId: `k${n}`, message: `gap: ${10 + n}px is off`, fix: { op: `U("k${n}", {})`, rationale: '' }, ...over }); + check('the fold key separates severities', foldIssues([mk({}, 1), mk({}, 2), mk({ severity: 'info' }, 3)]).length === 3); + check('the fold key separates rules', foldIssues([mk({ rule: 'a' }, 1), mk({ rule: 'a' }, 2), mk({ rule: 'b' }, 3)]).length === 3); + check('the fold key separates themes', foldIssues([mk({}, 1), mk({}, 2), mk({ theme: 'dark' }, 3)]).length === 3); + check('a node named by several members is listed once', JSON.stringify(dup[0].nodeIds) === '["n","m"]' && dup[0].count === 3); + check('the folded entry sits where the first member was', folded.findIndex((i) => i.nodeIds?.includes('a')) === 3); + + const ds = JSON.parse(readFileSync('test-fixtures/viewer-baseline/detail-web-dashboard.json', 'utf-8')) as Canvas; + const r = await evaluateCanvas(ds, { mode: 'fast' }); + const opsBefore = r.issues.filter((i) => i.fix).map((i) => i.fix!.op).sort(); + const f = foldIssues(r.issues); + const opsAfter = f.flatMap((i) => [...(i.fix ? [i.fix.op] : []), ...(i.fixes ?? []).map((x) => x.op)]).sort(); + check('every fix op survives folding on the baseline detail page', JSON.stringify(opsBefore) === JSON.stringify(opsAfter), `${opsBefore.length} vs ${opsAfter.length}`); + const before = JSON.stringify(r, null, 2).length; + const after = JSON.stringify({ ...r, issues: f }, null, 2).length; + check('the baseline detail result folds to at most 58% (FR-A7 as amended)', after <= before * 0.58, `${before} → ${after}`); + + const report: ImportReport = { counts: { nodes: 1, frames: 1, text: 0, maxDepth: 1, dropped: 0 }, snapped: [], literals: [], scaleMatches: [], layout: [], unmatchedFonts: [], unmatchedIcons: [], warnings: [] }; + for (let i = 0; i < 40; i++) report.snapped.push({ nodeId: `n${i}`, prop: 'fill', from: 'rgb(0,0,0)', token: i % 2 ? '$bg' : '$surface' }); + for (let i = 0; i < 30; i++) report.literals.push({ nodeId: `l${i}`, prop: 'fill', value: 'rgb(26, 22, 15)' }); + for (let i = 0; i < 50; i++) report.scaleMatches.push({ nodeId: `s${i}`, prop: 'gap', value: 8, token: '$sm' }); + const sum = summarizeImportReport(report); + check('snapped groups per token with counts and five ids', sum.snapped.length === 2 && sum.snapped.every((g) => g.count === 20 && g.nodeIds.length === 5) && sum.summarized === true); + check('scaleMatches group per token', sum.scaleMatches.length === 1 && sum.scaleMatches[0].token === '$sm' && sum.scaleMatches[0].count === 50 && sum.scaleMatches[0].nodeIds.length === 5); + check('literals group per value', sum.literals.length === 1 && sum.literals[0].value === 'rgb(26, 22, 15)' && sum.literals[0].count === 30); + check('the summary is under half the full report', JSON.stringify(sum, null, 2).length <= JSON.stringify(report, null, 2).length / 2); +} + +// ── FR-A8: generation ─────────────────────────────────────────────────────── +{ + check('desktop-app pivots on 14 (13 for data-dense)', typeBase('technical', { surface: 'desktop-app' }).size === 14 && typeBase('data-dense', { surface: 'desktop-app' }).size === 13 && typeBase('technical', { surface: 'desktop-app' }).source === 'desktop-app'); + check('an explicit baseSize wins on desktop-app', typeBase('soft', { surface: 'desktop-app', baseSize: 16 }).size === 16); + let worstRing = Infinity; let worstBorder = Infinity; let floor = Infinity; let bodyOk = true; + for (const p of PERSONALITY_NAMES) for (const seed of ['#2563EB', '#16A34A', '#F59E0B', '#DB2777', '#64748B', '#0EA5E9']) { + const s = generateDesignSystem(seed, p, { surface: 'desktop-app' }); + const light = s.variables.colors!; + const dark = { ...light, ...s.variables.dark!.colors! }; + for (const cols of [light, dark]) { + for (const k of Object.keys(cols).filter((k) => k.startsWith('bg-') || k.endsWith('-tint'))) worstRing = Math.min(worstRing, contrastRatio(parseColor(cols['focus-ring'])!, parseColor(cols[k])!)); + for (const k of ['bg-primary', 'bg-surface']) worstBorder = Math.min(worstBorder, contrastRatio(parseColor(cols['border-strong'])!, parseColor(cols[k])!)); + } + const sizes = Object.values(s.variables.typography!).map((t) => t.fontSize).filter((x): x is number => typeof x === 'number'); + floor = Math.min(floor, ...sizes); + if (![13, 14].includes(s.variables.typography!.body.fontSize as number)) bodyOk = false; + if (JSON.stringify(s.variables.state) !== JSON.stringify(STATE_LAYERS)) bodyOk = false; + } + check('focus-ring clears 3:1 on every bg-* and -tint in both themes, every personality and seed', worstRing >= 3, worstRing.toFixed(2)); + check('border-strong clears 3:1 on bg-primary and bg-surface in both themes', worstBorder >= 3, worstBorder.toFixed(2)); + check('desktop-app systems: body 13 or 14, nothing below 11, state tokens written', bodyOk && floor >= 11, `floor ${floor}`); + const merged = mergeDesignTokens({ state: { hover: 0.08, focus: 0.1 } }, { state: { hover: 0.12 } }); + check('state tokens layer workspace → canvas like motion', merged.state?.hover === 0.12 && merged.state?.focus === 0.1); + const c = canvasWith([]); + setVariables(c, { state: { pressed: 0.2 } }); + setVariables(c, { state: { hover: 0.05 } }); + check('set_variables merges state keys', c.variables.state?.pressed === 0.2 && c.variables.state?.hover === 0.05); + check('the state layers are Material 3\'s values', JSON.stringify(STATE_LAYERS) === JSON.stringify({ hover: 0.08, focus: 0.10, pressed: 0.10, disabledContent: 0.38, disabledContainer: 0.12 })); +} + +// ── FR-A9: the fixtures ───────────────────────────────────────────────────── +{ + const files = readdirSync('test-fixtures/viewer-baseline').filter((f) => f.endsWith('.json')).sort(); + check('four baseline fixtures are committed', JSON.stringify(files) === JSON.stringify(['archive.json', 'detail-mobile-ridgeline.json', 'detail-web-dashboard.json', 'gallery-pattern-library.json']), files.join(', ')); + for (const f of files) { + const raw = readFileSync(`test-fixtures/viewer-baseline/${f}`, 'utf-8'); + const c = JSON.parse(raw) as Canvas; + check(`${f} loads with a root and its own design system`, c.root?.type === 'document' && !!c.variables?.colors, createHash('sha256').update(raw).digest('hex').slice(0, 12)); + } +} + +// ── Structure resolution names every type ─────────────────────────────────── +{ + let msg = ''; + try { resolveStructure('pricing', 'desktop-app'); } catch (e) { msg = (e as Error).message; } + check('pricing on desktop-app refuses and names realisation "web"', /no desktop-app realisation/.test(msg) && /realisation: "web"/.test(msg), msg); + let feed = ''; + try { resolveStructure('feed', 'desktop-app'); } catch (e) { feed = (e as Error).message; } + check('feed on desktop-app refuses and names realisation "mobile"', /realisation: "mobile"/.test(feed) && !/realisation: "web"/.test(feed), feed); +} + +// ── FR-I1: the viewer's window breakpoints ────────────────────────────────── +{ + const html = await renderDetailPage(canvasWith([text('t', 'Hi', 14)], { type: 'desktop-app', device: 'window-1440' }), 0); + check('the detail page offers 1024 and 1280 window buttons plus the artboard', /id="bp-w1024"/.test(html) && /id="bp-w1280"/.test(html) && /id="bp-desktop"[^>]*>1440 !src.includes(`"${d}"`)); expect(`every device profile named in ${label}`, devices.length === 0, devices.join(', ')); + // Phase 31 — window profiles for desktop-app canvases. + const windows = Object.keys(WINDOW_PROFILES).filter((d) => !src.includes(`"${d}"`)); + expect(`every window profile named in ${label}`, windows.length === 0, windows.join(', ')); const fields = ['rulesOn', 'rulesOff', 'conflicts', 'typeSource', 'safeArea'].filter((f) => !src.includes(f)); expect(`surface fields documented in ${label}`, fields.length === 0, fields.join(', ')); } diff --git a/test-fixtures/viewer-baseline/archive.json b/test-fixtures/viewer-baseline/archive.json new file mode 100644 index 0000000..ec20260 --- /dev/null +++ b/test-fixtures/viewer-baseline/archive.json @@ -0,0 +1,639 @@ +{ + "components": {}, + "createdAt": "2026-11-08T19:07:06.482Z", + "id": "r0syKSM6YZ", + "lastModified": "2026-11-08T19:07:06.484Z", + "metadata": { + "fixtureNote": "Phase 31 FR-A9: framesmith 2.4.0 canvas_import_url of the live viewer (2026-11-08), with the repo workspace design system embedded so it resolves anywhere.", + "provenance": { + "at": "2026-11-08T19:07:06.484Z", + "importedFrom": "http://localhost:3001/archive" + } + }, + "name": "Viewer: archive", + "projectId": "viewer-baseline", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textPrimary", + "content": "Framesmith", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-1", + "letterSpacing": -0.1, + "type": "text" + } + ], + "gap": 11, + "id": "imp-frame-2", + "layout": "horizontal", + "padding": [ + 22, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "color": "$textMuted", + "content": "framesmith", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-3", + "letterSpacing": 0.9, + "textTransform": "uppercase", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-4", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Design system", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-5", + "type": "text" + }, + { + "color": "$textMuted", + "content": "1", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-6", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-7", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$info", + "id": "imp-frame-8", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "UI", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-9", + "type": "text" + }, + { + "color": "$textMuted", + "content": "5", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-10", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-11", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$danger", + "id": "imp-frame-12", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Releases", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-13", + "type": "text" + }, + { + "color": "$textMuted", + "content": "2", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-14", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-15", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$chartBar1", + "id": "imp-frame-16", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Pattern library", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-17", + "type": "text" + }, + { + "color": "$textMuted", + "content": "11", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-18", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-19", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-20", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Foundations", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-21", + "type": "text" + }, + { + "color": "$textMuted", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-22", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-23", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "rgb(236, 72, 153)", + "id": "imp-frame-24", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Mobile surface attempt", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-25", + "type": "text" + }, + { + "color": "$textMuted", + "content": "4", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-26", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-27", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "rgb(236, 72, 153)", + "id": "imp-frame-28", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Viewer baseline", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-29", + "type": "text" + }, + { + "color": "$textMuted", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-30", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-31", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-32", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-33", + "padding": [ + 4, + 8, + 0, + 8 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "d": "M21 8v13H3V8", + "fill": "$textPrimary", + "height": 16, + "id": "imp-path-34", + "type": "path", + "viewBox": "0 0 24 24", + "width": 16 + }, + { + "color": "$textPrimary", + "content": "Archive", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 600, + "id": "imp-text-35", + "type": "text" + }, + { + "color": "$chartBar2", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 600, + "id": "imp-text-36", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(245, 158, 11, 0.08)", + "gap": 10, + "id": "imp-frame-37", + "layout": "horizontal", + "padding": [ + 9, + 12 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-38", + "padding": [ + 10, + 8, + 14, + 8 + ], + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + } + ], + "fill": "$surface", + "id": "imp-frame-39", + "layout": "vertical", + "type": "frame" + }, + { + "children": [ + { + "children": [ + { + "color": "$textTertiary", + "content": "Archive", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-40", + "type": "text" + }, + { + "color": "$textPrimary", + "content": "Archived canvases", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 36, + "fontWeight": 700, + "id": "imp-text-41", + "letterSpacing": -0.6, + "lineHeight": "39.6px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "0 canvases across all projects", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-42", + "type": "text" + } + ], + "id": "imp-frame-43", + "layout": "vertical", + "padding": [ + 28, + 36, + 24, + 36 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textTertiary", + "content": "\u25a2", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 44, + "id": "imp-text-44", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "No archived canvases", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 15, + "id": "imp-text-45", + "type": "text" + }, + { + "children": [ + { + "color": "$textMuted", + "content": "Archive a canvas with or the Archive button on a canvas page.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "id": "imp-text-46", + "textAlign": "center", + "type": "text" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "canvas_archive({ canvasId })", + "fontFamily": "monospace", + "fontSize": 12, + "id": "imp-text-47", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 4, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-48", + "layout": "vertical", + "padding": [ + 2, + 6 + ], + "type": "frame" + } + ], + "id": "imp-frame-49", + "layout": "vertical", + "maxWidth": 480, + "type": "frame", + "width": "100%" + } + ], + "gap": 14, + "id": "imp-frame-50", + "justifyContent": "center", + "layout": "vertical", + "padding": [ + 120, + 32 + ], + "type": "frame", + "width": "100%" + } + ], + "fill": "rgb(13, 11, 10)", + "id": "imp-frame-51", + "layout": "vertical", + "type": "frame" + } + ], + "id": "imp-frame-52", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-53", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "fill": "#FFFFFF", + "height": 900, + "id": "9_WTszyo16", + "name": "Document", + "type": "document", + "width": 1440 + }, + "variables": { + "colors": { + "bg": "#09070a", + "border": "#2a241a", + "borderSubtle": "#1f1b13", + "chartBar1": "#f59e0b", + "chartBar2": "#fde68a", + "chartTrack": "#231d13", + "danger": "#ef4444", + "glassRim": "rgba(255, 255, 255, 0.06)", + "info": "#3b82f6", + "primary": "#f59e0b", + "primaryDeep": "#b45309", + "primarySoft": "#fde68a", + "success": "#22c55e", + "surface": "#14110c", + "surfaceElevated": "#1f1a12", + "surfaceHover": "#251f15", + "textMuted": "#4f4a3e", + "textPrimary": "#fafaf5", + "textSecondary": "#b8b3a6", + "textTertiary": "#7a7464" + }, + "radius": { + "lg": 14, + "md": 10, + "sm": 6 + }, + "spacing": { + "lg": 24, + "md": 16, + "sm": 10, + "xl": 32, + "xs": 6 + }, + "typography": { + "body": { + "fontSize": 13, + "fontWeight": 400, + "lineHeight": 1.5 + }, + "h1": { + "fontSize": 26, + "fontWeight": 700, + "lineHeight": 1.15 + }, + "h2": { + "fontSize": 15, + "fontWeight": 600, + "lineHeight": 1.4 + }, + "kpi": { + "fontSize": 28, + "fontWeight": 700, + "lineHeight": 1 + }, + "label": { + "fontSize": 11, + "fontWeight": 600, + "lineHeight": 1.3 + }, + "mono": { + "fontSize": 13, + "fontWeight": 500, + "lineHeight": 1.4 + }, + "tabular": { + "fontSize": 12, + "fontWeight": 500, + "lineHeight": 1.2 + } + } + } +} diff --git a/test-fixtures/viewer-baseline/detail-mobile-ridgeline.json b/test-fixtures/viewer-baseline/detail-mobile-ridgeline.json new file mode 100644 index 0000000..0bff863 --- /dev/null +++ b/test-fixtures/viewer-baseline/detail-mobile-ridgeline.json @@ -0,0 +1,2515 @@ +{ + "components": {}, + "createdAt": "2026-11-08T19:07:03.279Z", + "id": "-HbpiZZKrn", + "lastModified": "2026-11-08T19:07:03.282Z", + "metadata": { + "fixtureNote": "Phase 31 FR-A9: framesmith 2.4.0 canvas_import_url of the live viewer (2026-11-08), with the repo workspace design system embedded so it resolves anywhere.", + "provenance": { + "at": "2026-11-08T19:07:03.282Z", + "importedFrom": "http://localhost:3001/canvas/GbIGITEWjf" + } + }, + "name": "Viewer: detail (mobile Ridgeline)", + "projectId": "viewer-baseline", + "root": { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textTertiary", + "content": "\u2190 Back", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-1", + "type": "text" + }, + { + "color": "$textPrimary", + "content": "Ridgeline \u2014 Today (iOS)", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 15, + "fontWeight": 600, + "id": "imp-text-2", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textMuted", + "content": "390 x 844", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "id": "imp-text-3", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textPrimary", + "content": "default", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-4", + "lineHeight": "11px", + "type": "text" + } + ], + "cornerRadius": 999, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-5", + "layout": "vertical", + "padding": [ + 4, + 8 + ], + "stroke": "$chartBar1", + "strokeWidth": 1, + "type": "frame", + "width": 55 + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textTertiary", + "content": "empty", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-6", + "lineHeight": "14px", + "type": "text" + } + ], + "cornerRadius": 999, + "fill": "rgb(34, 28, 18)", + "gap": 6, + "id": "imp-frame-7", + "layout": "horizontal", + "padding": [ + 4, + 8 + ], + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": 60 + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textTertiary", + "content": "loading", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-8", + "lineHeight": "14px", + "type": "text" + } + ], + "cornerRadius": 999, + "fill": "rgb(34, 28, 18)", + "gap": 6, + "id": "imp-frame-9", + "layout": "horizontal", + "padding": [ + 4, + 8 + ], + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 4, + "id": "imp-frame-10", + "layout": "horizontal", + "type": "frame" + }, + { + "children": [ + { + "color": "$textMuted", + "content": "dashboard \u00b7 data \u00b7 split/dense", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-11", + "type": "text" + }, + { + "color": "$chartBar2", + "content": "\u25c6", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-12", + "type": "text" + } + ], + "id": "imp-frame-13", + "layout": "vertical", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Dark", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-14", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-15", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 57 + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Mobile", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-16", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-17", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Tablet", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-18", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-19", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "Desktop", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-20", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(245, 158, 11, 0.08)", + "id": "imp-frame-21", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$chartBar1", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Compare", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-22", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-23", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-24", + "layout": "horizontal", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Fit", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-25", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-26", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 45 + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "JSON", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-27", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-28", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 63 + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Comment", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-29", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-30", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-31", + "layout": "horizontal", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Archive", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-32", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-33", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Delete", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-34", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-35", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-36", + "layout": "horizontal", + "type": "frame" + }, + { + "cornerRadius": 4, + "fill": "$success", + "id": "imp-frame-37", + "type": "frame", + "width": 8 + } + ], + "fill": "$surface", + "gap": 14, + "id": "imp-frame-38", + "layout": "horizontal", + "padding": [ + 0, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "start", + "children": [ + { + "fill": "$textPrimary", + "id": "imp-frame-39", + "type": "frame" + } + ], + "fill": "$bg", + "id": "imp-frame-40", + "layout": "horizontal", + "padding": [ + 24, + 0 + ], + "type": "frame" + }, + { + "children": [ + { + "alignItems": "end", + "children": [ + { + "color": "$chartBar1", + "content": "Quality", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 600, + "id": "imp-text-41", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Design system", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-42", + "type": "text" + }, + { + "children": [ + { + "color": "$textTertiary", + "content": "Feedback", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-43", + "type": "text" + } + ], + "id": "imp-frame-44", + "layout": "vertical", + "padding": [ + 0, + 0, + 10, + 0 + ], + "type": "frame", + "width": 60 + }, + { + "color": "$textMuted", + "content": "Import", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-45", + "type": "text" + } + ], + "gap": 20, + "id": "imp-frame-46", + "layout": "horizontal", + "padding": [ + 16, + 20, + 0, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textPrimary", + "content": "99", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 40, + "fontWeight": 700, + "id": "imp-text-47", + "lineHeight": "40px", + "type": "text" + }, + { + "color": "$textMuted", + "content": "/100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-48", + "lineHeight": "14px", + "type": "text" + } + ], + "id": "imp-frame-49", + "layout": "vertical", + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Excellent", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 600, + "id": "imp-text-50", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-51", + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "id": "imp-frame-52", + "justifyContent": "space-between", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "cornerRadius": 4, + "fill": "$success", + "id": "imp-frame-53", + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-54", + "overflow": "hidden", + "type": "frame", + "width": "100%" + } + ], + "gap": 12, + "id": "imp-frame-55", + "layout": "vertical", + "padding": [ + 18, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "fill": "$border", + "id": "imp-frame-56", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textMuted", + "content": "Categories", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-57", + "letterSpacing": 1, + "textTransform": "uppercase", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "spacing", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-58", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-59", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-60", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-61", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-62", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-63", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-64", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-65", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-66", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-67", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-68", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-69", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-70", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-71", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-72", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "structure", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-73", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-74", + "type": "frame" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-75", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "90", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-76", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-77", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-78", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-79", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-80", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-81", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-82", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "usability", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-83", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-84", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-85", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-86", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-87", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "coverage", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-88", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-89", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-90", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-91", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-92", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "cliche", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-93", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-94", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-95", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-96", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-97", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "gap": 11, + "id": "imp-frame-98", + "layout": "vertical", + "padding": [ + 16, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "fill": "$border", + "id": "imp-frame-99", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textMuted", + "content": "Issues", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-100", + "letterSpacing": 1, + "textTransform": "uppercase", + "type": "text" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "18", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 700, + "id": "imp-text-101", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-102", + "layout": "vertical", + "padding": [ + 2, + 8 + ], + "type": "frame", + "width": 29 + } + ], + "id": "imp-frame-103", + "justifyContent": "space-between", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-104", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-105", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-106", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-107", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 65.9 (guideline ~75 for this size) against #CFF2DE \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-108", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-109", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-110", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-111", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-112", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-113", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-114", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 73.7 (guideline ~75 for this size) against #F1F9F4 \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-115", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-116", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-117", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-118", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-119", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-120", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-121", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 51.2 (guideline ~75 for this size) against #0F3624 in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-122", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-123", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-124", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-125", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-126", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-127", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-128", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 46.1 (guideline ~75 for this size) against #363C39 in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-129", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-130", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-131", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-132", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-133", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-134", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-135", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-136", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-137", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-138", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-139", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-140", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-141", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-142", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~60 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-143", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-144", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-145", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-146", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-147", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-148", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-149", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-150", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-151", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-152", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-153", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-154", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-155", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-156", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-157", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-158", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-159", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-160", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-161", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-162", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-163", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.3 (guideline ~75 for this size) against #363C39 in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-164", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-165", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-166", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-167", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-168", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-169", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-170", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-171", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-172", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-173", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-174", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-175", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-176", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-177", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-178", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-179", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-180", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-181", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-182", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-183", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-184", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-185", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-186", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-187", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-188", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-189", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-190", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-191", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 51.1 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-192", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-193", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-194", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-195", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-196", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-197", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-198", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-199", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-200", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-201", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-202", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-203", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-204", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-205", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-206", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-207", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-208", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-209", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-210", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-211", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-212", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 48.9 (guideline ~75 for this size) against #2C322F in the dark theme \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-213", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-214", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-215", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-216", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "structure", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-217", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-218", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 61 + } + ], + "gap": 8, + "id": "imp-frame-219", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "No component instances found in 93 nodes. Consider extracting repeated patterns into components.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-220", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Promote a repeated subtree with create_component, then stamp copies via batch_design ({ type: \"instance\", componentId, overrides }).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-221", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-222", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$chartBar1", + "id": "imp-frame-223", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "structure", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-224", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-225", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 61 + } + ], + "gap": 8, + "id": "imp-frame-226", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "This canvas looks like a phone screen (its artboard is 390px wide) but never declared its type, so it is judged as a web page.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-227", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "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.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-228", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-229", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + } + ], + "gap": 11, + "id": "imp-frame-230", + "layout": "vertical", + "padding": [ + 16, + 20 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-231", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "fill": "$surface", + "id": "imp-frame-232", + "layout": "vertical", + "type": "frame" + } + ], + "id": "imp-frame-233", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-234", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + } + ], + "fill": "#FFFFFF", + "height": 900, + "id": "RRrnVKQsey", + "name": "Document", + "type": "document", + "width": 1440 + }, + "variables": { + "colors": { + "bg": "#09070a", + "border": "#2a241a", + "borderSubtle": "#1f1b13", + "chartBar1": "#f59e0b", + "chartBar2": "#fde68a", + "chartTrack": "#231d13", + "danger": "#ef4444", + "glassRim": "rgba(255, 255, 255, 0.06)", + "info": "#3b82f6", + "primary": "#f59e0b", + "primaryDeep": "#b45309", + "primarySoft": "#fde68a", + "success": "#22c55e", + "surface": "#14110c", + "surfaceElevated": "#1f1a12", + "surfaceHover": "#251f15", + "textMuted": "#4f4a3e", + "textPrimary": "#fafaf5", + "textSecondary": "#b8b3a6", + "textTertiary": "#7a7464" + }, + "radius": { + "lg": 14, + "md": 10, + "sm": 6 + }, + "spacing": { + "lg": 24, + "md": 16, + "sm": 10, + "xl": 32, + "xs": 6 + }, + "typography": { + "body": { + "fontSize": 13, + "fontWeight": 400, + "lineHeight": 1.5 + }, + "h1": { + "fontSize": 26, + "fontWeight": 700, + "lineHeight": 1.15 + }, + "h2": { + "fontSize": 15, + "fontWeight": 600, + "lineHeight": 1.4 + }, + "kpi": { + "fontSize": 28, + "fontWeight": 700, + "lineHeight": 1 + }, + "label": { + "fontSize": 11, + "fontWeight": 600, + "lineHeight": 1.3 + }, + "mono": { + "fontSize": 13, + "fontWeight": 500, + "lineHeight": 1.4 + }, + "tabular": { + "fontSize": 12, + "fontWeight": 500, + "lineHeight": 1.2 + } + } + } +} diff --git a/test-fixtures/viewer-baseline/detail-web-dashboard.json b/test-fixtures/viewer-baseline/detail-web-dashboard.json new file mode 100644 index 0000000..47fcdda --- /dev/null +++ b/test-fixtures/viewer-baseline/detail-web-dashboard.json @@ -0,0 +1,2518 @@ +{ + "components": {}, + "createdAt": "2026-11-08T19:06:59.895Z", + "id": "ZFyUglW8LE", + "lastModified": "2026-11-08T19:06:59.898Z", + "metadata": { + "fixtureNote": "Phase 31 FR-A9: framesmith 2.4.0 canvas_import_url of the live viewer (2026-11-08), with the repo workspace design system embedded so it resolves anywhere.", + "provenance": { + "at": "2026-11-08T19:06:59.898Z", + "importedFrom": "http://localhost:3001/canvas/Y5vNp2j8aB" + } + }, + "name": "Viewer: detail (web dashboard)", + "projectId": "viewer-baseline", + "root": { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textTertiary", + "content": "\u2190 Back", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-1", + "type": "text" + }, + { + "color": "$textPrimary", + "content": "dashboard", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 15, + "fontWeight": 600, + "id": "imp-text-2", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textMuted", + "content": "1440 x 900", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "id": "imp-text-3", + "type": "text" + }, + { + "children": [ + { + "color": "$textMuted", + "content": "dashboard \u00b7 split/dense", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-4", + "type": "text" + }, + { + "color": "$chartBar2", + "content": "\u25c6", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-5", + "type": "text" + } + ], + "id": "imp-frame-6", + "layout": "vertical", + "type": "frame" + }, + { + "id": "imp-frame-7", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Mobile", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-8", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-9", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Tablet", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-10", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-11", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "Desktop", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-12", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(245, 158, 11, 0.08)", + "id": "imp-frame-13", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$chartBar1", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Compare", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-14", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-15", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-16", + "layout": "horizontal", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Fit", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-17", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-18", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 45 + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "JSON", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-19", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-20", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 63 + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Comment", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-21", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-22", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-23", + "layout": "horizontal", + "type": "frame" + }, + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textSecondary", + "content": "Archive", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-24", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-25", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Delete", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-26", + "textAlign": "center", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-27", + "layout": "vertical", + "padding": [ + 6, + 14 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "gap": 6, + "id": "imp-frame-28", + "layout": "horizontal", + "type": "frame" + }, + { + "cornerRadius": 4, + "fill": "$success", + "id": "imp-frame-29", + "type": "frame", + "width": 8 + } + ], + "fill": "$surface", + "gap": 14, + "id": "imp-frame-30", + "layout": "horizontal", + "padding": [ + 0, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "start", + "children": [ + { + "fill": "$textPrimary", + "id": "imp-frame-31", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-32", + "layout": "horizontal", + "padding": [ + 24, + 0 + ], + "type": "frame" + }, + { + "children": [ + { + "alignItems": "end", + "children": [ + { + "color": "$chartBar1", + "content": "Quality", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 600, + "id": "imp-text-33", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Design system", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-34", + "type": "text" + }, + { + "children": [ + { + "color": "$textTertiary", + "content": "Feedback", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-35", + "type": "text" + } + ], + "id": "imp-frame-36", + "layout": "vertical", + "padding": [ + 0, + 0, + 10, + 0 + ], + "type": "frame", + "width": 60 + }, + { + "color": "$textMuted", + "content": "Import", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-37", + "type": "text" + } + ], + "gap": 20, + "id": "imp-frame-38", + "layout": "horizontal", + "padding": [ + 16, + 20, + 0, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "children": [ + { + "color": "$textPrimary", + "content": "95", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 40, + "fontWeight": 700, + "id": "imp-text-39", + "lineHeight": "40px", + "type": "text" + }, + { + "color": "$textMuted", + "content": "/100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 500, + "id": "imp-text-40", + "lineHeight": "14px", + "type": "text" + } + ], + "id": "imp-frame-41", + "layout": "vertical", + "type": "frame" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "Excellent", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 600, + "id": "imp-text-42", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-43", + "layout": "vertical", + "padding": [ + 5, + 10 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame" + } + ], + "id": "imp-frame-44", + "justifyContent": "space-between", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "cornerRadius": 4, + "fill": "$success", + "id": "imp-frame-45", + "type": "frame" + } + ], + "cornerRadius": 4, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-46", + "overflow": "hidden", + "type": "frame", + "width": "100%" + } + ], + "gap": 12, + "id": "imp-frame-47", + "layout": "vertical", + "padding": [ + 18, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "fill": "$border", + "id": "imp-frame-48", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textMuted", + "content": "Categories", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-49", + "letterSpacing": 1, + "textTransform": "uppercase", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "spacing", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-50", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-51", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-52", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-53", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-54", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-55", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-56", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-57", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-58", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-59", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-60", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-61", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-62", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-63", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-64", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "structure", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-65", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-66", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-67", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-68", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-69", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-70", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-71", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-72", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-73", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-74", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "usability", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-75", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-76", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-77", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-78", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-79", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "coverage", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-80", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$danger", + "id": "imp-frame-81", + "type": "frame", + "width": 56 + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-82", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$danger", + "content": "30", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-83", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-84", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "color": "$textSecondary", + "content": "cliche", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-85", + "type": "text" + }, + { + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-86", + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 3, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-87", + "overflow": "hidden", + "type": "frame" + }, + { + "color": "$textSecondary", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "id": "imp-text-88", + "textAlign": "right", + "type": "text" + } + ], + "gap": 10, + "id": "imp-frame-89", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "gap": 11, + "id": "imp-frame-90", + "layout": "vertical", + "padding": [ + 16, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "fill": "$border", + "id": "imp-frame-91", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textMuted", + "content": "Issues", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-92", + "letterSpacing": 1, + "textTransform": "uppercase", + "type": "text" + }, + { + "children": [ + { + "color": "$textSecondary", + "content": "18", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 700, + "id": "imp-text-93", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-94", + "layout": "vertical", + "padding": [ + 2, + 8 + ], + "type": "frame", + "width": 29 + } + ], + "id": "imp-frame-95", + "justifyContent": "space-between", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-96", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "color", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-97", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-98", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 40 + } + ], + "gap": 8, + "id": "imp-frame-99", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "APCA advisory: Lc 40 (guideline ~75 for this size) against #3b82f6 \u2014 passes WCAG 2.2 (the gate), but perceptual contrast is weak.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-100", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Consider a step more contrast. APCA is a candidate method, not a standard \u2014 this never blocks.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-101", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-102", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-103", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-104", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-105", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-106", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-107", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Display text at 28px with default tracking \u2014 large sizes read tighter with slight negative letter-spacing.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-108", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "letterSpacing: -0.5 (or declare it on the typography token so the whole scale carries it).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-109", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-110", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-111", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-112", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-113", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-114", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-115", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Display text at 28px with default tracking \u2014 large sizes read tighter with slight negative letter-spacing.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-116", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "letterSpacing: -0.5 (or declare it on the typography token so the whole scale carries it).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-117", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-118", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-119", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-120", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-121", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-122", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-123", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Display text at 28px with default tracking \u2014 large sizes read tighter with slight negative letter-spacing.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-124", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "letterSpacing: -0.5 (or declare it on the typography token so the whole scale carries it).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-125", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-126", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-127", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "typography", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-128", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-129", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-130", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-131", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Display text at 28px with default tracking \u2014 large sizes read tighter with slight negative letter-spacing.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-132", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "letterSpacing: -0.5 (or declare it on the typography token so the whole scale carries it).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-133", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-134", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-135", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "structure", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-136", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-137", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 61 + } + ], + "gap": 8, + "id": "imp-frame-138", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "No component instances found in 43 nodes. Consider extracting repeated patterns into components.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-139", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Promote a repeated subtree with create_component, then stamp copies via batch_design ({ type: \"instance\", componentId, overrides }).", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-140", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-141", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-142", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-143", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-144", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-145", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-146", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 8 equals the $md token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-147", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$md\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-148", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-149", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-150", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-151", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-152", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-153", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-154", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 8 equals the $md token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-155", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$md\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-156", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-157", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-158", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-159", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-160", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-161", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-162", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 8 equals the $md token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-163", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$md\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-164", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-165", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-166", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-167", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-168", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-169", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-170", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 8 equals the $md token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-171", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$md\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-172", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-173", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-174", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-175", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-176", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-177", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-178", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 8 equals the $md token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-179", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$md\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-180", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-181", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-182", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-183", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-184", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-185", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-186", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 16 equals the $lg token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-187", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$lg\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-188", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-189", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-190", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-191", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-192", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-193", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-194", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 16 equals the $lg token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-195", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$lg\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-196", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-197", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-198", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-199", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-200", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-201", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-202", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 16 equals the $lg token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-203", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$lg\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-204", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-205", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-206", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-207", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-208", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-209", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-210", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 16 equals the $lg token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-211", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$lg\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-212", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-213", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$info", + "id": "imp-frame-214", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "consistency", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-215", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-216", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame" + }, + { + "color": "$success", + "content": "\u26a1 auto-fixable", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-217", + "type": "text" + } + ], + "gap": 8, + "id": "imp-frame-218", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "Literal cornerRadius: 16 equals the $lg token \u2014 detached literals drift when the token changes.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-219", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "Reference the token: cornerRadius: \"$lg\".", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-220", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-221", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$chartBar1", + "id": "imp-frame-222", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "coverage", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-223", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-224", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 60 + } + ], + "gap": 8, + "id": "imp-frame-225", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "This screen has a data table but no \"empty\" state variant is designed \u2014 the shipped UI will improvise it.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-226", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "canvas_add_variant with state \"empty\", delete the data rows, stamp the empty-state scaffold where they were.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-227", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-228", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 4, + "fill": "$chartBar1", + "id": "imp-frame-229", + "type": "frame", + "width": 7 + }, + { + "children": [ + { + "color": "$chartBar2", + "content": "coverage", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-230", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgb(34, 28, 18)", + "id": "imp-frame-231", + "layout": "vertical", + "padding": [ + 2, + 7 + ], + "type": "frame", + "width": 60 + } + ], + "gap": 8, + "id": "imp-frame-232", + "layout": "horizontal", + "type": "frame", + "width": "100%" + }, + { + "color": "$textPrimary", + "content": "This screen has a data table but no \"loading\" state variant is designed \u2014 the shipped UI will improvise it.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-233", + "lineHeight": "17.4px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "canvas_add_variant with state \"loading\", replace the data rows with the skeleton-table (or skeleton-card) scaffold.", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "id": "imp-text-234", + "lineHeight": "15.95px", + "type": "text" + } + ], + "cornerRadius": 10, + "fill": "rgb(26, 22, 15)", + "gap": 6, + "id": "imp-frame-235", + "layout": "vertical", + "padding": 12, + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + } + ], + "gap": 11, + "id": "imp-frame-236", + "layout": "vertical", + "padding": [ + 16, + 20 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-237", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "fill": "$surface", + "id": "imp-frame-238", + "layout": "vertical", + "type": "frame" + } + ], + "id": "imp-frame-239", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-240", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + } + ], + "fill": "#FFFFFF", + "height": 900, + "id": "NM5DG3jgLe", + "name": "Document", + "type": "document", + "width": 1440 + }, + "variables": { + "colors": { + "bg": "#09070a", + "border": "#2a241a", + "borderSubtle": "#1f1b13", + "chartBar1": "#f59e0b", + "chartBar2": "#fde68a", + "chartTrack": "#231d13", + "danger": "#ef4444", + "glassRim": "rgba(255, 255, 255, 0.06)", + "info": "#3b82f6", + "primary": "#f59e0b", + "primaryDeep": "#b45309", + "primarySoft": "#fde68a", + "success": "#22c55e", + "surface": "#14110c", + "surfaceElevated": "#1f1a12", + "surfaceHover": "#251f15", + "textMuted": "#4f4a3e", + "textPrimary": "#fafaf5", + "textSecondary": "#b8b3a6", + "textTertiary": "#7a7464" + }, + "radius": { + "lg": 14, + "md": 10, + "sm": 6 + }, + "spacing": { + "lg": 24, + "md": 16, + "sm": 10, + "xl": 32, + "xs": 6 + }, + "typography": { + "body": { + "fontSize": 13, + "fontWeight": 400, + "lineHeight": 1.5 + }, + "h1": { + "fontSize": 26, + "fontWeight": 700, + "lineHeight": 1.15 + }, + "h2": { + "fontSize": 15, + "fontWeight": 600, + "lineHeight": 1.4 + }, + "kpi": { + "fontSize": 28, + "fontWeight": 700, + "lineHeight": 1 + }, + "label": { + "fontSize": 11, + "fontWeight": 600, + "lineHeight": 1.3 + }, + "mono": { + "fontSize": 13, + "fontWeight": 500, + "lineHeight": 1.4 + }, + "tabular": { + "fontSize": 12, + "fontWeight": 500, + "lineHeight": 1.2 + } + } + } +} diff --git a/test-fixtures/viewer-baseline/gallery-pattern-library.json b/test-fixtures/viewer-baseline/gallery-pattern-library.json new file mode 100644 index 0000000..5226cdd --- /dev/null +++ b/test-fixtures/viewer-baseline/gallery-pattern-library.json @@ -0,0 +1,1569 @@ +{ + "components": {}, + "createdAt": "2026-11-08T19:06:56.839Z", + "id": "RZctjRj19o", + "lastModified": "2026-11-08T19:06:56.843Z", + "metadata": { + "fixtureNote": "Phase 31 FR-A9: framesmith 2.4.0 canvas_import_url of the live viewer (2026-11-08), with the repo workspace design system embedded so it resolves anywhere.", + "provenance": { + "at": "2026-11-08T19:06:56.843Z", + "importedFrom": "http://localhost:3001/project/QTrOgC44pt" + } + }, + "name": "Viewer: gallery (Pattern library)", + "projectId": "viewer-baseline", + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "color": "$textPrimary", + "content": "Framesmith", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-1", + "letterSpacing": -0.1, + "type": "text" + } + ], + "gap": 11, + "id": "imp-frame-2", + "layout": "horizontal", + "padding": [ + 22, + 20 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "color": "$textMuted", + "content": "framesmith", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "id": "imp-text-3", + "letterSpacing": 0.9, + "textTransform": "uppercase", + "type": "text" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-4", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Design system", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-5", + "type": "text" + }, + { + "color": "$textMuted", + "content": "1", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-6", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-7", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$info", + "id": "imp-frame-8", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "UI", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-9", + "type": "text" + }, + { + "color": "$textMuted", + "content": "5", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-10", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-11", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$danger", + "id": "imp-frame-12", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Releases", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-13", + "type": "text" + }, + { + "color": "$textMuted", + "content": "2", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-14", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-15", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 2, + "fill": "$chartBar1", + "id": "imp-frame-16", + "type": "frame", + "width": 2 + }, + { + "cornerRadius": 3, + "fill": "$chartBar1", + "id": "imp-frame-17", + "type": "frame", + "width": 6 + }, + { + "color": "$textPrimary", + "content": "Pattern library", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 600, + "id": "imp-text-18", + "type": "text" + }, + { + "color": "$chartBar2", + "content": "11", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 600, + "id": "imp-text-19", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(245, 158, 11, 0.08)", + "gap": 10, + "id": "imp-frame-20", + "layout": "horizontal", + "padding": [ + 8, + 12, + 8, + 10 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "$success", + "id": "imp-frame-21", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Foundations", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-22", + "type": "text" + }, + { + "color": "$textMuted", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-23", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-24", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "rgb(236, 72, 153)", + "id": "imp-frame-25", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Mobile surface attempt", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-26", + "type": "text" + }, + { + "color": "$textMuted", + "content": "4", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-27", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-28", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + }, + { + "alignItems": "center", + "children": [ + { + "cornerRadius": 3, + "fill": "rgb(236, 72, 153)", + "id": "imp-frame-29", + "type": "frame", + "width": 6 + }, + { + "color": "$textSecondary", + "content": "Viewer baseline", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-30", + "type": "text" + }, + { + "color": "$textMuted", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-31", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-32", + "layout": "horizontal", + "padding": [ + 8, + 12 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-33", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-34", + "padding": [ + 4, + 8, + 0, + 8 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "alignItems": "center", + "children": [ + { + "d": "M21 8v13H3V8", + "fill": "$textSecondary", + "height": 16, + "id": "imp-path-35", + "type": "path", + "viewBox": "0 0 24 24", + "width": 16 + }, + { + "color": "$textSecondary", + "content": "Archive", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-36", + "type": "text" + }, + { + "color": "$textMuted", + "content": "0", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "id": "imp-text-37", + "type": "text" + } + ], + "cornerRadius": 6, + "gap": 10, + "id": "imp-frame-38", + "layout": "horizontal", + "padding": [ + 9, + 12 + ], + "type": "frame", + "width": "100%" + } + ], + "id": "imp-frame-39", + "padding": [ + 10, + 8, + 14, + 8 + ], + "stroke": "$borderSubtle", + "strokeWidth": 1, + "type": "frame", + "width": "100%" + } + ], + "fill": "$surface", + "id": "imp-frame-40", + "layout": "vertical", + "type": "frame" + }, + { + "children": [ + { + "children": [ + { + "color": "$textTertiary", + "content": "framesmith / Pattern library", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-41", + "type": "text" + }, + { + "color": "$textPrimary", + "content": "Pattern library", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 36, + "fontWeight": 700, + "id": "imp-text-42", + "letterSpacing": -0.6, + "lineHeight": "39.6px", + "type": "text" + }, + { + "color": "$textTertiary", + "content": "11 canvases", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 500, + "id": "imp-text-43", + "type": "text" + } + ], + "id": "imp-frame-44", + "layout": "vertical", + "padding": [ + 28, + 36, + 24, + 36 + ], + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-45", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-46", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-47", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-48", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "auth", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-49", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-50", + "type": "text" + } + ], + "id": "imp-frame-51", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-52", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-53", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-54", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-55", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-56", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "bento-grid", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-57", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-58", + "type": "text" + } + ], + "id": "imp-frame-59", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-60", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-61", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-62", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-63", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-64", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "catalogue", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-65", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-66", + "type": "text" + } + ], + "id": "imp-frame-67", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-68", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "95", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-69", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-70", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 34 + }, + { + "id": "imp-frame-71", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-72", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "dashboard", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-73", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-74", + "type": "text" + } + ], + "id": "imp-frame-75", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-76", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + } + ], + "gap": 20, + "id": "imp-gridrow-77", + "layout": "horizontal", + "name": "Grid row", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-78", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-79", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-80", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-81", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "editorial-longform", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-82", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-83", + "type": "text" + } + ], + "id": "imp-frame-84", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-85", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-86", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-87", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-88", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-89", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "marquee-hero", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-90", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-91", + "type": "text" + } + ], + "id": "imp-frame-92", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-93", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-94", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-95", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-96", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-97", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "onboarding", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-98", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-99", + "type": "text" + } + ], + "id": "imp-frame-100", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-101", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-102", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-103", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-104", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-105", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "pricing", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-106", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-107", + "type": "text" + } + ], + "id": "imp-frame-108", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-109", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + } + ], + "gap": 20, + "id": "imp-gridrow-110", + "layout": "horizontal", + "name": "Grid row", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "98", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-111", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-112", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 34 + }, + { + "id": "imp-frame-113", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-114", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "settings", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-115", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-116", + "type": "text" + } + ], + "id": "imp-frame-117", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-118", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-119", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-120", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-121", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-122", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "split-workbench", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-123", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-124", + "type": "text" + } + ], + "id": "imp-frame-125", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-126", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "color": "$success", + "content": "100", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "id": "imp-text-127", + "lineHeight": "12px", + "type": "text" + } + ], + "cornerRadius": 6, + "fill": "rgba(9, 7, 10, 0.78)", + "id": "imp-frame-128", + "layout": "vertical", + "padding": [ + 3, + 8 + ], + "stroke": "$border", + "strokeWidth": 1, + "type": "frame", + "width": 42 + }, + { + "id": "imp-frame-129", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-130", + "layout": "vertical", + "overflow": "hidden", + "type": "frame", + "width": "100%" + }, + { + "children": [ + { + "color": "$textPrimary", + "content": "stat-led", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 600, + "id": "imp-text-131", + "letterSpacing": -0.1, + "type": "text" + }, + { + "color": "$textTertiary", + "content": "1440 x 900 \u00b7 6/26/2026, 6:47:09 PM", + "fontFamily": "-apple-system, \"system-ui\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 500, + "id": "imp-text-132", + "type": "text" + } + ], + "id": "imp-frame-133", + "layout": "vertical", + "padding": [ + 14, + 16, + 16, + 16 + ], + "type": "frame", + "width": "100%" + } + ], + "cornerRadius": 12, + "fill": "rgb(26, 22, 15)", + "id": "imp-frame-134", + "layout": "vertical", + "overflow": "hidden", + "shadows": "rgba(255, 255, 255, 0.04) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.4) 0px 4px 12px -2px", + "type": "frame", + "width": "23.7%" + } + ], + "gap": 20, + "id": "imp-gridrow-135", + "layout": "horizontal", + "name": "Grid row", + "type": "frame", + "width": "100%" + } + ], + "gap": 20, + "id": "imp-grid-136", + "layout": "vertical", + "name": "Grid", + "padding": [ + 28, + 36, + 36, + 36 + ], + "type": "frame", + "width": "100%" + } + ], + "fill": "rgb(13, 11, 10)", + "id": "imp-frame-137", + "layout": "vertical", + "type": "frame" + } + ], + "id": "imp-frame-138", + "layout": "horizontal", + "type": "frame", + "width": "100%" + } + ], + "fill": "$bg", + "id": "imp-frame-139", + "layout": "vertical", + "type": "frame", + "width": "100%" + } + ], + "fill": "#FFFFFF", + "height": 938, + "id": "RqAFboSaQY", + "name": "Document", + "type": "document", + "width": 1440 + }, + "variables": { + "colors": { + "bg": "#09070a", + "border": "#2a241a", + "borderSubtle": "#1f1b13", + "chartBar1": "#f59e0b", + "chartBar2": "#fde68a", + "chartTrack": "#231d13", + "danger": "#ef4444", + "glassRim": "rgba(255, 255, 255, 0.06)", + "info": "#3b82f6", + "primary": "#f59e0b", + "primaryDeep": "#b45309", + "primarySoft": "#fde68a", + "success": "#22c55e", + "surface": "#14110c", + "surfaceElevated": "#1f1a12", + "surfaceHover": "#251f15", + "textMuted": "#4f4a3e", + "textPrimary": "#fafaf5", + "textSecondary": "#b8b3a6", + "textTertiary": "#7a7464" + }, + "radius": { + "lg": 14, + "md": 10, + "sm": 6 + }, + "spacing": { + "lg": 24, + "md": 16, + "sm": 10, + "xl": 32, + "xs": 6 + }, + "typography": { + "body": { + "fontSize": 13, + "fontWeight": 400, + "lineHeight": 1.5 + }, + "h1": { + "fontSize": 26, + "fontWeight": 700, + "lineHeight": 1.15 + }, + "h2": { + "fontSize": 15, + "fontWeight": 600, + "lineHeight": 1.4 + }, + "kpi": { + "fontSize": 28, + "fontWeight": 700, + "lineHeight": 1 + }, + "label": { + "fontSize": 11, + "fontWeight": 600, + "lineHeight": 1.3 + }, + "mono": { + "fontSize": 13, + "fontWeight": 500, + "lineHeight": 1.4 + }, + "tabular": { + "fontSize": 12, + "fontWeight": 500, + "lineHeight": 1.2 + } + } + } +} diff --git a/test-pointer-targets.ts b/test-pointer-targets.ts new file mode 100644 index 0000000..1045f5d --- /dev/null +++ b/test-pointer-targets.ts @@ -0,0 +1,80 @@ +// Phase 31 FR-A4 — the WCAG 2.5.8 spacing exception in detailed mode. A target +// below 24px passes when a 24px circle centred on it reaches no other target's +// box and no other undersized target's circle; touching passes, nested and +// disabled targets never count. Needs Chrome (detailed mode lays out). +// +// Usage: npx tsx test-pointer-targets.ts + +import './test-env.js'; +import { evaluateCanvas } from './src/evaluate.js'; +import { shutdown } from './src/screenshot.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}` : ''}`); +} + +const btn = (id: string, size: number, extra: Partial = {}): SceneNode => + ({ id, name: id, type: 'button', icon: 'x', size: 'sm', width: size, height: size, ...extra }); +const row = (gap: number, children: SceneNode[]): SceneNode => + ({ id: 'row', name: 'row', type: 'frame', layout: 'horizontal', alignItems: 'center', gap, children }); + +function canvasOf(type: 'web' | 'desktop-app', children: SceneNode[]): Canvas { + return { + id: 'c', name: 'c', projectId: 'p', components: {}, variables: {}, createdAt: '', lastModified: '', + root: { id: 'document', type: 'document', name: 'Document', width: 1440, height: 900, fill: '#FFFFFF', layout: 'vertical', padding: 40, children }, + metadata: { surface: type === 'web' ? { type } : { type, device: 'window-1440' } }, + } as Canvas; +} + +async function targetFindings(type: 'web' | 'desktop-app', children: SceneNode[]) { + const r = await evaluateCanvas(canvasOf(type, children), { mode: 'detailed', categories: ['usability'] }); + return r.issues.filter((i) => /24px floor/.test(i.message)); +} + +for (const type of ['web', 'desktop-app'] as const) { + // Centres 24px apart: the two circles touch, and each circle stops 2px short + // of the neighbour's box. Passes. + let f = await targetFindings(type, [row(4, [btn('a', 20), btn('b', 20)])]); + check(`${type}: two 20px buttons with centres 24px apart pass`, f.length === 0, JSON.stringify(f.map((i) => i.message))); + + f = await targetFindings(type, [row(0, [btn('a', 20), btn('b', 20)])]); + check(`${type}: the same buttons touching fail, as errors naming the neighbour`, f.length === 2 && f.every((i) => i.severity === 'error' && /24px circle/.test(i.message) && /reaches/.test(i.message)), JSON.stringify(f.map((i) => [i.severity, i.message]))); + + // A 20px button 2px from a 32px one: its circle reaches 12px, the box is 12 + // away — touching, so it passes. At 1px the circle reaches into the box. + f = await targetFindings(type, [row(2, [btn('a', 20), btn('big', 32)])]); + check(`${type}: a circle that only touches a neighbour's box passes`, f.length === 0, JSON.stringify(f.map((i) => i.message))); + f = await targetFindings(type, [row(1, [btn('a', 20), btn('big', 32)])]); + check(`${type}: a circle reaching into a neighbour's box fails`, f.length === 1 && f[0].nodeId === 'a' && f[0].severity === 'error', JSON.stringify(f.map((i) => i.message))); + + f = await targetFindings(type, [row(0, [btn('a', 20), btn('b', 20, { disabled: true })])]); + check(`${type}: a disabled neighbour is not in the way`, f.length === 0, JSON.stringify(f.map((i) => i.message))); + + f = await targetFindings(type, [row(40, [btn('a', 20), btn('b', 20)])]); + check(`${type}: well-spaced small targets pass`, f.length === 0); + + // Boxes 13px apart but centres 23px apart: only the circle-vs-circle test + // catches it (each circle reaches 12, the other box is 13 away). + f = await targetFindings(type, [row(3, [btn('a', 20), btn('b', 20)])]); + check(`${type}: two undersized circles that overlap fail even when neither reaches the other box`, f.length === 2 && f.every((i) => i.severity === 'error'), JSON.stringify(f.map((i) => i.message))); + + // A tab-bar narrower than 24px a tab: every tab shares the bar's node id, so + // detailed mode cannot place one tab — the tabs stay blocking warnings. + f = await targetFindings(type, [{ id: 'tb', name: 'tb', type: 'tab-bar', width: 60, position: 'relative', items: ['A', 'B', 'C', 'D', 'E'].map((l) => ({ icon: 'home', label: l })) } as SceneNode]); + check(`${type}: a too-narrow tab-bar's tabs stay blocking in detailed mode`, f.length === 5 && f.every((i) => i.severity === 'warning' && /could not place/.test(i.message)), JSON.stringify(f.map((i) => [i.severity, i.message.slice(0, 90)]))); + + // A neighbour nested past the layout walker's depth cannot be placed, so the + // spacing exception is unproven and the small target stays blocking. + let deep: SceneNode = btn('deep', 32); + for (let d = 0; d < 28; d++) deep = { id: `w${d}`, name: `w${d}`, type: 'frame', children: [deep] }; + f = await targetFindings(type, [row(0, [btn('a', 20), deep])]); + const a = f.find((i) => i.nodeId === 'a'); + check(`${type}: a neighbour the layout cannot place keeps the small target blocking`, !!a && a.severity === 'warning' && /could not place/.test(a.message), JSON.stringify(f.map((i) => [i.nodeId, i.severity, i.message]))); +} + +await shutdown(); +console.log(allPass ? '\nALL PASS' : '\nSOME FAILED'); +process.exit(allPass ? 0 : 1); diff --git a/test-structures.ts b/test-structures.ts index 0cd301b..ae5d1e1 100644 --- a/test-structures.ts +++ b/test-structures.ts @@ -112,7 +112,7 @@ for (const anatomy of ['dashboard', 'settings', 'auth', 'onboarding', 'feed', 'd } check(JSON.stringify(byName.get('dashboard')?.surfaces) === '["web","mobile"]', 'dashboard is realised on web and mobile'); check(JSON.stringify(byName.get('feed-mobile')?.surfaces) === '["mobile"]', 'feed is realised on mobile only'); -check(JSON.stringify(byName.get('empty-state')?.surfaces) === '["web","mobile"]' && byName.get('empty-state')?.surface === 'any', 'an any component counts for both types'); +check(JSON.stringify(byName.get('empty-state')?.surfaces) === '["web","mobile","desktop-app"]' && byName.get('empty-state')?.surface === 'any', 'an any component counts for every type'); const ANY = ['form-field', 'stat-card', 'empty-state', 'skeleton-card', 'skeleton-stat-card', 'kpi-card', 'status-chip', 'initials-avatar']; check(JSON.stringify(all.filter((s) => s.surface === 'any').map((s) => s.name).sort()) === JSON.stringify([...ANY].sort()), 'exactly the eight documented components stamp on every type'); diff --git a/test-surface.ts b/test-surface.ts index 1af84f1..e271bd5 100644 --- a/test-surface.ts +++ b/test-surface.ts @@ -180,7 +180,7 @@ function rootStyle(canvas: Canvas): string { 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'); // 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)); + check('mobile rulesOn lists every mobile rule id plus the base artboard rules', JSON.stringify(ev.surface.rulesOn) === JSON.stringify([...MOBILE_RULES, 'mobile-artboard', 'desktop-app-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' }); @@ -190,7 +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)); + check('web rulesOn is only the base artboard rules', JSON.stringify(evPlain.surface.rulesOn) === JSON.stringify(['desktop-app-artboard', '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 }; diff --git a/test-usability.ts b/test-usability.ts index d2fcecc..468ce65 100644 --- a/test-usability.ts +++ b/test-usability.ts @@ -21,8 +21,8 @@ function check(name: string, cond: boolean, extra?: string) { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); } -async function usability(c: Canvas) { - const r = await evaluateCanvas(c, { mode: 'fast', categories: ['usability'] }); +async function usability(c: Canvas, mode: 'fast' | 'detailed' = 'fast') { + const r = await evaluateCanvas(c, { mode, categories: ['usability'] }); return r.issues; } @@ -36,7 +36,11 @@ I(row, { type: "text", content: "Enable" }) I(row, { type: "checkbox", width: 20, height: 20, checked: true }) `, small); const i1 = await usability(small); - check('20px checkbox in an unpadded row → one error', i1.length === 1 && i1[0].severity === 'error' && i1[0].message.includes('24px floor'), JSON.stringify(i1.map((x) => x.message))); + // Phase 31 FR-A4 — fast mode cannot measure spacing, so it warns and names + // detailed mode; detailed mode applies the WCAG 2.5.8 spacing exception. + check('20px checkbox in an unpadded row → one fast-mode warning naming detailed mode', i1.length === 1 && i1[0].severity === 'warning' && i1[0].message.includes('24px floor') && /detailed/.test(i1[0].suggestion ?? ''), JSON.stringify(i1.map((x) => x.message))); + const i1d = await usability(small, 'detailed'); + check('…and in detailed mode it passes by the spacing exception when its label is outside its 24px circle', i1d.length === 0 || i1d.every((x) => !x.message.includes('24px floor')), JSON.stringify(i1d.map((x) => x.message))); const padded = createCanvas('padded'); parseAndExecute(padded.root, ` @@ -65,7 +69,9 @@ I(row, { type: "checkbox", width: 18, height: 18 }) I(row, { type: "checkbox", width: 18, height: 18 }) `, two); const i4 = await usability(two); - check('two small controls sharing a row → both error', i4.filter((x) => x.severity === 'error').length === 2); + check('two small controls sharing a row → both warn in fast mode', i4.filter((x) => x.severity === 'warning' && x.message.includes('24px floor')).length === 2, JSON.stringify(i4.map((x) => `${x.severity}: ${x.message.slice(0, 80)}`))); + const i4d = await usability(two, 'detailed'); + check('…and both error in detailed mode (touching 18px boxes: each circle reaches the other)', i4d.filter((x) => x.severity === 'error' && x.message.includes('24px circle')).length === 2, JSON.stringify(i4d.map((x) => `${x.severity}: ${x.message.slice(0, 80)}`))); // touch-first advisory on a phone-width artboard const phone = createCanvas('phone');