diff --git a/README.md b/README.md index af4adb3..f1278d0 100644 --- a/README.md +++ b/README.md @@ -306,9 +306,9 @@ R("nodeId", { type: "text", content: "Replaced" }) **Returns** `{ ok, nodeIds, results }`. `nodeIds` maps each bound variable to the node ID it created — e.g. `{ "header": "n_a1b2" }` — so you can target those nodes in later calls (bindings only live within a single call). `results` lists each op's outcome in order; an op that succeeded but will not fully take effect carries a `warning` (a `shadow` beside an active `shadows`, a chart `highlight` entry outside the series data). If the call wrote a `fontFamily` nothing can serve yet (not cached, registered, or system/generic), a `Font warnings` content item names it — a cache-only check with no network call, so it's a heads-up, not exhaustive. -**Node types:** `frame`, `text`, `rectangle`, `ellipse`, `image`, `icon`, `path`, `component`, `instance`, `toggle`, `checkbox`, `radio`, `select`, `chart`, `skeleton` (loading-placeholder block — token-derived neutral fill; pulses subtly in the live viewer, always static in screenshots/exports so diffs stay deterministic; `pulse: false` opts a block out) +**Node types:** `frame`, `text`, `rectangle`, `ellipse`, `image`, `icon`, `path`, `component`, `instance`, `toggle`, `checkbox`, `radio`, `select`, `chart`, `skeleton` (loading-placeholder block — token-derived neutral fill; pulses subtly in the live viewer, always static in screenshots/exports so diffs stay deterministic; `pulse: false` opts a block out), and the mobile primitives `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row`, `sheet` (see [Mobile primitives](#mobile-primitives)) -**Properties:** `fill`, `gradient`, `stroke`, `strokeWidth`, `strokeStyle`, `borderTop`, `borderRight`, `borderBottom`, `borderLeft`, `cornerRadius`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `layout` (`"horizontal"` | `"vertical"` | `"grid"`), `gap`, `rowGap`, `gridColumns`, `gridColumn`, `gridRow`, `padding`, `alignItems`, `justifyContent`, `fontSize`, `fontFamily`, `fontWeight`, `color`, `content`, `textAlign`, `lineHeight`, `letterSpacing` (px), `textDecoration`, `textTransform`, `textOverflow` (`"ellipsis"` — designed single-line truncation; `canvas_stress` reports clips behind it as info), `tabularNums`, `fontVariationSettings`, `src`, `objectFit`, `opacity`, `shadow` (CSS string, or `"$elevation."` referencing an elevation token), `shadows`, `blur`, `backdropBlur`, `backdropFilter`, `overflow`, `wrap`, `position`, `x`, `y`, `icon`, `iconSize`, `iconColor`, `iconStyle`, `checked`, `disabled`, `value`, `d`, `viewBox`, `strokeLinecap`, `strokeLinejoin`, `strokeDasharray`, `animation`, `transition` (object, or `"$motion."` referencing a motion token), `kind`, `series`, `segments`, `innerRatio`, `centerValue`, `centerLabel`, `sparkKind`, `xDomain`, `yDomain`, `curve`, `gridlines`, `xLabels`, `yLabels`, `componentId`, `overrides`, `safeArea` (`{ top, bottom }`, document root only — insets the renderer adds to the root's top and bottom padding; `canvas_create` with `type: "mobile"` writes the device's), `name` (a readable label — `find_nodes` and component overrides match on it), `pulse` (skeleton only), `responsive` (`"stack"` | `"wrap"`) +**Properties:** `fill`, `gradient`, `stroke`, `strokeWidth`, `strokeStyle`, `borderTop`, `borderRight`, `borderBottom`, `borderLeft`, `cornerRadius`, `width`, `height`, `minWidth`, `minHeight`, `maxWidth`, `layout` (`"horizontal"` | `"vertical"` | `"grid"`), `gap`, `rowGap`, `gridColumns`, `gridColumn`, `gridRow`, `padding`, `alignItems`, `justifyContent`, `fontSize`, `fontFamily`, `fontWeight`, `color`, `content`, `textAlign`, `lineHeight`, `letterSpacing` (px), `textDecoration`, `textTransform`, `textOverflow` (`"ellipsis"` — designed single-line truncation; `canvas_stress` reports clips behind it as info), `tabularNums`, `fontVariationSettings`, `src`, `objectFit`, `opacity`, `shadow` (CSS string, or `"$elevation."` referencing an elevation token), `shadows`, `blur`, `backdropBlur`, `backdropFilter`, `overflow`, `wrap`, `position` (`"absolute"` | `"relative"` | `"fixed"` | `"sticky"`), `x`, `y`, `bottom`, `right`, `icon`, `iconSize`, `iconColor`, `iconStyle`, `checked`, `disabled`, `value`, `d`, `viewBox`, `strokeLinecap`, `strokeLinejoin`, `strokeDasharray`, `animation`, `transition` (object, or `"$motion."` referencing a motion token), `kind`, `series`, `segments`, `innerRatio`, `centerValue`, `centerLabel`, `sparkKind`, `xDomain`, `yDomain`, `curve`, `gridlines`, `xLabels`, `yLabels`, `componentId`, `overrides`, `safeArea` (`{ top, bottom }`, document root only — insets the renderer adds to the root's top and bottom padding; `canvas_create` with `type: "mobile"` writes the device's), `name` (a readable label — `find_nodes` and component overrides match on it), `pulse` (skeleton only), `responsive` (`"stack"` | `"wrap"`) Chart `series` entries take their own keys: `data`, `stroke`, `strokeWidth`, `strokeDasharray`, `area`, `points`, `highlight`, `barGradient`. @@ -346,6 +346,36 @@ Borders: `stroke` + `strokeWidth` draw all four sides (`strokeStyle: "dashed" | Grid: `layout: "grid"` + `gridColumns` (a count → equal columns; an array of fr weights / CSS lengths like `[2, 1, "240px"]`; or a raw template string) gives real CSS grid for bento/editorial compositions that flex can only approximate. Children place with `gridColumn` / `gridRow` — a number means "span N", strings accept `"span 2"` or `"1 / 3"`. `gap` covers both axes, `rowGap` overrides the row axis; `responsive: "stack"` collapses the grid to one column on mobile with spans reset. Tracks render as `minmax(0, Nfr)` so long content can't blow a column past its share. Values outside the supported forms never reach the DOM. A `gridColumns` that isn't a count, an array of numbers / CSS lengths (`px`, `fr`, `%`, `em`, `rem`, `auto`, `min-content`, `max-content`, `minmax(…)`) or a plain template string falls back to equal columns. A `gridColumn` / `gridRow` that isn't a number, `"span N"` or `"A / B"` is dropped. +#### Mobile primitives + +Phone screens are built from seven primitives that carry their own sizes, 44px hit areas and token colours (`$accent`, `$border`, `$bg-surface`, `$text-primary`, `$text-secondary`, `$danger`; neutral fallbacks unthemed). Labels truncate to one line with an ellipsis. + +| Type | Key props | What it is | +|---|---|---| +| `status-bar` | `time`, `appearance` | Device chrome pinned to the top, as tall as the root's `safeArea.top` (47 without one) | +| `home-indicator` | `appearance` | The home pill pinned to the bottom, as tall as `safeArea.bottom` (34) | +| `nav-bar` | `title`, `leading`, `trailing` (≤2), `largeTitle` | 44 tall (96 large), in flow; every action a 44×44 target | +| `tab-bar` | `items: [{ icon, label, active?, badge? }]`, `variant` (`standard` / `floating` / `fab`), `fab` | Pinned to the bottom, 49 + the inset; 2–5 labeled tabs; `fab` adds a raised 56px centre action | +| `button` | `label`, `icon`, `iconPosition`, `variant`, `size` (`sm` 32 / `md` 44 / `lg` 52), `disabled` | `md` meets the phone floor; `sm` is a mobile error by design | +| `list-row` | `title`, `subtitle`, `leading`, `trailing` (`text` / `chevron` / `toggle` / `badge`), `divider` | 44 tall (56 with a subtitle); repeated rows count as reuse | +| `sheet` | `detent`, `grabber`, children | A bottom sheet over a scrim, padded past the home indicator | + +`fill`, `stroke`, `color`, `iconColor`, `secondaryColor` (list-row subtitle and trailing) and `badgeColor` (badges) override the token defaults. + +`position: "fixed"` pins a node to the screen (the root's width × height is the viewport; content taller than it scrolls and the root grows with it, keeping its bottom padding), `"sticky"` sticks at its `y` while its container scrolls; `bottom` and `right` join `x` / `y`. The renderer raises the root's padding to at least the reach of any pinned `status-bar`, `tab-bar` or `home-indicator`, so content never starts or ends under chrome: + +``` +I("document", { type: "status-bar" }) +I("document", { type: "nav-bar", title: "Settings", largeTitle: true }) +list=I("document", { type: "frame", layout: "vertical" }) +I(list, { type: "list-row", title: "Notifications", leading: { icon: "bell" }, trailing: { toggle: true }, divider: true }) +I(list, { type: "list-row", title: "Account", subtitle: "ada@example.com", leading: { initials: "AL" }, trailing: { chevron: true } }) +I("document", { type: "tab-bar", items: [{ icon: "house", label: "Home" }, { icon: "settings", label: "Settings", active: true }] }) +I("document", { type: "home-indicator" }) +``` + +The mobile ruleset judges them (see [`canvas_evaluate`](#canvas_evaluate)); [GUIDELINES](docs/GUIDELINES.md#mobile-vocabulary-primitives-and-pinning) has the pinned-CTA-bar idiom. + ### `find_nodes` Find nodes by what they are — property values, text content, or name — instead of hand-tracking ids. Read-only; the query twin of `replace_matching_properties`. @@ -412,7 +442,7 @@ Render canvas to PNG (returned as base64 image). | `nodeId` | string? | Specific node to capture | | `width` | number? | Viewport width (default: the canvas's artboard width) | | `height` | number? | Viewport height (default: the canvas's artboard height) | -| `fullPage` | boolean? | Capture the whole design instead of one viewport — default false, so existing captures (diffs, responsive sets, the pattern gate) are byte-identical. A canvas taller than its artboard otherwise cuts off at the artboard height. `export` takes the same option (ignored for PDF, which paginates) | +| `fullPage` | boolean? | Capture the whole design instead of one viewport — default false, so existing captures (diffs, responsive sets, the pattern gate) are byte-identical. A canvas taller than its artboard otherwise cuts off at the artboard height. The capture grows to the content, so `position: "fixed"` elements land at its ends (a bottom-pinned tab bar at the bottom of the image). `export` takes the same option (ignored for PDF, which paginates) | | `scale` | number? | Device scale (default 2) | | `theme` | string? | `"dark"` renders the design system's dark token layer (`dark.colors`/`dark.elevation` overrides); default light — a no-op without a dark layer | @@ -827,9 +857,10 @@ Every result also carries a **`surface`** field: `{ type, device, source, rulesO | Rule | Category | Severity | Fires on | |---|---|---|---| -| `mobile-touch-target` | usability | error | A control whose touch target is below 44px. The target extends to its row only when the row is a horizontal frame holding that one control. | -| `mobile-handbuilt-target` | usability | info | A frame shaped like a tap target (one icon, or an icon plus a label of up to three words) whose box is below 44px. | -| `mobile-safe-area` | usability | warning | Content under the 47px status-bar or 34px home-indicator inset. In fast mode, with the root's `safeArea` only positioned content can get there; without it, the first and last content must clear the insets. Padding of 32px or more scales down on a phone, so clear an inset with a spacer frame inside the content frame. Detailed mode checks rendered positions and also catches a long screen that ends in the bottom inset. | +| `mobile-touch-target` | usability | error | A tap target below 44px: a control (its target extends to its row 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, or each `nav-bar` action. Primitives are measured by their own box; nodes inside an instance are judged as rendered and reported on the instance. | +| `mobile-tab-bar` | usability | warning | A `tab-bar` with fewer than 2 or more than 5 tabs (the FAB is not a tab), a tab with no label, or a bar not pinned to the bottom (a `position` override or any `y`). | +| `mobile-handbuilt-target` | usability | info | A frame shaped like a tap target (one icon, or an icon plus a label of up to three words) whose box is below 44px; rebuild it as a `button` if it is tappable. | +| `mobile-safe-area` | usability | warning | Content under the 47px status-bar or 34px home-indicator inset, or left under a pinned bar. With the root's `safeArea` or pinned chrome the renderer reserves the insets, so fast mode checks positioned content (`absolute`, `fixed`, `sticky` at its stuck `y`) and pinned bars: the scroll's end (or start) must clear a bar you pinned, and the finding names the spacer height to add to your content frame. Without `safeArea`, the first and last content must clear the insets. Padding of 32px or more scales down on a phone, so clear an inset with a spacer frame. A `sheet`'s content is never judged. Detailed mode checks rendered positions and is authoritative. | | `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. | @@ -850,8 +881,8 @@ Whenever the `coverage` category runs on a **base** canvas, the result also carr | `spacing` | 20 | Off-scale padding/gap values (`gap: 0`, a flush stack, is never flagged), plus spacing variety measured against the canvas's **declared** scale (`set_variables`/`generate_scale` spacing tokens) — only values *not* on that scale count toward sprawl, so a design built entirely from its own multi-step scale (framesmith's generated scale ships nine: `space-3xs`…`space-3xl`) isn't penalized for using it. No declared scale falls back to the old total-unique-values count, with a suggestion to declare one | | `color` | 25 | WCAG AA contrast ratios for text against nearest background — checked in BOTH themes when a `dark` token layer exists (dark-run issues carry `theme: "dark"`, score takes the worse theme, and dark failures point at the dark layer instead of carrying a literal fix). WCAG-passing pairs that are perceptually weak by **APCA** get an info advisory (Lc vs ~75 body / ~60 large) — APCA is a candidate method, not a standard; it never blocks | | `typography` | 20 | Type-scale ratios (1.1–2.0; adjacent sizes both declared as typography tokens are pinned and skip the check), font-family count, weight variation — plus score-neutral craft advisories: measure (prose past ~75 characters per line; warning past 90), tracking (display text ≥28px without negative letter-spacing, autofixable), and tabular numerals (numeric table columns without `tabularNums`, autofixable) On a mobile canvas `mobile-type-floor` adds −10 per warning. | -| `structure` | 15 | Tree depth, naming coverage, design-token usage %, component reuse, unknown icon names (an icon that resolves in neither set renders an empty slot: a directive-blocking warning, −10 structure score each; autofixable when one spelling resolves) `mobile-artboard` adds −10 per warning, on every type. | -| `consistency` | 20 | Frames missing `layout`, sibling overlap (detailed mode) — plus score-neutral advisories: **token detachment** (a literal `fill`/`stroke`/`color`/`cornerRadius` that exactly equals a token's value flags with a mechanical `$ref` autofix; ambiguous matches list candidates and never guess) and **ad-hoc motion** (3+ distinct duration/easing combos with no motion tokens declared) | +| `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). | @@ -968,17 +999,17 @@ Apply the ops by joining them with newlines and passing to `batch_design`, then ### `canvas_stress` -Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. The verdict is remembered for `canvas_evaluate`, with the canvas type it ran under: any run with warnings records `FRAGILE` (its directive then stays `NOT READY`), and only a run of the full default set records `CLEAN`, so a passing subset never clears an earlier `FRAGILE`. On a `mobile` canvas `long-text` leaves strings of three characters or fewer alone (monograms, short labels). +Content stress test — does the design survive real data? Re-renders the canvas under hostile-but-realistic content perturbations and reports exactly what broke, by node id. Never mutates the canvas. The verdict is remembered for `canvas_evaluate`, with the canvas type it ran under: any run with warnings records `FRAGILE` (its directive then stays `NOT READY`), and only a run of the full default set records `CLEAN`, so a passing subset never clears an earlier `FRAGILE`. On a `mobile` canvas `long-text` leaves strings of three characters or fewer alone (monograms, short labels), and the tab and nav-bar action labels (fixed vocabulary; `i18n` still lengthens them). The mobile primitives' own copy is perturbed (`button` label, `list-row` title / subtitle / trailing text, `nav-bar` title and actions, `tab-bar` labels), and a label hitting its designed ellipsis reports an *info*. | Perturbation | What it does | |---|---| | `long-text` | Non-data text ×2.2 plus one long unbroken token (the wrap-breaker) | | `i18n` | Text ×1.4 — the German/Finnish expansion rule | | `big-numbers` | Data-like text to its widest realistic form: `9` → `999+`, `$1.5M` → `$1,520,847.33` | -| `empty` | Detected tables lose their data rows | -| `many` | Data rows ×3 | +| `empty` | Detected tables lose their data rows (and runs of two or more consecutive `list-row`s are emptied) | +| `many` | Data rows ×3 (and `list-row` runs ×3) | -Finding kinds: `clip` (content cut off — *info* when a designed `text-overflow: ellipsis` is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; *warning* otherwise), `overflow-x` (a node escapes its parent box or the canvas), `layout-shift` (an **untouched** node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent are exempt). Only **new** breakage counts: anything already clipping at baseline is the design's standing state. Tables are found with the same inventory drift and coverage use; component instances are expanded first. +Finding kinds: `clip` (content cut off — *info* when a designed `text-overflow: 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` (a node escapes its parent box or the canvas), `layout-shift` (an **untouched** node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent are exempt). Only **new** breakage counts: anything already clipping at baseline is the design's standing state. Tables are found with the same inventory drift and coverage use; component instances are expanded first. | Param | Type | Description | |-------|------|-------------| @@ -1109,7 +1140,7 @@ I("parent", { type: "icon", icon: "material:star-fill" }) # "-fill" suffix = f I("parent", { type: "icon", icon: "material:chevron_right" }) # multi-word names keep Material's underscores ``` -Spelling differs by set: Lucide hyphenates (`chevron-right`), Material keeps underscores and needs the prefix (`material:chevron_right`). An icon name that resolves in neither set renders an empty slot, so `canvas_evaluate` warns on it (structure category) and `canvas_autofix` rewrites it when exactly one spelling resolves. +Spelling differs by set: Lucide hyphenates (`chevron-right`), Material keeps underscores and needs the prefix (`material:chevron_right`). An icon name that resolves in neither set renders an empty slot, so `canvas_evaluate` warns on it (structure category; the mobile primitives' icon props too) and `canvas_autofix` rewrites it on an icon node or `button.icon` when exactly one spelling resolves. `iconStyle` picks the Material variant (`"outlined"` default, `"rounded"`, `"sharp"`); it's ignored for Lucide. diff --git a/VISION.md b/VISION.md index 33bd00b..1c2b299 100644 --- a/VISION.md +++ b/VISION.md @@ -547,8 +547,8 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [x] Slice A — untangle the declarations: genre gets its own field so `apply_preset` stops overwriting it, every stored canvas keeps its verdict through a legacy read, and `data` becomes the canonical purpose genre with `dashboard` as its alias - [x] Slice B — the type axis: `metadata.surface` with one device profile (`iphone-390`), `canvas_create({ type })` and `canvas_set_type`, a ruleset table keyed by type, genre and preset that composes by union and can turn rules on, a `surface` report on every evaluation, and a mobile type scale from `generate_design_system` -- [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives join the set as they land), safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type -- [ ] Slice D — vocabulary: `fixed` / `sticky` positioning, `safeArea` on the root, and the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives +- [x] Slice C — the mobile ruleset: 44px targets as an error on every control (the slice D primitives joined it), safe areas, a type floor, undeclared phone-width canvases told to declare, hand-built tap targets and multi-column prose named as advisories, and stress that knows the type +- [x] Slice D — vocabulary: `fixed` / `sticky` positioning with the renderer reserving room for pinned chrome, a root that grows with long content (closing slice C's long-screen gap), the `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row` and `sheet` primitives, the primitives joining the 44px target rule, `mobile-tab-bar`, and pinned bars in the safe-area rule (`safeArea` on the root landed in slice B) - [ ] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them - [ ] Slice F — the viewer: thumbnails at the artboard's real proportions and a device frame with a safe-area overlay - [ ] Slice G — the acceptance re-run diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 3692ed5..d55314b 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -34,14 +34,14 @@ Pick the right `width` per node — this is the single biggest lever for respons **Default to fluid.** Reach for fixed pixel widths only when the content genuinely shouldn't scale. -**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 a `clip` finding from `canvas_stress` for exactly that case is `info`, not a problem to fix. 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. +**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) 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 on a screen that fits its artboard in-flow content never sits under the status bar or the home indicator. A screen taller than its artboard still ends in the bottom inset; see [the mobile ruleset](#the-mobile-ruleset). A root with `padding: 24` starts its first child 71px down (24 + 47). `safeArea` is part of the root, so changing it moves the `versionHash`. +- **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`. - **`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. @@ -51,19 +51,54 @@ A canvas declares what medium it is on, separately from its genre (what it is fo On a `mobile` canvas these rules run. Each finding names its rule in a `rule` field, and the `surface` report lists the rules in `rulesOn`. Errors and warnings from these rules block the directive like any other finding, on every type; infos never do. Fast mode (`canvas_evaluate` with the default `mode: "fast"`) is what you run in the loop; before presenting a phone screen, also run `canvas_evaluate({ canvasId, mode: "detailed" })`, which checks rendered positions. -- **`mobile-touch-target`** (usability, **error**). A control (toggle, checkbox, radio, select) whose touch target is below 44px in either dimension. The target extends to the control's row only when that row is a horizontal frame holding this one control: a toggle in a 56px settings row passes, and a lone checkbox inside a padded card is measured at its own size. To fix one, grow the control or put it alone in a horizontal row at least 44px tall. On web canvases the 24px error and the 44px phone-width info are unchanged. -- **`mobile-handbuilt-target`** (usability, info). A frame shaped like a tap target (one icon, or one icon and a label of three words or fewer) whose box is below 44px. It never blocks, because a frame can't say it is decorative. If it is tappable, make it at least 44×44. -- **`mobile-safe-area`** (usability, warning). Content under the status bar or the home indicator. - - With `safeArea` on the root, the renderer reserves both insets, so fast mode checks positioned (`absolute`) content only. An absolute node's `y` is measured from the top of the artboard when its parent is the root: keep it at 47 or more, and its bottom edge above 810 (844 − 34). - - Without it (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding or spacer frames. Content means text, icons, controls, component instances, and images narrower than the artboard; a full-width image or a filled frame can sit under the status bar. So a full-bleed header is a frame whose fill runs to the top and whose first child is a 47px spacer frame. With root `safeArea` the root's fill paints the inset bands instead, so a header colour can't reach under the status bar. - - Padding of 32px or more scales down on a phone: it renders at its value × 390 / 1440, but never below a quarter of its value (min 8). So 48 renders 13 and 64 renders 17. A spacer frame of the inset's height is the exact way to clear an inset by hand. Put spacers inside the frame that holds your content: a spacer directly under the root shrinks when the screen is taller than its artboard, because the root has a fixed height. - - Detailed mode checks rendered positions and is the authority. Fast mode cannot place absolute content inside an in-flow frame; it adds an info that says to run detailed mode. - - A screen taller than its artboard ends in the home-indicator inset even with `safeArea`, because the root's bottom padding does not extend past overflowing content. Only detailed mode catches this. End the frame that holds a long screen's content with a 34px spacer frame. +- **`mobile-touch-target`** (usability, **error**, −25 per target). A tap target below 44px in either dimension. + - 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. +- **`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. + - With `safeArea` on the root, the renderer reserves both insets. Pinned chrome (a `fixed` `status-bar`, `tab-bar` or `home-indicator`) can raise that reservation: each edge reserves the larger of (authored padding + inset) and the chrome's height plus its offset. The chrome's height already includes the inset, so with `safeArea` and a tab bar the bottom reservation is 83 (49 + 34), not 117; without `safeArea` the chrome still counts the default 47 / 34 insets. So in-flow content is safe, and fast mode checks positioned content only: `absolute`, `fixed`, and `sticky` (at its stuck `y`). A `fixed` node's `y` / `bottom` are measured from the screen's edges: keep its content at 47 or more from the top and 34 or more from the bottom. + - Without `safeArea` (a canvas made mobile by `canvas_set_type`), the first content must clear 47px from the top and the last content 34px from the bottom, through padding, spacer frames, pinned chrome, or an in-flow `status-bar` (which spaces by its height). Content means text, icons, controls, buttons, list rows, nav bars, component instances, and images narrower than the artboard; a full-width image or a filled frame can sit under the status bar. So a full-bleed header is a frame whose fill runs to the top and whose first child is a 47px spacer frame. With root `safeArea` the root's fill paints the inset bands instead, so a header colour can't reach under the status bar. + - A pinned bar you build (a `fixed` frame or button that is 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 of the document must reach the bar's height plus its `bottom` offset (for a bar pinned at a `y`, the first content's offset must reach its `y` plus its height). The finding names the spacer height to add at the end (or start) of the frame that holds your content, for the bar as it is: change the bar (its padding, its spacer) and re-evaluate, because the height it needs changes with it. Fast mode needs the bar's height: a numeric `height`, or padding plus primitives and fixed-height frames; a bar holding text gets an info that says to run detailed mode. + - Padding of 32px or more scales down on a phone: it renders at its value × 390 / 1440, but never below a quarter of its value (min 8). So 48 renders 13 and 64 renders 17. A spacer frame of the inset's height is the exact way to clear an inset by hand. + - A `sheet`'s content is never judged: it is an overlay the renderer pads past the home indicator. + - Detailed mode checks rendered positions and is the authority: 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 under a top bar at the start. Fast mode cannot place absolute content inside an in-flow frame, or a `fixed` node inside a frame with `blur`, `backdropBlur`, `backdropFilter` or a slideUp / slideDown / scaleIn `animation` (CSS pins it to that frame, not the screen); it adds an info that says to run detailed mode. - **`mobile-type-floor`** (typography, warning). Text below 11px; a text whose `fontSize` is `$body` and resolves below 16px; prose of five or more words below 13px. Long copy of twelve or more words at 13–15px is an info. Each text gets at most one finding, the first of these that applies. Words hold letters and no digits: figures and their units are not words, so a metrics line such as `5.2 mi · 1h 12m` is fine at 12px. - **`mobile-one-column`** (usability, info). A horizontal or grid frame that sets prose side by side: two or more children each holding a text of eight or more words. - **`mobile-artboard`** (structure; on for every type). A mobile canvas 500px or wider warns, and any other size than the device's is an info. An undeclared canvas narrower than 500px warns until you declare it, either way: `canvas_set_type({ type: "mobile" })` for a phone app screen (then follow its `hint`, which adds the root `safeArea`; for a new screen, `canvas_create({ type: "mobile" })` does both at once), or `canvas_set_type({ type: "web" })` for a web page designed at that width. A canvas declared `web` and narrower than 500px gets an info instead. -None of these rules has a mechanical fix, so `canvas_autofix` never proposes one. On a mobile canvas `canvas_stress` leaves strings of three characters or fewer (monograms, short labels) out of `long-text`. It records the type it ran under; after a type change the evaluate result's `stress.current` is false. A `FRAGILE` verdict keeps blocking and the directive asks for a re-run; a stale `CLEAN` one just reads `current: false`. +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`. + +## 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. + +| Primitive | Props | Renders | +|---|---|---| +| `status-bar` | `time` (`"9:41"`), `appearance` (`"dark"` glyphs for a light screen, `"light"` for a dark one; default `color`) | Device chrome, pinned (`fixed`) to the top, as tall as the root's `safeArea.top` (47 without one) | +| `home-indicator` | `appearance` | The 134×5 pill, pinned to the bottom, as tall as `safeArea.bottom` (34) | +| `nav-bar` | `title`, `leading` (`{ icon?, label? }`, icon defaults to `chevron-left`), `trailing` (up to two `{ icon }` / `{ label }`), `largeTitle` | 44 tall in flow (96 with `largeTitle`: the 44px action row plus a 52px row holding a 34px bold title); every action is a 44×44 target | +| `tab-bar` | `items: [{ icon, label, active?, badge? }]`, `variant` (`standard` / `floating` / `fab`), `fab: { icon }` | Pinned to the bottom, 49 plus the bottom inset (`safeArea.bottom`, 34 without one); `floating` is an inset pill; `fab` adds a raised 56px centre action (16px more box), which is not a tab | +| `button` | `label`, `icon`, `iconPosition`, `variant` (`primary` / `secondary` / `ghost` / `destructive`), `size` (`sm` 32 / `md` 44 / `lg` 52), `disabled` | Never narrower than tall; icon-only is square; `width: "100%"` for a full-width CTA. `sm` is for pointer surfaces and is an error on a phone | +| `list-row` | `title`, `subtitle`, `leading` (`{ icon }` / `{ initials }` / `{ image }`), `trailing` (`{ text }` / `{ chevron: true }` / `{ toggle: true }` / `{ badge }`), `divider` | 44 tall (56 with a subtitle), spanning its list. Repeated rows count as reuse | +| `sheet` | `detent` (`medium` / `large` / `auto`), `grabber` (default true), `padding`, `gap`, children | A bottom sheet over a dimmed scrim, padded past the home indicator; its children scroll inside it | + +**Pinning.** `position: "fixed"` pins a node to the screen: the root's width × height is the viewport, and content taller than it scrolls. `y` / `bottom` measure from the screen's top / bottom, `x` / `right` from the root's edges. A fixed node with no `x`, `right` or `width` spans the root's column; with no `y` or `bottom` it sticks to the top. `position: "sticky"` sticks at its `y` (or `bottom`) while its container scrolls. The chrome primitives default to fixed; override their `position` only for a deliberate composition (a `status-bar` set to `absolute` lets a full-bleed hero image run under it). The renderer raises the root's top and bottom padding to at least the reach of any pinned `status-bar`, `tab-bar` or `home-indicator`, so content never starts or ends under chrome. A fixed node inside a frame with `blur`, `backdropBlur`, `backdropFilter` or a slideUp / slideDown / scaleIn `animation` pins to that frame instead (CSS), so keep pinned bars out of them. `fullPage` captures grow to the content, so a bottom-pinned bar lands at the end of the image. + +**A pinned CTA bar** is not chrome, so it reserves nothing by itself. Build it as a `fixed` frame at `bottom: 0` with padding under 32 (larger padding renders smaller on a phone) and a 34px spacer frame as its last child, so its button clears the home indicator. Then end the frame that holds your content with a spacer as tall as the bar, so the last row scrolls clear of it. `mobile-safe-area` names the height when it is short: + +``` +list=I("document", { type: "frame", layout: "vertical" }) +I(list, { type: "list-row", title: "Subtotal", trailing: { text: "$42.00" } }) +I(list, { type: "frame", name: "Bar spacer", height: 68 }) +bar=I("document", { type: "frame", position: "fixed", bottom: 0, layout: "vertical", padding: [12, 16, 12, 16], fill: "$bg-surface" }) +I(bar, { type: "button", label: "Pay", width: "100%" }) +I(bar, { type: "frame", height: 34 }) +``` + +The bar is 12 + 44 + 34 + 12 = 102px tall; the root's `safeArea` reserves 34, so the content frame's spacer adds the other 68. ## Responsive hints @@ -291,7 +326,7 @@ The happy path of one static frame with ideal data is half a design. Real UX liv **The evaluator demands them.** The `coverage` category warns — directive-blocking — when a base canvas carries data-bearing content with no designed states: a detected table demands `empty` + `loading`; a form (3+ input controls) demands `error`. (Table detection requires three matching horizontal rows — two stacked rows are a layout, not data — so the rare header-plus-one-row table goes undetected; name the frame `Table` to claim the shape explicitly.) Variant canvases and non-data screens are silent. The result's `coverage: { dataBearing, states, missing }` tells you where you stand, and `canvas_list` shows the designed states per screen. -**Then stress it.** `canvas_stress` re-renders the canvas under hostile-but-realistic content — `long-text` (×2.2 + an unbroken German compound), `i18n` (×1.4), `big-numbers` (`9` → `999+`, `$1.5M` → `$1,520,847.33`), `empty`, `many` (rows ×3) — and reports what broke, by node id: `clip` (info when a designed ellipsis is doing its job, or when it's just the page growing taller than a fixed artboard — that scrolls on a real page; warning otherwise), `overflow-x`, `layout-shift` (an untouched node ballooning — perturbed nodes, their ancestors, and a stretch sibling that merely follows a legitimately growing parent, like a full-height sidebar next to a growing table, are all exempt). Only *new* breakage counts. Fix findings with the [width strategies](#width-strategies) above — fluid widths, `minWidth` or `minHeight` floors, wrapping — or give a must-stay-single-line label `textOverflow: "ellipsis"` (the designed-truncation property: the clip downgrades to info because truncating is the design) — and re-run until `CLEAN`. A data screen goes to the user with its states designed and a `CLEAN` stress verdict, the same way it goes with a >95 score. `canvas_evaluate` holds you to it: once stress has recorded a verdict, the evaluate result carries a `stress` field (`{ verdict, warnings, 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. +**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. ## Reviewing the set diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 4ab8f05..b3250eb 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -247,6 +247,68 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - May split D1 (positioning + chrome + navigation) and D2 (button, list-row, sheet) if the diff is large. - Tests: `test-primitives.ts` (render, defaults, unthemed, the pinned-chrome reservations with and without `safeArea`), `test-mobile-rules.ts` extended to the new tappables and `mobile-tab-bar`, new `test-positioning.ts` for fixed/sticky (needs Chrome, full tier). +- **Build contract (slice D, frozen before build).** Pins what the FR rows leave open. Where it changes an FR, it says so. One PR (no D1/D2 split). + - *Shared geometry.* A new `src/primitives.ts` holds every size and default the renderer and the rules both need, so they cannot drift: button heights `sm` 32 / `md` 44 / `lg` 52 and horizontal padding 12 / 16 / 20; list-row min height 44 (56 with a non-blank `subtitle`); nav-bar 44 (96 with `largeTitle`); nav-bar action box 44×44; tab-bar band 49; FAB 56; the default positions; `rootInsets(root)` = each side of `usableSafeArea(root.safeArea)` on a `document` root, else 47 / 34 (the `iphone-390` profile); `chromeHeight(node, insets)` = `status-bar` insets.top, `home-indicator` insets.bottom, `tab-bar` 49 + insets.bottom (65 + insets.bottom for `variant: "fab"`, whose box includes the raised FAB); `effectivePosition(node)` = `node.position` or the type's default. Every rule and the renderer read positions through `effectivePosition`, never the raw prop, so a defaulted-fixed `tab-bar` is positioned everywhere. + - *Root render (FR-D1, the slice C carry-over).* A `document` root with a numeric `height` keeps `height: px` and adds `min-height: max-content`, unless the root sets `overflow` to anything but `visible` (an authored clip keeps the plain fixed height). This holds on every type (render never reads `metadata.surface`). When the content fits, the box is H, so `height: "100%"` children still resolve against it and the render is byte-identical. When the content is taller, the root grows to it: its end padding (its `safeArea` included) follows the last child and explicit-height children keep their size. Two changes for canvases whose content overflowed the root, on web too: children stop shrinking, and an `absolute` node with `bottom` set against the root moves to the document end (use `fixed` to pin to the screen). canvas_stress's "page grows taller than the artboard" info stops appearing, because the root no longer clips. Every primitive builder emits `flex-shrink: 0`, so a primitive keeps its size inside a fixed-height column too. + - *Positioning (FR-D1).* `position` is `'absolute' | 'relative' | 'fixed' | 'sticky'`; `bottom` and `right` (numbers, px) join `x` / `y` and emit `bottom` / `right` whatever the position (inert without one, like `x` / `y`). `fixed`: vertical offsets are viewport offsets; horizontal offsets are measured from the root's edges, not the window: `left: calc(max(0px, (100% - px) / 2) + px)` (and `right` likewise), a fixed node with neither `x` nor `right` sits at the root's left edge, and a fixed node without `maxWidth` gets `max-width: px`. At every capture viewport (root width) this equals plain `left: px`; in a wider browser window it keeps pinned elements in the screen column. A `fixed` or `sticky` node with neither `y` nor `bottom` gets `top: 0`; a chrome primitive (`status-bar` top, `tab-bar` / `home-indicator` bottom) gets its edge's 0 under any position when its offsets are absent. CSS pins `fixed` to the viewport only while no ancestor sets a transform, filter or backdrop filter: in framesmith that is an ancestor with `blur`, `backdropBlur`, `backdropFilter` or an `animation` (slideUp / slideDown / scaleIn keep their transform). The docs say so, and the rules treat such a `fixed` node as unplaceable. Stacking: `sticky` z-index 5, `fixed` 10, a sheet's scrim and panel 30. `screenshot({ fullPage: true })` grows the viewport to the content, so top-pinned elements capture at the top of the image and bottom-pinned ones at its end; the docstring says so. + - *Reservation (FR-D1).* The renderer collects every effectively `fixed` `status-bar` (top), `tab-bar` and `home-indicator` (bottom) anywhere in the tree (instances resolved, a `sheet`'s subtree excluded) and raises that edge of the root's padding to the largest `chromeHeight + offset` (`y` at the top, `bottom` at the bottom, absent = 0) with CSS `max()`: `padding-bottom: max(calc( + px), px)`. Without such chrome the slice B emission is unchanged, byte for byte. + - *Primitives (FR-D2–D4).* Seven node types: `status-bar`, `home-indicator`, `nav-bar`, `tab-bar`, `button`, `list-row`, `sheet`. Each has its own builder beside `renderToggle`; the generic `buildStyles` applies to none of them except where stated. Primitives with copy render each label in an element marked `data-fs-label` that truncates to one line with an ellipsis. Icons resolve through `getIconSvg` (Lucide or `material:`). + - `status-bar` — `time` (default `"9:41"`), `appearance`. Default fixed top 0. Full width, height `insets.top`, transparent unless `fill` is set. Time left, signal / wifi / battery glyphs right, bundled SVG strings in the renderer, vertically centred. Glyph and time colour: `appearance: "dark"` → `#000000`, `"light"` → `#FFFFFF`, else `color` (default `$text-primary`). No children. + - `home-indicator` — `appearance`. Default fixed bottom 0. Full width, height `insets.bottom`, transparent, a 134×5 pill centred 8px above the bottom, `pointer-events: none`. Same colour rule. No children. + - `nav-bar` — `title`, `leading?: { icon?, label? }` (icon default `chevron-left`), `trailing?: Array<{ icon } | { label }>` (entries past two are ignored), `largeTitle?`. In flow by default (no default position). Full width (stretch). Height 44, or 96 with `largeTitle`: a 44 action row plus a 52 title row (34px bold, left, 16px inset). Without `largeTitle` the title (17px semibold) is centred. Every action renders a box at least 44×44 (a label action pads 8px each side). Title colour `color` (default `$text-primary`); action colour `iconColor` (default the accent chain). No children. + - `tab-bar` — `items: [{ icon, label, active?, badge? }]`, `variant` (`standard` default | `floating` | `fab`), `fab?: { icon }`. Default fixed bottom 0. Box height 49 + `insets.bottom`; the items sit in the top 49. `standard`: full width, `fill` (default `$bg-surface`), a 1px top border (`stroke`, default `$border`). `floating`: the box is transparent; a pill inset 16px each side fills the top 49, fully rounded, filled, with a 1px border and a soft shadow. `fab`: the box is 16px taller (transparent at the top) so it contains the 56px circular accent button in the middle slot (`ceil(n/2)` items to its left), raised 16px above the bar band's top edge; icon `fab.icon`, default `plus`. `fab` on another variant is ignored. An item: icon 24 over an 11px label, one per slot (slot width = bar width / slots, slots = items, + 1 for the FAB); active colour `iconColor` (default accent), inactive `color` (default `$text-secondary`); `badge` (string or number) is a small `$danger` pill on the icon. No children. + - `button` — `label?`, `icon?`, `iconPosition?` (`leading` default | `trailing`), `variant` (`primary` default | `secondary` | `ghost` | `destructive`), `size` (`md` default), `disabled?`. Height = the larger of numeric `height` (else the size's) and numeric `minHeight`, emitted as both `height` and `min-height`; `min-width` = that height, so a button is never narrower than tall; an icon-only button is square. `width` is honoured (`"100%"` for a full-width CTA). Fonts 14 / 16 / 17 semibold, gap 8. Colours: `primary` fills the accent, `destructive` fills `$danger` (fallback `#DC2626`), each with label colour picked by contrast (white or `#111827`, whichever contrasts more with the fill); `secondary` is transparent with a 1px `$border` stroke and `$text-primary` label; `ghost` transparent with an accent label. `cornerRadius` defaults to the `md` radius token, else 8. `disabled` → opacity 0.5. Focusable like the controls. No children. + - `list-row` — `title`, `subtitle?`, `leading?: { icon } | { initials } | { image }`, `trailing?: { text } | { chevron: true } | { toggle: boolean } | { badge }`, `divider?`. Horizontal, centred, gap 12, padding 8 / 16, `min-height` 44 (56 with a subtitle); with no `width` it stretches (`align-self: stretch`). Title 16px `color` (default `$text-primary`), subtitle 14px `$text-secondary`. Leading: icon 22 in `iconColor` (default accent); initials in a 32px circle; image a 32px round crop. Trailing: text 15px secondary; chevron `chevron-right` 18 secondary; toggle a 44×24 track (accent on, border off); badge an accent pill. `divider` draws a 1px `$border` line from the 16px inset to the right edge. Row `fill` transparent unless set. No children. + - `sheet` — a container: `detent` (`medium` | `large` | `auto`, default `auto`), `grabber?` (default true), plus `padding`, `gap`, `fill` (default `$bg-surface`), `cornerRadius` (default the `lg` radius token, else 12). Renders a fixed scrim (`rgba(0,0,0,0.4)`, root-width column, full height) and a fixed panel at the bottom: `medium` = 50% of the viewport height, `large` = from `insets.top + 10` to the bottom, `auto` = its content, capped there. Top corners rounded; a 36×5 grabber 6px from the top adds 16px to the top padding; the bottom padding is the authored padding + `insets.bottom`. Children lay out vertically; the panel scrolls (`overflow-y: auto`) when they outgrow it, and is marked `data-fs-scroll` so canvas_stress reads its vertical overflow as scrolling, not a clip. `position`, `x` and `y` are ignored. + - *Colour defaults.* `applyControlDefaults` gains every primitive above, each colour only when the node leaves it unset; the accent chain is `$accent` → `$primary` → `#2563EB`, the others fall back to the existing control neutrals. An unthemed canvas never crashes. + - *Tappable primitives (FR-C1).* With `mobile-touch-target` on, `checkUsability` also measures, each by its own box (never extended by an ancestor): a `button` (height as rendered; width = max(numeric width, height), or the height when icon-only or the width is not numeric); a `list-row` (height = max(numeric height, its min height); width = `knownWidth`, judged only when known); every `tab-bar` item (bar width = numeric `width`, else the artboard width, minus 32 for `floating`; item = bar width / slots × 49) and the FAB (56×56); every `nav-bar` action (44×44). One error per target below the floor in either dimension, −25 each, message naming the primitive and box; the `button` suggestion names `size: "md"` (44) or `"lg"` (52). On mobile, a row holds "exactly one control" only when it holds no other tappable primitive either, so a checkbox sharing a row with a `button` does not inherit the row. Web is unchanged: primitives are not measured there and the control count is the four controls. + - *Instances (every mobile rule).* On a canvas whose rules include any mobile rule id, `checkUsability`'s mobile branch and every `src/mobile-rules.ts` check run on the resolved tree with instances expanded (`resolveInstance`, recursively), the same tree the renderer draws, so a primitive or chrome inside a component is judged. A finding on a node inside an expanded instance reports the outermost instance's `nodeId` and names the inner node in its message (the component holds the node). Web checks keep reading the unexpanded tree. + - *`mobile-handbuilt-target` (carry-over).* The suggestion becomes: if it is tappable, rebuild it as a `button` (`md` is 44px tall; an icon-only button is square), or as a `list-row` / `tab-bar` item when that is what it is; if it is decorative, ignore this. The rule itself is unchanged. + - *`mobile-tab-bar` (FR-D3).* Registered in `MOBILE_RULES`, category usability, severity warning, −10 each, one finding per condition per tab-bar: `items` not an array of 2–5 entries; any item whose `label` is missing or blank (one finding naming the item indexes); not pinned to the bottom (effective position other than `fixed`, or any `y`: with a `y` the bar pins to the top whether or not `bottom` is set). Without a `y` and a `bottom`, the default `bottom: 0` applies. + - *`mobile-safe-area` (FR-C3, the slice D half).* + - *Positioned* means an effective position of `absolute`, `fixed` or `sticky`. A `fixed` or `sticky` node is placed in the artboard frame directly (sticky at its stuck position): top = `y`, or when only `bottom` is set, bottom edge = artboard height − `bottom` (top = that − its known height). An `absolute` node keeps slice C's chain, where a placed `fixed` / `sticky` ancestor is a valid base. A `fixed` node under an ancestor with `blur`, `backdropBlur`, `backdropFilter` or `animation` is not viewport-pinned and counts as unplaceable (the detailed-mode info). + - *Content* (Terms) adds `nav-bar` (it holds a title and actions, so it must clear the top inset). `status-bar`, `home-indicator` and `tab-bar` are chrome, never content. A `sheet`'s subtree is skipped in both modes (a modal overlay the renderer pads). + - *Fast, without a usable `safeArea` side:* the structural walk starts at max(rendered root padding, that edge's chrome reservation as the renderer computes it). An in-flow `status-bar` (top) or `home-indicator` (bottom) is a spacer of its chrome height. + - *Pinned bottom* = effectively `fixed`, `bottom` set, no `y`, not a `home-indicator`, `tab-bar` or `sheet`, holding content. Condition 1 (its content clears the inset) is slice C's placed-frame bottom walk from `bottom` + its rendered bottom padding (spacers inside count). Condition 2 (the scroll end clears it): the last in-flow content's clearance ≥ its height + `bottom`. The clearance is the structural bottom walk's offset for the root, started at the bottom reservation (the root's rendered bottom padding + usable `safeArea.bottom`, raised to the bottom chrome reservation) and counting spacers, so a spacer frame at the end of the content frame clears it. Its height: numeric `height`; else rendered vertical padding plus, for a horizontal or grid frame, its tallest child, for a vertical or layout-less frame, the sum of its in-flow children plus gaps; a child's height is its numeric `height`, a primitive's fixed height (button, list-row min, nav-bar, the controls' render defaults), or recursively a frame's; any unknown makes it unresolved, which skips condition 2 and adds one info naming detailed mode. Like slice C's bottom walk it can warn on a short screen whose content never reaches the bar; its remedy is correct on every screen: the suggestion names a spacer frame of the needed height at the end of the content frame (never root padding, which renders fluidly at 32px and above). + - *Top-pinned elements (the mirror).* An effectively `fixed` node with a `y` (not a `status-bar`), holding content: the first in-flow content's offset from the top (the structural top walk from the top reservation) must be ≥ its `y` + its height, or the bar hides the first content at scroll position 0. Same height resolution, same unresolved info, same spacer remedy. + - *Spec change (the FR-C3 example).* Padding of 32px or more renders fluidly (46 renders about 12px on a 390 artboard), so the FR's `paddingBottom: 46` bar fails condition 1 under the slice C rendered-padding contract. The passing idiom, used in the tests and the docs: a vertical bar with padding under 32 that ends in a spacer frame of the inset height (34), or a `bottom` offset of at least the inset. + - *Detailed:* `fixed` and `absolute` are positioned; a `sticky` node is in flow at scroll position 0 and is checked there as in-flow content, and also at its stuck position: each content rect under it, shifted by (`y` − the sticky rect's top) for a top sticky or to the `bottom` edge for a bottom sticky, fails when it reaches into that inset. The document bottom is the larger of every rect's bottom and the root rect's top + its `scrollHeight` when the walker recorded one (an `overflow` root scrolls inside itself). Added: in-flow content whose rect intersects a bottom-pinned element's rect moved to maximum scroll (rect.y + document bottom − artboard height) fails, for every effectively fixed node with a `bottom` and no `y` except `home-indicator` and `sheet` (the existing document-end inset check covers the home indicator); and in-flow content whose rect intersects a top-pinned element's rect (fixed with a `y`, not a `status-bar`) at scroll position 0 fails. + - *Reuse (FR-D4).* The "no component instances" advisory fires only when there are no instances and no single primitive type among `button` / `list-row` / `tab-bar` appears two or more times (a repeated primitive is reuse; one button plus one tab bar is not). + - *Unknown icons.* `checkUnknownIcons` also reads primitive icon props (`button.icon`, `list-row.leading.icon`, `nav-bar.leading.icon`, `nav-bar.trailing[].icon`, `tab-bar.items[].icon`, `tab-bar.fab.icon`): a blank glyph in a tab bar is the same shipped defect as a blank icon node. Findings are keyed by node and prop path, so every bad icon reports; the message names the path. Only an icon that is set and resolves in neither set is flagged, except `tab-bar.items[].icon`, which is required (a missing one is flagged too). A mechanical `fix` is offered only for `button.icon`, the one flat prop; the others name the spelling in the suggestion. + - *Stress (FR-C8 follow-through).* `LayoutRect` gains `truncated?: boolean`, set by the layout walker on an element with `data-node-id` that has a `data-fs-label` descendant whose content overflows it. `compareLayouts` reports a newly truncated rect as an `info` clip ("designed truncation engaged"). The walker also sets `scrolls: true` on an element marked `data-fs-scroll` (the sheet panel), and `compareLayouts` does not report its vertical overflow. Only primitives carry `data-fs-label` / `data-fs-scroll`, so web stress results are unchanged. `listRuns` loses its slice C cast. + - *Viewport meta (FR-D5).* `` after the charset meta. Puppeteer runs without mobile emulation, so it changes no capture. + - *Discoverability.* `test-discoverability.ts`'s node-type regex accepts hyphens (it reads `'([a-z]+)'` today and would silently skip `status-bar`). Every new type and prop lands on the `batch_design` docstring (node types and properties lines), README `**Node types:**`, GUIDELINES (a mobile vocabulary section with the pinned-bar idiom), `INSTRUCTIONS` and GOTCHAS (the mobile paragraph names the primitives and chrome), and the VISION Phase 30 checklist. + - *Pre-freeze verifier.* A verifier attacked the first draft on paper and in Chrome and found nine wrong verdicts and nine ambiguities; the contract above includes every fix: the root keeps `height` with `min-height: max-content` (a plain `min-height` collapsed `height: "100%"` children), primitives never flex-shrink, the mobile rules see inside instances, `fixed` under a transformed ancestor is unplaceable, condition 2 counts spacers, the FAB is inside the tab-bar's box and reservation, sticky is checked stuck in detailed mode, the document bottom honours an `overflow` root's scroll height, top-pinned bars get the mirror condition, and the icon, tab-bar, button `minHeight`, sheet-scroll and reuse rules are pinned. + - *Known limits, recorded.* Text checks (contrast, typography, the slop-copy tell) read text nodes only, so primitive labels are not contrast-checked; label colours are picked by contrast instead. Coverage still does not see list-row lists (Analyze). + +- **As built (slice D).** Where the build settles what the contract left open: + - *A fixed node with no `x`, `right` or `width` spans the root's column* (`left` and `right` both at the root's edges). Without it such a node shrank to its content: a fixed bar with no width rendered 0px wide. + - *List-row vertical padding is 6, not 8.* With 8, a row with a subtitle rendered 57px tall (16px title and 14px subtitle at line-height 1.3, plus a 2px gap), over the 56 the rules measure. + - *Two colour props* carry the roles the contract names: `secondaryColor` (list-row subtitle, trailing text and chevron; default `$text-secondary`) and `badgeColor` (tab and list-row badges; tab-bar default `$danger`). + - *`data-fs-primitive`* marks every primitive's root element, and the layout walker scans for `data-fs-label` only under it, so the scan never runs on ordinary frames (it would be quadratic in depth). + - *On a mobile canvas the whole usability category reads the expanded tree*, so label association and vague-copy findings inside an instance report on the instance too. Web keeps the unexpanded tree. + - *A pinned bar's height* counts a text child as unknown (the contract lists numeric heights, primitives and frames only), so a bar holding text gets the detailed-mode info. + - *Verify round.* Four verifiers, the docs steward and a cold reader checked the build; these changes came out of it: + - *A root child with `height: "100%"` renders as `flex: 1 1 auto`* (with `min-height: 0` unless it sets one). With plain `100%` under the growing root, a web page's `nav + main { height: 100% }` grew past the artboard instead of filling it; now `main` fills what the nav leaves and grows only with taller content. A root `minHeight` is the author's floor and replaces `min-height: max-content`. A root with `layout` `horizontal` or `grid` is unchanged. + - *Pinned offsets measure from the body, capped at the root.* Horizontal `fixed` offsets use the capture width (the body's max width, where the root starts) and the width cap is the root's own width, so a 390 root captured at 1440 keeps its tab bar 390 wide at its left edge. + - *`fullPage` keeps the artboard floor* (`min-height` marked `!important`), so a short design captures exactly as the viewport does. + - *Sheet children never shrink* (a render flag gives each direct child `flex-shrink: 0`); a wrapper element was tried and dropped because the layout walker only descends through elements with node ids. + - *Labels:* a short trailing text keeps its width beside a long list-row title (the title is `flex: 1 1 0`); a button label truncates inside its column (`max-width: 100%`); the nav-bar title sits between equal-flex sides and truncates before it reaches an action; the FAB rises past the band's 1px border to sit exactly 16px above it. + - *Malformed props never throw:* labels accept numbers, null entries in `items` / `trailing` are skipped, a zero badge renders nothing. A button that skipped colour defaults (one inside a component, which `resolveVariables` never reaches, a pre-existing gap for every control) falls back to the primary look instead of a white label on nothing. + - *Overlap:* the detailed consistency check no longer reports siblings that content scrolls under: `sticky` nodes, pinned chrome, a sheet, and a `fixed` bar pinned to the bottom. So a tab bar with a home indicator, or a sheet, over a list is not an error. In-flow overlap is still an error, and so is a `fixed` bar pinned to the top over in-flow content (it hides that content at scroll position 0; the 3b round caught the first version exempting every `fixed` node). + - *Token census:* the primitives' copy and enum props (`title`, `subtitle`, `label`, `time`, `variant`, `size`, `detent`, `appearance`, `iconPosition`) are not style values and no longer dilute "N% of values use design tokens". + - *Stress:* truncation infos are collected apart and added after the comparison, so twenty truncating rows can never use up the finding cap before a real clip (that was a false CLEAN). + - *Instances:* the mobile rules read the tree with every instance expanded and its inner ids prefixed by the instance path, so two instances of one component are two sets of findings, each on its own instance; detailed mode renders that same tree, so its rects are unambiguous too. + - *Detailed safe area:* the document bottom is the root's rect (plus its scroll height for an overflow root), so a long sheet or a clipped scroller no longer stretches it; content inside a frame that clips or scrolls is not checked against the end of the scroll; a fixed node under a blurred or animated ancestor is not a pinned bar. + - *`test-discoverability.ts`* strips comments before reading the `NodeType` union: a `;` inside a comment ended the match early, so the node-type check had never seen `skeleton`, `chart` or any slice D type. + - *After the 3b round* (one fix round, each fix proven red on 021224c and green after; not re-verified by an agent): + - *Nav-bar title:* the sides keep their actions' width and only the title shrinks. The verify-round version let a long title run under the actions. + - *Right offsets* of a `fixed` node measure from the root's right edge (`bodyWidth − columnWidth` in from the body's), matching the left-edge fix. + - *Clipping frames:* only a frame with a numeric height and a non-visible `overflow` keeps its content out of the end-of-scroll check; an auto-height `overflow: hidden` card clips nothing vertically. + - *`mobile-tab-bar` counts the items that render*, so a null entry is neither a tab nor an unlabeled one. + - *The mobile rules expand instances after resolving the canvas*, as the renderer does, so a component's own `$refs` read as the screenshot draws them and detailed mode's rects match the screenshot. + - *The five-tab i18n acceptance* uses labels long enough to truncate. Short labels ("Home", "Profile") still fit a 78px slot after i18n's ×1.4, so they report nothing, which is also correct (no warning). + **Slice E — mobile starting points (FR-E1..E4)** - `src/types.ts:316-326` (`Structure`), `src/structures.ts:1466-1468` (`listStructures`), `:1618` (`applyStructure`), `list_structures` at `src/index.ts:1449-1452` (whose docstring also misses the four state scaffolds today; fix it in passing). - Six mobile realisations in `src/structures.ts`, referencing type roles and $tokens only. diff --git a/src/evaluate.ts b/src/evaluate.ts index d41a3a8..f72f011 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -1,13 +1,14 @@ import type { Canvas, SceneNode, DesignVariables } from './types.js'; import { resolveVariables } from './variables.js'; import { getCanvasTokens } from './workspaces.js'; -import { renderToHtml } from './renderer.js'; +import { renderToHtml, resolveInstance } from './renderer.js'; import { computeLayout, type LayoutRect } from './screenshot.js'; import { extractInventory, expandInstances, isDataLike } from './drift.js'; import { getIconSvg, parseIconRef } from './icons.js'; import { storedGenre } from './genre.js'; -import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, type SurfaceType } from './surfaces.js'; -import { checkArtboard, checkHandbuiltTargets, checkOneColumn, checkSafeAreaDetailed, checkSafeAreaFast, checkTypeFloor, paddingSides, type MobileContext } from './mobile-rules.js'; +import { relaxedByPreset, relaxedByGenre, canonicalGenre, genreRulesets, activeRulesets, composeRulesets, resolveSurface, deviceProfile, DEVICE_PROFILES, DEFAULT_DEVICE, MOBILE_RULES, type SurfaceType } from './surfaces.js'; +import { checkArtboard, checkHandbuiltTargets, checkOneColumn, checkSafeAreaDetailed, checkSafeAreaFast, checkTabBars, checkTypeFloor, paddingSides, type MobileContext } from './mobile-rules.js'; +import { CHROME_EDGE, FAB_SIZE, NAV_ACTION, TAPPABLE_PRIMITIVES, buttonBox, effectivePosition, listRowMinHeight, navActions, pinsBottom, tabItemBox, tabItems } from './primitives.js'; // --- Types --- @@ -724,7 +725,8 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { } } - // Token usage + // Token usage (copy and variant names on the mobile primitives are not + // style values — they could never be tokens) const hasVariables = canvas.variables && ( Object.keys(canvas.variables.colors ?? {}).length > 0 || Object.keys(canvas.variables.spacing ?? {}).length > 0 @@ -734,7 +736,7 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { let totalProps = 0; for (const { node } of entries) { for (const [key, val] of Object.entries(node)) { - if (['id', 'type', 'name', 'children'].includes(key)) continue; + if (['id', 'type', 'name', 'children'].includes(key) || PRIMITIVE_COPY_PROPS.has(key)) continue; if (typeof val === 'string') { totalProps++; if (val.startsWith('$')) tokenRefs++; @@ -762,8 +764,16 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { score -= 10 * iconFindings.length; issues.push(...iconFindings); - // Component reuse - if (totalNodes > 10 && instanceNodes === 0) { + // Component reuse. Phase 30 slice D — a repeated primitive (list rows, + // buttons, tab bars) is reuse too: the primitive is the component. + const primitiveCounts = new Map(); + for (const e of entries) { + if (e.node.type === 'button' || e.node.type === 'list-row' || e.node.type === 'tab-bar') { + primitiveCounts.set(e.node.type, (primitiveCounts.get(e.node.type) ?? 0) + 1); + } + } + const repeatsPrimitive = [...primitiveCounts.values()].some((n) => n >= 2); + if (totalNodes > 10 && instanceNodes === 0 && !repeatsPrimitive) { issues.push({ category: 'structure', severity: 'info', @@ -776,6 +786,32 @@ function checkStructure(entries: NodeEntry[], canvas: Canvas): CheckResult { return { score: Math.max(0, Math.min(100, Math.round(score))), issues }; } +/** Every icon reference in a tree: icon nodes' `icon`, and the mobile + * primitives' icon props (a blank glyph in a tab bar is the same defect). */ +function iconSlots(root: SceneNode): Array<{ node: SceneNode; path: string; ref: string; flat: boolean; required: boolean }> { + const out: Array<{ node: SceneNode; path: string; ref: string; flat: boolean; required: boolean }> = []; + const add = (node: SceneNode, path: string, ref: unknown, flat: boolean, required: boolean) => { + if (typeof ref === 'string' || required) out.push({ node, path, ref: typeof ref === 'string' ? ref : '', flat, required }); + }; + (function walk(n: SceneNode) { + if (n.type === 'icon') add(n, 'icon', n.icon, true, true); + else if (n.type === 'button') add(n, 'icon', n.icon, true, false); + else if (n.type === 'list-row' || n.type === 'nav-bar') { + add(n, 'leading.icon', n.leading?.icon, false, false); + if (n.type === 'nav-bar' && Array.isArray(n.trailing)) n.trailing.slice(0, 2).forEach((a, i) => add(n, `trailing[${i}].icon`, a?.icon, false, false)); + } else if (n.type === 'tab-bar') { + (Array.isArray(n.items) ? n.items : []).forEach((it, i) => add(n, `items[${i}].icon`, it?.icon, false, true)); + if (n.variant === 'fab') add(n, 'fab.icon', n.fab?.icon, false, false); + } + n.children?.forEach(walk); + })(root); + return out; +} + +/** Phase 30 slice D — the mobile primitives' copy and enum props: words and + * variant names, never style values, so the token census skips them. */ +const PRIMITIVE_COPY_PROPS = new Set(['title', 'subtitle', 'label', 'time', 'variant', 'size', 'detent', 'appearance', 'iconPosition']); + /** Spellings worth trying for an icon name that didn't resolve: Material * Symbols names multi-word icons with `_` (chevron_right), Lucide with `-` * (chevron-right), and Material needs the `material:` prefix. Each candidate @@ -804,32 +840,37 @@ function checkUnknownIcons(canvas: Canvas): EvaluationIssue[] { const ownIds = new Set(); (function walk(n: SceneNode) { ownIds.add(n.id); n.children?.forEach(walk); })(canvas.root); - const icons: SceneNode[] = []; - (function walk(n: SceneNode) { if (n.type === 'icon') icons.push(n); n.children?.forEach(walk); })(expanded); + // Every icon reference: icon nodes, and (Phase 30 slice D) the primitives' + // icon props. `flat` = the prop is the node's own `icon`, so a one-prop + // update fixes it; `required` = a missing name is itself a defect. + const icons = iconSlots(expanded); // Tie-break between candidates by the icon set the rest of the canvas uses: // mixing stroke (Lucide) and fill (Material) glyphs is its own inconsistency. const resolvedSets = new Set(icons - .filter((n) => n.icon && getIconSvg(n.icon, 24, undefined, n.iconStyle)) - .map((n) => parseIconRef(n.icon!).set)); + .filter((sl) => sl.ref && getIconSvg(sl.ref, 24, undefined, sl.node.iconStyle)) + .map((sl) => parseIconRef(sl.ref).set)); const canvasSet = resolvedSets.size === 1 ? [...resolvedSets][0] : null; const issues: EvaluationIssue[] = []; const seen = new Set(); - for (const node of icons) { - if (seen.has(node.id)) continue; - seen.add(node.id); - const ref = (node.icon ?? '').trim(); + for (const { node, path, ref: rawRef, flat, required } of icons) { + const key = `${node.id}\u0000${path}`; + if (seen.has(key)) continue; + seen.add(key); + const ref = rawRef.trim(); if (ref && getIconSvg(ref, 24, undefined, node.iconStyle)) continue; + if (!ref && !required) continue; - const where = ownIds.has(node.id) ? '' : ' (inside a component — fix it in the component definition)'; + const where = (ownIds.has(node.id) ? '' : ' (inside a component — fix it in the component definition)') + + (node.type === 'icon' ? '' : ` (${node.type} ${path})`); if (!ref) { issues.push({ category: 'structure', severity: 'warning', nodeId: node.id, nodeName: node.name, - message: `Icon node has no icon name${where} — it renders as an empty slot.`, + message: `${node.type === 'icon' ? 'Icon node has' : 'Tab item has'} no icon name${where} — it renders as an empty slot.`, suggestion: 'Set icon to a Lucide name ("chevron-right") or a Material Symbols name with the material: prefix ("material:chevron_right"), or delete the node.', }); continue; @@ -848,7 +889,7 @@ function checkUnknownIcons(canvas: Canvas): EvaluationIssue[] { ? `Did you mean ${candidates.map((c) => `"${c}"`).join(' or ')}? Lucide names use hyphens (chevron-right); Material Symbols names use underscores and need the material: prefix (material:chevron_right).` : 'Lucide names use hyphens (chevron-right); Material Symbols names use underscores and need the material: prefix (material:chevron_right). Check the spelling against lucide.dev or fonts.google.com/icons.', }; - if (pick && ownIds.has(node.id)) { + if (pick && flat && ownIds.has(node.id)) { issue.fix = { op: formatUpdateOp(node.id, { icon: pick }), rationale: `"${ref}" doesn't exist; "${pick}" is the spelling that resolves${candidates.length > 1 ? ' in the icon set this canvas already uses' : ''}.`, @@ -1010,12 +1051,20 @@ function checkConsistencyDetailed(entries: NodeEntry[], layoutRects: LayoutRect[ if (childRects.length < 2) continue; - // Check if non-absolute siblings overlap + // Check if in-flow siblings overlap. Positioned nodes overlap by design: + // absolute, and (Phase 30 slice D) what content scrolls under — sticky + // nodes, pinned chrome, a sheet, a bar pinned to the bottom. A bar pinned + // to the top still hides what sits under it at scroll position 0. + const layered = (n: SceneNode) => { + const position = effectivePosition(n); + return position === 'absolute' || position === 'sticky' || n.type === 'sheet' || Object.hasOwn(CHROME_EDGE, n.type) + || (position === 'fixed' && pinsBottom(n)); + }; for (let i = 0; i < childRects.length; i++) { for (let j = i + 1; j < childRects.length; j++) { const a = childRects[i]; const b = childRects[j]; - if (a.node.position === 'absolute' || b.node.position === 'absolute') continue; + if (layered(a.node) || layered(b.node)) continue; const overlapX = Math.max(0, Math.min(a.rect.x + a.rect.width, b.rect.x + b.rect.width) - Math.max(a.rect.x, b.rect.x)); const overlapY = Math.max(0, Math.min(a.rect.y + a.rect.height, b.rect.y + b.rect.height) - Math.max(a.rect.y, b.rect.y)); @@ -1875,14 +1924,41 @@ function mobileTarget( return { w: tw, h: th }; } +/** Phase 30 slice D — the touch targets a tappable primitive carries, each + * its own rendered box (never extended by an ancestor). A width the tree + * doesn't fix is left undefined and not judged. */ +function primitiveTargets( + node: SceneNode, parentOf: Map, rootWidth: number, +): Array<{ what: string; w?: number; h: number; fix: string }> { + if (node.type === 'button') { + const b = buttonBox(node); + return [{ what: 'button', ...b, fix: 'Use size "md" (44px) or "lg" (52px) — "sm" is for pointer surfaces — and drop any smaller height.' }]; + } + if (node.type === 'list-row') { + const h = Math.max(typeof node.height === 'number' ? node.height : 0, listRowMinHeight(node)); + return [{ what: 'list-row', w: knownWidth(node, parentOf, rootWidth), h, fix: 'Let the row span its list (no width), or widen it.' }]; + } + if (node.type === 'tab-bar') { + const box = tabItemBox(node, rootWidth); + const items = tabItems(node).map((it, i) => ({ what: `tab "${it?.label ?? i + 1}"`, ...box, fix: 'Widen the tab bar (drop its width) or move a destination out of it.' })); + return node.variant === 'fab' ? [...items, { what: 'tab-bar FAB', w: FAB_SIZE, h: FAB_SIZE, fix: '' }] : items; + } + if (node.type === 'nav-bar') { + return navActions(node).map((a) => ({ what: `nav-bar action "${a.label ?? a.icon ?? 'back'}"`, w: NAV_ACTION, h: NAV_ACTION, fix: '' })); + } + return []; +} + function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor: number }): CheckResult { const issues: EvaluationIssue[] = []; let score = 100; const parentOf = new Map(); for (const en of entries) parentOf.set(en.node.id, en.parent); + // On a phone a row holds "one control" only when no tappable primitive + // shares it either — a checkbox beside a button doesn't own the row. const countControls = (n: SceneNode): number => { - let c = CONTROL_TYPES.has(n.type) ? 1 : 0; + let c = CONTROL_TYPES.has(n.type) || (touch !== undefined && TAPPABLE_PRIMITIVES.has(n.type)) ? 1 : 0; for (const ch of n.children ?? []) c += countControls(ch); return c; }; @@ -1893,6 +1969,23 @@ function checkUsability(entries: NodeEntry[], rootWidth: number, touch?: { floor 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)) { + if ((t.w !== undefined && t.w < touch.floor) || t.h < touch.floor) { + score -= 25; + issues.push({ + category: 'usability', + rule: 'mobile-touch-target', + severity: 'error', + nodeId: node.id, + nodeName: node.name, + message: `${t.what} has a ${t.w === undefined ? '?' : Math.round(t.w)}×${Math.round(t.h)}px touch target — below the ${touch.floor}px floor on a phone (Apple HIG; WCAG 2.5.5).`, + ...(t.fix ? { suggestion: t.fix } : {}), + }); + } + } + } + if (CONTROL_TYPES.has(node.type)) { const def = CONTROL_DEFAULT_SIZE[node.type]; let w = typeof node.width === 'number' ? node.width : def.w; @@ -2108,6 +2201,35 @@ function checkCoverage(canvas: Canvas, designedStates: string[]): { result: Chec }; } +/** Phase 30 slice D — the tree with every instance expanded into what it + * renders, for the mobile rules. Nodes inside an instance get ids prefixed by + * the instance path (`inst/child`), so two instances of one component never + * share an id, and `owners` maps each such id to the outermost instance. */ +function expandForRules(root: SceneNode, canvas: Canvas): { root: SceneNode; owners: Map } { + const owners = new Map(); + const walk = (node: SceneNode, owner: SceneNode | undefined, prefix: string): SceneNode => { + const id = owner ? prefix + node.id : node.id; + if (owner) owners.set(id, owner); + const resolved = node.type === 'instance' && node.componentId ? resolveInstance(node, canvas) : null; + if (resolved) { + const childPrefix = `${prefix}${node.id}/`; + return { ...resolved, id, children: resolved.children?.map((c) => walk(c, owner ?? node, childPrefix)) }; + } + return { ...node, id, children: node.children?.map((c) => walk(c, owner, prefix)) }; + }; + return { root: walk(root, undefined, ''), owners }; +} + +/** Re-point findings on nodes inside an instance at the instance (the + * component holds the node), naming the inner node. */ +function reportOnInstances(issues: EvaluationIssue[], owners: Map): EvaluationIssue[] { + return issues.map((i) => { + const owner = i.nodeId ? owners.get(i.nodeId) : undefined; + if (!owner) return i; + return { ...i, nodeId: owner.id, nodeName: owner.name, message: `${i.message} (inside instance "${owner.name ?? owner.id}" — fix it in its component)` }; + }); +} + export async function evaluateCanvas( canvas: Canvas, options: { mode: 'fast' | 'detailed' | 'llm'; categories?: string[]; genre?: string; designedStates?: string[] }, @@ -2127,8 +2249,16 @@ export async function evaluateCanvas( const rules = composeRulesets(activeRulesets({ type: declared.type, genre: resolvedGenre.genre, preset })); const surface: SurfaceReport = { ...declared, rulesOn: rules.on, rulesOff: rules.off, conflicts: rules.conflicts }; const ruleOn = new Set(rules.on); + // Phase 30 slice D — the mobile rules judge what the renderer draws, so they + // read the tree with instances expanded (a tab bar inside a component is + // still a tab bar). Findings inside an instance report the instance. + // Expanded after resolution, as the renderer does it: a component's own + // $refs stay as the screenshot draws them. + const { root: mobileRoot, owners } = expandForRules(resolvedRoot, canvas); + const expandedRaw = expandForRules(canvas.root, canvas).root; + const mobileTypeRules = MOBILE_RULES.some((id) => ruleOn.has(id)); const mobile: MobileContext = { - root: resolvedRoot, + root: mobileRoot, profile: deviceProfile(declared.device ?? DEFAULT_DEVICE) ?? DEVICE_PROFILES[DEFAULT_DEVICE], width: typeof resolvedRoot.width === 'number' ? resolvedRoot.width : 1440, height: typeof resolvedRoot.height === 'number' ? resolvedRoot.height : 900, @@ -2189,7 +2319,10 @@ export async function evaluateCanvas( if (options.mode === 'detailed' && (activeCategories.includes('consistency') || activeCategories.includes('typography') || safeAreaDetailed)) { const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; - const html = renderToHtml(resolvedRoot, w, h, canvas); + // On a mobile canvas render the tree the mobile rules read: the same + // pixels as the canvas render, with every node inside an instance under + // its own id, so the rects are unambiguous. Web keeps the canvas render. + const html = mobileTypeRules ? renderToHtml(mobileRoot, w, h, canvas) : renderToHtml(resolvedRoot, w, h, canvas); detailedLayout = await computeLayout(html, undefined, ruleOn.has('mobile-safe-area') ? 25 : 10, { width: w, height: h }); } if (activeCategories.includes('typography')) { @@ -2198,11 +2331,11 @@ export async function evaluateCanvas( layout: detailedLayout, tables: extractInventory(resolvedRoot).tables, }); - const rawById = new Map(rawEntries.map((en) => [en.node.id, en.node])); - results.set('typography', withRuleIssues(typography, ruleOn.has('mobile-type-floor') ? checkTypeFloor(mobile, rawById) : [])); + const rawById = new Map(buildTreeContext(expandedRaw).map((en) => [en.node.id, en.node])); + 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') ? checkArtboard(mobile, declared) : [])); + results.set('structure', withRuleIssues(checkStructure(rawEntries, canvas), ruleOn.has('mobile-artboard') ? reportOnInstances(checkArtboard(mobile, declared), owners) : [])); } if (activeCategories.includes('consistency')) { const consistencyCtx = { rawEntries, tokens: mergedTokens }; @@ -2214,12 +2347,17 @@ export async function evaluateCanvas( } if (activeCategories.includes('usability')) { const touch = ruleOn.has('mobile-touch-target') ? { floor: mobile.profile.touchTarget } : undefined; - const usability = checkUsability(entries, mobile.width, touch); - const extra = [ + // On a mobile canvas the whole usability check reads the expanded tree; + // web keeps the unexpanded one. + const usability = mobileTypeRules + ? (({ score, issues }) => ({ score, issues: reportOnInstances(issues, owners) }))(checkUsability(buildTreeContext(mobileRoot), mobile.width, touch)) + : checkUsability(entries, mobile.width, touch); + const extra = reportOnInstances([ ...(ruleOn.has('mobile-handbuilt-target') ? checkHandbuiltTargets(mobile) : []), ...(ruleOn.has('mobile-safe-area') ? (detailedLayout ? checkSafeAreaDetailed(mobile, detailedLayout) : checkSafeAreaFast(mobile)) : []), ...(ruleOn.has('mobile-one-column') ? checkOneColumn(mobile) : []), - ]; + ...(ruleOn.has('mobile-tab-bar') ? checkTabBars(mobile) : []), + ], owners); results.set('usability', withRuleIssues(usability, extra)); } @@ -2278,7 +2416,7 @@ export async function evaluateCanvas( let totalStringProps = 0; for (const { node } of rawEntries) { for (const [key, val] of Object.entries(node)) { - if (['id', 'type', 'name', 'children'].includes(key)) continue; + if (['id', 'type', 'name', 'children'].includes(key) || PRIMITIVE_COPY_PROPS.has(key)) continue; if (typeof val === 'string') { totalStringProps++; if (val.startsWith('$')) tokenRefs++; diff --git a/src/index.ts b/src/index.ts index 53c19e9..9711802 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,7 +83,7 @@ Bulk edits & queries: replace_matching_properties applies one property change to Point-and-tell feedback: the user toggles Comment mode in the viewer and clicks any element to leave a note anchored to that node (or to the whole page). Comments are stored on the canvas, git-diffable in bound repos, and reach the running server automatically. Check get_feedback when picking up a canvas — each entry carries the anchor nodeId plus a node snapshot, enough to act on immediately. Open feedback blocks presenting, same as open inspector comments: address every item, then close each via resolve_feedback with a one-line note saying what changed (your note shows up as a reply in the viewer's Feedback tab). -Phone app screens: declare the medium. canvas_create({ type: "mobile" }) makes a phone screen — the device artboard (390×844 for the default device "iphone-390") with the root reserving the safe-area insets as safeArea: { top: 47, bottom: 34 } (the renderer adds them to the root's own top/bottom padding, so on a screen that fits its artboard in-flow content never sits under the status bar or home indicator). type: "web" (the default) is a web page; canvas_set_type changes or clears the declaration on an existing canvas (it never resizes or writes safeArea — its result's hint names the op that does). The type selects a ruleset and defaults, never the render: generate_design_system on a mobile canvas pivots the type scale on the device body size (17px; 16px for data-dense), and every evaluate result carries a "surface" report ({ type, device, source "declared" | "default", rulesOn, rulesOff, conflicts }) naming the rule ids the type + genre + preset rulesets turned on and off — a rule both enabled and disabled stays on (in rulesOn) and is listed in conflicts. A mobile canvas runs the mobile ruleset; every finding names its rule in a "rule" field: mobile-touch-target (ERROR: a control's touch target below 44px — its row counts only when the row is a horizontal frame holding that one control), mobile-safe-area (WARNING: content under the 47px status-bar or 34px home-indicator inset; with root safeArea only absolute content can get there, without it the first/last content must clear the insets — padding ≥32px scales down on a phone, so use a spacer frame inside your content frame; detailed mode is authoritative and also catches a long screen ending in the bottom inset), mobile-type-floor (WARNING: text below 11px, $body below 16px, prose of 5+ words below 13px), mobile-handbuilt-target (info: an icon-shaped frame below 44px), mobile-one-column (info: two or more side-by-side children each holding 8+ words), and on every type mobile-artboard (a mobile canvas 500px+ wide WARNS; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it either way). +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. 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). Gotchas (current sharp edges): - Row rules and accent bars are per-side borders — borderTop: { width: 1, color: "$border" } on each table row, borderLeft: { width: 3, color: "$primary" } for an accent edge (style "dashed"|"dotted" for forecast/draft outlines; strokeDasharray dashes SVG paths). Never fake hairlines with gap: 1 + background bleed-through. @@ -112,7 +112,7 @@ const WORKFLOW_CHEATSHEET = [ 'Final polish when an LLM API key is present: canvas_evaluate mode "llm" (the five-axis vision rubric) → canvas_revise on failing axes → re-judge until it passes or canvas_revise stops. The heuristic gate is the floor; the critique loop is the ceiling — make it the standard last step before presenting, not an exception. It never blocks: still-failing axes after canvas_revise stops get named to the user, not looped on. Keyless sessions skip it (a note says so), nothing else changes.', 'One canvas per screen / state; let the per-project build log nudge you to vary structure.', 'Data screens: design every state — canvas_add_variant for "empty" / "loading" ("error" for forms) + the empty-state / skeleton-table / skeleton-stat-card scaffolds (coverage warns until they exist) — and run canvas_stress before presenting (fix clips/overflows with fluid widths, then re-run until CLEAN — a FRAGILE run keeps canvas_evaluate NOT READY until a full CLEAN run).', - 'Usability gates like contrast does: a control under the 24px hit-target floor is an ERROR (WCAG 2.5.8; a lone control inherits its padded row\'s box; on a canvas declared mobile the floor is 44px, mobile-touch-target), an unlabeled control is a warning, "Click here"-style copy is an info. The stock scaffolds already pass — hand-built controls are where these bite.', + '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.', '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.', @@ -140,7 +140,7 @@ const GOTCHAS = [ 'Every state, every string: a data screen is not done at one static frame with ideal data. Tables demand designed "empty" + "loading" state variants, forms demand "error" (the coverage category warns until they exist — each is one canvas_add_variant + one scaffold stamp: empty-state / skeleton-table / skeleton-card / skeleton-stat-card); skeleton blocks pulse only in the live viewer, never in screenshots. Skeleton EVERY data surface on a loading variant, not just the table — coverage flags a "half-designed" loading state when a region still shows live values beside skeletoned ones. Then canvas_stress before presenting: it renders hostile-but-realistic content (long-text / i18n / big-numbers / empty / many) and reports clips and overflows by node id — fix with fluid widths / minWidth or minHeight floors / wrapping, or textOverflow: "ellipsis" on a label that must stay single-line (that downgrades its clip to info instead of warning), and re-run until CLEAN. canvas_evaluate remembers the last stress verdict: after a FRAGILE run the directive stays NOT READY until a full CLEAN run (all five perturbations) replaces it — a subset run can record FRAGILE but never clear it.', 'Derive, don\'t hand-pick: generate_design_system (seed + REQUIRED personality: technical/editorial/soft/data-dense) writes the whole design language in one call — including $display/$heading/$title/$body/$label typography roles, $elevation.* shadow tokens (reference as shadow: "$elevation.raised"; the dark layer re-states depth), and $motion defaults. For targeted regeneration only: generate_scale gives a modular type + space scale from a ratio (craft defaults baked in; fluid clamp() optional); generate_color_system gives OKLCH ramps + a matched neutral + status colors + the semantic tokens the structures use, AA by construction, WITH a dark theme (sparse dark.colors layer) AND the color range — chart-1…chart-6 for series/segments/legend dots, plus the accent/success/warning/danger/neutral -tint layer for chips/tiles/badges (pair tint as fill with its base color as ink; AA by construction, dark included). Render dark via theme: "dark" on screenshot/screenshot_responsive/export; canvas_evaluate contrast-checks both themes automatically (dark failures point at the dark layer — never write a literal fix; APCA Lc is info-only, WCAG 2.2 gates). Literals that equal a token get re-attached by autofix; timing belongs in $motion tokens.', 'Beyond one canvas: bento/editorial compositions are REAL grid — layout: "grid" + gridColumns (count / fr-weight array / template string) + gridColumn/gridRow spans (a number means span N); responsive: "stack" collapses to one column on mobile. Never approximate tiles with nested flex. When a multi-screen module feels done, project_evaluate reviews the SET — radius-drift / accent-drift / token-adoption / copied-chrome / state-coverage findings, each naming its canvases — and mode "llm" judges up to 8 screens together for flow consistency (flowSkipped lists anything past the cap; keyless → full roll-up + a note). ADVISORY: only the per-canvas directives gate presenting.', - 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. On mobile a control below 44px is an ERROR (mobile-touch-target) and content in the status-bar / home-indicator insets is a WARNING (mobile-safe-area): keep root safeArea, and end the content frame of a screen taller than its artboard with a 34px spacer frame — not a spacer directly under the root, which shrinks (detailed mode checks the scroll end). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', + 'Declare the medium: a phone app screen is canvas_create({ type: "mobile" }) (device "iphone-390": 390×844, root safeArea: { top: 47, bottom: 34 } added to the root padding by the renderer) — not a 390px-wide web canvas. canvas_set_type re-declares an existing canvas (type: "web" | "mobile" | null) WITHOUT resizing it or writing safeArea; follow its hint. The type changes the ruleset and defaults only (generate_design_system pivots on a 17px body, 16 for data-dense), never pixels; evaluate\'s "surface" report shows the type, its source ("declared" | "default"; canvas_create reports the same as typeSource) and the rulesOn / rulesOff / conflicts the rulesets composed. Build a phone screen from the mobile primitives — status-bar, nav-bar, list-row, button, tab-bar, home-indicator, sheet — not hand-built frames: they carry 44px targets and pin themselves. On mobile a tap target below 44px is an ERROR (mobile-touch-target), content in the status-bar / home-indicator insets or left under a pinned bar is a WARNING (mobile-safe-area), and a tab bar outside 2–5 labeled tabs or not pinned to the bottom WARNS (mobile-tab-bar). Keep root safeArea; the root grows with long content and keeps its end padding. A pinned CTA bar: position "fixed", bottom: 0, padding under 32 (larger padding scales down on a phone) and a 34px spacer frame as its last child, plus a spacer at the end of your content frame tall enough that it and the root\'s 34px bottom inset reach the bar\'s height (the rule names it: 68 for a 102px bar; re-evaluate after changing the bar). An undeclared canvas under 500px wide WARNS (mobile-artboard) until you declare it either way. canvas_set_type does not reach existing state variants — its variantsOutOfSync lists them.', 'Gate integrity: a canvas describing a SHIPPED view is a contract — run canvas_check_drift against the live route BEFORE designing on it (findings: missing-in-page / missing-in-canvas / control-mismatch / table-mismatch), and reconcile deliberately: update the canvas, flag the implementation, or ask — never silently annotate a difference. canvas_sync_from_url answers "how much does it LOOK different" (pixel %); canvas_version makes approvals falsifiable — record { canvasId, versionHash } at approval time and check with expectedHash later (metadata/feedback never moves the hash; ANY inherited workspace/project token edit DOES, even to a token the canvas does not use). A legacy sha256: hash still checks but carries a warning — re-record it as the current sha256r: versionHash. CI can demand both: npx framesmith check-drift / verify exit 1 on failure.', ]; @@ -173,7 +173,7 @@ server.resource( // --- canvas_create --- server.tool( 'canvas_create', - 'Create a new design canvas. Returns the canvas ID, root node ID, project assignment, the artboard `width`/`height`, viewer URL, and a `diversification` signal — the recently-built structures in this project plus a hint to differ on at least one taxonomy axis, so successive canvases don\'t converge on the same layout. Always share the viewer URL with the user so they can see the design live in their browser. If `projectId` is omitted, the canvas lands in the default Untitled project. The artboard defaults to 1440×900; pass `width`/`height` for another size. Building a PHONE APP screen? Pass `type: "mobile"` — it declares the canvas a phone screen (it selects the mobile ruleset, and generate_design_system gives it a 17px body) and gives you the device artboard (390×844 for the default `device: "iphone-390"`) with the root reserving the device\'s safe-area insets as `safeArea: { top: 47, bottom: 34 }` (the renderer adds them to the root\'s top/bottom padding, so on a screen that fits its artboard in-flow content never sits under the status bar or home indicator). `type: "web"` (the default) declares a web page. The result reports `type`, `typeSource` ("declared" | "default"), and for mobile `device` + `safeArea`. Change it later with canvas_set_type.', + '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.', { 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.'), @@ -557,8 +557,8 @@ Use "document" to reference the root node. Bind results to reuse IDs: header=I(" Concatenate bindings: U(header+"/childId", {...}) Returns { ok, nodeIds, results }: nodeIds maps each bound variable to the node ID it created (e.g. { "header": "n_a1b2" }) — record it and use those IDs to target nodes in later calls (bindings only live within a single call). results lists each op's outcome in order. If the call wrote a fontFamily nothing can serve yet (not cached, not registered, not system/generic), an extra "Font warnings" content item names it — cache-only check, no network, so it can't catch everything set_fonts/network resolution would. -Node types: frame, text, rectangle, ellipse, image, icon, path, component, instance, toggle, checkbox, radio, select, chart, skeleton -Properties: fill, gradient, stroke, strokeWidth, strokeStyle, borderTop, borderRight, borderBottom, borderLeft, cornerRadius, width, height, minWidth, minHeight, maxWidth, layout ("horizontal"|"vertical"|"grid"), gap (0 = flush, never flagged as off-scale), rowGap, gridColumns, gridColumn, gridRow, padding, alignItems, justifyContent, fontSize, fontFamily, fontWeight, color, content, textAlign, lineHeight, letterSpacing (px), textDecoration, textTransform ("uppercase" etc. — don't bake casing into content), textOverflow ("ellipsis" — designed single-line truncation for labels that must survive hostile-length content; canvas_stress reports clips behind it as info, not warnings), tabularNums (fixed-width digits so number columns align — table scaffolds and chart ticks carry it by default), fontVariationSettings (variable-font axes, e.g. '"wght" 650'), src, objectFit, opacity, shadow, shadows (plural wins — a \`shadow\` written where \`shadows\` is set is ignored, and the op result carries a warning saying so), blur, backdropBlur, backdropFilter, overflow, wrap, position, x, y, icon, iconSize, iconColor, iconStyle, checked, disabled, value, pulse, d, viewBox, strokeLinecap, strokeLinejoin, strokeDasharray, animation, transition (object, or the string "$motion." referencing a motion token), kind, series, segments, innerRatio, centerValue, centerLabel, sparkKind, xDomain, yDomain, curve, gridlines, xLabels, yLabels, componentId, overrides, safeArea ({ top, bottom }, document root only — insets the renderer adds to the root's top/bottom padding; canvas_create type "mobile" writes the device's), responsive +Node types: frame, text, rectangle, ellipse, image, icon, path, component, instance, toggle, checkbox, radio, select, chart, skeleton, status-bar, home-indicator, nav-bar, tab-bar, button, list-row, sheet +Properties: fill, gradient, stroke, strokeWidth, strokeStyle, borderTop, borderRight, borderBottom, borderLeft, cornerRadius, width, height, minWidth, minHeight, maxWidth, layout ("horizontal"|"vertical"|"grid"), gap (0 = flush, never flagged as off-scale), rowGap, gridColumns, gridColumn, gridRow, padding, alignItems, justifyContent, fontSize, fontFamily, fontWeight, color, content, textAlign, lineHeight, letterSpacing (px), textDecoration, textTransform ("uppercase" etc. — don't bake casing into content), textOverflow ("ellipsis" — designed single-line truncation for labels that must survive hostile-length content; canvas_stress reports clips behind it as info, not warnings), tabularNums (fixed-width digits so number columns align — table scaffolds and chart ticks carry it by default), fontVariationSettings (variable-font axes, e.g. '"wght" 650'), src, objectFit, opacity, shadow, shadows (plural wins — a \`shadow\` written where \`shadows\` is set is ignored, and the op result carries a warning saying so), blur, backdropBlur, backdropFilter, overflow, wrap, position ("absolute"|"relative"|"fixed"|"sticky"), x, y, bottom, right, icon, iconSize, iconColor, iconStyle, checked, disabled, value, pulse, d, viewBox, strokeLinecap, strokeLinejoin, strokeDasharray, animation, transition (object, or the string "$motion." referencing a motion token), kind, series, segments, innerRatio, centerValue, centerLabel, sparkKind, xDomain, yDomain, curve, gridlines, xLabels, yLabels, componentId, overrides, safeArea ({ top, bottom }, document root only — insets the renderer adds to the root's top/bottom padding; canvas_create type "mobile" writes the device's), responsive, and the mobile primitives' props: time, appearance, title, subtitle, leading, trailing, largeTitle, items, variant, fab, label, iconPosition, size, divider, detent, grabber, secondaryColor, badgeColor Charts: { type: "chart", kind: "line"|"bar"|"donut"|"sparkline", series: [{ data: [210, 450, 648], stroke: "$accent", strokeDasharray?: "6 4", area?, points?, highlight?: [2] (a lone index 2 works too; null clears it; an entry that isn't an index into data renders no emphasis and the op result warns), barGradient? }], yDomain?: [0, 2700], curve?: "smooth", gridlines?: 4, xLabels?: ["Jan", ..., "Dec"], yLabels? } — the node does ALL the value→coordinate math (multi-series in one node; x = data index, a shorter series stops early against a longer one; domains auto from data, bars floor at 0). Dash the projected/forecast series, solid the actuals; highlight: [i] renders those bars solid over muted siblings (the selected-day treatment) and barGradient fades the muted bars inside the SVG. DONUT: kind "donut" + segments: [{ value, color: "$chart-1", label? }] + innerRatio? + centerValue/centerLabel — data-bound slices from 12 o\'clock clockwise, center figure rendered tabular; compose the legend yourself from frames + $chart-* dots. SPARKLINE: kind "sparkline" (+ sparkKind "bar"|"line") — axis-free mini-trend for KPI cards, latest point emphasized by default (override with series.highlight). NEVER hand-compute path d-strings, absolutely-position tick labels, fake a donut with conic gradients, or hand-place sparkline bars. @@ -575,6 +575,17 @@ Components: I("parent", { type: "instance", componentId: "cmp-shell", overrides: Input controls: toggle / checkbox / radio / select are real node types — I("parent", { type: "toggle", checked: true }), I("parent", { type: "select", value: "Admin", width: 200 }). Colors default from design tokens ($accent / $border / $bg-surface / $text-primary, neutral fallbacks when unthemed); fill / stroke / color override. NEVER fake a control from frames + ellipses. +Positioning: position "fixed" pins to the screen (the root's width × height is the viewport; content taller than it scrolls and the root grows to it), "sticky" sticks at its y (or bottom) while its container scrolls, "absolute" places against the nearest positioned ancestor. Offsets: x / y / bottom / right (px). A fixed node with no x, right or width spans the root's column; with no y or bottom it sticks to the top. The root grows with content taller than the artboard (a root child with height: "100%" fills what its siblings leave), so an absolute node with bottom set against the root measures from the end of the document, not the screen — use fixed to pin to the screen. A fixed node inside a frame with blur / backdropBlur / backdropFilter or a slideUp / slideDown / scaleIn animation pins to that frame instead of the screen (CSS), so keep pinned bars out of them. fullPage captures grow to the content, so a bottom-pinned bar captures at the end of the image. + +Mobile primitives (phone screens — they carry their own sizes, hit areas, and colours from $accent / $border / $bg-surface / $text-primary / $text-secondary / $danger, neutral fallbacks unthemed; NEVER hand-build them from frames): + - status-bar { time?: "9:41", appearance?: "dark"|"light" } and home-indicator { appearance? } — device chrome, pinned (fixed) top / bottom by default, sized from the root's safeArea (47 / 34 without one). No children. + - nav-bar { title, leading?: { icon?: "chevron-left", label? }, trailing?: [{ icon } | { label }] (max 2), largeTitle? } — 44 tall (96 with largeTitle), in flow; every action is a 44×44 target. + - tab-bar { items: [{ icon, label, active?, badge? }], variant?: "standard"|"floating"|"fab", fab?: { icon } } — pinned to the bottom by default, 49 + the bottom inset (16 more for "fab", whose raised 56px centre action is not a tab). 2–5 labeled tabs. + - button { label?, icon?, iconPosition?: "leading"|"trailing", variant?: "primary"|"secondary"|"ghost"|"destructive", size?: "sm"|"md"|"lg" (32/44/52), disabled? } — md (the default) meets the 44px phone floor; sm is for pointer surfaces and is an ERROR on a mobile canvas. Icon-only is square; width: "100%" for a full-width CTA. + - list-row { title, subtitle?, leading?: { icon } | { initials } | { image }, trailing?: { text } | { chevron: true } | { toggle: boolean } | { badge }, divider? } — 44 tall (56 with a subtitle), spans its list; repeated rows count as reuse. + - sheet { detent?: "medium"|"large"|"auto", grabber?: true, padding, gap, children } — a bottom sheet over a scrim, pinned, padded past the home indicator; its children scroll inside it. +The renderer reserves room for pinned chrome: the root's top/bottom padding is raised to at least the pinned status-bar / tab-bar / home-indicator height, so content never starts or ends under it. Labels truncate to one line with an ellipsis (canvas_stress reports that as designed truncation). + Responsive layout (author desktop-first, adapt down): - responsive: "stack" — on a horizontal container, flips to vertical below 768px (multi-column layouts that should stack on mobile) - responsive: "wrap" — children wrap to the next line instead of overflowing (card grids, tag rows) @@ -807,7 +818,7 @@ server.tool( nodeId: z.string().optional().describe('Specific node ID to screenshot (defaults to full canvas)'), width: z.number().optional().describe('Viewport width in pixels (default: the canvas artboard width)'), height: z.number().optional().describe('Viewport height in pixels (default: the canvas artboard height)'), - fullPage: z.boolean().optional().describe('Capture the whole design rather than one viewport. A canvas taller than its artboard is otherwise cut off at the artboard height, and the only way to see the rest was to edit the root height by hand. Default false, so existing captures are unchanged.'), + fullPage: z.boolean().optional().describe('Capture the whole design rather than one viewport. A canvas taller than its artboard is otherwise cut off at the artboard height, and the only way to see the rest was to edit the root height by hand. The capture grows to the content, so position "fixed" elements land at its ends (a bottom-pinned tab bar at the bottom of the image). Default false, so existing captures are unchanged.'), scale: z.number().optional().describe('Device scale factor (default 2 for retina)'), theme: z.enum(['light', 'dark']).optional().describe('Render theme — "dark" applies the design system\'s dark token layer (dark.colors/dark.elevation overrides); default light. No-op when no dark layer exists.'), }, @@ -2069,9 +2080,9 @@ server.tool( // --- canvas_evaluate --- server.tool( 'canvas_evaluate', - `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are warning/info severity — but EVERY tell blocks the directive regardless of severity. The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width WEB artboards additionally get a 44px touch-first info; on a canvas declared mobile, 44px is an ERROR instead — see the surface paragraph), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node whose name resolves in neither bundled set — it would render as an empty slot, invisible in a screenshot: the suggestion names the spellings that do resolve (hyphen/underscore swap, or the missing material: prefix), and when exactly one does — or several do but only one matches the icon set the rest of the canvas uses — the issue carries a fix canvas_autofix applies. Icons inside component instances are checked too; they point at the component definition and carry no fix op. Modes: + `Auto-score a design canvas against quality criteria. Returns an overall score (0-100), category scores, and actionable issues referencing specific node IDs. Categories: spacing, color, typography, structure, consistency (craft), plus "cliche" — the machine-made tells: default purple/indigo accents, gradient/glow overuse, fake browser/phone chrome (traffic-light dots), the hanging eyebrow-beside-heading header, fabricated-looking metrics/testimonials/logos, too many eyebrow labels (template rhythm — an eyebrow above nearly every section), slop copy (stock AI phrasing: filler verbs, scroll cues, placeholder names like "Jane Doe", hype labels), mixed radius systems (radius consistency — too many distinct corner radii), pure black/white (harsh #000000 ink or a stark #ffffff page vs a designed off-black/off-white), and competing accents (accent consistency — more than one accent hue). cliche issues carry a "tell" discriminator and are warning/info severity — but EVERY tell blocks the directive regardless of severity. The tells know dashboard vocabulary: a violet reached only through a $chart-* or $*-tint token doesn't trip the default-purple tell (a literal violet, or one through any OTHER token, still does), a gradient reached through a $token doesn't count toward gradient-overuse, thin rounded sparkline bars no longer match fake-chrome's small-circle census (it now requires a near-square shape: both dimensions ≤20px, aspect ratio ≤1.5:1, and round), and an uppercase label sitting beside a large tabular figure reads as a KPI-card metric — not counted in the eyebrow-rhythm census. The color category is DUAL-THEME aware: when the design system has a dark token layer (dark.colors — see set_variables / generate_color_system), contrast is checked in BOTH themes — dark-run issues carry theme: "dark", the category score takes the worse theme, and dark failures point at the dark token layer instead of carrying a literal fix (a node-level literal would break the light theme). WCAG 2.2 remains the gate; WCAG-passing pairs that are perceptually weak by APCA (Lc below ~75 body / ~60 large) get an INFO advisory — APCA is a candidate method, not a standard, and never blocks. Plus "coverage" (Phase 24): a BASE canvas whose content carries data — a detected table (empty + loading demanded) or a form of 3+ input controls (error demanded) — gets a directive-blocking WARNING per missing state variant (table detection needs THREE matching horizontal rows to read as data — two stacked rows are a layout, not a table — unless the frame is import-named "Table", which keeps the looser two-row shape); the result's "coverage" field reports { dataBearing, states, missing }. Designing the state is one canvas_add_variant + one scaffold stamp (empty-state / skeleton-table / skeleton-card / skeleton-stat-card); variant canvases themselves and non-data screens get no coverage findings. On a "loading" variant, coverage also flags a HALF-DESIGNED loading state — a data surface (a detected table, or a frame holding 2+ large tabular/numeric texts) still showing live values while a sibling region is skeletoned, one warning per offending region naming it and pointing at the matching skeleton scaffold. Plus "usability" (Phase 27): the mechanical UX floors beyond contrast — hit-target size gates as ERROR below the 24px WCAG 2.5.8 floor (the effective target counts a lone control's padded row, so a well-padded settings row passes; two controls sharing a row are measured bare; phone-width WEB artboards additionally get a 44px touch-first info; on a canvas declared mobile, 44px is an ERROR instead — see the surface paragraph), a control with no text label in its row or group is a WARNING (selects carry their own value text and are exempt), and vague action copy ("Click here" / "Learn more" / "Read more" / "Submit" as the exact label) is an INFO — name the action instead. The structure category also WARNS (directive-blocking, -10 structure score each) on every icon node, 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. + - "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. Designed for generator-evaluator loops: generate with batch_design, evaluate with canvas_evaluate, fix issues targeting the returned nodeIds (canvas_autofix handles the mechanical subset). The result includes a "directive" field — a present/keep-working verdict: it turns on BLOCKING findings, not the score — resolve every error/warning/cliché tell and it reads READY. The score (aim for > 95) is always reported alongside it and is the quality bar to keep climbing, but once nothing blocking is left, a sub-bar score no longer withholds readiness; the directive says so explicitly and names the remaining advisory findings as the lever. An "openFeedback" field (when > 0) counts the user's open point-and-tell comments — they block presenting even at a READY directive; read them with get_feedback and close them with resolve_feedback. A "stress" field ({ verdict: "CLEAN"|"FRAGILE", warnings, type, current }) appears once canvas_stress has recorded a verdict for this canvas — type is the canvas type the run used; current says whether that run was on the canvas's present versionHash AND type. A FRAGILE verdict keeps the directive NOT READY even with zero evaluate issues, until a full CLEAN canvas_stress run replaces it; when a FRAGILE run's current is false the directive says to re-run stress, naming whether an edit or a type change made it stale (the verdict still blocks); a stale CLEAN run just reads current: false. The verdict lives in server memory: a restart forgets it. canvas_evaluate never runs stress itself. @@ -2079,7 +2090,7 @@ THE HEURISTIC DIRECTIVE IS THE PRESENTATION GATE. mode: "llm" adds optional dept The result's "genre" field (present whenever cliche ran) makes the genre decision auditable: { active, source ("explicit" param | "provenance" stamp | null), relaxed (tells skipped, by the genre and by the applied preset), notRelaxed ([{ tell, relaxedBy }] — tells still flagging that a DIFFERENT genre would relax), byPreset ({ preset, relaxed } — present when the applied preset relaxed tells on its own, e.g. the material preset) }. active is the canonical genre: an alias reports as its canonical name ("dashboard" → "data", "checkout" → "commerce"). If the score is pinned by tells listed in notRelaxed, the genre is probably wrong — genre follows what the screen is FOR (read screens with the user's own figures → "data"; a screen you complete a purchase on → "commerce"; editors/admin forms → "material"), not what it contains. -The result's "surface" field (always present) reports the canvas TYPE and the rules it selected: { type ("web" | "mobile"), device (the device profile, e.g. "iphone-390", or null on web), source ("declared" by canvas_create type / canvas_set_type, or "default" — never declared, judged as web), rulesOn (rule ids the composed type + genre + preset rulesets turned on), rulesOff (rule ids they turned off), conflicts (ids both enabled and disabled — those stay ON) }. On a web canvas rulesOn is ["mobile-artboard"]. On a mobile canvas it lists the mobile ruleset, and each finding from a rule carries a "rule" field with its id: mobile-touch-target (usability ERROR, −25: a control — toggle/checkbox/radio/select — whose touch target is below 44px in either dimension; the target extends to its row only when the row is a horizontal frame holding that one control, sized by the row's numeric height/minHeight, else the control plus the row's rendered padding; a row with no width inside a vertical parent that sets no alignItems stretches to that parent's width when the parent's own width is known), mobile-handbuilt-target (usability info: a frame whose only content is one icon, or one icon plus a label of ≤3 words, with a box below 44px — make it 44×44 if tappable), mobile-safe-area (usability WARNING, −10: content in the status-bar or home-indicator inset; with root safeArea the renderer reserves the insets and only absolute content is checked; without it the first content must clear 47px and the last 34px through padding or spacer frames — padding ≥32px renders scaled down on a phone, about 27% at 390, so use a spacer frame inside the frame that holds your content (a spacer directly under the fixed-height root shrinks on a long screen); fast mode adds an info when absolute content sits inside an in-flow frame it cannot place; detailed mode checks rendered rects and is authoritative, and also flags in-flow content ending in the bottom inset at the end of the scroll, which happens on a screen taller than its artboard even with safeArea — end the content frame with a 34px spacer), mobile-type-floor (typography WARNING, −10: text below 11px, a $body text below 16px, prose of 5+ words below 13px; 12+ words at 13–15px is an info; figures and units are not words), mobile-one-column (usability info: prose side by side in a horizontal/grid frame), and mobile-artboard (structure; runs on EVERY type: a mobile canvas ≥500px wide WARNS, any other size than the device's is an info; an undeclared canvas narrower than 500px WARNS until canvas_set_type declares it; declared web and narrower than 500px gets an info). None has an autofix.`, +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.`, { 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'), @@ -2220,7 +2231,7 @@ server.tool( Perturbations (default: all): long-text (non-data text ×2.2 plus one long unbroken token — the wrap-breaker), i18n (×1.4 — the German/Finnish expansion rule), big-numbers (data-like text to its widest realistic form: "9" → "999+", "$1.5M" → "$1,520,847.33"), empty (detected tables lose their data rows), many (data rows ×3). Tables are found with the same inventory drift and coverage use. -Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job, or when it is just the page growing taller than its fixed artboard, which scrolls on a real page; WARNING otherwise), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. The verdict is remembered for canvas_evaluate, with the canvas type it ran under: any run with warnings records FRAGILE (the directive stays NOT READY), and only a run of the full default set records CLEAN — a passing subset never clears an earlier FRAGILE. On a mobile canvas, long-text leaves strings of three characters or fewer alone (monograms, short labels). Chrome required; ~1 render per perturbation.`, +Finding kinds: clip (content cut off — INFO when a designed text-overflow ellipsis is doing its job (a primitive's label truncating included), or when it is just a root with an authored overflow clipping or scrolling inside itself; WARNING otherwise; a sheet's own vertical overflow scrolls and is never a clip), overflow-x (a node escapes its parent box or the canvas), layout-shift (an UNTOUCHED node ballooning — perturbed nodes, their ancestors, and siblings that merely stretch with a growing parent are exempt). Only NEW breakage counts: anything already clipping at baseline is the design's standing state, not the perturbation's fault. verdict is CLEAN only with zero warnings — fix findings with fluid widths, minWidth or minHeight floors, wrapping, or textOverflow: "ellipsis" on labels that must stay single-line (that is the designed-truncation property the info path rewards), then re-run. The verdict is remembered for canvas_evaluate, with the canvas type it ran under: any run with warnings records FRAGILE (the directive stays NOT READY), and only a run of the full default set records CLEAN — a passing subset never clears an earlier FRAGILE. On a mobile canvas, long-text leaves strings of three characters or fewer alone (monograms, short labels), and the tab and nav-bar action labels (fixed vocabulary; i18n still lengthens them). The mobile primitives' own copy is perturbed (button label, list-row title / subtitle / trailing text, nav-bar title and action labels, tab labels), and two or more consecutive sibling list-rows are a list for empty and many. Chrome required; ~1 render per perturbation.`, { canvasId: z.string().describe('Canvas to stress'), perturbations: z.array(z.enum(['long-text', 'i18n', 'big-numbers', 'empty', 'many'])).optional() diff --git a/src/mobile-rules.ts b/src/mobile-rules.ts index 344ed45..2ceaf84 100644 --- a/src/mobile-rules.ts +++ b/src/mobile-rules.ts @@ -10,11 +10,20 @@ import type { EvaluationIssue } from './evaluate.js'; import type { LayoutRect } from './screenshot.js'; import { renderedPaddingPx } from './renderer.js'; import { usableSafeArea, type DeviceProfile, type SurfaceDeclaration } from './surfaces.js'; +import { + buttonHeight, chromeHeight, chromeReservation, effectivePosition, listRowMinHeight, navBarHeight, pinsBottom, pinsTop, + rootInsets, tabItems, unpinsFixed, +} from './primitives.js'; -/** Fixed-vocabulary mobile chrome primitives (slice D adds the node types). */ +/** Fixed-vocabulary mobile chrome primitives. */ const CHROME_TYPES = new Set(['status-bar', 'home-indicator', 'nav-bar', 'tab-bar', 'sheet']); -const CONTENT_TYPES = new Set(['icon', 'toggle', 'checkbox', 'radio', 'select', 'button', 'list-row', 'instance']); -const POSITIONED = new Set(['absolute']); +/** A nav-bar holds a title and actions, so it must clear the insets too. */ +const CONTENT_TYPES = new Set(['icon', 'toggle', 'checkbox', 'radio', 'select', 'button', 'list-row', 'nav-bar', 'instance']); +const POSITIONED = new Set(['absolute', 'fixed', 'sticky']); +/** Chrome that sits in an inset by design and renders its own reservation. */ +const PINNED_CHROME = new Set(['status-bar', 'home-indicator', 'tab-bar', 'sheet']); +/** The controls' render defaults (renderer controlSize / select). */ +const CONTROL_HEIGHT: Record = { toggle: 24, checkbox: 18, radio: 18, select: 36 }; /** A phone-width artboard: narrower than this reads as a phone screen. */ export const PHONE_WIDTH = 500; @@ -86,12 +95,15 @@ export function proseWords(content: string): number { const textOf = (n: SceneNode): string | undefined => n.type === 'text' && typeof n.content === 'string' && n.content.trim() ? n.content : undefined; -const isPositioned = (n: SceneNode): boolean => n.position !== undefined && POSITIONED.has(n.position); +const isPositioned = (n: SceneNode): boolean => { + const position = effectivePosition(n); + return position !== undefined && POSITIONED.has(position); +}; /** Parent links and the in-flow-content answer per node, built once per * evaluated tree: the walks below ask about the same subtrees repeatedly, and * recomputing would be cubic in nesting depth. */ -interface TreeIndex { parentOf: Map; flow: Map } +interface TreeIndex { parentOf: Map; flow: Map; unpinned: Map } const indexes = new WeakMap(); function indexFor(ctx: MobileContext): TreeIndex { @@ -99,12 +111,34 @@ function indexFor(ctx: MobileContext): TreeIndex { if (!index) { const parentOf = new Map(); (function link(n: SceneNode) { for (const c of n.children ?? []) { parentOf.set(c, n); link(c); } })(ctx.root); - index = { parentOf, flow: new Map() }; + index = { parentOf, flow: new Map(), unpinned: new Map() }; indexes.set(ctx.root, index); } return index; } +/** Inside a sheet (a modal overlay the renderer pads): skipped by the + * safe-area checks in both modes. */ +function inSheet(n: SceneNode, ctx: MobileContext): boolean { + const { parentOf } = indexFor(ctx); + for (let a: SceneNode | undefined = n; a; a = parentOf.get(a)) if (a.type === 'sheet') return true; + return false; +} + +/** A `fixed` node whose ancestor sets a transform, filter or backdrop filter + * pins to that ancestor, not the viewport — fast mode can't place it. */ +function fixedUnpinned(n: SceneNode, ctx: MobileContext): boolean { + const { parentOf, unpinned } = indexFor(ctx); + const parent = parentOf.get(n); + if (!parent) return false; + let held = unpinned.get(parent); + if (held === undefined) { + held = unpinsFixed(parent) || fixedUnpinned(parent, ctx); + unpinned.set(parent, held); + } + return held; +} + /** Content per the spec's Terms: text, icons, controls, the tappable * primitives, instances (opaque — they may hold content), and images narrower * than the artboard. Everything else is background. */ @@ -185,9 +219,11 @@ function firstContent(container: SceneNode, offset: number, edge: 'top' | 'botto for (const child of ordered) { const hit = visit(child, at); if (hit) return hit; - // A spacer: background, or a frame with nothing in flow beneath it. + // A spacer: background, or a frame with nothing in flow beneath it. In-flow + // chrome (a status bar, a home indicator) spaces by its rendered height. const pad = paddingSides(child, ctx.width); - at += Math.max(isNum(child.height) ? child.height : 0, isNum(child.minHeight) ? child.minHeight : 0, pad.top + pad.bottom) + gap; + const chrome = chromeHeight(child, rootInsets(ctx.root)) ?? 0; + at += Math.max(isNum(child.height) ? child.height : 0, isNum(child.minHeight) ? child.minHeight : 0, pad.top + pad.bottom, chrome) + gap; } return null; } @@ -199,117 +235,283 @@ function textHeight(n: SceneNode): number { return lh; } +/** A primitive's or control's own height, as the renderer draws it. */ +function fixedHeight(n: SceneNode): number | undefined { + if (n.type === 'button') return buttonHeight(n); + if (n.type === 'list-row') return Math.max(isNum(n.height) ? n.height : 0, listRowMinHeight(n)); + if (n.type === 'nav-bar') return navBarHeight(n); + if (isNum(n.height)) return n.height; + return CONTROL_HEIGHT[n.type]; +} + +/** A leaf's height for placement: text estimated from its size, primitives + * their own, anything else its numeric height or 0. */ +function leafHeight(n: SceneNode): number { + return n.type === 'text' ? textHeight(n) : fixedHeight(n) ?? 0; +} + +/** A pinned element's height when the tree fixes it: numeric height, else + * padding plus its in-flow children (the tallest across a row, the sum down a + * column); a child counts by its numeric height, a primitive's own height, or + * recursively. Any unknown → undefined. */ +function resolvedHeight(n: SceneNode, ctx: MobileContext): number | undefined { + const own = fixedHeight(n); + if (own !== undefined) return own; + if (n.type !== 'frame' && n.type !== 'component') return undefined; + const kids = (n.children ?? []).filter((c) => !isPositioned(c)); + const heights = kids.map((c) => resolvedHeight(c, ctx)); + if (heights.some((h) => h === undefined)) return undefined; + const hs = heights as number[]; + const pad = paddingSides(n, ctx.width); + const row = n.layout === 'horizontal' || n.layout === 'grid'; + const gap = isNum(n.gap) ? n.gap : 0; + const inner = row ? Math.max(0, ...hs) : hs.reduce((a, b) => a + b, 0) + gap * Math.max(0, hs.length - 1); + return pad.top + pad.bottom + inner; +} + const SAFE_AREA_OP = (p: DeviceProfile) => `U("document", { safeArea: { top: ${p.safeArea.top}, bottom: ${p.safeArea.bottom} } })`; +/** Where each edge's in-flow content starts: the root's rendered padding (+ + * its usable safeArea side), raised to the pinned chrome's reach — the + * renderer's max(). */ +function flowStart(ctx: MobileContext): { top: number; bottom: number } { + const pad = paddingSides(ctx.root, ctx.width); + const usable = ctx.root.type === 'document' ? usableSafeArea(ctx.root.safeArea) : {}; + const reach = chromeReservation(ctx.root); + return { + top: Math.max(pad.top + (usable.top ?? 0), reach.top), + bottom: Math.max(pad.bottom + (usable.bottom ?? 0), reach.bottom), + }; +} + /** Fast mode: the root's reservation (structural walk on each side without a - * usable safeArea) plus every positioned node fast mode can place. */ + * usable safeArea), every positioned node fast mode can place, and the pinned + * bars' two conditions. */ export function checkSafeAreaFast(ctx: MobileContext): EvaluationIssue[] { const issues: EvaluationIssue[] = []; const insets = insetsFor(ctx); - const rootPad = paddingSides(ctx.root, ctx.width); + const start = flowStart(ctx); const rule = 'mobile-safe-area'; for (const edge of ['top', 'bottom'] as const) { if (edge === 'top' ? insets.topReserved : insets.bottomReserved) continue; const inset = insets[edge]; - const hit = firstContent(ctx.root, edge === 'top' ? rootPad.top : rootPad.bottom, edge, ctx); + const hit = firstContent(ctx.root, start[edge], edge, ctx); if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, `"${hit.node.name ?? hit.node.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, - `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)} (the renderer adds them to the root's padding). For a deliberate full-bleed screen, put a ${inset}px spacer frame at the ${edge} of your content frame (padding of 32px or more scales down on a phone, and a spacer directly under the fixed-height root shrinks on a long screen).`)); + `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)} (the renderer adds them to the root's padding), or pin a ${edge === 'top' ? 'status-bar' : 'home-indicator'} (the renderer reserves its height). For a deliberate full-bleed screen, put a ${inset}px spacer frame at the ${edge} of your content frame (padding of 32px or more scales down on a phone).`)); } } - // Positioned content, placed through a chain of absolute ancestors with a - // numeric y up to the root. Anything else has an in-flow containing block - // whose position fast mode doesn't know. + // Positioned content. fixed / sticky place in the artboard frame directly + // (sticky at its stuck position); absolute through a chain of placed + // ancestors with a numeric y up to the root. Anything else has a containing + // block whose position fast mode doesn't know. let unplaceable = false; const placedY = new Map(); + const holds = (n: SceneNode) => isContent(n, ctx) || holdsFlowContent(n, ctx); const visit = (n: SceneNode, parent: SceneNode | undefined): void => { - if (parent && isPositioned(n)) { - const base = parent === ctx.root ? 0 : placedY.get(parent); - const y = base !== undefined && isNum(n.y) ? base + n.y : undefined; - if (y === undefined) { - if (isContent(n, ctx) || holdsFlowContent(n, ctx)) unplaceable = true; + if (n.type === 'sheet') return; + if (parent && isPositioned(n) && !PINNED_CHROME.has(n.type)) { + const position = effectivePosition(n); + if (position === 'fixed' && fixedUnpinned(n, ctx)) { + if (holds(n)) unplaceable = true; + } else if (position === 'fixed' || position === 'sticky') { + if (isNum(n.y) || !isNum(n.bottom)) { + const y = isNum(n.y) ? n.y : 0; + placedY.set(n, y); + checkPlaced(n, y, isNum(n.height) ? ctx.height - (y + n.height) : undefined); + } else { + const h = isContent(n, ctx) ? leafHeight(n) : resolvedHeight(n, ctx); + if (h !== undefined) placedY.set(n, ctx.height - n.bottom - h); + checkPlaced(n, h !== undefined ? ctx.height - n.bottom - h : undefined, n.bottom); + } } else { - placedY.set(n, y); - checkPlaced(n, y); + const base = parent === ctx.root ? 0 : placedY.get(parent); + const y = base !== undefined && isNum(n.y) ? base + n.y : undefined; + if (y === undefined) { + if (holds(n)) unplaceable = true; + } else { + placedY.set(n, y); + checkPlaced(n, y, isNum(n.height) ? ctx.height - (y + n.height) : undefined); + } } } for (const c of n.children ?? []) visit(c, n); }; - const checkPlaced = (n: SceneNode, y: number): void => { - const bottomEdge = ctx.height - insets.bottom; + /** `top` = its top in the artboard, `fromBottom` = its bottom edge's + * distance from the artboard's bottom (each when known). */ + const checkPlaced = (n: SceneNode, top: number | undefined, fromBottom: number | undefined): void => { if (isContent(n, ctx)) { - const h = n.type === 'text' ? textHeight(n) : isNum(n.height) ? n.height : 0; - if (y < insets.top || y + h > bottomEdge) { - const edge = y < insets.top ? 'top' : 'bottom'; + const h = leafHeight(n); + const bottomGap = fromBottom ?? (top !== undefined ? ctx.height - (top + h) : undefined); + const inTop = top !== undefined && top < insets.top; + const inBottom = bottomGap !== undefined && bottomGap < insets.bottom; + if (inTop || inBottom) { + const edge = inTop ? 'top' : 'bottom'; + const where = top !== undefined ? `y ${Math.round(top)}${h ? `, ${Math.round(h)}px tall` : ''}` : `${Math.round(bottomGap as number)}px from the bottom`; issues.push(issue('usability', rule, 'warning', n, - `Positioned "${n.name ?? n.id}" sits in the ${edge} ${edge === 'top' ? insets.top : insets.bottom}px inset (y ${Math.round(y)}${h ? `, ${Math.round(h)}px tall` : ''} on a ${ctx.height}px screen) — under the ${edge === 'top' ? 'status bar' : 'home indicator'}.`, - `Move it to y ≥ ${insets.top}${edge === 'bottom' ? ` and keep its bottom above ${bottomEdge}` : ''}.`)); + `Positioned "${n.name ?? n.id}" sits in the ${edge} ${edge === 'top' ? insets.top : insets.bottom}px inset (${where} on a ${ctx.height}px screen) — under the ${edge === 'top' ? 'status bar' : 'home indicator'}.`, + edge === 'top' ? `Move it to y ≥ ${insets.top}.` : `Keep its bottom at least ${insets.bottom}px above the screen's bottom edge (bottom: ${insets.bottom} or more).`)); } return; } if (!holdsFlowContent(n, ctx)) return; const pad = paddingSides(n, ctx.width); - const top = firstContent(n, y + pad.top, 'top', ctx); - const bottom = isNum(n.height) ? firstContent(n, ctx.height - (y + n.height) + pad.bottom, 'bottom', ctx) : null; - for (const [hit, edge, inset] of [[top, 'top', insets.top], [bottom, 'bottom', insets.bottom]] as const) { + const topHit = top !== undefined ? firstContent(n, top + pad.top, 'top', ctx) : null; + const bottomHit = fromBottom !== undefined ? firstContent(n, fromBottom + pad.bottom, 'bottom', ctx) : null; + for (const [hit, edge, inset] of [[topHit, 'top', insets.top], [bottomHit, 'bottom', insets.bottom]] as const) { if (hit && hit.offset < inset) { issues.push(issue('usability', rule, 'warning', hit.node, `"${hit.node.name ?? hit.node.id}" inside positioned "${n.name ?? n.id}" sits about ${Math.round(hit.offset)}px from the ${edge} of the screen — inside the ${inset}px ${edge === 'top' ? 'status-bar' : 'home-indicator'} inset.`, - `Pad "${n.name ?? n.id}" so its content clears ${inset}px from the ${edge} (a spacer frame of that height is exact; padding of 32px or more scales down on a phone).`)); + `Pad "${n.name ?? n.id}" so its content clears ${inset}px from the ${edge} — a spacer frame of that height at its ${edge} is exact (padding of 32px or more scales down on a phone).`)); } } }; visit(ctx.root, undefined); - if (unplaceable) { + + // Pinned bars: the scroll's start and end must clear them, or a bar hides + // the first or last content for good. + let unresolved = false; + (function bars(n: SceneNode) { + if (n.type === 'sheet') return; + if (n !== ctx.root && effectivePosition(n) === 'fixed' && !PINNED_CHROME.has(n.type) && !fixedUnpinned(n, ctx) && holds(n)) { + const bottomBar = pinsBottom(n); + const topBar = !bottomBar && pinsTop(n); + if (bottomBar || topBar) { + const h = isContent(n, ctx) ? leafHeight(n) : resolvedHeight(n, ctx); + if (h === undefined) { + unresolved = true; + } else { + const edge = bottomBar ? 'bottom' : 'top'; + const need = h + (bottomBar ? (isNum(n.bottom) ? n.bottom : 0) : (isNum(n.y) ? n.y : 0)); + const hit = firstContent(ctx.root, start[edge], edge, ctx); + if (hit && hit.offset < need - 0.5) { + const more = Math.ceil(need - hit.offset); + issues.push(issue('usability', rule, 'warning', hit.node, + `"${hit.node.name ?? hit.node.id}" ends up under pinned "${n.name ?? n.id}" at the ${edge === 'bottom' ? 'end' : 'start'} of the scroll — it clears ${Math.round(hit.offset)}px of the ${Math.round(need)}px the bar covers.`, + `Add a ${more}px spacer frame at the ${edge === 'bottom' ? 'end' : 'start'} of the frame that holds your content (${edge === 'bottom' ? 'after' : 'before'} "${hit.node.name ?? hit.node.id}"); root padding of 32px or more renders scaled down on a phone.`)); + } + } + } + } + n.children?.forEach(bars); + })(ctx.root); + + if (unplaceable || unresolved) { issues.push(issue('usability', rule, 'info', ctx.root, - 'Some positioned content sits inside an in-flow frame, so fast mode cannot place it against the safe areas.', + unplaceable && !unresolved + ? 'Some positioned content sits where fast mode cannot place it against the safe areas (inside an in-flow frame, or a fixed element under a blurred or animated ancestor, which pins to that ancestor instead of the screen).' + : 'Fast mode cannot work out the height of a pinned bar (it holds text or a frame with no fixed height), so it cannot check that the scroll clears it.', 'Run canvas_evaluate with mode: "detailed" — it checks rendered positions.')); } return issues; } /** Detailed mode: the rendered rects. Content in the top inset at scroll 0, - * positioned content in the artboard's bottom inset, and in-flow content in - * the last bottom-inset px of the document (a fixed-height root does not - * carry its end padding past overflowing content). */ + * positioned content in the artboard's bottom inset, in-flow content in the + * last bottom-inset px of the document, sticky content at its stuck position, + * and in-flow content under a pinned bar at the scroll's start or end. */ export function checkSafeAreaDetailed(ctx: MobileContext, layout: LayoutRect[]): EvaluationIssue[] { const issues: EvaluationIssue[] = []; const insets = insetsFor(ctx); + const rule = 'mobile-safe-area'; const rects = new Map(); (function flat(list: LayoutRect[]) { for (const r of list) { rects.set(r.nodeId, r); if (r.children) flat(r.children); } })(layout); - let docBottom = ctx.height; - for (const r of rects.values()) docBottom = Math.max(docBottom, r.y + r.height); - - const visit = (n: SceneNode, positioned: boolean): void => { + // The document ends where the root does: it grows with its content and + // keeps its end padding. A root that scrolls inside itself (an authored + // overflow) reports its full scroll height instead. Pinned overlays (a + // sheet's long list) and content clipped inside a scroller don't extend it. + const rootRect = rects.get(ctx.root.id); + let docBottom = Math.max(ctx.height, rootRect ? rootRect.y + rootRect.height : 0); + if (rootRect?.scrollHeight !== undefined) docBottom = Math.max(docBottom, rootRect.y + rootRect.scrollHeight); + const reserved = insets.topReserved && insets.bottomReserved; + const remedy = (stuck: boolean) => stuck + ? 'Give the sticky element a y (or bottom) offset that clears the inset, or pad its content past it.' + : reserved + ? 'Keep positioned content inside the safe area; on a long screen, end the frame that holds the content with a spacer frame of the inset height.' + : `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)}.`; + const isContentNode = (n: SceneNode, r: LayoutRect | undefined) => n.type === 'image' + ? r !== undefined && r.width < ctx.width - 1 + : n.type === 'text' ? textOf(n) !== undefined : CONTENT_TYPES.has(n.type); + const flowContent: Array<{ node: SceneNode; rect: LayoutRect }> = []; + const sticky: Array<{ node: SceneNode; rect: LayoutRect; content: Array<{ node: SceneNode; rect: LayoutRect }> }> = []; + + const visit = (n: SceneNode, positioned: boolean, stuck: typeof sticky[number] | undefined, clipped: boolean): void => { + if (n.type === 'sheet') return; const r = rects.get(n.id); - const content = n.type === 'image' - ? r !== undefined && r.width < ctx.width - 1 - : n.type === 'text' ? textOf(n) !== undefined : CONTENT_TYPES.has(n.type); - if (content) { + if (isContentNode(n, r)) { if (r && r.width > 0 && r.height > 0) { + if (stuck) stuck.content.push({ node: n, rect: r }); + if (!positioned && !clipped) flowContent.push({ node: n, rect: r }); const bottom = r.y + r.height; let where: string | undefined; if (r.y < insets.top && bottom > 0) where = `the top ${insets.top}px inset (y ${r.y})`; else if (positioned && bottom > ctx.height - insets.bottom && r.y < ctx.height) where = `the bottom ${insets.bottom}px inset of the screen (bottom edge ${bottom} on a ${ctx.height}px screen)`; - else if (!positioned && bottom > docBottom - insets.bottom) where = `the bottom ${insets.bottom}px inset at the end of the scroll (bottom edge ${bottom} of a ${docBottom}px document)`; + else if (!positioned && !clipped && bottom > docBottom - insets.bottom) where = `the bottom ${insets.bottom}px inset at the end of the scroll (bottom edge ${bottom} of a ${docBottom}px document)`; if (where) { - issues.push(issue('usability', 'mobile-safe-area', 'warning', n, - `"${n.name ?? n.id}" renders in ${where} — under the ${where.startsWith('the top') ? 'status bar' : 'home indicator'}.`, - insets.topReserved && insets.bottomReserved - ? 'Keep positioned content inside the safe area. On a screen taller than its artboard, end the frame that holds the content with a spacer frame of the inset height (not a spacer directly under the root, which shrinks).' - : `Reserve the insets on the root: ${SAFE_AREA_OP(ctx.profile)}.`)); + issues.push(issue('usability', rule, 'warning', n, + `"${n.name ?? n.id}" renders in ${where} — under the ${where.startsWith('the top') ? 'status bar' : 'home indicator'}.`, remedy(false))); } } return; // a content node's own descendants are part of it } - for (const c of n.children ?? []) visit(c, positioned || isPositioned(c)); + for (const c of n.children ?? []) { + const position = effectivePosition(c); + const cr = rects.get(c.id); + const own = position === 'sticky' && cr ? { node: c, rect: cr, content: [] } : undefined; + if (own) sticky.push(own); + // Below a fixed-height frame that clips or scrolls its overflow, content + // can't reach the end of the document's scroll (an auto-height card with + // overflow hidden clips nothing vertically). + const clips = n !== ctx.root && n.overflow !== undefined && n.overflow !== 'visible' && isNum(n.height); + visit(c, positioned || position === 'absolute' || position === 'fixed', own ?? stuck, clipped || clips); + } }; - visit(ctx.root, false); + visit(ctx.root, false, undefined, false); + + // Sticky content where it sticks: its offset from the viewport edge. + for (const s of sticky) { + const toBottom = !isNum(s.node.y) && isNum(s.node.bottom); + const shift = toBottom ? (ctx.height - (s.node.bottom as number)) - (s.rect.y + s.rect.height) : (isNum(s.node.y) ? s.node.y : 0) - s.rect.y; + for (const c of s.content) { + const top = c.rect.y + shift; + const bottom = top + c.rect.height; + if (toBottom ? bottom > ctx.height - insets.bottom : top < insets.top) { + issues.push(issue('usability', rule, 'warning', c.node, + `"${c.node.name ?? c.node.id}" sits in the ${toBottom ? `bottom ${insets.bottom}` : `top ${insets.top}`}px inset when sticky "${s.node.name ?? s.node.id}" sticks (${toBottom ? `bottom edge ${Math.round(bottom)}` : `y ${Math.round(top)}`}).`, remedy(true))); + } + } + } + + // Pinned bars: in-flow content under one at the scroll's end (bottom bars, + // moved to maximum scroll) or start (top bars). + const overlaps = (a: LayoutRect, top: number, b: LayoutRect) => + a.y < top + b.height && a.y + a.height > top && a.x < b.x + b.width && a.x + a.width > b.x; + const scroll = docBottom - ctx.height; + (function bars(n: SceneNode) { + if (n.type === 'sheet') return; + const r = rects.get(n.id); + // A fixed node under a blurred or animated ancestor pins to that ancestor, + // not the screen, so it is not a bar at the scroll's ends. + if (r && n !== ctx.root && effectivePosition(n) === 'fixed' && n.type !== 'home-indicator' && n.type !== 'status-bar' && !fixedUnpinned(n, ctx)) { + const bottomBar = pinsBottom(n); + if (bottomBar || pinsTop(n)) { + const top = bottomBar ? r.y + scroll : r.y; + for (const c of flowContent) { + if (overlaps(c.rect, top, r)) { + issues.push(issue('usability', rule, 'warning', c.node, + `"${c.node.name ?? c.node.id}" renders under pinned "${n.name ?? n.id}" at the ${bottomBar ? 'end' : 'start'} of the scroll.`, + `Add a spacer frame at the ${bottomBar ? 'end' : 'start'} of the frame that holds your content, as tall as the bar plus its offset.`)); + } + } + } + } + n.children?.forEach(bars); + })(ctx.root); return issues; } @@ -403,7 +605,7 @@ export function checkHandbuiltTargets(ctx: MobileContext): EvaluationIssue[] { if ((w !== undefined && w < floor) || (h !== undefined && h < floor)) { issues.push(issue('usability', 'mobile-handbuilt-target', 'info', n, `"${n.name ?? n.id}" looks like a hand-built tap target (${w ?? '?'}×${h ?? '?'}px) — below the ${floor}px touch floor if it is tappable, and the touch-target rule can't check a frame.`, - `If it is tappable, make it at least ${floor}×${floor}px. If it is decorative, ignore this.`)); + `If it is tappable, rebuild it as a \`button\` (size "md" is ${floor}px tall; an icon-only button is square), or as a \`list-row\` / \`tab-bar\` item when that is what it is. If it is decorative, ignore this.`)); } return; // the outermost tap-shaped frame is the target } @@ -413,6 +615,46 @@ export function checkHandbuiltTargets(ctx: MobileContext): EvaluationIssue[] { return issues; } +// ── mobile-tab-bar ───────────────────────────────────────────────────────── + +/** A phone tab bar: 2–5 labeled tabs (the FAB is not a tab), pinned to the + * bottom. One warning per broken condition per bar. */ +export function checkTabBars(ctx: MobileContext): EvaluationIssue[] { + const issues: EvaluationIssue[] = []; + const { min, max } = ctx.profile.tabCount; + const rule = 'mobile-tab-bar'; + (function walk(n: SceneNode) { + if (n.type === 'tab-bar') { + const name = n.name ?? n.id; + // Only the items that render count (a null entry is skipped by the renderer). + const items = tabItems(n); + const count = items.length; + if (count < min || count > max) { + issues.push(issue('usability', rule, 'warning', n, + `Tab bar "${name}" has ${count} tab${count === 1 ? '' : 's'} — a phone tab bar holds ${min}–${max} (3–5 recommended).`, + count > max ? 'Move the extra destinations into a "More" tab or a screen of their own.' : 'Give it at least two destinations, or drop the tab bar.')); + } + const labeled = (v: unknown) => (typeof v === 'string' && v.trim() !== '') || (typeof v === 'number' && Number.isFinite(v)); + const unlabeled = items.flatMap((it, i) => (labeled(it.label) ? [] : [i + 1])); + if (unlabeled.length) { + issues.push(issue('usability', rule, 'warning', n, + `Tab bar "${name}": tab${unlabeled.length === 1 ? '' : 's'} ${unlabeled.join(', ')} ${unlabeled.length === 1 ? 'has' : 'have'} no label — every tab needs one (an icon alone is a guess).`, + 'Give every item a short label (one word).')); + } + const position = effectivePosition(n); + if (position !== 'fixed' || n.y !== undefined) { + const why = n.y !== undefined && position === 'fixed' ? 'its y pins it to the top' : `position "${position}"`; + const inFlow = position !== 'fixed' && position !== 'absolute'; + issues.push(issue('usability', rule, 'warning', n, + `Tab bar "${name}" is not pinned to the bottom of the screen (${why})${inFlow ? ' — in flow it falls below the fold on a long screen' : ''}.`, + 'Remove its position, x and y overrides: a tab-bar pins to the bottom by default (position "fixed", bottom 0).')); + } + } + n.children?.forEach(walk); + })(ctx.root); + return issues; +} + // ── mobile-artboard ──────────────────────────────────────────────────────── export function checkArtboard(ctx: MobileContext, surface: SurfaceDeclaration): EvaluationIssue[] { diff --git a/src/primitives.ts b/src/primitives.ts new file mode 100644 index 0000000..4232c26 --- /dev/null +++ b/src/primitives.ts @@ -0,0 +1,138 @@ +// Phase 30 slice D — the geometry of the mobile primitives, shared by the +// renderer (which draws them) and the rules (which judge what it draws), so the +// two can never disagree about a button's height or where a tab bar pins. + +import type { SceneNode } from './types.js'; +import { DEFAULT_DEVICE, DEVICE_PROFILES, usableSafeArea } from './surfaces.js'; + +export const BUTTON_HEIGHT = { sm: 32, md: 44, lg: 52 } as const; +export const BUTTON_PADDING = { sm: 12, md: 16, lg: 20 } as const; +export const BUTTON_FONT = { sm: 14, md: 16, lg: 17 } as const; +export const LIST_ROW_HEIGHT = 44; +export const LIST_ROW_SUBTITLE_HEIGHT = 56; +export const NAV_BAR_HEIGHT = 44; +export const NAV_BAR_LARGE_HEIGHT = 96; +/** Every nav-bar action's hit box. */ +export const NAV_ACTION = 44; +/** The tab bar's item band (the home-indicator inset sits below it). */ +export const TAB_BAND = 49; +export const FAB_SIZE = 56; +/** How far the FAB rises above the band — included in the tab bar's box. */ +export const FAB_RAISE = 16; +export const FLOATING_INSET = 16; + +export const TAPPABLE_PRIMITIVES = new Set(['button', 'list-row', 'tab-bar', 'nav-bar']); +/** Chrome a pinned position reserves room for, and the edge it pins to. */ +export const CHROME_EDGE: Record = { 'status-bar': 'top', 'home-indicator': 'bottom', 'tab-bar': 'bottom' }; + +const PROFILE = DEVICE_PROFILES[DEFAULT_DEVICE]; +const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); +/** A label prop that renders: a non-blank string or a finite number. */ +const hasText = (v: unknown): boolean => (typeof v === 'string' && v.trim() !== '') || isNum(v); + +/** A node's position as rendered: pinned chrome defaults to `fixed`, and a + * sheet is always `fixed`. */ +export function effectivePosition(n: SceneNode): SceneNode['position'] { + if (n.type === 'sheet') return 'fixed'; // always an overlay; its position prop is ignored + return n.position ?? (Object.hasOwn(CHROME_EDGE, n.type) ? 'fixed' : undefined); +} + +/** The insets the primitives size themselves by: the root's usable safeArea + * sides, else the default device's (47 / 34). */ +export function rootInsets(root: SceneNode): { top: number; bottom: number } { + const usable = root.type === 'document' ? usableSafeArea(root.safeArea) : {}; + return { top: usable.top ?? PROFILE.safeArea.top, bottom: usable.bottom ?? PROFILE.safeArea.bottom }; +} + +/** The rendered height of a chrome primitive (undefined for anything else). */ +export function chromeHeight(n: SceneNode, insets: { top: number; bottom: number }): number | undefined { + if (n.type === 'status-bar') return insets.top; + if (n.type === 'home-indicator') return insets.bottom; + if (n.type === 'tab-bar') return TAB_BAND + insets.bottom + (n.variant === 'fab' ? FAB_RAISE : 0); + return undefined; +} + +export function buttonHeight(n: SceneNode): number { + const size = BUTTON_HEIGHT[n.size ?? 'md'] ?? BUTTON_HEIGHT.md; + return Math.max(isNum(n.height) ? n.height : size, isNum(n.minHeight) ? n.minHeight : 0); +} + +/** A button's box: never narrower than tall (min-width = height); an + * icon-only button is square. Width is the numeric width when there is one. */ +export function buttonBox(n: SceneNode): { w: number; h: number } { + const h = buttonHeight(n); + const iconOnly = !hasText(n.label); + return { w: !iconOnly && isNum(n.width) ? Math.max(n.width, h) : h, h }; +} + +export function listRowMinHeight(n: SceneNode): number { + return hasText(n.subtitle) ? LIST_ROW_SUBTITLE_HEIGHT : LIST_ROW_HEIGHT; +} + +export function navBarHeight(n: SceneNode): number { + return n.largeTitle ? NAV_BAR_LARGE_HEIGHT : NAV_BAR_HEIGHT; +} + +/** The tab items that render (a null entry is skipped). */ +export function tabItems(n: SceneNode): NonNullable { + return Array.isArray(n.items) ? n.items.filter((it) => !!it && typeof it === 'object') : []; +} + +/** Slots across the band: one per item, plus the FAB's. */ +export function tabSlots(n: SceneNode): number { + return tabItems(n).length + (n.variant === 'fab' ? 1 : 0); +} + +/** One tab item's box: bar width / slots × the band. */ +export function tabItemBox(n: SceneNode, rootWidth: number): { w: number; h: number } { + const bar = (isNum(n.width) ? n.width : rootWidth) - (n.variant === 'floating' ? FLOATING_INSET * 2 : 0); + return { w: bar / Math.max(1, tabSlots(n)), h: TAB_BAND }; +} + +/** The nav-bar actions that render: the back action and up to two trailing. */ +export function navActions(n: SceneNode): { icon?: string; label?: string }[] { + const trailing = Array.isArray(n.trailing) ? n.trailing.filter((a) => !!a && typeof a === 'object').slice(0, 2) : []; + return [...(n.leading && typeof n.leading === 'object' ? [n.leading] : []), ...trailing]; +} + +/** Pinned to the bottom edge: fixed with a `bottom` (or bottom chrome with no + * offsets) and no `y` — a `y` pins it to the top. */ +export const pinsBottom = (n: SceneNode): boolean => + effectivePosition(n) === 'fixed' && n.y === undefined && (n.bottom !== undefined || CHROME_EDGE[n.type] === 'bottom'); +/** Pinned to the top edge: fixed with a `y`, or top chrome with none. */ +export const pinsTop = (n: SceneNode): boolean => + effectivePosition(n) === 'fixed' && (n.y !== undefined || (n.bottom === undefined && CHROME_EDGE[n.type] !== 'bottom')); + +/** Animations whose end state keeps a transform (fadeIn only fades). */ +const TRANSFORMING_ANIMATIONS = new Set(['slideUp', 'slideDown', 'scaleIn']); + +/** A node that makes `fixed` descendants pin to it instead of the viewport + * (CSS: a transform, filter or backdrop filter creates their containing block). */ +export function unpinsFixed(n: SceneNode): boolean { + return !!n.blur || !!n.backdropBlur || n.backdropFilter !== undefined + || (n.animation !== undefined && TRANSFORMING_ANIMATIONS.has(n.animation.name)); +} + +/** How far each edge's pinned chrome reaches: the largest chrome height + + * offset per edge, over every effectively fixed status-bar (top) and tab-bar / + * home-indicator (bottom) in the tree. A sheet's subtree is skipped. `expand` + * resolves an instance to the tree it renders. */ +export function chromeReservation( + root: SceneNode, + expand: (n: SceneNode) => SceneNode = (n) => n, +): { top: number; bottom: number } { + const insets = rootInsets(root); + const out = { top: 0, bottom: 0 }; + (function walk(raw: SceneNode) { + const n = expand(raw); + if (n.type === 'sheet') return; + const edge = CHROME_EDGE[n.type]; + if (edge && effectivePosition(n) === 'fixed') { + const h = chromeHeight(n, insets) ?? 0; + if (edge === 'top' && n.bottom === undefined) out.top = Math.max(out.top, h + (isNum(n.y) ? n.y : 0)); + if (edge === 'bottom' && n.y === undefined) out.bottom = Math.max(out.bottom, h + (isNum(n.bottom) ? n.bottom : 0)); + } + n.children?.forEach(walk); + })(root); + return out; +} diff --git a/src/renderer.ts b/src/renderer.ts index 251940c..3c24560 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -2,6 +2,10 @@ import type { Canvas, FontFace, SceneNode } from './types.js'; import { getIconSvg } from './icons.js'; import { aliasFamilyStack } from './fonts.js'; import { usableSafeArea } from './surfaces.js'; +import { + BUTTON_FONT, BUTTON_PADDING, CHROME_EDGE, FAB_RAISE, FAB_SIZE, FLOATING_INSET, NAV_ACTION, NAV_BAR_HEIGHT, NAV_BAR_LARGE_HEIGHT, TAB_BAND, + buttonHeight, chromeHeight, chromeReservation, effectivePosition, listRowMinHeight, navBarHeight, rootInsets, +} from './primitives.js'; /** Build a `background: …` declaration from a node's `gradient`. * @@ -68,9 +72,33 @@ function registeredFamilies(canvas?: Canvas): ReadonlySet | undefined { return fonts?.length ? new Set(fonts.map((f) => f.family.toLowerCase())) : undefined; } +/** What every node's render may read about the document: the canvas (for + * instances), registered font families, and — Phase 30 slice D — the root's + * width, the insets the chrome primitives size by, and how far pinned chrome + * reaches at each edge. All of it comes from the hashed scene. */ +interface RenderCtx { + canvas?: Canvas; + registered?: ReadonlySet; + /** The body's max width (the capture width): pinned offsets measure from + * its left edge, which is where the root starts. */ + bodyWidth: number; + /** The width pinned elements are capped at: the root's own width when it + * sets one, never wider than the body. */ + columnWidth: number; + insets: { top: number; bottom: number }; + reservation: { top: number; bottom: number }; + /** Rendering a direct child of a column document root. */ + rootChild?: boolean; + /** Rendering a direct child of a sheet, whose panel scrolls: it never shrinks. */ + sheetChild?: boolean; +} + export function renderToHtml(root: SceneNode, width = 1440, height = 900, canvas?: Canvas, opts?: RenderOptions): string { const registered = registeredFamilies(canvas); - const body = renderNode(root, canvas, registered); + const expand = (n: SceneNode) => (n.type === 'instance' && n.componentId && canvas ? resolveInstance(n, canvas) ?? n : n); + const columnWidth = Math.min(typeof root.width === 'number' ? root.width : width, width); + const rc: RenderCtx = { canvas, registered, bodyWidth: width, columnWidth, insets: rootInsets(root), reservation: chromeReservation(root, expand) }; + const body = renderNode(root, rc); const responsiveCss = buildRendererStylesheet(root, canvas); // Phase 24 slice B — the pulse keyframes exist ONLY when the caller opts in // (the live viewer). Without them the fs-skeleton class is inert, so every @@ -96,6 +124,7 @@ export function renderToHtml(root: SceneNode, width = 1440, height = 900, canvas + ${preconnect}