feat(ai): design style-resolution color system + subtask token budget

get_guidelines(category=style) returns a resolved style from an own-built
catalog (7 styles x 7 palettes) with OKLCH-based contrast validation, baked
into the design pipeline (loop prompt + orchestrator subagent instruction).
Also raise the per-subtask output budget 8000 -> 12000 so image-rich
data-list sections stop truncating mid-script.
This commit is contained in:
Fini 2026-07-06 00:47:08 +08:00
parent 40c3094077
commit ea27689d0e
23 changed files with 2091 additions and 42 deletions

View file

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

View file

@ -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<f64> {
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<ContrastViolation>,
}
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<f64> {
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)
}

View file

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

View file

@ -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<Oklch> {
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<f64> {
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
}
}

View file

@ -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<String, String>,
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<PaletteAnchors> {
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,
}
}

View file

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

View file

@ -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<String>,
}
#[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<String, String>,
pub foreground: BTreeMap<String, String>,
pub accent: BTreeMap<String, String>,
pub border: BTreeMap<String, String>,
pub rounded: BTreeMap<String, f64>,
pub shadow: BTreeMap<String, Shadow>,
pub typography: Fonts,
pub on: BTreeMap<String, String>,
}
#[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<String>,
suggest: Vec<String>,
},
}
#[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(&params.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, &params.color_palette, &styles),
};
}
let style = style.expect("style checked above");
let anchors = anchors.expect("palette checked above");
let typography = effective_fonts(&style.fonts, &params.fonts);
let tokens = build_tokens(
&anchors,
&effective_choice(&style.roundness, &params.roundness),
&effective_choice(&style.elevation, &params.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<String> {
style_catalog_entries()
.into_iter()
.map(|entry| entry.name)
.collect()
}
fn style_catalog_entries() -> Vec<StyleCatalogEntry> {
let mut entries: Vec<StyleCatalogEntry> = 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<StyleCatalogEntry> {
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<String, String> {
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<String, String>, group: &str) -> BTreeMap<String, String> {
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<String, String>,
accent: &BTreeMap<String, String>,
border: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
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<String, f64> {
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<String, Shadow> {
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<String> {
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<Item = String>, limit: usize) -> Vec<String> {
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<usize> = (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, &params_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, &params_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, &params_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", &params_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, &params_for(palette));
let type_name = std::any::type_name::<ResolveOutcome>();
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)));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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::<serde_json::Value>(&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<String>,
fg: String,
bg: String,
ratio: f64,
target: f64,
}
const CONTRAST_AA_TARGET: f64 = 4.5;
fn scan_contrast_issues(nodes: &[PenNode]) -> Vec<ContrastIssue> {
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<String>,
fg: String,
bg: String,
target: f64,
}
fn collect_contrast_candidates(
node: &PenNode,
bg_stack: &mut Vec<String>,
out: &mut Vec<ContrastCandidate>,
) {
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<String> {
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<Vec<PenFill>>) -> Option<String> {
fill.as_ref()?.iter().find_map(|fill| match fill {
PenFill::Solid(body) => concrete_hex(&body.color),
_ => None,
})
}
fn concrete_hex(color: &str) -> Option<String> {
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-<role> 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();

View file

@ -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"]}}"#,

View file

@ -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<String, String>) -> 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<String, String>) -> 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<String, String>) -> 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, &params) {
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<String, String>) -> Result<StyleParams, (ToolErrorCode, String)> {
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<String, String>,
key: &str,
) -> Result<String, (ToolErrorCode, String)> {
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<String, String>) {
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<String, String>) {
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<String, f64>) {
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<String, Shadow>) {
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<String, String> {
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") {

View file

@ -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}}}"##;

View file

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

View file

@ -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<String> {
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<String>,
prefix: &str,
values: &std::collections::BTreeMap<String, String>,
) {
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<SkillLoadEntry> = filtered
.iter()
.map(|s| SkillLoadEntry {

View file

@ -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", &params)
.expect("known style and palette should format");
let ResolveOutcome::Hit(guide) = resolve_style("Atlas Grid", &params) 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", &params)
.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\""));
}
}

View file

@ -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<String> {
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<String> {
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, String>) -> String {
let tokens = values
.iter()
.map(|(role, value)| format!("{prefix}.{role}={value}"))
.collect::<Vec<_>>()
.join(", ");
format!("{prefix}: {tokens}")
}
fn format_number_tokens(prefix: &str, values: &BTreeMap<String, f64>, unit: &str) -> String {
let tokens = values
.iter()
.map(|(role, value)| format!("{prefix}.{role}={}{}", format_design_number(*value), unit))
.collect::<Vec<_>>()
.join(", ");
format!("{prefix}: {tokens}")
}
fn format_shadow_tokens(values: &BTreeMap<String, Shadow>) -> 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::<Vec<_>>()
.join(", ");
format!("shadow: {tokens}")
}

View file

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