refactor(core): extract element-tool tree builders to pen-core

New subdirectory packages/pen-core/src/element-builders/ with
12 pure tree-build functions matching pen-mcp's add_*_v0 family.
Browser-safe (no node:fs, no document-manager) — meant to be
imported by both pen-mcp handlers (server) and apps/web client
shims (embedded orchestrator) so the tree shape is byte-identical
across paths, eliminating drift by construction.

Covered: buildCardRow, buildMetricRow, buildBottomNav,
buildSectionHeader, buildTopNavBar, buildHeading, buildBodyText,
buildTextButton, buildSearchBar, buildListRow. Plus helpers
(assignIdsRecursively, buildScrollWrapper, ElementTree type) and
cjk-detect (detectCjkScript + cjkFontFamily for heading/body
text font dispatch per repo's CJK contract in text-rules.md).

Re-exported from pen-core's main barrel. Pure additive change —
no existing callers affected. Consumers switched in a following
commit to avoid mixing refactor + infrastructure in one review.
This commit is contained in:
Fini 2026-04-21 22:03:44 +08:00
parent 1fd4d11d2a
commit b006997ca4
14 changed files with 834 additions and 0 deletions

View file

@ -0,0 +1,33 @@
import { detectCjkScript } from './cjk-detect.js';
import type { ElementTree } from './helpers.js';
export interface BodyTextParams {
content: string;
}
/**
* Body / description text — always fontFamily='Inter' (per text-rules.md).
* lineHeight + letterSpacing ARE script-sensitive:
* - CJK: lineHeight=1.6, letterSpacing=0 (prevent character overlap)
* - Latin: lineHeight=1.5, no letterSpacing override
*
* Always width=fill_container + textGrowth='fixed-width' so long
* paragraphs wrap. Intended for VERTICAL-layout parents.
*/
export function buildBodyText(params: BodyTextParams): ElementTree {
const isCjk = detectCjkScript(params.content) !== null;
const body: ElementTree = {
type: 'text',
name: 'Body',
role: 'body',
content: params.content,
fontSize: 16,
fontWeight: 400,
fontFamily: 'Inter',
lineHeight: isCjk ? 1.6 : 1.5,
width: 'fill_container',
textGrowth: 'fixed-width',
};
if (isCjk) body.letterSpacing = 0;
return body;
}

View file

@ -0,0 +1,67 @@
import type { ElementTree } from './helpers.js';
export interface BottomNavItem {
title: string;
icon: string;
active?: boolean;
}
export interface BottomNavParams {
items: BottomNavItem[];
height?: number;
}
/**
* Inline bottom navigation bar — 3-5 tab items, icon + label stack,
* active tab gets `nav-item-active` role + fontWeight 600.
*
* Emits one frame with role='bottom-tab-bar'; caller inserts it as
* the LAST child of the page (no spacer needed — spec §NO FIXED-POSITION).
*/
export function buildBottomNav(params: BottomNavParams): ElementTree {
const height = params.height ?? 62;
const tabs = params.items.map((item) => buildTab(item));
return {
type: 'frame',
name: 'Bottom Tab Bar',
role: 'bottom-tab-bar',
width: 'fill_container',
height,
layout: 'horizontal',
justifyContent: 'space_around',
alignItems: 'center',
children: tabs,
};
}
function buildTab(item: BottomNavItem): ElementTree {
return {
type: 'frame',
name: `Tab (${item.title})`,
role: item.active ? 'nav-item-active' : 'nav-item',
width: 'fit_content',
height: 'fit_content',
layout: 'vertical',
alignItems: 'center',
gap: 4,
padding: [4, 12],
children: [
{
type: 'icon_font',
name: 'Icon',
iconFontName: item.icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
},
{
type: 'text',
name: 'Label',
role: 'label',
content: item.title,
fontSize: 11,
fontWeight: item.active ? 600 : 500,
},
],
};
}

View file

@ -0,0 +1,74 @@
import { buildScrollWrapper, type ElementTree } from './helpers.js';
export interface CardRowItem {
title: string;
subtitle?: string;
icon?: string;
}
export interface CardRowParams {
items: CardRowItem[];
card_width?: number;
gap?: number;
}
/**
* Horizontal scroll row of CARDS (title + subtitle + optional icon).
* Each card: `card_width`×160 frame, cornerRadius=20, padding=16,
* vertical layout, gap=8.
*
* Mirrors pen-mcp `add_card_row_v0` tree build exactly — both sides
* call this builder so drift is impossible.
*/
export function buildCardRow(params: CardRowParams): ElementTree {
const cardWidth = params.card_width ?? 140;
const gap = params.gap ?? 12;
const cards = params.items.map((item) => buildCard(item, cardWidth));
return buildScrollWrapper({ rowName: 'Card Row', innerChildren: cards, gap });
}
function buildCard(item: CardRowItem, cardWidth: number): ElementTree {
const children: ElementTree[] = [];
if (item.icon) {
children.push({
type: 'icon_font',
name: 'Icon',
iconFontName: item.icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
});
}
children.push({
type: 'text',
name: 'Title',
role: 'heading',
content: item.title,
fontSize: 16,
fontWeight: 600,
width: 'fill_container',
});
if (item.subtitle) {
children.push({
type: 'text',
name: 'Subtitle',
role: 'body',
content: item.subtitle,
fontSize: 13,
fontWeight: 400,
width: 'fill_container',
});
}
return {
type: 'frame',
name: 'Card',
role: 'card',
width: cardWidth,
height: 160,
cornerRadius: 20,
padding: 16,
layout: 'vertical',
gap: 8,
children,
};
}

View file

@ -0,0 +1,53 @@
/**
* Script detection for text content. Used by element-tool builders to
* pick the correct `fontFamily` per the repo's CJK font contract
* documented in
* `packages/pen-ai-skills/skills/phases/generation/text-rules.md`:
*
* "CJK font selection: heading='Noto Sans SC' (Chinese) /
* 'Noto Sans JP' (Japanese) / 'Noto Sans KR' (Korean),
* body='Inter'. NEVER use 'Space Grotesk' or 'Manrope' for CJK
* content — they have no CJK glyphs."
*
* Detection order matters:
* 1. Hiragana / Katakana present → Japanese (these scripts are
* unique to Japanese even when mixed with Han ideographs)
* 2. Hangul syllables present → Korean
* 3. Any Han ideograph / CJK punctuation → Chinese (Simplified default)
* 4. Otherwise null (Latin / other)
*
* Lives in `element-builders/` (not the more general `layout/` module)
* because the output — a Noto Sans family name — is specifically the
* font-selection policy shared between pen-mcp handlers and apps/web
* client shims. Pen-core's broader `hasCjkText` helper answers a
* boolean; this one answers "which font family".
*/
export type CjkScript = null | 'chinese' | 'japanese' | 'korean';
export function detectCjkScript(s: string): CjkScript {
// Hiragana U+3040-309F + Katakana U+30A0-30FF → Japanese
if (/[぀-ゟ゠-ヿ]/.test(s)) return 'japanese';
// Hangul Syllables U+AC00-D7AF → Korean
if (/[가-힯]/.test(s)) return 'korean';
// CJK Symbols / Punctuation U+3000-303F + CJK Unified Ideographs U+4E00-9FFF → Chinese
if (/[ -〿一-鿿]/.test(s)) return 'chinese';
return null;
}
/**
* Map a CJK script to its Noto Sans font family. Returns undefined
* for non-CJK so callers can fall back to theme default (Inter for
* body, theme heading for headings).
*/
export function cjkFontFamily(script: CjkScript): string | undefined {
switch (script) {
case 'japanese':
return 'Noto Sans JP';
case 'korean':
return 'Noto Sans KR';
case 'chinese':
return 'Noto Sans SC';
default:
return undefined;
}
}

View file

@ -0,0 +1,61 @@
import { cjkFontFamily, detectCjkScript } from './cjk-detect.js';
import type { ElementTree } from './helpers.js';
export type HeadingLevel = 'display' | 'h1' | 'h2' | 'h3';
export interface HeadingParams {
content: string;
level?: HeadingLevel;
}
interface HeadingPreset {
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing?: number;
fontFamily?: string;
}
const LATIN_PRESETS: Record<HeadingLevel, HeadingPreset> = {
display: { fontSize: 48, fontWeight: 700, lineHeight: 1.0, letterSpacing: -0.5 },
h1: { fontSize: 32, fontWeight: 700, lineHeight: 1.1 },
h2: { fontSize: 24, fontWeight: 600, lineHeight: 1.2 },
h3: { fontSize: 20, fontWeight: 600, lineHeight: 1.25 },
};
// CJK presets (from project_pencil_optimization + text-rules.md):
// - lineHeight 1.3-1.4 for headings (NOT 1.1-1.2 like Latin)
// - letterSpacing: 0, NEVER negative (would cause CJK character overlap)
// - fontFamily dispatched per script (SC/JP/KR); heading bar.
const CJK_BASE: Record<HeadingLevel, Omit<HeadingPreset, 'fontFamily'>> = {
display: { fontSize: 48, fontWeight: 700, lineHeight: 1.3 },
h1: { fontSize: 32, fontWeight: 700, lineHeight: 1.3 },
h2: { fontSize: 24, fontWeight: 600, lineHeight: 1.35 },
h3: { fontSize: 20, fontWeight: 600, lineHeight: 1.4 },
};
/**
* Typographic heading — single text node, typography preset chosen
* by `level` + CJK detection. Structure is always one text node so
* no "应拆尽拆" violation (enum controls typography, not structure).
*/
export function buildHeading(params: HeadingParams): ElementTree {
const level = params.level ?? 'h2';
const script = detectCjkScript(params.content);
const cjkFont = cjkFontFamily(script);
const preset: HeadingPreset = cjkFont
? { ...CJK_BASE[level], fontFamily: cjkFont }
: LATIN_PRESETS[level];
const heading: ElementTree = {
type: 'text',
name: `Heading (${level})`,
role: 'heading',
content: params.content,
fontSize: preset.fontSize,
fontWeight: preset.fontWeight,
lineHeight: preset.lineHeight,
};
if (preset.letterSpacing !== undefined) heading.letterSpacing = preset.letterSpacing;
if (preset.fontFamily !== undefined) heading.fontFamily = preset.fontFamily;
return heading;
}

View file

@ -0,0 +1,68 @@
import { generateId } from '../id.js';
/**
* Common shape produced by all element-tool builders. Intentionally
* loose (Record<string, unknown>) — the exact schema is validated by
* the downstream insert pipeline (pen-mcp handleBatchDesign /
* apps/web document-store.addNode).
*/
export type ElementTree = Record<string, unknown>;
/**
* Walk a node subtree and stamp every node with a fresh id.
* Mirrors the invariant pen-mcp's batch_design relies on — the
* top-level insert gets an id but nested children don't, so builders
* that emit multi-level trees must assign ids themselves.
*/
export function assignIdsRecursively(node: ElementTree): void {
if (typeof node.id !== 'string') node.id = generateId();
const children = node.children;
if (Array.isArray(children)) {
for (const child of children) {
if (child && typeof child === 'object') {
assignIdsRecursively(child as ElementTree);
}
}
}
}
/**
* Canonical scroll-row wrapper taught in
* `packages/pen-ai-skills/skills/phases/generation/overflow.md`
* §HORIZONTAL SCROLL ROWS: outer wrapper (fill_container + clipContent +
* vertical) > inner row (fit_content + horizontal + gap + padding=[0,20]) >
* children.
*
* Shared by add_card_row_v0 / add_metric_row_v0 / add_nav_chip_row_v0.
* Each tool only differs in the per-item node builder; the wrapper
* is identical and must stay so — it's what makes the overflow
* behavior predictable on weak models.
*/
export function buildScrollWrapper(opts: {
rowName: string;
innerChildren: ElementTree[];
gap: number;
}): ElementTree {
return {
type: 'frame',
name: opts.rowName,
role: 'scroll-row-wrapper',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
clipContent: true,
children: [
{
type: 'frame',
name: 'Scroll Inner Row',
role: 'scroll-row',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
gap: opts.gap,
padding: [0, 20],
children: opts.innerChildren,
},
],
};
}

View file

@ -0,0 +1,16 @@
export { assignIdsRecursively, buildScrollWrapper, type ElementTree } from './helpers.js';
export { cjkFontFamily, detectCjkScript, type CjkScript } from './cjk-detect.js';
export { buildCardRow, type CardRowItem, type CardRowParams } from './card-row.js';
export { buildMetricRow, type MetricRowItem, type MetricRowParams } from './metric-row.js';
export { buildBottomNav, type BottomNavItem, type BottomNavParams } from './bottom-nav.js';
export {
buildSectionHeader,
type SectionHeaderAction,
type SectionHeaderParams,
} from './section-header.js';
export { buildTopNavBar, type TopNavBarParams } from './top-nav-bar.js';
export { buildHeading, type HeadingLevel, type HeadingParams } from './heading.js';
export { buildBodyText, type BodyTextParams } from './body-text.js';
export { buildTextButton, type TextButtonParams } from './text-button.js';
export { buildSearchBar, type SearchBarParams } from './search-bar.js';
export { buildListRow, type ListRowParams } from './list-row.js';

View file

@ -0,0 +1,90 @@
import type { ElementTree } from './helpers.js';
export interface ListRowParams {
title: string;
subtitle?: string;
leading_icon?: string;
trailing_icon?: string;
}
/**
* iOS / Material-style list row: [optional leading icon] + [vertical
* text stack (title + optional subtitle)] + [optional trailing icon,
* typically chevron-right].
*
* No-overlap invariant: the text-stack sibling uses width=fill_container
* inside a VERTICAL wrapper so the title wraps correctly + wrap
* height propagates to the row's fit_content height. Text directly in
* horizontal parents with fill_container+fixed-width does NOT
* propagate wrap height per the layout engine — the vertical wrapper
* is why this tool needs it.
*/
export function buildListRow(params: ListRowParams): ElementTree {
const rowChildren: ElementTree[] = [];
if (params.leading_icon) {
rowChildren.push({
type: 'icon_font',
name: 'Leading Icon',
iconFontName: params.leading_icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
});
}
const textStackChildren: ElementTree[] = [
{
type: 'text',
name: 'Title',
role: 'label',
content: params.title,
fontSize: 15,
fontWeight: 500,
width: 'fill_container',
textGrowth: 'fixed-width',
},
];
if (params.subtitle) {
textStackChildren.push({
type: 'text',
name: 'Subtitle',
role: 'body',
content: params.subtitle,
fontSize: 13,
fontWeight: 400,
width: 'fill_container',
textGrowth: 'fixed-width',
});
}
rowChildren.push({
type: 'frame',
name: 'Text Stack',
role: 'list-row-text',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 2,
children: textStackChildren,
});
if (params.trailing_icon) {
rowChildren.push({
type: 'icon_font',
name: 'Trailing Icon',
iconFontName: params.trailing_icon,
iconFontFamily: 'lucide',
width: 16,
height: 16,
});
}
return {
type: 'frame',
name: 'List Row',
role: 'list-row',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 12,
padding: [12, 16],
children: rowChildren,
};
}

View file

@ -0,0 +1,71 @@
import { buildScrollWrapper, type ElementTree } from './helpers.js';
export interface MetricRowItem {
label: string;
value: string;
icon?: string;
}
export interface MetricRowParams {
items: MetricRowItem[];
tile_width?: number;
gap?: number;
}
/**
* Horizontal scroll row of METRIC TILES (small label + big value +
* optional icon). Each tile: `tile_width`×100 frame, cornerRadius=16,
* padding=16, vertical layout, gap=4.
*
* label = 12/500 (body role), value = 28/700 (heading role).
*/
export function buildMetricRow(params: MetricRowParams): ElementTree {
const tileWidth = params.tile_width ?? 120;
const gap = params.gap ?? 12;
const tiles = params.items.map((item) => buildTile(item, tileWidth));
return buildScrollWrapper({ rowName: 'Metric Row', innerChildren: tiles, gap });
}
function buildTile(item: MetricRowItem, tileWidth: number): ElementTree {
const children: ElementTree[] = [];
if (item.icon) {
children.push({
type: 'icon_font',
name: 'Icon',
iconFontName: item.icon,
iconFontFamily: 'lucide',
width: 20,
height: 20,
});
}
children.push({
type: 'text',
name: 'Label',
role: 'body',
content: item.label,
fontSize: 12,
fontWeight: 500,
width: 'fill_container',
});
children.push({
type: 'text',
name: 'Value',
role: 'heading',
content: item.value,
fontSize: 28,
fontWeight: 700,
width: 'fill_container',
});
return {
type: 'frame',
name: 'Metric Tile',
role: 'metric-tile',
width: tileWidth,
height: 100,
cornerRadius: 16,
padding: 16,
layout: 'vertical',
gap: 4,
children,
};
}

View file

@ -0,0 +1,45 @@
import type { ElementTree } from './helpers.js';
export interface SearchBarParams {
placeholder?: string;
leading_icon?: string;
}
/**
* Search bar — height=44, cornerRadius=22 (iOS HIG); leading icon
* (defaults to 'search') + placeholder text in a horizontal row.
* fill_container width so the bar stretches in a form / header.
*/
export function buildSearchBar(params: SearchBarParams): ElementTree {
const icon = params.leading_icon ?? 'search';
const placeholder = params.placeholder ?? 'Search...';
return {
type: 'frame',
name: 'Search Bar',
role: 'search-bar',
width: 'fill_container',
height: 44,
cornerRadius: 22,
layout: 'horizontal',
alignItems: 'center',
gap: 8,
padding: [0, 16],
children: [
{
type: 'icon_font',
name: 'Leading Icon',
iconFontName: icon,
iconFontFamily: 'lucide',
width: 20,
height: 20,
},
{
type: 'text',
name: 'Placeholder',
content: placeholder,
fontSize: 14,
fontWeight: 400,
},
],
};
}

View file

@ -0,0 +1,94 @@
import type { ElementTree } from './helpers.js';
export interface SectionHeaderAction {
label: string;
icon?: string;
}
export interface SectionHeaderParams {
title: string;
action?: SectionHeaderAction;
}
/**
* Dashboard / landing section header: big title on the left, optional
* trailing action (e.g. "See all →"). Horizontal + space_between via
* title (fill_container) + action (fit_content).
*
* Title is wrapped in a vertical container with textGrowth=fixed-width
* so multi-line titles correctly push downstream content down — see
* `packages/pen-ai-skills/skills/phases/generation/overflow.md`
* §"Text in VERTICAL layout".
*/
export function buildSectionHeader(params: SectionHeaderParams): ElementTree {
const children: ElementTree[] = [
{
type: 'frame',
name: 'Title Container',
role: 'section-header-title',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
children: [
{
type: 'text',
name: 'Title',
role: 'heading',
content: params.title,
fontSize: 20,
fontWeight: 700,
width: 'fill_container',
textGrowth: 'fixed-width',
},
],
},
];
if (params.action) {
children.push(buildActionGroup(params.action));
}
return {
type: 'frame',
name: 'Section Header',
role: 'section-header',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 16,
children,
};
}
function buildActionGroup(action: SectionHeaderAction): ElementTree {
const children: ElementTree[] = [
{
type: 'text',
name: 'Action Label',
role: 'label',
content: action.label,
fontSize: 14,
fontWeight: 500,
},
];
if (action.icon) {
children.push({
type: 'icon_font',
name: 'Action Icon',
iconFontName: action.icon,
iconFontFamily: 'lucide',
width: 16,
height: 16,
});
}
return {
type: 'frame',
name: 'Action',
role: 'section-header-action',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 4,
children,
};
}

View file

@ -0,0 +1,47 @@
import type { ElementTree } from './helpers.js';
export interface TextButtonParams {
label: string;
leading_icon?: string;
}
/**
* Padding-based text button. `frame(padding=[12,20], justifyContent=center)
* > [optional icon + text]` — height auto-derives from padding + text
* metrics, no explicit fixed height.
*/
export function buildTextButton(params: TextButtonParams): ElementTree {
const children: ElementTree[] = [];
if (params.leading_icon) {
children.push({
type: 'icon_font',
name: 'Icon',
iconFontName: params.leading_icon,
iconFontFamily: 'lucide',
width: 16,
height: 16,
});
}
children.push({
type: 'text',
name: 'Label',
role: 'label',
content: params.label,
fontSize: 14,
fontWeight: 500,
});
return {
type: 'frame',
name: 'Text Button',
role: 'button',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: [12, 20],
cornerRadius: 8,
children,
};
}

View file

@ -0,0 +1,76 @@
import type { ElementTree } from './helpers.js';
export interface TopNavBarParams {
title: string;
leading_icon?: string;
trailing_icon?: string;
height?: number;
}
/**
* Mobile top navigation bar: optional leading icon (back/menu) +
* centered title + optional trailing icon (search/more). Dual of
* bottom-nav. 44×44 hit targets for icons (Apple HIG + Material).
* Empty slots become same-footprint spacers so the title stays centered.
*/
export function buildTopNavBar(params: TopNavBarParams): ElementTree {
const height = params.height ?? 56;
return {
type: 'frame',
name: 'Top Nav Bar',
role: 'top-nav-bar',
width: 'fill_container',
height,
layout: 'horizontal',
justifyContent: 'space_between',
alignItems: 'center',
padding: [0, 16],
children: [
buildIconSlot(params.leading_icon, 'leading'),
{
type: 'text',
name: 'Title',
role: 'heading',
content: params.title,
fontSize: 17,
fontWeight: 600,
},
buildIconSlot(params.trailing_icon, 'trailing'),
],
};
}
function buildIconSlot(icon: string | undefined, position: 'leading' | 'trailing'): ElementTree {
if (!icon) {
return {
type: 'frame',
name: `${position} Spacer`,
role: 'nav-spacer',
width: 44,
height: 44,
layout: 'none',
children: [],
};
}
return {
type: 'frame',
name: `${position} Icon Button`,
role: 'icon-button',
width: 44,
height: 44,
layout: 'horizontal',
justifyContent: 'center',
alignItems: 'center',
cornerRadius: 8,
children: [
{
type: 'icon_font',
name: 'Icon',
iconFontName: icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
},
],
};
}

View file

@ -165,3 +165,42 @@ export type {
DocFieldName,
} from './merge/node-merge.js';
export { mergeDocuments } from './merge/node-merge.js';
// --- Element builders (shared by pen-mcp handlers + apps/web client shims) ---
// Pure tree-build functions matching pen-mcp's add_*_v0 tools. Browser-safe:
// no node:fs, no document-manager imports — just PenNode shape generation.
// Callers layer their own insert pipeline on top (pen-mcp adds parent_id
// validation + rollback; apps/web client shim calls document-store.addNode).
export {
assignIdsRecursively,
buildScrollWrapper,
buildCardRow,
buildMetricRow,
buildBottomNav,
buildSectionHeader,
buildTopNavBar,
buildHeading,
buildBodyText,
buildTextButton,
buildSearchBar,
buildListRow,
cjkFontFamily,
detectCjkScript,
type ElementTree,
type CjkScript,
type CardRowItem,
type CardRowParams,
type MetricRowItem,
type MetricRowParams,
type BottomNavItem,
type BottomNavParams,
type SectionHeaderAction,
type SectionHeaderParams,
type TopNavBarParams,
type HeadingLevel,
type HeadingParams,
type BodyTextParams,
type TextButtonParams,
type SearchBarParams,
type ListRowParams,
} from './element-builders/index.js';