diff --git a/crates/op-orchestrator/src/design_system.rs b/crates/op-orchestrator/src/design_system.rs index 03702d2cf..d08ba096e 100644 --- a/crates/op-orchestrator/src/design_system.rs +++ b/crates/op-orchestrator/src/design_system.rs @@ -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 { + 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 = 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::>() + .join(", "); + + // Spacing scale: "8, 16, 24, 32, 48, 64px" + let spacing_scale = ds + .spacing + .scale + .iter() + .map(|v| format_num(*v)) + .collect::>() + .join(", "); + let spacing_unit = format_num(ds.spacing.unit); + + // Radius: "8, 12, 16px" + let radius = ds + .radius + .iter() + .map(|v| format_num(*v)) + .collect::>() + .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; diff --git a/crates/op-orchestrator/src/design_system_tests.rs b/crates/op-orchestrator/src/design_system_tests.rs index 2a00668e9..1da1e6054 100644 --- a/crates/op-orchestrator/src/design_system_tests.rs +++ b/crates/op-orchestrator/src/design_system_tests.rs @@ -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 = 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 = 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 = 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" + ); + } } diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index 76ed3a5f1..91528a729 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -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;