feat(mcp): add_scroll_row_v0 MVP element tool for non-Claude stability

Step 2 of N-tool element design (spec v0 §7.1 pinned to this impl).
First production element-level tool, replacing the batch_design generic DSL
for the specific pattern LLMs most commonly get wrong: horizontal scroll
rows of cards / metric tiles / nav items.

Tool fixes the structure at schema level:
- Outer wrapper: fill_container + clipContent=true + vertical layout
- Inner row: fit_content + horizontal + gap + padding=[0,20]
- Children: fixed numeric width per variant (card=140 / metric=120 / nav=72)

Exactly matches packages/pen-ai-skills/skills/phases/generation/overflow.md
§HORIZONTAL SCROLL ROWS. Sugar over handleBatchDesign (D1=A route).

- tools/add-scroll-row-v0.ts: 230-line builder with 3 children_type presets
- routes/design-routes.ts: register in production DESIGN_TOOL_DEFINITIONS
- __tests__/add-scroll-row-v0.test.ts: 13 tests covering wrapper invariants,
  per-type structure, icon/subtitle optionality, overrides, persistence,
  golden snapshot
- d0-parity-spike.test.ts: rewrite "names exactly" assertion to "pre-D0
  tools still present" so new production tools don't break baseline; scope
  snapshot to pre-D0 5 tools only

70/70 pen-mcp tests pass. format:check + tsc --noEmit both green.
This commit is contained in:
Fini 2026-04-19 15:32:25 +08:00
parent 955adc1277
commit b8744fbda3
6 changed files with 745 additions and 8 deletions

View file

