210 tests: each builder × 5 pass configs, asserting each pass is a
fixed point on builder output.
Passes gated:
- normalizeTreeLayout
- unwrapFakePhoneMockups (second-call return=false verified)
- stripRedundantSectionFills (second-call return=false verified)
- normalizeStrokeFillSchema
- the full chain in the canonical order (schema → strip fills →
unwrap phone → layout fallback)
Full-chain idempotency is the strongest guarantee: it catches
cross-pass interactions that isolated pass tests miss. If future
refactors make any pass non-idempotent for a specific builder
shape, one of 210 rows fails and names the culprit.
- element-builders-layout.test.ts: 44 tests wrap each of the 42 builder
outputs in a 375x812 frame and run computeLayoutPositions, asserting
no NaN/Infinity coords, every child positioned, widths fit parent
bbox. Proves the real renderer path accepts every builder tree.
- element-builders-composition.test.ts: 3 screens (login / dashboard /
settings) assemble 4-8 builders into a vertical frame, stamp ids,
recurse computeLayoutPositions at every level, and assert expected
role presence. Proves multi-builder assembly survives layout end to
end.
- Drive-by: oxfmt reformat on ai-pipeline-e2e.test.ts imports.
Audited builder-emitted roles vs elements.md invariants list;
tab-underline was the only gap (emitted by buildTabs but not in
the doc's "Roles are set (...)" enumeration). skill-registry
regen happens at dev/build time.
Pairs with the apps/web shim drift-guard (SUPPORTED_EMBEDDED_ELEMENT_TOOLS
⇔ ELEMENT_TOOL_NAMES). This one fires from the server side: every
add_*_v0 registered in pen-mcp must have a matching pen-core
buildX export. Catches the case where a new handler ships without
the canonical pen-core builder (which would mean the embedded
shim + Nitro SERVER_BUILDERS can't cover it, silently degrading
AI generation under the flag).
Failure message maps each missing tool to the expected buildX
name so the fix is mechanical. pen-mcp tests: 8 → 9.
Post-42/42 delegate refactor: pen-mcp handlers all import
assignIdsRecursively + buildScrollWrapper + CJK helpers from
@zseven-w/pen-core/element-builders. The local copies in
element-tool-helpers.ts had zero callers and could drift from
the canonical pen-core versions. Re-export from pen-core so any
external caller that was still reaching in by path keeps
compiling without churn.
File shrinks from ~276 to ~189 lines. Kept: ensureParentExists,
insertElementTree (with its simulateDslParentResolve helper,
rollback + post-insert verification) — these are pen-mcp-specific
server I/O / integrity logic that have no place in pen-core.
Final 11 builders moved to pen-core: rating_stars, carousel_dots,
link, kbd, price, quote_block, code_block, color_swatch, chart_bars,
timeline, calendar_grid. pen-mcp handlers delegate; shim + Nitro
SERVER_BUILDERS now match the full 42-tool pen-mcp catalog.
With this batch the embedded orchestrator can execute any element
tool the AI emits — no more fallback-to-batch_design routing on
elements.md names that happened to be outside the shim registry.
The "advertised vs executable" asymmetry is closed: elements.md
catalog = pen-mcp handler set = shim set = SERVER_BUILDERS set.
Test suite updated: the "unsupported tool short-circuits before
HTTP" case now uses a fictional name (add_fictional_future_v1)
since every real add_*_v0 is now wired. 1907/1907 pass, zero
pen-mcp handler behavior regressions (builders are byte-identical
to the local tree build they replaced).
Batch B — five controls (switch, checkbox, radio, tabs,
segmented_control) moved to pen-core builders; pen-mcp delegates;
embedded shim + Nitro SERVER_BUILDERS gain direct coverage. 311/311
handler tests still pass unchanged. AI generation under the flag
can now emit common form controls without batch_design fallback.
Batch A — six atomic/single-node tools moved from pen-mcp-local to
pen-core builders so the embedded shim + Nitro SERVER_BUILDERS
actually cover them: divider, badge, avatar, icon_button,
icon_label, stat_grid. pen-mcp handlers delegate; 311/311 handler
tests still pass (zero behavior change). Dispatcher short-circuit
list now names 16 tools instead of 10 — AI generation under the
flag can emit these directly without bouncing through batch_design
fallback.
ELEMENT_TOOL_OUTPUT_FORMAT tells the AI to emit
`<op_tool>{"name":"batch_design", ...}` when no element-tool fits.
Prior Nitro implementation hard-coded a 501 for any DSL payload,
so the FALLBACK branch advertised to the AI was a lie — any AI
that actually took the guidance would see its generation fail.
Fix extracts pen-mcp's `handleBatchDesign` pure executor
(`runBatchDesignDsl`) from the file-I/O wrapper and exposes it on
the package's main barrel. Nitro's `/api/mcp/exec-tool` now
accepts `{dsl}`, runs the executor against a clone of the
sync-state doc (no file I/O, no post-processing hooks — those
belong to the pen-mcp server process), and calls setSyncDocument
to broadcast the result via SSE. Response shape gains
`insertedNodeIds: string[]` so batch inserts (multiple root
bindings in one DSL) surface all their root nodes to the
orchestrator's progress accounting, not just the first.
Client dispatcher updated to prefer the array form with fallback
to the legacy single-id field. Adds a test asserting the
dispatcher actually calls fetch when taking the DSL fallback
(proves the route wires end-to-end). JSDoc in dispatcher +
endpoint updated so code and docs agree.
handleBatchDesign's external behavior is unchanged — it still
opens / post-processes / saves around the refactored executor;
311/311 pen-mcp tests pass unchanged.
The 10 highest-frequency add_*_v0 handlers (card_row / metric_row /
bottom_nav / section_header / top_nav_bar / heading / body_text /
text_button / search_bar / list_row) now dispatch their tree build
step to @zseven-w/pen-core's buildX functions. Pre-check, rollback,
post-insert verification stay in element-tool-helpers.ts (server
invariants — apps/web shim doesn't need them).
Zero behavior change: 26 existing pen-mcp handler tests pass
unchanged through the refactor. Server output byte-identical to
pre-refactor. The value is forward-looking — apps/web client shims
(Phase 2) import the same pen-core builders, so the two sides
produce identical trees without manual parity maintenance.
element-tool-helpers.ts re-exports detectCjkScript / cjkFontFamily
from pen-core for backward compat with any external caller.
New subdirectory packages/pen-core/src/element-builders/ with
12 pure tree-build functions matching pen-mcp's add_*_v0 family.
Browser-safe (no node:fs, no document-manager) — meant to be
imported by both pen-mcp handlers (server) and apps/web client
shims (embedded orchestrator) so the tree shape is byte-identical
across paths, eliminating drift by construction.
Covered: buildCardRow, buildMetricRow, buildBottomNav,
buildSectionHeader, buildTopNavBar, buildHeading, buildBodyText,
buildTextButton, buildSearchBar, buildListRow. Plus helpers
(assignIdsRecursively, buildScrollWrapper, ElementTree type) and
cjk-detect (detectCjkScript + cjkFontFamily for heading/body
text font dispatch per repo's CJK contract in text-rules.md).
Re-exported from pen-core's main barrel. Pure additive change —
no existing callers affected. Consumers switched in a following
commit to avoid mixing refactor + infrastructure in one review.
- add_chart_bars_v0: bar-chart skeleton, bottom-aligned via
alignItems=flex-end; 2px floor on zero-valued bars so pen-core
does not collapse them; negative / non-finite values clamp to 0
- add_timeline_v0: vertical timeline with 24×24 dots + fixed 24px
connectors. Connector height is fixed (not fill_container)
because pen-core has no minHeight / stretch — a fill_container
connector collapses to 0 when content col is shorter than the
dot. No row padding, no outer gap, no icon-col gap — connector
IS the full inter-item spacing so dots land flush against both
connector ends. Wrap-content >52px creates a small visual gap
before the next dot (pen-core has no stretch workaround).
- add_calendar_grid_v0: Sun-start month grid, 40px cells; today
gets a light tint, selected_day a solid primary fill (selected
wins on overlap). Emitted as vertical-of-horizontal frames
since pen-core has no grid primitive.
All three follow the applied "应拆尽拆" contract: ≤5 simple
params each, no union types, single output block. Contract test
+ element-tool-defs extended to 42 tools total. elements.md
updated with decision-tree entries, PREFER list matches, example
usage, and role list.
executeLine's three parse regexes (assign / bindless / call) missed
the `s` flag, so `.+` stopped at the first newline and rejected any
pretty-printed JSON body — even though splitOperations already
groups balanced `()`/`[]`/`{}` spans into one logical line. Kimi K2.5
was primed into this style by elements.md examples, tripping the
latent bug on 3/24 A/B prompts; baseline models never pretty-printed
so the bug stayed masked. Regression test locks the fix: bound +
bindless insert + bound update with newline-embedded bodies now
parse, and a genuinely malformed single-line input still surfaces
as an error instead of being silently swallowed.
See openpencil-docs/superpowers/notes/2026-04-21-kimi-k25-regression-rca.md.
User hit at dev-server startup:
Module "node:fs" has been externalized for browser compatibility.
Cannot access "node:fs.readdirSync" in client code.
Chain: apps/web's design-parser.ts imports `parseModelOutput` from
`@zseven-w/pen-ai-skills`; main barrel re-exports everything from
`./corpus`; `./corpus/index.ts` re-exports `loadCorpus` which imports
`node:fs`. Vite pulls the whole graph into the client bundle → crash
on the first browser-side module evaluation.
Fix: remove `loadCorpus` from `./corpus/index.ts`. The barrel now
only exposes pure-string helpers (parser, scorer, aggregator, types)
— all browser-safe. `loadCorpus` stays in `corpus-loader.ts` but
Node-only consumers (`scripts/ab-corpus/run.ts`) import it directly
via a relative path. Package.json only declares the main entry in
`exports`, so sub-path imports via the package name fail at runtime
(pkg runs under Bun for the harness) — relative file path avoids
that gate.
Verification:
- `bun scripts/ab-corpus/run.ts --only X --dry-run` still runs end
to end
- tsc --noEmit exit 0
- Full test suite 1866/1866
Browser-side verification (user): restart Vite dev server — the
design-parser import no longer pulls node:fs through the barrel.
element-tool-defs.ts crossed the repo's 800-line ceiling (814 → 827
after comment expansion) when the 8-tool atom batch landed in 776cdbd.
Move the 19 base tool schema definitions out to
element-tool-defs-base.ts, leaving the main file as a thin aggregator
that just imports BASE + EXT, builds the combined ELEMENT_TOOL_DEFINITIONS
array, and wires the handleElementToolCall switch. File sizes after:
element-tool-defs.ts 153 (aggregator + dispatcher)
element-tool-defs-base.ts 683 (19 schemas)
element-tool-defs-ext.ts 528 (20 schemas)
Zero behavior change — the ELEMENT_TOOL_DEFINITIONS export contents
are byte-identical (same 39 entries in the same order). All 288 pen-mcp
tests + 1834-test full suite pass.
Prompted by Codex stop-hook review flagging the 800-line violation.
Batch adds 8 atomic element tools and routes them through the element-tool
dispatcher: rating_stars / link / kbd / carousel_dots / price / quote_block
/ code_block / color_swatch. Each locks a single-component shape (≤5 simple
params, no union types) so weak models cannot produce an illegal layout
from the input side. ListTools count grows from 70 to 78; schemaVersion
and v0-MUST contract tests cover every new tool.
Also:
- elements.md decision tree / PREFER list / minimal-usage examples extended
to cover the 8 new tools; roles invariant list appended
- buildDesignPrompt() now concatenates getSkillContent('elements') at the
end of the full prompt so external MCP clients asking for the full
prompt see element-tool docs (was a side-excluded section)
The earlier doc-backed fix left one leak: handleGetDesignMd/handleSetDesignMd
still called setDesignMdForPrompt(spec), which wrote into a process-level
module variable `_designMdContent` that get_design_prompt's "style" +
"design-md" sections read. Switching between documents kept the prior
file's policy; get_design_prompt itself had no filePath parameter so it
couldn't even identify the current document.
Fix:
- Delete `_designMdContent` / setDesignMdForPrompt / getDesignMdForPrompt.
- `buildDesignPrompt(section, designMdPolicy?)` takes policy as an explicit
stateless argument.
- Export `designMdSpecToPromptPolicy(spec)` — pure converter.
- Add `filePath` to get_design_prompt's schema. The route handler opens
the addressed document, derives the policy from `doc.designMd`, and
threads it into buildDesignPrompt. Add `design-md` to the section enum
(previously only returned via the "style" override).
- design-md.ts handlers no longer touch the old setter.
Verified by a two-file live smoke: set design.md on A → get_design_prompt
on B returns "No design.md loaded" with no A-specific tokens.
design.md was stored in a global Zustand store + per-file-key localStorage
in apps/web, and in a module-level cache in pen-mcp. Both leaked across
files: a newly-created document could pick up the previous file's dark
palette (async clearForNewDocument raced with AI chat reads; hydrate()
could rehydrate the last file's designMd on refresh; shared .pen files
lost the spec entirely because it wasn't inside the document).
Fix:
- Add `designMd?: DesignMdSpec` to PenDocument (pen-types). It now
serializes with .pen/.op and travels across sessions/users.
- Add `setDesignMd` action to document-store.
- Rewrite design-md-store as a thin mirror over document-store so the
legacy hook API still works. On document load it migrates any legacy
localStorage entry into the opened document and deletes the localStorage
key; hydrate() wipes the orphan `openpencil-design-md-current-key`.
- MCP handleGetDesignMd / handleSetDesignMd / handleExportDesignMd read
`doc.designMd` directly and persist via saveDocument. Removed the
process-level `_mcpDesignMd` cache.
Verified via MCP live round-trip: set on file A → persists to A's .op on
disk → new file B returns hasDesignMd:false (no leak).
pen-core's DEFAULT_FILL is gray-300 (#d1d5db) — any text/icon without
explicit fill renders light gray, unreadable on primary blue / dark /
white backgrounds. Set explicit foreground colors on toast text + icon,
fab icon, stepper step numbers, checkbox check, and segmented-control
labels. Added contrast-regression test to lock the invariant.
Feedback + floating + nav batch. progress_bar uses fixed bar_width so the
fill can be derived from value/100 (pen-core has no percent sizing).
Stepper connectors use fill_container so the bar splits evenly between
circles.
Layout-engine trap: a fill_container child inside a fit_content parent
resolves to the grandparent's width (pen-core engine.ts:182-187), so the
active tab's underline rect blew the whole tab up to bar width. Switched
every tab to width=fill_container (Twitter/Material pattern) so the bar
splits evenly and the underline sits correctly inside its slot.
PenStroke.thickness only supports number | [T,R,B,L] — {bottom:N} silently
resolves to 0 in resolveStrokeWidth so the old tab underline never rendered.
Switched active tab to a vertical frame with a sibling rectangle underline
(role=tab-underline), matching how add_divider_v0 already handles this.
Codex stop-hook: design-routes.ts reached 849 lines, violating the
repo's CLAUDE.md "Single files must not exceed 800 lines" rule.
Extract the 17 element-tool JSON schema definitions + names + dispatch
switch into a new file `routes/element-tool-defs.ts` (671 lines). The
core design-routes.ts keeps only:
- 2 core tool defs (get_design_prompt, batch_design)
- LAYERED_DESIGN_TOOLS spread
- D0 spike tool def + dispatch (gated)
- Combined DESIGN_TOOL_DEFINITIONS / DESIGN_TOOL_NAMES / handleDesignToolCall
that merges core + element-tool via re-export
design-routes.ts: 849 → 201 lines
element-tool-defs.ts: 0 → 671 lines (both under the 800 cap; room to
add ~3-5 more element tools before element-tool-defs itself needs
splitting by category — e.g. atom-tool-defs vs row-tool-defs)
handleDesignToolCall falls through via `if (ELEMENT_TOOL_NAMES.has(name))
return handleElementToolCall(name, a)` instead of an inlined 17-case
switch. Same dispatch semantics, much shorter file.
DESIGN_TOOL_NAMES kept as a single exported Set so existing callers
(server.ts, test files) still see all 22 tool names (5 core + 17 element)
via one import. ELEMENT_TOOL_NAMES also exported for tests that want to
assert the split explicitly.
180/180 pen-mcp tests pass unchanged. format + tsc green. Bundle
rebuilt.
Two composition primitives completing the "atoms + composition" tier.
- add_icon_label_v0: atomic icon + text horizontal pair (alignItems=
center, gap=8, fit_content). Building block for menu items,
breadcrumbs, status indicators. Narrow schema: icon always leads,
sizes fixed (icon 16, text 14/500), no alignment enum.
- add_list_row_v0: iOS/Material list row — optional leading icon +
vertical text stack (title + optional subtitle) + optional trailing
icon (typically chevron-right).
No-overlap invariant: middle text stack wrapped in VERTICAL
container with width=fill_container so long titles wrap vertically
instead of pushing the trailing icon out of frame — same pattern
as add_section_header_v0. overflow.md rule: text with
fill_container + fixed-width only propagates wrap height inside
vertical-layout parents. The vertical wrapper is what prevents the
overlap.
Tests: 11 new unit (5 icon-label + 6 list-row). List-row includes
an explicit no-overlap regression test asserting the text stack is
vertical + fill_container. contract test ELEMENT_TOOL_NAMES updated
15 → 17.
elements.md skill gets a new "Composition" category in the decision
tree (items 16-17) + 2 new PREFER phrases + 2 usage examples + role
list extended. d0 snapshot updated.
MCP live smoke: ListTools = 57 (40 baseline + 17 element);
icon_label produces 3-node tree; list_row full variant produces
6-node tree with text stack correctly vertical + fill_container.
180/180 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
Codex stop-hook #17: after fix#16 made add_body_text_v0 always use
Inter for CJK body, one source still allowed the alternative:
cjk-typography.md:16 said "Body: 'Inter' (system CJK fallback) or
'Noto Sans SC'". Every other authority in the repo says body=Inter
unconditionally:
- text-rules.md (text section of get_design_prompt): body='Inter'
- skills/phases/planning/decomposition.md:45: "body='Inter'"
- packages/pen-mcp/src/tools/add-body-text-v0.ts: always 'Inter'
- skills/phases/generation/elements.md: "Inter everywhere"
- role-definitions.md:88: "body-text: lineHeight=1.5 (CJK: 1.6)"
(no font override)
cjk-typography's "or Noto Sans SC" was the lone dissenter — an AI
reading the domain skill would see a contradictory option that no
other skill or tool supports. Remove the alternative so the repo is
single-voiced.
Also clarify the heading vs body split in the last two bullets: the
script-specific Noto rule is HEADING-only; body is Inter + CJK
lineHeight/letterSpacing. Cross-reference the other authorities so
a future editor knows which rule sources must stay in sync.
253/253 tests pass (pen-mcp + pen-ai-skills). format green.
Codex stop-hook #16: CJK guidance was internally contradictory across
repo skills and the handler. Three sources disagreed:
- text-rules.md (design-prompt TEXT_RULES): "body='Inter'" — unqualified
- cjk-typography.md: body is "'Inter' (system CJK fallback) OR
'Noto Sans SC'" — permissive
- My previous add_body_text_v0 (e24c7fc): body was mapped per-script
to Noto Sans SC / JP / KR — this combination is NOT authorized
by any repo skill (cjk-typography only allows Inter or SC; never
lists JP/KR for body)
Authoritative rule: text-rules.md. Body is Inter regardless of
script. Inter has system CJK fallback at render time so a single
body face serves all scripts. ONLY headings dispatch to
script-specific Noto faces (Noto Sans SC for Chinese / JP for
Japanese / KR for Korean) — that's add_heading_v0's job; body
doesn't need the same split.
Fix:
- add_body_text_v0 handler: fontFamily always 'Inter'; script
detection is now used ONLY to decide lineHeight (1.5 Latin /
1.6 CJK) + letterSpacing (undefined Latin / 0 CJK)
- tool description: rewritten to explicitly note "body ALWAYS Inter"
and "only headings dispatch to Noto faces"
- elements.md usage examples: all 4 body examples now show Inter
output with only lineHeight varying by script; added inline
comment clarifying the text-rules.md derivation
- elements.md decision-tree item 15: reworded to "Inter everywhere"
- add-body-text-v0.test.ts: per-script tests now assert fontFamily
===Inter across zh/jp/ko + kanji+hiragana + mixed content
- d0 parity snapshot regenerated (description changes reach
pre-D0 definitions; snapshot works as designed, catches drift
and is updated deliberately)
169/169 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
Codex stop-hook #15: previous fix (91b9054) updated the handlers to
dispatch by script (Chinese → SC / Japanese → JP / Korean → KR),
but four AI-consumable surfaces still said "all CJK → Noto Sans SC":
- add_body_text_v0 tool description: "Chinese/Japanese/Korean content
gets fontFamily=Noto Sans SC + lineHeight=1.6"
- add_heading_v0 tool description: listed only Latin presets; no
mention of CJK handling at all (silently stale)
- elements.md skill usage example: "// auto Noto Sans SC + 1.6" for a
Chinese string but no example showing JP/KR getting their own faces
- elements.md decision tree line 14-15: just said "CJK detection
(correct fontFamily)" — generic enough that drift was invisible
Fix:
- body_text description rewritten to enumerate the 4-way script
dispatch explicitly (SC/JP/KR/Inter), note JP precedence for
hiragana/katakana + kanji mixes, and document the letterSpacing=0
invariant
- heading description expanded to list both Latin and CJK preset
tables plus the script-specific font mapping with the explicit
"NEVER use SC for JP/KR" admonition lifted from text-rules.md
- elements.md gets 2 new usage examples (JP, KR) showing the
different Noto face selection so the AI reading the skill sees
non-SC CJK fonts in practice, not just in prose
- decision-tree lines for heading/body now name SC/JP/KR explicitly
- d0-parity-spike snapshot regenerated (get_design_prompt enum + tool
definitions have changed descriptions)
169/169 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
Codex stop-hook #14: previous fix (8ca2cb9) mapped ALL CJK content to
'Noto Sans SC'. text-rules.md spec (and memory
project_pencil_optimization) requires script-specific fonts:
Chinese → Noto Sans SC
Japanese → Noto Sans JP
Korean → Noto Sans KR
Using SC for JP/KR has reasonable Unicode coverage but violates the
explicit font contract. Each Noto face ships with script-native
punctuation + glyph variants that the dedicated face renders
correctly.
Fix: extract script detection + font mapping into shared helpers in
element-tool-helpers.ts so add_heading_v0 and add_body_text_v0 stay
in sync:
detectCjkScript(s): 'chinese' | 'japanese' | 'korean' | null
Detection order:
1. Hiragana (U+3040-309F) / Katakana (U+30A0-30FF) → Japanese
(these scripts are UNIQUE to Japanese even when mixed with
Han ideographs — a heading like "今日は" is Japanese despite
having kanji, because hiragana "は" disambiguates)
2. Hangul Syllables (U+AC00-D7AF) → Korean
3. CJK Unified Ideographs / Symbols → Chinese (Simplified default)
4. Otherwise null
cjkFontFamily(script): 'Noto Sans SC'|'Noto Sans JP'|'Noto Sans KR'|undefined
Apply to both add_heading_v0 (CJK_BASE preset table + per-script
fontFamily injection) and add_body_text_v0 (fontFamily = cjkFont ??
'Inter'; letterSpacing = 0 only when CJK).
Tests: 3 new per-script font assertions in each tool's test file
(Japanese → Noto Sans JP, Korean → Noto Sans KR, plus the kanji
+hiragana disambiguation edge case). 169/169 pen-mcp suite pass.
format + tsc green. Bundle rebuilt.
Codex stop-hook #13: heading presets hardcoded Latin typography
(lineHeight 1.0/1.1/1.2/1.25, display letterSpacing -0.5) and violated
three documented CJK rules when called with Chinese/Japanese/Korean
content:
1. memory project_pencil_optimization: "CJK headings 1.3-1.4 (NOT
1.1-1.2 like Latin)"
2. text-rules.md: "CJK letterSpacing: 0, NEVER negative. Negative
letterSpacing causes CJK character overlap."
3. text-rules.md: "CJK font selection: heading=Noto Sans SC /
Noto Sans JP / Noto Sans KR. NEVER Space Grotesk or Manrope —
they have no CJK glyphs."
Fix: same auto-CJK detection used by add_body_text_v0 (regex scan for
\u3000-\u303f \u3040-\u309f \u30a0-\u30ff \u4e00-\u9fff \uac00-\ud7af).
CJK content selects a separate preset table:
display 48/700/1.3 Noto Sans SC (was 48/700/1.0/-0.5 Latin)
h1 32/700/1.3 Noto Sans SC (was 32/700/1.1)
h2 24/600/1.35 Noto Sans SC (was 24/600/1.2)
h3 20/600/1.4 Noto Sans SC (was 20/600/1.25)
All CJK presets drop letterSpacing entirely (never negative, never
overridden from theme). Latin presets unchanged.
Tests: 6 new CJK cases (zh/jp/ko/mixed/Latin-still-works +
per-level lineHeight verification). Loop test uses unique filenames
per iteration to sidestep openDocument cache reuse.
13/13 heading tests pass. 160/160 pen-mcp overall. format + tsc green.
Bundle rebuilt.
Three text-primitive tools, each encoding a documented Pencil-demo or
memory-noted non-Claude failure mode.
- add_text_button_v0: padding-based button (padding=[12,20],
cornerRadius=8, fit_content × 2, horizontal, centered). Pencil demo
pattern — height auto-derives from padding, no explicit height.
Optional leading icon at 16px. Narrow: single md preset (no size
enum). Label + optional icon = 1-2 children.
- add_heading_v0: typographic heading with 4-level preset enum
(display / h1 / h2 / h3; default h2). Each preset fixes fontSize
/ fontWeight / lineHeight / optional letterSpacing per memory
data (display=48/700/1.0/-0.5, h1=32/700/1.1, h2=24/600/1.2,
h3=20/600/1.25). Single text node output — the enum only changes
typography, not structure ("应拆尽拆" compliant). Prevents the
"default 1.5 lineHeight makes multi-word headings stack tight"
failure mode.
- add_body_text_v0: body text with AUTO CJK detection via regex
scan (/[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff
\uac00-\ud7af]/). CJK → fontFamily='Noto Sans SC' + lineHeight 1.6
+ letterSpacing 0 (memory: NEVER Space Grotesk/Manrope for CJK).
Latin → Inter + 1.5 + no letterSpacing override. Always sets
width=fill_container + textGrowth=fixed-width (intended for
vertical-layout parents per the documented rule). Mixed
content triggers CJK.
Tests: 18 new unit (4 + 7 + 7). CJK detection tested across Chinese /
Japanese / Korean / mixed. 160/160 pen-mcp suite green.
Contract test's ELEMENT_TOOL_NAMES updated 12 → 15. elements.md skill
gets a new "Text + button primitives" category in the decision tree
(items 13-15) + PREFER phrases + usage examples; regression test
dynamically derives expected names from registry so no stale-drift.
MCP live smoke (/tmp/claude/mcp-test-phase-1c-tools.ts): ListTools =
55, element tools = 15; display heading gets correct typography
preset; Latin body gets Inter+1.5; CJK body gets Noto Sans SC+1.6+0
letterSpacing.
format + tsc green. Bundle rebuilt.
Codex stop-hook #12: previous fix (e05ca79) introduced its own rot —
- Test "names EVERY production element tool" asserted
elementTools.length >= 12. A hardcoded count is exactly what the
whole regression suite is trying to prevent. If someone removes a
tool, the count drops to 11 and the test keeps passing (because
>=12 is a floor, not a target).
- get_design_prompt description said "12 tools covering rows[..]/
containers[..]/atoms[..]". "12 tools" is stale the moment we add
or remove a tool. The category names (rows/containers/atoms) are
also hardcoded — if we ship a new category, the description is
misleading.
Fix:
- Test: replace `>=12` with `>0`. Assertion is now: "registry has
at least one element tool AND every element tool is mentioned in
the elements skill." No hardcoded count.
- Description: replace the "12 tools covering [..]" enumeration
with "N-tool element-tool family reference — decision tree,
PREFER/FALLBACK rules, composition pattern; the section itself
enumerates the current tools." Specific names + counts live in
elements.md skill where the regression test keeps them in sync
with the registry.
- Add regression test asserting the description does NOT match
/\d+\s+tools?\s+(covering|in|across)/i — catches any future
reintroduction of a hardcoded count.
d0 snapshot updated to reflect the new description.
142/142 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
Codex stop-hook #11: the last 3 tools (divider / badge / avatar) were
registered in MCP + tested in unit tests, but the AI-facing integration
layer was stale:
- elements.md skill still listed only 9 tools (missing divider / badge
/ avatar from decision tree, PREFER phrases, usage examples, and
role registry)
- get_design_prompt tool description still enumerated the ORIGINAL 5
element tools ("add_card_row_v0 / add_metric_row_v0 / …")
- Both are read directly by external MCP clients (Claude Code / Codex
/ Gemini CLI / Cursor) to decide which tool to pick — stale content
means the AI never learns the newer tools exist.
Fix:
- elements.md: add divider / badge / avatar to decision tree (new
"Atoms" category after rows + containers), PREFER phrases list,
usage example block, role guarantee list. Frontmatter description
updated to "12 tools" with category breakdown. Budget bumped
1500 → 1800 tokens to accommodate the 3 new sections.
- design-routes.ts: get_design_prompt description rewritten to
describe the element-tool family as "12 tools covering rows
[card/metric/nav_chip/stat_grid], containers [bottom_nav/
top_nav_bar/section_header/icon_button/activity_ring], atoms
[divider/badge/avatar]" — generic categorization plus named
examples, not a stale list that rots on every addition.
- d0-parity-spike snapshot updated (get_design_prompt definition
changed — intentional).
Regression tests to prevent future drift:
- "names EVERY production element tool": derives the expected list
dynamically from DESIGN_TOOL_DEFINITIONS, so any future element
tool added without updating elements.md trips the test. Was
previously hard-coded to 5 tool names.
- "description has no stale element-tool references": any
`add_*_v0` name appearing in get_design_prompt's description
must correspond to an actually-registered tool.
142/142 pen-mcp tests pass (was 141; +1 stale-guard for description).
format + tsc green. Bundle rebuilt.
Three low-risk single-/double-node tools completing the first
"应拆尽拆" batch. ListTools now 52 (40 baseline + 12 element).
- add_divider_v0: hairline rectangle (horizontal default: fill_container
width, height=1; vertical swaps axes). Memory-documented pattern
(Pencil reverse engineering): "Dividers: rectangle(h=1,
fill_container) or directional stroke". Ships colorless.
- add_badge_v0: short pill / tag (cornerRadius=999, padding=[4,10],
font 11/600). Forces the documented constraint (overflow.md):
CJK ≤8 chars / Latin ≤16 chars — longer labels should not be badges.
- add_avatar_v0: circular avatar with optional centered initial. Same
frame+cornerRadius=size/2+flex-centering pattern as activity_ring —
NEVER the ellipse+sibling text anti-pattern (layout.md §RING /
CIRCLE WITH CENTER CONTENT). Initial font auto-scales (size × 0.4,
floored at 12 for tiny avatars).
Tests: 16 new unit (6 divider + 4 badge + 6 avatar). contract test
updated from 9 → 12 tool names. 141/141 pen-mcp suite passes.
MCP live smoke (/tmp/claude/mcp-test-phase-1b-tools.ts): ListTools
= 52, all 3 new tools callable with expected node counts
(divider=1, badge=2, avatar-with-initial=2), avatar cornerRadius
verified as size/2.
format + tsc green. Bundle rebuilt.
Codex stop-hook #10: previous fix (72087f8) used
width=fill_container + textGrowth=fixed-width on the title text but
placed it DIRECTLY inside the horizontal section-header frame. Per
packages/pen-ai-skills/skills/phases/generation/overflow.md:
"Text in VERTICAL layout: width=fill_container + textGrowth=fixed-width.
In horizontal: width=fit_content." The layout engine only measures
wrap-grown height when text follows the vertical-layout rule. In our
horizontal header, a wrapped title rendered visually but did not
propagate its extra height to header.height=fit_content, so the
header stayed at single-line height and the NEXT sibling in the
parent vertical layout overlapped the wrapped lines.
Fix: introduce a fill_container + vertical + fit_content title
container that wraps the text node. The text now follows the
documented rule (fill_container + fixed-width in vertical parent),
wrap height is measured correctly, and header.height=fit_content
grows to match. Following content gets pushed down by the wrapped
height as intended.
Regression test: add a long-title case that asserts
- header.height === 'fit_content'
- title-container.layout === 'vertical'
- title-text.width === 'fill_container'
- title-text.textGrowth === 'fixed-width'
- header has NO space_between AND NO fixed height
Existing id-count test updated: 5 → 6 nodes (added title container).
7/7 section-header tests pass; 125/125 pen-mcp suite green. format +
tsc clean. Bundle rebuilt.
Codex stop-hook #9: previous implementation used
justifyContent:space_between with both children at natural
(fit_content) width. Long titles push past the action's starting
position and overlap it — flexbox space_between distributes REMAINING
space but does not clip items that collectively exceed the container
width.
Fix: title now takes width:fill_container + textGrowth:fixed-width so
it consumes all remaining horizontal space and wraps vertically when
too long (the header's height:fit_content accommodates wrapping).
Action stays width:fit_content on the right. Header adds gap:16 for
guaranteed visual breathing room. justifyContent:space_between is
intentionally removed — fill_container on one sibling makes it
redundant and the removal is what prevents the overlap.
Regression test: seed a header with a deliberately long title + short
action and assert title has fill_container/fixed-width, action has
fit_content, and header has NO justifyContent. 7/7 section-header
tests pass. 124/124 pen-mcp suite green.
Step 7 continues: 5 → 9 narrow element tools, each solving one
documented anti-pattern from pen-ai-skills prompt knowledge.
- add_stat_grid_v0: NON-scrolling 2-5 metric grid. Each cell uses
width=fill_container so the renderer auto-distributes space.
Directly solves the documented activity-rings overflow bug in
packages/pen-ai-skills/skills/phases/generation/layout.md
(three fixed 100px rings in a 279px inner card silently clip the
third; with fill_container the third fits by construction).
Different from add_metric_row_v0 which is a scrolling wrapper
with fixed-px items.
- add_section_header_v0: heading + optional trailing action ("See
all" / "View more"). Forces horizontal space_between alignItems=
center so action stays flush-right. Common dashboard pattern that
non-Claude models frequently vertical-stack instead.
- add_top_nav_bar_v0: mobile app bar. Leading icon (back/menu) +
centered title + trailing icon (search/more). Dual of
add_bottom_nav_v0. Empty slots become 44×44 spacers so the title
stays visually centered even with asymmetric icons.
- add_icon_button_v0: 44×44 icon-only button with flex centering.
Explicitly NOT layout=none (the documented anti-pattern in
memory: layout=none + nested absolute-positioned children renders
unreliably under Skia). Forces layout=horizontal + justifyContent
/alignItems=center.
All four follow the established element-tool pattern:
- Sugar route via insertElementTree (parent_id pre-check, DSL
escape pre-check, snapshot rollback, post-check parent-location
verification)
- assignIdsRecursively on the built subtree
- No union types in schema; narrow required params; optional
sizing/styling via follow-up batch_design U-op
Tests: 22 new unit (5+6+6+5 per tool) + element-tools-contract
updated to assert all 9 tools satisfy §4 invariants. All 124
pen-mcp tests pass.
skill file `elements.md` expanded from 5 → 9 tools (decision tree
updated, PREFER phrases mapped per tool, usage examples added,
role list extended). Registry regenerated from 44 → 44 skills
(same count, elements.md edit in place).
MCP live e2e smoke via StdioClientTransport confirms ListTools now
returns 49 tools (40 baseline + 9 element), all 4 new tools
callable, structural invariants verified on live output
(stat-grid cell.width=fill_container; icon-button layout \!= none;
3-slot top-nav structure; section-header action group).
format + tsc green. Bundle rebuilt.
Codex stop-hook #8: elements.md had `trigger: null` which makes
resolveSkills('generation', ...) unconditionally load its 1500-token
N-tool reference into every generation prompt — including the
embedded orchestrator in apps/web/src/services/ai that emits
single-shot JSON and CANNOT call MCP tools. The content was dead
weight there (orchestrator-sub-agent.ts:333 / ai-prompts.ts:132 both
build generation prompt via resolveSkills without any tool-use path).
Fix: trigger: { flags: [hasMcpTools] }. Skill only auto-loads when
caller explicitly declares MCP tools are available. No existing caller
sets this flag, so the embedded orchestrator prompt is now clean
again.
External MCP clients (Claude Code / Codex / Gemini CLI / Cursor) still
get the content via get_design_prompt(section='elements'), which uses
getSkillByName direct lookup and bypasses resolveSkills' trigger
filter. That contract is preserved.
Added explanatory HTML comment in the skill header so future editors
understand the gating + opt-in rule.
Tests: 4 new cases in design-prompt-elements.test.ts verify:
- getSkillByName returns skill regardless of flags (direct lookup)
- resolveSkills('generation') WITHOUT flag → elements excluded
- resolveSkills('generation') WITH {hasMcpTools:true} → elements included
- buildDesignPrompt('elements') works regardless of flag (bypass path)
14/14 design-prompt-elements tests pass; 186/186 across pen-mcp +
pen-ai-skills. format + tsc green. Bundle rebuilt.
Codex stop-hook #7: previous rollback (a0ace58) called
saveDocument(fp, restoredDoc) to trigger the live-canvas re-sync, but
saveDocument runs JSON.stringify(doc) with no indent and writes the
result to disk. That overwrites the snapshot we just restored via
writeFile — losing the user's original formatting (indentation, key
order, number representation, trailing newlines) and replacing it
with pen-mcp's canonical serialization.
Fix:
- Export pushLiveDocument from document-manager so callers can trigger
a live-canvas push WITHOUT re-writing disk
- Rollback path: writeFile(snapshot) + invalidateCache + openDocument +
pushLiveDocument. The file is restored byte-exact; pushLiveDocument
reads the parsed doc from cache and pushes it, no disk rewrite
Semantically equivalent to before for the live-canvas side (same push
with same restored doc) but now preserves the snapshot bytes on disk
exactly. Important for users who hand-edit .op files or whose original
document was produced by a different serializer (pen-core, Electron
save-as, etc.).
88/88 pen-mcp tests pass (existing "file bytes unchanged" assertions
already verify this; they simply now rely on a path that doesn't
re-serialize). format + tsc green.
Codex stop-hook #6: saveDocument is DUAL-WRITE for file-backed paths
(document-manager.ts:411) — it writes to disk AND calls
pushLiveDocument. handleBatchDesign uses saveDocument so a bad insert
gets pushed to the live canvas before our post-check runs. Prior
rollback (6dfa88b) only restored the file via writeFile, leaving the
live-canvas renderer showing the bad insert until the next refresh.
Fix: after writeFile + invalidateCache, call saveDocument(fp, restoredDoc)
in the rollback path. saveDocument will re-write the file (no-op, same
content) AND call pushLiveDocument again with the restored doc,
bringing the live canvas back in sync with disk. If the live-sync step
fails, re-throw with a diagnostic noting the live canvas may be stale
but disk is authoritative.
Wrap the re-sync in try/catch so a transient live-sync failure doesn't
swallow the original insert-failure reason. Disk is the source of
truth and is already restored when we hit this path.
If no sync URL is configured (common in unit-test contexts),
pushLiveDocument is a no-op — so this is safe in all scenarios.
Live-canvas-only paths (filePath='live://canvas') remain
non-rollback-able because pushLiveDocument is a one-way push without
a history mechanism; the pre-check is the primary defense there.
88/88 pen-mcp tests pass (rollback behavior unchanged at the
observable-test level since our tests don't configure a live sync
URL; the re-sync is correct by construction given saveDocument's
published semantics). format + tsc green.
Codex stop-hook #5: previous post-insert check (5311313) throws on
wrong-parent detection, but by the time we detect it batch_design has
ALREADY written the bad insert to disk / pushed to live canvas. The
throw is a post-facto notification, not a guarantee.
Three-layer defense:
1. **Pre-check (NEW, prevents disk write)**: simulateDslParentResolve
mirrors batch-design.ts:resolveRef (`raw.replace(/^"|"$/g, '')`, no
JSON-unescape). If JSON.stringify(parent_id) → quote-strip ≠
parent_id, the DSL round-trip is lossy → throw before ever calling
handleBatchDesign. Covers ids containing `"` or `\` (the cases
Codex #3 and #4 flagged).
2. **Snapshot + rollback (NEW, for file-backed docs)**: before calling
handleBatchDesign we readFile the target into a snapshot. If any
post-check (errors / missing insert / wrong parent) trips, we
writeFile the snapshot back + invalidateCache, then throw.
File-backed docs are now atomic at the element-tool boundary.
3. **Post-check (kept as defense-in-depth)**: node-in-tree +
parent-location verification. With pre-check in place these should
be unreachable, but they guard against future DSL-parser changes
or silent-failure modes we haven't anticipated.
Known limitation: live canvas cannot be atomically rolled back because
pushLiveDocument is a one-way push. The rollback path skips live with
a clear error message noting the insert may still be visible until
next refresh. Pre-check is the primary defense for live; post-check
rollback-best-effort applies only to filePath'd calls.
Tests updated: existing "weird quoted parent_id" and
"A\"B decoy collision" regression tests now hit pre-check (cleaner
error, same invariant: throw + file unchanged). 88/88 pen-mcp pass.
format + tsc green.
Codex stop-hook #4: the prior post-insert check (e797584) verified that
the inserted nodeId is findable anywhere in the tree, but did not
verify it landed under the REQUESTED parent. Silent wrong-parent
inserts are still possible if batch_design's resolveRef quote-strip
produces a literal that matches a DIFFERENT node than the one
ensureParentExists validated.
Concrete example: doc has nodes with ids `A"B` (3 chars: A, ", B) and
`A\"B` (4 chars: A, \, ", B). User passes parent_id='A"B'.
ensureParentExists does a raw-string `===` match and finds the first
node. insertElementTree does JSON.stringify → `"A\"B"` in DSL source.
batch_design's parseInsertArgs → resolveRef → `/^"|"$/g` quote-strip
→ literal `A\"B` (4 chars). insertNodeInTree matches the DECOY and
inserts under it. Prior post-check: node is in tree → passes. Actual:
wrong parent.
Fix: when args.parent_id is provided, post-check also walks
findParentInTree(postChildren, insertedId) and confirms the resolved
parent id === the requested parent_id. Mismatch → throw with clear
diagnostic.
Regression test: element-tools-contract.test.ts adds the A"B / A\"B
decoy scenario and asserts the tool throws (not silent success).
88/88 pen-mcp tests pass (+1 new wrong-parent guard). format + tsc
green. Bundle rebuilt.
Codex stop-hook #3: JSON.stringify(parent_id) in insertElementTree
doesn't prevent silent no-op. batch_design's resolveRef
(batch-design.ts:442-445) only does `/^"|"$/g` quote-stripping — it does
NOT JSON-unescape — so a parent_id containing `"` or `\` round-trips
as a different literal than what's actually stored in the document.
insertNodeInTree finds no match, silently returns the original tree,
and batch_design still reports success (pushes to results, bumps
nodeCount, errors stays empty).
ensureParentExists catches the "parent genuinely doesn't exist" case
via pre-check using the raw string for direct equality. It does NOT
catch the escape-mismatch case (which passes pre-check with the raw
string but fails in the DSL path).
Fix: insertElementTree now re-reads the document after handleBatchDesign
and confirms the inserted nodeId is actually findable via
findNodeInTree. If not present → throw with diagnostic info about
parent_id and pageId. This is the single source of truth for "did the
insert land?" regardless of what DSL parser subtleties occur
downstream.
Regression test: element-tools-contract.test.ts adds a case that seeds
a document with parent id containing a literal `"` (manually crafted —
nanoid never produces such ids, but imported/migrated docs might),
then calls handleAddBottomNavV0 with that parent_id. Previously the
tool would return success with an orphan record; now it throws and the
on-disk document is verified unchanged.
87/87 pen-mcp tests pass. format + tsc green. Bundle rebuilt.
Four cleanup items from the second independent Codex review, all
scoped to element tools + their contract tests.
P0.1 insertElementTree safety boundary (element-tool-helpers.ts:112-124):
- parent_id now JSON.stringify'd so ids containing quotes/backslashes
cannot escape DSL quoting and inject additional batch_design ops
- per-item batch_design errors are re-thrown with a concise summary
instead of silently surfaced via result.errors (N-tool single-insert
semantics require loud failure; callers can't distinguish "inserted
with errors" from "didn't insert at all")
P0.2 add_activity_ring_v0 narrowing (add-activity-ring-v0.ts):
- strip ring_color / text_size / text_weight params (were 6 business
params → now 3). Typography/color hardcoded (#000000, 16, 700) per
spec D6 Style-Guide-orthogonal invariant. Callers override via a
follow-up batch_design U-op, same as card/metric/nav_chip rows
- route schema + description + test updated accordingly
P0.3 test hardening:
- metric_row + nav_chip_row gain "every node has a unique non-empty id"
regression test matching the other 3 element tools (id coverage parity)
- all 5 element tools gain "throws on bogus parent_id AND leaves file
untouched" side-effect assertion. Previous tests only checked the
throw (mirroring helper impl); now tests verify the actual invariant
that matters to the user — the document doesn't get partially
mutated when validation rejects
P0.4 schemaVersion input property (design-routes.ts + new
element-tools-contract.test.ts):
- every element tool (5 production + 1 spike) now declares schemaVersion
as an input property (enum: ['1.0'], optional) per spec §4.2. Was
previously only mentioned in description strings, which clients
couldn't introspect
- new element-tools-contract.test.ts cross-validates §4.1 (additive),
§4.2 (schemaVersion property), §4.4 (filePath/parent_id accepted),
and "应拆尽拆" invariant (no children_type/variant unions leaked
back in)
86/86 pen-mcp tests pass (+6 new contract + 2 new id coverage + 5 new
side-effect). format + tsc green. Bundle rebuilt.
P1 items still open (server-side JSON Schema enforcement, role
registration for nav-chip-active/nav-item-active, live-canvas race
condition in ensureParentExists, saveDocument silent-failure path)
tracked separately.