diff --git a/crates/op-ai-skills/skills/phases/agent/design-agent.md b/crates/op-ai-skills/skills/phases/agent/design-agent.md index 71813e592..019f8412a 100644 --- a/crates/op-ai-skills/skills/phases/agent/design-agent.md +++ b/crates/op-ai-skills/skills/phases/agent/design-agent.md @@ -27,6 +27,11 @@ select:get_editor_state,get_guidelines,get_style_guide_tags,get_style_guide,get_ - Call `get_guidelines(topic)` using `"web-app"` or `"mobile"` for product-design principles that apply to the screen you are editing. - Do NOT pull a full style guide when you are adjusting an existing composition. +**Visual style for new or refreshed work:** +- Early in the loop, also fetch one visual style with `get_guidelines(category:"style", name:"Atlas Grid", colorPalette:"Alloy Blue", roundness:"medium", elevation:"low", headings:"Inter", body:"Inter", captions:"Inter", data:"IBM Plex Mono")`; choose styles like Atlas Grid, Beacon Landing, or Console Board and palettes like Alloy Blue or Amber Field. +- Treat the returned TokenMap as reference values only. +- BAKE concrete values (fills, text colors, radius, font) from the style directly into nodes. Do NOT create document variables from the style, and Do NOT call `set_variables` for it — the style is reference guidance, not document state. (You may still reuse the document's own existing `$variables` per Step 4.) + ### Step 4 — Read design variables Call `get_variables` to see the existing design variables and themes. Reuse them by using `$variable` references in node properties instead of hardcoding color values or sizes. diff --git a/crates/op-ai-skills/src/color/contrast.rs b/crates/op-ai-skills/src/color/contrast.rs new file mode 100644 index 000000000..7095ad6f6 --- /dev/null +++ b/crates/op-ai-skills/src/color/contrast.rs @@ -0,0 +1,62 @@ +use super::oklch::{hex_saturation, hex_to_oklch, parse_hex_rgb, srgb_to_linear}; + +pub fn wcag(fg_hex: &str, bg_hex: &str) -> Option { + let fg = rel_lum(fg_hex)?; + let bg = rel_lum(bg_hex)?; + let hi = fg.max(bg); + let lo = fg.min(bg); + Some((hi + 0.05) / (lo + 0.05)) +} + +pub fn on_color(bg_hex: &str) -> &'static str { + let Some(lightness) = hex_to_oklch(bg_hex).map(|oklch| oklch.l) else { + return "#0F172A"; + }; + let Some(saturation) = hex_saturation(bg_hex) else { + return "#0F172A"; + }; + + if lightness < 0.5 || (saturation >= 0.5 && lightness <= 0.72) { + "#FFFFFF" + } else { + "#0F172A" + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ContrastViolation { + pub fg: String, + pub bg: String, + pub ratio: f64, + pub target: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ContrastReport { + pub violations: Vec, +} + +pub fn scan_pairs(pairs: &[(String, String, f64)]) -> ContrastReport { + let violations = pairs + .iter() + .filter_map(|(fg, bg, target)| { + let ratio = wcag(fg, bg)?; + (ratio < *target).then(|| ContrastViolation { + fg: fg.clone(), + bg: bg.clone(), + ratio, + target: *target, + }) + }) + .collect(); + + ContrastReport { violations } +} + +fn rel_lum(hex: &str) -> Option { + let (r, g, b) = parse_hex_rgb(hex)?; + let r = srgb_to_linear(f64::from(r) / 255.0); + let g = srgb_to_linear(f64::from(g) / 255.0); + let b = srgb_to_linear(f64::from(b) / 255.0); + Some(0.2126 * r + 0.7152 * g + 0.0722 * b) +} diff --git a/crates/op-ai-skills/src/color/mod.rs b/crates/op-ai-skills/src/color/mod.rs new file mode 100644 index 000000000..f12b6c9e8 --- /dev/null +++ b/crates/op-ai-skills/src/color/mod.rs @@ -0,0 +1,76 @@ +pub mod contrast; +pub mod oklch; +pub mod palettes; + +pub use contrast::{on_color, scan_pairs, wcag, ContrastReport, ContrastViolation}; +pub use oklch::{hex_saturation, hex_to_oklch, oklch_to_hex, scale12, Mode, Oklch}; + +#[cfg(test)] +mod tests { + use super::*; + + fn rgb_channels(hex: &str) -> [i32; 3] { + let hex = hex.strip_prefix('#').unwrap_or(hex); + [ + i32::from_str_radix(&hex[0..2], 16).expect("red channel"), + i32::from_str_radix(&hex[2..4], 16).expect("green channel"), + i32::from_str_radix(&hex[4..6], 16).expect("blue channel"), + ] + } + + fn assert_hex_close(actual: &str, expected: &str) { + let actual = rgb_channels(actual); + let expected = rgb_channels(expected); + for (actual, expected) in actual.iter().zip(expected) { + assert!( + (actual - expected).abs() <= 1, + "expected {actual:?} to be within 1 of {expected:?}" + ); + } + } + + #[test] + fn oklch_roundtrip_stable() { + for hex in [ + "#000000", "#FFFFFF", "#1E3A8A", "#3B82F6", "#FFEB3B", "#00FF00", "#64748B", + ] { + let oklch = hex_to_oklch(hex).expect("valid hex"); + let roundtripped = oklch_to_hex(oklch); + assert_hex_close(&roundtripped, hex); + } + } + + #[test] + fn on_color_dark_for_bright_yellow() { + assert_eq!(on_color("#FFEB3B"), "#0F172A"); + let ratio = wcag("#000000", "#FFEB3B").expect("valid colors"); + assert!((ratio - 17.2).abs() <= 0.3, "ratio was {ratio}"); + } + + #[test] + fn on_color_dark_for_pure_green() { + assert_eq!(on_color("#00FF00"), "#0F172A"); + let ratio = wcag("#000000", "#00FF00").expect("valid colors"); + assert!((ratio - 15.3).abs() <= 0.3, "ratio was {ratio}"); + } + + #[test] + fn on_color_white_for_deep_blue() { + assert_eq!(on_color("#1E3A8A"), "#FFFFFF"); + } + + #[test] + fn contrast_scan_flags_low_pair() { + let low = scan_pairs(&[("#777777".to_string(), "#888888".to_string(), 4.5)]); + assert_eq!(low.violations.len(), 1); + assert_eq!(low.violations[0].fg, "#777777"); + assert_eq!(low.violations[0].bg, "#888888"); + assert!(low.violations[0].ratio < low.violations[0].target); + + let pass = scan_pairs(&[ + ("#000000".to_string(), "#FFFFFF".to_string(), 4.5), + ("#FFFFFF".to_string(), "#1E3A8A".to_string(), 4.5), + ]); + assert!(pass.violations.is_empty()); + } +} diff --git a/crates/op-ai-skills/src/color/oklch.rs b/crates/op-ai-skills/src/color/oklch.rs new file mode 100644 index 000000000..5b9a7adb3 --- /dev/null +++ b/crates/op-ai-skills/src/color/oklch.rs @@ -0,0 +1,167 @@ +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Mode { + Light, + Dark, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Oklch { + pub l: f64, + pub c: f64, + pub h: f64, +} + +const L_LIGHT: [f64; 12] = [ + 0.992, 0.977, 0.954, 0.933, 0.911, 0.885, 0.850, 0.798, 0.640, 0.590, 0.520, 0.240, +]; +const L_DARK: [f64; 12] = [ + 0.178, 0.213, 0.255, 0.285, 0.314, 0.353, 0.412, 0.487, 0.640, 0.680, 0.770, 0.930, +]; +const C_MULT: [f64; 12] = [ + 0.30, 0.45, 0.65, 0.80, 0.90, 0.95, 1.00, 1.00, 1.00, 0.95, 0.75, 0.55, +]; +const NEUTRAL_CMAX: f64 = 0.006; + +pub fn oklch_to_hex(o: Oklch) -> String { + let mut chroma = o.c.max(0.0); + if !in_gamut(oklch_to_linear(o.l, chroma, o.h)) { + let mut lo = 0.0; + let mut hi = chroma; + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if in_gamut(oklch_to_linear(o.l, mid, o.h)) { + lo = mid; + } else { + hi = mid; + } + } + chroma = lo; + } + + let (r, g, b) = oklch_to_linear(o.l, chroma, o.h); + let r = (linear_to_srgb(r) * 255.0).round() as u8; + let g = (linear_to_srgb(g) * 255.0).round() as u8; + let b = (linear_to_srgb(b) * 255.0).round() as u8; + format!("#{r:02X}{g:02X}{b:02X}") +} + +pub fn hex_to_oklch(hex: &str) -> Option { + let (r, g, b) = parse_hex_rgb(hex)?; + let r = srgb_to_linear(f64::from(r) / 255.0); + let g = srgb_to_linear(f64::from(g) / 255.0); + let b = srgb_to_linear(f64::from(b) / 255.0); + + let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + + let lightness = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + let a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + let b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + let chroma = (a * a + b * b).sqrt(); + let hue = if chroma < 1e-12 { + 0.0 + } else { + positive_degrees(b.atan2(a).to_degrees()) + }; + + Some(Oklch { + l: lightness, + c: chroma, + h: hue, + }) +} + +pub fn hex_saturation(hex: &str) -> Option { + let (r, g, b) = parse_hex_rgb(hex)?; + let r = f64::from(r) / 255.0; + let g = f64::from(g) / 255.0; + let b = f64::from(b) / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + if max == 0.0 { + Some(0.0) + } else { + Some((max - min) / max) + } +} + +pub fn scale12(seed_hue: f64, cmax: f64, mode: Mode, neutral: bool) -> [String; 12] { + let lightness = match mode { + Mode::Light => L_LIGHT, + Mode::Dark => L_DARK, + }; + let base_chroma = if neutral { NEUTRAL_CMAX } else { cmax }; + std::array::from_fn(|i| { + oklch_to_hex(Oklch { + l: lightness[i], + c: base_chroma * C_MULT[i], + h: seed_hue, + }) + }) +} + +pub(crate) fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> { + let hex = hex.trim(); + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 || !hex.is_ascii() { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some((r, g, b)) +} + +pub(crate) fn srgb_to_linear(c: f64) -> f64 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(c: f64) -> f64 { + let c = c.clamp(0.0, 1.0); + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn oklch_to_linear(lightness: f64, chroma: f64, hue: f64) -> (f64, f64, f64) { + let a = chroma * hue.to_radians().cos(); + let b = chroma * hue.to_radians().sin(); + let l_ = lightness + 0.3963377774 * a + 0.2158037573 * b; + let m_ = lightness - 0.1055613458 * a - 0.0638541728 * b; + let s_ = lightness - 0.0894841775 * a - 1.2914855480 * b; + + let l = l_.powi(3); + let m = m_.powi(3); + let s = s_.powi(3); + + let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + (r, g, b) +} + +fn in_gamut(rgb: (f64, f64, f64)) -> bool { + const EPS: f64 = 1e-4; + (-EPS..=1.0 + EPS).contains(&rgb.0) + && (-EPS..=1.0 + EPS).contains(&rgb.1) + && (-EPS..=1.0 + EPS).contains(&rgb.2) +} + +fn positive_degrees(degrees: f64) -> f64 { + if degrees < 0.0 { + degrees + 360.0 + } else { + degrees + } +} diff --git a/crates/op-ai-skills/src/color/palettes.rs b/crates/op-ai-skills/src/color/palettes.rs new file mode 100644 index 000000000..c7bb14716 --- /dev/null +++ b/crates/op-ai-skills/src/color/palettes.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeMap; + +use super::oklch::{oklch_to_hex, scale12, Mode as OklchMode, Oklch}; + +pub const PALETTE_COUNT: usize = 7; +pub const HARSH_PALETTE_NAME: &str = "Amber Field"; +pub const HARSH_ROLE: &str = "accent.primary"; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PaletteSeed { + pub name: &'static str, + pub neutral_hue: f64, + pub accent_hue: f64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PaletteMode { + Light, + Dark, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteAnchors { + pub name: &'static str, + pub mode: PaletteMode, + pub roles: BTreeMap, + pub neutral_scale: [String; 12], + pub accent_scale: [String; 12], +} + +const PALETTE_SEEDS: [PaletteSeed; PALETTE_COUNT] = [ + PaletteSeed { + name: "Alloy Blue", + neutral_hue: 252.0, + accent_hue: 248.0, + }, + PaletteSeed { + name: "Harbor Teal", + neutral_hue: 218.0, + accent_hue: 188.0, + }, + PaletteSeed { + name: "Fern Signal", + neutral_hue: 156.0, + accent_hue: 142.0, + }, + PaletteSeed { + name: "Ember Coral", + neutral_hue: 28.0, + accent_hue: 24.0, + }, + PaletteSeed { + name: "Iris Steel", + neutral_hue: 266.0, + accent_hue: 286.0, + }, + PaletteSeed { + name: HARSH_PALETTE_NAME, + neutral_hue: 92.0, + accent_hue: 82.0, + }, + PaletteSeed { + name: "Rose Circuit", + neutral_hue: 334.0, + accent_hue: 342.0, + }, +]; + +pub fn palette_names() -> Vec<&'static str> { + PALETTE_SEEDS.iter().map(|seed| seed.name).collect() +} + +pub fn palette_seed(name: &str) -> Option<&'static PaletteSeed> { + PALETTE_SEEDS + .iter() + .find(|seed| seed.name.eq_ignore_ascii_case(name.trim())) +} + +pub fn palette_anchors(name: &str, mode: PaletteMode) -> Option { + palette_seed(name).map(|seed| palette_anchors_from_seed(seed, mode)) +} + +pub fn palette_anchors_from_seed(seed: &PaletteSeed, mode: PaletteMode) -> PaletteAnchors { + let oklch_mode = match mode { + PaletteMode::Light => OklchMode::Light, + PaletteMode::Dark => OklchMode::Dark, + }; + let neutral_scale = scale12(seed.neutral_hue, 0.006, oklch_mode, true); + let accent_scale = scale12(seed.accent_hue, 0.118, oklch_mode, false); + let mut roles = BTreeMap::new(); + + roles.insert("surface.primary".to_string(), neutral_scale[0].clone()); + roles.insert("surface.secondary".to_string(), neutral_scale[1].clone()); + roles.insert("surface.inverse".to_string(), neutral_scale[11].clone()); + roles.insert("foreground.primary".to_string(), neutral_scale[11].clone()); + roles.insert( + "foreground.secondary".to_string(), + neutral_scale[10].clone(), + ); + roles.insert("foreground.muted".to_string(), neutral_scale[10].clone()); + roles.insert("foreground.inverse".to_string(), neutral_scale[0].clone()); + roles.insert("border.subtle".to_string(), neutral_scale[5].clone()); + + let accent_primary = if seed.name == HARSH_PALETTE_NAME { + oklch_to_hex(Oklch { + l: match mode { + PaletteMode::Light => 0.86, + PaletteMode::Dark => 0.80, + }, + c: 0.18, + h: seed.accent_hue, + }) + } else { + oklch_to_hex(Oklch { + l: match mode { + PaletteMode::Light => 0.48, + PaletteMode::Dark => 0.78, + }, + c: 0.118, + h: seed.accent_hue, + }) + }; + roles.insert(HARSH_ROLE.to_string(), accent_primary); + + PaletteAnchors { + name: seed.name, + mode, + roles, + neutral_scale, + accent_scale, + } +} diff --git a/crates/op-ai-skills/src/lib.rs b/crates/op-ai-skills/src/lib.rs index 4deda8249..9b2f4939d 100644 --- a/crates/op-ai-skills/src/lib.rs +++ b/crates/op-ai-skills/src/lib.rs @@ -19,11 +19,13 @@ use include_dir::{include_dir, Dir}; pub mod budget; +pub mod color; pub mod compose; pub mod frontmatter; pub mod loader; pub mod memory; pub mod resolve; +pub mod resolve_style; pub mod resolver; pub mod style_guide; pub mod types; @@ -104,6 +106,10 @@ pub fn design_agent_system_prompt() -> &'static str { /// registry on first access (see [`loader`]). pub static SKILLS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/skills"); +/// The embedded P2 style catalog. This is deliberately separate from +/// [`SKILLS`] so catalog entries cannot be parsed as phase skills. +pub static STYLE_CATALOG: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/style_catalog"); + /// Recursively count `.md` files in an embedded directory. #[cfg(test)] pub(crate) fn count_md(dir: &Dir) -> usize { @@ -327,4 +333,38 @@ mod tests { "must describe label in nav tab rule" ); } + + #[test] + fn design_agent_prompt_mentions_style_fetch() { + let prompt = design_agent_system_prompt(); + assert!( + prompt.contains("get_guidelines(category:\"style\""), + "design-agent prompt must tell the loop to fetch a style guideline" + ); + assert!( + prompt.contains("colorPalette:\""), + "design-agent prompt must show the flat colorPalette style param" + ); + } + + #[test] + fn design_agent_prompt_says_bake_not_variables() { + let prompt = design_agent_system_prompt(); + assert!( + prompt.contains("Treat the returned TokenMap as reference values only"), + "design-agent prompt must frame TokenMap values as references" + ); + assert!( + prompt.contains("BAKE concrete values"), + "design-agent prompt must require baking concrete values into nodes" + ); + assert!( + prompt.contains("Do NOT create document variables"), + "design-agent prompt must prohibit creating document variables" + ); + assert!( + prompt.contains("Do NOT call `set_variables`"), + "design-agent prompt must prohibit set_variables" + ); + } } diff --git a/crates/op-ai-skills/src/resolve_style.rs b/crates/op-ai-skills/src/resolve_style.rs new file mode 100644 index 000000000..341a9ebfd --- /dev/null +++ b/crates/op-ai-skills/src/resolve_style.rs @@ -0,0 +1,577 @@ +use std::collections::BTreeMap; + +use crate::color::contrast::{on_color, scan_pairs, ContrastReport}; +use crate::color::palettes::{palette_anchors, palette_names, PaletteAnchors, PaletteMode}; + +pub const STYLE_COUNT: usize = 7; + +#[derive(Clone, Debug, PartialEq)] +pub struct Fonts { + pub headings: String, + pub body: String, + pub captions: String, + pub data: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StyleParams { + pub color_palette: String, + pub roundness: String, + pub elevation: String, + pub fonts: Fonts, + pub decorative_imagery: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Shadow { + pub shadow_type: String, + pub color: String, + pub offset_x: f64, + pub offset_y: f64, + pub blur: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TokenMap { + pub surface: BTreeMap, + pub foreground: BTreeMap, + pub accent: BTreeMap, + pub border: BTreeMap, + pub rounded: BTreeMap, + pub shadow: BTreeMap, + pub typography: Fonts, + pub on: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StyleGuide { + pub prose: String, + pub tokens: TokenMap, + pub contrast: ContrastReport, +} + +#[derive(Clone, Debug, PartialEq)] +#[allow(clippy::large_enum_variant)] +pub enum ResolveOutcome { + Hit(StyleGuide), + Miss { + missing: Vec, + suggest: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct StyleCatalogEntry { + name: String, + prose: String, + roundness: String, + elevation: String, + fonts: Fonts, +} + +pub fn resolve_style(name: &str, params: &StyleParams) -> ResolveOutcome { + let styles = style_catalog_entries(); + let style = styles + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(name.trim())); + let anchors = palette_anchors(¶ms.color_palette, PaletteMode::Light); + + let mut missing = Vec::new(); + if style.is_none() { + missing.push(format!("style:{name}")); + } + if anchors.is_none() { + missing.push(format!("palette:{}", params.color_palette)); + } + if !missing.is_empty() { + return ResolveOutcome::Miss { + missing, + suggest: suggestions(name, ¶ms.color_palette, &styles), + }; + } + + let style = style.expect("style checked above"); + let anchors = anchors.expect("palette checked above"); + let typography = effective_fonts(&style.fonts, ¶ms.fonts); + let tokens = build_tokens( + &anchors, + &effective_choice(&style.roundness, ¶ms.roundness), + &effective_choice(&style.elevation, ¶ms.elevation), + typography, + ); + let contrast = scan_pairs(&contrast_pairs(&tokens)); + let prose = compose_prose(style, params, &tokens); + + ResolveOutcome::Hit(StyleGuide { + prose, + tokens, + contrast, + }) +} + +pub fn style_names() -> Vec { + style_catalog_entries() + .into_iter() + .map(|entry| entry.name) + .collect() +} + +fn style_catalog_entries() -> Vec { + let mut entries: Vec = crate::STYLE_CATALOG + .files() + .filter(|file| { + file.path() + .extension() + .map(|ext| ext == "md") + .unwrap_or(false) + }) + .filter_map(|file| file.contents_utf8()) + .filter_map(parse_style_catalog_entry) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries +} + +fn parse_style_catalog_entry(text: &str) -> Option { + let rest = text.strip_prefix("---\n")?; + let (frontmatter, prose) = rest.split_once("\n---\n")?; + let meta = parse_frontmatter(frontmatter); + Some(StyleCatalogEntry { + name: meta.get("name")?.to_string(), + prose: prose.trim().to_string(), + roundness: meta.get("roundness")?.to_string(), + elevation: meta.get("elevation")?.to_string(), + fonts: Fonts { + headings: meta.get("headings")?.to_string(), + body: meta.get("body")?.to_string(), + captions: meta.get("captions")?.to_string(), + data: meta.get("data")?.to_string(), + }, + }) +} + +fn parse_frontmatter(frontmatter: &str) -> BTreeMap { + frontmatter + .lines() + .filter_map(|line| { + let (key, value) = line.split_once(':')?; + Some(( + key.trim().to_string(), + value.trim().trim_matches('"').to_string(), + )) + }) + .collect() +} + +fn effective_choice(default: &str, requested: &str) -> String { + if requested.trim().is_empty() { + default.to_string() + } else { + requested.trim().to_ascii_lowercase() + } +} + +fn effective_fonts(defaults: &Fonts, requested: &Fonts) -> Fonts { + Fonts { + headings: effective_font(&defaults.headings, &requested.headings), + body: effective_font(&defaults.body, &requested.body), + captions: effective_font(&defaults.captions, &requested.captions), + data: effective_font(&defaults.data, &requested.data), + } +} + +fn effective_font(default: &str, requested: &str) -> String { + if requested.trim().is_empty() { + default.to_string() + } else { + requested.trim().to_string() + } +} + +fn build_tokens( + anchors: &PaletteAnchors, + roundness: &str, + elevation: &str, + typography: Fonts, +) -> TokenMap { + let mut surface = group_roles(&anchors.roles, "surface"); + let mut foreground = group_roles(&anchors.roles, "foreground"); + let mut accent = group_roles(&anchors.roles, "accent"); + let mut border = group_roles(&anchors.roles, "border"); + + surface + .entry("tertiary".to_string()) + .or_insert_with(|| anchors.neutral_scale[2].clone()); + border + .entry("primary".to_string()) + .or_insert_with(|| anchors.neutral_scale[7].clone()); + accent + .entry("secondary".to_string()) + .or_insert_with(|| anchors.accent_scale[11].clone()); + accent + .entry("tertiary".to_string()) + .or_insert_with(|| anchors.accent_scale[3].clone()); + + foreground + .entry("primary".to_string()) + .or_insert_with(|| anchors.neutral_scale[11].clone()); + foreground + .entry("secondary".to_string()) + .or_insert_with(|| anchors.neutral_scale[10].clone()); + foreground + .entry("muted".to_string()) + .or_insert_with(|| anchors.neutral_scale[10].clone()); + foreground + .entry("inverse".to_string()) + .or_insert_with(|| anchors.neutral_scale[0].clone()); + + let on = on_roles(&surface, &accent, &border); + + TokenMap { + surface, + foreground, + accent, + border, + rounded: rounded_tokens(roundness), + shadow: shadow_tokens(elevation), + typography, + on, + } +} + +fn group_roles(roles: &BTreeMap, group: &str) -> BTreeMap { + let prefix = format!("{group}."); + roles + .iter() + .filter_map(|(key, value)| { + key.strip_prefix(&prefix) + .map(|role| (role.to_string(), value.clone())) + }) + .collect() +} + +fn on_roles( + surface: &BTreeMap, + accent: &BTreeMap, + border: &BTreeMap, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for (group, roles) in [("surface", surface), ("accent", accent), ("border", border)] { + for (role, value) in roles { + out.insert(format!("on-{group}.{role}"), on_color(value).to_string()); + } + } + out +} + +fn rounded_tokens(profile: &str) -> BTreeMap { + let scale = match profile { + "sharp" | "flat" | "none" => [0.0, 2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 9999.0], + "large" | "soft" | "full" => [0.0, 8.0, 12.0, 16.0, 20.0, 28.0, 36.0, 9999.0], + _ => [0.0, 4.0, 8.0, 12.0, 16.0, 24.0, 32.0, 9999.0], + }; + ["none", "sm", "md", "lg", "xl", "2xl", "3xl", "full"] + .into_iter() + .zip(scale) + .map(|(name, value)| (name.to_string(), value)) + .collect() +} + +fn shadow_tokens(profile: &str) -> BTreeMap { + let values = match profile { + "flat" | "none" => [(0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], + "raised" | "deep" => [(0.0, 2.0, 8.0), (0.0, 8.0, 24.0), (0.0, 18.0, 48.0)], + _ => [(0.0, 1.0, 4.0), (0.0, 4.0, 14.0), (0.0, 10.0, 30.0)], + }; + ["sm", "md", "lg"] + .into_iter() + .zip(values) + .map(|(name, (offset_x, offset_y, blur))| { + ( + name.to_string(), + Shadow { + shadow_type: "drop".to_string(), + color: "#0F172A24".to_string(), + offset_x, + offset_y, + blur, + }, + ) + }) + .collect() +} + +fn contrast_pairs(tokens: &TokenMap) -> Vec<(String, String, f64)> { + let mut pairs = Vec::new(); + for fg in ["primary", "secondary", "muted"] { + for bg in ["primary", "secondary", "tertiary"] { + push_pair( + &mut pairs, + tokens.foreground.get(fg), + tokens.surface.get(bg), + 4.5, + ); + } + } + push_pair( + &mut pairs, + tokens.foreground.get("inverse"), + tokens.surface.get("inverse"), + 4.5, + ); + + for (role, bg) in tokens + .surface + .iter() + .map(|(role, bg)| (format!("on-surface.{role}"), bg)) + .chain( + tokens + .accent + .iter() + .map(|(role, bg)| (format!("on-accent.{role}"), bg)), + ) + { + if let Some(fg) = tokens.on.get(&role) { + pairs.push((fg.clone(), bg.clone(), 4.5)); + } + } + pairs +} + +fn push_pair( + pairs: &mut Vec<(String, String, f64)>, + fg: Option<&String>, + bg: Option<&String>, + target: f64, +) { + if let (Some(fg), Some(bg)) = (fg, bg) { + pairs.push((fg.clone(), bg.clone(), target)); + } +} + +fn compose_prose(style: &StyleCatalogEntry, params: &StyleParams, tokens: &TokenMap) -> String { + let decoration = params + .decorative_imagery + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("use imagery only when it clarifies the product or content"); + + format!( + "{prose}\n\n## Resolved Defaults\n- color palette: {palette}\n- roundness: {roundness}\n- elevation: {elevation}\n- headings: {headings}\n- body: {body}\n- captions: {captions}\n- data: {data}\n- decoration role: {decoration}\n\n## Token Map usage notes\nUse the returned role maps as reference data for concrete fills, text colors, borders, radii, shadows, and fonts. Keep authored nodes concrete and local to the design output.", + prose = style.prose, + palette = params.color_palette, + roundness = tokens + .rounded + .get("md") + .map(|value| format!("md {value}px")) + .unwrap_or_else(|| style.roundness.clone()), + elevation = style.elevation, + headings = tokens.typography.headings, + body = tokens.typography.body, + captions = tokens.typography.captions, + data = tokens.typography.data, + ) +} + +fn suggestions( + style_query: &str, + palette_query: &str, + styles: &[StyleCatalogEntry], +) -> Vec { + let mut out = nearest( + style_query, + styles.iter().map(|entry| entry.name.clone()), + 3, + ); + out.extend(nearest( + palette_query, + palette_names().into_iter().map(str::to_string), + 3, + )); + out.sort(); + out.dedup(); + out +} + +fn nearest(query: &str, candidates: impl Iterator, limit: usize) -> Vec { + let query = query.trim().to_ascii_lowercase(); + let mut ranked: Vec<(usize, String)> = candidates + .map(|candidate| { + let score = levenshtein(&query, &candidate.to_ascii_lowercase()); + (score, candidate) + }) + .collect(); + ranked.sort_by(|(score_a, name_a), (score_b, name_b)| { + score_a.cmp(score_b).then_with(|| name_a.cmp(name_b)) + }); + ranked + .into_iter() + .take(limit) + .map(|(_, candidate)| candidate) + .collect() +} + +fn levenshtein(a: &str, b: &str) -> usize { + let mut costs: Vec = (0..=b.chars().count()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut prev = costs[0]; + costs[0] = i + 1; + for (j, cb) in b.chars().enumerate() { + let insert = costs[j + 1] + 1; + let delete = costs[j] + 1; + let replace = prev + usize::from(ca != cb); + prev = costs[j + 1]; + costs[j + 1] = insert.min(delete).min(replace); + } + } + costs.last().copied().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::palettes::{palette_anchors, HARSH_PALETTE_NAME, HARSH_ROLE}; + use crate::loader::{get_skill_by_name, get_skill_registry}; + + fn default_fonts() -> Fonts { + Fonts { + headings: "Inter".to_string(), + body: "Inter".to_string(), + captions: "Inter".to_string(), + data: "IBM Plex Mono".to_string(), + } + } + + fn params_for(palette: &str) -> StyleParams { + StyleParams { + color_palette: palette.to_string(), + roundness: "medium".to_string(), + elevation: "low".to_string(), + fonts: default_fonts(), + decorative_imagery: Some("restrained product imagery".to_string()), + } + } + + fn assert_required_roles(tokens: &TokenMap) { + for key in ["primary", "secondary", "tertiary", "inverse"] { + assert!(tokens.surface.contains_key(key), "missing surface.{key}"); + } + for key in ["primary", "secondary", "muted", "inverse"] { + assert!( + tokens.foreground.contains_key(key), + "missing foreground.{key}" + ); + } + for key in ["primary", "secondary", "tertiary"] { + assert!(tokens.accent.contains_key(key), "missing accent.{key}"); + } + for key in ["primary", "subtle"] { + assert!(tokens.border.contains_key(key), "missing border.{key}"); + } + for key in ["none", "sm", "md", "lg", "xl", "2xl", "3xl", "full"] { + assert!(tokens.rounded.contains_key(key), "missing rounded.{key}"); + } + for key in ["sm", "md", "lg"] { + assert!(tokens.shadow.contains_key(key), "missing shadow.{key}"); + } + } + + #[test] + fn resolve_our_style_structurally_complete() { + let style = style_names().first().cloned().expect("style catalog names"); + let palette = palette_names() + .first() + .copied() + .expect("palette catalog names"); + let ResolveOutcome::Hit(guide) = resolve_style(&style, ¶ms_for(palette)) else { + panic!("known style and palette must resolve"); + }; + + assert_required_roles(&guide.tokens); + assert!(!guide.prose.contains("set_variables")); + assert!(!guide.prose.contains("EditorCommand")); + } + + #[test] + fn resolve_our_catalog_zero_aa_violations() { + for style in style_names() { + for palette in palette_names() { + let ResolveOutcome::Hit(guide) = resolve_style(&style, ¶ms_for(palette)) else { + panic!("{style} with {palette} must resolve"); + }; + assert!( + guide.contrast.violations.is_empty(), + "{style} / {palette} reported contrast violations: {:?}", + guide.contrast.violations + ); + } + } + } + + #[test] + fn resolve_original_palette_value_not_mutated() { + let before = palette_anchors(HARSH_PALETTE_NAME, PaletteMode::Light) + .expect("known harsh palette") + .roles + .get(HARSH_ROLE) + .cloned() + .expect("harsh explicit anchor"); + let style = style_names().first().cloned().expect("style catalog names"); + let ResolveOutcome::Hit(guide) = resolve_style(&style, ¶ms_for(HARSH_PALETTE_NAME)) + else { + panic!("known style and harsh palette must resolve"); + }; + + assert_eq!( + guide.tokens.accent.get("primary"), + Some(&before), + "explicit palette anchor must survive resolve unchanged" + ); + } + + #[test] + fn resolve_unknown_returns_miss_with_suggest() { + let ResolveOutcome::Miss { missing, suggest } = + resolve_style("not-a-real-style", ¶ms_for("not-a-real-palette")) + else { + panic!("unknown style or palette must miss"); + }; + + assert!(!missing.is_empty(), "miss must identify missing inputs"); + assert!(!suggest.is_empty(), "miss must return catalog suggestions"); + } + + #[test] + fn resolve_style_returns_data_not_command() { + let style = style_names().first().cloned().expect("style catalog names"); + let palette = palette_names() + .first() + .copied() + .expect("palette catalog names"); + let outcome = resolve_style(&style, ¶ms_for(palette)); + + let type_name = std::any::type_name::(); + let debug = format!("{outcome:?}"); + assert!(!type_name.contains("EditorCommand")); + assert!(!debug.contains("EditorCommand")); + assert!(!debug.contains("set_variables")); + } + + #[test] + fn style_catalog_not_scanned_as_skill() { + let names = style_names(); + assert_eq!(crate::count_md(&crate::STYLE_CATALOG), STYLE_COUNT); + assert_eq!(names.len(), STYLE_COUNT); + assert_eq!(palette_names().len(), crate::color::palettes::PALETTE_COUNT); + + for name in &names { + assert!( + get_skill_by_name(name).is_none(), + "style catalog entry leaked into skill registry: {name}" + ); + } + assert!(get_skill_registry() + .iter() + .all(|entry| !names.contains(&entry.meta.name))); + } +} diff --git a/crates/op-ai-skills/style_catalog/atlas-grid.md b/crates/op-ai-skills/style_catalog/atlas-grid.md new file mode 100644 index 000000000..298e91c0e --- /dev/null +++ b/crates/op-ai-skills/style_catalog/atlas-grid.md @@ -0,0 +1,23 @@ +--- +name: Atlas Grid +roundness: medium +elevation: low +headings: Inter +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Atlas Grid + +## Identity +Atlas Grid is a practical workspace style for dense product surfaces. It favors calm surfaces, restrained accent use, and a visible rhythm that helps repeated tools feel dependable. + +## Layout +Use strong rows, measured columns, and compact grouping. Primary actions should sit close to the work area, while secondary controls stay quieter and aligned to predictable edges. + +## Token Map usage +Use surface.primary for the main canvas, surface.secondary for tool rails, and surface.tertiary for grouped work zones. Accent.primary is reserved for the current selection or commit action; border.subtle separates repeated rows. + +## Hierarchy +Keep headings modest, usually two to three times the body size. Let spacing, alignment, and foreground weight do more work than large type. + diff --git a/crates/op-ai-skills/style_catalog/beacon-landing.md b/crates/op-ai-skills/style_catalog/beacon-landing.md new file mode 100644 index 000000000..0e88dfa80 --- /dev/null +++ b/crates/op-ai-skills/style_catalog/beacon-landing.md @@ -0,0 +1,23 @@ +--- +name: Beacon Landing +roundness: large +elevation: raised +headings: Manrope +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Beacon Landing + +## Identity +Beacon Landing is built for a first screen that makes the offer clear without becoming decorative clutter. It has a crisp product signal, direct copy, and a controlled accent path. + +## Layout +Start with one dominant message and one supporting action area. Follow with visible proof or feature content so the page does not stop at a poster-like hero. + +## Token Map usage +Use surface.primary for the page, surface.secondary for proof bands, and accent.primary for the single main action. Surface.inverse can frame a short proof strip or featured metric. + +## Hierarchy +The top heading can be large, but supporting labels and buttons stay compact. Later sections should step down quickly into scan-friendly headings and body text. + diff --git a/crates/op-ai-skills/style_catalog/chronicle-page.md b/crates/op-ai-skills/style_catalog/chronicle-page.md new file mode 100644 index 000000000..264c279dc --- /dev/null +++ b/crates/op-ai-skills/style_catalog/chronicle-page.md @@ -0,0 +1,23 @@ +--- +name: Chronicle Page +roundness: small +elevation: flat +headings: Source Serif 4 +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Chronicle Page + +## Identity +Chronicle Page is an editorial system for reading, comparison, and narrative product content. It uses quiet containers, confident typography, and thin separators instead of heavy panels. + +## Layout +Build pages from a clear article column, side notes, pull facts, and occasional full-width breaks. Keep media and data close to the text they explain. + +## Token Map usage +Use surface.primary for long-form reading, surface.secondary for side notes, and border.subtle for section breaks. Accent.secondary is useful for links, inline markers, and short labels. + +## Hierarchy +Headings carry the voice, body text carries the pace, and captions stay small but readable. Avoid stacking too many headline sizes in the same viewport. + diff --git a/crates/op-ai-skills/style_catalog/console-board.md b/crates/op-ai-skills/style_catalog/console-board.md new file mode 100644 index 000000000..af89a996a --- /dev/null +++ b/crates/op-ai-skills/style_catalog/console-board.md @@ -0,0 +1,23 @@ +--- +name: Console Board +roundness: medium +elevation: layered +headings: Inter +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Console Board + +## Identity +Console Board is a data-operating style for dashboards, monitoring, and review queues. It is dense, legible, and low-drama, with accents used to show state and priority. + +## Layout +Use stable regions for filters, tables, summaries, and inspection. Keep cards compact, favor aligned numbers, and avoid oversized decoration that slows scanning. + +## Token Map usage +Use surface.secondary for control bands, surface.tertiary for nested data groups, and foreground.muted for metadata. Accent.primary marks active filters or current state; accent.tertiary can mark neutral insight callouts. + +## Hierarchy +Numbers and labels should line up cleanly. Data type, weight, and proximity should create most of the hierarchy, with headings staying short and functional. + diff --git a/crates/op-ai-skills/style_catalog/form-studio.md b/crates/op-ai-skills/style_catalog/form-studio.md new file mode 100644 index 000000000..c9d9905fc --- /dev/null +++ b/crates/op-ai-skills/style_catalog/form-studio.md @@ -0,0 +1,23 @@ +--- +name: Form Studio +roundness: medium +elevation: low +headings: Inter +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Form Studio + +## Identity +Form Studio supports careful input, review, and confirmation flows. It feels orderly and forgiving, with strong labels, clear field states, and direct validation space. + +## Layout +Group related inputs into short sections with visible progression. Keep primary actions at the end of the decision path and preserve room for errors and helper text. + +## Token Map usage +Use surface.primary for the form page, surface.secondary for grouped field areas, and border.primary for focused or validated fields. Accent.primary should identify progress or the final submit action. + +## Hierarchy +Section titles should be compact and steady. Labels need stronger priority than helper text, while errors and confirmations should rely on placement plus accent, not only color. + diff --git a/crates/op-ai-skills/style_catalog/modular-cards.md b/crates/op-ai-skills/style_catalog/modular-cards.md new file mode 100644 index 000000000..0fdc3d0ae --- /dev/null +++ b/crates/op-ai-skills/style_catalog/modular-cards.md @@ -0,0 +1,23 @@ +--- +name: Modular Cards +roundness: large +elevation: layered +headings: Manrope +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Modular Cards + +## Identity +Modular Cards is for browse, compare, and collect workflows. It uses repeated blocks with enough structure to feel systematic and enough contrast to keep each item distinct. + +## Layout +Arrange repeated items on a steady grid with consistent image, title, metadata, and action zones. Vary emphasis by size or placement rather than by inventing a new card for every item. + +## Token Map usage +Use surface.secondary for cards, surface.tertiary for nested badges or media mats, and border.subtle for item boundaries. Accent.secondary works well for saved, recommended, or active states. + +## Hierarchy +Cards should scan from title to key fact to action. Keep badges and metadata smaller than body text so repeated chrome does not overpower item content. + diff --git a/crates/op-ai-skills/style_catalog/pocket-task.md b/crates/op-ai-skills/style_catalog/pocket-task.md new file mode 100644 index 000000000..33c4a0e8d --- /dev/null +++ b/crates/op-ai-skills/style_catalog/pocket-task.md @@ -0,0 +1,23 @@ +--- +name: Pocket Task +roundness: full +elevation: low +headings: Inter +body: Inter +captions: Inter +data: IBM Plex Mono +--- +# Pocket Task + +## Identity +Pocket Task is a mobile-first style for short sessions and direct actions. It keeps controls reachable, surfaces simple, and feedback immediate. + +## Layout +Use a clear top rhythm, thumb-friendly action areas, and short vertical sections. Avoid forcing navigation patterns that do not match the content. + +## Token Map usage +Use surface.primary for the app background, surface.secondary for action groups, and surface.tertiary for input or status wells. Accent.primary marks the next action; surface.inverse can highlight a compact summary. + +## Hierarchy +Use one strong screen title, one primary action, and short supporting text. Secondary labels should be readable without competing with the active task. + diff --git a/crates/op-host-services/src/design_agent_tools.rs b/crates/op-host-services/src/design_agent_tools.rs index e6c2fa6ca..81a8ea7a8 100644 --- a/crates/op-host-services/src/design_agent_tools.rs +++ b/crates/op-host-services/src/design_agent_tools.rs @@ -15,6 +15,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use jian_ops_schema::node::container::LayoutMode; use jian_ops_schema::node::{ContainerProps, PenNode, TextContent}; +use jian_ops_schema::style::PenFill; use op_ai::chat_provider::{ChatToolDef, ChatToolResult}; use op_editor_core::pen_node_ext::PenNodeExt; use op_editor_core::EditorState; @@ -145,15 +146,23 @@ pub fn execute_design_tool_with_root_seed_guard( // repairs them in-process, instead of piling defects up for the loop-end // finalize. Deterministic analogue of Pencil's per-batch // snapshot_layout feedback. - let issues = op_orchestrator::geometry_validation::geometry_diagnostics(state); - if !issues.is_empty() || root_seed_hint.is_some() { + let layout_issues = op_orchestrator::geometry_validation::geometry_diagnostics(state); + let contrast_issues = scan_contrast_issues(state.active_children()); + if !layout_issues.is_empty() || !contrast_issues.is_empty() || root_seed_hint.is_some() { if let Ok(mut envelope) = serde_json::from_str::(&result.content) { if let Some(obj) = envelope.as_object_mut() { - if !issues.is_empty() { - obj.insert("layoutIssues".into(), serde_json::json!(issues)); + if !layout_issues.is_empty() { + obj.insert("layoutIssues".into(), serde_json::json!(layout_issues)); + } + if !contrast_issues.is_empty() { + obj.insert( + "contrastHint".into(), + serde_json::json!(contrast_hint(contrast_issues.len())), + ); + obj.insert("contrastIssues".into(), serde_json::json!(contrast_issues)); } let mut hints = Vec::new(); - if !issues.is_empty() { + if !layout_issues.is_empty() { hints.push( "The resolved layout has the issues above. Fix them with a follow-up batch_design before building the next section." .to_string(), @@ -581,6 +590,127 @@ fn node_is_named_structure(node: &PenNode) -> bool { || base.name.as_deref().is_some_and(|name| !name.is_empty()) } +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +struct ContrastIssue { + #[serde(rename = "nodeId")] + node_id: String, + #[serde(rename = "nodeName")] + node_name: Option, + fg: String, + bg: String, + ratio: f64, + target: f64, +} + +const CONTRAST_AA_TARGET: f64 = 4.5; + +fn scan_contrast_issues(nodes: &[PenNode]) -> Vec { + let mut candidates = Vec::new(); + let mut bg_stack = Vec::new(); + for node in nodes { + collect_contrast_candidates(node, &mut bg_stack, &mut candidates); + } + + let pairs: Vec<(String, String, f64)> = candidates + .iter() + .map(|candidate| (candidate.fg.clone(), candidate.bg.clone(), candidate.target)) + .collect(); + let report = op_ai_skills::color::contrast::scan_pairs(&pairs); + let mut violations = report.violations.into_iter().peekable(); + let mut issues = Vec::new(); + for candidate in candidates { + let Some(violation) = violations.peek() else { + break; + }; + if violation.fg == candidate.fg + && violation.bg == candidate.bg + && (violation.target - candidate.target).abs() < f64::EPSILON + { + let violation = violations.next().expect("peeked violation"); + issues.push(ContrastIssue { + node_id: candidate.node_id, + node_name: candidate.node_name, + fg: violation.fg, + bg: violation.bg, + ratio: violation.ratio, + target: violation.target, + }); + } + } + issues +} + +#[derive(Debug, Clone, PartialEq)] +struct ContrastCandidate { + node_id: String, + node_name: Option, + fg: String, + bg: String, + target: f64, +} + +fn collect_contrast_candidates( + node: &PenNode, + bg_stack: &mut Vec, + out: &mut Vec, +) { + let pushed_bg = container_background_hex(node); + if let Some(bg) = pushed_bg.as_ref() { + bg_stack.push(bg.clone()); + } + + if let PenNode::Text(text) = node { + if let (Some(fg), Some(bg)) = (first_solid_hex(&text.fill), bg_stack.last()) { + out.push(ContrastCandidate { + node_id: text.base.id.clone(), + node_name: text.base.name.clone(), + fg, + bg: bg.clone(), + target: CONTRAST_AA_TARGET, + }); + } + } + + if let Some(children) = node.children() { + for child in children { + collect_contrast_candidates(child, bg_stack, out); + } + } + + if pushed_bg.is_some() { + bg_stack.pop(); + } +} + +fn container_background_hex(node: &PenNode) -> Option { + match node { + PenNode::Frame(n) => first_solid_hex(&n.container.fill), + PenNode::Group(n) => first_solid_hex(&n.container.fill), + PenNode::Rectangle(n) => first_solid_hex(&n.container.fill), + PenNode::Tabs(n) => first_solid_hex(&n.fill), + _ => None, + } +} + +fn first_solid_hex(fill: &Option>) -> Option { + fill.as_ref()?.iter().find_map(|fill| match fill { + PenFill::Solid(body) => concrete_hex(&body.color), + _ => None, + }) +} + +fn concrete_hex(color: &str) -> Option { + let color = color.trim(); + let hex = color.strip_prefix('#')?; + (hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| color.to_string()) +} + +fn contrast_hint(issue_count: usize) -> String { + format!( + "{issue_count} text/background pairs below AA ({CONTRAST_AA_TARGET}:1); use a darker foreground or the on- color." + ) +} + fn reveal_now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -810,6 +940,78 @@ mod tests { ); } + #[test] + fn contrast_scanner_flags_bad_pair() { + let bad_root: PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": "root", + "name": "Card", + "fill": [{ "type": "solid", "color": "#888888" }], + "children": [{ + "type": "text", + "id": "title", + "name": "Title", + "content": "Low contrast", + "fill": [{ "type": "solid", "color": "#777777" }] + }] + })) + .unwrap(); + let issues = scan_contrast_issues(&[bad_root]); + assert_eq!(issues.len(), 1, "exactly one bad text/background pair"); + assert_eq!(issues[0].node_id, "title"); + assert_eq!(issues[0].node_name.as_deref(), Some("Title")); + assert_eq!(issues[0].fg, "#777777"); + assert_eq!(issues[0].bg, "#888888"); + assert_eq!(issues[0].target, 4.5); + assert!(issues[0].ratio < issues[0].target); + + let passing_root: PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": "root", + "name": "Card", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [{ + "type": "text", + "id": "title", + "name": "Title", + "content": "Readable", + "fill": [{ "type": "solid", "color": "#111111" }] + }] + })) + .unwrap(); + assert!(scan_contrast_issues(&[passing_root]).is_empty()); + } + + #[test] + fn batch_design_result_carries_contrast_issues() { + let mut state = EditorState::new(); + let (result, mutated) = execute_design_tool( + &mut state, + "batch_design", + r##"{"operations":"root=I(null,{type:'frame',name:'Card',width:320,height:120,fill:[{type:'solid',color:'#888888'}],children:[{type:'text',name:'Title',content:'Low contrast',fill:[{type:'solid',color:'#777777'}]}]})"}"##, + ); + assert!(!result.is_error, "batch failed: {}", result.content); + assert!(mutated); + + let v: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + let issues = v["contrastIssues"] + .as_array() + .expect("contrastIssues attached"); + assert!(!issues.is_empty(), "bad contrast pair reported"); + assert_eq!(issues[0]["nodeName"], "Title"); + assert_eq!(issues[0]["fg"], "#777777"); + assert_eq!(issues[0]["bg"], "#888888"); + assert!(issues[0]["ratio"].as_f64().unwrap() < issues[0]["target"].as_f64().unwrap()); + assert!( + v["contrastHint"] + .as_str() + .unwrap_or("") + .contains("text/background pairs below AA"), + "actionable contrast hint attached: {}", + result.content + ); + } + #[test] fn execute_design_first_batch_seeds_mobile_sizeless_root() { let mut state = EditorState::new(); diff --git a/crates/op-host-services/src/mcp_serve/schemas.rs b/crates/op-host-services/src/mcp_serve/schemas.rs index 86df65182..d60c22887 100644 --- a/crates/op-host-services/src/mcp_serve/schemas.rs +++ b/crates/op-host-services/src/mcp_serve/schemas.rs @@ -24,7 +24,7 @@ pub const TOOL_SCHEMAS: &[&str] = &[ r#"{"name":"export_design_md","description":"Export design.md markdown, falling back to best-effort extraction when none is persisted.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"}}}}"#, r#"{"name":"get_style_guide_tags","description":"Return all available style guide tags for filtering light/dark visual styles.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_style_guide","description":"Return a style guide by name or best tag match. Provide tags array/string, name, and optional platform.","inputSchema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#, - r#"{"name":"get_guidelines","description":"Return OpenPencil product-design guidelines for a topic. topic may be \"web-app\", \"mobile\", or \"code-to-design\".","inputSchema":{"type":"object","properties":{"topic":{"type":"string","enum":["web-app","mobile","code-to-design"],"description":"Target design surface or workflow"}},"required":["topic"]}}"#, + r#"{"name":"get_guidelines","description":"Return OpenPencil product-design guidelines. Default category is guide, which reads topic. category=style resolves an OpenPencil style using flat scalar string args.","inputSchema":{"type":"object","properties":{"category":{"type":"string","enum":["guide","style"],"default":"guide","description":"guide returns the existing topic guideline path; style resolves the style catalog"},"topic":{"type":"string","enum":["web-app","mobile","code-to-design"],"description":"Guide topic for category=guide"},"name":{"type":"string","description":"Style catalog name for category=style"},"colorPalette":{"type":"string","description":"Style palette name for category=style"},"roundness":{"type":"string","description":"Roundness profile for category=style"},"elevation":{"type":"string","description":"Elevation profile for category=style"},"headings":{"type":"string","description":"Heading font family for category=style"},"body":{"type":"string","description":"Body font family for category=style"},"captions":{"type":"string","description":"Caption font family for category=style"},"data":{"type":"string","description":"Data font family for category=style"},"decorativeImagery":{"type":"string","description":"Optional decorative imagery guidance for category=style"}}}}"#, r#"{"name":"spawn_agents","description":"Split a large design task into parallel subtasks. Each config item gives a prompt, the container node(s) to fill, and the styleguide + guideline NAMES to pass to the subagent (subagents cannot search styleguides). Returns the spawned agent ids. Execution runs the subagents in parallel.","inputSchema":{"type":"object","properties":{"config":{"type":"array","items":{"type":"object","properties":{"prompt":{"type":"string"},"containerNodes":{"type":"array","items":{"type":"string"}},"styleguideName":{"type":"string"},"guidelineNames":{"type":"array","items":{"type":"string"}}},"required":["prompt","styleguideName"]}}},"required":["config"]}}"#, r#"{"name":"ToolSearch","description":"Discover tools by keyword or exact selection. Use 'select:Name1,Name2' to load specific tools, or a keyword query to search names+descriptions.","inputSchema":{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"default":5}},"required":["query"]}}"#, r#"{"name":"get_screenshot","description":"Render a node to a base64 PNG for visual verification. nodeId may be \"root\" for the active page's top node.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"string","description":"Node id, or \"root\""}},"required":["nodeId"]}}"#, diff --git a/crates/op-mcp/src/guidelines_tools.rs b/crates/op-mcp/src/guidelines_tools.rs index 9408e66da..08f940774 100644 --- a/crates/op-mcp/src/guidelines_tools.rs +++ b/crates/op-mcp/src/guidelines_tools.rs @@ -7,6 +7,9 @@ use std::collections::BTreeMap; use op_ai_skills::guideline_for; +use op_ai_skills::resolve_style::{ + resolve_style, Fonts, ResolveOutcome, Shadow, StyleGuide, StyleParams, TokenMap, +}; use super::{McpTool, ToolErrorCode, ToolOutcome}; @@ -24,39 +27,225 @@ impl McpTool for GetGuidelines { } fn call(&self, args: &BTreeMap) -> ToolOutcome { - let topic = match args.get("topic").map(String::as_str) { - Some(t) if !t.trim().is_empty() => t.trim(), - _ => { - return ToolOutcome::Err( - ToolErrorCode::MissingArgument, - "topic is required (\"web-app\", \"mobile\", or \"code-to-design\")".into(), - ) - } - }; + let category = args + .get("category") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("guide") + .to_ascii_lowercase(); - match guideline_for(topic) { - Some(content) => { - let mut out = BTreeMap::new(); - out.insert("topic".into(), topic.to_string()); - out.insert("content".into(), content); - ToolOutcome::Ok(out) - } - None => { - // Mirror the shape get_style_guide returns when no match is found: - // ToolOutcome::Ok with an "error" key — not a transport-level error. - let mut out = BTreeMap::new(); - out.insert( - "error".into(), - format!( - "Unknown topic: \"{topic}\". Supported topics: web-app, mobile, code-to-design." - ), - ); - ToolOutcome::Ok(out) - } + match category.as_str() { + "guide" => call_guide(args), + "style" => call_style(args), + other => ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("category must be \"guide\" or \"style\", got {other:?}"), + ), } } } +fn call_guide(args: &BTreeMap) -> ToolOutcome { + let topic = match args.get("topic").map(String::as_str) { + Some(t) if !t.trim().is_empty() => t.trim(), + _ => { + return ToolOutcome::Err( + ToolErrorCode::MissingArgument, + "topic is required (\"web-app\", \"mobile\", or \"code-to-design\")".into(), + ) + } + }; + + match guideline_for(topic) { + Some(content) => { + let mut out = BTreeMap::new(); + out.insert("topic".into(), topic.to_string()); + out.insert("content".into(), content); + ToolOutcome::Ok(out) + } + None => { + // Mirror the shape get_style_guide returns when no match is found: + // ToolOutcome::Ok with an "error" key — not a transport-level error. + let mut out = BTreeMap::new(); + out.insert( + "error".into(), + format!( + "Unknown topic: \"{topic}\". Supported topics: web-app, mobile, code-to-design." + ), + ); + ToolOutcome::Ok(out) + } + } +} + +fn call_style(args: &BTreeMap) -> ToolOutcome { + let name = match required_arg(args, "name") { + Ok(value) => value, + Err((code, message)) => return ToolOutcome::Err(code, message), + }; + let params = match style_params(args) { + Ok(params) => params, + Err((code, message)) => return ToolOutcome::Err(code, message), + }; + + match resolve_style(&name, ¶ms) { + ResolveOutcome::Hit(style_guide) => { + let mut out = BTreeMap::new(); + out.insert("category".into(), "style".into()); + out.insert("name".into(), name); + out.insert("content".into(), format_style_guide(&style_guide)); + ToolOutcome::Ok(out) + } + ResolveOutcome::Miss { missing, suggest } => { + let mut out = BTreeMap::new(); + out.insert("category".into(), "style".into()); + out.insert("name".into(), name); + out.insert( + "error".into(), + format!( + "Unable to resolve style. Missing: {}. Suggested catalog candidates: {}.", + join_or_none(&missing), + join_or_none(&suggest) + ), + ); + out.insert("missing".into(), join_or_none(&missing)); + out.insert("suggest".into(), join_or_none(&suggest)); + ToolOutcome::Ok(out) + } + } +} + +fn style_params(args: &BTreeMap) -> Result { + Ok(StyleParams { + color_palette: required_arg(args, "colorPalette")?, + roundness: required_arg(args, "roundness")?, + elevation: required_arg(args, "elevation")?, + fonts: Fonts { + headings: required_arg(args, "headings")?, + body: required_arg(args, "body")?, + captions: required_arg(args, "captions")?, + data: required_arg(args, "data")?, + }, + decorative_imagery: args + .get("decorativeImagery") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + }) +} + +fn required_arg( + args: &BTreeMap, + key: &str, +) -> Result { + match args.get(key).map(String::as_str).map(str::trim) { + Some(value) if !value.is_empty() => Ok(value.to_string()), + _ => Err(( + ToolErrorCode::MissingArgument, + format!("{key} is required for category=\"style\""), + )), + } +} + +fn format_style_guide(style_guide: &StyleGuide) -> String { + let mut out = String::new(); + out.push_str(style_guide.prose.trim()); + out.push_str("\n\n## TokenMap\n"); + append_token_map(&mut out, &style_guide.tokens); + append_contrast(&mut out, style_guide); + out +} + +fn append_token_map(out: &mut String, tokens: &TokenMap) { + out.push_str("colorPalette:\n"); + append_color_role_map(out, "surface", &tokens.surface); + append_color_role_map(out, "foreground", &tokens.foreground); + append_color_role_map(out, "accent", &tokens.accent); + append_color_role_map(out, "border", &tokens.border); + append_number_map(out, "roundness", &tokens.rounded); + append_shadow_map(out, "elevation", &tokens.shadow); + out.push_str("typography:\n"); + out.push_str(&format!(" headings: {}\n", tokens.typography.headings)); + out.push_str(&format!(" body: {}\n", tokens.typography.body)); + out.push_str(&format!(" captions: {}\n", tokens.typography.captions)); + out.push_str(&format!(" data: {}\n", tokens.typography.data)); + append_string_map(out, "on", &tokens.on); +} + +fn append_color_role_map(out: &mut String, title: &str, values: &BTreeMap) { + out.push_str(&format!(" {title}:\n")); + for (key, value) in values { + out.push_str(&format!(" {key}: {value}\n")); + } +} + +fn append_string_map(out: &mut String, title: &str, values: &BTreeMap) { + out.push_str(title); + out.push_str(":\n"); + for (key, value) in values { + out.push_str(&format!(" {key}: {value}\n")); + } +} + +fn append_number_map(out: &mut String, title: &str, values: &BTreeMap) { + out.push_str(title); + out.push_str(":\n"); + for (key, value) in values { + out.push_str(&format!(" {key}: {}\n", format_number(*value))); + } +} + +fn append_shadow_map(out: &mut String, title: &str, values: &BTreeMap) { + out.push_str(title); + out.push_str(":\n"); + for (key, shadow) in values { + out.push_str(&format!(" {key}:\n")); + out.push_str(&format!(" type: {}\n", shadow.shadow_type)); + out.push_str(&format!(" color: {}\n", shadow.color)); + out.push_str(&format!( + " offsetX: {}\n", + format_number(shadow.offset_x) + )); + out.push_str(&format!( + " offsetY: {}\n", + format_number(shadow.offset_y) + )); + out.push_str(&format!(" blur: {}\n", format_number(shadow.blur))); + } +} + +fn append_contrast(out: &mut String, style_guide: &StyleGuide) { + out.push_str("contrast:\n"); + if style_guide.contrast.violations.is_empty() { + out.push_str(" status: pass\n"); + return; + } + out.push_str(" status: review\n"); + out.push_str(" violations:\n"); + for violation in &style_guide.contrast.violations { + out.push_str(&format!(" - foreground: {}\n", violation.fg)); + out.push_str(&format!(" background: {}\n", violation.bg)); + out.push_str(&format!(" ratio: {:.2}\n", violation.ratio)); + out.push_str(&format!(" target: {:.2}\n", violation.target)); + } +} + +fn format_number(value: f64) -> String { + if value.fract().abs() < f64::EPSILON { + format!("{}", value as i64) + } else { + format!("{value:.2}") + } +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values.join(", ") + } +} + /// Snapshot constructor — mirrors `get_style_guide_snapshot` convention. pub fn get_guidelines_snapshot() -> GetGuidelines { GetGuidelines @@ -65,6 +254,7 @@ pub fn get_guidelines_snapshot() -> GetGuidelines { #[cfg(test)] mod tests { use super::*; + use op_ai_skills::resolve_style::{resolve_style, Fonts, ResolveOutcome, StyleParams}; fn call(topic: &str) -> ToolOutcome { let mut args = BTreeMap::new(); @@ -72,6 +262,103 @@ mod tests { get_guidelines_snapshot().call(&args) } + fn style_args() -> BTreeMap { + BTreeMap::from([ + ("category".into(), "style".into()), + ("name".into(), "Atlas Grid".into()), + ("colorPalette".into(), "Alloy Blue".into()), + ("roundness".into(), "medium".into()), + ("elevation".into(), "low".into()), + ("headings".into(), "Inter".into()), + ("body".into(), "Inter".into()), + ("captions".into(), "Inter".into()), + ("data".into(), "IBM Plex Mono".into()), + ( + "decorativeImagery".into(), + "product diagrams only when they clarify state".into(), + ), + ]) + } + + fn style_params() -> StyleParams { + StyleParams { + color_palette: "Alloy Blue".into(), + roundness: "medium".into(), + elevation: "low".into(), + fonts: Fonts { + headings: "Inter".into(), + body: "Inter".into(), + captions: "Inter".into(), + data: "IBM Plex Mono".into(), + }, + decorative_imagery: Some("product diagrams only when they clarify state".into()), + } + } + + #[test] + fn get_guidelines_style_roundtrips_our_style() { + let expected = match resolve_style("Atlas Grid", &style_params()) { + ResolveOutcome::Hit(guide) => guide, + other => panic!("expected Atlas Grid hit, got {other:?}"), + }; + + match get_guidelines_snapshot().call(&style_args()) { + ToolOutcome::Ok(out) => { + assert_eq!(out.get("category").map(String::as_str), Some("style")); + assert_eq!(out.get("name").map(String::as_str), Some("Atlas Grid")); + let content = out.get("content").expect("content field"); + assert!( + content.contains("Atlas Grid is a practical workspace style"), + "style prose must be present: {content}" + ); + assert!(content.contains("colorPalette:"), "{content}"); + assert!(content.contains("roundness:"), "{content}"); + assert!(content.contains("elevation:"), "{content}"); + assert!(content.contains("typography:"), "{content}"); + assert!(content.contains("on:"), "{content}"); + + let surface_primary = expected + .tokens + .surface + .get("primary") + .expect("surface.primary token"); + let on_surface_primary = expected + .tokens + .on + .get("on-surface.primary") + .expect("on-surface.primary token"); + assert!( + content.contains(surface_primary), + "surface.primary token value must roundtrip: {content}" + ); + assert!( + content.contains(on_surface_primary), + "computed on-surface.primary token value must roundtrip: {content}" + ); + } + other => panic!("expected Ok, got {other:?}"), + } + } + + #[test] + fn get_guidelines_guide_topic_unchanged() { + match call("web-app") { + ToolOutcome::Ok(out) => { + assert_eq!(out.get("topic").map(String::as_str), Some("web-app")); + assert!( + !out.contains_key("category"), + "default guide path should preserve the existing envelope: {out:?}" + ); + assert_eq!( + out.get("content"), + op_ai_skills::guideline_for("web-app").as_ref(), + "default guide path should return the same guideline content" + ); + } + other => panic!("expected Ok, got {other:?}"), + } + } + #[test] fn web_app_returns_non_empty_content_with_purpose_first() { match call("web-app") { diff --git a/crates/op-mcp/src/mcp_read_tests.rs b/crates/op-mcp/src/mcp_read_tests.rs index f3445cbd9..cd1e125f3 100644 --- a/crates/op-mcp/src/mcp_read_tests.rs +++ b/crates/op-mcp/src/mcp_read_tests.rs @@ -423,6 +423,39 @@ fn parse_tool_call_rejects_structured_values_in_mcp_tools_call_shape() { assert_eq!(call.arguments.get("node_id"), Some(&"42".to_string())); } +#[test] +fn get_guidelines_style_flat_params_parses() { + let line = r#"{"jsonrpc":"2.0","id":17,"method":"tools/call","params":{"name":"get_guidelines","arguments":{"category":"style","name":"Atlas Grid","colorPalette":"Alloy Blue","roundness":"medium","elevation":"low","headings":"Inter","body":"Inter","captions":"Inter","data":"IBM Plex Mono","decorativeImagery":"product diagrams only when they clarify state"}}}"#; + let call = parse_tool_call(line).expect("flat scalar style args must parse"); + assert_eq!(call.tool, "get_guidelines"); + assert_eq!( + call.arguments.get("colorPalette"), + Some(&"Alloy Blue".to_string()) + ); + assert_eq!( + call.arguments.get("data"), + Some(&"IBM Plex Mono".to_string()) + ); + + let mut registry = ToolRegistry::default(); + registry.register(Box::new(get_guidelines_snapshot())); + match registry.dispatch(call) { + ToolResponse::Ok { result, .. } => { + assert_eq!(result.get("category").map(String::as_str), Some("style")); + let content = result.get("content").expect("content field"); + assert!( + content.contains("Atlas Grid is a practical workspace style"), + "style guideline must dispatch through registry: {content}" + ); + assert!( + content.contains("on-surface.primary"), + "computed on-* tokens must be serialized: {content}" + ); + } + other => panic!("expected Ok dispatch, got {other:?}"), + } +} + #[test] fn parse_tool_call_allows_structured_variable_payloads_for_ts_parity() { let vars = r##"{"id":1,"method":"tools/call","params":{"name":"set_variables","arguments":{"variables":{"brand":{"type":"color","value":"#ff0000"}},"replace":true}}}"##; diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index faa80782b..f3f7a0386 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -25,6 +25,7 @@ pub mod plan; pub mod plan_normalize; pub mod plan_repair; pub mod program_gen; +mod resolved_style_prompt; pub mod retry; pub mod script_gen; pub mod semantic_palette; @@ -69,6 +70,8 @@ pub mod tree_heuristics; #[cfg(test)] mod geometry_chip_tests; #[cfg(test)] +mod prompt_resolved_style_tests; +#[cfg(test)] mod radial_stub_tests; #[cfg(test)] mod sidebar_archetype_tests; diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 1bd3fc7f1..67ec74e4a 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -16,11 +16,13 @@ use crate::design_md_policy::build_design_md_style_policy; use crate::design_type::{detect_design_type, DesignType}; use crate::model_profile::{resolve_model_profile, ModelTier}; use crate::plan::{OrchestratorPlan, Subtask}; +use crate::resolved_style_prompt::build_resolved_style_instruction_for_plan; use crate::style_guide_context::build_planning_style_guide_context; use crate::timeouts::{ apply_profile_to_timeouts, builtin_planning_timeouts, orchestrator_timeouts, sub_agent_timeouts, }; use crate::types::{AbortFlag, CallRequest, DesignRequest, PlanningMode, PlanningPrompt}; +use op_ai_skills::resolve_style::{resolve_style, ResolveOutcome}; use op_ai_skills::style_guide::{ extract_style_guide_values, select_style_guide, style_guide_registry, SelectOptions, }; @@ -471,6 +473,60 @@ fn build_style_guide_instruction( Some(lines.join("\n")) } +pub fn build_resolved_style_instruction( + name: &str, + params: &op_ai_skills::resolve_style::StyleParams, +) -> Option { + let guide = match resolve_style(name, params) { + ResolveOutcome::Hit(guide) => guide, + ResolveOutcome::Miss { .. } => return None, + }; + let tokens = &guide.tokens; + + let mut lines = vec![ + format!( + "RESOLVED STYLE REFERENCE ({} / {})", + name.trim(), + params.color_palette.trim() + ), + "Bake these reference values directly into node fills, text colors, borders, radii, and font fields. Do NOT create document variables. Do NOT call set_variables.".to_string(), + ]; + push_resolved_string_tokens(&mut lines, "surface", &tokens.surface); + push_resolved_string_tokens(&mut lines, "foreground", &tokens.foreground); + push_resolved_string_tokens(&mut lines, "accent", &tokens.accent); + push_resolved_string_tokens(&mut lines, "border", &tokens.border); + for (role, value) in &tokens.rounded { + lines.push(format!("rounded.{role}={}px", format_design_number(*value))); + } + lines.push(format!( + "typography: headings={}, body={}, captions={}, data={}", + tokens.typography.headings, + tokens.typography.body, + tokens.typography.captions, + tokens.typography.data + )); + for (role, value) in &tokens.on { + let role = if role.starts_with("on-") { + role.to_string() + } else { + format!("on-{role}") + }; + lines.push(format!("{role}={value}")); + } + + Some(lines.join("\n")) +} + +fn push_resolved_string_tokens( + lines: &mut Vec, + prefix: &str, + values: &std::collections::BTreeMap, +) { + for (role, value) in values { + lines.push(format!("{prefix}.{role}={value}")); + } +} + fn resolve_generation_skills_after_prompt_filter( intent: &str, opts: &ResolveOptions, @@ -798,13 +854,16 @@ fn build_subagent_prompt_core( // generic `design-system` skill. let style_guide_instruction = build_style_guide_instruction(plan.style_guide_name.as_deref(), tier); + let resolved_style_instruction = build_resolved_style_instruction_for_plan(plan); // `design-system` is dropped when ANOTHER styling source already covers it: // the `design-md` skill (`has_design_md`), the `style-defaults` skill (loads - // on `noStyleGuideMatch`), OR the style-guide instruction block just built. + // on `noStyleGuideMatch`), OR a style instruction block just built. // Keeping it alongside any of those would inject design-system's conflicting // "output ONLY a JSON token object" header redundantly (Codex review). - let design_system_covered = - has_design_md || no_style_guide_match || style_guide_instruction.is_some(); + let design_system_covered = has_design_md + || no_style_guide_match + || style_guide_instruction.is_some() + || resolved_style_instruction.is_some(); let explicit_tokens = extract_explicit_design_tokens(&req.prompt); let explicit_token_instruction = explicit_design_token_instruction(explicit_tokens); @@ -924,6 +983,10 @@ fn build_subagent_prompt_core( system_prompt.push_str("\n\n"); system_prompt.push_str(sg); } + if let Some(resolved) = &resolved_style_instruction { + system_prompt.push_str("\n\n"); + system_prompt.push_str(resolved); + } if let Some(instruction) = &explicit_token_instruction { system_prompt.push_str("\n\n"); system_prompt.push_str(instruction); @@ -1057,8 +1120,10 @@ CRITICAL LAYOUT CONSTRAINTS:\n\ // Assemble the per-subtask skill-load report from the FINAL skill set // (post tier/dedup filtering). `budget_max` reflects the tier budget - // override (None == Full tier's 8000 default). - let budget_max = budget_override.unwrap_or(8000); + // override. Full-tier defaults to 12000 because image-rich data-list + // sections (restaurants/products with ratings/prices) overflowed 8000 + // tokens and truncated their scripts to zero generated nodes. + let budget_max = budget_override.unwrap_or(12000); let included: Vec = filtered .iter() .map(|s| SkillLoadEntry { diff --git a/crates/op-orchestrator/src/prompt_resolved_style_tests.rs b/crates/op-orchestrator/src/prompt_resolved_style_tests.rs new file mode 100644 index 000000000..19dc7c599 --- /dev/null +++ b/crates/op-orchestrator/src/prompt_resolved_style_tests.rs @@ -0,0 +1,132 @@ +use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; +use crate::prompt::{build_resolved_style_instruction, build_subagent_prompt}; +use crate::types::{AbortFlag, DesignRequest}; +use op_ai_skills::resolve_style::{resolve_style, Fonts, ResolveOutcome, StyleParams}; +use op_editor_core::ComponentLibrary; + +fn atlas_params() -> StyleParams { + StyleParams { + color_palette: "Alloy Blue".to_string(), + roundness: "medium".to_string(), + elevation: "low".to_string(), + fonts: Fonts { + headings: "Inter".to_string(), + body: "Inter".to_string(), + captions: "Inter".to_string(), + data: "IBM Plex Mono".to_string(), + }, + decorative_imagery: Some("restrained product imagery".to_string()), + } +} + +fn req() -> DesignRequest { + DesignRequest { + prompt: "a dense analytics workspace".into(), + model: Some("claude".into()), + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: true, + visual_ref_enabled: false, + } +} + +fn subtask() -> Subtask { + Subtask { + id: "hero".into(), + label: "Hero".into(), + region: Region { + width: 1200.0, + height: 400.0, + }, + id_prefix: "hero".into(), + parent_frame_id: Some("root".into()), + elements: Some("overview metrics and primary workspace controls".into()), + screen: None, + generated_root_id: None, + existing_section_labels: None, + } +} + +fn plan_with_style(style_name: &str) -> OrchestratorPlan { + OrchestratorPlan { + root_frame: RootFrameSpec { + id: "root".into(), + name: "P".into(), + width: 1200.0, + height: 800.0, + layout: None, + gap: None, + padding: None, + fill: None, + }, + subtasks: vec![subtask()], + style_guide_name: Some(style_name.to_string()), + } +} + +#[test] +fn subagent_style_instruction_contains_our_tokens() { + let params = atlas_params(); + let block = build_resolved_style_instruction("Atlas Grid", ¶ms) + .expect("known style and palette should format"); + let ResolveOutcome::Hit(guide) = resolve_style("Atlas Grid", ¶ms) else { + panic!("known style and palette should resolve"); + }; + + let surface_primary = guide + .tokens + .surface + .get("primary") + .expect("surface.primary"); + let accent_primary = guide.tokens.accent.get("primary").expect("accent.primary"); + + assert!(block.contains("RESOLVED STYLE REFERENCE (Atlas Grid / Alloy Blue)")); + assert!(block.contains(&format!("surface.primary={surface_primary}"))); + assert!(block.contains(&format!("accent.primary={accent_primary}"))); + assert!(block.contains("rounded.md=8px")); + assert!(block + .contains("typography: headings=Inter, body=Inter, captions=Inter, data=IBM Plex Mono")); + assert!(block.contains("on-surface.primary=")); + assert!(block.contains("Bake these reference values directly into node fills")); + assert!(block.contains("Do NOT create document variables")); + assert!(block.contains("Do NOT call set_variables")); +} + +#[test] +fn subagent_resolved_style_emits_no_variable_commands() { + let params = atlas_params(); + let block = build_resolved_style_instruction("Atlas Grid", ¶ms) + .expect("known style and palette should format"); + + let block_type = std::any::type_name_of_val(&block); + assert!( + block_type.contains("String"), + "resolved-style builder must return prompt text, got {block_type}" + ); + + let plan = plan_with_style("Atlas Grid"); + let (call, _) = build_subagent_prompt( + &plan.subtasks[0], + &plan, + &req(), + AbortFlag::new(), + false, + false, + &ComponentLibrary::default(), + ); + assert!( + call.system_prompt + .contains("RESOLVED STYLE REFERENCE (Atlas Grid / Alloy Blue)"), + "live subagent prompt should append the resolved-style block" + ); + + for text in [&block, &call.system_prompt] { + assert!(!text.contains("EditorCommand")); + assert!(!text.contains("SetVariable")); + assert!(!text.contains("MergeThemePreset")); + assert!(!text.contains("set_variables(")); + assert!(!text.contains("\"set_variables\"")); + } +} diff --git a/crates/op-orchestrator/src/resolved_style_prompt.rs b/crates/op-orchestrator/src/resolved_style_prompt.rs new file mode 100644 index 000000000..84ab2008c --- /dev/null +++ b/crates/op-orchestrator/src/resolved_style_prompt.rs @@ -0,0 +1,107 @@ +use crate::plan::OrchestratorPlan; +use op_ai_skills::resolve_style::{resolve_style, Fonts, ResolveOutcome, Shadow, StyleParams}; +use std::collections::BTreeMap; + +fn format_design_number(value: f64) -> String { + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + format!("{value:.1}") + } +} + +fn default_resolved_style_params() -> StyleParams { + StyleParams { + color_palette: "Alloy Blue".to_string(), + roundness: "medium".to_string(), + elevation: "low".to_string(), + fonts: Fonts { + headings: String::new(), + body: String::new(), + captions: String::new(), + data: String::new(), + }, + decorative_imagery: None, + } +} + +pub(crate) fn build_resolved_style_instruction_for_plan(plan: &OrchestratorPlan) -> Option { + build_resolved_style_instruction( + plan.style_guide_name.as_deref()?, + &default_resolved_style_params(), + ) +} + +/// Build the concrete-token style reference block for the new OpenPencil +/// style catalog. This is prompt text only: v1 bakes values into authored +/// nodes and never creates document variables. +pub fn build_resolved_style_instruction(name: &str, params: &StyleParams) -> Option { + let guide = match resolve_style(name, params) { + ResolveOutcome::Hit(guide) => guide, + ResolveOutcome::Miss { .. } => return None, + }; + let tokens = &guide.tokens; + let name = name.trim(); + let palette = params.color_palette.trim(); + + Some( + [ + format!("RESOLVED STYLE REFERENCE ({name} / {palette}):"), + "Authoring rule: Bake these reference values directly into node fills, text colors, border stroke colors, cornerRadius, effect shadows, and font fields. Do NOT create document variables. Do NOT call set_variables. Author concrete values only.".to_string(), + "StyleGuide prose:".to_string(), + guide.prose.trim().to_string(), + "Reference tokens:".to_string(), + format_string_tokens("surface", &tokens.surface), + format_string_tokens("foreground", &tokens.foreground), + format_string_tokens("accent", &tokens.accent), + format_string_tokens("border", &tokens.border), + format_number_tokens("rounded", &tokens.rounded, "px"), + format_shadow_tokens(&tokens.shadow), + format!( + "typography: headings={}, body={}, captions={}, data={}", + tokens.typography.headings, + tokens.typography.body, + tokens.typography.captions, + tokens.typography.data + ), + format_string_tokens("on", &tokens.on), + ] + .join("\n"), + ) +} + +fn format_string_tokens(prefix: &str, values: &BTreeMap) -> String { + let tokens = values + .iter() + .map(|(role, value)| format!("{prefix}.{role}={value}")) + .collect::>() + .join(", "); + format!("{prefix}: {tokens}") +} + +fn format_number_tokens(prefix: &str, values: &BTreeMap, unit: &str) -> String { + let tokens = values + .iter() + .map(|(role, value)| format!("{prefix}.{role}={}{}", format_design_number(*value), unit)) + .collect::>() + .join(", "); + format!("{prefix}: {tokens}") +} + +fn format_shadow_tokens(values: &BTreeMap) -> String { + let tokens = values + .iter() + .map(|(role, shadow)| { + format!( + "shadow.{role}={} {} offset({}px,{}px) blur={}px", + shadow.shadow_type, + shadow.color, + format_design_number(shadow.offset_x), + format_design_number(shadow.offset_y), + format_design_number(shadow.blur) + ) + }) + .collect::>() + .join(", "); + format!("shadow: {tokens}") +} diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index 1b770b91b..ab946d935 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -732,7 +732,7 @@ mod tests { included: vec![brief], dropped: vec![("examples".into(), "budget".into())], budget_used: 5200, - budget_max: 8000, + budget_max: 12000, }; let retry = Progress::SubtaskRetry { id: "header".into(), @@ -802,7 +802,7 @@ mod tests { reason: DropReason::BudgetExhausted, }], budget_used: 5200, - budget_max: 8000, + budget_max: 12000, }; let (briefs, drops, used, max) = report_to_progress_parts(&report); assert_eq!(briefs.len(), 1); @@ -811,7 +811,7 @@ mod tests { assert!(briefs[0].truncated); assert_eq!(drops, vec![("examples".to_string(), "budget".to_string())]); assert_eq!(used, 5200); - assert_eq!(max, 8000); + assert_eq!(max, 12000); } /// All 7 `DropReason` variants map to distinct, non-empty display strings.