Commit graph

235 commits

Author SHA1 Message Date
Fini 52835ef1ca test(ai): full-screen orchestrator-drive pipeline (login/dashboard/settings)
5 integration tests where one raw AI response contains 5-10 op_tool
tags forming a complete screen. Verifies:

- 8-tool login screen applies each tool, one undo batch wraps all
- dashboard: 5 tools land in emitted order (top-nav → stat-grid →
  section-header → scroll-row-wrapper → bottom-tab-bar)
- settings: interleaved list-row + divider preserves order
- mixed known/unknown: 2 apply + 1 short-circuit, still one batch
- empty emission: parser returns [], dispatcher reports 'empty'

Complements ai-pipeline-e2e.test.ts (one-tag-at-a-time chain). This
is the multi-tag shape the N-tool orchestrator actually emits for a
full screen — catches ordering / batching / partial-failure
regressions that single-tag tests miss.
2026-04-22 01:38:03 +08:00
Fini 4307b3da91 test(ai): every builder output survives rewriteLlmAntiPatterns untouched
44 tests (42 builders × no-op assertion + 2 regression anchors for
activity-ring / progress-bar primitives).

Builders are clean-by-construction templates — they should never
trip an LLM anti-pattern detector. If any detector mutates a
builder tree, this test fails on that row with a visible diff,
pointing directly at either:
  - a builder regression (e.g. drifted to stacked ellipses), or
  - a false-positive in the detector on valid builder output.

Two regression anchors hard-code the ring rule from auto-memory:
activity-ring and progress-bar must use frame/rectangle, never
stacked ellipses — the same anti-pattern from the 2026-04-07 lesson.
2026-04-22 01:34:22 +08:00
Fini 6120be6923 test(pen-core): post-processing idempotency across all 42 builders
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.
2026-04-22 01:32:06 +08:00
Fini 12915d10cb test(ai): role-resolver coverage over all 42 element builders
- 128 tests (42 builders × 3 assertions + 2 vocabulary sanity checks):
  1. resolveTreeRoles doesn't throw on light theme
  2. resolveTreeRoles doesn't throw on dark theme (forced via 7th arg)
  3. node count preserved + top-level role survives resolve pass
- Aggregate role set assertion (>= 60 distinct roles) catches mass
  stripping if a refactor drops role annotations.
- Covers 85 unique role strings emitted by builders. Unknown roles
  are documented pass-through per role-resolver.ts:292, so a typo
  wouldn't throw; this test at least anchors the vocabulary in place.

The test is in apps/web because resolveTreeRoles + role-definitions
live there (browser-side post-generation pipeline).
2026-04-22 01:30:04 +08:00
Fini de8e3df15c test(pen-core): layout smoke + composition pipeline for element builders
- 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.
2026-04-22 01:26:19 +08:00
Fini 2aac67c0fd test(ai): end-to-end AI-string → dispatcher → store pipeline (9 cases)
Covers the full embedded orchestrator path as it runs in production:
raw <op_tool> response → tryParseElementToolOutput → dispatcher →
document-store. Nothing mocked between parser and store.

Cases: happy-path element tool lands as text node with correct
content; parent_id targets seeded container; stale parent_id
fails without write; batch_design fallback detected + HTTP
attempted (fetch stubbed to fail); multi-tag response batches
into one undo entry; malformed tag returns null (orchestrator
falls to legacy JSONL); uncovered tool name short-circuits
before HTTP; <think> wrapper stripped (reasoning models);
ctx.defaultParentId applied when payload is rootless.

Pairs with the isolated dispatcher / parser / shim tests — this
one's the "everything wired together" proof. Full repo: 2052
tests across 212 files.
2026-04-22 01:16:45 +08:00
Fini b669f361a8 feat(ai): multi-op_tool dispatcher support (BatchDispatchResult)
design-parser.ts gains tryParseAllElementToolOutputs(raw) → returns
every `<op_tool>` tag in emit order (element-tool or
batch-design-dsl shape). Single-tag helper stays for orchestrator's
current path; this one's for future prompts that emit multiple
tags per response.

