Ayelsh/dsh-glass-theme

Ayelsh/dsh-glass-theme

Apple Liquid Glass 皮肤:重写 Harness 的整套设计 token,并自带设置页面、带峰谷时段标记的分组模型选择器和合并后的用量面板。

主题与外观dshliquid-glasstheme

dsh-glass-theme

An Apple Liquid Glass skin for the DeepSeek Harness Web GUI. Every surface in the app is redrawn: floating glass chrome over an ambient light field, an opaque reading surface for the transcript, and one material ladder shared by every panel, popover and dialog.

Pure CSS plus a ~150-line client runtime. Nothing in the shipped app is patched; the skin loads as an ordinary Harness bundle and layers its stylesheets after the app's own.

Light · Dark ·

What it does

  • Re-keys the app's whole token vocabulary. The shipped theme publishes an

empty token set, so the palette is CSS-only. src/css/tokens.css re-declares ~250 --dsw-* custom properties on body (and body[data-ds-dark-theme]), which makes every page the skin has never heard of inherit the redesign. It also defines 11 properties the shipped build consumes but never defines.

  • Redraws every surface across 16 sheets: shell, sidebar, conversation,

composer, overlays, model, picker, stats, settings, pages, workspace, widgets, motion.

  • Replaces the composer's model picker. src/picker.js mounts its own React

card into conversation.input.model and hides the host trigger: grouped providers, a one-line description per model, the reseller price ratio as a badge, a live peak/off-peak chip that follows the SELECTED model's provider (DeepSeek: 工作日 9:00–12:00 + 14:00–18:00, ≈2×; 智谱: 工作日 14:00–18:00, ≈2×; providers without a schedule show no chip), and an inline effort segmented control beside the model list (both visible at once; picking a model keeps the card open). Configured providers narrow the default list; everything else stays behind one "所有供应商" row.

  • Merges the usage readouts. src/stats.js replaces the two stat pills and

the context ring with one control that opens a single panel: context pressure and its three buckets, session timings, and the token ledger. The pill sits at the input box's right edge and the panel hangs off that same right edge.

  • Adds its own settings page (settings.section, "Liquid Glass") where the

quick-provider list is chosen. The page has one material object — a frosted board lit from inside by its own light field (the settings sheet is already glass; a second card would be glass on glass) — with a capacity meter, glyph tiles, staggered entrance, and etched grain that answers prefers-reduced-transparency.

  • Adds the 工作空间 workbench (src/workspace.js, sidebar entry lg-workspace): a

whole page answering "what needs me today" across [tapd-ai-cli][tapd] and [wecom-cli][wecom] — my TAPD bugs by severity, stories/tasks, WeCom todos, an attention list (high severity or overdue), WeCom doc search, in-row workflow transitions and owner reassignment, and "start a session here" (pick a directory → create workspace → start session). All CLI execution goes through the package's own fenced host routes (/ws/status, /ws/run, /ws/install: loopback + same-origin fence, subcommand whitelist, spawn without a shell, timeouts and output caps); a missing CLI degrades to clearly-marked demo data with a one-click managed install (npm i -g @wecom/cli, or a checksum-verified GitHub download into ~/.dsh/bin for tapd). See docs/WORKSPACE.md.

  • Answers the accessibility questions: prefers-reduced-transparency,

prefers-contrast, prefers-reduced-motion, hover: none, and keyboard-only focus rings (gated on input modality, so a pointer user never sees one).

  • Styles foreign pages. Third-party plugins render unclassed HTML into the

main slot; those controls get the app's type and control scale too, without reaching into markdown output.

[tapd]: https://github.com/studyzy/tapd-ai-cli [wecom]: https://github.com/WecomTeam/wecom-cli

Install

lib/ is committed, so a clone is ready to load — installing needs no build step.

The releases page carries the same build as a prebuilt tarball, dsh-glass-theme.tgz. Its name is version-free on purpose, so that URL keeps resolving across releases; storefronts serve it instead of a build-from-source command.

  1. Copy the package into your profile's node_modules:
cp -r . ~/.dsh/profiles/desktop/node_modules/dsh-glass-theme
  1. Add it to that profile's bundle list in /package.json:
{ "dsh": { "profile": { "bundles": ["dsh-glass-theme"] } } }
  1. Restart the Harness.

The restart is not optional: the plugin roster is composed from the profile before the window opens, and the host half (routes, peak-rate cache, usage ledger) is mounted at boot. Nothing in the shipped app is modified — removing the folder and the bundle entry restores the stock interface exactly.

