GAP-1: add 'fontWeight' to the text-node key list in resolveNodeForCanvas so
$type-*-weight refs resolve to a number before reaching the renderer.
GAP-2: replace the early-exit `if (!variables || Object.keys(variables).length === 0)
return node` with `if (!variables) variables = {}` so DEFAULT_PALETTE_FALLBACK
fires even when the document has an empty variables map (un-seeded v1 docs).
Adds 2 new tests to fallback-equivalence.test.ts covering both gaps.
Validates full pipeline: createEmptyDocument → applySemanticPalette →
buildHeadingV1/buildSettingRowV1/etc. (theme:'system') → resolveNodeForCanvas
Light/Dark. Surfaces 2 known gaps in the resolver (fontWeight not resolved;
DEFAULT_PALETTE_FALLBACK unreachable for empty-vars docs).
Switch all 89 element tool recommendations in elements.md from v0 to v1
with theme: 'system' as the default. Add "Theme handling" section explaining
when to use system/light/dark/v0. v0 entries retained as "Byte-frozen escape
hatch" sub-entries for rare byte-parity requirements.
Adds theme-aware v1 builders for icon_button, image_placeholder,
inbox_message, inline_action, input_with_action, invite_row, kbd,
legend_item, link, and list_row. Group A (zero-color: icon_button,
link, list_row) — no hardcoded colors in v0, all three modes identical.
Group B (kbd) — key bg → surface2, stroke → border in dark/system.
Group C (remaining 6) — surface/text/border/accent/alertColors tokens
applied in dark/system modes, full byte-parity with v0 in light mode.
Extends ext-6 shard (357→647 lines, within 800-line ceiling) housing
all 20 batch-4 + batch-5 tool schema definitions. All 9 touchpoints
wired per playbook: builder, index.ts, pen-core barrel, handler,
dispatcher, ext-6 shard, client shim, server builder, elements.md entries.
Verified: format:check clean, tsc --noEmit clean, 4086/4086 tests pass.
Adds theme-aware v1 builders for cookie_banner, data_table_row,
date_picker, drawer_shell, empty_state, event_card, fab, faq_item,
filter_group, and form_field. Group A (zero-color: empty_state,
form_field) — no hardcoded colors in v0, all three modes identical.
Group B (fab) — accent bg is brand-invariant, maps to accent token
in dark/system; icon stays white in all modes. Group C (remaining 7)
— surface/text/border/accent tokens applied in dark/system modes, full
byte-parity with v0 in light mode.
Creates ext-6 shard (ext-5 was at 798-line ceiling) housing all 10
new tool schema definitions (357 lines). All 9 touchpoints wired per
playbook: builder, index.ts, pen-core barrel, handler, dispatcher,
ext-6 shard, client shim, server builder, elements.md entries.
Verified: format:check clean, tsc --noEmit clean, 4076/4076 tests pass.
Adds theme-aware v1 builders for chart_bars, chart_line, chart_pie,
chat_bubble, checkbox, chip_input, code_block, color_swatch, combobox,
and comment. Group A (chart tools) maps bar/line color to chart-1 token
and pie default palette to chart-1..6 tokens in dark/system modes.
Group B (color_swatch) is theme-invariant — swatch color is caller-
supplied and passes through unchanged. Group C (chat_bubble, checkbox,
chip_input, code_block, combobox, comment) resolves surface/text/border
via semantic palette tokens. All light modes are byte-parity with v0.
Adds theme-aware v1 builders for alert, bottom_nav, breadcrumb,
activity_ring, carousel_dots, action_menu, attachment_row,
calendar_grid, avatar_group, and callout. Group A (zero-color:
alert/bottom_nav/breadcrumb/activity_ring) produce identical output
across all three theme modes. Group B (carousel_dots) maps active=
text-primary, inactive=border in dark/system modes. Group C (action_menu/
attachment_row/calendar_grid/avatar_group) resolve surface/text/border via
semantic palette. Group D (callout) maps tone-keyed bg/fg to alert palette
tokens in dark/system modes. All light modes are byte-parity with v0.
avatar-v1, badge-v1, divider-v1, body_text-v1, icon_label-v1 — each with
full 9-touchpoint coverage (pen-core builder + index + pen-mcp handler +
schema shard + dispatcher + apps/web shim + SERVER_BUILDERS + parity test
+ elements.md). Light mode is byte-equal to v0; dark/system modes produce
identical output since all 5 tools emit zero hardcoded color fills — theme
param accepted for API consistency across all v1 tools. New shard
element-tool-defs-ext-5.ts created (ext-4 was at 739 lines). All 2026
pen-core + pen-mcp tests pass; format:check + tsc clean.
card_row-v1, setting_row-v1, member_row-v1, activity_log-v1 — each with
full 9-touchpoint coverage (pen-core builder + index + pen-mcp handler +
schema shard + dispatcher + apps/web shim + SERVER_BUILDERS + parity test
+ elements.md). Light mode is byte-equal to v0; dark/system use resolveTheme()
for all color fills. activity_log-v1 maps tone×theme to alertColors tokens
(info/success/warning/danger) with neutral falling back to surface/textMuted.
All 3998 tests pass. Completes P2 representative phase.
Task 2.3 — representative v1 tool walkthrough for Plan 14 byte-parity contract.
Light mode is byte-equal to add_heading_v0 (V0_LATIN_PRESETS table reused);
dark/system modes use resolveTheme() for fill color and typography token refs.
Adds theme enum [light, dark, system] to add_heading_v1 MCP schema, elements.md
decision tree, shim-server-parity CASES, and SERVER_BUILDERS. All 3937 tests pass.
P1.5 consumer compatibility gate before P2 element-tool v1 builders.
resolveNodeForCanvas was already resolving gap/padding/opacity/color refs,
but missed text typography fields (fontSize, lineHeight, letterSpacing) and
cornerRadius. Since both skia-engine and pen-renderer consume resolver output
before layout runs, unresolved string refs would pass arithmetic as NaN.
layout/engine.ts also patched to guard typeof === 'number' at the 4 text
font-size/line-height access points — defensive fallback to 16/1.x even when
called on pre-resolution nodes (e.g. MCP server, normalizer pipeline).
Adds 21-test p1-5-consumer-compat.test.ts covering all 6 consumer paths.
All 1183 pen-core tests pass. format:check and tsc --noEmit clean.
Introduce DEFAULT_PALETTE_FALLBACK (56-entry map built from semantic-palette
at module init) and wire it into resolveVariableRef so that v1 'system' mode
on an un-seeded doc resolves to canonical hex/numeric defaults rather than
undefined — fulfilling the equivalence guarantee from spec §5.3.
Update modal-shell-v1 test to reflect the new contract: un-seeded doc +
Mode:Dark now yields #1E293B (color-surface dark) instead of undefined.
The 4 extra single-value tokens (color-accent-dark, color-info-surface,
color-warning-text-strong, color-danger-text-strong) introduced in P1.1.6
violated spec §3.1 / §7.4 — those hex were INTENDED to merge into existing
tokens with ≤ 5% accepted color drift, not become new tokens.
Replaced with MERGE_MAP in measure-v0-hex-coverage.ts that tracks the 4
near-shade redirections (#1D4ED8→color-accent, #EFF6FF→color-info-bg,
#B45309→color-warning-text, #B91C1C→color-danger-text). Cover rate
calculation now reports direct + merge breakdown.
Final palette token count: 56 (28 color + 18 type + 2 letterSpacing +
5 spacing + 3 radius). Cover rate: 28 direct + 4 merge = 32/32 = 100.0%.
spacing-1..5 = 4/8/12/16/24 px — a 4-point base scale covering xs through xl.
All tokens use type="number" with plain scalar values. Palette total grows to 57.
type-display-letter-spacing (-0.5 px) for large display text, and
type-uppercase-label-letter-spacing (1.5 px) for uppercase / overline labels.
Palette total grows to 52.
size + weight + line-height for 6 roles: display / h1 / h2 / h3 / body / caption.
All 18 tokens use type="number" with a plain scalar value (no theme axis).
getSemanticPaletteHex() omits numeric tokens from its return map.
Palette grows from 32 color → 50 total variables.
8 light/dark alert pairs (info/success/warning/danger bg+text) and 6
single-value chart series colors added to PALETTE. PaletteEntry union
type introduced to support LightDarkEntry | SingleColorEntry | SingleNumberEntry.
getSemanticPalette(), getSemanticPaletteHex(), applySemanticPalette()
updated to handle all three entry shapes. Palette grows from 14 → 28 tokens.
Two diet/teaching changes to elements.md, both motivated by today's
ab-v4 smoke results:
(a) parent_id teaching — minimax-m2.7 invented "members-section" /
"canvas" as parent_id values on its multi-tool composite output,
then every one of its 14 tags failed apply with "parent_id X not
found in document". Same pattern as yesterday. Adds an explicit
rule near the top of elements.md (right under the multi-tool
banner) — `parent_id` is REAL or OMITTED, never invented. The
`<page>` / `<panel>` / `<sidebar>` placeholders in the cookbook
recipes are documentation conventions; in actual output, OMIT
the field. Names the failure mode by reproducing the error
message format so models learn to avoid it.
(b) Phase 3 token diet — strip ". Different from <tool> (...)"
disambiguation suffixes from the PREFER list (22 entries had
them, ~80-200 chars each = ~2.5kb / ~625 tokens saved). The
primary keyword + tool-name + capability description survives
intact; the cross-references pointing at sibling tools get
dropped. Risk: slight increase in wrong-tool routing on
ambiguous prompts. Worth it for the size reduction; the
decision tree alone still shows the tool family in context.
Verified by mechanical diff — perl in-place edit, then visual
review confirms no other content was touched. 3785 vitest pass,
format clean, tsc silent.
Net effect on T-prompt size (chars / 4 estimate):
T + composite + mobile: was 17,930 → now 17,475 (-455 chars)
T + composite + dashboard: was 18,437 → now 17,983 (-454 chars)
T + obvious + dashboard: ~14,009 → ~13,556 (-453 chars)
Per-arm savings are smaller than the raw 2.5kb trim because (a)
adds ~700 chars of parent_id teaching. Net win: ~450 chars / ~110
tokens per call. Modest but compounds across 520+ ab-v4 runs.
Sweep follow-up to 113bd55a — same defensive pattern (reject
unknown enum strings at the entry boundary) applied to every other
builder that indexed a Record<EnumLiteral, T> with a value sourced
from raw JSON args.
Builders + enums covered:
- buildTag — TagTone (default | accent | success | warning | error)
- buildCallout — CalloutTone (info | success | warning | danger | note)
- buildActivityLog — tone (info | success | warning | danger | neutral)
- buildInviteRow — InviteStatus (pending | expired | accepted)
- buildMemberRow — trailing.tone for status_dot (online | busy | away
| offline). role_badge / menu variants skip the check (no tone field)
Same failure mode each one fixed: when a model invents an
out-of-enum string (gpt-5.4 did this with `level: "caption"` in
ab-v4), the lookup `TONES[bad]` / `STATUS_TONE[bad]` returned
undefined, the next property access crashed mid-batch with a
cryptic `undefined is not an object`, and the surrounding dispatch
loop dropped every remaining tag (until df33e937 + 07639f6d landed
the per-shape continuation + partial-doc scoring earlier today).
With validation in place, a bad enum becomes a clean per-shape
error message + the rest of the batch still applies.
13 new edge-case tests cover throw on bad input + valid path on
every enum value + omitted-default for each builder. 3785 vitest
pass, format clean, tsc silent.
Builders not touched: heading.ts (already done in 113bd55a).
Builders that don't fit this pattern (no enum→Record lookup of a
user-controlled string): everything else surveyed via grep on
`Record<.*Tone|Status|Level|Mode|Kind`.
Codex stop-time review caught the previous fix (df33e937) handing
the scorer a partial PenDocument that the scorer immediately
ignored. score-run.ts:87 short-circuited on `!applied.ok || !applied.doc`,
so even though apply.ts now surfaces 12 of 13 successfully-applied
tags as a populated `applied.doc`, the row still scored as a total
failure (M1=false, M3=false, m3_failure_reason="apply failed before
shape checks") — exactly the noise df33e937 was meant to eliminate.
Loosens the short-circuit to `!applied.doc` only. When apply.ok=false
but apply.doc is populated, the scorer now:
- runs the issue detector against the partial doc (issues surface)
- keeps M1 strict (apply.ok=false → M1=false regardless of detector)
- decouples M3 from M1: M3 = shape.ok against the partial doc
- sets m3_failure_reason to the shape miss when shape fails;
otherwise to "partial apply (M3 met by what landed): <error>" so
the row reads "tag 12 of 13 broke, but role coverage still met"
instead of silently swallowing the partial signal
- surfaces applied.error in row.applyError so per-shape failure
messages flow into reports
Two new tests cover the new path:
1. partial apply + shape match → M1=false, M3=true, reason mentions
"partial apply"
2. partial apply + shape miss → M1=false, M3=false, shape-miss
reason wins (structural verdict trumps the partial-apply notice)
Plumbing chain across today's session is now consistent:
- apply.ts continues past per-shape failures (df33e937)
- score-run.ts scores the partial doc that lands (this commit)
- scoring no longer over-attributes to "apply failed" when the model
actually produced most of the brief
3774 vitest pass (+2), format clean, tsc silent.
Codex stop-time review caught the previous commit (113bd55a) message
overstating apply.ts's behavior — I claimed "scripts/ab-corpus/apply
both catch per-shape and keep running the remaining tags" but the
loop at line 55 had no inner try/catch. A single throw from any
handleElementToolCall (e.g. the heading invalid-level reject 113bd55a
just added) would bubble up through the outer try at line 40 and
return early, dropping every remaining tag in a composite batch on
the floor — gpt-5.4's 13-tag team-people-page response would lose
tags 12-13 instead of just tag 12.
Wraps each handleElementToolCall in its own try/catch + accumulates
failures into a per-shape list. ELEMENT_TOOL_NAMES miss is also a
push-and-continue (was a return). When `failures.length > 0` we
return ok:false with a message listing every failed tag, AND the
partial PenDocument that DID land — so M3 (role coverage) can still
score the 11 tags that worked. M1 stays strict (any failure → false).
Mirrors apps/web/src/services/ai/element-tools-dispatcher::
dispatchElementToolCalls's "collect-errors-keep-going" semantics —
production already worked this way; ab-corpus now does too.
3772 vitest pass, format clean, tsc silent. Existing dry-run + live
sweeps exercise the path; a focused apply.ts unit test would need
pen-mcp setup that the harness's existing build-prompt test sidesteps,
so leaving that as a followup.
ab-v4 partial sweep (2026-05-01) caught gpt-5.4 emitting
`add_heading_v0({"content":"Pending invitations","level":"caption"})`
as the 12th tag of a 13-tag composite multi-tool response. Even
though the MCP tool def has `enum: ['display','h1','h2','h3']`, the
ab-corpus harness and the in-process production dispatcher both call
buildHeading() with raw JSON args (no jsonschema gate), so the model's
invented "caption" reached the preset lookup. LATIN_PRESETS["caption"]
is undefined, and the next line `fontSize: preset.fontSize` crashed
the WHOLE batch with `undefined is not an object (evaluating
'preset.fontSize')` — the 11 valid tags ahead of it never landed.
Adds an entry-point validation in buildHeading: if `level` is set and
not in the {display, h1, h2, h3} set, throw with a clear message.
The dispatch loops in apps/web/element-tools-dispatcher and
scripts/ab-corpus/apply both catch per-shape and keep running the
remaining tags, so a single bad level on tag 12 no longer kills tags
1-11 + 13.
3 new edge-case tests in element-builders-edge-cases.test.ts
cover the throw + the four valid levels + the omitted-default case.
3772 vitest pass (+3), format clean, tsc silent.
Other element builders likely have the same pattern (preset lookup
on a string enum without runtime validation) — separate sweep, not
shotgunning here.
Codex stop-time review caught the next inconsistency: f5d9a29c
switched the orchestrator to tryParseAllElementToolOutputs +
dispatchElementToolCalls, but the plural parser cheerfully returned
both element-tool AND batch_design shapes side by side. A
non-compliant model (saw this on minimax-m2.7 in the ab-v4
search-filters composite — 3 element tools + 1 batch_design
scaffolding tag) would slip the forbidden mixed strategy through
the dispatcher, applying the element calls AND the batch_design
DSL together — exact thing the prompt forbids and exact thing the
ab-corpus output-parser silently rejects on the harness side.
Aligns the production parser with corpus output-parser.ts: when
ANY element-tool tag is present in the response, batch_design tags
are DROPPED. Pure Strategy B (no element-tool tags, only batch_design
fallback) keeps working — the drop only fires on mixed output.
Two new regression tests:
- mixed input → only element-tool shapes returned
- pure batch_design input → batch-design-dsl shape returned
3769 vitest pass (+2), format clean, tsc silent. Together with
f5d9a29c and 1a14a6c2, production now has prompt + parser + dispatch
all consistent with the Strategy A/B contract — no path can smuggle
mixed output past any of them.
The Strategy A prompt I shipped in 1a14a6c2 invites the model to
chain N op_tool tags ("settings panel with 4 toggle rows is 5 tool
calls"), but the orchestrator sub-agent was still calling the SINGULAR
tryParseElementToolOutput → dispatchElementToolCall path, which
silently kept only the first tag. A composite-T response with 5 tags
would render only the section header and drop the 4 setting rows on
the floor — exact thing the prompt promises won't happen. Codex
stop-time review caught it.
design-parser and element-tools-dispatcher already had the plural
counterparts (tryParseAllElementToolOutputs, dispatchElementToolCalls)
plumbed end-to-end with one history batch wrapping the whole loop.
Switches the orchestrator to use those.
Failure handling: BatchDispatchResult exposes per-shape DispatchResult
in `results`. When status != 'applied' we concatenate the failed
shapes' messages tagged by toolName so the UI's diagnostic preview
shows which tag(s) broke instead of a generic "dispatch failed".
Partial successes still surface their inserted nodes through
onApplyPartial — the user sees what landed, plus an error summary
naming the broken pieces.
3767 vitest pass, format clean, tsc silent. End-to-end: web app
chat / orchestrator now actually realizes the multi-tool gain my
1a14a6c2 prompt change advertised.
The orchestrator sub-agent's ELEMENT_TOOL_OUTPUT_FORMAT was still
running the pre-Codex-fix wording from before today's ab-corpus pass:
- "Respond with one <op_tool> tag, nothing else"
- "Do not combine multiple tags"
Same self-defeating prompt that gave ab-v3 0/25 composite multi-tool
runs. Production code path stayed broken while the harness kept
getting fixed. Caught when investigating ab-v4's gpt-5.4
search-filters garbage — orchestrator-sub-agent's leading comment
explicitly says it's kept verbatim against the ab-corpus version.
Aligns with the latest scripts/ab-corpus/build-prompt.ts version:
- "Respond with one or more <op_tool> tags" (multi-tool allowed)
- STRATEGY A — element tools, one tag per component, with a 3-tag
worked example
- EMBEDDED COVERAGE — production-specific block listing the subset
of add_*_v0 tools the embedded orchestrator can actually execute,
inserted between Strategy A and Strategy B (the ab-corpus harness
has full coverage so it doesn't need this block)
- STRATEGY B — single batch_design covering the whole response when
any component falls outside EMBEDDED COVERAGE
- Explicit "Do not mix Strategy A and Strategy B" guard, naming the
parser's silent-drop behavior
design-parser.ts::tryParseElementToolOutput already collects every
`<op_tool>` tag into tool_calls (line 61: `parsed.kind === 'tool_calls'
&& parsed.calls.length > 0`), so the multi-tool path works end-to-end
on the production parser side too — no parser change needed.
3767 vitest pass, format clean, tsc silent. Real-user impact: web app
chat / orchestrator runs against minimax / glm / kimi / deepseek now
get the same multi-tool teaching that took composite routing from
0% to 42% in ab-v4.
Codex stop-time review caught that the Phase 2 domain filter
corrupted output when two adjacent kept blocks abutted: the regex's
trailing `\s*` consumed every character of whitespace BEFORE the next
match's leading `\s*`, so block N's body ended on its closing ``` and
block N+1's body started on its `### heading` with no separator at
all. Output: ` ```### Audit / activity feed` on a single line — fence
left unclosed, heading swallowed.
Replace the greedy `\s*` on both sides of each marker with a literal
`\n`, so the regex only consumes the single newline immediately
adjoining the comment. Surrounding blank lines stay in the
surrounding text where they belong, separating adjacent kept blocks
naturally.
Adds a regression guard test that searches the output for ` ```###`
(closing fence directly followed by a level-3 heading on the same
line). Codex effectively asked for it.
3767 vitest pass, format clean. Side effect: dropped blocks now
leave their surrounding blank lines intact, so dropped-block savings
shrink by ~3-5 chars per block; total Phase 2 savings stay within
the 500-char floor the existing test asserts.
ab-v3 / ab-v4 showed Phase 1A (cookbook strip on obvious difficulty)
shaved ~4.4k tokens off T-obvious. Phase 2 adds a per-category gate
that strips cookbook recipes whose domain doesn't match the prompt's
category — mobile briefs don't see dashboard recipes, dashboard
briefs don't see mobile / landing recipes, etc.
Mechanism: HTML comment block markers in elements.md
(`<!-- @domain:dashboard --> ... <!-- /@domain -->`) plus a
stripNonMatchingDomains() pass in buildSystemPrompt that drops blocks
whose tag list doesn't include the active category. Untagged content
is "general" and stays in every variant — the safe default.
Tagged 7 single-domain cookbook recipes:
- dashboard: Team / members list, Audit / activity feed, Faceted
search filter sidebar, Dashboard KPI strip
- landing: Pricing section
- mobile: Onboarding "How it works", Support chat thread
Cross-domain recipes (Login, Signup, Settings page, OTP, Empty
inbox) stay untagged so they load for every category. Decision tree
+ PREFER list also untagged today; the per-tool annotations there
would be a much larger judgment pass for marginal additional savings.
Token measurements (chars / 4 estimate):
full mobile dashboard landing
- T + composite 19.0k 17.9k 18.4k 17.7k
(-1.1k) (-0.6k) (-1.3k)
- T + obvious 14.6k 13.5k 14.0k 13.3k
(-1.1k) (-0.6k) (-1.3k)
Modest absolute savings — Phase 2 only filters cookbook RECIPES (in
elements.md), and most cookbook content is in elements-cookbook.md
which Phase 1A already strips on obvious. To hit the 6-8k T target
we still need decision-tree compression or PREFER-list trim, but
both are lossier than this gate. Phase 3 candidates noted in the
ab-v4 results doc.
real-model.ts plumbs call.prompt.category through to buildSystemPrompt.
3766 vitest pass (+6 category filter tests including a 500-char
floor regression guard that the filter actually shaves bytes).
ab-v4 raw output capture on dashboard-search-filters-composite shows
minimax-m2.7 emitting <think>...</think> + 4 op_tool tags that fit
inside the 4096 default — its measured completion-token average for
this run was 697, well under the cap. So thinking-budget truncation
is NOT the actual root cause of minimax's lower multi-tool hit rate
(25% vs gpt+deepseek 50%); the real issues are instruction-following
(mixed Strategy A + B despite the explicit forbidance, invented
"canvas" parent_id placeholder).
Still doubling the cap defensively: composite multi-tool outputs can
chain 12-13 op_tool tags + thinking, and "fit easy" today doesn't
mean "fits headroom-free on a longer brief tomorrow." The bump is
free on the happy path (provider stops generating when done, doesn't
bill unused headroom) and only ever helps when the model would
otherwise hit a real ceiling.
Real follow-up for minimax: instruction compliance — the no-mix rule
needs to land harder than a single trailing sentence. Probably wants
the rule moved to top-of-prompt + a few-shot bad-example contrast.
Out of scope here; tracked under Phase 2 prompt design.
ab-v4's dashboard-search-filters-composite garbaged on gpt-5.4 with
"codex timed out after 300000ms" — Codex's own agent framing
(~20k tokens) plus our 18.9k composite system prompt plus thinking
budget plus a 13-tool composite output is enough to blow the old
5-minute cap. The same model produced 13 chained op_tool tags on
team-people-page-composite within the window, so we know it's
generation latency under heavy briefs, not a hung CLI.
Doubles the default to 600000ms and adds AB_CORPUS_CODEX_TIMEOUT_MS
to override either way (lower it to surface slowness as a hard fail
when iterating, raise it for one-off long-form runs). Pairs with
the existing AB_CORPUS_CALL_TIMEOUT_MS for openai-compat clients.
Doesn't fix the deepseek empty-content failure on the same prompt
(already covered by retries=2 + exp backoff) or the minimax mix
+ invented "canvas" parent_id (model-side instruction skip; out of
scope for this commit).
Codex stop-time review (3rd round) caught residual "T prompt
includes mixed-strategy instructions": even after the prompt
forbade mixing, elements.md still taught the mixed pattern in
several places that are also part of the T system prompt.
Cleaned out every spot that paired batch_design with add_*_v0:
- Login screen / Pricing section / Dashboard KPI strip recipes:
dropped the leading `batch_design: foo = I("page", {...})` line
and renamed `<foo>` / `<row>` placeholders to `<page>` so each
recipe is now Strategy A (element tools only). Lost: explicit
page-level layout/padding/horizontal row — acceptable, the
recipes still teach the tool selection + chain pattern.
- Intro paragraph: dropped "override via a follow-up batch_design
U-op if needed" (taught a per-component fallback that the parser
drops).
- Banner: rewrote the fall-back clause from "when no element tool
fits a specific component shape" to "when at least one component
truly needs a custom shape no element tool covers — and then use
a SINGLE batch_design for the WHOLE response, never mixed."
- "STILL use batch_design when" list: collapsed 3 mixed-strategy
bullets ("larger composite via batch_design then element tool",
"post-hoc styling via batch_design U-ops") into 3 clean
Strategy-B-only bullets, all explicitly emit a SINGLE batch_design.
- Removed the "## Composition pattern" section entirely — its 3-step
plan was a textbook mixed pattern (batch_design root → element
tool inserts → batch_design U-op styling) that depended on real
MCP multi-round semantics the corpus harness can't provide.
- Composition rules of thumb: replaced "Don't mix N-tool and
batch_design DSL ops in a single call" + "Style overrides come
AFTER structure" with a single "Don't mix in the same output"
rule that names the corpus parser behavior explicitly.
3760 vitest pass, format clean, tsc silent. T-obvious 14.5k tokens
(down ~100 from the cleanup); composite still 18.9k.
The earlier T instruction split tool selection into PRIMARY / COMPOSITE
(element tools) and FALLBACK (batch_design). FALLBACK was scoped
"when no element tool fits a given component shape" — implying you
could mix per-component. But output-parser.ts silently drops every
batch_design tag whenever any element call is also present (filter
ELEMENT_TOOL_NAME_RE then return). A mixed response
<op_tool>{"name":"add_section_header_v0",...}</op_tool>
<op_tool>{"name":"batch_design", ...scaffolding...}</op_tool>
would lose the batch_design half and only run the element call —
the brief is half-applied without anyone noticing. Codex stop-time
review caught the mismatch.
Reframes T as a binary choice:
- STRATEGY A: every component fits an add_*_v0 tool — emit one tag
per component
- STRATEGY B: at least one component needs batch_design — emit a
SINGLE batch_design covering the whole brief
Plus an explicit "Do not mix Strategy A and Strategy B" guard line.
Tests assert both strategy markers are present in every T variant
and the dropped per-component-fallback phrase is gone.
The elements.md cookbook still teaches batch_design + element tool
composition for real MCP multi-round usage; the corpus T arm is
single-shot so the trailing T_TOOL_CALL_INSTRUCTIONS overrides via
"last instruction wins".
Codex stop-time review caught a contradiction: elements.md taught
"emit one <op_tool> per component" while T_TOOL_CALL_INSTRUCTIONS
explicitly forbade it ("Respond with one tag, nothing else" + "Do
not combine multiple tags"). Live models were probably reading the
later, more authoritative trailing instructions and dropping back to
batch_design — which matches ab-v3's 0/25 composite-T multi-tool
runs even though the cookbook had recipes.
Rewrites the T output-format block to:
- declare "one or more <op_tool> tags" up front
- add an explicit COMPOSITE clause showing chained tags for
multi-component briefs (settings panel / team list / audit feed /
onboarding) with a 3-tag worked example
- keep the FALLBACK clause for batch_design when no element tool fits
- keep the "no prose between tags" rule
Tests now assert the multi-tool marker is present in every T variant
AND the forbidding phrases ("one tag, nothing else", "Do not combine
multiple tags") are GONE — the regression guard Codex effectively
asked for. Also pulls in an oxfmt auto-format on the measurement
helper that landed unformatted in a3c5bf2d.
One-shot diagnostic that prints char + token-estimate sizes for every
(variant, difficulty) combination of buildSystemPrompt. Used to verify
the elements-cookbook diet is shaving the bytes the test floor
predicts (>10kb) before running an actual sweep. Confirms today's
numbers: B 15kb, T-obvious 57kb, T-composite 75kb — diet saves ~17kb
(~4.4k tokens) on every obvious prompt. Phase 2 (per-domain split)
still needed to hit the 6-8k target.