openpencil/packages/pen-core/src/element-builders
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
..
action-menu.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
activity-log.ts fix(pen-core): reject invalid enum values in 5 more element builders 2026-04-29 09:49:57 +08:00
activity-ring.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
alert.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
attachment-row.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
avatar-group.ts feat(ai): add_avatar_group_v0 — stacked presence tile group (78th tool) 2026-04-27 08:30:00 +08:00
avatar.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
badge.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
body-text.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
bottom-nav.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
breadcrumb.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
calendar-grid.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
callout.ts fix(pen-core): reject invalid enum values in 5 more element builders 2026-04-29 09:49:57 +08:00
card-row.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
carousel-dots.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
chart-bars.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
chart-line.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
chart-pie.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
chat-bubble.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
checkbox.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
chip-input.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
cjk-detect.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
code-block.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
color-swatch.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
combobox.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
comment.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
cookie-banner.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
data-table-row.ts fix(ai): data-table cells clip overflow so long content stays in column 2026-04-27 08:50:00 +08:00
date-picker.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
divider.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
drawer-shell.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
empty-chart-v1.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
empty-chart.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
empty-state.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
event-card.ts feat(ai): ship 5 element tools to reach 97 (filter_group / invite_row / activity_log / event_card / step_card) 2026-04-28 08:50:00 +08:00
fab.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
faq-item.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
filter-group.ts feat(ai): ship 5 element tools to reach 97 (filter_group / invite_row / activity_log / event_card / step_card) 2026-04-28 08:50:00 +08:00
form-field.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
heading.ts fix(pen-core): reject invalid level in buildHeading with a clear error 2026-04-29 09:49:54 +08:00
helpers.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
icon-button.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
icon-label.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
image-placeholder.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
inbox-message.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
index.ts feat(ai): ship 5 element tools to reach 97 (filter_group / invite_row / activity_log / event_card / step_card) 2026-04-28 08:50:00 +08:00
inline-action.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
input-with-action.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
invite-row.ts fix(pen-core): reject invalid enum values in 5 more element builders 2026-04-29 09:49:57 +08:00
kbd.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
legend-item.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
link.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
list-row.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
member-row.ts fix(pen-core): reject invalid enum values in 5 more element builders 2026-04-29 09:49:57 +08:00
metric-comparison.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
metric-row.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
modal-shell-v1.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
modal-shell.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
nav-chip-row.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
notification-row.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
otp-input.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
pagination.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
phone-input.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
price.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
pricing-card.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
profile-header.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
progress-bar.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
quote-block.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
radio.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
range-slider.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
rating-stars.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
README.md Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
search-bar.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
section-header.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
segmented-control.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
select.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
setting-row.ts feat(ai): add_setting_row_v0 — settings menu row (91st tool) 2026-04-28 06:30:00 +08:00
share-row.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
sidebar-nav.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
skeleton.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
social-login-row.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
spinner.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
stat-card.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
stat-grid.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
status-badge.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
step-card.ts fix(ai): step-card marker clips overflow + tighten doc to short index 2026-04-28 08:55:00 +08:00
stepper.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
switch.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
tabs.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
tag.ts fix(pen-core): reject invalid enum values in 5 more element builders 2026-04-29 09:49:57 +08:00
text-button.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
textarea.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
timeline.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
toast-v1.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
toast.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
toolbar.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
tooltip.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
top-nav-bar.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00
upload-dropzone.ts chore(merge): integrate origin/v0.8.0 — main pre-release sync + CI fixes 2026-04-27 08:15:00 +08:00
user-card.ts feat(ai): ship 10 element tools to reach 90 (user_card / drawer / combobox / toolbar / callout / share / inline_action / legend_item / inbox / profile_header) 2026-04-27 09:05:00 +08:00
video-placeholder.ts Merge branch 'main' of github.com:ZSeven-W/openpencil into v0.8.0 2026-04-26 19:39:14 +08:00

element-builders

Pure tree-build functions (one per N-tool add_X_v0 MCP tool) that produce PenNode subtrees from typed parameters. Shared between pen-mcp handlers (external MCP clients like Claude Code / Codex / Gemini CLI) and the browser-side client shim in apps/web/src/services/ai/element-tool-shims/.

Why a shared module

The N-tool system has three executable paths that all need to produce identical trees for the same args:

 ┌────────────────────┐       ┌────────────────────┐       ┌────────────────────┐
 │  pen-mcp handler   │       │ apps/web shim      │       │ Nitro server bld   │
 │  (external clients │       │ (browser-side      │       │ (/api/mcp/exec-    │
 │   via stdio/HTTP)  │       │  client shim)      │       │  tool HTTP fallbk) │
 └─────────┬──────────┘       └─────────┬──────────┘       └─────────┬──────────┘
           │                            │                            │
           ▼                            ▼                            ▼
           ┌──────────────────────────────────────────────────────────┐
           │              @zseven-w/pen-core/element-builders         │
           │  (buildHeading, buildCardRow, buildTopNavBar, …, 50×)    │
           └──────────────────────────────────────────────────────────┘

If all three paths import the same buildX here, the tree is drift-free by construction — no registry can silently emit a different shape.

Module layout

  • index.ts — barrel; every new builder must be re-exported here
  • helpers.ts — assignIdsRecursively, buildScrollWrapper, ElementTree
  • cjk-detect.ts — detectCjkScript, cjkFontFamily (Noto Sans SC/JP/KR dispatch)
  • <name>.ts — one file per tool (50 today, as of 2026-04-22), each exporting build<Name> + its params type

What a builder is

A pure function that takes typed params and returns an ElementTree:

import type { ElementTree } from './helpers.js';

