mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
8744947712
commit
0c4acd554d
3 changed files with 563 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue