feat(orchestrator): design_system — generator + variable seeding + prompt context

Task B1 of S4. Adds three functions to design_system.rs:
- generate_design_system(): async LLM call with design-system skill as system
  prompt; parses response via parse_design_system; falls back to DEFAULT on
  any failure (port of design-system-generator.ts:20-29).
- design_system_to_seed_commands(): emits SetVariableColor/SetVariableScalar
  for 8 palette tokens (color-*), 6 spacing steps (spacing-xs..2xl), 3 radius
  steps (radius-sm/md/lg), 2 font strings (font-heading/font-body), and 6 type
  scale steps (font-size-1..6) — 25 commands for DEFAULT (port of TS:134-156).
- design_system_to_prompt_context(): byte-exact port of the TS template prose
  "DESIGN SYSTEM (use these values consistently):\nColors: bg ... Style: ..."
  (port of TS:161-170).

11 new TDD tests; full suite 550 green (baseline was 539).
This commit is contained in:
Fini 2026-05-23 20:10:35 +08:00
parent 8744947712
commit 0c4acd554d
3 changed files with 563 additions and 7 deletions

View file

@ -1,18 +1,22 @@
//! `design_system.rs` — S4 A1: `DesignSystem` struct, `DEFAULT_DESIGN_SYSTEM`
//! const, and `parse_design_system` JSON-cleaning fallback chain.
//! `design_system.rs` — S4 A1 + B1: `DesignSystem` struct, parse, defaults,
//! LLM generator, variable seeding, and prompt context.
//!
//! Port of `design-system-generator.ts` (deleted in commit `0f12b6e9`),
//! specifically:
//! Port of `design-system-generator.ts` (deleted in commit `0f12b6e9`):
//! - L20-29: `generateDesignSystem` entry (→ `generate_design_system`).
//! - L40-100: `parseDesignSystem` + `tryParseDS` 4-stage fallback chain.
//! - L102-124: `DEFAULT_DESIGN_SYSTEM` constant values.
//! - L134-156: `designSystemToVariables` (→ `design_system_to_seed_commands`).
//! - L161-170: `designSystemToPromptContext` (→ `design_system_to_prompt_context`).
//! - L59-81: `DesignSystem` interface (via `ai-types.ts`).
//!
//! No LLM call yet — that is added in Task B1.
use futures::StreamExt;
use op_editor_core::{EditorCommand, VariableScalarPayload};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::OnceLock;
use crate::types::{AbortFlag, CallRequest, LlmChunk, LlmClient};
// ── DesignSystem struct (mirrors TS ai-types.ts:59-81) ────────────────────────
/// Typography section of the design system.
@ -265,6 +269,214 @@ fn extract_code_fence(text: &str) -> Option<&str> {
Some(inner)
}
// ── B1: generate_design_system (port of TS L20-29) ───────────────────────────
/// Generate a `DesignSystem` from a user prompt via a single LLM call.
///
/// Port of `generateDesignSystem` in `design-system-generator.ts:20-29`:
/// 1. Loads the `design-system` skill as system prompt via
/// `op_ai_skills::get_skill_by_name`.
/// 2. Makes a single (non-streaming) `LlmClient::call` with the user prompt.
/// 3. Collects all text chunks, then parses via `parse_design_system`.
/// 4. Falls back to `DEFAULT_DESIGN_SYSTEM` on any parse/LLM failure.
///
/// The `model` / `provider` / `abort` params match the existing `CallRequest`
/// shape used by `subagent.rs` and `prompt.rs`.
pub async fn generate_design_system(
prompt: &str,
llm: &dyn LlmClient,
model: Option<&str>,
provider: Option<&str>,
abort: &AbortFlag,
) -> DesignSystem {
// Load the design-system skill content as system prompt.
let system_prompt = op_ai_skills::get_skill_by_name("design-system")
.map(|e| e.content.as_str())
.unwrap_or("")
.to_string();
let req = CallRequest {
system_prompt,
user_prompt: prompt.to_string(),
model: model.map(|s| s.to_string()),
provider: provider.map(|s| s.to_string()),
timeout: std::time::Duration::from_secs(30),
abort: abort.clone(),
no_text_timeout: None,
first_text_timeout: None,
};
// Collect all text chunks.
let mut stream = llm.call(req);
let mut text = String::new();
while let Some(item) = stream.next().await {
match item {
Ok(LlmChunk::Text(t)) => text.push_str(&t),
Ok(LlmChunk::Thinking(_)) => {}
Err(_) => break,
}
}
// Parse and return; falls back to DEFAULT on failure.
parse_design_system(&text)
}
// ── B1: design_system_to_seed_commands (port of TS L134-156) ─────────────────
/// Convert a `DesignSystem` into `EditorCommand::SetVariable*` commands.
///
/// Faithful port of `designSystemToVariables` in
/// `design-system-generator.ts:134-156`. Emits ONLY:
/// - Palette: `color-{kebab(key)}` → `SetVariableColor` (one per palette entry)
/// - Spacing scale: `spacing-{xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl}` →
/// `SetVariableScalar::Number` (capped at `spacingNames.length`)
/// - Radius: `radius-{sm|md|lg|xl}` → `SetVariableScalar::Number` (capped at
/// `radiusNames.length`)
///
/// Typography is NOT seeded into document variables — the TS source feeds
/// heading/body fonts + type scale into the LLM via
/// `design_system_to_prompt_context` only.
///
/// DEFAULT_DESIGN_SYSTEM emits exactly 17 commands: 8 palette + 6 spacing + 3 radius.
pub fn design_system_to_seed_commands(ds: &DesignSystem) -> Vec<EditorCommand> {
let mut cmds = Vec::new();
// Colors: palette → SetVariableColor with kebab-case name.
// TS: `for (const [key, value] of Object.entries(ds.palette))`
// Note: BTreeMap iterates in sorted key order, which is fine for determinism.
for (key, value) in &ds.palette {
let name = format!("color-{}", camel_to_kebab(key));
cmds.push(EditorCommand::SetVariableColor {
name,
hex: value.clone(),
});
}
// Spacing scale → spacing-xs/sm/md/lg/xl/2xl/3xl/4xl/5xl/6xl
// TS: `const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']`
const SPACING_NAMES: &[&str] = &[
"xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl",
];
for (i, &label) in SPACING_NAMES
.iter()
.enumerate()
.take(ds.spacing.scale.len())
{
let name = format!("spacing-{label}");
cmds.push(EditorCommand::SetVariableScalar {
name,
scalar: VariableScalarPayload::Number(ds.spacing.scale[i]),
});
}
// Radius → radius-sm/md/lg/xl
// TS: `const radiusNames = ['sm', 'md', 'lg', 'xl']`
const RADIUS_NAMES: &[&str] = &["sm", "md", "lg", "xl"];
for (i, &label) in RADIUS_NAMES.iter().enumerate().take(ds.radius.len()) {
let name = format!("radius-{label}");
cmds.push(EditorCommand::SetVariableScalar {
name,
scalar: VariableScalarPayload::Number(ds.radius[i]),
});
}
cmds
}
/// Convert a camelCase string to kebab-case.
///
/// Port of `kebab` in `design-system-generator.ts`:
/// `str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()`
fn camel_to_kebab(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let chars: Vec<char> = s.chars().collect();
for i in 0..chars.len() {
let c = chars[i];
if i > 0 && c.is_uppercase() && chars[i - 1].is_lowercase() {
out.push('-');
}
out.push(c.to_ascii_lowercase());
}
out
}
// ── B1: design_system_to_prompt_context (port of TS L161-170) ────────────────
/// Build a fixed-form design-system context string for AI prompts.
///
/// Port of `designSystemToPromptContext` in `design-system-generator.ts:161-170`:
/// ```text
/// DESIGN SYSTEM (use these values consistently):
/// Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, ...
/// Fonts: heading "{headingFont}", body "{bodyFont}"
/// Type scale: {scale}px
/// Spacing: {scale}px ({unit}px grid)
/// Radius: {radius}px
/// Style: {aesthetic}
/// ```
pub fn design_system_to_prompt_context(ds: &DesignSystem) -> String {
let p = &ds.palette;
let bg = p.get("background").map(|s| s.as_str()).unwrap_or("");
let surface = p.get("surface").map(|s| s.as_str()).unwrap_or("");
let text = p.get("text").map(|s| s.as_str()).unwrap_or("");
let muted = p.get("textSecondary").map(|s| s.as_str()).unwrap_or("");
let primary = p.get("primary").map(|s| s.as_str()).unwrap_or("");
let primary_light = p.get("primaryLight").map(|s| s.as_str()).unwrap_or("");
let accent = p.get("accent").map(|s| s.as_str()).unwrap_or("");
let border = p.get("border").map(|s| s.as_str()).unwrap_or("");
// Type scale: "14, 16, 20, 28, 40, 56px"
let type_scale = ds
.typography
.scale
.iter()
.map(|v| format_num(*v))
.collect::<Vec<_>>()
.join(", ");
// Spacing scale: "8, 16, 24, 32, 48, 64px"
let spacing_scale = ds
.spacing
.scale
.iter()
.map(|v| format_num(*v))
.collect::<Vec<_>>()
.join(", ");
let spacing_unit = format_num(ds.spacing.unit);
// Radius: "8, 12, 16px"
let radius = ds
.radius
.iter()
.map(|v| format_num(*v))
.collect::<Vec<_>>()
.join(", ");
format!(
"DESIGN SYSTEM (use these values consistently):\n\
Colors: bg {bg}, surface {surface}, text {text}, muted {muted}, primary {primary}, primaryLight {primary_light}, accent {accent}, border {border}\n\
Fonts: heading \"{heading}\", body \"{body}\"\n\
Type scale: {type_scale}px\n\
Spacing: {spacing_scale}px ({spacing_unit}px grid)\n\
Radius: {radius}px\n\
Style: {aesthetic}",
heading = ds.typography.heading_font,
body = ds.typography.body_font,
aesthetic = ds.aesthetic,
)
}
/// Format a float as an integer if it has no fractional part, or with 1
/// decimal place otherwise. Matches the JS number-to-string behaviour for
/// the values in the design system (all are whole numbers in practice).
fn format_num(v: f64) -> String {
if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
format!("{v:.1}")
}
}
#[cfg(test)]
#[path = "design_system_tests.rs"]
mod tests;

