Commit graph

409 commits

Author SHA1 Message Date
Fini dc656be409 fix(pen-core/variables): resolveNodeForCanvas — fontWeight + empty-vars fallback
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.
2026-04-29 09:50:32 +08:00
Fini aa2658fcd1 feat(scripts): smoke test for v1 system mode + design system ripple
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).
2026-04-29 09:50:31 +08:00
Fini 1650f81fa8 feat(ai-skills): default decision tree to v1 system mode (P4)
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.
2026-04-29 09:50:30 +08:00
Fini 238ab344e2 feat(element-tools): add 11 v1 tools with theme parameter (P3 batch 9 — FINAL)
Converts tabs, tag, text_button, textarea, timeline, toolbar, tooltip,
top_nav_bar, upload_dropzone, user_card, video_placeholder to theme-aware v1.
All 9 touchpoints wired; ext-8 extended and new ext-9 shard created for overflow.
ListTools count: 177 → 188. All 4127 tests pass.

Classification:
- Pass-through (all modes identical, no surface colors): text_button, textarea,
  top_nav_bar, tabs (accent brand-invariant), tooltip (dark=inverted per §3.4),
  tag (status tones per §3.4), video_placeholder (dark bg per §3.4)
- Surface-tint (light/dark/system tokenized): timeline (inactive dot+connector+subtitle),
  toolbar (surface+border+active-bg+icon), upload_dropzone (5 tokens),
  user_card (name+role text)
2026-04-29 09:50:29 +08:00
Fini d547e6917b feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 8)
Converts sidebar_nav, skeleton, social_login_row, spinner, stat_card,
stat_grid, status_badge, step_card, stepper, switch to theme-aware v1.
All 9 touchpoints wired; ext-8 shard extended for schema definitions.
ListTools count: 167 → 177. All 4116 tests pass.