Layout

src/css/               the 16 stylesheets, in cascade order
src/client.js          mounts the sheets, tracks modality, wires the layers
src/catalog.js         the model catalogue: providers, families, descriptions, pricing
src/picker.js          the ported model picker (React, mounted into the model slot)
src/stats.js           the merged context/usage panel
src/settings-section.js the skin's settings page
src/workspace.js       the 工作空间 workbench page (tapd + wecom-cli)
src/host/              the host half: routes, peak-rate catalogue, usage ledger, workspace CLI executor
lib/client.js          build output (generated)
lib/host/              build output of src/host (generated)
vendor/                the reference package's data files and assets (MIT, see LICENSE-reference.txt)
tools/                 build + verification tooling
docs/                  feature documents (WORKSPACE.md)

Build and verify

node tools/verify.mjs       # every selector must resolve against the shipped build
node tools/leak.mjs         # [class*=] must not over-match sibling locals
node tools/build.mjs        # inlines src/css/*.css into lib/client.js, copies the host half
node tools/smoke.mjs        # evaluates the bundle against a stubbed DOM
node tools/assert-built.mjs # the authored rules actually survived into lib/client.js
node tools/host-check.mjs   # the host half registers its routes without throwing
node tools/ws-check.mjs     # the workspace CLI routes: fence, whitelist, probe, caps (mock server)
node tools/ws-render.mjs    # source render smoke test, including explicit open-case handlers
node tools/ws-rich-check.mjs # the detail panel's rich description: sanitizer, markdown, show unwrap
node tools/ws-package-check.mjs dsh-glass-theme-0.2.3.tgz # published bundle regression

Live probes (each needs the GUI running; they mint their own auth cookie):

node tools/two-level.mjs                # the picker's three states: unconfigured, configured, the second card
node tools/settings-page.mjs            # the skin's settings page renders, lists providers, and its switches write
node tools/pick-check.mjs               # one row checked, across providers that share a model id
node tools/effort3.mjs                  # the effort segment applies a level
node tools/picker-rows.mjs              # fail if any row lacks a description or its ratio badge is block-level
node tools/chk-chip.mjs                 # the peak/off-peak chip's state and countdown
node tools/meter.mjs --shot G:/tmp/m    # the context meter panel
node tools/stats-dom.mjs --theme light  # the two stat dialogs
node tools/rail.mjs --scan              # the turn rail's hover previews
node tools/wsselect-probe.mjs           # the native <select> computes an opaque background (popup rows stay readable)

verify.mjs is the important one. A skin is nothing but selectors, so a wrong hash prefix or a renamed local is a silently dead rule — no error, no warning, just a surface that keeps its old look. The tool resolves every token against research/shipped-class-map.json (extracted from the installed app) and fails the build on a miss.

The inventories verify.mjs resolves against

The app generates its CSS-module class names (_local_hash), so the only honest way to check a selector is against the classes the *installed* build emits. Those maps are generated, not committed — they contain the app's own stylesheet text, which is not ours to redistribute. One step each:

node tools/extract-inventories.mjs --asar "<install>/resources/app.asar"      # shipped classes + attributes
node tools/foreign-inventory.mjs --root ~/.dsh/profiles/desktop/node_modules  # installed plugin classes

Both write into ../research/ — one level above the package, so an installed copy never carries them. extract-inventories.mjs auto-detects a local install when --asar is omitted.

assert-built.mjs exists because the bundle escapes its own CSS: each sheet is embedded as a JSON string, so a newline is the two characters ` n`. A regex written against the source file misses in the bundle for that reason alone — which looks exactly like a lost rule. The tool unescapes once and then asserts both the rules that must exist and the ones that must not.

Design rules the sheets follow

  1. A page is a reading surface; a control is glass. The transcript column is

opaque on purpose — text that scrolls must never sit on a moving blur. It picks up the room's light direction through a very low-alpha gradient instead.

  1. One ladder. Menus are L2, sheets and dialogs are L3, toasts float. Nothing

stacks more than two backdrop layers, and nothing is glass on glass.

  1. Large glass gets more fill and less saturation. A 640px dock at 180%

saturation turns the ambient field's chroma into a purple wash; the same material at 140% reads as glass.

  1. A floating layer earns its float from its own rim. Over an opaque surface

there is nothing to blur, so the 0.5px lit top edge and hairline do the work.

  1. Know what to leave alone. The app's scrollbars, delete-button styling and

