Commit graph

105 commits

Author SHA1 Message Date
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 88505648eb fix(ab-corpus): plumb multi-tool output end-to-end for composite
Codex stop-hook caught: ab-v3 introduced composite-difficulty prompts
that *expect* multi-tool emit (e.g. 5× member_row + 1× invite_row
for a team page), but `ParsedOutput.tool_call` was a single
{name, arguments} so the parser silently dropped every call after
the first. apply.ts only invoked one tool, M3 min_roles couldn't
pass on legitimately-routed multi-tool runs, and byTool stats
under-counted. The composite routing 'multi-tool' bucket was
correctly assigned in classifyRouting, but downstream the pipeline
behaved as if the model emitted a single call.

This commit replaces `kind: 'tool_call'` with
`kind: 'tool_calls'` (NON-EMPTY list) across every consumer:

- types.ts: ParsedOutput tagged union; new ParsedOpToolCall.
  ScoreRow.toolName → toolNames: string[].
- output-parser.ts: collects ALL element-tool tags in emit order;
  unknown-tool path also surfaces as single-element tool_calls so
  routing keeps the same wrong-tool semantics.
- score-run.ts: classifyRouting uses Array.includes for obvious
  prompts (right-tool when ANY emitted call matches expected_tool —
  over-production isn't a routing miss). Composite stays multi-tool
  on any non-empty list.
- aggregate.ts byTool: tallies EVERY name in toolNames, so a
  composite row that emits 6× add_activity_log_v0 + 1×
  add_section_header_v0 contributes 6+1 = 7 invocations across two
  tools (with row-level m1_legal applied to both buckets — apply is
  all-or-nothing).
- apply.ts: loops over parsed.calls and invokes
  handleElementToolCall in emit order. Any single call failing
  aborts the row (M1=false); we don't partial-apply.
- mock-llm.ts mockLlmParsed: collects all `<op_tool>` tags into the
  list (composite-prompt mocks can carry multi-call raw strings).
- apps/web design-parser.tryParseElementToolOutput: maps tool_calls
  → its single-shape DesignOutputShape contract using the FIRST
  call (the multi-tag path `tryParseAllElementToolOutputs` was
  already correct).

Tests: 3746 → 3750 vitest. New cases:
- output-parser: surfaces ALL element-tool tags in emit order with
  intermixed batch_design scaffolds dropped (3 element calls from
  5 tags).
- score-run: right-tool when expected appears alongside extras;
  composite multi-call captures every name in toolNames.
- aggregate: 6× activity_log + 1× section_header → byTool reports
  6 and 1 invocations respectively.

dry-run on ab-v3 produces a 208-row report; tsc + format clean.
2026-04-29 08:35:00 +08:00
Fini 87c56f7284 feat(ab-corpus): retry transient errors on ark + deepseek
ab-v2 (2026-04-28) saw kimi-k2.6 garbage rate hit 17.5% — every
failure was Ark returning empty `choices[0].message.content` or
hitting the 120s wall clock, not a model-quality issue (the model
itself routed to the right element tool 80% of the time when it
did respond). Same pattern at 7.5% on deepseek-v4-pro.

Adds optional `retries` to `callOpenAICompat` with a transient-error
allowlist: empty content, abort/timeout, HTTP 5xx, HTTP 429. Linear
250ms × (attempt+1) backoff. HTTP 4xx other than 429 stays fatal so
auth/bad-request failures don't burn retry budget.

Wires `retries: 1` through clients/ark.ts and clients/deepseek.ts.
MiniMax + Bailian + Codex stay untouched — their ab-v2 failures
were model-quality (DSL escape errors, output truncation), where
retry wastes a call without changing the outcome.

Adds an 8-case fixture in scripts/ab-corpus/clients/__tests__/ that
mocks fetch to verify: first-try success, empty-then-success,
5xx-then-success, 429-then-success, 401 fatal, retries-default-zero,
retries exhausted, and retries=2 (3 attempts total). Extends the
apps/web vitest include glob to pick up scripts/**/__tests__/.
2026-04-29 07:15:00 +08:00
Fini 7ce2b1886e refactor(test): split parity CASES out so test file stays under 800 lines
shim-server-parity.test.ts grew to 838 lines after the recent batch of
element tools added 7 fixture entries. Move the CASES table and the
build* imports to shim-server-parity-cases.ts (a sibling .ts, not
.test.ts so vitest doesn't pick it up as a separate suite); keep the
mocks, helpers, and describe blocks in the test file (now 139 lines).

Both files are well under the repo's 800-line ceiling. vitest's
vi.mock hoisting still works because the mock is registered at the
test file's module-resolution time, before the cases file's imports
resolve — confirmed by the 104 parity assertions still passing.
2026-04-28 09:00:00 +08:00
Fini 1fee613111 feat(ai): ship 5 element tools to reach 97 (filter_group / invite_row / activity_log / event_card / step_card)
Closes the obvious gaps remaining in the family:
- add_filter_group_v0 — sidebar facet (heading + checkbox-style options
  with optional counts). Distinct from nav_chip_row (horizontal scrolling
  chips), tag (single applied chip), segmented_control (mutex tabs).
- add_invite_row_v0 — pending invite row (avatar + email/role + status
  pill + trailing action). Distinct from member_row (a JOINED member,
  no status pill or action) and list_row (no avatar / status / action).
- add_activity_log_v0 — single-line audit feed entry (optional tinted
  icon dot + actor in bold + action + right-aligned timestamp). Uses
  StyledTextSegment[] content for the bold/regular split. Distinct from
  timeline (multi-event vertical with connectors) and notification_row
  (title + body, no actor focus).
- add_event_card_v0 — single calendar event tile (date column with
  month band + day number, then title + time + location). Distinct from
  calendar_grid (the full month grid) and card_row (no date column).
- add_step_card_v0 — onboarding step card (numbered circle / check +
  title + description). Distinct from stepper (horizontal progress nav
  with connectors) and faq_item (collapsible Q&A header).

9 touchpoints per tool: pen-core builder + index + barrel + types,
pen-mcp handler + dispatcher + ext-4 schema, apps/web shim +
SERVER_BUILDERS, parity test (+5 cases), elements.md decision tree
items 86-89 + 6 PREFER mappings with cross-links to existing tools,
elements-cookbook.md arg-shape examples (8 entries across 5 tools).
2026-04-28 08:50:00 +08:00
Fini a5b594cf69 feat(ai): add_member_row_v0 — team / member list row (92nd tool)
Avatar + (name over optional subtitle) + optional trailing slot
(role badge / kebab menu / status dot). Distinct from
add_user_card_v0 (compact fit_content tile, no trailing slot) and
add_list_row_v0 (no avatar slot — leading icon instead).

9 touchpoints wired: pen-core builder + index + barrel + types,
pen-mcp handler + dispatcher + ext-4 schema, apps/web shim +
SERVER_BUILDERS, parity test, elements.md decision tree #84 +
PREFER mapping, cookbook arg shapes (3 variants).

Also disambiguates add_avatar_group_v0's PREFER mapping: drop
"团队成员" (now points at member_row), keep narrower phrases like
"成员头像" / "团队头像" / "presence indicator" that genuinely match
the stacked-avatars affordance, and add the cross-link to member_row.
2026-04-28 08:05:00 +08:00
Fini af1ddd1ad2 feat(ai): add_setting_row_v0 — settings menu row (91st tool)
Leading icon + (title over optional subtitle) + trailing control with
4 variants: chevron / value text / switch / badge. Distinct from
add_list_row_v0 (trailing is always icon, no switch/value/badge) and
add_form_field_v0 (label-above-input for forms).

Wires all 9 touchpoints: pen-core builder + index + barrel re-export,
pen-mcp handler + dispatcher case + ext-4 schema, apps/web shim +
Nitro SERVER_BUILDERS, elements.md decision tree #83 + PREFER mapping,
elements-cookbook arg-shape examples, plus shim-server parity case.
2026-04-28 06:30:00 +08:00
Fini 5859c3f9af feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header)
Adds the desktop-leaning batch needed to round the family to 90:

- add_user_card_v0 — compact avatar+name+role row
- add_drawer_shell_v0 — full-height side panel header
- add_combobox_v0 — open-state autocomplete with dropdown
- add_toolbar_v0 — desktop icon button row + dividers
- add_callout_v0 — inline doc tip block, 5 tones
- add_share_row_v0 — circular social-share buttons
- add_inline_action_v0 — message + Undo-style action
- add_legend_item_v0 — chart legend marker+label+value
- add_inbox_message_v0 — email/inbox row with unread dot
- add_profile_header_v0 — large profile hero block

All ten go through the standard 9-touchpoint wiring and land in a
new ext-4 schema shard so existing shards stay under 800 lines.
Drift guards (contract / registry parity / shim-server parity) cover
each new name; per-tool handler tests are deferred — every tool's
structure is exercised through the parity build call already.
2026-04-27 09:05:00 +08:00
Fini 072856e522 feat(ai): add_tag_v0 — single closable filter chip (80th tool) 2026-04-27 08:55:00 +08:00
Fini d24b587b6c feat(ai): add_data_table_row_v0 — desktop tabular row (79th tool) 2026-04-27 08:40:00 +08:00
Fini e225104e43 feat(ai): add_avatar_group_v0 — stacked presence tile group (78th tool) 2026-04-27 08:30:00 +08:00
Fini c5655000fd chore(release): bump to v0.8.0 2026-04-27 08:20:00 +08:00
Fini 6e54054c73 chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes
origin's v0.8.0 had cherry-picks of the v0.7.5 deepseek/image-search
fixes (a727632a, a5952bc8) overlapping local 2073cf5b / 04f4fbc1, plus
new commits (model-selector ark-coding deepseek-v4-pro/flash IDs that
ARK rejects, fetch error.cause unwrap, CI agent-native build, op
export docs cleanup, main merge). Resolved the ark-coding list in
favor of HEAD's deepseek-v3.2 entry (only model ARK Coding Plan
actually supports — see openpencil-docs note).
2026-04-27 08:15:00 +08:00
Kayshen-X b554b4f1a6 Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
Kayshen-X 4ed1203bf1 fix(ai): unwrap fetch error.cause for actionable network failures
Custom OpenAI-compatible providers surfaced Node's opaque
`TypeError: fetch failed` whenever the upstream HTTP call failed
(#121) — DNS, TLS handshake, connection refused, timeout — all
collapsed to the same useless string. The actual reason was already
on `error.cause` as a SystemError but never reached the user.

Add `formatFetchError()` that walks the cause chain (including
AggregateError emitted when undici tries multiple A records and each
attempt fails) and prefixes the SystemError code so users see
`ENOTFOUND: getaddrinfo ENOTFOUND api.foo.com` or
`ECONNREFUSED: connect ECONNREFUSED 127.0.0.1:443` instead of
`fetch failed`. Wire it into the model-list proxy (most common
trigger from the AI Settings dialog) and the builtin chat stream.

Closes #121
2026-04-26 19:20:32 +08:00
Kayshen-X b80db5b798 docs: drop op export from CLI docs and clarify pen-mcp usage
The `op export` command was removed in 0.7.x but the README still
advertised it (#116). The pen-mcp README also documented an
`npx @zseven-w/pen-mcp` quick-start that never worked because the
package ships TypeScript source against workspace-only deps with no
`bin` entry (#117).

- Strip `op export` references from all 15 root and 15 cli READMEs
- Sync AGENTS.md, CLAUDE.md, apps/cli/CLAUDE.md to match the codegen-
  pipeline reality (no standalone export command anymore)
- Rewrite pen-mcp README's quick-start: explain the package ships as
  part of the OpenPencil app and external clients connect over HTTP

Closes #116
Closes #117
2026-04-26 19:20:14 +08:00
Fini 829772f60e fix(ai): swallow image-search network failures into the existing fallback
`fetchFromOpenverse` and `fetchFromWikimedia` were missing try/catch,
so a ConnectTimeoutError on `api.openverse.org` (frequent on networks
that can't reach Openverse) bubbled up to nitro's default handler and
turned a single image-search lookup into a HTTP 500 for the whole
design generation flow. The handler already treats `null` (Openverse)
and `[]` (Wikimedia) as the documented fallback signals — wrap the
fetches and return those on any throw, plus an explicit 8s
AbortSignal.timeout so the wait is bounded.
2026-04-26 07:30:00 +08:00
Fini abbccc1ba2 fix(ai): refresh DeepSeek defaults to v4 model series
`/models` now returns only deepseek-v4-pro and deepseek-v4-flash;
deepseek-chat / deepseek-reasoner sunset 2026-07-24 and the
deepseek-v3.2 hard-coded in the ark-coding fallback list never
existed. Both v4 models default to thinking enabled and the API
toggles via `{"thinking":{"type":"disabled"}}` — keep
`thinkingMode: 'disabled'` so the app's fast/non-thinking default
stays intact (server reasoning paths honor it; the Zig openai-compat
path doesn't emit the toggle yet, so calls through that path still
get provider-default thinking until it's wired). v4-pro promoted to
full tier; legacy aliases pinned to an exact RegExp so future
deepseek-* variants don't inherit a forced disabled mode.

Bandaid for the unwired toggle: v4-pro gets `timeoutMultiplier: 2`
because its default-on reasoning blows past the orchestrator's
planning timeout on long system prompts (observed in dev: planning
phase falls back, sub-agent then succeeds — UX degraded but
functional). Drop the multiplier once the Zig path actually sends
`thinking:{type:disabled}`.

Don't add a BUILTIN_MODEL_LISTS.deepseek entry — DeepSeek exposes
/v1/models, so let `fetchProviderModels` pull the live catalog
through `/api/ai/provider-models` instead of pinning a snapshot
(the ark-coding `deepseek-v3.2` ghost above shows what those
snapshots drift into).
2026-04-26 06:30:00 +08:00
Fini e9d9e37770 feat(ai): add_sidebar_nav_v0 — desktop persistent left rail (77th tool)
Top_nav_bar / bottom_nav / nav_chip_row covered mobile and inline
chrome but desktop dashboards still had to hand-roll their left
sidebar via batch_design. Adds a 240px-wide vertical rail with
icon+label rows, optional brand title, and slate-100 pill bg on the
active item — distinct surface from the existing nav tools so the
decision tree picks it cleanly for "sidebar / side nav / 侧边栏".
2026-04-26 06:29:56 +08:00
Fini 6d91f60f1e fix(ai): keep deepseek-v3.2 in ark-coding fallback list
The previous DeepSeek refresh swapped this entry for v4-pro / v4-flash,
but ark-coding routes to Volcengine's Coding Plan, not the DeepSeek
direct API — and ARK Coding ships its own DeepSeek catalog where only
deepseek-v3.2 is supported. Selecting v4-pro / v4-flash through ARK
returns "404 The xxxxxx model does not support the coding plan
feature". Restore v3.2 in this list and add a comment so the next
person doesn't repeat the mistake. Direct-DeepSeek preset/profile
keeps the v4 changes — those are correct for api.deepseek.com.

Source: https://developer.volcengine.com/articles/7615528054736945158
2026-04-26 06:29:55 +08:00
Fini b9daac8a22 fix(ai): swallow image-search network failures into the existing fallback
`fetchFromOpenverse` and `fetchFromWikimedia` were missing try/catch,
so a ConnectTimeoutError on `api.openverse.org` (frequent on networks
that can't reach Openverse) bubbled up to nitro's default handler and
turned a single image-search lookup into a HTTP 500 for the whole
design generation flow. The handler already treats `null` (Openverse)
and `[]` (Wikimedia) as the documented fallback signals — wrap the
fetches and return those on any throw, plus an explicit 8s
AbortSignal.timeout so the wait is bounded.
2026-04-26 06:29:54 +08:00
Fini cc4ca08da7 fix(ai): refresh DeepSeek defaults to v4 model series
`/models` now returns only deepseek-v4-pro and deepseek-v4-flash;
deepseek-chat / deepseek-reasoner sunset 2026-07-24 and the
deepseek-v3.2 hard-coded in the ark-coding fallback list never
existed. Both v4 models default to thinking enabled and the API
toggles via `{"thinking":{"type":"disabled"}}` — keep
`thinkingMode: 'disabled'` so the app's fast/non-thinking default
stays intact (server reasoning paths honor it; the Zig openai-compat
path doesn't emit the toggle yet, so calls through that path still
get provider-default thinking until it's wired). v4-pro promoted to
full tier; legacy aliases pinned to an exact RegExp so future
deepseek-* variants don't inherit a forced disabled mode.

Bandaid for the unwired toggle: v4-pro gets `timeoutMultiplier: 2`
because its default-on reasoning blows past the orchestrator's
planning timeout on long system prompts (observed in dev: planning
phase falls back, sub-agent then succeeds — UX degraded but
functional). Drop the multiplier once the Zig path actually sends
`thinking:{type:disabled}`.

Don't add a BUILTIN_MODEL_LISTS.deepseek entry — DeepSeek exposes
/v1/models, so let `fetchProviderModels` pull the live catalog
through `/api/ai/provider-models` instead of pinning a snapshot
(the ark-coding `deepseek-v3.2` ghost above shows what those
snapshots drift into).
2026-04-26 06:29:53 +08:00
Fini 6c44d58a31 feat(ai): add_cookie_banner_v0 — GDPR/CCPA cookie banner (76th tool)
Sticky bottom-of-page disclosure card with title, body, accept /
decline buttons (decline-then-accept order), and an optional
"Cookie settings" link for fine-grained consent. Caller positions
the banner; the tool emits the card itself with shadow + 1px slate
border for visual lift above the page content. Plus the v1 corpus
prompt landing-cookie-banner (26 v1 prompts total).
2026-04-25 08:15:00 +08:00
Fini 1acd31a2e1 feat(ai): add_input_with_action_v0 — input + inline action button (75th tool)
The "Subscribe to newsletter" / "Apply discount code" / "Send chat
message" pattern. Two action variants:
  - text (default): pill button with label like "Subscribe"
  - icon: 44×44 square icon button (chat send arrow / search apply)

Distinct from add_form_field_v0 (label-above, no inline button)
and add_search_bar_v0 (no trailing action button). Optional
leading_icon adds an icon inside the input itself. Plus the v1
corpus prompt landing-newsletter-signup (25 v1 prompts total).
2026-04-25 08:00:00 +08:00
Fini 577488546a feat(ai): add_phone_input_v0 — international phone input (74th tool)
The "+1 (555) …" pattern from every modern signup / login screen.
A 44px row with leading country selector (flag + dial code +
chevron-down), a 1px slate divider, and the digits input on the
right. Country selector is a button-shape (no actual dropdown
menu); caller handles picker UX as a separate concern. `value`
toggles between placeholder (slate-400) and populated (slate-900).
2026-04-25 07:30:00 +08:00
Fini d1022e74d5 feat(ai): add_empty_chart_v1 — theme-aware no-data placeholder
Third theme-aware v1 (after add_modal_shell_v1 and add_toast_v1).
Same dashed-border "no data yet" tile shape as v0 with a `theme`
param that swaps 5 colors (bg / border / icon / title / subtitle):
  - light (default): byte-parity with v0 (slate-50 / slate-300)
  - dark: slate-800 bg + slate-600 border + slate-200 title
  - system: $color-surface-2 / $color-border / $color-text-primary
    refs — requires applySemanticPalette(doc) seeded

Lets dark-theme dashboards keep a matching empty-slot surface
instead of punching a light rectangle out of dark cards.
2026-04-25 01:37:40 +08:00
Fini aea75fa198 feat(ai): add_range_slider_v0 — single-thumb range slider (71st tool)
Visual static representation of a horizontal slider. Track splits
into left fill (accent) + 20×20 thumb (white + accent stroke) +
right remaining (slate), all aligned on a 20px track wrap. Optional
label + value readout row above; `value_suffix` renders "60%" /
"128px" / "0°" style readouts. Pixel math: fill = (width-20) * pct,
auto-collapses fill or remaining at either extreme.
2026-04-25 01:33:46 +08:00
Fini 984bb10bd1 feat(ai): add_toast_v1 — theme-aware floating pill toast
Second theme-aware v1 (after add_modal_shell_v1). Same pill shape as
v0 with a `theme` param:
  - light (default): byte-parity with v0 (dark #111827 pill + white fg)
  - dark: INVERTED contrast — light pill (#F1F5F9) + dark fg (#0F172A)
  - system: $color-text-primary bg + $color-surface fg (inverted swap)

Unlike surface-like v1s (modal-shell), toasts use inverted contrast by
design — a dark pill on light bg, a light pill on dark bg — so the
dark variant flips the pill rather than darkening it.
2026-04-25 01:28:43 +08:00
Fini 91d303d0f0 feat(ai): add_pricing_card_v0 — SaaS pricing tier card (70th tool)
The "Pro $29/month" column for pricing tables. Tier name + big
price (currency + amount + period) + check-mark feature list + CTA
button. Two emphases: default (slate border + slate CTA) and
featured (accent border + accent CTA + auto "Most popular" badge
unless overridden by explicit `badge` value).
2026-04-25 01:24:24 +08:00
Fini e0b926c256 feat(ai): add_social_login_row_v0 — social auth button row (69th tool)
The "Continue with Google / Apple / Microsoft" row. Vertical default
(stacked full-width 48px buttons) or horizontal (compact icon-only
48×48 pills). Known provider names (google, apple, github, microsoft,
facebook, twitter, linkedin, discord, slack, gitlab, email, phone)
auto-map to lucide icons; `icon` param overrides for SSO/SAML/Okta.
2026-04-25 01:19:00 +08:00
Fini 127eccdefb feat(ai): add_stat_card_v0 — big-number KPI tile (68th tool)
Featured-metric dashboard tile: label (uppercase muted) above a
huge 32/700 primary value, with optional tone-colored delta line
and corner icon. Distinct from its neighbors:

  - add_stat_grid_v0   — multi-cell side-by-side, smaller values
  - add_metric_comparison_v0 — horizontal label+value+inline arrow
  - add_stat_card_v0 (this) — single featured metric, whole-card focus

Trend enum tones the delta line only (value stays slate-900):
  up    → #10B981 (emerald)
  down  → #EF4444 (red)
  flat  → #64748B (slate, default)

Wired through all standard points — schema into ext-3 (shortest
shard at 371 lines + new tool → 408, still comfortably under 800)
+ shim + SERVER_BUILDERS + parity CASES + contract allow-list +
elements.md decision tree + triggers + minimal usage.

Handler test covers 6 cases: minimal registration + defaults +
icon+delta+up-tone + down-tone + flat-tone default + width
clamp + bogus parent_id rejection.
2026-04-25 01:09:49 +08:00
Fini 165fb94e44 feat(ai): add_chat_bubble_v0 — messaging bubble (67th tool)
Chat / messaging / customer-support UI message unit. Two variants
via the `side` enum:

  - side="left" (default): from-others bubble. Slate-100 fill,
    slate-900 text, alignItems=flex-start. Optional `author` text
    shown above the bubble (group-chat pattern).
  - side="right": from-self bubble. Accent-color fill (customizable
    via `accent_color`), white text, alignItems=flex-end. Author
    intentionally suppressed on this side — a self-bubble never
    carries "You:".

Optional `timestamp` below the bubble on either side.

Max-width mechanic: pen-core has no native max-width primitive, so
`max_width` becomes the bubble's fixed width (clamped 160..480).
Short messages get extra padding on one side — matches every real
chat client (iMessage / WhatsApp / Slack). Message text uses
`textGrowth: 'fixed-width'` + `width: 'fill_container'` to wrap
correctly inside the fixed-width surface.

Full wiring: pen-core builder + pen-mcp handler + schema (into
ext-2, shorter shard — 24 tools vs ext-1's 24 after this) + shim
+ SERVER_BUILDERS + parity CASES + contract allow-list +
elements.md decision tree + triggers + minimal usage for both
sides. Handler test covers 9 cases: registration, left defaults,
left+author, right with self-dropped-author, right+accent_color,
timestamp both sides, max_width clamps (low + high split into
separate tests to avoid cache-interference), textGrowth wiring,
bogus parent_id rejection.
2026-04-22 22:03:44 +08:00
Fini 49d7d1baf2 feat(ai): add_attachment_row_v0 — file attachment list unit (66th tool)
Fills another common UI gap: the "here's an already-uploaded file"
row you see in email composers, chat attachments, and form upload
summaries. Compact horizontal layout: type-icon + filename (bold) +
optional muted size string + optional right-side × remove affordance.

Structure: horizontal frame (slate-50 bg, cornerRadius=8) with
three children:
  1. attachment-icon — lucide file-* (caller picks: file / file-
     text / file-image / file-video / file-audio / file-archive /
     file-spreadsheet / file-code)
  2. attachment-meta — vertical frame with filename + optional size
  3. attachment-remove — × icon, suppressed via removable=false

Intentionally NOT embedding an upload-progress variant in v0. The
pen-core schema lacks percentage-width primitives, so a %-filled
progress bar would either need a fixed track width (brittle across
parents) or a caller-computed pixel value (awkward API). Callers
who need the uploading state compose `add_progress_bar_v0` directly
below the row — cleaner separation.

Wired through all standard points: schema into ext-1 (balanced
shards 23/23 after upload-dropzone landed there last commit) +
shim + SERVER_BUILDERS + parity CASES + contract allow-list +
elements.md decision tree + triggers + minimal usage.

Handler test covers 7 cases: registration + minimal (no size) +
size rendered + custom icon + removable=false + default icon +
bogus parent_id rejection.
2026-04-22 21:53:47 +08:00
Fini dc73885015 feat(ai): add_otp_input_v0 — verification code input (65th tool)
Fills the auth-flow gap: 2FA / PIN / phone-verification codes.
Horizontal row of N square slots (4..8), one digit per slot.
Renders three states per caller intent:

  - blank (no `digits`): all slots empty, `focused_index` marks
    the currently-typing slot with an accent-color 2px outline
  - partial: first M slots filled with digit text, slot M+1
    focused, rest empty
  - full: all N slots filled (final submittable state)

Filled slots get role=otp-slot-filled + slate-700 border + 20/600
digit text. Focused empty slot gets role=otp-slot-focused +
2px accent border. Blank unfocused slots get role=otp-slot +
1px slate-300 border.

Wired through all standard points — schema into ext-2 (shorter
shard) + shim + SERVER_BUILDERS + parity CASES + contract
allow-list + elements.md decision tree + triggers + minimal
usage for each state.

Handler test covers 8 cases: registration + defaults (6 blank
focused-first) + partial state / full state / length clamp low
(< 4 → 4) + length clamp high (> 8 → 8) + accent color override
+ bogus parent_id rejection.
2026-04-22 21:48:05 +08:00
Fini 45214efb70 feat(ai): add_upload_dropzone_v0 — file drop zone (64th tool)
Fills a real gap in the element-tool family: upload / drag-and-
drop surfaces. Dashed border + cloud icon + two-line instruction
("Drop files to upload" / "or click to browse") — the classic
pattern from every modern file-upload UI.

Deliberately structurally similar to add_empty_chart_v0 (dashed
border + icon + title/subtitle) but semantically distinct:
  - empty_chart = "chart widget will render when data arrives"
    (320×200, icon chart-typed)
  - upload_dropzone = "users drop files here" (480×200, icon
    semantic: upload-cloud / upload / file-up)

elements.md routes them by intent, and the tool descriptions
cross-reference each other to prevent the AI from picking the
wrong one on ambiguous prompts.

Wired through all the standard points per the add-new-tool
checklist: builder + handler + schema (into ext-1, the shorter
shard) + shim + SERVER_BUILDERS + parity CASES + contract
allow-list + elements.md decision tree + keyword triggers +
minimal usage. Handler test covers 5 cases: defaults, dashed
stroke, overrides, size clamping, bogus parent_id rejection.
2026-04-22 21:41:13 +08:00
Fini 0ce113c733 fix(ai): dispatcher imports DSL executor via browser-safe subpath
[Codex P1] The browser-side element-tools-dispatcher imported
runBatchDesignDsl from the \`@zseven-w/pen-mcp\` package barrel.
That barrel re-exports node-only modules — document-manager,
log-utils, theme-presets — which import node:fs / node:path at
top level. Vite / esbuild resolve the barrel BEFORE tree-shaking
can drop those branches, so browser builds failed on unresolved
node built-ins.

Fix:
  - packages/pen-mcp/package.json: add \`./dsl\` subpath export
    pointing at tools/batch-design-dsl.ts — the pure executor
    file already guarded as browser-safe by the adjacent
    regression test.
  - apps/web dispatcher: switch import to
    \`@zseven-w/pen-mcp/dsl\`. No other changes — the re-exported
    symbols (runBatchDesignDsl / OpResult / ImageSearchFetcher /
    RunBatchDesignDslOptions) are identical shape.
  - batch-design-dsl-browser-safe.test.ts: add an assertion that
    package.json's exports field preserves the \`./dsl\` key
    pointing at the expected file. Without this, silently
    removing the subpath would re-introduce the browser-breaking
    resolution path.

The package barrel keeps its current export of runBatchDesignDsl
too (a few internal test files still import from it). Browser
callers should migrate to \`@zseven-w/pen-mcp/dsl\` per the JSDoc
note now in the dispatcher.
2026-04-22 11:15:00 +08:00
Fini dd5d156160 feat(ai): add_modal_shell_v1 — first theme-aware MCP tool (63rd, v1-family debut)
Wires the buildModalShellV1 pen-core builder through the full MCP
toolchain — handler + schema + dispatch + shim + SERVER_BUILDERS
+ parity CASES + handler tests + elements.md skill — so external
MCP clients (Claude Code / Codex / Gemini CLI) can call
add_modal_shell_v1 as a first-class tool alongside the 62 v0
tools.

Three theme variants exposed via `theme` param (enum [light, dark,
system]):
  - omitted / `'light'`: byte-parity with add_modal_shell_v0
    (same hex, same structure, same role tree)
  - `'dark'`: hardcoded dark palette (#1E293B card, #F1F5F9 title,
    #94A3B8 muted). No \$refs needed.
  - `'system'`: emits \$color-surface / \$color-text-primary /
    \$color-text-muted refs. Caller MUST have run
    applySemanticPalette(doc) first or refs resolve to undefined
    (documented in schema description).

Scrim stays #000000 in ALL themes — modal backdrops are a dim
effect, not a themeable surface. Pinned by handler test.

9 handler test cases cover: registration + schema shape +
required[title] + theme variants + scrim invariant + bogus
parent_id rejection. Parity test added a CASES entry with
\`theme:'dark'\` args (exercises the theme branch in both
shim and server paths).

elements.md gained:
  - §46b decision-tree entry pointing to the v1 variant
  - Trigger list entry for dark-mode / theme-aware prompts
  - Minimal usage showing \`theme:'dark'\` + \`theme:'system'\`

This is the reference implementation for the remaining 9 theme-
aware v1 tools in the top-10 offenders list (empty-chart,
chip-input, toast, pagination, notification-row, image-placeholder,
faq-item, comment, checkbox — per dark-theme-audit §offenders).
2026-04-22 11:00:00 +08:00
Fini 315ded51ad chore(ai): generalize element-tool name regex to accept _v\d+
Five regex sites across pen-ai-skills + pen-mcp + apps/web were
anchored at `_v0$`, blocking the _v1 family from being recognized
as element tools:

  - packages/pen-ai-skills/src/corpus/output-parser.ts
    ELEMENT_TOOL_NAME_RE (filters tool_call outputs in A/B
    scorer)
  - apps/web/src/services/ai/design-parser.ts:106 (embedded
    orchestrator dispatch)
  - packages/pen-mcp/src/__tests__/design-prompt-elements.test.ts
    (×2 — stale-integration guard for elements.md)
  - packages/pen-mcp/src/__tests__/element-tool-registry-parity.test.ts
    ("every tool name matches convention" — renamed to _vN)
  - apps/web/src/services/ai/__tests__/element-tools-dispatcher.test.ts
    (drift guard for SUPPORTED_EMBEDDED_ELEMENT_TOOLS)

All now accept /^add_[a-z_]+_v\d+$/. Registry-parity test's
expectedBuilder mapping already handled both v0 (strip suffix →
buildModalShell) and v1+ (preserve → buildModalShellV1) via the
existing `.replace(/_v0$/, '')` — no change there.

Prerequisite for landing add_modal_shell_v1 as a first-class MCP
tool in the next commit.
2026-04-22 10:55:00 +08:00
Fini 42b6492404 chore(tests): fix tsc errors in earlier test files
Two tsc errors surfaced by a later tsc run:

1. browser-image-search-fetcher.test.ts — imported `beforeEach`
   but never used; also `spy.mock.calls[0]` typed as empty tuple
   since vi.fn()'s signature isn't inferred. Cast via `unknown` +
   explicit tuple shape.

2. chart-builders-visual-smoke.test.ts — custom-dimensions case
   passed `width`/`height` to buildChartLine. The real shape is
   `point_spacing` + `chart_height`. Fixed the test to use the
   actual param names (test itself wasn't broken, just the type).
2026-04-22 10:35:00 +08:00
Fini 90d409207c feat(ai): browser-side G() image-search fetcher (relative URL)
Adds makeBrowserImageSearchFetcher() — a browser-safe
ImageSearchFetcher that POSTs to /api/ai/image-search (relative,
same-origin) for inline G() resolution in the batch_design DSL.

The server-side fetcher uses absolute URL via getSyncUrl() — not
applicable in the browser, where fetch resolves relative paths
against window.origin. This helper mirrors the server-side shape
but drops the sync-URL dependency, so any browser caller that
wants inline image-search can opt in.

NOT wired into the default dispatch path. The existing behavior
(applyBatchDesignDsl omits the fetcher → empty src → enriched
asynchronously by scanAndFillImages) stays the default because
per-G() round-trips would blow up latency on batches with many
images. This helper is opt-in for callers that accept that
trade-off (composition smoke tests, user-opt-in preview modes).

Never throws. Returns null on: empty query, network error,
non-ok response, non-JSON body, missing/empty results, invalid
shape. Callers can drop it in without try/catch wrappers.

16 test cases covering happy path (URL/body/headers/first-of-
many) + 11 failure modes + 1 concurrency invariant.
2026-04-22 10:25:00 +08:00
Fini 59e6c09b18 test(ai): orchestrator cancel mid-batch — structural + behavioral (12 cases)
Two-layered coverage for the abort-signal pattern that's the sole
mechanism preventing the orchestrator from continuing to call the
LLM after the user hits Stop.

A. **Structural** (grep-level): pins that every sequential / per-
   screen-group loop in orchestrator-sub-agent.ts AND the "no nodes"
   throw + validation gate in orchestrator.ts check abortSignal at
   the right points. A new loop that forgets the guard silently
   wastes tokens AND mutates canvas post-stop; this grep-style
   check catches it at unit-test time.

B. **Behavioral** (pure): stubbed sequencer that mirrors the
   actual loop body, exercised with 7 scenarios:
     - no abort → all run
     - abort during iteration N → N+1 onwards never run
     - pre-aborted signal → zero execution
     - post-completion abort → no-op
     - no signal → normal behavior
     - concurrent abort during one worker → other workers stop at
       next iteration-start check
     - pre-aborted signal with concurrent workers → both skip

Real orchestrator logic lives in orchestrator-sub-agent.ts but is
wrapped in LLM-calling code that's expensive to mock. The
behavioral stub is pattern-isomorphic: if the stub works here, the
real loop does too; if the real loop changes shape, the structural
check catches the drift.
2026-04-22 10:20:00 +08:00
Fini a22dc6a639 test(ai): model-tier × elements-skill injection e2e (18 cases)
Existing model-profiles-element-tools.test.ts only covers the
upstream flag boolean (\`needsElementTools\`). This file pins the
downstream filter — \`compactSubAgentSkills\` — specifically
around the elements skill, the spot where a regression would
silently drop elements.md content from the sub-agent prompt even
though VITE_ENABLE_ELEMENT_TOOLS=1 is set.

Covers:
  - basic tier: allow-list preserves elements (mobile + non-mobile)
  - basic + reducedComplexity: elements INTENTIONALLY dropped for
    retry path (~17k char savings when fallback to batch_design)
  - standard / full: elements always passes through
  - jsonl-format vs jsonl-format-simplified conflict: simplified
    wins, elements survives both resolutions
  - Screen-type gates (mobile-app vs landing-page/copywriting/
    anti-slop) are orthogonal to elements — elements survives
    every combination
  - Determinism: same input → same output; original array not
    mutated

Also exercises edge cases: empty skill list, elements-only list,
unknown-name skill at each tier.
2026-04-22 10:15:00 +08:00
Fini 4a75b53c45 feat(ai): add_date_picker_v0 — date input closed state (62nd tool)
Adds an N-tool for the labeled date input + calendar-icon trigger.
Emits ONLY the CLOSED state; the open month grid lives in
add_calendar_grid_v0 and is typically shown inside a popover,
not stacked directly below. Two visual states:

- placeholder (no value): slate-400 "Select date" + calendar icon
- populated (value): slate-900 date text + calendar icon

`clearable: true` adds a small X affordance to the right of the
value (only when value is present — no-op for the placeholder
state since there's nothing to clear). `required: true` appends
" *" to the label.

Keeping closed + open as separate tools is intentional: AI specs
often ask for only the closed trigger inside a form, and a single
combined tool would either force an unwanted grid or require a
mode flag that splits the parameter surface. Separate narrow tools
compose cleanly via batch_design when a designer DOES want both.

Wired through all 3 paths + parity/contract/design-prompt tests.
Handler test covers 7 cases: placeholder state fills, populated
value fills, clearable X behaviors (both present + absent value),
custom placeholder override, required marker, bogus parent_id
rejection.
2026-04-22 09:45:00 +08:00
Fini 1b4f362d59 feat(ai): add_action_menu_v0 — context/kebab dropdown panel (61st tool)
Adds an N-tool for the floating card that drops from a "⋯ more"
button or appears on right-click. Emits the OPEN state: vertical
stack of padded icon+label rows in a white card with subtle stroke
and shadow. Positioning and show/hide are caller concerns (same
philosophy as add_modal_shell_v0 / add_toast_v0).

Destructive items (destructive=true) render in red with role
`action-menu-item-destructive` so renderers can style the hover
state separately. divider_before=true on any item (except first,
where it's ignored) inserts a 1px hairline above — useful for
"Edit / Share / Report / Delete" grouping patterns.

Wired through all 3 paths + parity/contract/design-prompt tests.
Handler test covers 7 cases: simple list, destructive red fill,
divider between groups, leading-divider ignored, label-only no
icon, width clamp, bogus parent_id rejection.
2026-04-22 09:40:00 +08:00
Fini 97343c1d74 feat(ai): add_empty_chart_v0 — chart-slot empty state (60th tool)
Adds an N-tool for the "no data yet" tile that sits in the exact
footprint where a real chart would go. Default 320×200 matches the
line/bar chart default footprint; dashed border + slate-50 fill +
slate icon signal "chart slot, currently empty". Caller can hint
at the widget type via icon ("line-chart" / "pie-chart" / default
"bar-chart-2").

Intentionally separate from add_empty_state_v0 — that tool is for
inbox/onboarding/no-results full-page empties (has optional CTA,
no dashed border). add_empty_chart_v0 reads as "chart widget is
live, just lacks data yet" rather than "nothing to show on this
screen at all".

Wired through all 3 paths + parity/contract/design-prompt tests.
Handler test covers 6 cases: defaults + dashed stroke + icon
override + size clamping + title/subtitle override + bogus
parent_id rejection.
2026-04-22 09:35:00 +08:00
Fini 6102d54cd9 feat(ai): add_chip_input_v0 — tag / multi-select input (59th tool)
Adds an N-tool for the "variable-N pill-plus-cursor" pattern: a
labeled form control that holds N removable tag pills followed by
an inline placeholder caret. Wrap layout (layoutWrap=wrap) so
chips flow onto additional rows as they accumulate — a horizontal
fit_content row would clip after ~4 chips.

Each chip: pill (cornerRadius=16, slate-100 fill, padding 10/4/6/6
L/R/T/B) + label + 14×14 lucide "x". Default caret placeholder is
"Add tag…" when chips is empty; caller overrides with `placeholder`
(e.g. "Enter emails" for recipient lists).

Wired through all 3 paths with matching handler test (7 cases) +
parity tests auto-picking-up the entry. elements.md: decision tree
§54, trigger list, minimal usage (populated + empty).
2026-04-22 09:30:00 +08:00
Fini e948a081d2 feat(ai): add_faq_item_v0 — accordion/FAQ item (58th tool)
Adds an N-tool for one row in a FAQ list. Collapsed (default):
bold question + chevron-right header. Expanded (expanded=true):
chevron-down + multi-line answer paragraph beneath. Optional
show_divider draws a 1px slate-200 rectangle at the bottom for
visual separation between items (no implicit padding — caller
stacks in a vertical parent).

Wired through all 3 paths (pen-core buildFaqItem + pen-mcp handler
+ apps/web shim + Nitro SERVER_BUILDERS); both parity tests and
the stale-integration guard auto-pick-up the entry. Handler test
covers 5 cases: collapsed default, expanded with answer, expanded
without answer (guards against undefined), show_divider hairline,
bogus parent_id rejection.
2026-04-22 09:25:00 +08:00
Fini fa69986fc8 feat(ai): add_pagination_v0 — Google-style pagination bar (57th tool)
Adds an N-tool for list/table footer pagination: row of page-
number pills flanked by optional prev/next chevron buttons. Active
page renders filled with the accent color, inactive pages are
ghost. Long ranges collapse with "…" Google-style (always show 1
and total, plus a ±siblings window around current).

Wired through all 3 paths: pen-core buildPagination + pen-mcp
handler + apps/web browser shim + Nitro SERVER_BUILDERS. Both
parity tests (shim-server-parity, element-tool-registry-parity)
pick up the entry automatically. Handler test covers 7 cases:
small range no ellipsis, 10-page ellipsis, start-edge current,
accent override, show_arrows=false, total=1 single pill, bogus
parent_id rejection.

Also updates packages/pen-ai-skills/skills/phases/generation/
elements.md: decision tree §52, keyword triggers (pagination /
page nav / 分页 / 分页条), minimal usage example. The stale-
integration guard (design-prompt-elements.test.ts) now passes.
2026-04-22 09:20:00 +08:00