Notable: spinner/stat_grid/status_badge/switch emit identical trees
across all theme modes (caller-param colors, status semantics, or iOS
HIG builder-private literals per spec §3.4) — theme param accepted
for API consistency only.
2026-04-29 09:50:28 +08:00
Fini e1a931e4ff feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 7)
Converts progress_bar, quote_block, radio, range_slider, rating_stars,
search_bar, section_header, segmented_control, select, share_row to
theme-aware v1. All 9 touchpoints wired; adds ext-8 shard for schema
definitions. ListTools count: 157 → 167. All 4106 tests pass.
2026-04-29 09:50:27 +08:00
Fini ab695d9633 feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 6)
Converts metric_comparison, metric_row, nav_chip_row, notification_row,
otp_input, pagination, phone_input, price, pricing_card, profile_header
to theme-aware v1. All 9 touchpoints wired; adds ext-7 shard for schema
definitions. ListTools count: 147 → 157. All 4096 tests pass.
2026-04-29 09:50:26 +08:00
Fini 028ef9048b feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 5)
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.
2026-04-29 09:50:25 +08:00
Fini f4a38dc508 feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 4)
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.
2026-04-29 09:50:24 +08:00
Fini 957f845bf5 feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 3)
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.
2026-04-29 09:50:23 +08:00
Fini c0717d43c4 feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 2)
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.
2026-04-29 09:50:22 +08:00
Fini ddfb3fa81b feat(element-tools): add 5 atom v1 tools with theme parameter (P3 batch 1)
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.
2026-04-29 09:50:21 +08:00
Fini 4962d6176e feat(element-tools): add 4 representative v1 tools (Task 2.4)
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.
2026-04-29 09:50:20 +08:00
Fini 6a74933945 feat(element-tools): add heading-v1 with theme parameter (9 touchpoints)
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.
2026-04-29 09:50:19 +08:00
Fini 6d055c1bbb feat(empty-chart-v1): full token coverage via resolveTheme (P2.2) 2026-04-29 09:50:18 +08:00
Fini c57bdab2d8 feat(toast-v1): full token coverage via resolveTheme (P2.2) 2026-04-29 09:50:17 +08:00
Fini 8bf59014f0 feat(modal-shell-v1): full token coverage via resolveTheme (P2.2) 2026-04-29 09:50:16 +08:00
Fini f927a200a8 feat(element-builders): resolveTheme helper for v1 builders (P2.1) 2026-04-29 09:50:15 +08:00
Fini caa35e5d8b fix(pen-core): resolve fontSize/lineHeight/letterSpacing/cornerRadius $refs in resolveNodeForCanvas
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.
2026-04-29 09:50:14 +08:00
Fini 8a8151d35c feat(pen-core/variables): resolver-side fallback to default palette (P1.6)
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.
2026-04-29 09:50:13 +08:00
Fini f18f94da84 fix(pen-core/variables): remove 4 extra tokens, add merge map per spec §3.1
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%.
2026-04-29 09:50:12 +08:00
Fini cfabfe8a3d style(types): format P1.1-P1.5 changes with oxfmt (no logic change) 2026-04-29 09:50:11 +08:00
Fini 8ab607bc9d feat(types): add 3 radius tokens to semantic-palette (P1.5)
radius-sm=4 / radius-md=8 / radius-lg=12 px — covers chip, card, and sheet
border-radius tiers. All P1 token additions complete (60 total tokens:
32 color + 18 type + 2 letterSpacing + 5 spacing + 3 radius).
2026-04-29 09:50:10 +08:00
Fini eb14bada88 feat(types): add 5 spacing tokens to semantic-palette (P1.4)
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.
2026-04-29 09:50:09 +08:00
Fini c9aad9f3c9 feat(types): add 2 letterSpacing tokens to semantic-palette (P1.3)
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.
2026-04-29 09:50:08 +08:00
Fini 67003d72f6 feat(types): add 18 typography tokens to semantic-palette (P1.2)
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.
2026-04-29 09:50:07 +08:00
Fini 7eefa08acf fix(types): add §3.4 exclusion list to cover-rate script, reach 100% (P1.1.6)
10 builder-private hex literals excluded from denominator per Codex B-route
decision. 4 uncovered semantic hex added as new single-value tokens
(color-accent-dark, color-info-surface, color-warning-text-strong,
color-danger-text-strong) → semantic cover rate 87.5% → 100%. Hard gate passes.
2026-04-29 09:50:06 +08:00
Fini b23c55ea10 feat(types): add 14 alert + chart color tokens to semantic-palette (P1.1.5)
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.
2026-04-29 09:50:05 +08:00
Fini 37a430d963 feat(scripts): v0 hex cover-rate gate for design-system-aware migration 2026-04-29 09:50:04 +08:00
Fini 98921c5846 fix(ai-skills): correct setting-row schema in parent_id teaching examples
df750c98 加的 ❌/✅ 反例里 setting_row 字段名写错了:用了
\`label\` 但 SettingRowParams 实际字段是 \`title\`;trailing.switch
用了 \`value: true\` 但 schema 实际是 \`{ kind: "switch", on: boolean }\`。
Codex stop-time review 抓到 "invalid prompt example would teach a
bad tool schema" — 直接用 source builder 的 SettingRowParams /
SettingRowTrailing 类型作为 ground truth 更新两处例子。
2026-04-29 09:50:03 +08:00
Fini 067942fcf8 fix(ab-corpus): bump kimi-k2.6 retries=3 + timeout=180s on Ark CP
ab-v5 (2026-05-02) 把 kimi-k2.6 T arm garbage rate 砍到 17%,但仍
贡献 9/14 of 全部 T arm garbage,全部失败模式都是 ARK 端
"empty content" 或 "120s timeout"——不是模型质量。给 kimi 单独提
retries=3 (extra 4000ms backoff attempt) + timeoutMs=180s (60s
headroom) 让慢响应有机会被等到。glm-5.1 garbage <2%,保留原配置避
免在健康调用上浪费 budget。
2026-04-29 09:50:02 +08:00
Fini 01b925bd7e feat(ai-skills): teach parent_id rule with concrete WRONG/RIGHT examples
ab-v5 (2026-05-02) 显示 5acde087 教学只是把模型发明的 placeholder
名字从 "root" 改成 "entry-1" / "members-section",没根除"先发明 id
再引用"的习惯。加 ❌/✅ side-by-side 例子让模型直接看到 invented
name 跟 omit 的区别。token 增量 ~200,仍在 Phase 3 trim (-540) 净盈
余范围内。
2026-04-29 09:50:01 +08:00
Fini 577928472f fix(ab-corpus): append each row when ready, not after Promise.all
并行 dispatch 改动(前一 commit)把每个 prompt 内 10 个调用 Promise.all
集合后再批量 append。问题是任何 1 个调用卡满 120s ARK timeout 都会
把其它 9 个已完成 row 的落盘也推迟,破坏了 scores.jsonl 作为 partial-
state-on-crash 的设计。

把 fs.appendFileSync 移进 runOne:每个 row 由 scoreRun 返回后立即
落盘。fs.appendFileSync 是 Node 同步 syscall,单线程 event loop 下
绝不会与另一次 append 交错,所以不需要 mutex。
2026-04-29 09:50:00 +08:00
Fini be04c8beea fix(ab-corpus): pin codex reasoning effort to medium
gpt-5.5 在 codex CLI 默认走 xhigh,单调用 1-2 分钟,把 ab-v5 全
量 sweep 从 ~15 分钟拉到预测 13 小时。pin 到 medium 跟 ab-v4
gpt-5.4 历史值同档,保留 AB_CORPUS_CODEX_REASONING env override
留给以后想测全火力上限。
2026-04-29 09:49:59 +08:00
Fini 2902bdc88e feat(ai-skills): teach parent_id rule + trim PREFER list "Different from"
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.
2026-04-29 09:49:58 +08:00
Fini bafbdfaeef fix(pen-core): reject invalid enum values in 5 more element builders
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`.
2026-04-29 09:49:57 +08:00
Fini b4b931211c fix(pen-ai-skills): score partial PenDocument when apply.ok=false
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.
2026-04-29 09:49:56 +08:00
Fini bd370bd5b8 fix(ab-corpus): keep applying composite tags past per-shape failures
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.
2026-04-29 09:49:55 +08:00
Fini 39aeb0c30e fix(pen-core): reject invalid level in buildHeading with a clear error
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.
2026-04-29 09:49:54 +08:00
Fini bfff97a42a fix(ai): plural parser drops batch_design when element-tool tags coexist
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.
2026-04-29 09:49:53 +08:00
Fini 580431cc6f fix(ai): apply every <op_tool> tag in production, not just the first
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.
2026-04-29 09:49:52 +08:00
Fini f7140f8994 fix(ai): sync production element-tool prompt with ab-corpus Strategy A/B
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.
2026-04-29 09:49:51 +08:00
Fini f0a575e210 fix(ab-corpus): preserve markdown structure across kept @domain blocks
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.
2026-04-29 09:49:50 +08:00
Fini c835976479 feat(ab-corpus): per-domain cookbook filter (Phase 2 of token diet)
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).
2026-04-29 09:49:49 +08:00
Fini bacafae52a feat(ab-corpus): bump minimax max_tokens to 8192 (defensive)
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.
2026-04-29 09:49:48 +08:00
Fini f928d51b35 feat(ab-corpus): bump codex CLI timeout to 10min + env override
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).
2026-04-29 09:49:47 +08:00
Fini cae5501021 fix(ai-skills): purge mixed-strategy teaching from elements.md cookbook
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.
2026-04-29 09:49:46 +08:00
Fini f6746eb91c fix(ab-corpus): forbid mixing batch_design with element tools in T
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".
2026-04-29 09:49:45 +08:00
Fini b7e6097da5 fix(ab-corpus): allow multi-tool output in T system prompt
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.
2026-04-29 09:49:44 +08:00
Fini 2f31b62ca9 chore(ab-corpus): add prompt-size measurement helper
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.
2026-04-29 09:49:43 +08:00