Step 0 of OP Rust-ification (per kickoff spec v7 FROZEN):
- Cargo workspace at root (members = ["crates/*"], glob)
- 9 skeleton crates: openpencil-app, openpencil-shell-{core,web,native},
pen-{types,core,engine,codegen,figma}
- rust-toolchain.toml pinned 1.85 (forced from 1.80 → 1.82 → 1.85
due to crates.io ecosystem edition2024 requirements)
- deny.toml with kickoff §1.2 wasm32 ban invariant
- 2 GitHub Actions: rust-check.yml (3-platform native + cargo-deny)
and wasm-bundle-check.yml (wasm32 forward + reverse cargo-deny bans)
- vendor/agent submodule → github.com/ZSeven-W/agent-rs
- Bun script wrappers (cargo:check / :test / :wasm-check / :deny)
- README "Rust subsystem" section + Phase boundary note
§1.2 invariants live:
- Forward wasm32 check: shell-web + 5 bucket A crates compile
- Reverse cargo-deny check bans: native + wasm32 both clean
- compile_error guard: shell-native fails wasm32 build with explicit
message, validated by canary
Step 1+ owns real implementation; Phase 0 docs (snapshot / plan
patches / IPC inventory / parley-taffy matrix / cargo-deny validation)
in openpencil-docs.
|
||
|---|---|---|
| .. | ||
| action-menu-v1.ts | ||
| action-menu.ts | ||
| activity-log-v1.ts | ||
| activity-log.ts | ||
| activity-ring-v1.ts | ||
| activity-ring.ts | ||
| alert-v1.ts | ||
| alert.ts | ||
| attachment-row-v1.ts | ||
| attachment-row.ts | ||
| avatar-group-v1.ts | ||
| avatar-group.ts | ||
| avatar-v1.ts | ||
| avatar.ts | ||
| badge-v1.ts | ||
| badge.ts | ||
| body-text-v1.ts | ||
| body-text.ts | ||
| bottom-nav-v1.ts | ||
| bottom-nav.ts | ||
| breadcrumb-v1.ts | ||
| breadcrumb.ts | ||
| calendar-grid-v1.ts | ||
| calendar-grid.ts | ||
| callout-v1.ts | ||
| callout.ts | ||
| card-row-v1.ts | ||
| card-row.ts | ||
| carousel-dots-v1.ts | ||
| carousel-dots.ts | ||
| chart-bars-v1.ts | ||
| chart-bars.ts | ||
| chart-line-v1.ts | ||
| chart-line.ts | ||
| chart-pie-v1.ts | ||
| chart-pie.ts | ||
| chat-bubble-v1.ts | ||
| chat-bubble.ts | ||
| checkbox-v1.ts | ||
| checkbox.ts | ||
| chip-input-v1.ts | ||
| chip-input.ts | ||
| cjk-detect.ts | ||
| code-block-v1.ts | ||
| code-block.ts | ||
| color-swatch-v1.ts | ||
| color-swatch.ts | ||
| combobox-v1.ts | ||
| combobox.ts | ||
| comment-v1.ts | ||
| comment.ts | ||
| cookie-banner-v1.ts | ||
| cookie-banner.ts | ||
| data-table-row-v1.ts | ||
| data-table-row.ts | ||
| date-picker-v1.ts | ||
| date-picker.ts | ||
| divider-v1.ts | ||
| divider.ts | ||
| drawer-shell-v1.ts | ||
| drawer-shell.ts | ||
| empty-chart-v1.ts | ||
| empty-chart.ts | ||
| empty-state-v1.ts | ||
| empty-state.ts | ||
| event-card-v1.ts | ||
| event-card.ts | ||
| fab-v1.ts | ||
| fab.ts | ||
| faq-item-v1.ts | ||
| faq-item.ts | ||
| filter-group-v1.ts | ||
| filter-group.ts | ||
| form-field-v1.ts | ||
| form-field.ts | ||
| heading-v1.ts | ||
| heading.ts | ||
| helpers.ts | ||
| icon-button-v1.ts | ||
| icon-button.ts | ||
| icon-label-v1.ts | ||
| icon-label.ts | ||
| image-placeholder-v1.ts | ||
| image-placeholder.ts | ||
| inbox-message-v1.ts | ||
| inbox-message.ts | ||
| index.ts | ||
| inline-action-v1.ts | ||
| inline-action.ts | ||
| input-with-action-v1.ts | ||
| input-with-action.ts | ||
| invite-row-v1.ts | ||
| invite-row.ts | ||
| kbd-v1.ts | ||
| kbd.ts | ||
| legend-item-v1.ts | ||
| legend-item.ts | ||
| link-v1.ts | ||
| link.ts | ||
| list-row-v1.ts | ||
| list-row.ts | ||
| member-row-v1.ts | ||
| member-row.ts | ||
| metric-comparison-v1.ts | ||
| metric-comparison.ts | ||
| metric-row-v1.ts | ||
| metric-row.ts | ||
| modal-shell-v1.ts | ||
| modal-shell.ts | ||
| nav-chip-row-v1.ts | ||
| nav-chip-row.ts | ||
| notification-row-v1.ts | ||
| notification-row.ts | ||
| otp-input-v1.ts | ||
| otp-input.ts | ||
| pagination-v1.ts | ||
| pagination.ts | ||
| phone-input-v1.ts | ||
| phone-input.ts | ||
| price-v1.ts | ||
| price.ts | ||
| pricing-card-v1.ts | ||
| pricing-card.ts | ||
| profile-header-v1.ts | ||
| profile-header.ts | ||
| progress-bar-v1.ts | ||
| progress-bar.ts | ||
| quote-block-v1.ts | ||
| quote-block.ts | ||
| radio-v1.ts | ||
| radio.ts | ||
| range-slider-v1.ts | ||
| range-slider.ts | ||
| rating-stars-v1.ts | ||
| rating-stars.ts | ||
| README.md | ||
| resolve-theme.ts | ||
| search-bar-v1.ts | ||
| search-bar.ts | ||
| section-header-v1.ts | ||
| section-header.ts | ||
| segmented-control-v1.ts | ||
| segmented-control.ts | ||
| select-v1.ts | ||
| select.ts | ||
| setting-row-v1.ts | ||
| setting-row.ts | ||
| share-row-v1.ts | ||
| share-row.ts | ||
| sidebar-nav-v1.ts | ||
| sidebar-nav.ts | ||
| skeleton-v1.ts | ||
| skeleton.ts | ||
| social-login-row-v1.ts | ||
| social-login-row.ts | ||
| spinner-v1.ts | ||
| spinner.ts | ||
| stat-card-v1.ts | ||
| stat-card.ts | ||
| stat-grid-v1.ts | ||
| stat-grid.ts | ||
| status-badge-v1.ts | ||
| status-badge.ts | ||
| step-card-v1.ts | ||
| step-card.ts | ||
| stepper-v1.ts | ||
| stepper.ts | ||
| switch-v1.ts | ||
| switch.ts | ||
| tabs-v1.ts | ||
| tabs.ts | ||
| tag-v1.ts | ||
| tag.ts | ||
| text-button-v1.ts | ||
| text-button.ts | ||
| textarea-v1.ts | ||
| textarea.ts | ||
| timeline-v1.ts | ||
| timeline.ts | ||
| toast-v1.ts | ||
| toast.ts | ||
| toolbar-v1.ts | ||
| toolbar.ts | ||
| tooltip-v1.ts | ||
| tooltip.ts | ||
| top-nav-bar-v1.ts | ||
| top-nav-bar.ts | ||
| upload-dropzone-v1.ts | ||
| upload-dropzone.ts | ||
| user-card-v1.ts | ||
| user-card.ts | ||
| video-placeholder-v1.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