element-tools-dispatcher.ts gains dispatchElementToolCalls(shapes,
ctx) — wraps the whole loop in ONE startBatch/endBatch pair so
N tags collapse to one undo entry. Per-shape results preserve
emit order. Individual shape failure does NOT abort the batch
(matches pen-mcp handleBatchDesign's "collect-errors-keep-going"
philosophy; the AI's later tags may depend on earlier successful
inserts). BatchDispatchResult.status rolls up to applied /
partial / all-failed / empty.

5 new unit tests: empty list skips batch, 3-successful one undo
entry, partial status, all-failed status, result order matches
input order. Full repo: 2043 tests across 211 files.
2026-04-22 01:13:04 +08:00
Fini 0d9726d792 test: insertElementTree direct + builder edge-case coverage
insert-element-tree.test.ts (8 cases) — previously only covered
indirectly via handler integration tests. Pins parent_id quote-
safety pre-check, byte-exact file rollback on rejection,
post-insert verification catches silent no-op, happy-path root
insert + parent-targeted insert.

element-builders-edge-cases.test.ts (20 cases) — robustness
boundary coverage: mixed-script i18n (Arabic, emoji, hangul+han,
hiragana+latin), extreme numeric values (chart_bars 1e9 / all-
zero, stepper total=100, rating_stars overflow clamp), minimal
inputs (single-item rows, solo timeline entry), extreme label
length (badge 40 char, kbd 4-key no separator), calendar_grid
boundaries (28-day, 31-day offset 6, day 0 not marked, upper
clamps).
2026-04-22 01:03:40 +08:00
Fini ab35c0d777 test(ai): Nitro /api/mcp/exec-tool endpoint unit tests (12 cases)
Covers the endpoint's full responsibility matrix:
- 400: missing body / unknown tool name (via different branches)
- 404: unknown tool, invalid pageId, invalid parent_id,
  invalid default_parent_id
