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.
This commit is contained in:
Fini 2026-06-13 00:35:35 +08:00
parent 9ffaf5980c
commit a9b0e729fd
5 changed files with 384 additions and 22 deletions

View file

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

View file

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

View file

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

View file

@ -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<VariableDefinition> {
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<String, VariableDefinition> {
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<String, Vec<String>> {
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()])
);
}
}

View file

@ -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<String>,
/// seed 前不存在的主题轴 —— 回滚时从 themes 里剔除。
pub created_axes: Vec<String>,
}
/// 在 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<EditorCommand> {
Vec::new()
/// 缺失的调色板 token / 轴 → 一条 `MergeThemePreset`。全部已存在时
/// 返回空(完全播种过的文档零命令)。
pub fn seed_commands(_plan: &OrchestratorPlan, snap: &VarSnapshot) -> Vec<EditorCommand> {
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]