| 1 | # DSH bundle skin — palette via the documented theme API |
| 2 | |
| 3 | Status: accepted for v0.9.9 (owner decision 2026-08-16). |
| 4 | Supersedes the 0.9.8 `--skin` CSS export, which is dead on arrival by |
| 5 | construction: `dsh-client-ui-layout` writes alias tokens as inline |
| 6 | `body.style.setProperty(...)` (`lib/client.js:375` in 0.1.0-rc.6), and inline |
| 7 | vars beat any stylesheet rule. The export is removed; the palette now rides |
| 8 | the one mechanism DSH actually supports. |
| 9 | |
| 10 | ## Goal |
| 11 | |
| 12 | `dsh --profile codewhale` (the `install-bundle` profile) renders in the |
| 13 | Codewhale palette — Blue Stage dark and light — with an explicit Whale |
| 14 | Brothers / Codewhale identity lockup, applied through |
| 15 | `ctx.theme.overrideTokens`, the documented token-level override in |
| 16 | `@deepseek-ai/dsh-client-ui-theme`. No build toolchain, no runtime deps, no |
| 17 | injection hacks. |
| 18 | |
| 19 | ## Verified seams (dsh 0.1.0-rc.6, installed at |
| 20 | `/Users/hunterbown/.npm-global/lib/node_modules/@deepseek-ai/dsh`) |
| 21 | |
| 22 | 1. `ThemeService.overrideTokens(source: string, tokens: Record<string, |
| 23 | {light: string, dark: string}>): () => void` — stacks a partial token |
| 24 | layer over the active theme; later layers win per-token; returns a |
| 25 | disposer that removes exactly this layer. |
| 26 | (`dsh-client-ui-theme/lib/types/client/index.d.ts:167`) |
| 27 | 2. Client plugins: `package.json` gains |
| 28 | `dsh.client: { platform: "web", immediately: true, inject: |
| 29 | ["@deepseek-ai/dsh-client-ui-theme"] }` and `exports["./client"]`. |
| 30 | `lib/client.js` is a plain script of the form |
| 31 | `window.__ModuleLoader__.load({ id, factory })` — mirror the wrapper |
| 32 | boilerplate from `dsh-client-ui-theme/lib/client.js` verbatim. |
| 33 | The module must also export `inject = ["theme"]`: cordis 4 exposes a |
| 34 | sibling plugin's service on `ctx` only through the plugin's own `inject` |
| 35 | (reading `ctx.theme` without it throws `cannot get property "theme" |
| 36 | without inject`, which fails the web boot). The package-level |
| 37 | `dsh.client.inject` only orders the boot manifest. |
| 38 | 3. Overlay row insert: `cordis.patch.yml` gains |
| 39 | `- insert: [{ id: codewhale-skin, name: codewhale-dsh-bundle }]` under |
| 40 | the existing root-entry list, appending our entry last (patch rows apply |
| 41 | in order; last wins). |
| 42 | |
| 43 | ## Design |
| 44 | |
| 45 | ### Rust — token table as the single source of truth |
| 46 | |
| 47 | `crates/tui/src/integrations/dsh/skin.rs`: |
| 48 | |
| 49 | - New: `pub(crate) struct SkinTokens` / `pub(crate) fn skin_tokens() -> |
| 50 | BTreeMap<String, (String, String)>` — alias name → (light, dark), both |
| 51 | rendered from the real TUI palette (`crates/palette/src`, Blue Stage |
| 52 | dark + light). Port every mapping from today's `alias_map()` + |
| 53 | `theme_block()`. |
| 54 | - New: `pub(crate) fn bundle_client_js() -> String` — renders the client |
| 55 | half: `__ModuleLoader__.load` wrapper + `factory` whose module applies |
| 56 | `ctx.theme?.overrideTokens("codewhale-dsh-bundle", TOKENS)` inside |
| 57 | `ctx.effect(() => ...)` and returns the disposer, with |
| 58 | `exports.inject = ["theme"]` so cordis defers `apply` until the theme |
| 59 | service exists (plus a belt-and-braces `if (!ctx.theme) return;`). |
| 60 | TOKENS is a JSON |
| 61 | literal rendered from `skin_tokens()`; values are palette constants only — |
| 62 | no secrets, no user data, no environment. Include a |
| 63 | `codewhale-skin/<version>` comment header for diffability. |
| 64 | - Delete: `skin_css()`, `skin_preview_html()`, `SKIN_FILE`, |
| 65 | `SKIN_PREVIEW_FILE`, and their call sites. The `--skin` flag survives with |
| 66 | new semantics (below). Keep `hex()`/`mark_data_uri()` only if still used. |
| 67 | |
| 68 | ### Bundle — dual-face plugin |
| 69 | |
| 70 | `bundle.rs::render_bundle_files`: |
| 71 | |
| 72 | - `package.json` gains `dsh.client` (per seam 2) and |
| 73 | `exports` covering `"."` (`./lib/index.js`), `"./client"` (`./lib/client.js`) and `"./package.json"` — Node exports maps are exhaustive, and both the cordis loader (bare import) and `dsh-client-modules` (`require.resolve("<name>/package.json")`) need their subpath. |
| 74 | - Emits `lib/index.js` (trivial Node cordis plugin: `apply` is a no-op; it |
| 75 | exists so the entry mounts) and `lib/client.js` |
| 76 | (`bundle_client_js()`). |
| 77 | - `cordis.patch.yml` gains the insert row (seam 3) — only when skin enabled. |
| 78 | - `install-bundle [--skin true|false]` (default **true**) and |
| 79 | `update --skin true|false`: `false` regenerates the bundle without the |
| 80 | client half and without the insert row. Decision recorded in the receipt |
| 81 | (`skin: bool`, `skin_sha256`: SHA-256 of the rendered TOKENS JSON). |
| 82 | - Stale detection: a missing/modified `lib/client.js` while the receipt says |
| 83 | skin=true (or present while receipt says false) reports `stale-config`, |
| 84 | same as a drifted patch. `remove-bundle` deletes the client half with the |
| 85 | rest of the Codewhale-owned bundle files. |
| 86 | - `connect --skin` stays accepted, means true, and now only controls the |
| 87 | future bundle (the `--patch` overlay path never carries code — palette is |
| 88 | bundle-profile only; `launch --profile web|headless` stays overlay-only). |
| 89 | |
| 90 | ### Failure handling |
| 91 | |
| 92 | - `theme` service never provided (non-web composition) → the client entry |
| 93 | stays pending on its `inject`; the Node half never throws. |
| 94 | - `overrideTokens` validation errors surface in the browser console with |
| 95 | source id `codewhale-dsh-bundle` (dsh behavior; we do not catch/swallow). |
| 96 | - pnpm missing → unchanged refusal (existing behavior). |
| 97 | |
| 98 | ## Tests |
| 99 | |
| 100 | 1. Token table: every key starts `--dsw-alias-`; every value has non-empty |
| 101 | light AND dark; both schemes differ where the palette differs (at least |
| 102 | bg + label-primary); the rendered TOKENS JSON round-trips through |
| 103 | serde_json back to the table. |
| 104 | 2. `bundle_client_js()` snapshot test (deterministic given version + table); |
| 105 | asserts the source id string and `ctx.effect` disposal shape; asserts no |
| 106 | `skin_css`/`<style` output remains. |
| 107 | 3. Bundle files: with skin on, `package.json` parses and carries |
| 108 | `dsh.client.inject` containing `@deepseek-ai/dsh-client-ui-theme`; |
| 109 | `cordis.patch.yml` ends with the insert row; with skin off, neither |
| 110 | exists. Receipt carries `skin` + `skin_sha256`. |
| 111 | 4. Stale detection unit tests for present/absent/modified client half vs |
| 112 | receipt decision. |
| 113 | 5. Live check (manual, this machine — dsh 0.1.0-rc.6 + pnpm installed): |
| 114 | `codewhale integrations dsh install-bundle`, `launch`, then a browser |
| 115 | screenshot asserting `body` background equals the Codewhale surface color |
| 116 | in BOTH schemes (flip `ui-theme.preference`); `remove-bundle` restores |
| 117 | stock DSH. |
| 118 | |
| 119 | ## Docs |
| 120 | |
| 121 | - `docs/INTEGRATIONS_DSH.md`: rewrite the Skin section — from "unsupported |
| 122 | overlay, never injected" to "applied through the bundle profile via |
| 123 | `overrideTokens`, on by default, `--skin false` disables, disposable and |
| 124 | reversed by `remove-bundle`". Remove the CSS/preview paragraphs. |
| 125 | - CHANGELOG (release branch, not this PR): `Added` (palette via documented |
| 126 | theme API) + `Removed` (dead CSS/preview export, with the inline-vars |
| 127 | reason). |
| 128 | |
| 129 | ## Ocean scene (v0.9.9 addendum, owner request 2026-08-17) |
| 130 | |
| 131 | The palette alone recolors DSH; it does not make it *look* like Codewhale. |
| 132 | `crates/tui/src/integrations/dsh/scene.js` (owned by `scene.rs`, |
| 133 | `include_str!`) is a plain-script fragment that `skin::bundle_client_js(true)` |
| 134 | splices into the client half. It defines `createOcean(palette)`; the client |
| 135 | module calls it inside a second `ctx.effect`, starts it, follows |
| 136 | `theme/change`, and stops/unmounts on dispose. |
| 137 | |
| 138 | Verified seam: `dsh-client-modules` serves only `/plugins/<id>/client.js` |
| 139 | (+ `.map`) per client package (`lib/index.js:321-327`), so a sibling |
| 140 | `lib/scene.js` would never be fetched — hence the splice, and no separate |
| 141 | file in the bundle. `overrideTokens` validates only the `{light, dark}` |
| 142 | shape, not the token name, so `--dsw-specific-sidebar-fill` can ride the same |
| 143 | layer as the alias tokens. |
| 144 | |
| 145 | Design: one near whale (≈0.34 × viewport width, clamped 240–560 px) and one |
| 146 | far, smaller, fainter whale on slow linear crossings (75–110 s) with a |
| 147 | gentle sine in Y, pitch clamped to ±0.12 rad; paths are biased to the lower |
| 148 | half (near) and the top edge (far) so neither crosses the composer card. The |
| 149 | silhouette is a single filled shape (no eye) at ~5:1 length:height: blunt |
| 150 | rounded head, long back with a low soft dorsal hump about two thirds back, |
| 151 | slightly convex belly, thin tail stock, a HORIZONTAL fluke drawn as a wide, |
| 152 | low, notched T seen with a hint of perspective (lobes sweep back, the lower |
| 153 | one a touch longer for the downward curl — never a vertical fish tail), and |
| 154 | one long pectoral flipper (~1/3 body length) sweeping down and back from a |
| 155 | third of the way along the body. The fluke flexes ±10° about the tail stock |
| 156 | (added to the path under a rotation, so no point math); when a whale is in |
| 157 | the top third it occasionally releases a short bubble stream from the head. |
| 158 | A 16-fish school of `><>` / `><o>` glyphs (14 px code font, scaled |
| 159 | 0.85–1.25) follows a lissajous leader with damped steering and facing |
| 160 | hysteresis; 26 stroked bubbles; a two-stop depth gradient. Layer order: |
| 161 | gradient, far whale, bubbles, spouts, fish, near whale. Palette per scheme |
| 162 | is derived from the skin's `surface_bg` / `accent_primary` / `text_body` / |
| 163 | `text_dim`; canvas alphas (near 0.64 light / 0.78 dark, far 0.4 / 0.46) are |
| 164 | deliberately visible through the lighter veil. |
| 165 | |
| 166 | Visibility: `--dsw-alias-bg-base` → rgba α 0.42 and |
| 167 | `--dsw-specific-sidebar-fill` → rgba α 0.78 (`scene::ocean_veil_tokens`), |
| 168 | merged over the opaque `TOKENS` only when the scene is on. The lighter main |
| 169 | veil makes the cast unmistakable while the stronger sidebar layer keeps |
| 170 | navigation distinct. Every other layer stays opaque. |
| 171 | |
| 172 | Budget and guards: rAF capped at ~30 fps, `visibilitychange` pause, |
| 173 | `prefers-reduced-motion: reduce` → one settled static frame, DPR ≤ 2, |
| 174 | typed arrays reused, no per-frame string/array allocation. Off switch: |
| 175 | `localStorage["codewhale.ocean"] = "off"` or body class |
| 176 | `codewhale-ocean-off` (both also skip the veil); `window.__codewhaleOcean` |
| 177 | exposes `start/stop/setIntensity/setScheme`. Config: `update --ocean |
| 178 | true|false` (default on; receipt `ocean`, package.json `codewhale.ocean` + |
| 179 | `codewhale.ocean_scene_sha256`); the client-half byte check makes an ocean |
| 180 | toggle or a drifted scene report `stale-config`. |
| 181 | |
| 182 | Live check (this machine, dsh 0.1.0-rc.6, headless Chromium): canvas |
| 183 | present (`fixed`, `z-index:-1`, `pointer-events:none`), two frames 700 ms |
| 184 | apart differ, console clean, dark via `prefers-color-scheme` follows through |
| 185 | `theme/change`, reduced-motion frame is static. Screenshots: |
| 186 | `docs/design/assets/dsh-ocean-light.png`, `docs/design/assets/dsh-ocean-dark.png`. |
| 187 | |
| 188 | ## Whale Brothers / Codewhale identity (v0.9.9 addendum) |
| 189 | |
| 190 | `brand.js` renders a plugin-owned top-right lockup through DSH's additive |
| 191 | `shell.overlay` slot, with the literal hierarchy |
| 192 | `WHALE BROTHERS` / `CODEWHALE` / `× DEEPSEEK HARNESS`. It is deliberately |
| 193 | additive: no DSH-owned branding, controls, or DOM classes are replaced. The |
| 194 | surface is token-driven and pointer-inert, collapses to a compact whale mark |
| 195 | below 760 px via media CSS, and unmounts with the client plugin. `package.json` |
| 196 | records `brand_sha256` so generated bundle identity covers the lockup as well |
| 197 | as the palette and ocean. |
| 198 | |
| 199 | ## Out of scope (decided) |
| 200 | |
| 201 | Title/favicon, persona text, DSH-owned layout, any non-bundle injection path, |
| 202 | supporting dsh newer than the verified rc.6 (reported |
| 203 | honestly as `stale-version`). |
| 204 |