返回 CodeWhale
skin.rs
根目录 / crates / tui / src / integrations / dsh / skin.rs
1 //! Codewhale palette for the DeepSeek Harness web surface.
2 //!
3 //! DSH 0.1.0-rc.6 applies alias tokens as inline `body.style.setProperty`
4 //! values, so a stylesheet cannot win. The documented token-level override is
5 //! `ctx.theme.overrideTokens`. This module is the single source of truth for
6 //! that layer: alias name → (light, dark), both rendered from the TUI Blue
7 //! Stage palettes. The generated bundle also mounts a plugin-owned Whale
8 //! Brothers / Codewhale identity lockup; it leaves DSH-owned branding intact.
9
10 use std::collections::BTreeMap;
11
12 use ratatui::style::Color;
13 use serde::{Deserialize, Serialize};
14
15 use codewhale_palette::{
16 LIGHT_PANEL, LIGHT_SURFACE, LIGHT_UI_THEME, UI_THEME, UiTheme, WHALE_BG, WHALE_CHROME,
17 WHALE_COMPOSER, WHALE_PANEL, hex_rgb_string,
18 };
19
20 /// Source id passed to `overrideTokens` and used as the client module id.
21 pub(crate) const SKIN_SOURCE: &str = "codewhale-dsh-bundle";
22
23 /// One alias token's light/dark CSS values, rendered from the TUI palette.
24 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25 pub(crate) struct SkinTokens {
26 pub(crate) light: String,
27 pub(crate) dark: String,
28 }
29
30 fn hex(color: Color) -> String {
31 hex_rgb_string(color).unwrap_or_else(|| "inherit".to_string())
32 }
33
34 /// Opaque Blue Stage palettes for the browser-owned DSH surface.
35 ///
36 /// The native TUI's Whale presets deliberately leave ordinary Flat shell
37 /// surfaces as `Color::Reset` so the terminal owns them. DSH is a browser
38 /// surface with no terminal background to inherit, so its token override and
39 /// optional ocean veil must restore the same concrete palette colors instead
40 /// of serializing `Reset` as CSS `inherit`.
41 pub(super) fn browser_themes() -> (UiTheme, UiTheme) {
42 let mut light = LIGHT_UI_THEME;
43 light.surface_bg = LIGHT_SURFACE;
44 light.panel_bg = LIGHT_PANEL;
45 light.composer_bg = LIGHT_PANEL;
46 light.header_bg = LIGHT_SURFACE;
47 light.footer_bg = LIGHT_SURFACE;
48
49 let mut dark = UI_THEME;
50 dark.surface_bg = WHALE_BG;
51 dark.panel_bg = WHALE_PANEL;
52 dark.composer_bg = WHALE_COMPOSER;
53 dark.header_bg = WHALE_CHROME;
54 dark.footer_bg = WHALE_CHROME;
55 (light, dark)
56 }
57
58 /// Alias name → (light, dark). Keys are `--dsw-alias-*`; values are palette
59 /// hex constants only — no secrets, no user data, no environment.
60 pub(crate) fn skin_tokens() -> BTreeMap<String, (String, String)> {
61 let (light, dark) = browser_themes();
62 let mut map = BTreeMap::new();
63 let mut add = |name: &str, pick: fn(&UiTheme) -> Color| {
64 map.insert(name.to_string(), (hex(pick(&light)), hex(pick(&dark))));
65 };
66 // Bounded mapping of DSH `--dsw-alias-*` variables onto Codewhale tokens.
67 // Names come from dsh-client-ui-theme/lib/styles/design-platform.css.
68 add("--dsw-alias-bg-base", |t| t.surface_bg);
69 add("--dsw-alias-bg-layer-1", |t| t.panel_bg);
70 add("--dsw-alias-bg-layer-2", |t| t.composer_bg);
71 add("--dsw-alias-bg-layer-3", |t| t.elevated_bg);
72 add("--dsw-alias-bg-overlay", |t| t.elevated_bg);
73 add("--dsw-alias-bg-module-platform", |t| t.panel_bg);
74 add("--dsw-alias-border-l1", |t| t.border);
75 add("--dsw-alias-border-l2", |t| t.border);
76 add("--dsw-alias-border-l3", |t| t.selection_bg);
77 add("--dsw-alias-border-l4", |t| t.selection_bg);
78 add("--dsw-alias-brand-primary", |t| t.accent_primary);
79 add("--dsw-alias-brand-text", |t| t.accent_primary);
80 add("--dsw-alias-button-primary-fill", |t| t.accent_primary);
81 add("--dsw-alias-button-primary-hover", |t| t.info);
82 add("--dsw-alias-button-primary-dimmed", |t| t.selection_bg);
83 add("--dsw-alias-interactive-bg-hover", |t| t.selection_bg);
84 add("--dsw-alias-interactive-bg-active", |t| t.selection_bg);
85 add("--dsw-alias-interactive-bg-hover-danger", |t| {
86 t.error_surface
87 });
88 add("--dsw-alias-label-primary", |t| t.text_body);
89 add("--dsw-alias-label-secondary", |t| t.text_soft);
90 add("--dsw-alias-label-tertiary", |t| t.text_muted);
91 add("--dsw-alias-label-caption", |t| t.text_hint);
92 add("--dsw-alias-label-dimmed", |t| t.text_dim);
93 add("--dsw-alias-label-primary-bluish", |t| t.accent_primary);
94 add("--dsw-alias-state-error-primary", |t| t.error_fg);
95 add("--dsw-alias-state-error-secondary", |t| t.error_surface);
96 add("--dsw-alias-state-success-primary", |t| t.success);
97 add("--dsw-alias-state-success-secondary", |t| t.diff_added_bg);
98 add("--dsw-alias-state-warn-primary", |t| t.warning);
99 add("--dsw-alias-state-warn-label", |t| t.warning);
100 add("--dsw-alias-state-business-primary", |t| t.accent_action);
101 add("--dsw-alias-markdown-code-block", |t| t.panel_bg);
102 add("--dsw-alias-markdown-inline-code", |t| t.composer_bg);
103 add("--dsw-alias-scrollbar-bg-l1", |t| t.border);
104 add("--dsw-alias-scrollbar-hover-l1", |t| t.selection_bg);
105 add("--dsw-alias-toast-bg", |t| t.elevated_bg);
106 add("--dsw-alias-tooltip-bg", |t| t.elevated_bg);
107 map
108 }
109
110 pub(crate) fn skin_token_objects() -> BTreeMap<String, SkinTokens> {
111 skin_tokens()
112 .into_iter()
113 .map(|(name, (light, dark))| (name, SkinTokens { light, dark }))
114 .collect()
115 }
116
117 /// Deterministic TOKENS JSON object (alias → `{light, dark}`).
118 pub(crate) fn skin_tokens_json() -> String {
119 serde_json::to_string_pretty(&skin_token_objects()).expect("skin tokens are json")
120 }
121
122 pub(crate) fn skin_tokens_sha256() -> String {
123 super::identity::sha256_hex(skin_tokens_json().as_bytes())
124 }
125
126 fn indent_json_block(json: &str, indent: &str) -> String {
127 json.lines()
128 .enumerate()
129 .map(|(i, line)| {
130 if i == 0 {
131 line.to_string()
132 } else {
133 format!("{indent}{line}")
134 }
135 })
136 .collect::<Vec<_>>()
137 .join("\n")
138 }
139
140 /// Client half: `__ModuleLoader__.load` wrapper + factory that applies
141 /// `overrideTokens` inside `ctx.effect` and returns the disposer. `inject:
142 /// ["theme", "slots"]` is required: cordis 4 only exposes injected (or
143 /// self/ancestor provided) services on `ctx`; reading either sibling service
144 /// without it fails the whole web boot.
145 ///
146 /// The Whale Brothers / Codewhale lockup is spliced in for every skin and
147 /// registered into DSH's additive `shell.overlay` slot. With `ocean` the
148 /// ambient scene (`scene::bundle_scene_js`) is also spliced in: the module
149 /// mounts the canvas inside another `ctx.effect`, follows `theme/change` for
150 /// light/dark, and re-issues the veil tokens (`scene::ocean_veil_tokens`) as
151 /// translucent rgba over the opaque table. The browser-side off switch
152 /// (`localStorage["codewhale.ocean"] = "off"` or body class
153 /// `codewhale-ocean-off`) skips both the canvas and the veil.
154 pub(crate) fn bundle_client_js(ocean: bool) -> String {
155 let version = env!("CARGO_PKG_VERSION");
156 let tokens = indent_json_block(&skin_tokens_json(), "\t\t");
157 let brand = indent_json_block(super::brand::bundle_brand_js().trim_end(), "\t\t");
158 let ocean_block = if ocean {
159 let veil = indent_json_block(&super::scene::ocean_veil_json(), "\t\t");
160 let palette = indent_json_block(&super::scene::ocean_palette_json(), "\t\t");
161 let scene = indent_json_block(super::scene::bundle_scene_js().trim_end(), "\t\t");
162 format!(
163 "\t\tconst OCEAN = true;\n\
164 \t\tconst OCEAN_VEIL = {veil};\n\
165 \t\tconst OCEAN_PALETTE = {palette};\n\
166 \t\t{scene}\n"
167 )
168 } else {
169 "\t\tconst OCEAN = false;\n\
170 \t\tconst OCEAN_VEIL = null;\n\
171 \t\tconst OCEAN_PALETTE = null;\n\
172 \t\tfunction createOcean() { return null; }\n"
173 .to_string()
174 };
175 format!(
176 "/* codewhale-skin/{version} */\n\
177 window.__ModuleLoader__.load({{\n\
178 \tid: \"{SKIN_SOURCE}\",\n\
179 \tfactory: (require) => {{\n\
180 \t\tvar module = {{ exports: {{}} }};\n\
181 \t\tvar exports = module.exports;\n\
182 \t\tObject.defineProperty(exports, Symbol.toStringTag, {{ value: \"Module\" }});\n\
183 \t\tlet React = require(\"react\");\n\
184 \t\tconst TOKENS = {tokens};\n\
185 {ocean_block}\
186 {brand}\n\
187 \t\tfunction apply(ctx) {{\n\
188 \t\t\tif (!ctx.theme || !ctx.slots) return;\n\
189 \t\t\tvar ocean = OCEAN ? createOcean(OCEAN_PALETTE) : null;\n\
190 \t\t\tvar oceanOn = ocean !== null && !ocean.isOff();\n\
191 \t\t\tvar tokens = oceanOn ? Object.assign({{}}, TOKENS, OCEAN_VEIL) : TOKENS;\n\
192 \t\t\tctx.effect(() => ctx.theme?.overrideTokens(\"{SKIN_SOURCE}\", tokens));\n\
193 \t\t\tctx.slots.inject(\"shell.overlay\", () => ctx.slots.register(\n\
194 \t\t\t\t{{ name: \"shell.overlay\", id: \"codewhale-brand-lockup\", order: 100, label: \"Codewhale\" }},\n\
195 \t\t\t\t() => React.createElement(CodewhaleBrand),\n\
196 \t\t\t));\n\
197 \t\t\tif (oceanOn) {{\n\
198 \t\t\t\tctx.effect(() => {{\n\
199 \t\t\t\t\tocean.setScheme(ctx.theme.getTheme().active.colorScheme);\n\
200 \t\t\t\t\tocean.start();\n\
201 \t\t\t\t\tvar off = ctx.on(\"theme/change\", (snapshot) => ocean.setScheme(snapshot.active.colorScheme));\n\
202 \t\t\t\t\treturn () => {{ off(); ocean.stop(); }};\n\
203 \t\t\t\t}});\n\
204 \t\t\t}}\n\
205 \t\t}}\n\
206 \t\texports.apply = apply;\n\
207 \t\texports.inject = [\"theme\", \"slots\"];\n\
208 \t\treturn module.exports;\n\
209 \t}}\n\
210 }});\n"
211 )
212 }
213
214 /// Trivial Node cordis plugin: `apply` is a no-op so the insert-row entry mounts.
215 pub(crate) fn bundle_index_js() -> String {
216 "function apply() {}\nexport { apply };\n".to_string()
217 }
218
218 lines RUST