fix(ai): JSONL sub-agent path emits design-system refs not hex

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.
This commit is contained in:
Fini 2026-04-29 09:50:35 +08:00
parent e80326473d
commit e0039a303e
6 changed files with 109 additions and 29 deletions

View file

@ -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');
});
});

View file

@ -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');

View file

@ -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}`;
}

View file

@ -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<string, string> = {};
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(

View file

@ -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.

View file

@ -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.