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