View file

@ -1,4 +1,5 @@
//! Tests for `design_system.rs` — A1 step 1 (failing tests first, TDD).
//! B1 tests appended at the bottom.
#[cfg(test)]
mod tests {
@ -217,4 +218,346 @@ mod tests {
assert_eq!(ds.typography.heading_font, "Space Grotesk");
assert_eq!(ds.spacing.unit, 8.0);
}
// ── Task B1: generate_design_system ──────────────────────────────────────
/// Scripted LLM returning valid JSON design-system → parsed DesignSystem
/// (not the default — the LLM-provided values win).
#[tokio::test]
async fn generate_design_system_happy_path() {
use crate::design_system::generate_design_system;
use crate::test_support::{ScriptResponse, ScriptedLlm};
use crate::types::AbortFlag;
// Craft a valid JSON that differs from DEFAULT_DESIGN_SYSTEM so we
// can confirm the LLM value was used.
// Build the JSON string using serde_json to avoid raw-string delimiter conflicts.
let custom_json = serde_json::json!({
"palette": {
"background": "\u{23}111111",
"surface": "\u{23}222222",
"text": "\u{23}FFFFFF",
"textSecondary": "\u{23}AAAAAA",
"primary": "\u{23}FF0000",
"primaryLight": "\u{23}FF9999",
"accent": "\u{23}00FF00",
"border": "\u{23}333333"
},
"typography": {
"headingFont": "Roboto",
"bodyFont": "Open Sans",
"scale": [12.0_f64, 14.0, 18.0, 24.0, 36.0, 48.0]
},
"spacing": { "unit": 4.0_f64, "scale": [4.0_f64, 8.0, 12.0, 16.0, 24.0, 32.0] },
"radius": [4.0_f64, 8.0, 12.0],
"aesthetic": "dark minimal"
})
.to_string();
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(custom_json.to_string())]);
let abort = AbortFlag::new();
let ds = generate_design_system("a dark app", &llm, None, None, &abort).await;
// LLM-provided values must win over default
assert_eq!(ds.palette["background"], "#111111");
assert_eq!(ds.typography.heading_font, "Roboto");
assert_eq!(ds.typography.body_font, "Open Sans");
assert_eq!(ds.aesthetic, "dark minimal");
assert_eq!(ds.radius, vec![4.0, 8.0, 12.0]);
}
/// Scripted LLM returning garbage → fallback to DEFAULT_DESIGN_SYSTEM.
#[tokio::test]
async fn generate_design_system_garbage_falls_back_to_default() {
use crate::design_system::generate_design_system;
use crate::test_support::{ScriptResponse, ScriptedLlm};
use crate::types::AbortFlag;
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(
"not valid json at all!".to_string(),
)]);
let abort = AbortFlag::new();
let ds = generate_design_system("any prompt", &llm, None, None, &abort).await;
let default = default_design_system();
assert_eq!(ds.palette, default.palette);
assert_eq!(ds.aesthetic, default.aesthetic);
}
/// LLM returning JSON wrapped in code fence → parsed correctly.
#[tokio::test]
async fn generate_design_system_code_fence_response() {
use crate::design_system::generate_design_system;
use crate::test_support::{ScriptResponse, ScriptedLlm};
use crate::types::AbortFlag;
let ds_default = default_design_system();
let inner = serde_json::to_string(ds_default).unwrap();
let fenced = format!("```json\n{inner}\n```");
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(fenced)]);
let abort = AbortFlag::new();
let ds = generate_design_system("prompt", &llm, None, None, &abort).await;
assert_eq!(ds.palette, ds_default.palette);
}
// ── Task B1: design_system_to_seed_commands ───────────────────────────────
/// DEFAULT_DESIGN_SYSTEM → expected number of SetVariable* commands.
/// Faithful to TS `designSystemToVariables` (L134-156): typography is NOT
/// seeded into document variables, only colors + spacing + radius.
/// 8 palette (color) + 6 spacing scale + 3 radius = 17.
#[test]
fn seed_commands_default_count() {
use crate::design_system::design_system_to_seed_commands;
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
assert_eq!(
cmds.len(),
17,
"expected 17 seed commands (8 palette + 6 spacing + 3 radius), got {}",
cmds.len()
);
}
/// DEFAULT_DESIGN_SYSTEM → no typography variables emitted.
/// Faithful to TS — typography reaches the LLM via prompt context, not vars.
#[test]
fn seed_commands_no_typography_variables() {
use crate::design_system::design_system_to_seed_commands;
use op_editor_core::EditorCommand;
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
let has_font_var = cmds.iter().any(|c| match c {
EditorCommand::SetVariableColor { name, .. }
| EditorCommand::SetVariableScalar { name, .. } => {
name.starts_with("font-") || name.starts_with("typography-")
}
_ => false,
});
assert!(
!has_font_var,
"typography MUST NOT be seeded into document variables (faithful to TS)"
);
}
/// Palette colors → SetVariableColor with kebab-case names.
#[test]
fn seed_commands_palette_color_names() {
use crate::design_system::design_system_to_seed_commands;
use op_editor_core::EditorCommand;
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
// Collect all SetVariableColor names
let color_names: Vec<String> = cmds
.iter()
.filter_map(|c| {
if let EditorCommand::SetVariableColor { name, .. } = c {
Some(name.clone())
} else {
None
}
})
.collect();
// Verify all 8 palette keys are present in kebab-case
assert!(
color_names.contains(&"color-background".to_string()),
"missing color-background"
);
assert!(
color_names.contains(&"color-text".to_string()),
"missing color-text"
);
assert!(
color_names.contains(&"color-text-secondary".to_string()),
"missing color-text-secondary (textSecondary → text-secondary)"
);
assert!(
color_names.contains(&"color-primary-light".to_string()),
"missing color-primary-light (primaryLight → primary-light)"
);
assert_eq!(color_names.len(), 8, "expected 8 color variables");
}
/// Palette color value is correctly mapped.
#[test]
fn seed_commands_palette_color_value() {
use crate::design_system::design_system_to_seed_commands;
use op_editor_core::EditorCommand;
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
let bg_cmd = cmds.iter().find(|c| {
matches!(c, EditorCommand::SetVariableColor { name, .. } if name == "color-background")
});
assert!(bg_cmd.is_some(), "missing color-background command");
if let Some(EditorCommand::SetVariableColor { hex, .. }) = bg_cmd {
assert_eq!(hex, "#F8FAFC", "wrong color-background value");
}
}
/// Spacing scale → SetVariableScalar::Number with spacing-xs/sm/... names.
#[test]
fn seed_commands_spacing_scale_names() {
use crate::design_system::design_system_to_seed_commands;
use op_editor_core::{EditorCommand, VariableScalarPayload};
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
let spacing_names: Vec<String> = cmds
.iter()
.filter_map(|c| {
if let EditorCommand::SetVariableScalar {
name,
scalar: VariableScalarPayload::Number(_),
} = c
{
if name.starts_with("spacing-") {
return Some(name.clone());
}
}
None
})
.collect();
assert!(
spacing_names.contains(&"spacing-xs".to_string()),
"missing spacing-xs"
);
assert!(
spacing_names.contains(&"spacing-sm".to_string()),
"missing spacing-sm"
);
assert!(
spacing_names.contains(&"spacing-md".to_string()),
"missing spacing-md"
);
assert!(
spacing_names.contains(&"spacing-lg".to_string()),
"missing spacing-lg"
);
assert!(
spacing_names.contains(&"spacing-xl".to_string()),
"missing spacing-xl"
);
assert!(
spacing_names.contains(&"spacing-2xl".to_string()),
"missing spacing-2xl"
);
assert_eq!(spacing_names.len(), 6, "expected 6 spacing variables");
}
/// Radius steps → SetVariableScalar::Number with radius-sm/md/lg names.
#[test]
fn seed_commands_radius_names() {
use crate::design_system::design_system_to_seed_commands;
use op_editor_core::{EditorCommand, VariableScalarPayload};
let ds = default_design_system();
let cmds = design_system_to_seed_commands(ds);
let radius_names: Vec<String> = cmds
.iter()
.filter_map(|c| {
if let EditorCommand::SetVariableScalar {
name,
scalar: VariableScalarPayload::Number(_),
} = c
{
if name.starts_with("radius-") {
return Some(name.clone());
}
}
None
})
.collect();
assert!(
radius_names.contains(&"radius-sm".to_string()),
"missing radius-sm"
);
assert!(
radius_names.contains(&"radius-md".to_string()),
"missing radius-md"
);
assert!(
radius_names.contains(&"radius-lg".to_string()),
"missing radius-lg"
);
assert_eq!(radius_names.len(), 3, "expected 3 radius variables");
}
// ── Task B1: design_system_to_prompt_context ─────────────────────────────
/// `design_system_to_prompt_context` produces the exact TS template.
#[test]
fn prompt_context_exact_template() {
use crate::design_system::design_system_to_prompt_context;
let ds = default_design_system();
let ctx = design_system_to_prompt_context(ds);
// Verify structural lines (port of TS L161-170 format)
assert!(
ctx.starts_with("DESIGN SYSTEM (use these values consistently):"),
"wrong header: {ctx}"
);
assert!(ctx.contains("Colors: bg #F8FAFC"), "missing Colors line");
assert!(ctx.contains("surface #FFFFFF"), "missing surface");
assert!(ctx.contains("text #0F172A"), "missing text");
assert!(ctx.contains("muted #475569"), "missing muted");
assert!(ctx.contains("primary #2563EB"), "missing primary");
assert!(ctx.contains("primaryLight #DBEAFE"), "missing primaryLight");
assert!(ctx.contains("accent #0EA5E9"), "missing accent");
assert!(ctx.contains("border #E2E8F0"), "missing border");
assert!(
ctx.contains(r#"Fonts: heading "Space Grotesk""#),
"missing heading font"
);
assert!(ctx.contains(r#"body "Inter""#), "missing body font");
assert!(
ctx.contains("Type scale: 14, 16, 20, 28, 40, 56px"),
"wrong type scale line: {ctx}"
);
assert!(
ctx.contains("Spacing: 8, 16, 24, 32, 48, 64px (8px grid)"),
"wrong spacing line: {ctx}"
);
assert!(
ctx.contains("Radius: 8, 12, 16px"),
"wrong radius line: {ctx}"
);
assert!(
ctx.contains("Style: clean modern blue"),
"wrong style line: {ctx}"
);
}
/// Byte-exact match of the TS template for DEFAULT_DESIGN_SYSTEM.
#[test]
fn prompt_context_byte_exact() {
use crate::design_system::design_system_to_prompt_context;
let ds = default_design_system();
let ctx = design_system_to_prompt_context(ds);
let expected = "DESIGN SYSTEM (use these values consistently):\n\
Colors: bg #F8FAFC, surface #FFFFFF, text #0F172A, muted #475569, primary #2563EB, primaryLight #DBEAFE, accent #0EA5E9, border #E2E8F0\n\
Fonts: heading \"Space Grotesk\", body \"Inter\"\n\
Type scale: 14, 16, 20, 28, 40, 56px\n\
Spacing: 8, 16, 24, 32, 48, 64px (8px grid)\n\
Radius: 8, 12, 16px\n\
Style: clean modern blue";
assert_eq!(
ctx, expected,
"prompt context does not match TS template byte-exactly"
);
}
}

View file

@ -49,7 +49,8 @@ pub use design_md_policy::{
build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background,
};
pub use design_system::{
default_design_system, parse_design_system, DesignSystem, Spacing, Typography,
default_design_system, design_system_to_prompt_context, design_system_to_seed_commands,
generate_design_system, parse_design_system, DesignSystem, Spacing, Typography,
};
pub use design_type::{detect_design_type, DesignType, DesignTypePreset};
pub use intent::classify_intent;