Spec-driven breakdown, borrowing spec-kit's flow: Specify → Clarify → Plan → Tasks → Analyze. This is a planning artifact, not code. File/symbol references verified against
src/on 2026-07-19. Source: issues #129–#136 — all filed from one dogfooding session building a financial results-and-pace dashboard.
A real dogfooding session — a data-dense financial dashboard with an app shell, a 17-row table, and a 4-series pace-to-goal chart — hit eight distinct walls, and they compound into one meta-problem: framesmith is tuned for marketing-page design, and product screens are where design tooling actually gets used. Concretely:
- The chart got cut from the design because every coordinate had to be hand-computed into
pathdstrings, and the four series could only be told apart by colour — no dashing (#129, #132). - The app shell was rebuilt node-by-node (~35 ops) despite an identical shell in a sibling canvas; the evaluator simultaneously scolds "no component instances found" while nothing in the authoring surface creates one (#130).
- Row rules and accent bars aren't expressible — the
gap: 1fill-bleed hack renders right but fights the spacing linter (#131). - The >95 presentation bar is unreachable by construction: 53
honest-contentflags on the screen's own data (every money figure and percentage) zeroed the cliché category and pinned the overall at 70, and each flag — thoughinfo— isdirective-blocking (#135). - Mechanical work the tools should do stayed manual:
canvas_autofixproposes op strings you re-paste and skips array-form padding (#133); nodes can only be addressed by hand-tracked ids, which caused a real wrong-node edit (#136);fontFamily: "mono"silently fell back to serif for a full build cycle, andset_fontsignored the caller's family label for stylesheet URLs (#134).
Phase 22 closes the set: rendering primitives a product screen needs, an evaluator that can be calibrated to data-dense genres instead of forcing the marketing-page prior, and authoring ergonomics that scale to 300-node canvases.
- Draw product furniture natively: per-side borders, dashed/dotted strokes, data-driven charts — no hand-computed
dstrings, nogap: 1hacks. - Reuse instead of rebuild: promote a subtree to a component, instantiate it with overrides, copy across canvases.
- Make the bar reachable for every genre: realistic data and an app-matched type scale must be able to score >95 without deleting the design's reason to exist — while keeping the anti-slop teeth for marketing pages.
- Close the mechanical loop: autofix applies in place and covers all deterministic spacing forms; nodes are findable by property/name/text; font mistakes surface at authoring time.
- US1 — As the authoring agent, I emit
{ type: "chart", series: [...], yDomain: [0, 2700] }and get a correct 4-series line chart; changing one data point is a one-op edit, and "projected" series are dashed. - US2 — As the authoring agent, I give a table row
borderTop: { width: 1, color: "$outline-variant" }and the current-month rowborderLeft: { width: 3, color: "$primary" }— no layout hacks, no linter fight. - US3 — As the authoring agent, I
create_componenton the app-shell frame once, then stampinstancenodes on 15 sibling canvases (viacopy_nodesfor the definition), overriding the active nav item per screen. - US4 — As the authoring agent on a dashboard canvas, I pass the genre and the evaluator scores craft — spacing, contrast, hierarchy — without flagging the dashboard's own figures, so >95 is reachable and the directive is trustworthy again.
- US5 — As the authoring agent, I ask
find_nodes({ match: { fontSize: 30 }, type: "text" })and get ids + names + paths instead of guessing which id was the value text and editing an icon. - US6 — As the authoring agent,
canvas_autofixwithapply: truesnaps all 136 off-scale spacings — scalar and array — in one call, and tells me what it wrote. - US7 — As the authoring agent,
fontFamily: "mono"renders monospaced, and if I typo a real family, the batch_design result warns me immediately — not three renders later.
| ID | Requirement | Acceptance |
|---|---|---|
| FR-A1 | Per-side borders (#131) — borderTop/borderRight/borderBottom/borderLeft: { width, color, style? } on frames, composable with stroke (per-side wins on its side). |
17-row table gets 1px top rules per row with gap: 0; a row gets a 3px $primary left accent; evaluator raises no spacing flags for it. |
| FR-A2 | Dash control (#132) — strokeDasharray (string or number array, SVG syntax) on path; style: "dashed" | "dotted" on per-side borders and a strokeStyle for the all-sides stroke. |
A path with strokeDasharray: "6 4" renders dashed; a forecast card renders a dashed outline. |
| FR-B1 | find_nodes tool (#136) — property predicate (same match semantics as replace_matching_properties), plus text (substring, case-insensitive) and name (exact) conveniences, scope/type filters; returns [{ id, type, name?, path }] where path is the named ancestor chain. |
find_nodes({ text: "$1.52M" }) returns exactly the value text node with a human-readable path; never mutates. |
| FR-B2 | Autofix applies (#133) — apply: true on canvas_autofix executes the fix ops via the existing op executor and reports applied/failed per op; default false keeps today's propose-only behavior. |
On a canvas with 25 fixables, apply: true leaves zero fixable issues on re-evaluate; result lists the 25 applied ops. |
| FR-B3 | Array padding fixable (#133) — array-form padding gets a fix op that writes the complete snapped array (every entry snapped to scale), removing the "would clobber the others" gap. |
padding: [9, 0, 9, 18] yields fix op U(id, { padding: [8, 0, 8, 16] }). |
| FR-C1 | Genre calibration for honest-content (#135) — a data-genre (e.g. dashboard) relaxes the honest-content tell via the existing RELAXED_BY_GENRE mechanism; reachable via the genre param or provenance. |
The 53-flag dashboard evaluates with zero honest-content flags under the genre and unchanged under default; marketing-page behavior untouched. |
| FR-C2 | Type-scale pinning (#135) — font sizes declared in the merged typography tokens form a pinned scale; the adjacent-ratio check skips pairs where both sizes are pinned. Fix the code/message mismatch (code flags ratio < 1.1, message claims 1.15–1.75). | Declaring 14/13/12/11 as typography tokens clears the ratio warnings; undeclared 16→15 still flags. |
| FR-C3 | Directive honesty (#135) — GUIDELINES + tool text state which number gates presentation (heuristic directive) and what mode: "llm" adds, incl. the no-provider-key failure mode. |
An agent reading only the tool surfaces knows the heuristic directive is the gate and llm mode is optional depth. |
| FR-D1 | Generic font aliases (#134) — mono, sans, serif (and the full CSS generic names) pass through as CSS generics: never sent to Google, never warned, render in the browser's generic face. |
fontFamily: "mono" renders monospaced with no warning and no network. |
| FR-D2 | Caller's family label honored (#134) — set_fonts with a stylesheet URL + explicit family registers the extracted faces under the caller's label (aliasing the stylesheet's name). |
{ family: "mono", url: "<css2 JetBrains Mono URL>" } makes fontFamily: "mono" render JetBrains Mono. |
| FR-D3 | Authoring-time font warning (#134) — batch_design results warn when an op writes a fontFamily that is neither registered, cached, a system/generic family, nor resolvable — at authoring time, not first render. |
A U(id, { fontFamily: "JetBrans Mono" }) (typo) returns a warning content item in the same call. |
| FR-E1 | Promote to component (#130) — create_component({ canvasId, nodeId, name }) moves the subtree into canvas.components and replaces the original with an instance referencing it; returns the componentId. |
Promoting the app shell leaves the canvas rendering identically; the evaluator's instance count goes positive. |
| FR-E2 | Cross-canvas copy (#130) — copy_nodes({ fromCanvasId, nodeIds, toCanvasId, parentId?, index? }) deep-copies subtrees across canvases with re-keyed ids, returning an idMap (the apply_structure shape); component definitions referenced by copied instances travel along. |
Copying the shell instance to a sibling canvas brings the component definition and renders identically. |
| FR-F1 | Chart node (#129) — a data-driven chart node: series: [{ data, stroke?, strokeDasharray?, area?, points?, label? }], yDomain/xDomain (auto from data when omitted), curve: "linear" | "smooth", rendered as SVG sized by width/height. Multi-series in one node. |
The 4-series × 12-month pace chart is ONE node; editing one value is a one-prop edit; series differ by dash + colour. |
| FR-F2 | Chart furniture (#129) — optional gridlines (y-count), xLabels/yLabels (rendered tick labels), and a kind: "line" | "bar" switch; bars share the same series/domain model. |
A labeled monthly bar chart needs zero absolutely-positioned helper nodes. |
- No donut/gauge/pie in v1 (#129) — line + bar cover the dogfood evidence; radial charts are a follow-up once the series model proves out.
- No chart interactivity or tooltips — static renders, same as every other node.
- No project/workspace-scoped component registry (#130) — components stay canvas-scoped;
copy_nodesmoves them between canvases. A shared registry rides on the existing designSystem inheritance chain and deserves its own slice when demand is proven. A shellstructureis the interim answer for new projects. - No name-based addressing inside
batch_designops (#136) —U("@YearTable", ...)is ambiguous the moment names repeat;find_nodes+ ids covers the need with an explicit resolution step. - No cornerRadius autofix (#133) — radius consolidation interacts with the
radius-consistencycliché tell and intent (pills vs cards); it stays a suggestion. - No general spacing-opt-out annotation (#133) — the only motivating case was the
gap: 1hairline hack, which FR-A1 obsoletes.
- C1 — Honest-content calibration mechanism. ★ Decided 2026-07-19: genre-relax. Recommend: genre-relax via the existing
RELAXED_BY_GENREtable — add adashboardgenre (alias:data) relaxinghonest-content; no new metadata surface, one-line mechanism, reachable via the existinggenreparam and provenance. Alternatives: acontentMode: "realistic-data"canvas field (new surface, same effect); downgrading info-severity cliché tells to non-blocking globally (weakens the anti-slop contract for marketing pages — the tells are info but deliberately blocking). Con of genre-only: the agent must know to pass it; mitigated by GOTCHAS + the tell's suggestion text pointing at the genre. - C2 — Chart node shape. ★ Decided 2026-07-19: one
chartnode. Recommend: onechartnode type withkind: "line" | "bar"and aseriesarray — one node per chart (the 4-series case collapses to one node), domains shared across series, furniture (gridlines/labels) opt-in props. Alternative (issue's sketch): a lower-levelpolylinenode per series — more composable but reintroduces per-series alignment bookkeeping (shared scale must be repeated per node) and makes axis labels someone else's problem again. Con ofchart: a bigger single node schema; acceptable — it's still declarative data-in, SVG-out. - C3 — Component scope. ★ Decided 2026-07-19: canvas-scoped + copy_nodes. Recommend: canvas-scoped components +
create_component+copy_nodes(FR-E1/E2) — builds on machinery that already exists end-to-end (canvas.components, instance render resolution,I()component registration at operations.ts:211). Project-scoped components are explicitly deferred (non-goal) — revisit with real demand. Alternative: shipping only a shellstructure— helps new projects but doesn't reuse this project's bespoke shell. - C4 — Generic-alias target. ★ Decided 2026-07-19: CSS generic pass-through. Recommend: pass generics through as CSS generics (
mono→monospace,sans→sans-serif) — honest, zero-network, and the face is then explicitly upgradeable viaset_fonts+ FR-D2 aliasing. Alternative: hard-map generics to designated Google faces (e.g.mono→ JetBrains Mono) — prettier defaults but bakes taste into the resolver and surprises anyone expecting CSS semantics. - C5 —
applydefault. Recommend:apply: falsedefault — preserves current behavior, keeps the propose/inspect loop available;apply: trueis one keystroke for the confident path. Flipping the default breaks the documented contract silently. - C6 — Where the authoring-time font check runs. Recommend: in the
batch_designhandler afterparseAndExecute, checking only families written by this call against registry +SYSTEM_FAMILIES+ generics — cache-only check (no network on the hot path); unresolved families get a warning content item mirroring the render-time wording. A fullwarmFamiliesresolve stays render/token-time. - C7 — Per-side border vs divider node. Recommend: per-side border properties (FR-A1) only, no
dividernode type — borders express both motivating patterns (row rules, accent bars) without a new node type, and match the CSS mental model the importer already reads (borderBottom*is inSTYLE_WHITELISTfrom Phase 18 — import can map it straight in). - C8 —
find_nodesvs extendingread_nodes. Recommend: a separatefind_nodestool —read_nodesanswers "show me this subtree",find_nodesanswers "which nodes match X"; overloading one tool with a query mode muddies both docstrings. Pairs withreplace_matching_propertiesas query-side / write-side of the same predicate semantics (sharedcollectMatchingNodes).
- types.ts:
BorderSide = { width: number; color: string; style?: 'solid' | 'dashed' | 'dotted' };borderTop/borderRight/borderBottom/borderLeft?: BorderSide;strokeStyle?: 'solid' | 'dashed' | 'dotted'(all-sides stroke);strokeDasharray?: string | number[](path only). - renderer.ts
buildStyles: todaystrokeemits the uniformbordershorthand at renderer.ts:374. Emitborder-<side>: <w>px <style> <color>after the shorthand so per-side wins per CSS cascade;strokeStyleswapssolidin the shorthand. Path: appendstroke-dasharraytopathAttrs(renderer.ts:114–119) with a safe-value guard likeSAFE_PATH_D(renderer.ts:105); number arrays join with spaces. - evaluate.ts: no changes needed — borders don't touch the spacing checks; verify hairline borders don't trip
checkConsistency. - import.ts / tailwind-map.ts: map imported
borderBottom*computed styles (already whitelisted, Phase 18 slice A) ontoborderBottominstead of the Divider-frame reconstruction where both exist — verify only; behavior change is optional polish, the Divider path stays. - Discoverability: batch_design docstring property list + README properties row + GUIDELINES;
test-discoverability.tsneeds no new list (properties aren't enumerated) — but the node-type list is untouched, keep green.
- find_nodes (index.ts): thin handler over
collectMatchingNodes(scene-graph.ts, from #127) + two new predicate conveniences implemented alongside it:text(substring, case-insensitive,contentonly),name(exact).path= names (fallback type) of ancestors root→node," / "-joined — computed with a parent-tracking walk (acollectMatchesWithPathvariant;findNodegives single-parent only). - canvas_autofix (index.ts:1416):
apply: z.boolean().optional(); when true, join fix ops with newlines →parseAndExecute(canvas.root, ops, canvas)(operations.ts:14 — stops on first error; report per-op results,touchCanvason any success). Result gainsapplied/failedarrays; docstring updated (the "handles the mechanical subset" wording currently over-promises — align it). - evaluate.ts
checkSpacing: array-padding entries (evaluate.ts:212–218) currentlyfixable: false. Group per node instead: compute the fully-snapped array once and attachfix: { op: formatUpdateOp(id, { padding: snappedArray }) }on a single combined issue per node (avoids N conflicting whole-array ops for one node — the current per-entry loop would emit clobbering duplicates). - Tests:
test-find-nodes.ts(predicates, text/name, path, scope); extendtest-autofix.ts(apply mode round-trip, array-padding snap, per-node single fix op).
- evaluate.ts:
RELAXED_BY_GENRE(evaluate.ts:612) gainsdashboard: ['honest-content'](+ aliasdata); genre already resolves asoptions.genre ?? provenance.preset(evaluate.ts:1255). UpdatetellHonestContent's suggestion text to name the genre escape hatch. - Type-scale pinning:
checkTypography(evaluate.ts:341) receives the mergedDesignVariables; collectvariables.typography.*.fontSizeinto a pinned set; in the adjacent-ratio walk (evaluate.ts:355–368) skip pairs where both sizes are pinned. Fix the threshold/message mismatch (code 1.1 vs message "1.15–1.75") — align message to code (do not tighten the code mid-phase; tightening moves the bar for every existing canvas and needs its own benchmark pass). - Benchmark:
benchmark/baselines.jsonre-check — pinning only removes flags, so scores move up or stay; regenerate if any baseline shifts. - Docs (FR-C3): GUIDELINES "Before you present" + Sharp edges: the heuristic directive is the gate;
mode: "llm"is optional depth and requiresANTHROPIC_API_KEY/OPENAI_API_KEY; genre list for data-dense screens.
- fonts.ts:
GENERIC_ALIASES = { mono: 'monospace', sans: 'sans-serif' }(serif already generic); apply infirstResolvableFamily(fonts.ts:55–64) and add the alias keys to theSYSTEM_FAMILIESskip-set (fonts.ts:35–44) so they never hit Google or warn. Renderer emits the aliased generic. - resolveStylesheetUrl (fonts.ts:310–324): accept an optional
labelparam; when the caller suppliedfamily, register the cached faces under the caller's label (cacheFaces(label, faces)) — index.ts:750–752 passes it through. The stylesheet's own name is reported back in the result text so the alias is visible. - batch_design handler (index.ts, post-
parseAndExecute): per C6, diff the families written this call; warn on families failing the cache/system/generic check via the existing warning content-item pattern (fontWarningContent, index.ts:565). - Tests: extend
test-fonts-by-name.ts/test-custom-fonts.ts(alias render, stylesheet-label honoring, authoring-time warning — cache-only, no network).
- scene-graph.ts / new
src/components.ts:promoteToComponent(canvas, nodeId, name)— deep-clone subtree intocanvas.components[newId](re-keyed ids),replaceNodethe original with{ type: 'instance', componentId: newId }(render path already resolves this:resolveInstance, renderer.ts:271–290; overrides match children byname— renderer.ts:292–304, so the tool result should nudge naming override targets). copy_nodes(index.ts): load both canvases (ensureFreshboth),deepCloneWithNewIds(scene-graph.ts:352) per subtree intotoCanvas, plus copy anycanvas.componentsentries referenced bycomponentIdwithin the copied trees (re-keyed, collision-checked — theapply_structurere-key pattern); return idMap. Persist only the target canvas.- evaluate.ts: no change —
checkStructure's instance advisory (evaluate.ts:483–489) starts passing naturally once instances exist. - Tests:
test-components.ts— promote render-equivalence (screenshot-free: compare resolved trees), cross-canvas copy w/ component travel, override-by-name after promote.
- types.ts:
'chart'inNodeType;ChartSeries = { data: number[]; label?: string; stroke?: string; strokeWidth?: number; strokeDasharray?: string | number[]; area?: boolean; points?: boolean }; node props:kind: 'line' | 'bar',series: ChartSeries[],xDomain?/yDomain?: [number, number](auto = min/max across series, zero-floored for bar),curve?: 'linear' | 'smooth',gridlines?: number,xLabels?/yLabels?: string[]. - renderer.ts:
renderChartSvg(node)besiderenderPathSvg(renderer.ts:108) — pure value→coordinate math (invert y), polyline/pathper series (smooth = Catmull-Rom→cubic),<rect>groups for bars, hairline gridlines,<text>tick labels using the node's font context; token refs in series colors already resolve (resolveVariableswalks all string props — verify arrays of objects are walked; extend the walker if not, same shape asshadows). - Slice A dependency: series dashing reuses the
strokeDasharrayguard from Slice A. - evaluate.ts: chart internals are renderer-generated — exclude
chartchildren from node-count/spacing walks (it has no children; verifycollectEntriestreats it as a leaf). - structures.ts: refresh the
dashboardpage structure's chart placeholder to a realchartnode — the pattern library then demos it (keeptest-patterns.ts> 95 green). - Tests:
test-chart.ts— coordinate math pure-tested (domain mapping, inversion, auto-domain, bar widths), SVG snapshot-ish string assertions; screenshot smoke behind Chrome availability like existing render tests.
Tool docstrings + shared INSTRUCTIONS/GOTCHAS + README + GUIDELINES land in the same PR as each slice; test-discoverability.ts lists extended for: new tools (find_nodes, create_component, copy_nodes), new node type (chart — docstring + README node-type lines), new properties in the batch_design property line. docs-steward dispatched per slice PR.
- Slice A — rendering primitives. Per-side borders + dash control (FR-A1, FR-A2). First: it kills the active linter-fight and unblocks F.
- Slice B — query + autofix.
find_nodes, autofixapply+ array padding (FR-B1–B3). Pure ergonomics, no design risk. - Slice C — evaluator calibration. Genre relax, type-scale pinning, directive docs (FR-C1–C3). Small diff, big trust repair; benchmark re-check.
- Slice D — fonts. Generic aliases, label honoring, authoring-time warning (FR-D1–D3).
- Slice E — components.
create_component+copy_nodes(FR-E1–E2). - Slice F — charts.
chartnode, line + bar, furniture; dashboard structure refresh (FR-F1–F2). Last: biggest surface, depends on A.
- Evaluator loosening is one-way — a genre that relaxes
honest-contentcan be over-applied (agent passesdashboardon a marketing page to dodge flags). Mitigation: genre also comes from provenance; GOTCHAS wording frames it as "declare the genre the screen actually is", and the tell stays fully active by default. - Type-scale pinning trusts tokens — an agent could "pin" a bad scale by declaring junk tokens. Acceptable: declaring the scale is exactly the intentionality signal the check lacks today; the >6-unique-sizes check (evaluate.ts:371) still fires.
copy_nodesid-collision surface — re-keying must covercomponentIdreferences inside copied trees and override keys matched by name (renderer matches overrides by childname, which re-keying must not disturb — names are preserved, only ids re-key).- Chart schema creep — the series model will attract requests (stacked bars, dual axes, log scales). The
kindenum + non-goals line draw the boundary; anything beyond line/bar/donut is a new phase. resolveVariableswalk coverage — token refs insideseries[].strokerequire the resolver to walk object arrays; verify before Slice F lands (same pattern asshadowsif it already works, small extension if not).- Benchmark drift (Slice C) — pinning/genre only remove flags, but
baselines.jsonmust be regenerated deliberately (the Phase 12 lesson: don't let baselines absorb changes silently). - ★ user confirms: C1 (genre-relax mechanism), C2 (single
chartnode w/ series), C3 (canvas-scoped components + copy_nodes), C4 (generics → CSS generic pass-through) — all four confirmed as recommended, 2026-07-19. C5–C8 have recommended defaults a review comment can flip.