diff --git a/README.md b/README.md index 144fa25..238164d 100644 --- a/README.md +++ b/README.md @@ -788,7 +788,7 @@ Compatible with the 55+ design systems in [awesome-design-md](https://github.com Render a canvas at multiple viewport sizes. Defaults to mobile (390x844), tablet (768x1024), and desktop (1440x900). -The renderer emits `clamp()` for paddings ≥ 32px and font sizes ≥ 24px, so headlines and large spacing shrink proportionally at narrower viewports (assuming a 1440px design width). Smaller values stay static. +The renderer emits `clamp()` for paddings ≥ 32px and font sizes ≥ 24px, so headlines and large spacing shrink proportionally at narrower viewports. Font sizes render as authored at the root's own width (a 33px title on a 390 phone artboard renders 33px; 1440 when the root has no numeric width) and shrink below it, to 60% at most (16px floor); paddings shrink against a 1440px design width. Smaller values stay static. | Param | Type | Description | |-------|------|-------------| diff --git a/VISION.md b/VISION.md index 74e2ee3..36c961b 100644 --- a/VISION.md +++ b/VISION.md @@ -551,7 +551,9 @@ The mobile attempt (2026-09-07) built one iOS screen end to end on v2.2.0 and go - [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) - [x] Slice E — starting points: anatomy and realisation on structures, resolution by canvas type, six mobile realisations, and the pattern gate extended to them - [x] 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 +- [x] Slice G — the acceptance re-run: Ridgeline Today rebuilt from `dashboard-mobile` in 33 nodes (93 by hand), 100/100 and READY in fast and detailed mode with no dark-theme finding, `canvas_stress` CLEAN, every tap target a primitive, pinned as `test-ridgeline.ts`; the re-run surfaced two render defects, fixed here: large type now scales against the artboard's own width (a phone's titles rendered at 60%, under the body), and chart tick labels no longer stretch with the plot + +All seven slices are merged and the acceptance re-run passed. The phase is closed. _Full spec-driven breakdown in [`docs/specs/PHASE-30-SPEC.md`](docs/specs/PHASE-30-SPEC.md). Deliberately out of scope: an Android profile, tablet and other types, a tappable flag on frames, a thumb-reach rule, interaction and flows, mobile import, and mobile commerce archetypes (those come next, on the anatomy/realisation split this phase creates)._ diff --git a/docs/GUIDELINES.md b/docs/GUIDELINES.md index 7370857..761ad76 100644 --- a/docs/GUIDELINES.md +++ b/docs/GUIDELINES.md @@ -179,7 +179,7 @@ The `chart` node owns the value→coordinate math — give it data and domains, - **One node per chart, multi-series inside.** `series: [{ data, stroke, strokeDasharray?, area?, points? }]` — the 4-series pace-to-goal chart is one node with a shared scale. - **Dash is the convention for projected vs actual.** Solid accent line + `strokeDasharray: "6 4"` reference/forecast lines — don't differentiate similar lines by colour alone. - **X positions are data indexes.** A 7-point booked series against a 12-point target series stops at 7/12 of the width automatically; `xDomain`/`yDomain` default from the data (bars floor at 0). -- **Furniture is opt-in props**: `gridlines: 4`, `xLabels`/`yLabels` (spread evenly; empty strings skip intermediate ticks), `curve: "smooth"`, `kind: "bar"` for grouped bars. +- **Furniture is opt-in props**: `gridlines: 4`, `xLabels`/`yLabels` (spread evenly, and on a bar chart with one label per bar each centres under its bar; empty strings skip intermediate ticks; labels keep their 11px size at any chart width, so `width: "100%"` is safe; each x label truncates with an ellipsis inside its slot (its bar, or halfway to the nearest labelled tick on each side), so on a narrow chart use short labels ("Sep", not "September") and on a line chart leave ticks empty to give labels room: twelve months on a phone read as "Jan", "", "Mar", …), `curve: "smooth"`, `kind: "bar"` for grouped bars. - **Editing a data point is a one-prop edit** — update the series array, done. - **Bar emphasis is a prop, not a rebuild.** `highlight: [11]` (or just `highlight: 11`) on a bar series renders the selected bar solid while the rest go muted (~30%) — an entry that isn't an index into `data` emphasizes nothing, and the `batch_design` op result warns about it; add `barGradient: true` and the muted bars fade vertically inside the SVG — invisible to the gradient-overuse tell by construction. The floating tooltip card stays your composition (`position: "absolute"` frame with `$elevation.floating`). - **Donuts are data-bound.** `kind: "donut"` + `segments: [{ value: 385, color: "$chart-1", label: "North" }, …]` + `innerRatio?` + `centerValue`/`centerLabel` — slices run clockwise from 12 o'clock, the center figure renders tabular and bold. Compose the legend yourself: rows of `$chart-*` dots + labels + values (legend layout is design, not charting). Never fake a ring with conic gradients. @@ -190,7 +190,7 @@ The `chart` node owns the value→coordinate math — give it data and domains, - **Three fixed-pixel cards in a horizontal row.** `width: 360` × 3 in a row without `responsive: "stack"` clips on a 390px mobile viewport instead of reflowing. Either set `responsive: "stack"` or use `width: "100%", maxWidth: 360`. - **Setting `margin` on every node.** Use `gap` on the parent flex container and `padding` on the child. The renderer doesn't surface `margin`. - **Setting `fontFamily` on every text node.** The renderer defaults to a system sans-serif stack at the body level. Only set `fontFamily` when you want a *different* face — and prefer `system-ui, -apple-system, sans-serif`-style stacks; if you need quoted multi-word names (`'Segoe UI'`), they're supported, but keep them inside the double-quoted value. -- **Hardcoding pixel font sizes everywhere.** Large sizes get a `clamp()` treatment by the renderer to scale down on small viewports — only set `fontSize` to the *desktop* value and let the renderer handle the rest. +- **Hardcoding pixel font sizes everywhere.** Large sizes (24px and up) get a `clamp()` treatment by the renderer to scale down on small viewports — set `fontSize` to the value you want at your artboard's width (the desktop value on a 1440 page, the phone value on a 390 mobile canvas) and let the renderer handle the rest. - **Unicode glyphs as icon stand-ins.** `✓ ● ▾ ○` read as unfinished. Use the `icon` node type — two sets render by name as inline SVG: 1,900+ [Lucide](https://lucide.dev) (`icon: "check"`) and 3,800+ [Material Symbols](https://fonts.google.com/icons) (`icon: "material:check"`, `iconStyle: "outlined" | "rounded" | "sharp"`, `-fill` suffix for filled variants). `I("parent", { type: "icon", icon: "material:check", iconSize: 16, iconColor: "$primary" })`. For Material-style design systems, prefer the Material set. - **Spelling an icon in the other set's style.** Lucide hyphenates multi-word names (`chevron-right`); Material Symbols keeps underscores and needs the prefix (`material:chevron_right`, `material:chevron_right-fill`). A bare `chevron_right` is looked up in Lucide and renders nothing. A name that resolves in neither set renders an empty slot, so `canvas_evaluate` reports it as a `structure` warning naming the spellings that do resolve — and `canvas_autofix` rewrites it when exactly one does. - **Baking uppercase into `content`.** Use `textTransform: "uppercase"` (with `letterSpacing` for tracking) so the underlying copy stays editable and case-styling lives in the design, not the data. diff --git a/docs/specs/PHASE-30-SPEC.md b/docs/specs/PHASE-30-SPEC.md index 05d736c..9d0747e 100644 --- a/docs/specs/PHASE-30-SPEC.md +++ b/docs/specs/PHASE-30-SPEC.md @@ -349,7 +349,7 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO - *The preset sentence is exact:* `mobile-type-floor` flags a preset's body only on screens that set text in `$body` (`auth`, `onboarding`, `detail`). - *Recorded, not changed:* the gate's check that `empty-state`'s action is a `button` is structural; a different hand-built CTA of unknown width would pass the rules, as FR-C2 accepts. - *Known limit, recorded.* The diversification hint, fed a phone project's realisations, says they repeat `heroTreatment: none` and `rhythm: uniform`: true (five of the six have no hero), and advisory only, but on a phone it can only be satisfied by forcing a web structure. - - *Known limit, not fixed here.* The renderer scales any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it is a render change outside slice E. Slice G's Ridgeline rebuild will show it. + - *Known limit, fixed in slice G.* The renderer scaled any font size of 24px or more fluidly against a 1440 design width (`responsiveFontSize`: `clamp(max(16, 0.6 × size), …)`), so on a 390 artboard `$title` (28) renders at 17px, the same as body. The nav-bar's 34px large title is fixed and unaffected. The gate cannot see it (the rules read the authored 28), and it predates this slice and applies to every narrow artboard, so changing it was a render change outside slice E. Slice G's Ridgeline rebuild showed it, and slice G fixed it: the reference is now the root's own width (see "As built (slice G)"). **Slice F — the viewer (FR-F1..F2)** — needs B's type, profile table and `root.safeArea` (for the overlay). - (Pre-build pointers; the lines moved.) `src/viewer.ts:603-604` and `:756` (thumb sizing), `:515` / `:679` (card iframes), `:1107-1109` and `:1350-1385` (detail sizing, breakpoint buttons, compare grid), `:1474-1476` (active-state detection). Profile table imported from `src/surfaces.ts`. @@ -378,6 +378,22 @@ Slice B re-checked every value against the HIG through developer.apple.com's JSO **Slice G — acceptance (FR-G1).** Rebuild Ridgeline from the attempt brief (`.framesmith/mobile-surface-attempt/`, untracked) on the merged phase, quote the results in the final PR. +- **Build contract (slice G, frozen before build).** Pins what FR-G1 leaves open. No pre-freeze verifier: FR-G1's gate was attacked with the rest of the spec before it froze, and this slice adds no verdict that depends on prior runs or stored state. + - *The brief.* The attempt's screen, as its gap catalog describes it: "Ridgeline — Today", the home screen of a trail-running app at 390×844 — status bar, a greeting header (the runner's name and a notifications action), a hero week card (distance run this week against a goal), a quick-action row (Start, Routes, Plan, Gear), a week bar chart of daily distance with today highlighted, three recent runs (name, day and pace, distance), a tab bar with a centre start-run action, the home indicator. The same copy and figures as the attempt (`ridgeline-today-ios.json`). Seed `#277350` (the attempt's accent). Personality `soft`, chosen for a consumer fitness app and recorded here because the attempt's own choice was not recorded. + - *The path.* Exactly FR-G1's: `canvas_create({ type: "mobile" })`, `generate_design_system` on the canvas, `apply_structure("dashboard")` (which resolves to `dashboard-mobile`), then adaptation through `batch_design` only. `canvas_set_genre` is allowed if the gate needs it, and its use is reported: declaring the purpose is an agent's documented move, not a borrowed type. Not allowed, per Analyze's last bullet: a hand-built tab bar, nav bar, status bar or home indicator, a borrowed canvas type, a rewritten scaffold, `canvas_autofix` (the result must be reachable by authoring), or any change to the rules to make this screen pass. + - *Every tap target a primitive.* Each thing a runner taps (the notifications action, the four quick actions, "See all", each run, each tab, the centre action) is a `button`, `list-row`, `nav-bar` action or `tab-bar` item. Zero `mobile-handbuilt-target` findings. + - *The proof is a test.* `test-ridgeline.ts` runs the whole path through the real tool handlers over MCP stdio (the `test-genre-mcp.ts` pattern, with `FRAMESMITH_HOME` and the project dir in temp dirs) and asserts FR-G1: `canvas_evaluate` with no blocking finding (the directive READY) and zero warnings, errors and tells in fast and in detailed mode, in light and in dark; one full `canvas_stress` run CLEAN; fewer than 93 nodes counted over the stored tree (every node, primitives counting one each); zero `mobile-handbuilt-target`; no `frame` named or shaped as device chrome; and a `surface` report whose `rulesOn` holds every mobile rule id in the `RULESETS` mobile row and `mobile-artboard`. It prints the numbers the PR quotes. It joins the full tier (it drives Chrome through the server, so it is listed in the runner's `CHROME_ANYWAY`). "Both themes": evaluate checks dark contrast on every run already (dual-theme colour category); the test also checks that the dark layer exists, so the dark run is not vacuous. + - *What the re-run finds.* "The proof is the re-run" (Analyze): a defect in this phase's shipped surface that blocks the path above is fixed in this slice, with its own test, and recorded under "As built (slice G)". A finding outside the phase (for example a pre-existing renderer limit) is recorded and left, unless it makes the rebuilt screen wrong to look at; the known `$title` fluid-type limit (slice E as built) is judged on the render. + - *Docs.* VISION ticks slice G and closes Phase 30; this spec gains "As built (slice G)" with the numbers. Agent surfaces change only if a fix changes behaviour. +- **As built (slice G).** Where the build departs from the contract above, or settles what it left open: + - *The rebuild.* `test-ridgeline.ts` stamps `dashboard-mobile` and adapts it in one `batch_design` batch: the nav bar's large title as the greeting ("Good morning, Fiona") with a bell action; the headline card as the week (42.8 km, a goal track, "78% of your 55 km goal"); the stat row turned into a two-by-two grid of four `button`s (Start run, Routes, Plan, Gear); a week-chart card (a bar chart, Saturday highlighted); the three runs as `list-row`s; the tab bar as the `fab` variant with four tabs; a `home-indicator` added over the tab bar's inset (the realisation has none; the brief does). 33 nodes against the attempt's 93, counted the same way (every node of the stored tree, the document included). `canvas_set_genre("data")` is used: the honest-content tell flagged "78% of your 55 km goal" and its own suggestion names `data` for a fitness screen's own figures. The attempt's "FM" avatar and the notification badge are not rebuilt: `nav-bar` has no avatar slot and its actions carry no badge, and a hand-built avatar beside the large title would be the kind of frame the phase replaces. The quick actions are a 2×2 grid rather than the attempt's row of four icon tiles: a phone button carries its label beside its icon, and four of them do not fit one 358px row with a 44px height and room for an i18n label. + - *Results.* Fast and detailed: 100/100, READY TO PRESENT, three info findings each (APCA advisories on secondary text in the dark theme), no warning, error or tell, no dark-theme contrast finding. `canvas_stress` (the full default set): CLEAN, 20 designed truncations (info). Zero `mobile-handbuilt-target`. The surface report has all six mobile rules and `mobile-artboard` on. The test also fails when the design system's fonts fall back (a "Font warnings" item on the screenshot), because a fallback face changes the glyph widths every verdict above is measured on. + - *Spec change: two render fixes (approved by Victor, 2026-11-01).* The gate passed on the first adapted build, but the render was wrong to look at, so under "What the re-run finds" both were fixed here: + - *Large type scales against the artboard.* `responsiveFontSize` measured every size of 24px or more against a fixed 1440 design width, so on a 390 artboard it rendered at 60% (16px floor): `$title` (33) at 20px, under the 21px `$text-lg`, and `$heading` (27) at 16px, under the 17px body. The hero figure read smaller than a card title. It now measures against the root's own numeric width (1440 when the root has none), so a size renders as authored on a viewport as wide as its artboard and shrinks below it as before. A 1440 canvas, and a breakpoint render of one, emit exactly what they did. Web canvases of another width now render their large type at the authored size instead of scaled to 1440 (a 32px heading on a 1280 page used to render at 28.4px). Padding keeps the 1440 reference: the slice C safe-area and pinned-bar rules model it (`renderedPaddingPx`), and changing it would move those gates. `test-fluid-type.ts`. + - *Chart tick labels are HTML.* The cartesian chart's SVG uses `preserveAspectRatio="none"` so the plot fills any box, and its tick labels were SVG text inside it, so they stretched with the plot: squashed to about half width in a phone card at `width: "100%"` (the SVG's default viewBox is 600 wide), widened on a desktop card. They now render as spans over the SVG, placed by percentage, at 11px whatever the chart's width. Each x label owns a slot and truncates with an ellipsis inside it: on a bar chart with one x label per bar the slot is the bar's band (centred); otherwise a label's room reaches halfway to the nearest labelled tick on each side (an empty label skips its tick and leaves its room to its neighbours): the end labels hug the plot's edges, the others centre on their tick. A label count other than one per bar keeps the edge-to-edge spread. A chart whose width or height is zero, negative or NaN renders no labels. Sparklines and donuts are unchanged. `test-chart.ts`. + - *Verify round 1 (four agents on 484c854).* Changes that came out of it: the dense-label case (twelve month names on a phone-width bar chart overlapped once labels stopped being squashed) is why labels own slots and truncate, which also keeps them inside the chart box; `canvas_sync_from_url` with a viewport override renders the imported page in a document as wide as the canvas root (`renderImportedTree` takes the design width), because the two sides otherwise scaled large type against different widths and an in-sync page reported drift (`test-sync-drift.ts`); the Ridgeline brief's greeting ("Good morning, Fiona") replaced the "Today" title and a `home-indicator` was added; the test now spells out the rule ids instead of reading them from `src/surfaces.ts`, asserts the five default perturbations ran, the generated `$body` floor, no "Font warnings", and a dark-only contrast failure as a negative control; boundary cases for 24px and zero or negative root widths. Accepted: a web canvas narrower or wider than 1440 now renders its large type as authored (intended; it goes in the release notes), `canvas_diff` between two canvases of different root widths compares two different artboards, `node.color` reaches the label spans as it already reached the chart's own style (not a new surface), and importing framesmith's own exported chart HTML yields its label text (it yielded an empty frame before; both lose the drawing). + - *Verify round 2 (one agent on the fix diff 484c854..5d7c28e).* It confirmed the fixes and found one regression: with a slot of half a step at each end, a twelve-tick line chart with every tick labelled truncated "Jan" and "Dec" on a phone, which the squashed SVG text had not. Fix: a label's room reaches halfway to the nearest *labelled* tick, so leaving ticks empty gives labels room; a fully labelled twelve-tick line chart on a phone still truncates its labels, because twelve labels do not fit 358px without overlapping, and the docs now say to label every other tick. Its note that a fluid-width root (`width: "100%"`) synced at a viewport above 1440 drifted (the imported document got 1440, the canvas `100%`) is fixed by passing the canvas root's width through unchanged. This last round was proven red on 5d7c28e and green after for the chart (`test-chart.ts`); the sync change is a one-line pass-through in the handler, which `test-sync-drift.ts` mirrors rather than calls, so its new fluid-root case passes on both and the fix is read-verified only. Neither was re-verified by an agent. Left as notes: y labels can still spill left of the chart box when wider than the label gutter (unchanged from the SVG, which clipped them); the home indicator over the tab bar is added by hand because the tab-bar realisations do not stamp one. + Slice order: A → B → C → D → E → F → G. A untangles the field B's ruleset table reads. B gives C a type to key on. C's target rule exists before D so each D primitive is born under it. E needs D's primitives. F needs only B, and can move earlier if convenient. G needs everything. --- diff --git a/scripts/run-tests.ts b/scripts/run-tests.ts index 0f27b84..a5ae3e4 100644 --- a/scripts/run-tests.ts +++ b/scripts/run-tests.ts @@ -35,6 +35,9 @@ const CHROME_ANYWAY = new Set([ // Shells out to the CLI, whose check-drift path launches Chrome. Nothing in // this file's own imports reveals that. 'test-cli.ts', + // Drives the MCP server over stdio, and the server renders in Chrome for + // detailed evaluate, canvas_stress and screenshots. + 'test-ridgeline.ts', ]); /** Direct use of the browser is the honest signal — these are the entry points diff --git a/src/import.ts b/src/import.ts index bad3279..093ff39 100644 --- a/src/import.ts +++ b/src/import.ts @@ -1083,8 +1083,11 @@ export async function importHtml(html: string, opts: ImportHtmlOptions = {}): Pr /** Render an imported tree the same way a canvas renders (Phase 16 font * backstop included) WITHOUT creating a canvas — canvas_sync_from_url diffs * this against the stored design (spec FR-D1: ephemeral, mutates nothing). */ -export async function renderImportedTree(root: SceneNode, width: number, height: number): Promise { - const doc: SceneNode = { id: 'sync-doc', type: 'document', fill: '#FFFFFF', width, height, children: [root] }; +export async function renderImportedTree(root: SceneNode, width: number, height: number, rootWidth: SceneNode['width'] = width): Promise { + // The document carries the compared canvas root's width, not the viewport's: + // the renderer scales large type against the root's width, so both sides of + // a diff must share it or an in-sync page reads as drifted at another viewport. + const doc: SceneNode = { id: 'sync-doc', type: 'document', fill: '#FFFFFF', width: rootWidth, height, children: [root] }; const { extraFonts } = await ensureFontsForRender(doc, { fonts: [] } as unknown as Canvas, undefined); return renderToHtml(doc, width, height, undefined, { extraFonts }); } diff --git a/src/index.ts b/src/index.ts index d1c6a30..3068469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -561,7 +561,7 @@ Returns { ok, nodeIds, results }: nodeIds maps each bound variable to the node I 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. +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"] (spread evenly; one per bar on a bar chart centres each under its bar; tick labels keep their size at any chart width, so width: "100%" is safe; an x label wider than its slot (its bar, or halfway to the nearest labelled tick) truncates with an ellipsis, so on a narrow chart keep labels short and leave line-chart ticks empty ("") to give them room), 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. Borders: stroke + strokeWidth draw all four sides (strokeStyle "solid"|"dashed"|"dotted", default solid — a dashed outline is the forecast/placeholder convention). Per-side borders take an object: borderTop: { width: 1, color: "$border", style?: "solid"|"dashed"|"dotted" } — use these for table row rules (borderTop on each row) and accent edges (borderLeft: { width: 3, color: "$primary" }), NEVER a gap-1-with-fill-bleed hack. Paths dash via strokeDasharray: "6 4" (or [6, 4]). @@ -1799,7 +1799,7 @@ The design-of-record becomes a living contract: run this after deploys, or wire const h = viewport?.height ?? (typeof canvas.root.height === 'number' ? canvas.root.height : 900); const imported = await importUrl(url, { viewport: { width: w, height: h }, selector, waitFor, auth }); - const liveHtml = await renderImportedTree(imported.root, w, h); + const liveHtml = await renderImportedTree(imported.root, w, h, canvas.root.width); const { resolved, renderOpts, fontWarnings } = await prepareRender(canvas); const canvasHtml = renderToHtml(resolved, w, h, canvas, renderOpts); diff --git a/src/renderer.ts b/src/renderer.ts index 37779c3..f540e94 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -87,6 +87,9 @@ interface RenderCtx { columnWidth: number; insets: { top: number; bottom: number }; reservation: { top: number; bottom: number }; + /** The width large type is authored at: the root's own width, else 1440. + * Fluid font sizes render as authored at this width and shrink below it. */ + designWidth: 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. */ @@ -97,7 +100,8 @@ export function renderToHtml(root: SceneNode, width = 1440, height = 900, canvas const registered = registeredFamilies(canvas); 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 designWidth = typeof root.width === 'number' && root.width > 0 ? root.width : DESIGN_WIDTH; + const rc: RenderCtx = { canvas, registered, bodyWidth: width, columnWidth, insets: rootInsets(root), reservation: chromeReservation(root, expand), designWidth }; const body = renderNode(root, rc); const responsiveCss = buildRendererStylesheet(root, canvas); // Phase 24 slice B — the pulse keyframes exist ONLY when the caller opts in @@ -431,20 +435,55 @@ function renderChartSvg(node: SceneNode): string { } } - // Tick labels: spread evenly along the bottom / left edges (never on sparklines). + // Tick labels (never on sparklines) are HTML over the plot, placed by + // percentage: the plot stretches to the node's box (preserveAspectRatio + // none), and SVG text would stretch with it — squashed on a 100%-wide chart + // in a phone card, widened on a desktop one. Each x label owns a slot (its + // bar's band when there is one label per bar, else the room halfway to the + // nearest labelled tick on each side) and truncates with an ellipsis inside + // it, so dense labels never run into each other or past the plot. Empty + // labels skip a tick and leave their room to the neighbours. const labelColor = escapeAttr(node.color ?? CHART_LABEL_COLOR); - for (const [i, label] of ((spark ? [] : node.xLabels) ?? []).entries()) { - const n = node.xLabels!.length; - const lx = round2(geom.x0 + (n === 1 ? 0.5 : i / (n - 1)) * geom.plotW); - parts.push(`${escapeHtml(label)}`); + const labels: string[] = []; + const pct = (v: number, of: number) => `${round2((v / of) * 100)}%`; + const label = (pos: string, text: string) => + labels.push(`${escapeHtml(text)}`); + const xLabels = (spark ? [] : node.xLabels) ?? []; + const yLabels = (spark ? [] : node.yLabels) ?? []; + const sized = boxW > 0 && boxH > 0; + const maxLen = Math.max(...series.map((s) => s.data.length)); + const n = xLabels.length; + const slot = (left: number, width: number, align: string) => + `top: ${pct(baselineY + 4, boxH)}; left: ${pct(left, boxW)}; width: ${pct(width, boxW)}; text-align: ${align}`; + for (const [i, text] of (sized ? xLabels : []).entries()) { + if (barForm && n === maxLen) { + const band = geom.plotW / n; + label(slot(geom.x0 + i * band, band, 'center'), text); + } else if (n === 1) { + label(slot(geom.x0, geom.plotW, 'center'), text); + } else if (text) { + // Ticks sit at the plot's ends and evenly between. A label's room on + // each side reaches halfway to the nearest labelled tick (the whole plot + // when it is the only one): the end labels hug the edges inside it, the + // rest centre on their tick inside the nearer side's room. + const step = geom.plotW / (n - 1); + const tick = geom.x0 + i * step; + const labelled = xLabels.flatMap((t, j) => (t ? [j] : [])); + const k = labelled.indexOf(i); + const before = k > 0 ? ((i - labelled[k - 1]) * step) / 2 : tick - geom.x0; + const after = k < labelled.length - 1 ? ((labelled[k + 1] - i) * step) / 2 : geom.x0 + geom.plotW - tick; + if (i === 0) label(slot(tick, after, 'left'), text); + else if (i === n - 1) label(slot(tick - before, before, 'right'), text); + else label(slot(tick - Math.min(before, after), 2 * Math.min(before, after), 'center'), text); + } } - for (const [i, label] of ((spark ? [] : node.yLabels) ?? []).entries()) { - const n = node.yLabels!.length; - const ly = round2(geom.y0 + geom.plotH - (n === 1 ? 0.5 : i / (n - 1)) * geom.plotH); - parts.push(`${escapeHtml(label)}`); + for (const [i, text] of (sized ? yLabels : []).entries()) { + const ly = geom.y0 + geom.plotH - (yLabels.length === 1 ? 0.5 : i / (yLabels.length - 1)) * geom.plotH; + label(`top: ${pct(ly, boxH)}; right: ${pct(boxW - (geom.x0 - 8), boxW)}; transform: translateY(-50%)`, text); } - return `${parts.join('')}`; + const svg = `${parts.join('')}`; + return labels.length ? `
${svg}${labels.join('')}
` : svg; } function escapeAttr(value: string): string { @@ -806,7 +845,7 @@ function buildStyles(node: SceneNode, rc: RenderCtx): string { } // Text - if (node.fontSize) s.push(`font-size: ${responsiveFontSize(node.fontSize)}`); + if (node.fontSize) s.push(`font-size: ${responsiveFontSize(node.fontSize, rc.designWidth)}`); if (node.fontFamily) s.push(`font-family: ${aliasFamilyStack(node.fontFamily, registered)}`); // "mono"/"sans" → CSS generics unless registered under that label (#134) if (node.fontWeight) s.push(`font-weight: ${node.fontWeight}`); if (node.color) s.push(`color: ${node.color}`); @@ -1229,8 +1268,9 @@ function cssLength(v: number | string): string { } // Canonical design width — matches the default screenshot viewport. -// Used to express padding/font as a fraction of viewport so narrower -// breakpoints shrink the value proportionally. +// Padding is expressed as a fraction of the viewport against it, so narrower +// breakpoints shrink the value proportionally. Font sizes use the root's own +// width instead (RenderCtx.designWidth), falling back to this one. const DESIGN_WIDTH = 1440; const PADDING_SCALE_MIN = 32; const FONT_SCALE_MIN = 24; @@ -1452,13 +1492,18 @@ function responsivePadding(value: number): string { // pass through when they match a safe character set; anything else is dropped. const SAFE_CSS_LENGTH = /^[a-zA-Z0-9\s().,%*+\/-]+$/; -function responsiveFontSize(value: number | string): string { +/** Large type is fluid: it renders as authored on a viewport as wide as the + * artboard it was authored on (`designWidth`) and shrinks on narrower ones, + * down to 60% (16px floor). Phase 30 slice G: the reference used to be a fixed + * 1440, so a 33px title on a 390 phone artboard rendered at 20px — under the + * 21px card title and every heading under the 17px body. */ +function responsiveFontSize(value: number | string, designWidth: number): string { if (typeof value === 'string') { return SAFE_CSS_LENGTH.test(value) ? value : '14px'; } if (value < FONT_SCALE_MIN) return `${value}px`; const min = Math.max(16, Math.round(value * 0.6)); - const fluid = ((value / DESIGN_WIDTH) * 100).toFixed(2); + const fluid = ((value / designWidth) * 100).toFixed(2); return `clamp(${min}px, ${fluid}vw, ${value}px)`; } diff --git a/test-chart.ts b/test-chart.ts index 4711967..2781b46 100644 --- a/test-chart.ts +++ b/test-chart.ts @@ -74,10 +74,20 @@ const render = () => renderToHtml(root, 1200, 800, canvas); check('area fill at 12% opacity', html.includes('fill-opacity="0.12"')); check('point markers drawn', (html.match(/Jan') && html.includes('>Dec')); - // Phase 25 slice A: tick labels carry tabular-nums, so the anchor attr is - // no longer the last one before the text content. - check('y labels right-aligned at the left edge', /text-anchor="end"[^>]*>2\.7M<\/text>/.test(html)); + check('x labels rendered, empty ticks skipped... rendered as empty', html.includes('>Jan') && html.includes('>Dec')); + // Phase 30 slice G: tick labels are HTML over the stretched plot, so they + // keep their size whatever width the chart renders at. + check('no tick label is SVG text (it would stretch with the plot)', !/]*>(Jan|Dec|0|2\.7M)<\/text>/.test(html)); + check('labels sit in a positioned wrapper over the svg', html.includes('
]*>2\.7M<\/span>/.test(html)); + // Each x label owns a slot and truncates inside it. Plot 44..596 (y labels + // reserve 44), three ticks 276 apart: the end labels get half a step each. + const slotOf = (h: string, text: string) => h.match(new RegExp(`left: ([\\d.]+)%; width: ([\\d.]+)%; text-align: (\\w+)[^>]*>${text}`))?.slice(1).join(' '); + // The empty middle tick leaves its room to its neighbours: each end label + // reaches halfway to the other (276 of 600). + check('line x labels: first slot starts at the plot, left-aligned', slotOf(html, 'Jan') === '7.33 46 left', slotOf(html, 'Jan')); + check('line x labels: last slot ends at the plot, right-aligned', slotOf(html, 'Dec') === '53.33 46 right', slotOf(html, 'Dec')); + check('x labels truncate inside their slot', /overflow: hidden; text-overflow: ellipsis[^>]*>Jan<\/span>/.test(html)); // Smooth curve emits cubic segments. chart.curve = 'smooth'; @@ -90,6 +100,36 @@ const render = () => renderToHtml(root, 1200, 800, canvas); }); const barHtml = render(); check('bar chart draws a rect per value per series', (barHtml.match(/ 0 && !barHtml.slice(at - 120, at).includes('position: relative')); + + // One label per bar: each centres under its band. 400 wide, no y labels: + // plot 0..396, three bands of 132 → centres 66, 198, 330 → 16.5%, 49.5%, 82.5%. + barChart.xLabels = ['Mon', 'Tue', 'Wed']; + const centred = render(); + check('bar labels centre in their bars\' bands', ['0 33 center', '33 33 center', '66 33 center'].every((x, i) => slotOf(centred, ['Mon', 'Tue', 'Wed'][i]) === x), + ['Mon', 'Tue', 'Wed'].map((t) => slotOf(centred, t)).join(' | ')); + // Line charts: a lone label centres on the plot, a middle one on its tick. + // 600 wide, no y labels: plot 0..596. + const line = insertNode(root, root.id, { type: 'chart', kind: 'line', width: 600, height: 200, series: [{ data: [1, 2, 3] }], xLabels: ['Only'] }); + check('a lone x label centres on the plot', slotOf(render(), 'Only') === '0 99.33 center', slotOf(render(), 'Only')); + line.xLabels = ['A', 'Mid', 'C']; + check('a middle x label centres on its tick, a step wide', slotOf(render(), 'Mid') === '24.83 49.67 center', slotOf(render(), 'Mid')); + // A zero-size box has no geometry to place labels in: none, not NaN%. + line.width = 0; + line.xLabels = ['Zero']; + check('a zero-width chart renders no labels', !render().includes('>Zero') && !render().includes('NaN%') && !render().includes('Infinity%')); + line.width = 600; + // Twelve ticks, every other one labelled: each label gets two steps of room + // (plot 0..596, step 54.18), so "Wk1" is not squeezed into half a step. + line.xLabels = ['Wk1', '', 'Wk3', '', 'Wk5', '', 'Wk7', '', 'Wk9', '', 'Wk11', '']; + const sparse = render(); + check('a sparse label reaches halfway to the next labelled tick', slotOf(sparse, 'Wk1') === '0 9.03 left' && slotOf(sparse, 'Wk3') === '9.03 18.06 center', `${slotOf(sparse, 'Wk1')} | ${slotOf(sparse, 'Wk3')}`); + line.xLabels = undefined; + // A label count that differs from the bar count keeps the edge-to-edge spread. + barChart.xLabels = ['Q1', 'Q4']; + const spread = render(); + check('bar labels not one per bar spread edge to edge', slotOf(spread, 'Q1') === '0 49.5 left' && slotOf(spread, 'Q4') === '49.5 49.5 right', `${slotOf(spread, 'Q1')} | ${slotOf(spread, 'Q4')}`); // Label content is escaped. barChart.xLabels = ['']; diff --git a/test-fluid-type.ts b/test-fluid-type.ts new file mode 100644 index 0000000..0c1fed1 --- /dev/null +++ b/test-fluid-type.ts @@ -0,0 +1,79 @@ +import './test-env.js'; +// Phase 30 slice G — large type is fluid against the artboard it was authored +// on (the root's own width), not a fixed 1440. On a 390 phone artboard a 33px +// $title used to render at 20px, under the 21px card title, and a 27px +// $heading at 16px, under the 17px body. Padding keeps its 1440 reference +// (the mobile rules model it, slice C). Markup only; no Chrome needed. +// +// Usage: npx tsx test-fluid-type.ts + +import { renderToHtml } from './src/renderer.js'; +import type { SceneNode } from './src/types.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +const doc = (width: number | string | undefined, children: SceneNode[]): SceneNode => + ({ id: 'doc', type: 'document', ...(width === undefined ? {} : { width }), children } as SceneNode); +const text = (id: string, fontSize: number | string): SceneNode => ({ id, type: 'text', content: id, fontSize }); +const fontOf = (html: string, id: string) => html.match(new RegExp(`data-node-id="${id}"[^>]*style="[^"]*font-size: ([^;"]+)`))?.[1]; + +/** The px a fluid clamp(min, Nvw, max) renders at on a viewport. */ +function at(css: string | undefined, viewport: number): number | undefined { + const m = css?.match(/^clamp\((\d+)px, ([\d.]+)vw, (\d+)px\)$/); + if (!m) return css?.endsWith('px') ? parseFloat(css) : undefined; + return Math.min(+m[3], Math.max(+m[1], (+m[2] / 100) * viewport)); +} + +// A phone artboard: authored sizes render as authored at its own width. +{ + const html = renderToHtml(doc(390, [text('title', 33), text('heading', 27), text('lg', 21), text('body', 17)]), 390, 844); + const px = ['title', 'heading', 'lg', 'body'].map((id) => at(fontOf(html, id), 390)); + check('390 artboard: the 33px title renders 33px at 390', Math.abs(px[0]! - 33) < 0.05, fontOf(html, 'title')); + check('390 artboard: the 27px heading renders 27px at 390', Math.abs(px[1]! - 27) < 0.05, fontOf(html, 'heading')); + check('390 artboard: hierarchy holds — title > heading > lg > body', px[0]! > px[1]! && px[1]! > px[2]! && px[2]! > px[3]!, px.join(' > ')); + check('390 artboard: fluid still shrinks on a narrower viewport', at(fontOf(html, 'title'), 320)! < 33); + check('390 artboard: never grows past authored on a wider viewport', at(fontOf(html, 'title'), 1440) === 33); +} + +// The 1440 default is unchanged, including under a narrow responsive render. +{ + const root = doc(1440, [text('h', 64), text('s', 32)]); + const desktop = renderToHtml(root, 1440, 900); + check('1440 artboard: 64 keeps clamp(38px, 4.44vw, 64px)', fontOf(desktop, 'h') === 'clamp(38px, 4.44vw, 64px)', fontOf(desktop, 'h')); + check('1440 artboard: 32 keeps clamp(19px, 2.22vw, 32px)', fontOf(desktop, 's') === 'clamp(19px, 2.22vw, 32px)', fontOf(desktop, 's')); + const phone = renderToHtml(root, 390, 844); + check('a breakpoint render reads the root width, not the viewport', fontOf(phone, 'h') === fontOf(desktop, 'h'), fontOf(phone, 'h')); +} + +// No numeric root width → the 1440 reference. +for (const w of [undefined, '100%', 0, -5]) { + const html = renderToHtml(doc(w, [text('h', 64)]), 1440, 900); + check(`root width ${JSON.stringify(w)}: falls back to 1440`, fontOf(html, 'h') === 'clamp(38px, 4.44vw, 64px)', fontOf(html, 'h')); +} + +// Another web width: authored at its own width. +{ + const html = renderToHtml(doc(1280, [text('h', 32)]), 1280, 800); + check('1280 artboard: 32 renders 32 at 1280', fontOf(html, 'h') === 'clamp(19px, 2.50vw, 32px)', fontOf(html, 'h')); +} + +// Below the threshold and string sizes are untouched. +{ + const html = renderToHtml(doc(390, [text('small', 23), text('edge', 24), text('str', 'clamp(1rem, 0.9rem + 0.5vw, 1.25rem)')]), 390, 844); + check('23px stays static', fontOf(html, 'small') === '23px', fontOf(html, 'small')); + check('24px is the first fluid size', fontOf(html, 'edge') === 'clamp(16px, 6.15vw, 24px)', fontOf(html, 'edge')); + check('a CSS length string passes through', fontOf(html, 'str') === 'clamp(1rem, 0.9rem + 0.5vw, 1.25rem)', fontOf(html, 'str')); +} + +// Padding keeps its 1440 reference on a phone (slice C's mobile rules model it). +{ + const html = renderToHtml(doc(390, [{ id: 'pad', type: 'frame', padding: 48, children: [] } as SceneNode]), 390, 844); + check('padding 48 on a 390 artboard keeps clamp(12px, 3.33vw, 48px)', html.includes('padding: clamp(12px, 3.33vw, 48px)')); +} + +console.log(allPass ? '\nAll fluid-type tests passed.' : '\nSome fluid-type tests FAILED.'); +process.exit(allPass ? 0 : 1); diff --git a/test-ridgeline.ts b/test-ridgeline.ts new file mode 100644 index 0000000..8da5ca2 --- /dev/null +++ b/test-ridgeline.ts @@ -0,0 +1,176 @@ +// Phase 30 slice G (FR-G1) — the acceptance re-run. Ridgeline Today, the +// trail-running home screen the 2026-09-07 mobile attempt hand-built in 93 +// nodes, rebuilt on the phase through the REAL tool handlers over MCP stdio: +// canvas_create({ type: "mobile" }) → generate_design_system → +// apply_structure("dashboard") → batch_design adaptation. It must reach CLEAN +// in fast and detailed mode in both themes, with a full canvas_stress run +// CLEAN, every tap target a primitive, no hand-built device chrome, in fewer +// than 93 nodes. The numbers it prints are the ones the slice G PR quotes. +// +// Usage: npx tsx test-ridgeline.ts +// RIDGELINE_OUT= also writes light + dark screenshots there. + +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +const ATTEMPT_NODES = 93; +// Spelled out, not read from src/surfaces.ts, so dropping a rule from the +// ruleset cannot also drop it from what this gate expects. +const MOBILE_RULE_IDS = ['mobile-touch-target', 'mobile-handbuilt-target', 'mobile-safe-area', 'mobile-type-floor', 'mobile-one-column', 'mobile-tab-bar', 'mobile-artboard']; +const STRESS_DEFAULTS = ['long-text', 'i18n', 'big-numbers', 'empty', 'many']; +const out = process.env.RIDGELINE_OUT; +if (out) mkdirSync(out, { recursive: true }); + +const home = mkdtempSync(join(tmpdir(), 'framesmith-ridgeline-')); +const cwd = mkdtempSync(join(tmpdir(), 'framesmith-ridgeline-cwd-')); +const transport = new StdioClientTransport({ + command: resolve('node_modules/.bin/tsx'), + args: [resolve('src/index.ts')], + cwd, + env: { ...process.env, FRAMESMITH_HOME: home, CANVAS_MCP_PROJECT_DIR: cwd, FRAMESMITH_VIEWER_URL: 'http://localhost:1' } as Record, + stderr: 'ignore', +}); +const client = new Client({ name: 'test-ridgeline', version: '0' }); +await client.connect(transport); + +type Content = { type: string; text?: string; data?: string }; +async function call(name: string, args: Record): Promise { + const r = await client.callTool({ name, arguments: args }, undefined, { timeout: 170_000 }) as { content: Content[] }; + return r.content; +} +async function tool(name: string, args: Record) { + const text = (await call(name, args))[0]?.text ?? ''; + try { return JSON.parse(text); } catch { return text; } +} + +// The adaptation: the attempt's copy and figures, every tap target a +// primitive. The stamp's ids are dashboard-mobile's (dm-*). +const ADAPT = ` +U("dm-nav", { title: "Good morning, Fiona", largeTitle: true, trailing: [{ icon: "bell" }] }) +U("dm-hero-label", { content: "This week" }) +U("dm-hero-value", { content: "42.8 km" }) +track=I("dm-hero", { type: "frame", name: "Goal track", width: "100%", height: 8, cornerRadius: 4, fill: "$accent-tint" }) +I(track, { type: "frame", name: "Goal fill", width: "78%", height: 8, cornerRadius: 4, fill: "$accent" }) +M(track, "dm-hero", 2) +U("dm-hero-delta-icon", { icon: "target" }) +U("dm-hero-delta-text", { content: "78% of your 55 km goal" }) +D("dm-stat-1") +D("dm-stat-2") +U("dm-stats", { name: "Quick actions", layout: "grid", gridColumns: 2, gap: "$space-xs" }) +I("dm-stats", { type: "button", label: "Start run", icon: "play", variant: "primary", width: "100%" }) +I("dm-stats", { type: "button", label: "Routes", icon: "map", variant: "secondary", width: "100%" }) +I("dm-stats", { type: "button", label: "Plan", icon: "calendar-days", variant: "secondary", width: "100%" }) +I("dm-stats", { type: "button", label: "Gear", icon: "footprints", variant: "secondary", width: "100%" }) +week=I("dm-content", { type: "frame", name: "Week chart", width: "100%", layout: "vertical", gap: "$space-sm", padding: "$space-lg", cornerRadius: "$radius-lg", fill: "$bg-surface", stroke: "$border", strokeWidth: 1, shadow: "$elevation.flat" }) +M(week, "dm-content", 2) +head=I(week, { type: "frame", name: "Chart header", width: "100%", layout: "horizontal", alignItems: "center", justifyContent: "space-between", gap: "$space-sm" }) +I(head, { type: "text", content: "Daily distance", fontSize: "$text-lg", fontWeight: 600, color: "$text-primary", textOverflow: "ellipsis", minWidth: 0 }) +I(head, { type: "text", content: "km", fontSize: "$caption", color: "$text-secondary" }) +I(week, { type: "chart", name: "Week bars", kind: "bar", width: "100%", height: 120, series: [{ data: [6.2, 0, 8.4, 5.1, 0, 12.6, 10.5], highlight: [5], stroke: "$accent" }], xLabels: ["M", "T", "W", "T", "F", "S", "S"] }) +U("dm-recent-head-title", { content: "Recent runs" }) +U("dm-row-1", { title: "Cedar Ridge loop", subtitle: "Yesterday · 5:42 /km", leading: { icon: "mountain" }, trailing: { text: "12.6 km" } }) +U("dm-row-2", { title: "Riverside tempo", subtitle: "Thursday · 4:58 /km", leading: { icon: "timer" }, trailing: { text: "8.4 km" } }) +U("dm-row-3", { title: "Old Mill recovery", subtitle: "Wednesday · 6:20 /km", leading: { icon: "leaf" }, trailing: { text: "5.1 km" } }) +I("document", { type: "home-indicator" }) +U("dm-tabs", { variant: "fab", fab: { icon: "plus" }, items: [{ icon: "house", label: "Home", active: true }, { icon: "map", label: "Routes" }, { icon: "users", label: "Club" }, { icon: "user", label: "Profile" }] }) +`; + +type Node = { type: string; name?: string; children?: Node[] }; +const walk = (n: Node, out: Node[] = []): Node[] => { out.push(n); (n.children ?? []).forEach((c) => walk(c, out)); return out; }; + +type Issue = { severity: string; rule?: string; tell?: string; theme?: string; message?: string; detail?: string; nodeId?: string }; +const describe = (issues: Issue[]) => issues.map((i) => `${i.severity}:${i.rule ?? i.tell ?? ''} ${i.nodeId ?? ''} ${(i.message ?? i.detail ?? '').slice(0, 90)}`).join(' | '); + +try { + const c = await tool('canvas_create', { name: 'Ridgeline — Today', type: 'mobile' }); + const id = c.canvasId as string; + check('canvas_create declares a 390×844 mobile canvas', c.type === 'mobile' && c.width === 390 && c.height === 844, JSON.stringify({ type: c.type, width: c.width, height: c.height })); + + const ds = await tool('generate_design_system', { canvasId: id, seed: '#277350', personality: 'soft' }); + check('generate_design_system pivots on the phone body size', ds.baseSizeSource === 'mobile', `${ds.baseSize}px from ${ds.baseSizeSource}`); + + const s = await tool('apply_structure', { canvasId: id, structure: 'dashboard' }); + check('apply_structure("dashboard") stamps dashboard-mobile', s.applied === 'dashboard-mobile', s.applied); + + // The figures are the runner's own data and ARE the design: declare the + // purpose, as the honest-content tell's suggestion says to. + const g = await tool('canvas_set_genre', { canvasId: id, genre: 'data' }); + check('canvas_set_genre declares the data purpose', g.canonical === 'data', JSON.stringify(g).slice(0, 200)); + + const b = await tool('batch_design', { canvasId: id, operations: ADAPT }); + const opErrors = typeof b === 'string' ? [b] : (b.errors ?? []); + check('the adaptation applies without op errors', opErrors.length === 0, JSON.stringify(opErrors).slice(0, 300)); + + // The verdicts below are only about the brief's fonts if they loaded: a + // fallback face (offline, a Google Fonts outage) has other glyph widths. + const shot = await call('screenshot', { canvasId: id }); + const fontWarnings = shot.filter((x) => x.type === 'text' && x.text?.startsWith('Font warnings')); + check('the design system\'s fonts load (no Font warnings)', fontWarnings.length === 0, fontWarnings.map((x) => x.text).join(' ')); + + // Stress first, so evaluate's directive reads its verdict. + const st = await tool('canvas_stress', { canvasId: id }); + if (out) writeFileSync(join(out, 'stress.json'), JSON.stringify(st, null, 2)); + const ran = (st.perturbations ?? []).map((p: { name: string }) => p.name).sort(); + check('canvas_stress ran the full default set', JSON.stringify(ran) === JSON.stringify([...STRESS_DEFAULTS].sort()), ran.join(', ')); + const stressWarnings = (st.perturbations ?? []).flatMap((p: { findings?: Issue[] }) => p.findings ?? []).filter((f: Issue) => f.severity !== 'info'); + check('canvas_stress (full default set) is CLEAN', /^CLEAN\b/.test(st.verdict) && stressWarnings.length === 0, `${st.verdict}${stressWarnings.length ? ' — ' + describe(stressWarnings) : ''}`); + + const canvas = JSON.parse(readFileSync(join(home, 'canvases', `${id}.json`), 'utf-8')); + check('the dark token layer exists, so the dark contrast run is not vacuous', Object.keys(canvas.variables?.dark?.colors ?? {}).length > 0); + // Ridgeline's copy never references $body, so pin the role the type floor + // judges: the generated phone body must be at least 16px. + const body = canvas.variables?.typography?.body?.fontSize; + check('the generated $body meets the 16px phone floor', typeof body === 'number' && body >= 16, `${body}px`); + + for (const mode of ['fast', 'detailed'] as const) { + const ev = await tool('canvas_evaluate', { canvasId: id, mode }); + if (out) writeFileSync(join(out, `evaluate-${mode}.json`), JSON.stringify(ev, null, 2)); + const issues = ev.issues as Issue[]; + const blocking = issues.filter((i) => i.severity === 'error' || i.severity === 'warning' || i.tell); + const dark = issues.filter((i) => i.theme === 'dark' && i.severity !== 'info'); + check(`${mode}: CLEAN — zero errors, warnings and tells, READY`, blocking.length === 0 && /^READY/.test(ev.directive), `${ev.overallScore}/100${blocking.length ? ' — ' + describe(blocking) : ''}`); + check(`${mode}: no dark-theme contrast finding`, dark.length === 0, describe(dark)); + const handbuilt = issues.filter((i) => i.rule === 'mobile-handbuilt-target'); + check(`${mode}: zero mobile-handbuilt-target findings`, handbuilt.length === 0, describe(handbuilt)); + const on = new Set(ev.surface?.rulesOn ?? []); + const missing = MOBILE_RULE_IDS.filter((r) => !on.has(r)); + check(`${mode}: the surface report has every mobile rule on`, ev.surface?.type === 'mobile' && missing.length === 0, missing.length ? `missing ${missing.join(', ')}` : (ev.surface?.rulesOn ?? []).join(', ')); + console.log(` ${mode} score ${ev.overallScore}/100 · ${issues.length} findings (${issues.filter((i) => i.severity === 'info').length} info)`); + } + + const nodes = walk(canvas.root); + check(`fewer than ${ATTEMPT_NODES} nodes`, nodes.length < ATTEMPT_NODES, `${nodes.length} nodes`); + const types = new Set(nodes.map((n) => n.type)); + check('device chrome and navigation are the primitives', ['status-bar', 'nav-bar', 'tab-bar', 'home-indicator'].every((t) => types.has(t))); + const chromeFrames = nodes.filter((n) => n.type === 'frame' && /status bar|home indicator|tab bar|nav bar|dynamic island|indicators/i.test(n.name ?? '')); + check('no hand-built device chrome frame', chromeFrames.length === 0, chromeFrames.map((n) => n.name).join(', ')); + + // Negative control: a dark-only contrast failure must surface, so the + // clean dark result above is a real pass. Last, because it edits the canvas. + await tool('set_variables', { canvasId: id, variables: { dark: { colors: { 'text-secondary': canvas.variables.dark.colors['bg-surface'] } } } }); + const control = await tool('canvas_evaluate', { canvasId: id, mode: 'fast' }); + check('control: a dark-only contrast failure is caught', (control.issues as Issue[]).some((i) => i.theme === 'dark' && i.severity !== 'info')); + await tool('set_variables', { canvasId: id, variables: { dark: { colors: { 'text-secondary': canvas.variables.dark.colors['text-secondary'] } } } }); + + if (out) { + for (const theme of ['light', 'dark']) { + const img = (await call('screenshot', { canvasId: id, theme, fullPage: true })).find((x) => x.type === 'image'); + if (img?.data) writeFileSync(join(out, `ridgeline-${theme}.png`), Buffer.from(img.data, 'base64')); + } + writeFileSync(join(out, 'ridgeline.json'), JSON.stringify(canvas, null, 2)); + } +} finally { + await client.close(); +} + +console.log(allPass ? '\nAll checks passed.' : '\nSome checks FAILED.'); +process.exit(allPass ? 0 : 1); diff --git a/test-sync-drift.ts b/test-sync-drift.ts index 1cb88b2..47d2631 100644 --- a/test-sync-drift.ts +++ b/test-sync-drift.ts @@ -38,12 +38,12 @@ const URL_ = `http://127.0.0.1:${port}/`; const W = 800, H = 400; /** The handler's pipeline, minus the MCP envelope. */ -async function sync(canvas: Canvas): Promise { - const imported = await importUrl(URL_, { viewport: { width: W, height: H } }); - const liveHtml = await renderImportedTree(imported.root, W, H); +async function sync(canvas: Canvas, w = W): Promise { + const imported = await importUrl(URL_, { viewport: { width: w, height: H } }); + const liveHtml = await renderImportedTree(imported.root, w, H, canvas.root.width); const resolved = resolveVariables(canvas.root, canvas.variables); - const canvasHtml = renderToHtml(resolved, W, H, canvas); - const diff = await computeDiff(canvasHtml, liveHtml, W, H, 1); + const canvasHtml = renderToHtml(resolved, w, H, canvas); + const diff = await computeDiff(canvasHtml, liveHtml, w, H, 1); return diff.changePercent; } @@ -61,6 +61,17 @@ try { const inSync = await sync(canvas); expect('unchanged page diffs ≈ 0%', inSync < 0.5, `${inSync}%`); + // Phase 30 slice G — a viewport override narrower than the canvas: large + // type scales against the root's width, and the imported side shares it, + // so the 40px heading shrinks alike on both sides and the page stays in sync. + const narrow = await sync(canvas, 600); + expect('unchanged page at a narrower viewport diffs ≈ 0%', narrow < 0.5, `${narrow}%`); + // A fluid root (no numeric width) scales type against 1440 on both sides. + canvas.root.width = '100%'; + const fluid = await sync(canvas, 1920); + expect('a fluid-width root at a viewport above 1440 diffs ≈ 0%', fluid < 0.5, `${fluid}%`); + canvas.root.width = W; + // ── 2. the app drifts → real signal ───────────────────────────────────── accent = '#b71421'; // someone shipped a different CTA color const colorDrift = await sync(canvas);