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`. |
||
|---|---|---|
| .. | ||
| action-menu.ts | ||
| activity-log.ts | ||
| activity-ring.ts | ||
| alert.ts | ||
| attachment-row.ts | ||
| avatar-group.ts | ||
| avatar.ts | ||
| badge.ts | ||
| body-text.ts | ||
| bottom-nav.ts | ||
| breadcrumb.ts | ||
| calendar-grid.ts | ||
| callout.ts | ||
| card-row.ts | ||
| carousel-dots.ts | ||
| chart-bars.ts | ||
| chart-line.ts | ||
| chart-pie.ts | ||
| chat-bubble.ts | ||
| checkbox.ts | ||
| chip-input.ts | ||
| cjk-detect.ts | ||
| code-block.ts | ||
| color-swatch.ts | ||
| combobox.ts | ||
| comment.ts | ||
| cookie-banner.ts | ||
| data-table-row.ts | ||
| date-picker.ts | ||
| divider.ts | ||
| drawer-shell.ts | ||
| empty-chart-v1.ts | ||
| empty-chart.ts | ||
| empty-state.ts | ||
| event-card.ts | ||
| fab.ts | ||
| faq-item.ts | ||
| filter-group.ts | ||
| form-field.ts | ||
| heading.ts | ||
| helpers.ts | ||
| icon-button.ts | ||
| icon-label.ts | ||
| image-placeholder.ts | ||
| inbox-message.ts | ||
| index.ts | ||
| inline-action.ts | ||
| input-with-action.ts | ||
| invite-row.ts | ||
| kbd.ts | ||
| legend-item.ts | ||
| link.ts | ||
| list-row.ts | ||
| member-row.ts | ||
| metric-comparison.ts | ||
| metric-row.ts | ||
| modal-shell-v1.ts | ||
| modal-shell.ts | ||
| nav-chip-row.ts | ||
| notification-row.ts | ||
| otp-input.ts | ||
| pagination.ts | ||
| phone-input.ts | ||
| price.ts | ||
| pricing-card.ts | ||
| profile-header.ts | ||
| progress-bar.ts | ||
| quote-block.ts | ||
| radio.ts | ||
| range-slider.ts | ||
| rating-stars.ts | ||
| README.md | ||
| search-bar.ts | ||
| section-header.ts | ||
| segmented-control.ts | ||
| select.ts | ||
| setting-row.ts | ||
| share-row.ts | ||
| sidebar-nav.ts | ||
| skeleton.ts | ||
| social-login-row.ts | ||
| spinner.ts | ||
| stat-card.ts | ||
| stat-grid.ts | ||
| status-badge.ts | ||
| step-card.ts | ||
| stepper.ts | ||
| switch.ts | ||
| tabs.ts | ||
| tag.ts | ||
| text-button.ts | ||
| textarea.ts | ||
| timeline.ts | ||
| toast-v1.ts | ||
| toast.ts | ||
| toolbar.ts | ||
| tooltip.ts | ||
| top-nav-bar.ts | ||
| upload-dropzone.ts | ||
| user-card.ts | ||
| video-placeholder.ts | ||
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 herehelpers.ts—assignIdsRecursively,buildScrollWrapper,ElementTreecjk-detect.ts—detectCjkScript,cjkFontFamily(Noto Sans SC/JP/KR dispatch)<name>.ts— one file per tool (50 today, as of 2026-04-22), each exportingbuild<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, noimport.meta.envguards, no async. Builders are synchronous value producers. - No id stamping: ids are stamped by
assignIdsRecursivelyAFTER construction (callers own that step). - No parent wiring:
parent_id/pageId/filePathare 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_fontnodes:type: 'icon_font'+iconFontFamily: 'lucide'+iconFontName: '<slug>'. Never usepathfor icons in builder output. - Text nodes: never set explicit
height. Let text grow; usefontSize/lineHeight/letterSpacingto 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
buildHeadingdispatches fontFamily per script (Noto Sans SC/JP/KR). Body text alwaysfontFamily: '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. Neverlayout: 'none'with manual x/y. - Ring / circle with content: use
framewithcornerRadius: width/2. Never two stacked ellipses — that anti-pattern tripsrewriteLlmAntiPatterns. - Divider:
rectanglewithheight: 1andfill_container. Never a one-sided stroke on a frame (renderer only supports uniform or[T,R,B,L]stroke thicknesses).
Adding a new builder
- Create
packages/pen-core/src/element-builders/<name>.tsexportingbuild<Name>(params)+<Name>Params - Re-export from
packages/pen-core/src/element-builders/index.ts - 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-coredirectly - Wire pen-mcp handler:
packages/pen-mcp/src/tools/add-<name>-v0.tsimportsbuild<Name>and delegates - 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) - Wire apps/web shim: add to
ELEMENT_SHIMSinapps/web/src/services/ai/element-tool-shims/index.ts - Wire server builder: add to
SERVER_BUILDERSinapps/web/server/api/mcp/exec-tool.post.ts - Add to
elements.md:packages/pen-ai-skills/skills/phases/generation/elements.md— add the tool to the PREFER list + examples section - 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, checkbuilder-icon-coverage.test.tscovers it
- Layout smoke in
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 |
Related reading
packages/pen-core/CLAUDE.md— broader pen-core module mappackages/pen-ai-skills/skills/phases/generation/elements.md— prompt-level spec of each tool, loaded by the AIapps/web/src/services/ai/element-tool-shims/index.ts— client shim registryapps/web/src/services/ai/element-tools-dispatcher.ts— routes parsed<op_tool>→ shim → insertapps/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