export interface MyThingParams {
  label: string;
  icon?: string;
}

export function buildMyThing(params: MyThingParams): ElementTree {
  return {
    type: 'frame',
    name: 'My Thing',
    role: 'my-thing',
    width: 'fill_container',
    height: 'fit_content',
    layout: 'horizontal',
    // …
    children: [
      // …
    ],
  };
}

Rules:

  • Browser-safe: no node:fs, no I/O, no import.meta.env guards, no async. Builders are synchronous value producers.
  • No id stamping: ids are stamped by assignIdsRecursively AFTER construction (callers own that step).
  • No parent wiring: parent_id / pageId / filePath are meta params stripped before the builder is called.
  • Return shape is intentionally Record<string, unknown> (loose) — the downstream insert pipeline validates.

Conventions crystallized from 42 existing builders

  • Layout sizing: frame containers use width: 'fill_container' + height: 'fit_content' as the default. Atoms that carry concrete dimensions (avatars, rings) set numeric sizes.
  • Icons are icon_font nodes: type: 'icon_font' + iconFontFamily: 'lucide' + iconFontName: '<slug>'. Never use path for icons in builder output.
  • Text nodes: never set explicit height. Let text grow; use fontSize/lineHeight/letterSpacing to control typography.
  • Roles: every top-level node sets role: '<kebab-name>'. Sub-nodes may set scoped roles (list-row-text, card, stat-cell). The role string drives downstream post-processing (role-resolver, contrast pass, layout inference).
  • CJK dispatch: only buildHeading dispatches fontFamily per script (Noto Sans SC/JP/KR). Body text always fontFamily: 'Inter'. Other builders don't dispatch CJK — children inherit the renderer default.
  • Text-button / form-input: width: 'fill_container', height 48, cornerRadius 8, padding [12, 16] or [12, 24] — matches the Pencil-demo contract.
  • Icon-only buttons: 44×44 (Apple HIG / Material min-hit-target), flex-centered icon_font. Never layout: 'none' with manual x/y.
  • Ring / circle with content: use frame with cornerRadius: width/2. Never two stacked ellipses — that anti-pattern trips rewriteLlmAntiPatterns.
  • Divider: rectangle with height: 1 and fill_container. Never a one-sided stroke on a frame (renderer only supports uniform or [T,R,B,L] stroke thicknesses).

Adding a new builder

  1. Create packages/pen-core/src/element-builders/<name>.ts exporting build<Name>(params) + <Name>Params
  2. Re-export from packages/pen-core/src/element-builders/index.ts
  3. Re-export from packages/pen-core/src/index.ts (main barrel) — only matters if apps/web or pen-mcp import it via @zseven-w/pen-core directly
  4. Wire pen-mcp handler: packages/pen-mcp/src/tools/add-<name>-v0.ts imports build<Name> and delegates
  5. Register the pen-mcp handler in packages/pen-mcp/src/routes/element-tool-defs.ts (add import + switch branch + tool schema in -base.ts / -ext.ts)
  6. Wire apps/web shim: add to ELEMENT_SHIMS in apps/web/src/services/ai/element-tool-shims/index.ts
  7. Wire server builder: add to SERVER_BUILDERS in apps/web/server/api/mcp/exec-tool.post.ts
  8. Add to elements.md: packages/pen-ai-skills/skills/phases/generation/elements.md — add the tool to the PREFER list + examples section
  9. Tests (all of these should pass automatically if the builder follows conventions):
    • Layout smoke in packages/pen-core/src/__tests__/element-builders-layout.test.ts
    • Idempotency in element-builders-post-process-idempotent.test.ts
    • Role coverage in apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts
    • Parity in shim-server-parity.test.ts + element-tool-registry-parity.test.ts
    • If the builder emits an icon_font, check builder-icon-coverage.test.ts covers it

Parity tests fail-fast on any skipped step — the test names tell you which registry or handler is missing.

Drift guards in the test suite

Tests to run when touching this module (bun run test at repo root runs all):

Test What it catches
element-builders-layout.test.ts computeLayoutPositions throws on your new builder
element-builders-composition.test.ts Builder doesn't compose with others in a screen frame
element-builders-post-process-idempotent.test.ts A post-pass mutates output twice
element-builders-edge-cases.test.ts Extreme inputs (empty, huge, boundary) crash
element-builders-cjk-dispatch.test.ts CJK font dispatch regresses
element-builders-normalize-preservation.test.ts normalizePenDocument drops a semantic field
element-builders-performance.test.ts New builder adds >5ms avg or >50ms cold
role-resolver-builder-coverage.test.ts (apps/web) Role string doesn't match role-definitions set or typo
detectors-builder-clean.test.ts (apps/web) Builder trips a pre-validation detector
anti-patterns-builder-clean.test.ts (apps/web) Builder produces an anti-pattern (stacked ellipses, open path + fill)
shim-server-parity.test.ts (apps/web) Shim and direct-buildX output diverge
builder-icon-coverage.test.ts (apps/web) Builder emits an icon name that doesn't resolve
element-tool-registry-parity.test.ts (pen-mcp) Registry + handler file + dispatcher switch drift
  • packages/pen-core/CLAUDE.md — broader pen-core module map
  • packages/pen-ai-skills/skills/phases/generation/elements.md — prompt-level spec of each tool, loaded by the AI
  • apps/web/src/services/ai/element-tool-shims/index.ts — client shim registry
  • apps/web/src/services/ai/element-tools-dispatcher.ts — routes parsed <op_tool> → shim → insert
  • apps/web/server/api/mcp/exec-tool.post.ts — Nitro HTTP fallback (same builder catalog, for cases the client shim doesn't support)
  • Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md