The two log files (console-*.log, page-*.yml from 2026-04-11) slipped
into the previous textarea commit via git add -A. They're stale
browser-automation session artifacts, not source. Untrack + gitignore
so future git add -A doesn't grab them again.
Same label-above-input shape as add_form_field_v0 but the input
grows vertically by `rows` (default 4, clamped 2..12) for notes /
bio / feedback use cases. Input frame layout is vertical with
placeholder top-aligned, matching native iOS/Material behavior.
Wired across all three paths:
- pen-core: `buildTextarea` + TextareaParams in element-builders
- pen-mcp: `handleAddTextareaV0` + tool schema in element-tool-defs-ext
- apps/web: shim + Nitro SERVER_BUILDERS entry
- elements.md: PREFER list + keyword map + 2 example lines
Tests:
- pen-mcp: 8 new cases in add-textarea-v0.test.ts (height math,
rows clamp, required suffix, placeholder wiring, id uniqueness,
parent_id rollback)
- Parametric builder tests auto-extended (+1 case each in 9 files):
layout smoke, post-process idempotency, normalize preservation,
performance, role coverage, anti-patterns clean, detectors
clean, shim-server parity. Total delta: 2907 → 2931 passing.
Closes#47; covers part of #50 (textarea entry).
Explains the three-path architecture (pen-mcp handler + apps/web
shim + Nitro server), the 42-builder convention set, and the
checklist to wire a new builder into all registries.
The drift-guard test table ties the README to the CI gates — any
missed registry wiring now fails a named test instead of shipping
as a silent 500 at runtime.
Simulates realistic "full screen design" orchestrator turns where
one dispatchElementToolCalls call handles 40-60 tools at once.
Coverage:
- 40-tool batch: <500ms end-to-end, 1 undo entry, 40 children, all
ids unique.
- 60-tool batch (upper bound): <800ms, all land, no id collisions.
- 3 × 20 consecutive batches: 60 total children, 3 undo entries,
global id uniqueness preserved across rounds.
- Promise.all of 3 parallel dispatches into 3 distinct roots: each
completes independently, full document id set remains unique.
The mixed pattern (heading + body + list-row + divider + stat-grid)
exercises multi-level tree inserts, not just flat heading stacks —
which is closer to what real AI orchestration emits.
Why the latency budget: AI thinking dominates generation time
(5-30s typical). The dispatch pipeline shouldn't be the bottleneck;
500ms for 40 tools = ~12ms per tool including store round-trip,
which is reasonable. A regression past this threshold signals
quadratic behavior somewhere in the pipeline.
Guards against builders hardcoding icon names that don't resolve at
runtime — a regression would render as an empty glyph or fallback
circle on canvas, silent-but-broken.
Three layers:
1. Per-builder (17 tests): collect every icon_font iconFontName
from default output, assert lookupIconByName resolves each. Fail
message names the specific builder + unresolved slugs.
2. Aggregate (2 tests): full cross-builder icon vocabulary resolves
at runtime; icon set is non-trivial (≥10 distinct icons).
3. Invariants (2 tests): text-only builders emit zero icons;
every icon_font node has iconFontFamily='lucide' (prevents a
regression to a font-family the renderer doesn't bundle).
Note: uses lookupIconByName (not AVAILABLE_LUCIDE_ICONS directly)
because the dictionary has prefix/substring fallbacks that resolve
common names ("home", "more-vertical") even when the literal slug
isn't in the exported list. The lookup is the authoritative runtime
resolver, so matching its behavior is correct.
42 builders × 2 budget checks + 1 full-batch check:
1. Average over 100 runs < 5ms per builder (post-warm-up). Generous
enough to absorb CI noise; tight enough to catch a regression
that adds a synchronous font round-trip, icon lookup, or N²
layout pre-pass.
2. Cold first call < 50ms per builder (JIT amortization allowance).
3. Full 42-builder batch < 100ms — simulates a realistic "AI emits
a whole screen" where the orchestrator builds 40+ trees in a
single streaming turn. Stuttering > 100ms would be visible.
Background: element tools fire inside streaming AI generation, so
every builder sits on the hot path. A builder slipping from O(n)
to O(n²) wouldn't fail correctness tests but would ruin the
streaming experience. This floor catches that early.
If a builder legitimately needs more budget (e.g. a real-time
vector-graphics computation), bump the constant explicitly and
document why in the commit — the explicit bump is the deliberate
signal the budget has shifted.
Source-level parity checks across pen-mcp's three derived artifacts:
Source of truth: ELEMENT_TOOL_DEFINITIONS array
Derived 1: ELEMENT_TOOL_NAMES set (runtime dispatch filter)
Derived 2: handler file at tools/<kebab-case>.ts
Derived 3: switch branch in handleElementToolCall
Checks:
- ≥42 tools registered (floor)
- Every tool name matches add_[a-z_]+_v0
- Every tool has a handler file at the expected kebab path
- Every handler file exports handle<PascalName>
- Every handler imports its matching build<Name> from pen-core
- Every tool has a switch branch in the runtime dispatcher
- Every switch branch actually calls the handler (no copy-paste
typos where a case dispatches to the wrong handler)
- No orphan handler files (every tools/add-*-v0.ts maps to a name)
- No duplicate tool names
- ELEMENT_TOOL_NAMES set === ELEMENT_TOOL_DEFINITIONS names
This is the pen-mcp-side counterpart to apps/web's
shim-server-parity.test.ts. Together they guard against drift on
both client and server sides of the N-tool integration.
42 builders × 2 assertions:
1. Semantic preservation: role / type / content / layout are
identical at every tree position before and after
normalizePenDocument. Also asserts node count unchanged — a pass
must not create or drop nodes.
2. Idempotency: normalize(normalize(x)) === normalize(x).
Why it matters: normalizePenDocument runs at document open + after
AI generation rounds (see load-op-file.ts / import-pen-document.ts).
Builders produce canonical format output, so normalize SHOULD be a
no-op on the semantic fields. A regression that silently rewrites
a role or swaps a type would be invisible without this guard.
Complements element-builders-post-process-idempotent.test.ts (which
gates the OTHER four post-processing passes); this one specifically
covers the format-layer pass.
Bilateral drift guard across three registries + two executable paths:
Registries:
- ELEMENT_SHIMS (client shim, apps/web)
- SUPPORTED_EMBEDDED_ELEMENT_TOOLS (canonical exported list)
- ELEMENT_TOOL_NAMES (pen-mcp source of truth for all 42 tools)
Executable paths:
- A: client shim → pen-core buildX
- B: server /api/mcp/exec-tool → pen-core buildX
Since both paths delegate to the SAME pen-core buildX, the structural
parity is transitive: if shim output matches direct buildX output,
server output matches too.
Tests:
- CASES (42 fixtures) covers every ELEMENT_SHIMS key — refactor adding
a new shim without a test row fails here.
- CASES covers SUPPORTED_EMBEDDED_ELEMENT_TOOLS — same guarantee at
the exported constant.
- SUPPORTED_EMBEDDED_ELEMENT_TOOLS ⊆ ELEMENT_TOOL_NAMES — shim must
only expose tools that pen-mcp actually defines.
- No duplicate keys in ELEMENT_SHIMS.
- For each tool: stripIds(shim(args).node) === stripIds(buildX(args)).
The shim is a pure delegation layer plus id stamping.
- Meta-param extraction: parent_id / pageId / filePath are split out
BEFORE the builder sees them (no spurious field leak into the node).
If any of these diverge in future refactors, the failure points
directly at the broken registry or transformation.
Verifies the user-visible history contract the dispatcher owes:
1. One batch dispatch = exactly ONE undo entry (even for 8 tools).
No Ctrl-Z spam to reverse one AI turn.
2. Separate dispatches = separate undo entries. The batch window
closes at endBatch; subsequent dispatches don't piggyback.
3. Undo reverts the whole batch atomically (all tools snap back).
4. Undo → redo restores the whole batch atomically.
5. All-unsupported batch creates ZERO undo entries — endBatch's
"no changes" short-circuit prevents ghost undos that would jump
the UI between identical states.
6. Mixed valid+unsupported batch → 1 undo entry for the valid ones.
Complements element-tools-dispatcher.test.ts (which spies on
startBatch/endBatch) by exercising the actual history-store round-
trip and asserting stack length deltas.
Full matrix of parse → dispatch → builder preservation for
basic / standard / full tier resolutions. The wire format
(`<op_tool>{...}</op_tool>`) is tier-independent TODAY; this test
anchors that as a hard contract so a future "tier-specific argument
escape" surfaces immediately.
Coverage:
- Tier gating sanity (3 model ids → expected tier)
- ASCII content (3 tiers)
- CJK + mixed scripts + RTL (15 tests: 3 tiers × 5 fixtures)
- Emoji deliberately stripped (anchors applyNoEmojiIconHeuristic
behavior — emojis become icon_font nodes, not embedded text)
- Embedded quotes, backslashes, newlines, tabs, unicode punctuation
(18 tests: 3 tiers × 6 tricky fixtures)
- Large number arrays + nested item objects + timeline shape
(9 tests: 3 tiers × 3 tool shapes)
- Multi-tag batch: 5 tools with varied shapes all parse + match
original args (1 test)
Notable findings:
- gpt-4o-mini resolves to "standard" (matches 'gpt-4o' rule first),
so claude-haiku is the stable basic-tier fixture id
- The pipeline scrubs emojis AND collapses 2+ whitespace chars;
this is intentional (applyNoEmojiIconHeuristic), now anchored
- Unicode punctuation (em-dash, ellipsis, curly quotes) passes
through untouched — the emoji regex is conservative
44 tests (42 builders × clean assertion + 2 sanity/negative anchors).
For every builder output:
- run detectAllIssues (invisible-container + empty-path +
text-explicit-height + sibling-inconsistency detectors)
- filter to severity !== 'info' (info is detect-only, skipped
by the auto-fix pipeline, not a regression signal)
- fail with a per-issue summary if any fire
Plus 2 negative-case sanity checks so passing tests can't mask
broken detectors:
- text with explicit pixel height → height detector fires
- same-fill-as-parent container → invisible-container detector fires
The test lives in apps/web/__tests__ (not pen-ai-skills/__tests__)
because pen-ai-skills doesn't depend on pen-core — apps/web is the
first place both are available.
Walks every branch of the parent_id resolution rule:
payload.parent_id > ctx.defaultParentId > page root
Matrix:
payload.parent_id ∈ {present+valid, present+invalid, absent}
ctx.defaultParentId ∈ {set+valid, set+stale, null, undefined}
Notable cases that happy-path tests miss:
- parent_id exists + defaultParentId set → parent_id wins (default
MUST NOT contaminate when explicit id is valid)
- defaultParentId set to stale id + parent_id absent → fails fast
with "stale" in diagnostic (2026-04-21 regression anchor)
- valid parent_id + stale defaultParentId → still applies (the
dispatcher must not inspect default when explicit is valid)
Plus 3 result-shape assertions: insertedNodes, route, toolName on
applied/failed/unsupported results — orchestrator consumes these
for its progress + inserted-node accounting.
52 tests cover the text-rules contract end to end:
- cjk-detect primitive: 6 scripts (zh/ja/ko/ar/emoji/latin) × correct
mapping to 'chinese' | 'japanese' | 'korean' | null
- cjkFontFamily: maps each script to Noto Sans SC/JP/KR, null → undefined
- heading: 4 levels × 6 scripts = 24 tests verifying
zh→Noto Sans SC, ja→Noto Sans JP, ko→Noto Sans KR, else undefined
- heading CJK preset: lineHeight >= 1.3 + non-negative letterSpacing
(regression guard — CJK chars overlap with negative tracking)
- body-text: 6 scripts all resolve to Inter (fallback font stack
handles CJK, not explicit dispatch)
- Baseline: 10 other text-carrying builders with CJK content emit
sub-text nodes WITHOUT fontFamily — this anchors current behavior
so a future regression that leaks dispatch becomes visible.
The baseline tests are deliberate: they document "what doesn't
dispatch today" rather than asserting it's wrong. If we ever want
list-row or card-row to auto-pick CJK fonts, the test will need to
be updated as part of that decision.
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.
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.
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.
- 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).
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).