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:
parent
1fd4d11d2a
commit
b006997ca4
33
packages/pen-core/src/element-builders/body-text.ts
Normal file
33
packages/pen-core/src/element-builders/body-text.ts
Normal 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;
|
||||
}
|
||||
67
packages/pen-core/src/element-builders/bottom-nav.ts
Normal file
67
packages/pen-core/src/element-builders/bottom-nav.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
74
packages/pen-core/src/element-builders/card-row.ts
Normal file
74
packages/pen-core/src/element-builders/card-row.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
53
packages/pen-core/src/element-builders/cjk-detect.ts
Normal file
53
packages/pen-core/src/element-builders/cjk-detect.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
61
packages/pen-core/src/element-builders/heading.ts
Normal file
61
packages/pen-core/src/element-builders/heading.ts
Normal 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;
|
||||
}
|
||||
68
packages/pen-core/src/element-builders/helpers.ts
Normal file
68
packages/pen-core/src/element-builders/helpers.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
16
packages/pen-core/src/element-builders/index.ts
Normal file
16
packages/pen-core/src/element-builders/index.ts
Normal 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';
|
||||
90
packages/pen-core/src/element-builders/list-row.ts
Normal file
90
packages/pen-core/src/element-builders/list-row.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
71
packages/pen-core/src/element-builders/metric-row.ts
Normal file
71
packages/pen-core/src/element-builders/metric-row.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
packages/pen-core/src/element-builders/search-bar.ts
Normal file
45
packages/pen-core/src/element-builders/search-bar.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
94
packages/pen-core/src/element-builders/section-header.ts
Normal file
94
packages/pen-core/src/element-builders/section-header.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
packages/pen-core/src/element-builders/text-button.ts
Normal file
47
packages/pen-core/src/element-builders/text-button.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
76
packages/pen-core/src/element-builders/top-nav-bar.ts
Normal file
76
packages/pen-core/src/element-builders/top-nav-bar.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue