# dsh-desktop-acrylic [中文](README.md) · **English** Tokyo Night themes for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Desktop — two themes (dark and light), frosted panels, and a monospace font picker. Both skins are **real themes registered into the built-in theme runtime** (`ctx.theme.register`), so switching a theme switches the runtime preference itself — no attribute toggles, no after-the-fact CSS overrides. > **DSH Desktop (beta) download**: [deepseek-harness discussions](https://github.com/deepseek-ai/deepseek-harness/discussions/7667?sort=new#discussioncomment-18598677). > If you use this project in a paper, report or another project, please cite it — templates are in > [Citation and license](#citation-and-license), and a machine-readable [`CITATION.cff`](CITATION.cff) > is included (GitHub renders it as "Cite this repository"). ## Screenshots **Dark** — welcome page: ![Tokyo Night dark theme (welcome page)](snapdark.png) **Light** — welcome page: ![Tokyo Night light theme (welcome page)](snaplight.png) **Settings window** — frosted panel over a blurred backdrop (dark; the "Tokyo Night theme" settings row and the `masks 1` diagnostic are visible): ![Settings window: frosted panel with a blurred backdrop mask](setting.png) --- ## Features | Feature | How it works | Values | | --- | --- | --- | | Themes: Tokyo Night dark / light | Skin tokens (static ramp + alias ramp + component + syntax), 180 tokens each | dark base `#1a1b26`; light base near-white (`#e7e8ee` → `#eff1f6` family) | | Icons follow the theme | The shell paints icons with `currentColor` and ships no icon-specific tokens, so icons follow `--dsw-alias-label-*` / `--dsw-alias-brand-primary` | nothing to configure | | Canvas transparency | Background-family tokens carry alpha | dark `0.10` (90% transparent); light `0.30` | | Material base | A soft blue/purple/cyan field built from the palette, pinned to the bottom layer and pre-blurred | strength dark 30% / light 24%, `blur(28px)` | | Menus, popovers, floating surfaces | The skin ships `--dsw-menu-backdrop-filter`, applied by the shell's own material path | `blur(24px) saturate(1.15)` | | Dialogs (the settings window) | Frosted panel: positioned dialogs use `isolation` + an absolutely positioned `::before`; **static** ones (common for a settings window) get the blur on the element itself, after excluding dialogs that contain `position: fixed` descendants | fill `rgba(…, 0.75)` (25% transparent) + `blur(30px)` | | Dialog backdrop mask | The dim overlay behind an open modal gets a `backdrop-filter`, so the interface shows through frosted; native `` elements get `::backdrop`; disabled together with the blur layer under `prefers-reduced-transparency` | `blur(12px) saturate(1.1)` (`--dwa-mask-blur`) | | Workspace sidebar | Uniform grain (pure `background-image`, no direction, no edges) | dark 6% / light 14% (perception is asymmetric, so the values differ) | | Cards and tables (genui) | The card library's own `--tv-*` palette is bridged to our alias tokens | 22 bridges: surfaces, interaction, text, icons, borders | | Native widgets | `color-scheme` follows the theme | scrollbars, checkboxes and friends follow along | | Fonts | Two searchable pickers (UI font / code font) listing **monospace families installed on this machine**, patched variants included | see "Font detection" below | Everything is declared in `src/theme.mjs`: `ACRYLIC` (blur radii), `surfaceTable` (surfaces and alphas), `MONO_FONTS` / `MONO_FONT_BASES` / `MONO_FONT_SUFFIXES` (font data). The stylesheets are generated by the build script. --- ## Install ```sh # from GitHub dsh plugin --profile desktop add github:LeoLee0097/dsh-desktop-acrylic # from a local directory (-w is required: the profile directory is a pnpm workspace root) dsh plugin --profile desktop add -w /absolute/path/to/dsh-desktop-acrylic ``` ### Version compatibility | Plugin version | Supported DSH | Notes | | --- | --- | --- | | **0.9.0+** | **0.2.0-rc.1 and later** | Peer dependencies aligned to `@deepseek-ai/dsh-client-* @ ^0.2.0-rc.1` and `@deepseek-ai/cordis @ ^4.0.4`; the retired `@deepseek-ai/dsh-client-runtime` is **dropped** in favour of the packages that actually provide the services used (`dsh-client-store`, `dsh-client-locale`, `dsh-client-ui-theme`, `dsh-client-ui-settings-general`) | | **0.9.2+** | **verified on 0.2.0-rc.2** | Measured against DSH 0.2.0-rc.2: every peer still satisfies (^0.2.0-rc.1 covers prereleases of the same 0.2.0 tuple, rc.2 included). Note that **exact-version exemptions are keyed by DSH version**, so a DSH upgrade invalidates them all and compatibility.json has to be regenerated for the new version | | 0.8.x and earlier | the 0.1.0-rc.6 line | Still declares `dsh-client-runtime ^0.1.0-rc.6`, which the plugin manager rejects on DSH 0.2.0-rc.1 | Upgrading from 0.8.x to 0.9.0: pull the new version and restart DSH Desktop so the plugin manager re-reads `peerDependencies`. If it still reports an incompatibility, re-run the install command above to refresh the profile's dependency record. **No client API changed in this release** — `__ModuleLoader__`, `ctx.slots.inject/register`, `ctx.theme.register`, `ctx.on('theme/change')` and `webServer.register({ kind: 'exact' })` are all unchanged in 0.2.0-rc.1 (each one was verified against the shipped bundle). ### Web (web profile) > **Disclaimer**: the developer uses this plugin primarily with the DSH Desktop beta and **has not > tested it on the web build**. If something misbehaves, include the settings-row diagnostics > (`preference` / `matched` / `masks` and friends) when reporting it. The same plugin installs into the web profile as-is — the desktop app already runs on top of `@deepseek-ai/dsh-web-app`, and the browser half is declared with `platform: "web"`. Only the profile name changes: ```sh dsh plugin --profile web add github:LeoLee0097/dsh-desktop-acrylic # or a local directory dsh plugin --profile web add -w /absolute/path/to/dsh-desktop-acrylic ``` How the web build differs (the client degrades automatically; nothing to configure): - **Durable state**: falls back to `localStorage` when the host route is absent — on the web that origin is stable, so this layer is actually more reliable than on desktop; - **Font list**: the host directory scan is unavailable, leaving renderer-side probing only (candidate catalog under "Font detection" below); - **Same visuals**: the material base, frosted panels and the mask blur are all in-page work and never relied on a window material; if a web build renames the DOM classes, the candidate selectors simply match nothing — the `matched` / `masks` diagnostics report 0 honestly. Quit DSH Desktop **completely** and start it again. After that, reloading the renderer alone is enough to pick up a new client bundle. > Note: **changes to the host half (`lib/index.js`) only take effect after the process restarts**, > because its route table is registered at startup. The client half (`lib/client.js`) and the data > tables only need a renderer reload. This distinction once caused a "I changed it but it 404s" > misdiagnosis, which is why it is spelled out here. ## Usage **Settings → General → "Tokyo Night theme"**: - **Theme**: dark / light / default — applies immediately; - **Acrylic**: blur on / blur off — turning it off removes the blur but keeps the theme; - **UI font / code font**: one searchable picker each, applied on selection, with "restore default"; - **Diagnostics line**: `preference · computed bg-base · chrome on/off · hosts armed/candidates · frost n · panels n`. `bg-base` is read back with `getComputedStyle(document.body)`, i.e. the value the browser actually resolved — that is how you tell "the theme landed in the cascade" from "it just looks unchanged". ### Font detection The browser is **not allowed** to enumerate installed fonts, so the list is built from two sources: 1. **Renderer probing** (always available): candidate names come from `MONO_FONT_BASES` × `MONO_FONT_SUFFIXES` (42 bases × 6 suffixes = 252 probes; suffixes are `NF`, `NFM`, `Nerd Font`, `Nerd Font Mono`, `PL`). A candidate counts as present when its text metrics differ from all three fallbacks under a canvas measurement. 2. **Host directory scan** (when the route is available): reads the font directories and parses each file's `name` table (family) and `post` table (`isFixedPitch`, the format's own monospace flag), so families the catalog never guessed — CJK monospace among them — are found too. The union of both is used. The `fontSource` diagnostic reports `probe` / `host` / `host+probe`, and `fontCount` is the number of entries. ```sh node scripts/check-fonts.mjs # print what the host scan found (monospace list) node scripts/check-fonts.mjs --all # also print every family ``` ## Project layout ``` dsh-desktop-acrylic/ ├── src/ │ ├── theme.mjs # palettes, both skins, panel/texture/font/bridge CSS (hand-written source of truth) │ └── client.tpl.js # browser half template (hand-written) ├── scripts/ │ ├── build.mjs # build + self-checks → produces the two entries below │ └── check-fonts.mjs # debugging tool for the local font scan ├── lib/ │ ├── index.js # host half: state route + local font route │ └── client.js # browser half (GENERATED, do not edit by hand) ├── themes/ │ ├── tokyo-night.json │ └── tokyo-night-day.json ├── docs/desktop-window-material.md ├── cordis.patch.yml # profile patch layer: inserts one loader entry ├── package.json # declares dsh.bundle.patch + dsh.client.inject └── LICENSE / README.md / README.en.md / CITATION.cff / .gitignore / .gitattributes / snapdark.png / snaplight.png / setting.png ``` ```sh node scripts/build.mjs # or npm run build ``` The build refuses to write anything unless four groups of checks pass: 1. **Placeholders** are all substituted, with none left over; 2. **Skins** agree on count / id / colour scheme, default skin and locale dictionaries, and the font data tables are non-empty; 3. **Token values** match a conservative grammar (`#hex`, `rgb(a)`, `color-mix`, `blur(..) saturate(..)`, `transparent`, `px`, or a `var(--dsw-…)` pointing at one of our own alias tokens) and contain no braces or semicolons; 4. **CSS safety**: blur only ever lives on a `::before` layer, frosted panels must carry an opaque fill token, and no rule gives a layout container a filter or a position. ## Diagnostics The plugin reports its live state to a host route, which writes `~/.dsh/dark-acrylic-state.json`: ```sh curl http://127.0.0.1:/dark-acrylic/state # state and live report curl http://127.0.0.1:/dark-acrylic/fonts # installed font catalog (host scan) curl -X PUT http://127.0.0.1:/dark-acrylic/preference \ -H 'content-type: application/json' \ -d '{"preference": "tokyo-night"}' # persist the theme preference into the profile patch layer curl http://127.0.0.1:/dark-acrylic/preference # read back the ui-theme preference from every profile patch layer ``` ```json { "version": 2, "plugin": "tokyo-night-theme", "themes": ["tokyo-night", "tokyo-night-day"], "chosen": "tokyo-night", "acrylic": true, "font": "JetBrainsMono Nerd Font", "codeFont": "", "preference": "tokyo-night", "chrome": true, "hosts": 0, "hostStats": { "matched": 23, "armed": 0, "skipped": 23 }, "frost": 1, "panels": 1, "masks": 1, "heals": 0, "layer": "written", "fontSource": "probe", "fontCount": 18, "panelDebug": ["div.ZTP_x 900x600 → flat"], "maskDebug": ["div.aGz_z → mask"], "computed": { "bgBase": "rgba(22, 22, 30, 0.1)", "sidebar": "rgba(15, 16, 23, 0.1)", "menuBlur": "blur(24px) saturate(1.15)" }, "instance": "…", "at": "…" } ``` `heals` counts how often the silent-reset detection had to re-assert the theme (0 in a healthy shell; a persistently rising value means the shell keeps resetting the preference); `layer` reports the last preference-layer write outcome (`written` / `unchanged` / `error` / `unavailable`). `panels` counts the dialogs currently carrying the frosted panel, and `panelDebug` states *why* each dialog was armed or skipped — `div.… 900x600 → flat`, `→ positioned`, `→ skip(static, anchored=3)` or `→ skip(small)`. `anchored` is the total of `position: fixed/absolute` descendants: a static dialog is nobody's containing block, so once the filter lands on the element itself those descendants re-anchor into the box and get clipped (a toolbar showing only its top half) — static dialogs with anchored descendants are therefore never frosted flat. When a dialog has no effect, that line is the first thing to read. `masks` counts how many backdrop masks behind open dialogs were frosted, and `maskDebug` records the outcome — `div.… → mask` (hit) or `div.… → no-mask` (no viewport-sized fixed/absolute parent or sibling matched; a native `` still gets its `::backdrop` rule in that case). When the background behind a dialog is not frosted, that line is the first thing to read. `hosts` and `hostStats` explain "why is there no frost here": `matched` is the number of candidate containers, `armed` how many actually received a blur layer. Layout columns are usually static elements, so being rejected by the guard is the expected outcome, not a failure. ## Pitfalls (each one is enforced by the build or documented in the source) 1. **Never put `backdrop-filter` on a layout container.** It turns the element into a containing block for `position: fixed` descendants, and the shell pins its sidebar toggle and new-session button exactly that way — the controls re-anchor and disappear. 2. **Never place an absolutely positioned blur layer inside a static container.** The pseudo-element escapes to the nearest positioned ancestor, covers the viewport and blurs the entire interface, text included — this was the cause of the early "text does not render" regressions. The blur layer is therefore only mounted on containers that are *already* positioned. 3. **Font variables must not reference themselves.** `--dsw-font-family: X, var(--dsw-font-family)` is a CSS cycle: the variable and every `font:` shorthand reading it become invalid. 4. **The settings slot hands a row exactly three things**: `t` (from `locale`), `useStore` (from `store`) and the return value of `inject(actions)`. There is no ambient theme service on props. 5. **"Transparent" and "frosted" are different things.** Alpha gives transparency; frost is convolution of what is behind. There must be *something* behind to convolve, which is what the material base layer is for. 6. **Native widgets ignore page styling and scroll unreliably.** A `