@ -0,0 +1,147 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add_scroll_row_v0 — snapshot golden output > golden: 3 cards with title+subtitle+icon > scroll-row-cards-golden 1`] = `
{
"children": [
{
"children": [
{
"children": [
{
"height": 24,
"iconFontFamily": "lucide",
"iconFontName": "flame",
"name": "Icon",
"type": "icon_font",
"width": 24,
},
{
"content": "Hiit",
"fontSize": 16,
"fontWeight": 600,
"name": "Title",
"role": "heading",
"type": "text",
"width": "fill_container",
},
{
"content": "30 min",
"fontSize": 13,
"fontWeight": 400,
"name": "Subtitle",
"role": "body",
"type": "text",
"width": "fill_container",
},
],
"cornerRadius": 20,
"gap": 8,
"height": 160,
"layout": "vertical",
"name": "Card",
"padding": 16,
"role": "card",
"type": "frame",
"width": 140,
},
{
"children": [
{
"height": 24,
"iconFontFamily": "lucide",
"iconFontName": "dumbbell",
"name": "Icon",
"type": "icon_font",
"width": 24,
},
{
"content": "Strength",
"fontSize": 16,
"fontWeight": 600,
"name": "Title",
"role": "heading",
"type": "text",
"width": "fill_container",
},
{
"content": "45 min",
"fontSize": 13,
"fontWeight": 400,
"name": "Subtitle",
"role": "body",
"type": "text",
"width": "fill_container",
},
],
"cornerRadius": 20,
"gap": 8,
"height": 160,
"layout": "vertical",
"name": "Card",
"padding": 16,
"role": "card",
"type": "frame",
"width": 140,
},
{
"children": [
{
"height": 24,
"iconFontFamily": "lucide",
"iconFontName": "leaf",
"name": "Icon",
"type": "icon_font",
"width": 24,
},
{
"content": "Yoga",
"fontSize": 16,
"fontWeight": 600,
"name": "Title",
"role": "heading",
"type": "text",
"width": "fill_container",
},
{
"content": "25 min",
"fontSize": 13,
"fontWeight": 400,
"name": "Subtitle",
"role": "body",
"type": "text",
"width": "fill_container",
},
],
"cornerRadius": 20,
"gap": 8,
"height": 160,
"layout": "vertical",
"name": "Card",
"padding": 16,
"role": "card",
"type": "frame",
"width": 140,
},
],
"gap": 12,
"height": "fit_content",
"layout": "horizontal",
"name": "Scroll Inner Row",
"padding": [
0,
20,
],
"role": "scroll-row",
"type": "frame",
"width": "fit_content",
},
],
"clipContent": true,
"height": "fit_content",
"layout": "vertical",
"name": "Scroll Row (card)",
"role": "scroll-row-wrapper",
"type": "frame",
"width": "fill_container",
}
`;

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`D0 parity spike — additive & gated tool registration > existing tool DEFINITIONS are unchanged (snapshot) > existing-design-tool-definitions 1`] = `
exports[`D0 parity spike — additive & gated tool registration > pre-D0 production design tool DEFINITIONS are unchanged (snapshot) > pre-d0-design-tool-definitions 1`] = `
[
{
"description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide). Omit section for the full prompt.",

View file

@ -0,0 +1,288 @@
/**
* Unit tests for add_scroll_row_v0 — MVP element tool (Step 2 of N-tool spec v0).
*
* The tool builds a horizontal scroll row that strictly follows the
* nested wrapper pattern taught in
* `packages/pen-ai-skills/skills/phases/generation/overflow.md` §HORIZONTAL
* SCROLL ROWS. These tests lock down:
*
* - Registration in production DESIGN_TOOL_DEFINITIONS (not gated)
* - Outer wrapper: fill_container / fit_content / vertical / clipContent=true
* - Inner row: fit_content / horizontal / gap / padding=[0,20]
* - Each child kind has the correct fixed width, cornerRadius, padding
* - icon / subtitle are optional; title always present
* - card_width / gap overrides propagate
* - Parent resolution (null root vs parent_id)
* - Round-trip through handleBatchDesign persists to disk
*
* See spec openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7.1
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes';
import { handleAddScrollRowV0 } from '../tools/add-scroll-row-v0';
import { invalidateCache } from '../document-manager';
const TMP_DIR = join(tmpdir(), 'openpencil-add-scroll-row-v0-tests');
const EMPTY_DOC = JSON.stringify({ version: '1.0.0', children: [] });
async function fresh(name: string): Promise<string> {
const fp = join(TMP_DIR, name);
await writeFile(fp, EMPTY_DOC, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const pageChildren = pages?.[0]?.children;
const topChildren = doc['children'] as Record<string, unknown>[] | undefined;
const root = pageChildren?.[0] ?? topChildren?.[0];
if (!root) throw new Error('expected root child');
return root;
}
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true });
});
afterEach(async () => {
for (const f of ['cards.op', 'metrics.op', 'nav.op', 'min.op', 'override.op', 'persist.op']) {
try {
const fp = join(TMP_DIR, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('add_scroll_row_v0 — registration', () => {
it('is registered in production DESIGN_TOOL_DEFINITIONS', () => {
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name);
expect(names).toContain('add_scroll_row_v0');
});
it('is in DESIGN_TOOL_NAMES', () => {
expect(DESIGN_TOOL_NAMES.has('add_scroll_row_v0')).toBe(true);
});
it('has required fields children_type and items', () => {
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_scroll_row_v0');
expect(def).toBeDefined();
expect(def?.inputSchema.required).toEqual(['children_type', 'items']);
});
});
describe('add_scroll_row_v0 — wrapper structure invariants', () => {
it('outer wrapper has fill_container + clipContent + vertical layout', async () => {
const fp = await fresh('cards.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'Hello' }],
});
const doc = await readDoc(fp);
const wrapper = getRoot(doc);
expect(wrapper.type).toBe('frame');
expect(wrapper.role).toBe('scroll-row-wrapper');
expect(wrapper.width).toBe('fill_container');
expect(wrapper.height).toBe('fit_content');
expect(wrapper.layout).toBe('vertical');
expect(wrapper.clipContent).toBe(true);
});
it('inner row has fit_content + horizontal + padding=[0,20]', async () => {
const fp = await fresh('cards.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'A' }, { title: 'B' }],
});
const wrapper = getRoot(await readDoc(fp));
const innerRow = (wrapper.children as Record<string, unknown>[])[0];
expect(innerRow.role).toBe('scroll-row');
expect(innerRow.width).toBe('fit_content');
expect(innerRow.height).toBe('fit_content');
expect(innerRow.layout).toBe('horizontal');
expect(innerRow.padding).toEqual([0, 20]);
expect(innerRow.gap).toBe(12); // default
expect((innerRow.children as unknown[]).length).toBe(2);
});
});
describe('add_scroll_row_v0 — children_type: card', () => {
it('default card is 140×160, cornerRadius=20, padding=16', async () => {
const fp = await fresh('cards.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'Only' }],
});
const wrapper = getRoot(await readDoc(fp));
const row = (wrapper.children as Record<string, unknown>[])[0];
const card = (row.children as Record<string, unknown>[])[0];
expect(card.width).toBe(140);
expect(card.height).toBe(160);
expect(card.cornerRadius).toBe(20);
expect(card.padding).toBe(16);
expect(card.role).toBe('card');
});
it('card with title + subtitle + icon has all 3 children with correct roles', async () => {
const fp = await fresh('cards.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'Hiit', subtitle: '30 min', icon: 'flame' }],
});
const wrapper = getRoot(await readDoc(fp));
const row = (wrapper.children as Record<string, unknown>[])[0];
const card = (row.children as Record<string, unknown>[])[0];
const kids = card.children as Record<string, unknown>[];
expect(kids.length).toBe(3);
expect(kids[0].type).toBe('icon_font');
expect(kids[0].iconFontName).toBe('flame');
expect(kids[0].iconFontFamily).toBe('lucide');
expect(kids[1].type).toBe('text');
expect(kids[1].content).toBe('Hiit');
expect(kids[1].role).toBe('heading');
expect(kids[2].type).toBe('text');
expect(kids[2].content).toBe('30 min');
expect(kids[2].role).toBe('body');
});
it('card with only title has just one text child', async () => {
const fp = await fresh('min.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'Bare' }],
});
const wrapper = getRoot(await readDoc(fp));
const card = (
(wrapper.children as Record<string, unknown>[])[0].children as Record<string, unknown>[]
)[0];
const kids = card.children as Record<string, unknown>[];
expect(kids.length).toBe(1);
expect(kids[0].type).toBe('text');
expect(kids[0].content).toBe('Bare');
});
});
describe('add_scroll_row_v0 — children_type: metric_tile', () => {
it('default metric_tile is 120×100, cornerRadius=16', async () => {
const fp = await fresh('metrics.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'metric_tile',
items: [{ title: 'Steps', subtitle: '8,432' }],
});
const wrapper = getRoot(await readDoc(fp));
const tile = (
(wrapper.children as Record<string, unknown>[])[0].children as Record<string, unknown>[]
)[0];
expect(tile.width).toBe(120);
expect(tile.height).toBe(100);
expect(tile.cornerRadius).toBe(16);
expect(tile.role).toBe('metric-tile');
const kids = tile.children as Record<string, unknown>[];
expect(kids[0].content).toBe('Steps');
expect(kids[0].role).toBe('body');
expect(kids[1].content).toBe('8,432');
expect(kids[1].role).toBe('heading');
expect(kids[1].fontSize).toBe(28);
});
});
describe('add_scroll_row_v0 — children_type: nav_item', () => {
it('default nav_item is 72 wide with fit_content height + alignItems=center', async () => {
const fp = await fresh('nav.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'nav_item',
items: [
{ title: 'Home', icon: 'home' },
{ title: 'Search', icon: 'search' },
],
});
const wrapper = getRoot(await readDoc(fp));
const items = (wrapper.children as Record<string, unknown>[])[0].children as Record<
string,
unknown
>[];
expect(items.length).toBe(2);
expect(items[0].width).toBe(72);
expect(items[0].height).toBe('fit_content');
expect(items[0].alignItems).toBe('center');
expect(items[0].role).toBe('nav-item');
});
});
describe('add_scroll_row_v0 — overrides', () => {
it('respects card_width and gap overrides', async () => {
const fp = await fresh('override.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'A' }, { title: 'B' }],
card_width: 220,
gap: 24,
});
const wrapper = getRoot(await readDoc(fp));
const row = (wrapper.children as Record<string, unknown>[])[0];
expect(row.gap).toBe(24);
const cards = row.children as Record<string, unknown>[];
expect(cards[0].width).toBe(220);
expect(cards[1].width).toBe(220);
});
});
describe('add_scroll_row_v0 — persistence', () => {
it('result.nodeCount reflects full node tree (wrapper+row+cards+children)', async () => {
const fp = await fresh('persist.op');
const result = await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [{ title: 'A', subtitle: 's' }, { title: 'B', icon: 'x' }, { title: 'C' }],
});
// wrapper (1) + row (1) + 3 cards + [2 card-A children + 2 card-B children + 1 card-C children] = 10
expect(result.nodeCount).toBe(10);
expect(result.results).toHaveLength(1);
expect(result.results[0].binding).toBe('row');
expect(result.errors).toBeUndefined();
});
});
describe('add_scroll_row_v0 — snapshot golden output', () => {
it('golden: 3 cards with title+subtitle+icon', async () => {
const fp = await fresh('cards.op');
await handleAddScrollRowV0({
filePath: fp,
children_type: 'card',
items: [
{ title: 'Hiit', subtitle: '30 min', icon: 'flame' },
{ title: 'Strength', subtitle: '45 min', icon: 'dumbbell' },
{ title: 'Yoga', subtitle: '25 min', icon: 'leaf' },
],
});
const wrapper = getRoot(await readDoc(fp));
// Strip ids for stable snapshot
const stripIds = (n: unknown): unknown => {
if (Array.isArray(n)) return n.map(stripIds);
if (n && typeof n === 'object') {
const o = { ...(n as Record<string, unknown>) };
delete o.id;
for (const k of Object.keys(o)) o[k] = stripIds(o[k]);
return o;
}
return n;
};
expect(stripIds(wrapper)).toMatchSnapshot('scroll-row-cards-golden');
});
});

View file

@ -67,19 +67,35 @@ describe('D0 parity spike — additive & gated tool registration', () => {
expect(D0_SPIKE_TOOL_NAMES.has('add_section_v0')).toBe(true);
});
it('existing design tool names are exactly the pre-spike set', () => {
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name).sort();
expect(names).toEqual([
it('pre-D0 production design tools still present (no regression)', () => {
// These 5 existed when D0 spike validated Sugar route. They must remain
// present for any future additive changes. New tools (add_scroll_row_v0
// and beyond) may be added, but none of these 5 may disappear.
const names = DESIGN_TOOL_DEFINITIONS.map((t) => t.name);
for (const preD0 of [
'batch_design',
'design_content',
'design_refine',
'design_skeleton',
'get_design_prompt',
]) {
expect(names).toContain(preD0);
}
});
it('pre-D0 production design tool DEFINITIONS are unchanged (snapshot)', () => {
// Snapshot only the 5 pre-D0 tools. Any future MVP tool
// (add_scroll_row_v0 etc.) is excluded from this snapshot so it keeps
// its D0-baseline role: detecting drift in pre-existing tool schemas.
const preD0Names = new Set([
'batch_design',
'design_content',
'design_refine',
'design_skeleton',
'get_design_prompt',
]);
});
it('existing tool DEFINITIONS are unchanged (snapshot)', () => {
expect(DESIGN_TOOL_DEFINITIONS).toMatchSnapshot('existing-design-tool-definitions');
const preD0Tools = DESIGN_TOOL_DEFINITIONS.filter((t) => preD0Names.has(t.name));
expect(preD0Tools).toMatchSnapshot('pre-d0-design-tool-definitions');
});
});

View file

@ -5,6 +5,7 @@ import { handleDesignContent } from '../tools/design-content';
import { handleDesignRefine } from '../tools/design-refine';
import { LAYERED_DESIGN_TOOLS } from '../tools/layered-design-defs';
import { handleAddSectionV0 } from '../tools/add-section-v0';
import { handleAddScrollRowV0 } from '../tools/add-scroll-row-v0';
export const DESIGN_TOOL_DEFINITIONS = [
{
@ -78,6 +79,57 @@ export const DESIGN_TOOL_DEFINITIONS = [
},
},
...LAYERED_DESIGN_TOOLS,
{
name: 'add_scroll_row_v0',
description:
'Create a horizontal scroll row (card / metric_tile / nav_item) with guaranteed overflow safety. ' +
'Builds the exact nested wrapper + clipContent + fit_content inner-row structure that batch_design ' +
'generates incorrectly ~X% of the time on non-Claude models (see ' +
'packages/pen-ai-skills/skills/phases/generation/overflow.md §HORIZONTAL SCROLL ROWS). ' +
'Always prefer this tool over batch_design when the spec mentions "horizontal scrolling cards", ' +
'"swipeable row", "chip row", "metric tiles" or similar. schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
filePath: {
type: 'string',
description: 'Path to .op file, or omit to use the live canvas (default)',
},
children_type: {
type: 'string',
enum: ['card', 'metric_tile', 'nav_item'],
description:
'Kind of children to lay out. card = 140x160 vertical card with title/subtitle/icon; ' +
'metric_tile = 120x100 with small label + big value; nav_item = 72 auto-height icon+label.',
},
items: {
type: 'array',
description: 'Row items. Each needs title; subtitle and icon are optional.',
items: {
type: 'object',
properties: {
title: { type: 'string' },
subtitle: { type: 'string' },
icon: { type: 'string', description: 'lucide icon name (e.g. "activity")' },
},
required: ['title'],
},
},
card_width: {
type: 'number',
description:
'Fixed numeric width per item (overrides default: card=140, metric_tile=120, nav_item=72)',
},
gap: { type: 'number', description: 'Inner-row gap in px (default 12)' },
parent_id: {
type: 'string',
description: 'Target parent node id. Omit for root-level insertion.',
},
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
},
required: ['children_type', 'items'],
},
},
];
export const DESIGN_TOOL_NAMES = new Set([
@ -86,6 +138,7 @@ export const DESIGN_TOOL_NAMES = new Set([
'design_skeleton',
'design_content',
'design_refine',
'add_scroll_row_v0',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -113,6 +166,8 @@ export async function handleDesignToolCall(
return JSON.stringify(await handleDesignContent(a), null, 2);
case 'design_refine':
return JSON.stringify(await handleDesignRefine(a), null, 2);
case 'add_scroll_row_v0':
return JSON.stringify(await handleAddScrollRowV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,231 @@
import { handleBatchDesign } from './batch-design';
export interface AddScrollRowV0Item {
title: string;
subtitle?: string;
icon?: string;
}
export type AddScrollRowV0ChildrenType = 'card' | 'metric_tile' | 'nav_item';
export interface AddScrollRowV0Params {
children_type: AddScrollRowV0ChildrenType;
items: AddScrollRowV0Item[];
card_width?: number;
gap?: number;
parent_id?: string;
filePath?: string;
pageId?: string;
}
/**
* MVP element tool (D1=A sugar route). Builds a horizontal scroll row that
* strictly follows the pattern taught in
* `packages/pen-ai-skills/skills/phases/generation/overflow.md` §HORIZONTAL
* SCROLL ROWS: outer wrapper (fill_container + clipContent) > inner row
* (fit_content, horizontal, gap, padding) > children with fixed widths.
*
* This is the exact structure LLMs (especially non-Claude) get wrong when
* using batch_design directly. By exposing a narrow tool we force the
* correct shape at schema level.
*
* Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §7.1
*/
export async function handleAddScrollRowV0(
params: AddScrollRowV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
const gap = params.gap ?? 12;
const cardWidth = params.card_width ?? defaultCardWidth(params.children_type);
const wrapper = buildWrapperNode(params, gap, cardWidth);
const parentRef = params.parent_id ? `"${params.parent_id}"` : 'null';
const dsl = `row=I(${parentRef}, ${JSON.stringify(wrapper)})`;
return handleBatchDesign({
operations: dsl,
filePath: params.filePath,
pageId: params.pageId,
postProcess: false,
});
}
function defaultCardWidth(kind: AddScrollRowV0ChildrenType): number {
switch (kind) {
case 'card':
return 140;
case 'metric_tile':
return 120;
case 'nav_item':
return 72;
}
}
function buildWrapperNode(
params: AddScrollRowV0Params,
gap: number,
cardWidth: number,
): Record<string, unknown> {
const cards = params.items.map((item) => buildChildNode(item, params.children_type, cardWidth));
return {
type: 'frame',
name: `Scroll Row (${params.children_type})`,
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,
padding: [0, 20],
children: cards,
},
],
};
}
function buildChildNode(
item: AddScrollRowV0Item,
kind: AddScrollRowV0ChildrenType,
cardWidth: number,
): Record<string, unknown> {
switch (kind) {
case 'card':
return buildCard(item, cardWidth);
case 'metric_tile':
return buildMetricTile(item, cardWidth);
case 'nav_item':
return buildNavItem(item, cardWidth);
}
}
function buildCard(item: AddScrollRowV0Item, cardWidth: number): Record<string, unknown> {
const children: Record<string, unknown>[] = [];
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,
};
}
function buildMetricTile(item: AddScrollRowV0Item, cardWidth: number): Record<string, unknown> {
const children: Record<string, unknown>[] = [];
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.title,
fontSize: 12,
fontWeight: 500,
width: 'fill_container',
});
if (item.subtitle) {
children.push({
type: 'text',
name: 'Value',
role: 'heading',
content: item.subtitle,
fontSize: 28,
fontWeight: 700,
width: 'fill_container',
});
}
return {
type: 'frame',
name: 'Metric Tile',
role: 'metric-tile',
width: cardWidth,
height: 100,
cornerRadius: 16,
padding: 16,
layout: 'vertical',
gap: 4,
children,
};
}
function buildNavItem(item: AddScrollRowV0Item, cardWidth: number): Record<string, unknown> {
const children: Record<string, unknown>[] = [];
if (item.icon) {
children.push({
type: 'icon_font',
name: 'Icon',
iconFontName: item.icon,
iconFontFamily: 'lucide',
width: 24,
height: 24,
});
}
children.push({
type: 'text',
name: 'Label',
role: 'label',
content: item.title,
fontSize: 11,
fontWeight: 500,
});
return {
type: 'frame',
name: 'Nav Item',
role: 'nav-item',
width: cardWidth,
height: 'fit_content',
cornerRadius: 12,
padding: [8, 12],
layout: 'vertical',
gap: 4,
alignItems: 'center',
children,
};
}