- 409: no live-canvas doc synced (element-tool route + DSL route)
- 501: real filePath (live://canvas is accepted as sentinel)
- 400: DSL with parse errors (per-line preview in message)
- happy paths: element-tool writes to sync-state; parent_id
  actually nests inserted node under named container; DSL
  route invokes runBatchDesignDsl + returns inserted IDs

Uses `vi.mock('h3', ...)` so the handler runs as a plain async
function — no live Nitro runtime. `clearSyncState()` in each
beforeEach isolates doc-mutation cases. server-logger mocked to
keep test output clean.

Full-repo: 2010 tests across 209 files.
2026-04-22 00:41:15 +08:00
Fini b1e153ba21 fix(ai-skills): add tab-underline to elements.md roles list
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.
2026-04-22 00:37:38 +08:00
Fini 2bca3e2abf test(mcp): contract drift-guard — pen-core builder exists for every tool
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.
2026-04-22 00:36:17 +08:00
Fini 05cfa53795 refactor(mcp): drop dead tree-build helpers from element-tool-helpers
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.
2026-04-22 00:35:29 +08:00
Fini d10ff757c3 test(core): pen-core element-builders pure-function test coverage (42)
88 unit tests across 3 files lock builder output shape + key
invariants for all 42 buildX functions:

- element-builders-atoms.test.ts (15 builders): divider / badge /
  avatar / icon_button / icon_label / link / kbd / price /
  color_swatch / fab / toast / heading (+CJK dispatch) / body_text
  / text_button / switch
- element-builders-rows.test.ts (17 builders): card_row /
  metric_row / nav_chip_row / bottom_nav / section_header /
  top_nav_bar / stat_grid / tabs / segmented_control / breadcrumb
  / stepper / rating_stars / carousel_dots / list_row / search_bar
  / form_field / chart_bars
- element-builders-composites.test.ts (10 builders): empty_state
  / alert / checkbox / radio / activity_ring (frame+cornerRadius
  pattern) / progress_bar / quote_block / code_block / timeline
  (connector invariants) / calendar_grid (offset + selected-wins)

Guards structural drift when builder internals change. Pairs with
the apps/web shim drift-guard to lock "shape + name coverage" from
both sides. 1997/1997 test total; pen-mcp handlers unchanged.
2026-04-22 00:33:38 +08:00
Fini 681f7f41e1 test(ai): drift-guard shim registry ⇔ pen-mcp ELEMENT_TOOL_NAMES
Two assertions that fire if the embedded shim registry drifts out
of sync with the pen-mcp catalog:

  1. Every add_*_v0 name pen-mcp exposes must have a shim —
     catches "added a pen-mcp tool, forgot the builder / shim /
     Nitro SERVER_BUILDERS update" triple-edit drift.
  2. Every shim key must exist in pen-mcp — catches stale shims
     for removed or renamed tools.

Failure message names exactly which tools are missing on each
side so the fix is a copy-paste, not a search. Pairs with the
existing short-circuit test — together they lock the invariant
that elements.md catalog = shim set = Nitro registry.
2026-04-22 00:20:50 +08:00
Fini 9f7c8f7b54 feat(ai): complete embedded element-tool coverage to 42/42
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).
2026-04-22 00:19:30 +08:00
Fini 54d24fe650 feat(ai): extend embedded element-tool coverage to 31 (+state/nav batch)
Batch C — ten tools moved to pen-core builders: empty_state, alert,
toast, progress_bar, fab, breadcrumb, stepper, form_field,
nav_chip_row, activity_ring. pen-mcp handlers delegate; embedded
shim + Nitro SERVER_BUILDERS pick up direct coverage. 311/311
handler tests still pass. Remaining to reach 42/42: 11 tools
(rating/carousel/link/kbd/price/quote/code/color + 3 new session
atoms chart_bars/timeline/calendar_grid).
2026-04-22 00:07:26 +08:00
Fini ea6c571cb1 feat(ai): extend embedded element-tool coverage to 21 (+controls batch)
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.
2026-04-22 00:03:29 +08:00
Fini 804709d1f9 feat(ai): extend embedded element-tool coverage to 16 (+atoms batch)
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.
2026-04-22 00:01:35 +08:00
Fini 0440989a43 fix(ai): type-safe dispatcher DSL test — vi.fn().mock.calls tuple cast
fetchFn's vi.fn() return type inferred its mock.calls entries as
empty tuples `[]`, so destructuring `[url, init]` tripped TS2493
("no element at index 0/1") and the follow-up `as { body: string }`
cast of possibly-undefined `init` tripped TS2352. Cast the call
tuple through `unknown` to `[string, { body: string } | undefined]`
and guard the body-access with an optional chain — same behavioral
assertions, no untyped any escape.
2026-04-21 22:29:13 +08:00
Fini 3a6a0b6dcf feat(ai): make advertised batch_design fallback actually executable
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.
2026-04-21 22:25:10 +08:00
Fini 4731a73b79 fix(ai): dispatcher short-circuits + prompt names embedded coverage
elements.md's 42-tool catalog is authored for external MCP clients
that talk to pen-mcp's full handler set via stdio/HTTP. The embedded
orchestrator (this runtime) can only execute tools with BOTH a
client-side shim (element-tool-shims) AND a matching Nitro
SERVER_BUILDERS entry — currently 10 of the 42. Prior code
advertised the full catalog to the AI and promised HTTP fallback
coverage without restriction, so 32/42 tool names would silently
route through to a 404 → surfaced-error path.

Fix:
- Export SUPPORTED_EMBEDDED_ELEMENT_TOOLS from the shim module as
  the canonical covered list. Shim + Nitro registries stay in sync
  by convention; extending coverage requires updating both.
- Dispatcher short-circuits on tool names not in the list — no
  wasted HTTP roundtrip, diagnostic carries the covered-list so the
  caller can route to batch_design.