inline-styled third-party controls are already correct; the skin does not restyle them.

  1. Never style a control by its ARIA role alone. The role lands on whatever

element receives the click, not on the painted shape. role="switch" sits on an 88×20 toolbar button in the shipped app and on a 530×70 full-width row in dsh-zhipu-plan; a generic rule painted those rows as accent capsules that swallowed their own labels. Style a control when its own class says what it is, and let a plugin's own design stand when it does not.

  1. A list row is a member of its group, not a card in it. When a section

header already says "these belong together", a plate behind every row redraws the group once per row — it is the same statement repeated N times, and it eats the row's own horizontal room. Reserve plates for lists of independent cards (schedules, goals); for a grouped list use the header, the whitespace and one state signal per row.

  1. A state signal goes on the thing that changes state. The running job row

is told apart by a tinted command and the primitive's spinner, not by a background — a fill would make "running" a surface, which it is not, and would leave nothing louder for the row that is actually selected.

  1. Inline styles win, so measure before believing. The shipped StateDot

sets width/height inline and renders an SVG spinner for ongoing but a ::after dot for every other state. A skin rule cannot resize it, and a fixture that hand-writes the markup will happily "prove" a bug that the app does not have. Copy the real markup into the fixture, then measure.

  1. A class can name a modifier, not an element. xpvNua_fadeTop is not an

overlay drawn over the turn rail's ends — it is added to the same div that carries xpvNua_scroller, so it IS the scroll container. Styling it as if it were a separate overlay (pointer-events: none) disabled every mark inside the rail: it still scrolled, still looked right, and could not be clicked or previewed. Read the markup before styling a name.

  1. A shared primitive can reach across sheets. [role='tooltip'] is the

right hook for tooltips and also matches the turn rail's hover preview, which is a role="tooltip" reading card that brings its own material. The generic rule cropped it to a 9px radius and a 4px inset. Scope a primitive with :not([class~='…']) — and use ~= there, since a *= guard over-matches the card's own child classes.

  1. Inject beside a flex column and it becomes a block. OptionCopy is

flex-direction: column, so appending the multiplier badge as a sibling of the model name stretched it to the full row width — a 300px grey bar under every name. The badge had to go INSIDE the name span. When a decorator adds nodes to someone else's React tree, the parent's layout mode decides what the node becomes; put it where its shape is already correct.

  1. A wrapping flex row is not a grid. Six provider chips broke 5+1 across

two lines, which reads as a mistake and costs the same height a balanced 3+3 does. grid-template-columns: repeat(3, minmax(0, 1fr)) gives equal cells, so the row scans as one control and every chip is the same target size.

  1. A number needs a reason, not just a state. 空闲 at 10:00 on a Wednesday

and 空闲 on 国庆 are different facts, and a weekday-only rule is wrong on the ~dozen days a year the reader is most likely to check. The price chip carries data-lg-reason (法定节假日 / 周末 / absent) so a holiday reads as a holiday rather than as a clock that stopped. A year with no holiday table falls back to the weekday rule instead of claiming accuracy it does not have.

  1. A card that renders is not a card that works. A portal mounts outside the

React tree, a handler can be lost in a port, and select() can reject while the trigger keeps showing the old model — all invisible in a screenshot. tools/picker-select.mjs drives a real mouse, asserts the label changed, and then puts the model back, because a probe that leaves the user on a different model has done damage, not measurement. It reads a row's name from its own text nodes: the name span also carries the multiplier badge, so an exact match against the trigger label would never hit.

  1. A host half is untested until it has been applied. apply() only runs at

GUI start, and a throw there can take the whole app down — but the GUI under test is the one serving the conversation, so "restart and see" is not a test. tools/host-check.mjs runs the real apply(ctx) against a recording stub and asserts the routes it registers; it is what turns a launch-time crash into a line of output.

  1. A service must be declared before it can be used. ctx.slots is only an

accessor when the bundle exports it in inject; without inject: ['slots'] the call throws inside a try, the page never appears, and nothing is logged where a user would look. A slot's own inject is an INJECTOR FUNCTION, not a list — an array registers a section that renders nothing and reports nothing.

  1. An entry at the end of a scroll is an entry most users never see. The

effort row and the "所有供应商" door were rows inside the scrolling body, so they sat ~1300px below the viewport once a 28-model provider was listed. They moved into a footer outside the scroll area: a control is not the last row of a table.