`/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).
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 / 侧边栏".
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
`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.
`/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).
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).
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).
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).
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.
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.
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.
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).
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.
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.
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.
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.
[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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
Dark slate (#334155) box + centered white play icon + optional
caption. Default 320×180 for 16:9. The "future video embed"
affordance — semantically distinct from add_image_placeholder_v0:
dark bg + play icon reads as "play me later", not "picture coming".
Play affordance is a lucide `play` icon_font, NEVER a hand-drawn
path triangle (classic LLM anti-pattern for video placeholders).
Regression test locks that invariant.
Wired across all three paths + elements.md entry (44b) + keyword
map + example + parametric test CASES in 8 files. Handler test
has 6 assertions including the path-vs-icon_font anti-pattern
guard.
Tool count: 55 → 56. Test count: 3225 → 3241 (+16).
- add_metric_comparison_v0: KPI cell with trend. label above + big
value + optional arrow icon + change amount. trend enum (up/down/
flat) drives arrow icon (trending-up/-down/minus) + color (emerald/
red/slate). Distinct from add_metric_row_v0 (scroll row of label+
value cells without trend affordance). Required: label + value.
- add_notification_row_v0: leading icon + (title + optional
timestamp + optional unread red dot) header + optional body
preview. Distinct from add_list_row_v0 which has no timestamp
or unread affordance. Required: title only.
Both wired across all three paths + elements.md ("Analytics / KPIs"
and "Notifications" sections) + keyword map + examples + 2 corpus
prompts in ab-v1/ (dashboard-revenue-trend, mobile-notification-item).
Fixed in same turn: ab-v1 file that was created with the wrong
filename got renamed.
Tool count: 53 → 55. Test count: 3180 → 3225 (+45).
- add_spinner_v0: static loading spinner — full ring (track) + 270°
arc (active). Sits at size=32 default, clamped 16..128. Two
ellipses at SAME origin with DIFFERENT sweep ranges — NOT the
"stacked ellipses for ring" anti-pattern (rewriteLlmAntiPatterns
only fires when both are full-sweep duplicates).
- add_tooltip_v0: small dark pill (#111827) + white text for
hover hints. position param ("top"/"bottom"/"left"/"right")
encodes a role hint (`tooltip-top` etc.) for downstream position
logic; visual body is identical. NO arrow pointer (pen-core has
no clean triangle primitive — caller composes via batch_design
rectangle + rotate if needed).
Wired across all three paths. elements.md adds "Feedback / loading"
section (#48, #49). ab-v1 corpus +1 prompt (mobile-help-tooltip);
spinner omitted from corpus for now — the prompt wording is too
ambiguous for "obvious" difficulty.
Tool count: 51 → 53. Test count: 3138 → 3180 (+42).
Small colored dot + short label: "● Online" / "● Busy" / "● Error"
pattern. Distinguished from the more general add_badge_v0 (just a
pill label) by always having a dot.
tone enum picks dot color:
- success → emerald #10B981
- warning → amber #F59E0B
- error → red #EF4444
- info → blue #3B82F6
- neutral → slate #94A3B8 (default)
Dot uses `frame + cornerRadius=4`, NEVER `ellipse` — an 8×8 ellipse
is the classic "status dot via stacked ellipses" anti-pattern bait.
Keeping it a frame stays clean of rewriteLlmAntiPatterns. Regression
test locked in pen-mcp/add-status-badge-v0.test.ts.
Wired across all three paths + elements.md entry + keyword map +
examples + ab-v1/dashboard-server-status.yaml corpus prompt +
parametric builder test CASES in 7 files.
Tool count: 50 → 51. Test count: 3114 → 3138 (+24).
Fills 3 common UI gaps the 47-tool set didn't cover:
- add_image_placeholder_v0: gray box + centered icon + optional
caption. The "future image slot" affordance. Separate from G()
(which fetches real images). Emits frame+fill, NEVER image node
(empty image renders as broken indicator).
- add_comment_v0: avatar + (author + timestamp) header + body.
Social / UGC / feedback unit. Does NOT handle replies / likes /
action menu — compose via batch_design.
- add_modal_shell_v0: dimmed scrim + centered card (rounded,
shadowed) + title + optional subtitle. "Shell" in the name is
deliberate — this is chrome only; body content goes into the
`modal-shell-card` role via a follow-up insert.
Wired across all three paths (pen-core buildX + pen-mcp handler +
routes + schema + apps/web shim + Nitro SERVER_BUILDERS) + elements.md
decision-tree entries + keyword map + examples + 3 pen-mcp handler
tests (22 cases total) + 3 A/B v1 corpus prompts in ab-v1/ +
parametric builder test CASES auto-extended in 9 files.
Milestones:
- Tool count: 47 → 50
- Test count: 3045 → 3114 (+69)
- A/B v1 corpus: 5 → 8 prompts
Defensive gate: the browser DSL executor clones the doc, mutates
it across all ops, then applies the final doc back in ONE call.
A regression that applied per-op would thrash React + history
state for no benefit (the surrounding startBatch already wraps it
into one undo entry anyway). Spy on `applyExternalDocument` and
assert it's called exactly once per 4-op batch.
Pairs with the #44 commit (browser-safe DSL executor). Every test
stubs fetch to reject so any regression falling back to HTTP fails
loudly.
Coverage:
- Single I() at root → frame inserted, no HTTP
- Binding chain: root + nested child land in correct parent
- U() update applied: properties merged on bound node
- 6-op realistic screen (nav + cards + divider): order preserved
- Multi-op batch → exactly ONE undo entry (dispatcher's
startBatch/endBatch wrap survives the browser path)
- Malformed op in the middle: per-line errors surfaced as
status=failed (not opaque HTTP 500)
- Empty DSL: zero ops, applied + no insertions
- G() without fetcher: image node inserted with empty src (the
apps/web scanAndFillImages pipeline enriches later)
Complements the 5 static browser-safety checks in pen-mcp
(`batch-design-dsl-browser-safe.test.ts`) — that file gates the
import tree, this one gates the runtime behavior.
Total: 3033 → 3041 passing.
Extract ~600 lines of pure DSL logic from pen-mcp/tools/batch-design.ts
into a sibling batch-design-dsl.ts that does not import document-manager
(node:fs) or hooks (server-injected). `handleBatchDesign` becomes a
thin server-side wrapper that opens/saves the .op file around the pure
executor. Backward-compat: pen-mcp barrel + batch-design.ts both
re-export `runBatchDesignDsl` so existing callers keep working.
apps/web dispatcher changes:
- `applyBatchDesignDsl` now runs `runBatchDesignDsl` DIRECTLY in the
browser against useDocumentStore.getState().document (structuredClone
+ apply via applyExternalDocument).
- HTTP `/api/mcp/exec-tool` fallback fires only when the in-browser
executor throws (rare — caller error or future regression).
- Removes per-tag HTTP latency on the common batch_design path.
- ctx.defaultParentId is intentionally NOT applied here: the DSL is
the AI's verbatim instruction set and rewriting `null` parents
would change author intent. Element-tool calls still honor it.
Image search (`G()` op) is swapped from `getSyncUrl`-based absolute
URL (server) to an injectable `ImageSearchFetcher` callback. Server
wrapper keeps the old behavior; browser path omits the fetcher so
`src` stays empty for the apps/web image pipeline (scanAndFillImages)
to enrich later.
Tests:
- New browser-safety gate `batch-design-dsl-browser-safe.test.ts`:
walks the transitive import graph from batch-design-dsl.ts and
fails if any reachable file imports node:fs / node:os / node:path
/ document-manager / hooks. 5 checks including a negative control
on batch-design.ts (wrapper) to confirm the split is meaningful.
- Dispatcher tests updated to match new behavior: happy path does
NOT hit fetch; malformed DSL returns `status=failed` with per-op
error surfaced (via pure executor's `errors[]`), not an opaque
HTTP 500.
- Total: 3028 → 3033 passing.
chart_line: polyline through N data points (normalized to max),
optional dots at each vertex. Emits a `path` node with computed SVG
`d`="M x y L x y …" + N `ellipse` dots. fit_content width = values
× point_spacing.
chart_pie: N colored slices via ellipse `startAngle`/`sweepAngle`
arc support. NOT the "stacked full ellipses" anti-pattern — each
slice has a UNIQUE sweep range (sums to 360°). Supports donut cut-
out via `inner_radius_ratio`. Default 6-color palette rotates.
All-zero input throws (degenerate chart can't be drawn).
Wired across all three paths + elements.md decision-tree entries +
keyword map + examples. Both use layout=none (absolute positioning
for vertices / slices stacked at origin).
Tests:
- pen-mcp: 8 cases chart_line + 8 cases chart_pie (height math,
clamp bounds, custom colors, donut, error paths, id uniqueness)
- Parametric coverage auto-extended (+2 cases each in 9 files)
- Total delta: 2979 → 3027 passing (+48).
Closes#45 + #46; covers part of #50 (charts entries).
Dropdown/picker closed-state display. Same label-above-input shape
as add_form_field_v0 with:
- always-present trailing chevron-down icon
- when `value` is set: black value text + chevron
- when absent: placeholder text styled gray (#94A3B8) + chevron
- justifyContent=space_between pushes the chevron to the right edge
Explicitly NOT modeled: open-menu state (dropdown list). An open
dropdown needs absolute positioning + scrim + per-option states
that belong in a different builder; compose via batch_design for now.
Wired across all three paths + elements.md keyword map + examples.
Tests:
- pen-mcp: 7 new cases (value/placeholder rendering, custom
trailing_icon, required suffix, id uniqueness, parent_id rollback)
- Parametric coverage: auto-extended (+1 case each in 9 files).
Total delta: 2955 → 2979 passing.
Closes#48; covers part of #50 (select entry).
Same label-above-input shape as add_form_field_v0 but the input
grows vertically by `rows` (default 4, clamped 2..12) for notes /
bio / feedback use cases. Input frame layout is vertical with
placeholder top-aligned, matching native iOS/Material behavior.
Wired across all three paths:
- pen-core: `buildTextarea` + TextareaParams in element-builders
- pen-mcp: `handleAddTextareaV0` + tool schema in element-tool-defs-ext
- apps/web: shim + Nitro SERVER_BUILDERS entry
- elements.md: PREFER list + keyword map + 2 example lines
Tests:
- pen-mcp: 8 new cases in add-textarea-v0.test.ts (height math,
rows clamp, required suffix, placeholder wiring, id uniqueness,
parent_id rollback)
- Parametric builder tests auto-extended (+1 case each in 9 files):
layout smoke, post-process idempotency, normalize preservation,
performance, role coverage, anti-patterns clean, detectors
clean, shim-server parity. Total delta: 2907 → 2931 passing.
Closes#47; covers part of #50 (textarea entry).
Simulates realistic "full screen design" orchestrator turns where
one dispatchElementToolCalls call handles 40-60 tools at once.
Coverage:
- 40-tool batch: <500ms end-to-end, 1 undo entry, 40 children, all
ids unique.
- 60-tool batch (upper bound): <800ms, all land, no id collisions.
- 3 × 20 consecutive batches: 60 total children, 3 undo entries,
global id uniqueness preserved across rounds.
- Promise.all of 3 parallel dispatches into 3 distinct roots: each
completes independently, full document id set remains unique.
The mixed pattern (heading + body + list-row + divider + stat-grid)
exercises multi-level tree inserts, not just flat heading stacks —
which is closer to what real AI orchestration emits.
Why the latency budget: AI thinking dominates generation time
(5-30s typical). The dispatch pipeline shouldn't be the bottleneck;
500ms for 40 tools = ~12ms per tool including store round-trip,
which is reasonable. A regression past this threshold signals
quadratic behavior somewhere in the pipeline.
Guards against builders hardcoding icon names that don't resolve at
runtime — a regression would render as an empty glyph or fallback
circle on canvas, silent-but-broken.
Three layers:
1. Per-builder (17 tests): collect every icon_font iconFontName
from default output, assert lookupIconByName resolves each. Fail
message names the specific builder + unresolved slugs.
2. Aggregate (2 tests): full cross-builder icon vocabulary resolves
at runtime; icon set is non-trivial (≥10 distinct icons).
3. Invariants (2 tests): text-only builders emit zero icons;
every icon_font node has iconFontFamily='lucide' (prevents a
regression to a font-family the renderer doesn't bundle).
Note: uses lookupIconByName (not AVAILABLE_LUCIDE_ICONS directly)
because the dictionary has prefix/substring fallbacks that resolve
common names ("home", "more-vertical") even when the literal slug
isn't in the exported list. The lookup is the authoritative runtime
resolver, so matching its behavior is correct.
Bilateral drift guard across three registries + two executable paths:
Registries:
- ELEMENT_SHIMS (client shim, apps/web)
- SUPPORTED_EMBEDDED_ELEMENT_TOOLS (canonical exported list)
- ELEMENT_TOOL_NAMES (pen-mcp source of truth for all 42 tools)
Executable paths:
- A: client shim → pen-core buildX
- B: server /api/mcp/exec-tool → pen-core buildX
Since both paths delegate to the SAME pen-core buildX, the structural
parity is transitive: if shim output matches direct buildX output,
server output matches too.
Tests:
- CASES (42 fixtures) covers every ELEMENT_SHIMS key — refactor adding
a new shim without a test row fails here.
- CASES covers SUPPORTED_EMBEDDED_ELEMENT_TOOLS — same guarantee at
the exported constant.
- SUPPORTED_EMBEDDED_ELEMENT_TOOLS ⊆ ELEMENT_TOOL_NAMES — shim must
only expose tools that pen-mcp actually defines.
- No duplicate keys in ELEMENT_SHIMS.
- For each tool: stripIds(shim(args).node) === stripIds(buildX(args)).
The shim is a pure delegation layer plus id stamping.
- Meta-param extraction: parent_id / pageId / filePath are split out
BEFORE the builder sees them (no spurious field leak into the node).
If any of these diverge in future refactors, the failure points
directly at the broken registry or transformation.
Verifies the user-visible history contract the dispatcher owes:
1. One batch dispatch = exactly ONE undo entry (even for 8 tools).
No Ctrl-Z spam to reverse one AI turn.
2. Separate dispatches = separate undo entries. The batch window
closes at endBatch; subsequent dispatches don't piggyback.
3. Undo reverts the whole batch atomically (all tools snap back).
4. Undo → redo restores the whole batch atomically.
5. All-unsupported batch creates ZERO undo entries — endBatch's
"no changes" short-circuit prevents ghost undos that would jump
the UI between identical states.
6. Mixed valid+unsupported batch → 1 undo entry for the valid ones.
Complements element-tools-dispatcher.test.ts (which spies on
startBatch/endBatch) by exercising the actual history-store round-
trip and asserting stack length deltas.
Full matrix of parse → dispatch → builder preservation for
basic / standard / full tier resolutions. The wire format
(`<op_tool>{...}</op_tool>`) is tier-independent TODAY; this test
anchors that as a hard contract so a future "tier-specific argument
escape" surfaces immediately.
Coverage:
- Tier gating sanity (3 model ids → expected tier)
- ASCII content (3 tiers)
- CJK + mixed scripts + RTL (15 tests: 3 tiers × 5 fixtures)
- Emoji deliberately stripped (anchors applyNoEmojiIconHeuristic
behavior — emojis become icon_font nodes, not embedded text)
- Embedded quotes, backslashes, newlines, tabs, unicode punctuation
(18 tests: 3 tiers × 6 tricky fixtures)
- Large number arrays + nested item objects + timeline shape
(9 tests: 3 tiers × 3 tool shapes)
- Multi-tag batch: 5 tools with varied shapes all parse + match
original args (1 test)
Notable findings:
- gpt-4o-mini resolves to "standard" (matches 'gpt-4o' rule first),
so claude-haiku is the stable basic-tier fixture id
- The pipeline scrubs emojis AND collapses 2+ whitespace chars;
this is intentional (applyNoEmojiIconHeuristic), now anchored
- Unicode punctuation (em-dash, ellipsis, curly quotes) passes
through untouched — the emoji regex is conservative
44 tests (42 builders × clean assertion + 2 sanity/negative anchors).
For every builder output:
- run detectAllIssues (invisible-container + empty-path +
text-explicit-height + sibling-inconsistency detectors)
- filter to severity !== 'info' (info is detect-only, skipped
by the auto-fix pipeline, not a regression signal)
- fail with a per-issue summary if any fire
Plus 2 negative-case sanity checks so passing tests can't mask
broken detectors:
- text with explicit pixel height → height detector fires
- same-fill-as-parent container → invisible-container detector fires
The test lives in apps/web/__tests__ (not pen-ai-skills/__tests__)
because pen-ai-skills doesn't depend on pen-core — apps/web is the
first place both are available.