From b006997ca4353d5df58f42e1877b76639446d6e1 Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 21 Apr 2026 22:03:44 +0800 Subject: [PATCH] refactor(core): extract element-tool tree builders to pen-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/element-builders/body-text.ts | 33 +++++++ .../src/element-builders/bottom-nav.ts | 67 +++++++++++++ .../pen-core/src/element-builders/card-row.ts | 74 +++++++++++++++ .../src/element-builders/cjk-detect.ts | 53 +++++++++++ .../pen-core/src/element-builders/heading.ts | 61 ++++++++++++ .../pen-core/src/element-builders/helpers.ts | 68 ++++++++++++++ .../pen-core/src/element-builders/index.ts | 16 ++++ .../pen-core/src/element-builders/list-row.ts | 90 ++++++++++++++++++ .../src/element-builders/metric-row.ts | 71 ++++++++++++++ .../src/element-builders/search-bar.ts | 45 +++++++++ .../src/element-builders/section-header.ts | 94 +++++++++++++++++++ .../src/element-builders/text-button.ts | 47 ++++++++++ .../src/element-builders/top-nav-bar.ts | 76 +++++++++++++++ packages/pen-core/src/index.ts | 39 ++++++++ 14 files changed, 834 insertions(+) create mode 100644 packages/pen-core/src/element-builders/body-text.ts create mode 100644 packages/pen-core/src/element-builders/bottom-nav.ts create mode 100644 packages/pen-core/src/element-builders/card-row.ts create mode 100644 packages/pen-core/src/element-builders/cjk-detect.ts create mode 100644 packages/pen-core/src/element-builders/heading.ts create mode 100644 packages/pen-core/src/element-builders/helpers.ts create mode 100644 packages/pen-core/src/element-builders/index.ts create mode 100644 packages/pen-core/src/element-builders/list-row.ts create mode 100644 packages/pen-core/src/element-builders/metric-row.ts create mode 100644 packages/pen-core/src/element-builders/search-bar.ts create mode 100644 packages/pen-core/src/element-builders/section-header.ts create mode 100644 packages/pen-core/src/element-builders/text-button.ts create mode 100644 packages/pen-core/src/element-builders/top-nav-bar.ts diff --git a/packages/pen-core/src/element-builders/body-text.ts b/packages/pen-core/src/element-builders/body-text.ts new file mode 100644 index 000000000..b6f3f1aff --- /dev/null +++ b/packages/pen-core/src/element-builders/body-text.ts @@ -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; +} diff --git a/packages/pen-core/src/element-builders/bottom-nav.ts b/packages/pen-core/src/element-builders/bottom-nav.ts new file mode 100644 index 000000000..b916e1061 --- /dev/null +++ b/packages/pen-core/src/element-builders/bottom-nav.ts @@ -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, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/card-row.ts b/packages/pen-core/src/element-builders/card-row.ts new file mode 100644 index 000000000..1f0130c4c --- /dev/null +++ b/packages/pen-core/src/element-builders/card-row.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/cjk-detect.ts b/packages/pen-core/src/element-builders/cjk-detect.ts new file mode 100644 index 000000000..39c8acc63 --- /dev/null +++ b/packages/pen-core/src/element-builders/cjk-detect.ts @@ -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; + } +} diff --git a/packages/pen-core/src/element-builders/heading.ts b/packages/pen-core/src/element-builders/heading.ts new file mode 100644 index 000000000..26214339f --- /dev/null +++ b/packages/pen-core/src/element-builders/heading.ts @@ -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 = { + 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> = { + 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; +} diff --git a/packages/pen-core/src/element-builders/helpers.ts b/packages/pen-core/src/element-builders/helpers.ts new file mode 100644 index 000000000..f7622b107 --- /dev/null +++ b/packages/pen-core/src/element-builders/helpers.ts @@ -0,0 +1,68 @@ +import { generateId } from '../id.js'; + +/** + * Common shape produced by all element-tool builders. Intentionally + * loose (Record) — the exact schema is validated by + * the downstream insert pipeline (pen-mcp handleBatchDesign / + * apps/web document-store.addNode). + */ +export type ElementTree = Record; + +/** + * 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, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts new file mode 100644 index 000000000..9154ea338 --- /dev/null +++ b/packages/pen-core/src/element-builders/index.ts @@ -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'; diff --git a/packages/pen-core/src/element-builders/list-row.ts b/packages/pen-core/src/element-builders/list-row.ts new file mode 100644 index 000000000..68d003f2f --- /dev/null +++ b/packages/pen-core/src/element-builders/list-row.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/metric-row.ts b/packages/pen-core/src/element-builders/metric-row.ts new file mode 100644 index 000000000..c9bfceec3 --- /dev/null +++ b/packages/pen-core/src/element-builders/metric-row.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/search-bar.ts b/packages/pen-core/src/element-builders/search-bar.ts new file mode 100644 index 000000000..7e7231d7b --- /dev/null +++ b/packages/pen-core/src/element-builders/search-bar.ts @@ -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, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/section-header.ts b/packages/pen-core/src/element-builders/section-header.ts new file mode 100644 index 000000000..6cac7a953 --- /dev/null +++ b/packages/pen-core/src/element-builders/section-header.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/text-button.ts b/packages/pen-core/src/element-builders/text-button.ts new file mode 100644 index 000000000..968eb7191 --- /dev/null +++ b/packages/pen-core/src/element-builders/text-button.ts @@ -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, + }; +} diff --git a/packages/pen-core/src/element-builders/top-nav-bar.ts b/packages/pen-core/src/element-builders/top-nav-bar.ts new file mode 100644 index 000000000..e7ea55c09 --- /dev/null +++ b/packages/pen-core/src/element-builders/top-nav-bar.ts @@ -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, + }, + ], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index dc080551e..0e01a5617 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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';