From a9b0e729fdff0cedd4ed909694ca63efd3241a70 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 13 Jun 2026 00:35:35 +0800 Subject: [PATCH] feat(ai): seed semantic palette at orchestration start Port the TS applySemanticPalette 56-token palette (22 themed light/dark colors on a Mode axis, 6 chart singles, 28 numerics) and wake the dormant variables.rs seeding path. Merge semantics differ from TS: editor-core set_variables_bulk extends (incoming wins) while TS keeps existing values, so the snapshot computes the missing set first and seeds only absent tokens via one MergeThemePreset; rollback removes exactly the created names/axes when no content survives. Live-smoke verified: generated .op carries the 56 vars + Mode axis and $color-* refs resolve at paint. --- .../op-orchestrator/src/cleanup_tests_c1.rs | 1 + crates/op-orchestrator/src/lib.rs | 1 + crates/op-orchestrator/src/run.rs | 4 +- .../op-orchestrator/src/semantic_palette.rs | 218 ++++++++++++++++++ crates/op-orchestrator/src/variables.rs | 182 +++++++++++++-- 5 files changed, 384 insertions(+), 22 deletions(-) create mode 100644 crates/op-orchestrator/src/semantic_palette.rs diff --git a/crates/op-orchestrator/src/cleanup_tests_c1.rs b/crates/op-orchestrator/src/cleanup_tests_c1.rs index 95be8205f..2899ccda6 100644 --- a/crates/op-orchestrator/src/cleanup_tests_c1.rs +++ b/crates/op-orchestrator/src/cleanup_tests_c1.rs @@ -270,6 +270,7 @@ fn cleanup_concurrent_all_scaffold_only_deletes_all_and_rolls_back_vars() { // no content survived. let snap = VarSnapshot { created: vec!["color-primary".into()], + created_axes: vec![], }; cleanup_concurrent_roots(&mut sink, &[&root_a, &root_b], &[0, 0], &snap); diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index bfb85b460..1f2393556 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -23,6 +23,7 @@ pub mod plan; pub mod plan_normalize; pub mod plan_repair; pub mod retry; +pub mod semantic_palette; pub mod stub_providers; pub mod style_guide_context; pub mod timeouts; diff --git a/crates/op-orchestrator/src/run.rs b/crates/op-orchestrator/src/run.rs index 5ef7fcfee..8c828a62d 100644 --- a/crates/op-orchestrator/src/run.rs +++ b/crates/op-orchestrator/src/run.rs @@ -133,7 +133,7 @@ impl Orchestrator { let var_snapshot = snapshot_plan_vars(sink, &plan); // -- 阶段 2:画布搭建 -- - for cmd in seed_commands(&plan) { + for cmd in seed_commands(&plan, &var_snapshot) { sink.apply(cmd); } let scaffold_root_index = sink.state().active_children().len(); @@ -440,7 +440,7 @@ async fn run_concurrent_path( let var_snapshot = snapshot_plan_vars(sink, &plan); // -- 阶段 2 (并发):变量播种 + N-root scaffold -- - for cmd in seed_commands(&plan) { + for cmd in seed_commands(&plan, &var_snapshot) { sink.apply(cmd); } diff --git a/crates/op-orchestrator/src/semantic_palette.rs b/crates/op-orchestrator/src/semantic_palette.rs new file mode 100644 index 000000000..949c9a7c8 --- /dev/null +++ b/crates/op-orchestrator/src/semantic_palette.rs @@ -0,0 +1,218 @@ +//! 56-token semantic palette — the Rust port of +//! `packages/pen-core/src/variables/semantic-palette.ts`. +//! +//! Composition (P1 token system, values verbatim from the TS table): +//! - 14 base color tokens (surface, border, text, accent, scrim) — light/dark +//! - 8 alert color tokens (info/success/warning/danger × bg/text) — light/dark +//! - 6 chart color tokens (color-chart-1..6) — single-value +//! - 18 typography tokens (size + weight + line-height × 6 roles) — numeric +//! - 2 letterSpacing tokens — numeric +//! - 5 spacing tokens (4/8/12/16/24) — numeric +//! - 3 radius tokens (4/8/12) — numeric +//! +//! Theme-aware tokens carry BOTH values on the single `Mode: [Light, +//! Dark]` axis; single-value entries have no theme axis. The palette is +//! what `$color-*` / `$type-*` refs emitted by theme="system" element +//! builders resolve against (paint-time resolution lives in +//! `op-editor-ui::scene_vars::VariableTable`). + +use jian_ops_schema::variable::{ + ThemedValue, VariableDefinition, VariableKind, VariableScalar, VariableValue, +}; +use std::collections::BTreeMap; + +/// The single theme axis the palette declares. +pub const THEME_AXIS: &str = "Mode"; +pub const THEME_LIGHT: &str = "Light"; +pub const THEME_DARK: &str = "Dark"; + +enum Entry { + /// Theme-aware color — (light hex, dark hex). + LightDark(&'static str, &'static str), + /// Single-value color, no theme axis (chart palette). + Color(&'static str), + /// Numeric token, no theme axis (typography / spacing / radius). + Num(f64), +} + +/// Raw value table — source of truth, kept in the same order as the TS +/// `PALETTE` constant so review diffs line up. +const PALETTE: &[(&str, Entry)] = &[ + ("color-surface", Entry::LightDark("#FFFFFF", "#1E293B")), + ("color-surface-2", Entry::LightDark("#F1F5F9", "#334155")), + ("color-surface-3", Entry::LightDark("#F3F4F6", "#475569")), + ("color-bg-deep", Entry::LightDark("#F8FAFC", "#0F172A")), + ("color-border", Entry::LightDark("#E2E8F0", "#334155")), + ( + "color-border-strong", + Entry::LightDark("#CBD5E1", "#475569"), + ), + ("color-text-primary", Entry::LightDark("#0F172A", "#F1F5F9")), + ("color-text-body", Entry::LightDark("#334155", "#CBD5E1")), + ("color-text-muted", Entry::LightDark("#64748B", "#94A3B8")), + ("color-text-subtle", Entry::LightDark("#94A3B8", "#64748B")), + ("color-accent", Entry::LightDark("#2563EB", "#60A5FA")), + ("color-destructive", Entry::LightDark("#EF4444", "#F87171")), + ("color-success", Entry::LightDark("#10B981", "#34D399")), + ("color-scrim", Entry::LightDark("#00000080", "#00000099")), + // ── Alert tokens ────────────────────────────────────────────── + ("color-info-bg", Entry::LightDark("#DBEAFE", "#1E3A8A")), + ("color-info-text", Entry::LightDark("#1E40AF", "#BFDBFE")), + ("color-success-bg", Entry::LightDark("#DCFCE7", "#14532D")), + ("color-success-text", Entry::LightDark("#166534", "#BBF7D0")), + ("color-warning-bg", Entry::LightDark("#FEF3C7", "#78350F")), + ("color-warning-text", Entry::LightDark("#92400E", "#FDE68A")), + ("color-danger-bg", Entry::LightDark("#FEE2E2", "#7F1D1D")), + ("color-danger-text", Entry::LightDark("#991B1B", "#FECACA")), + // ── Typography: size + weight + line-height × 6 roles ───────── + ("type-display-size", Entry::Num(64.0)), + ("type-display-weight", Entry::Num(700.0)), + ("type-display-line-height", Entry::Num(1.0)), + ("type-h1-size", Entry::Num(24.0)), + ("type-h1-weight", Entry::Num(600.0)), + ("type-h1-line-height", Entry::Num(1.2)), + ("type-h2-size", Entry::Num(20.0)), + ("type-h2-weight", Entry::Num(600.0)), + ("type-h2-line-height", Entry::Num(1.25)), + ("type-h3-size", Entry::Num(16.0)), + ("type-h3-weight", Entry::Num(600.0)), + ("type-h3-line-height", Entry::Num(1.3)), + ("type-body-size", Entry::Num(14.0)), + ("type-body-weight", Entry::Num(400.0)), + ("type-body-line-height", Entry::Num(1.5)), + ("type-caption-size", Entry::Num(12.0)), + ("type-caption-weight", Entry::Num(400.0)), + ("type-caption-line-height", Entry::Num(1.4)), + // ── Border radius ───────────────────────────────────────────── + ("radius-sm", Entry::Num(4.0)), + ("radius-md", Entry::Num(8.0)), + ("radius-lg", Entry::Num(12.0)), + // ── Spacing scale ───────────────────────────────────────────── + ("spacing-1", Entry::Num(4.0)), + ("spacing-2", Entry::Num(8.0)), + ("spacing-3", Entry::Num(12.0)), + ("spacing-4", Entry::Num(16.0)), + ("spacing-5", Entry::Num(24.0)), + // ── Sparse letterSpacing ────────────────────────────────────── + ("type-display-letter-spacing", Entry::Num(-0.5)), + ("type-uppercase-label-letter-spacing", Entry::Num(1.5)), + // ── Chart palette ───────────────────────────────────────────── + ("color-chart-1", Entry::Color("#3B82F6")), + ("color-chart-2", Entry::Color("#8B5CF6")), + ("color-chart-3", Entry::Color("#EC4899")), + ("color-chart-4", Entry::Color("#14B8A6")), + ("color-chart-5", Entry::Color("#F59E0B")), + ("color-chart-6", Entry::Color("#F97316")), +]; + +/// Every palette token name, table order. +pub fn palette_names() -> Vec<&'static str> { + PALETTE.iter().map(|(name, _)| *name).collect() +} + +fn themed(mode: &str, hex: &str) -> ThemedValue { + ThemedValue { + value: VariableScalar::Str(hex.to_string()), + theme: Some(BTreeMap::from([(THEME_AXIS.to_string(), mode.to_string())])), + } +} + +/// One token's `VariableDefinition`; `None` for unknown names. +pub fn palette_variable(name: &str) -> Option { + let entry = PALETTE.iter().find(|(n, _)| *n == name).map(|(_, e)| e)?; + Some(match entry { + Entry::LightDark(light, dark) => VariableDefinition { + kind: VariableKind::Color, + value: VariableValue::Themed(vec![ + themed(THEME_LIGHT, light), + themed(THEME_DARK, dark), + ]), + }, + Entry::Color(hex) => VariableDefinition { + kind: VariableKind::Color, + value: VariableValue::Scalar(VariableScalar::Str(hex.to_string())), + }, + Entry::Num(n) => VariableDefinition { + kind: VariableKind::Number, + value: VariableValue::Scalar(VariableScalar::Num(*n)), + }, + }) +} + +/// The full palette as a variables map (for `MergeThemePreset`). +pub fn palette_variables() -> BTreeMap { + PALETTE + .iter() + .map(|(name, _)| { + ( + name.to_string(), + palette_variable(name).expect("table name"), + ) + }) + .collect() +} + +/// The `Mode: [Light, Dark]` theme axis declaration. +pub fn palette_themes() -> BTreeMap> { + BTreeMap::from([( + THEME_AXIS.to_string(), + vec![THEME_LIGHT.to_string(), THEME_DARK.to_string()], + )]) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 56 tokens: 22 themed colors + 6 chart colors + 28 numerics — + /// the TS `SEMANTIC_PALETTE_NAMES` inventory. + #[test] + fn palette_inventory_matches_ts() { + let vars = palette_variables(); + assert_eq!(vars.len(), 56); + let themed = vars + .values() + .filter(|v| matches!(v.value, VariableValue::Themed(_))) + .count(); + let numbers = vars + .values() + .filter(|v| v.kind == VariableKind::Number) + .count(); + assert_eq!(themed, 22, "14 base + 8 alert light/dark pairs"); + assert_eq!(numbers, 28, "18 type + 2 tracking + 5 spacing + 3 radius"); + assert_eq!(vars.len() - themed - numbers, 6, "chart singles"); + } + + /// Spot-check values stay verbatim with the TS table. + #[test] + fn spot_values_verbatim() { + let accent = palette_variable("color-accent").unwrap(); + let VariableValue::Themed(pair) = &accent.value else { + panic!("accent must be themed"); + }; + assert_eq!(pair[0].value, VariableScalar::Str("#2563EB".into())); + assert_eq!(pair[1].value, VariableScalar::Str("#60A5FA".into())); + assert_eq!( + pair[0].theme.as_ref().unwrap().get(THEME_AXIS), + Some(&THEME_LIGHT.to_string()) + ); + + let body = palette_variable("type-body-size").unwrap(); + assert_eq!(body.value, VariableValue::Scalar(VariableScalar::Num(14.0))); + + let chart = palette_variable("color-chart-1").unwrap(); + assert_eq!( + chart.value, + VariableValue::Scalar(VariableScalar::Str("#3B82F6".into())) + ); + } + + #[test] + fn themes_declare_mode_axis() { + let themes = palette_themes(); + assert_eq!( + themes.get(THEME_AXIS), + Some(&vec!["Light".to_string(), "Dark".to_string()]) + ); + } +} diff --git a/crates/op-orchestrator/src/variables.rs b/crates/op-orchestrator/src/variables.rs index 9c16ad3c6..8991ac77a 100644 --- a/crates/op-orchestrator/src/variables.rs +++ b/crates/op-orchestrator/src/variables.rs @@ -1,47 +1,107 @@ -//! plan 派生变量 —— seed / 快照 / 回滚。 +//! 设计变量 seed —— 语义调色板播种 / 快照 / 回滚。 //! -//! S3b-1a:plan 已无 `palette`。三个公共函数入眠态(签名不变, -//! `run.rs` 调用方不动)。忠实的 styleGuideName → 解析 guide → -//! 播种变量是后续项。 +//! 编排开始时把 56-token 语义调色板([`crate::semantic_palette`], +//! TS `applySemanticPalette` 的 Rust 移植)播进文档,让 theme="system" +//! 元素发出的 `$color-*` 引用在 paint 时能解析(此前 seeding 休眠, +//! 悬空引用渲染灰底)。 +//! +//! 合并语义:**已有者赢**(对齐 TS)。`set_variables_bulk(merge)` 在 +//! editor-core 侧是 `.extend`(新值赢),所以这里先用快照算出缺失集, +//! seed 只发缺失的 token / 轴 —— 文档里已自定义的 `color-accent` +//! 永远不被覆盖。 +//! +//! 忠实的 styleGuideName → 解析 guide → 播种 guide 调色板仍是后续项; +//! 本模块是其缺省底座(DEFAULT_PALETTE 等价物)。 use crate::plan::OrchestratorPlan; +use crate::semantic_palette; use crate::types::DocSink; use op_editor_core::EditorCommand; -/// 回滚快照 —— seed 前"不存在"的 plan 变量名集合(即 seed 会 -/// 真正新建的那批)。 +/// 回滚快照 —— seed 前"不存在"的调色板变量名 / 主题轴集合(即 seed +/// 会真正新建的那批)。 #[derive(Debug, Clone, Default)] pub struct VarSnapshot { /// seed 前不存在的变量名 —— 回滚时删除这些。 pub created: Vec, + /// seed 前不存在的主题轴 —— 回滚时从 themes 里剔除。 + pub created_axes: Vec, } -/// 在 seed *之前* 调用。S3b-1a:plan 已无 `palette`,恒为空快照。 -/// 忠实的 styleGuideName → 解析 guide → 播种变量是后续项。 -pub fn snapshot_plan_vars(_sink: &dyn DocSink, _plan: &OrchestratorPlan) -> VarSnapshot { - VarSnapshot::default() +/// 在 seed *之前* 调用:对照现有文档变量/主题轴,算出调色板里缺失 +/// 的部分。快照同时是 [`seed_commands`] 的输入(只播缺失项)和 +/// [`rollback`] 的依据(只删自己新建的)。 +pub fn snapshot_plan_vars(sink: &dyn DocSink, _plan: &OrchestratorPlan) -> VarSnapshot { + let doc = &sink.state().doc; + let existing_vars = doc.variables.as_ref(); + let created = semantic_palette::palette_names() + .into_iter() + .filter(|name| !existing_vars.is_some_and(|vars| vars.contains_key(*name))) + .map(str::to_string) + .collect(); + let existing_axes = doc.themes.as_ref(); + let created_axes = semantic_palette::palette_themes() + .into_keys() + .filter(|axis| !existing_axes.is_some_and(|axes| axes.contains_key(axis))) + .collect(); + VarSnapshot { + created, + created_axes, + } } -/// plan 调色板 → seed 命令。S3b-1a:plan 无 `palette`,恒为空。 -pub fn seed_commands(_plan: &OrchestratorPlan) -> Vec { - Vec::new() +/// 缺失的调色板 token / 轴 → 一条 `MergeThemePreset`。全部已存在时 +/// 返回空(完全播种过的文档零命令)。 +pub fn seed_commands(_plan: &OrchestratorPlan, snap: &VarSnapshot) -> Vec { + if snap.created.is_empty() && snap.created_axes.is_empty() { + return Vec::new(); + } + let palette = semantic_palette::palette_variables(); + let variables = snap + .created + .iter() + .filter_map(|name| palette.get(name).map(|def| (name.clone(), def.clone()))) + .collect(); + let themes = semantic_palette::palette_themes() + .into_iter() + .filter(|(axis, _)| snap.created_axes.contains(axis)) + .collect(); + vec![EditorCommand::MergeThemePreset { variables, themes }] } -/// 回滚 seed 新建的变量。S3b-1a:无 seed,故 no-op(快照恒空)。 +/// 回滚 seed 新建的变量与主题轴(已有者从未被覆盖,所以恢复 = 删除 +/// 新建项)。 pub fn rollback(sink: &mut dyn DocSink, snap: &VarSnapshot) { for name in &snap.created { sink.apply(EditorCommand::DeleteVariable { name: name.clone() }); } + if !snap.created_axes.is_empty() { + let themes = sink + .state() + .doc + .themes + .clone() + .unwrap_or_default() + .into_iter() + .filter(|(axis, _)| !snap.created_axes.contains(axis)) + .collect(); + sink.apply(EditorCommand::SetThemes { + themes, + replace: true, + }); + } } #[cfg(test)] mod tests { use super::*; use crate::test_support::VecDocSink; + use jian_ops_schema::variable::{ + VariableDefinition, VariableKind, VariableScalar, VariableValue, + }; - #[test] - fn seed_is_dormant() { - let plan = crate::plan::build_fallback_plan(&crate::types::DesignRequest { + fn plan() -> OrchestratorPlan { + crate::plan::build_fallback_plan(&crate::types::DesignRequest { prompt: "a page".into(), model: None, provider: None, @@ -51,10 +111,92 @@ mod tests { validation_enabled: true, visual_ref_enabled: false, - }); - assert!(seed_commands(&plan).is_empty()); + }) + } + + /// 空文档:快照报告全部 56 token + Mode 轴缺失,seed 出一条 + /// MergeThemePreset 把它们全部带上。 + #[test] + fn seed_on_empty_doc_carries_full_palette() { let sink = VecDocSink::new(); - assert!(snapshot_plan_vars(&sink, &plan).created.is_empty()); + let snap = snapshot_plan_vars(&sink, &plan()); + assert_eq!(snap.created.len(), 56); + assert_eq!(snap.created_axes, vec!["Mode".to_string()]); + + let cmds = seed_commands(&plan(), &snap); + assert_eq!(cmds.len(), 1); + let EditorCommand::MergeThemePreset { variables, themes } = &cmds[0] else { + panic!("expected MergeThemePreset, got {:?}", cmds[0]); + }; + assert_eq!(variables.len(), 56); + assert!(variables.contains_key("color-accent")); + assert_eq!(themes.get("Mode").map(Vec::len), Some(2)); + } + + /// 已有同名变量获胜(TS applySemanticPalette 语义):快照不把它 + /// 计入 created,seed 不发它 —— editor-core 的 merge 是新值赢, + /// 守住"已有者赢"全靠这里不发。 + #[test] + fn seed_respects_existing_variables_and_axes() { + let mut sink = VecDocSink::new(); + sink.state.doc.variables = Some( + [( + "color-accent".to_string(), + VariableDefinition { + kind: VariableKind::Color, + value: VariableValue::Scalar(VariableScalar::Str("#FF00FF".into())), + }, + )] + .into(), + ); + sink.state.doc.themes = Some([("Mode".to_string(), vec!["Light".to_string()])].into()); + + let snap = snapshot_plan_vars(&sink, &plan()); + assert_eq!(snap.created.len(), 55); + assert!(!snap.created.contains(&"color-accent".to_string())); + assert!(snap.created_axes.is_empty(), "Mode axis pre-exists"); + + let cmds = seed_commands(&plan(), &snap); + let EditorCommand::MergeThemePreset { variables, themes } = &cmds[0] else { + panic!("expected MergeThemePreset"); + }; + assert!(!variables.contains_key("color-accent")); + assert!(themes.is_empty()); + } + + /// 全量播种过的文档:零命令。 + #[test] + fn seed_is_noop_when_fully_seeded() { + let mut sink = VecDocSink::new(); + sink.state.doc.variables = Some( + crate::semantic_palette::palette_variables() + .into_iter() + .collect(), + ); + sink.state.doc.themes = Some(crate::semantic_palette::palette_themes()); + let snap = snapshot_plan_vars(&sink, &plan()); + assert!(snap.created.is_empty()); + assert!(snap.created_axes.is_empty()); + assert!(seed_commands(&plan(), &snap).is_empty()); + } + + /// 回滚删除新建变量,并把新建的 Mode 轴从 themes 里剔除。 + #[test] + fn rollback_deletes_created_vars_and_axes() { + let mut sink = VecDocSink::new(); + let snap = VarSnapshot { + created: vec!["color-accent".to_string()], + created_axes: vec!["Mode".to_string()], + }; + rollback(&mut sink, &snap); + assert!(sink.applied.iter().any(|c| matches!( + c, + EditorCommand::DeleteVariable { name } if name == "color-accent" + ))); + assert!(sink.applied.iter().any(|c| matches!( + c, + EditorCommand::SetThemes { themes, replace: true } if !themes.contains_key("Mode") + ))); } #[test]