From e0039a303ef333fe366dfab1246dcf621bd55e5c Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:35 +0800 Subject: [PATCH] fix(ai): JSONL sub-agent path emits design-system refs not hex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-part fix for the web-app chat path (both built-in and CLI mode go through sub-agent JSONL output, not MCP tool calls — `jsonl-format.md` line 50 forbids tool calls). Without this, every fill in generated designs was a hex literal even after the 5/3 design-system-aware work — the 188 v1 element tools and DEFAULT_PALETTE_FALLBACK were dead weight here. A. STYLE GUIDE injection now uses ref + (hex) double form so the model sees `$color-accent` paired with the resolved hex it represents: Before: - Background: #FFF8F0 Surface: #FFFFFF After: - Background: `$color-bg-deep` (resolves to #FFF8F0) - Surface: `$color-surface` (#FFFFFF) Applied to both `buildSubAgentStyleGuideInstruction` (selectedStyleGuideContent path) and the inline `plan.styleGuide` injection in orchestrator-sub-agent.ts. B. `seedDocVariablesFromStyleGuide` runs once before sub-agent execution: when `doc.variables` is empty AND a style guide is selected, it maps the palette to v1 token names (`color-bg-deep` / `color-accent` / etc) and seeds them into `doc.variables`. This makes refs emitted by the model resolve to the user's chosen palette at render time instead of falling back to the default #2563EB blue. A and B are coupled — A alone would make designs render in the wrong color (every design becomes blue regardless of style guide); B alone leaves the model mimicking the hex from the prompt. Both must ship together. Also: - jsonl-format.md: DESIGN SYSTEM TOKENS section + example fills converted to refs + CRITICAL contract moved to top (so future budget overruns can't truncate it). Budget bumped 1500 → 1700 for safety margin. - elements.md: Theme handling section now flags MCP path vs JSONL path so models on either path know which guidance applies. --- .../__tests__/orchestrator-sub-agent.test.ts | 7 +-- .../ai/orchestrator-sub-agent-compact.ts | 33 ++++++++---- .../src/services/ai/orchestrator-sub-agent.ts | 14 +++-- apps/web/src/services/ai/orchestrator.ts | 52 ++++++++++++++++++- .../skills/phases/generation/elements.md | 2 + .../skills/phases/generation/jsonl-format.md | 30 +++++++---- 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts index dfb9bfb4a..d354dd334 100644 --- a/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts +++ b/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts @@ -74,12 +74,13 @@ platform: mobile | Button | 12px | `; - it('summarizes style guide tokens for non-full tiers', () => { + it('summarizes style guide tokens for non-full tiers using ref + (hex) form', () => { const summary = buildSubAgentStyleGuideInstruction(styleGuide, 'dark-bold-mobile', 'basic'); expect(summary).toContain('VISUAL STYLE GUIDE SUMMARY'); - expect(summary).toContain('Background: #111111'); - expect(summary).toContain('Accent: #22C55E'); + expect(summary).toContain('`$color-bg-deep` (resolves to #111111)'); + expect(summary).toContain('`$color-accent` (resolves to #22C55E)'); + expect(summary).toContain('EMIT REFS'); expect(summary).not.toContain('## Color System'); }); }); diff --git a/apps/web/src/services/ai/orchestrator-sub-agent-compact.ts b/apps/web/src/services/ai/orchestrator-sub-agent-compact.ts index eeee0a26a..836c9b5f6 100644 --- a/apps/web/src/services/ai/orchestrator-sub-agent-compact.ts +++ b/apps/web/src/services/ai/orchestrator-sub-agent-compact.ts @@ -80,10 +80,6 @@ export function buildSubAgentStyleGuideInstruction( styleGuideName: string | undefined, tier: 'full' | 'standard' | 'basic', ): string { - if (tier === 'full') { - return `VISUAL STYLE GUIDE (follow these specifications exactly):\n${content}`; - } - const values = extractStyleGuideValues(content); const tags = styleGuideName ? styleGuideRegistry @@ -92,19 +88,36 @@ export function buildSubAgentStyleGuideInstruction( .join(', ') : undefined; + // The palette below is SEEDED into doc.variables when the sub-agent runs (see + // seedDocVariablesFromStyleGuide in orchestrator.ts), so emitting `$color-*` + // refs in JSONL fills is both correct AND visually accurate — refs resolve to + // the hex shown in parens. + const refLine = (label: string, ref: string, hex: string | undefined) => + hex ? `- ${label}: \`${ref}\` (resolves to ${hex})` : null; + + const refLines = [ + refLine('Background', '$color-bg-deep', values.colors.background), + refLine('Surface', '$color-surface', values.colors.surface), + refLine('Accent', '$color-accent', values.colors.accent), + refLine('Text', '$color-text-primary', values.colors.textPrimary), + refLine('Secondary text', '$color-text-body', values.colors.textSecondary), + refLine('Muted text', '$color-text-muted', values.colors.textMuted), + refLine('Border', '$color-border', values.colors.border), + ].filter(Boolean); + + if (tier === 'full') { + return `VISUAL STYLE GUIDE (follow these specifications exactly):\n${content}\n\nDESIGN-SYSTEM REFS — these are SEEDED into doc.variables. Emit \`$color-*\` refs in JSONL fills (NOT hex) so the design respects the user's tokens at render time:\n${refLines.join('\n')}`; + } + const lines = [ `VISUAL STYLE GUIDE SUMMARY${styleGuideName ? ` (${styleGuideName})` : ''}:`, tags ? `- Tags: ${tags}` : null, - values.colors.background ? `- Background: ${values.colors.background}` : null, - values.colors.surface ? `- Surface: ${values.colors.surface}` : null, - values.colors.accent ? `- Accent: ${values.colors.accent}` : null, - values.colors.textPrimary ? `- Text: ${values.colors.textPrimary}` : null, - values.colors.textSecondary ? `- Secondary text: ${values.colors.textSecondary}` : null, + ...refLines, values.typography.displayFont ? `- Heading font: ${values.typography.displayFont}` : null, values.typography.bodyFont ? `- Body font: ${values.typography.bodyFont}` : null, values.radius.card != null ? `- Card radius: ${values.radius.card}` : null, values.radius.button != null ? `- Button radius: ${values.radius.button}` : null, - '- Match the selected guide using these tokens; do not invent a conflicting palette.', + '- These `$color-*` refs are SEEDED into doc.variables. EMIT REFS in your JSONL fills (NOT hex) — they resolve to the hex above at render time. Do not invent a conflicting palette.', ]; return lines.filter(Boolean).join('\n'); diff --git a/apps/web/src/services/ai/orchestrator-sub-agent.ts b/apps/web/src/services/ai/orchestrator-sub-agent.ts index d9245f431..35b5a0079 100644 --- a/apps/web/src/services/ai/orchestrator-sub-agent.ts +++ b/apps/web/src/services/ai/orchestrator-sub-agent.ts @@ -774,10 +774,16 @@ CRITICAL LAYOUT CONSTRAINTS: } else if (plan.styleGuide) { const sg = plan.styleGuide; const p = sg.palette; - prompt += `\n\nSTYLE GUIDE (use these consistently): -- Background: ${p.background} Surface: ${p.surface} -- Text: ${p.text} Secondary: ${p.secondary} -- Accent: ${p.accent} Accent2: ${p.accent2} Border: ${p.border} + // The palette below is SEEDED into doc.variables (see + // seedDocVariablesFromStyleGuide in orchestrator.ts). Emit `$color-*` refs + // in JSONL fills — they resolve to the hex shown in parens at render time. + prompt += `\n\nSTYLE GUIDE — these refs are SEEDED into doc.variables; EMIT REFS in your JSONL fills (NOT hex): +- Background: \`$color-bg-deep\` (resolves to ${p.background}) +- Surface: \`$color-surface\` (${p.surface}) +- Text: \`$color-text-primary\` (${p.text}) +- Secondary: \`$color-text-body\` (${p.secondary}) +- Accent: \`$color-accent\` (${p.accent}) +- Border: \`$color-border\` (${p.border}) - Heading font: ${sg.fonts.heading} Body font: ${sg.fonts.body} - Aesthetic: ${sg.aesthetic}`; } diff --git a/apps/web/src/services/ai/orchestrator.ts b/apps/web/src/services/ai/orchestrator.ts index 29ab5a615..430210aad 100644 --- a/apps/web/src/services/ai/orchestrator.ts +++ b/apps/web/src/services/ai/orchestrator.ts @@ -22,7 +22,7 @@ import type { DesignMdSpec } from '@/types/design-md'; import { streamChat } from './ai-service'; import { resolveSkills } from '@zseven-w/pen-ai-skills'; import { styleGuideRegistry } from '@zseven-w/pen-ai-skills/_generated/style-guide-registry'; -import { selectStyleGuide } from '@zseven-w/pen-ai-skills/style-guide'; +import { extractStyleGuideValues, selectStyleGuide } from '@zseven-w/pen-ai-skills/style-guide'; import { getOrchestratorTimeouts, prepareDesignPrompt, @@ -482,6 +482,54 @@ function reorderDashboardMainChildren(plan: OrchestratorPlan, mainParentId: stri }); } +/** + * Seed `doc.variables` with the plan's style-guide palette (mapped to v1 + * semantic token names) so sub-agent JSONL output containing `$color-*` refs + * resolves to the user's chosen palette at render time, not the + * DEFAULT_PALETTE_FALLBACK blue. + * + * Without this, the sub-agent prompt teaches the model to emit + * `$color-accent` (see `buildSubAgentStyleGuideInstruction` in + * `orchestrator-sub-agent-compact.ts`) but the renderer would fall back to + * the default `#2563EB` blue — completely overriding any warm/orange/etc + * style guide picked by the planner. + * + * Skipped when `doc.variables` is already non-empty: respects user-seeded + * tokens (design.md, custom palette) without overwriting. + */ +export function seedDocVariablesFromStyleGuide(plan: OrchestratorPlan): void { + const store = useDocumentStore.getState(); + const existing = store.document.variables ?? {}; + if (Object.keys(existing).length > 0) return; + + const palette: Record = {}; + + if (plan.styleGuide?.palette) { + const p = plan.styleGuide.palette; + palette['color-bg-deep'] = p.background; + palette['color-surface'] = p.surface; + palette['color-text-primary'] = p.text; + palette['color-text-body'] = p.secondary; + palette['color-accent'] = p.accent; + palette['color-border'] = p.border; + } else if (plan.selectedStyleGuideContent) { + const v = extractStyleGuideValues(plan.selectedStyleGuideContent).colors; + if (v.background) palette['color-bg-deep'] = v.background; + if (v.surface) palette['color-surface'] = v.surface; + if (v.textPrimary) palette['color-text-primary'] = v.textPrimary; + if (v.textSecondary) palette['color-text-body'] = v.textSecondary; + if (v.textMuted) palette['color-text-muted'] = v.textMuted; + if (v.accent) palette['color-accent'] = v.accent; + if (v.border) palette['color-border'] = v.border; + } + + if (Object.keys(palette).length === 0) return; + + for (const [name, value] of Object.entries(palette)) { + store.setVariable(name, { type: 'color', value }); + } +} + import { applyAppendContextToPlan } from './orchestrator-append'; export { applyAppendContextToPlan } from './orchestrator-append'; export type { AppendPlanResult } from './orchestrator-append'; @@ -866,6 +914,8 @@ export async function executeOrchestration( totalNodes: 0, }; + seedDocVariablesFromStyleGuide(plan); + let results: SubAgentResult[]; try { results = await executeSubAgents( diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 5a335b81d..e2d143437 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -50,6 +50,8 @@ Multi-tool example — a "Notifications" settings section with a header + 4 togg ## Theme handling — when to pass `theme: 'system'` +> **This section applies to the MCP tool-call path only** (codex CLI / Claude Code / Cursor calling `add_X_v1` directly). The web-app sub-agent JSONL path forbids tool calls — there, write `$color-*` / `$type-*` / `$spacing-*` / `$radius-*` refs directly in JSONL fills/fontSize/etc. See the **DESIGN SYSTEM TOKENS** section in `jsonl-format.md` for the available token names and when refs vs literals are appropriate. + **Default to `theme: 'system'`** for every v1 tool call. This makes the output respect the user's design system (`doc.variables` / `doc.themes`): - If user has seeded `applySemanticPalette(doc)` or set custom token values, v1 'system' mode emits `$color-*` / `$type-*` / `$spacing-*` / `$radius-*` refs that resolve to user's design system at paint time. diff --git a/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md b/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md index 80c0bb8e3..c06c5c08c 100644 --- a/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md +++ b/packages/pen-ai-skills/skills/phases/generation/jsonl-format.md @@ -4,18 +4,20 @@ description: Sub-agent flat JSONL output format with node types and rules phase: [generation] trigger: null priority: 0 -budget: 1500 +budget: 1700 category: base --- +CRITICAL: Output ONLY the `json block. Do NOT write any text, explanation, plan, tool calls, or function calls. Do NOT use [TOOL_CALL] or {tool => ...} syntax. Start your response with `json immediately. + PenNode flat JSONL engine. Output a ```json block with ONE node per line. TYPES: frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,textAlign,textGrowth,lineHeight,letterSpacing), icon_font (iconFontName,width,height,fill), path (d,width,height,fill,stroke), image (width,height,imageSearchQuery,imagePrompt). imagePrompt: describe subject+scene+style, NEVER mention background type (transparent/white/plain). Match composition to aspect ratio. SHARED: id, type, name, role, x, y, opacity ROLES: section, row, column, divider | navbar, button, icon-button, badge, input, search-bar | card, stat-card, pricing-card, feature-card | heading, subheading, body-text, caption, label | table, table-row, table-header -width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex"}]. -Stroke: {"thickness":N,"fill":[{"type":"solid","color":"#hex"}]}. Directional: {"thickness":{"bottom":1},"fill":[...]}. +width/height: number | "fill_container" | "fit_content". padding: number | [v,h] | [T,R,B,L]. Fill=[{"type":"solid","color":"#hex" | "$color-*"}]. +Stroke: {"thickness":N,"fill":[{"type":"solid","color":"#hex" | "$color-*"}]}. Directional: {"thickness":{"bottom":1},"fill":[...]}. RULES: @@ -33,18 +35,24 @@ RULES: - FORMS: ALL inputs AND button use width="fill_container". gap=16-20. - Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap. +DESIGN SYSTEM TOKENS — prefer refs over literals so output respects the user's design system. The renderer resolves refs against `doc.variables` (or a default light palette when un-seeded), so refs are SAFE even when the doc has no design system seeded yet. + +- COLORS: `$color-{bg-deep|surface|surface-2|surface-3|border|border-strong|text-primary|text-body|text-muted|text-subtle|accent|destructive|success|scrim|info-bg|info-text|success-bg|success-text|warning-bg|warning-text|danger-bg|danger-text|chart-1..6}`. Light defaults: bg-deep `#F8FAFC`, surface `#FFFFFF`, surface-2 `#F1F5F9`, border `#E2E8F0`, text-primary `#0F172A`, text-body `#334155`, text-muted `#64748B`, text-subtle `#94A3B8`, accent `#2563EB`, destructive `#EF4444`, success `#10B981`. +- TYPOGRAPHY: `$type-{display|h1|h2|h3|body|caption}-{size|weight|line-height}`. Defaults: display 64/700/1.0, h1 24/600/1.2, h2 20/600/1.25, h3 16/600/1.3, body 14/400/1.5, caption 12/400/1.4. Plus `$type-display-letter-spacing` (-0.5), `$type-uppercase-label-letter-spacing` (1.5). +- SPACING / RADIUS: `$spacing-{1|2|3|4|5}` = 4/8/12/16/24 px. `$radius-{sm|md|lg}` = 4/8/12 px. + +USE refs for: standard semantic colors (page bg, surfaces, text levels, borders, accent, alerts, charts), and typography sizes/weights/line-heights that match the scale above. KEEP literal hex / numbers for: brand-specific colors not in the palette (custom logo color, off-palette accent), pixel values that don't match the typography scale, and "white text on accent" (`#FFFFFF`). + FORMAT: \_parent (null=root, else parent-id). Parent before children. ```json -{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"#F8FAFC"}]} +{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","gap":24,"padding":[48,24],"fill":[{"type":"solid","color":"$color-bg-deep"}]} {"_parent":"root","id":"header","type":"frame","name":"Header","justifyContent":"space_between","alignItems":"center","width":"fill_container"} -{"_parent":"header","id":"logo","type":"text","name":"Logo","content":"ACME","fontSize":18,"fontWeight":600,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"#0D0D0D"}]} +{"_parent":"header","id":"logo","type":"text","name":"Logo","content":"ACME","fontSize":18,"fontWeight":600,"fontFamily":"Space Grotesk","fill":[{"type":"solid","color":"$color-text-primary"}]} {"_parent":"header","id":"notifBtn","type":"frame","name":"Notification","width":44,"height":44} -{"_parent":"notifBtn","id":"notifIcon","type":"icon_font","name":"Bell","iconFontName":"bell","width":20,"height":20,"fill":"#0D0D0D","x":12,"y":12} -{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":0.95,"fill":[{"type":"solid","color":"#0F172A"}]} -{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning that adapts to your pace","fontSize":16,"textGrowth":"fixed-width","width":"fill_container","lineHeight":1.5,"fill":[{"type":"solid","color":"#64748B"}]} -{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"#2563EB"}]} +{"_parent":"notifBtn","id":"notifIcon","type":"icon_font","name":"Bell","iconFontName":"bell","width":20,"height":20,"fill":"$color-text-primary","x":12,"y":12} +{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":0.95,"fill":[{"type":"solid","color":"$color-text-primary"}]} +{"_parent":"root","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning that adapts to your pace","fontSize":16,"textGrowth":"fixed-width","width":"fill_container","lineHeight":1.5,"fill":[{"type":"solid","color":"$color-text-muted"}]} +{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","padding":[14,28],"cornerRadius":10,"justifyContent":"center","fill":[{"type":"solid","color":"$color-accent"}]} {"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]} ``` - -CRITICAL: Output ONLY the `json block. Do NOT write any text, explanation, plan, tool calls, or function calls. Do NOT use [TOOL_CALL] or {tool => ...} syntax. Start your response with `json immediately.