- ELEMENT_TOOL_OUTPUT_FORMAT in orchestrator-sub-agent names the
  available subset inline so the AI knows which add_*_v0 it can
  emit and when to fall back to batch_design.
- JSDoc in dispatcher, shim module, and exec-tool endpoint updated
  to reflect actual behavior (insertStreamingNode path, embedded-
  vs-external coverage asymmetry) instead of the stale "HTTP
  fallback covers everything" story.
- New test locks the short-circuit: calling an uncovered tool name
  (e.g. add_divider_v0) must not attempt fetch.

Real follow-up work is still to extract the remaining ~32 pen-mcp
tool tree-build functions into pen-core, shim them, and extend
SERVER_BUILDERS. Until then, the routes advertised to the AI
actually match what the runtime can execute.
2026-04-21 22:14:25 +08:00
Fini c58106c38e feat(ai): Nitro /api/mcp/exec-tool HTTP fallback for element tools
Server-side apply path the dispatcher calls when the browser has
no shim for a given tool (or a caller explicitly wants server
state). POST `{name, arguments, default_parent_id?}` — builds the
tree via the SAME pen-core builders the client shim uses (so the
shape is byte-identical across paths), resolves target page /
parent (honoring explicit arguments.pageId + arguments.parent_id
with existence validation; falls back to body.default_parent_id
forwarded from the dispatcher, then page root), calls
setSyncDocument to broadcast via SSE, returns `{ok:true, document,
insertedNodeId}`. Client applies the response doc synchronously
via applyExternalDocument so the apply lands inside the dispatcher's
history batch without racing the SSE roundtrip.

Errors surface as structured 4xx: 400 for missing name, 404 for
unknown tool / parent_id / pageId / stale default_parent_id, 501
for batch_design DSL (deferred — needs document-manager adaptation)
and for real .op filePaths (they belong to pen-mcp's file-aware
handler). The "live://canvas" sentinel is accepted the same as
omitted filePath so callers don't get false negatives when
explicitly naming the default target.

insertNodeInTree index=Infinity (append) matches the streaming
path's generation-order semantics. Default document-store.addNode
prepends (index=0) for user-created nodes on top of the layer
panel; AI generation wants each element to stack after earlier
siblings so multi-call output renders top-to-bottom as emitted.
2026-04-21 22:04:39 +08:00
Fini ff7b736d90 feat(ai): Phase 2 element-tools dispatcher + client shim registry
Replaces orchestrator-sub-agent.ts's Phase 1 stub (which only
logged and errored on `<op_tool>` output) with a real apply path:

- element-tools-dispatcher.ts: dispatchElementToolCall(shape, ctx)
  runs the full pass inside one startBatch/endBatch pair so a
  generation collapses to a single undo entry. Routes element-tool
  calls through the shim registry; falls back to /api/mcp/exec-tool
  HTTP when shim misses. Validates parent_id existence, rejects
  filePath unless it is the "live://canvas" sentinel, rejects
  pageId that diverges from the active page, rejects stale
  defaultParentId — all with structured failure messages instead
  of silent drops with "applied" reports.

- element-tool-shims/: 10-tool registry backed by pen-core
  element-builders. wrap<T>() strips parent_id/pageId/filePath
  before invoking the builder and surfaces them on ElementShimResult
  so the dispatcher can honor them during insert. Same pen-core
  builders the server-side pen-mcp handler uses — drift impossible.

- orchestrator-sub-agent.ts: invokes dispatcher with
  defaultParentId = subtask.parentFrameId ?? plan.rootFrame.id,
  mirroring StreamingDesignRenderer's construction so rootless
  payloads land inside the generation's target frame. DispatchResult
  carries insertedNodes[] and the orchestrator uses them to update
  progressEntry.nodeCount / progress.totalNodes / onApplyPartial
  so a successful element-tool subtask does not look like a failure
  to upstream accounting.

- Dispatcher uses insertStreamingNode (not raw addNode) so element
  tool output goes through the same canonical path the streaming
  renderer uses: id collision guard, parent remap, layout-aware
  child normalization, phone-placeholder guards, append semantics,
  and auto expandRootFrameHeight.

13 tests lock invariants: batch wrap fires exactly once per
dispatch (including back-to-back), applied/failed/unsupported
return shapes, parent_id / pageId / filePath / defaultParentId
validation branches, shim-hit success, HTTP fallback path under
fetch failure, "stale default + valid payload parent_id" payload-wins
precedence.
2026-04-21 22:04:21 +08:00
Fini 81a75aa1b7 refactor(mcp): delegate 10 element-tool handlers to pen-core builders
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.
2026-04-21 22:03:57 +08:00
Fini b006997ca4 refactor(core): extract element-tool tree builders to pen-core
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.
2026-04-21 22:03:44 +08:00
Fini 1fd4d11d2a feat(ai): add 3 element tools (chart_bars, timeline, calendar_grid)
- 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.
2026-04-21 22:03:29 +08:00
Fini 05f3044648 fix(ai): batch_design parser accepts pretty-printed multi-line JSON
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.
2026-04-21 22:03:11 +08:00
Fini 0eb5ca1ada fix(ai): allow elements skill through basic-tier compact filter
Live smoke test with VITE_ENABLE_ELEMENT_TOOLS=1 showed `elements`
missing from the sub-agent prompt — the skill was correctly included
by resolveSkills (hasMcpTools flag fired) but then stripped by
compactSubAgentSkills's basic-tier allow-list. Result: the feature
flag was effectively a no-op on basic-tier models, which is exactly
the tier the A/B v1 data says benefits most (MiniMax/GLM +8-21pp ΔM1).

- Add 'elements' to the basic-tier allowed set in
  compactSubAgentSkills. The `hasMcpTools` gate at resolveSkills is
  still the primary ON/OFF — this just stops the compact step from
  silently dropping the skill downstream.
- Deliberately OMIT 'elements' from the reducedComplexity retry-
  allowed set. Retries are the last-ditch fallback after a full-
  skill attempt already failed; elements.md is ~17k chars and adds
  to the prompt budget we're trying to shrink.

Test fix: model-profiles-element-tools.test.ts was passing in
isolation but failing under the full suite. Root cause: vitest's Node
runner `vi.stubEnv` doesn't reach `import.meta.env` across modules
(per-module import.meta instance) and dev's `.env.local` sets
VITE_ENABLE_ELEMENT_TOOLS=1 at Vite transform time. Changes:
- setFlag() now writes to both process.env AND the test file's own
  import.meta.env object (belt and braces; doesn't cross modules
  but removes the test file's own leakage path).
- Browser-safe (`process` broken) tests changed from
  `toBe(false)` to `not.toThrow()`. The actual regression guarded by
  these tests is the no-throw contract; cross-module env stubbing is
  intractable in the current setup and the boolean path is already
  covered by the "flag OFF" suite through process.env stubs.

Full suite: 200/200 files, 1866/1866 tests, format/tsc clean.
2026-04-21 01:17:46 +08:00
Fini 2c99a2466a fix(ai-skills): keep corpus barrel browser-safe (drop loadCorpus re-export)
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.
2026-04-21 00:43:48 +08:00
Fini c86f9dfdc2 fix(ai): rename element-tools flag to VITE_ENABLE_ELEMENT_TOOLS
Browser-side helper (`isElementToolsFlagEnabled` in model-profiles.ts)
reads via `import.meta.env` as the client fallback, but Vite's default
`envPrefix` only exposes `VITE_`-prefixed variables to the browser
bundle. The previous name `ENABLE_ELEMENT_TOOLS_IN_ORCHESTRATOR`
would be inlined as `undefined` at build time for client code —
meaning flipping the flag in `.env.local` could NEVER actually
enable the feature from the embedded orchestrator, defeating the
Phase 2 rollout plan.

Rename to `VITE_ENABLE_ELEMENT_TOOLS` so client code can actually
see the toggle. Server-side `process.env` reads work with any name,
so one variable name now covers both sides of the SSR boundary.

Docstring in model-profiles.ts now explicitly calls out the VITE_
prefix requirement so future edits don't regress — the "bare name
would be inlined as undefined" point is worth preserving in-file.

Also updated the orchestrator-sub-agent.ts error message that points
users at the flag so its instructions match the real var name.

Tests: 23 → 23 (renamed FLAG constant, all cases still pass). Full
suite 1866/1866 green.
2026-04-21 00:35:07 +08:00
Fini 386a00da2a fix(ai): guard element-tools flag read against missing process.env
Default-off path crashed in browser bundles before reaching the
false return: Vite doesn't polyfill `process`, and
orchestrator-sub-agent.ts runs client-side, so a bare
`process.env.ENABLE_ELEMENT_TOOLS_IN_ORCHESTRATOR` read raised
`ReferenceError: process is not defined` — defeating the whole
"scaffolding off until explicitly enabled" rollout premise.

Fix:
- Extract `readFlagFromEnv(name)` with three layered safety nets:
  1. `typeof process !== 'undefined'` guard around process.env read
  2. try/catch around the access itself (Deno/workerd throw on
     env inspection rather than returning undefined)
  3. Fall through to `import.meta.env` (Vite's canonical browser
     env reader) so a dev can toggle the flag via `.env.local`
     and hit the same behavior on both sides of SSR
- Any failure path returns undefined → default-off survives

Regression tests (3 new, 23 total):
- simulated browser (`globalThis.process = undefined`) → returns false
- simulated sandbox (`process.env = undefined`) → returns false
- simulated Deno/workerd (getter throws on process.env) → returns false

Full suite 1866/1866 (was 1863; +3 tests).
2026-04-21 00:29:31 +08:00
Fini ddc82f2743 feat(ai): N-tool orchestrator integration Phase 1 (flag off by default)
Implements plan §3.1-§3.5 of the tier-aware embedded-orchestrator
integration behind ENABLE_ELEMENT_TOOLS_IN_ORCHESTRATOR env var. With
the flag unset (default production state) this change is a no-op —
every path added here short-circuits on !needsElementTools(profile).

§3.1  model-profiles.ts:
  - needsElementTools(profile) — returns true iff env flag truthy AND
    tier in {basic, standard}. Full tier stays OFF per A/B v1 Kimi K2.5
    ceiling-effect finding (Δ M1 -12.5pp).
  - 20 unit tests cover the 2×3 flag × tier matrix + truthy-value
    allow-list parsing.

§3.2  orchestrator-sub-agent.ts:
  - Pass hasMcpTools: needsElementTools(modelProfile) into
    resolveSkills('generation', ...) so elements.md auto-loads for
    gated models, matching the A/B v1 treatment arm.

§3.3  orchestrator-sub-agent.ts:
  - When flag fires, append ELEMENT_TOOL_OUTPUT_FORMAT block to the
    sub-agent system prompt. Verbatim from
    scripts/ab-corpus/build-prompt.ts::T_TOOL_CALL_INSTRUCTIONS so
    production reproduces the measured behavior (PRIMARY element-tool
    call / FALLBACK batch_design wrapped in op_tool).

§3.4  design-parser.ts:
  - tryParseElementToolOutput(raw) wraps pen-ai-skills parseModelOutput
    and returns a tagged union {kind:'element-tool'|'batch-design-dsl'}
    when <op_tool> is detected, or null to route back through the
    legacy extractJsonFromResponse flow.
  - 9 unit tests cover happy-path detection, <think> stripping,
    multi-tag preference (element tool wins over scaffold batch_design),
    legacy passthrough, and malformed-tag graceful fallback.

§3.5  orchestrator-sub-agent.ts:
  - STUB: when streaming applied zero nodes AND the completed response
    is element-tool-shape, return a clear error pointing at plan §3.5
    as the Phase 2 work item. Apply-path dispatch (server-side pen-mcp
    handler invocation, live://canvas merge) is deferred to avoid
    shipping a path that's untested against the live-canvas sync
    machinery.

Tests: 1863/1863 (was 1834; +20 profile tests + 9 parser tests).
Format and tsc clean. No behavior change with flag off.
2026-04-21 00:23:54 +08:00
Fini 6ef172f0b4 refactor(mcp): split element-tool-defs into base + ext + aggregator
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.
2026-04-21 00:01:28 +08:00
Fini 51878c3894 feat(scripts): ab-corpus harness with multi-provider model adapters
Harness at scripts/ab-corpus/ wires the pen-ai-skills corpus evaluator to
real model endpoints and pen-mcp handlers:

run.ts            — CLI entry (--dry-run / --live / --models A,B,C / --only ID)
apply.ts          — ApplyFn impl dispatching tool_call → element handler
                    and batch_design DSL → handleBatchDesign, against a
                    fresh tmp .op per run (isolated, auto-cleanup)
build-prompt.ts   — B variant strips elements.md + appends batch_design
                    <op_tool> format instruction; T keeps elements + adds
                    element-tool PRIMARY / batch_design FALLBACK
                    instruction. Uniform <op_tool> wrapper in both arms
                    isolates "tool set width" as the only A/B variable.
stub-model.ts     — fixture-based offline model for --dry-run
real-model.ts     — router by model id (minimax* / gpt-*/o* / glm-5.1 /
                    glm-* / kimi-*)
clients/
  openai-compat.ts — generic chat/completions POST
  minimax.ts       — api.minimax.io/v1, MINIMAX_API_KEY
  codex-cli.ts     — spawns `codex exec` (GPT-5.4 via Codex Pro sub)
  bailian.ts       — coding.dashscope.aliyuncs.com/v1 CP,
                     DASHSCOPE_BAILIAN_CODING_KEY (hosts glm-4.7, kimi-k2.5)
  glm.ts           — open.bigmodel.cn/api/coding/paas/v4 official CP,
                     GLM_OFFICIAL_CODING_KEY
write-report.ts   — Report → report.md + report.json in out dir;
                    4-way routing breakdown table per model

Kept entirely outside packages/ — scripts are a local dev tool, not part
of the published SDK. API keys never hit disk or git.

v1 run results logged separately in openpencil-docs
superpowers/notes/2026-04-20-ab-v1-results.md (5 models × 24 prompts).
2026-04-20 23:53:23 +08:00
Fini 589549672d feat(ai-skills): A/B corpus evaluator for the N-tool hypothesis
Adds a self-contained evaluation subsystem in packages/pen-ai-skills:

corpus/ab-v0/*.yaml (24 prompts)
  - 8 per category (mobile / dashboard / landing), 4 obvious + 4 optional
  - Obvious entries are single-component-scope (one element tool fits 1:1)
    and carry expected_tool_if_any; optional entries test composite
    prompts where batch_design is the expected path.

src/corpus/
  - types.ts       — CorpusPrompt / ScoreRow / Report / ApplyFn
  - corpus-loader.ts — yaml → CorpusPrompt[] with schema validation
  - output-parser.ts — model output → tool_call / batch_design / garbage
    tagged union; handles <op_tool> wrapper, multi-tag preference
    (element tools win over batch_design scaffolding), malformed-tag
    recovery, reasoning-model <think> stripping, and strict cleanDsl
    validation mirroring handleBatchDesign's splitter so mixed payloads
    don't falsely pass as batch_design
  - score-run.ts  — M1 (apply + no detector errors) + M3 (shape checks
    on roles) + 4-way routing classification (right-tool / wrong-tool
    / fallback / garbage; denominator includes garbage so right-tool
    rate stays interpretable)
  - aggregate.ts  — per-model, per-category, per-tool summaries
  - index.ts      — public exports
  - js-yaml.d.ts  — minimal ambient decl (avoids @types/js-yaml churn)

Decoupled from pen-mcp via ApplyFn injection — the scorer never imports
handleBatchDesign; scripts/ab-corpus provides the concrete impl.

61 unit tests cover the corpus pipeline end-to-end; no live API keys
required. Full test suite: 1834/1834.
2026-04-20 23:53:01 +08:00
Fini 386053dd58 feat(mcp,ai): add 8 element tools, extend full prompt with elements section
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)
2026-04-20 23:52:41 +08:00
Fini cd25bd0cf9 test(electron): unblock desktop/git tests on git 2.36 + stubbed electron
- git init --bare now takes -b main (7 sites): bare HEAD defaulted to a
  dangling master ref, so clone checked out nothing and `push origin main`
  couldn't resolve the refspec (surfaced as ENOENT + engine-crash).
- git-ipc.test.ts mocks electron: the file only tests pure helpers but
  imports via ipc-handlers, whose `import { ipcMain } from 'electron'`
  throws when electron's postinstall left path.txt missing.

Production code unchanged; worktree-merge.ts still requires git 2.36 for
--format=%(path) — addressed separately.
2026-04-20 01:22:28 +08:00
Fini aa755f13e0 docs: add Working Principles section adapted from Karpathy skills
Map the four coding principles (think-before-coding, simplicity-first,
surgical-changes, verify-before-done) to OpenPencil's multi-layer
structure so assistance stays grounded in actual pen-core / apps/web /
pen-ai-skills / MCP layers instead of generic advice. Also surface the
pre-commit format:check command in the Commands list.
2026-04-20 00:57:05 +08:00
Fini b153b37cfe fix(mcp): get_design_prompt reads design.md per-document (no global leak)
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.
2026-04-20 00:44:49 +08:00
Fini 2aabe494f5 fix(core): design.md lives on PenDocument — kill cross-document leak
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).
2026-04-20 00:37:31 +08:00
Fini bc7e16fa20 fix(mcp): element tools with colored bg set foreground for readable contrast
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.
2026-04-20 00:02:59 +08:00
Fini 039fb89251 feat(mcp): add alert + toast + progress_bar + fab + breadcrumb + stepper (25 → 31 element tools)
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.
2026-04-19 23:55:52 +08:00
Fini fc64af5609 fix(mcp): add_tabs_v0 tabs split bar evenly (width=fill_container)
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.
2026-04-19 23:25:12 +08:00
Fini 477c0b2377 fix(mcp): add_tabs_v0 underline uses sibling rectangle (directional stroke unsupported)
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.
2026-04-19 23:17:28 +08:00
Fini 36c46a50ef feat(mcp): add switch + checkbox + radio + tabs + segmented + empty_state (19 → 25 element tools)
Controls + empty state batch. Schemas split into element-tool-defs-ext.ts
to keep the main route file under the 800-line limit as the family grows.
2026-04-19 23:08:06 +08:00
Fini c12390c98d feat(mcp): add search_bar + form_field (17 → 19 element tools)
Forms coverage. search_bar fixes 44/22 hit-target; form_field enforces
fill_container input + 48px height from design-guidelines ROLE_GUIDE.
2026-04-19 22:52:50 +08:00
Fini a59189303b refactor(mcp): split element tool defs out of design-routes (848 → 201+671)
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.
2026-04-19 22:42:38 +08:00
Fini fa8fb393ca feat(mcp): add icon_label + list_row (15 → 17 element tools)
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.
2026-04-19 22:32:02 +08:00
Fini b5dd3d52b2 fix(ai): cjk-typography.md body rule aligns with text-rules/tool/skill
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.
2026-04-19 20:55:05 +08:00
Fini f7ce47fa0d fix(mcp): add_body_text_v0 uses Inter for ALL scripts (end CJK-rule conflict)
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.
2026-04-19 20:48:27 +08:00