# Changelog

All notable changes to [`dsh-widgets`](https://github.com/Physicolor/dsh-widgets), newest first. A feature release is a minor bump, a fix-only release a patch bump; a version that was bumped but never published is not listed on its own — its changes are folded into the release that actually shipped them.

Where each kind of detail lives:

| Layer | What it answers | Location |
| --- | --- | --- |
| README | what the plugin is, how to install it, the current release at a glance | [`README.md`](README.md) · [`README.zh-CN.md`](README.zh-CN.md) |
| This file | every release, entry by entry, with the measurement behind each change | [`CHANGELOG.md`](CHANGELOG.md) · [`CHANGELOG.zh-CN.md`](CHANGELOG.zh-CN.md) |
| GitHub Releases | the same entry as release notes, anchored to the commit (tag) that shipped it | repository → Releases |
| `docs/` | the raw evidence: CDP probe scripts, screenshots, JSON receipts, per-incident fix records | [`docs/`](docs/) |

Every entry is written in the language of the file it lives in: English here, Chinese in `CHANGELOG.zh-CN.md`. A term that names a piece of the UI is quoted the way the UI itself says it in that language.

## v1.8.5 — five more cards on the clock, a data-source contract, and a rail that reads its co-tenant

> The biggest content drop since the card library was opened: **six new units** — a clock, a calendar tied to DSH's own schedule service, a task timer, a stopwatch, a countdown and the DeepSeek official balance card — take the registry from 55 to **61**, and the data behind every card moved onto a declared contract (a unit names its sources in `manifest.json`; one generic poller answers for all of them). Underneath sit four defects the owner hit in the same fortnight: **the hover oracle was a whole row off on a rail a co-tenant had pushed down** (25 of 45 checks failed), **the session-header anchor froze whenever the shell swapped its node**, **the whole-card rule and the wheel cap were laid out blind** because `paneH` had never been measured at all, and **nvidia-smi's MiB were passed through as bytes**. Two build-facing additions come with them: an **offline gate suite** (`node scripts/gates.mjs`, G1–G12) wired into a new CI workflow, and the **card title** / **body-posture** standards the two review incidents below produced. Published as a **patch** at the owner's call, as v1.8.1 was: the content is feature-grade, the version number is not the point.

### Added — the time family: clock, calendar, task timer, stopwatch, countdown

- **Five units at orders 31–35**, each built through the production workflow and archived per unit in `docs/workflow/records/2026-10-14-*.json` (all four records sit at `INTEGRATION`, with 2–4 REWORK rounds apiece):
  - `clock` (2×2) — analog or digital dial, six zones, a second hand that actually ticks;
  - `calendar` (2×2 / 2×4) — this month, today badged, weekends or statutory holidays tinted, and **days carrying a DSH scheduled task dotted**; the 2×4 lists the upcoming ones. It declares `reads: ["schedule"]` and falls back to a plain month grid on a deployment without the schedule subsystem;
  - `task-timer` (2×2 / 2×4) — how long this turn has run and what it is waiting on right now (a tool call or a model step), read from projections the session already has, advancing every second while a turn is open;
  - `stopwatch` (2×2) — start / pause / resume / reset, the reading advancing by itself, the state stored in the instance config so a reload or another browser keeps it;
  - `countdown` (2×2) — duration capsules (1 / 3 / 5 / 10 minutes, one of them customisable) **or** the next DSH scheduled task.
- **The REWORK rounds are the interesting half** (they are what the two standards below were written from): the countdown shipped first without `reads: ["schedule"]` and with a dead `done` field; the calendar drew its agenda in the wrong direction and truncated its `when` column until the column was given 64px; the stopwatch's first face was rebuilt as `HH:MM:SS.cc` with circular controls; and the task timer called the tool counter “calls” until it was renamed `tools`, with the 2×2's sub-line moved onto the parallel-call count.
- **Shared machinery, not five copies**: `src/client/lib/clock-face.ts` + `timer-state.ts`, the rich renderers `src/client/render/rich/{clock-face,month-grid,timer-face}.tsx`, one `CardBody` rich block and the rail's timer dispatch. Adding the five needed nothing outside those files plus the five unit directories.
- **What the units found upstream** is written down instead of worked around (`docs/research/TIME-CARDS-INTEGRATION.md`, against 0.2.0-rc.2): `schedule.create` has no `@Remote` face, so a card cannot create a reminder (it can list / catalog / history / update / delete); `todos` carry no id and no date and are cleared on `turn/start`; `jobsBySession` does not exist — the real API is `ctx.jobs` — so the jobs card's per-session figure is **always null** in this deployment; and the renderer's own tick is the only place a “moving” reading can live.

### Added — DeepSeek balance, and the host route it reads

- **`deepseek-balance`** (2×2, order 68, group `deepseek`, `source: deepseek`) shows the official account balance with the ledger-derived day's spend beside it.
- It is the first unit to need a **host route of its own**: `src/host/deepseek.ts` publishes `/api/deepseek-balance` (ten fields — `available` / `currency` / `day` / `granted` / `hasBalance` / `sampledAt` / `spentToday` / `toppedUp` / `total`), and the route table, the client contract and the bridge snapshot (`bridge.deepseekBalance` plus its error sibling) were extended with it.
- The derived day is folded **offline, against the built bundle**: `scripts/verify-deepseek-balance.mjs` runs 17 assertions over the ledger fold and every error code the route can answer with (gate **G12**).

### Added — the v1.11 data-source contract, and one poller for all of them

- A unit no longer reaches for data; it **declares** it (`reads: ["schedule"]`, `source: "deepseek"`, a skeleton) and the client answers. `src/client/data/sources.ts` + `source-contract.ts` + `source-poller.tsx` implement that, the six families moved their fetch/parse halves into `src/client/families/<family>/adapter.ts`, and `scripts/gen-registry.mjs` now fails the build when a declaration drifts from the unit (`node scripts/verify-source-contract.mjs --prove`, gate **G9**).
- **G11** (`scripts/verify-source-resolution.mjs`) answers the next question down — for a given install, *which* sources are polled, *which* instances feed them and at what cadence — as pure rules, executed.
- Deliberately no new host routes here: the families keep the endpoints they already had, so this is a client-side re-plumbing and `docs/architecture/baseline/host-routes.json` is unchanged for it.

### Fixed — the hover oracle and the wave read the co-tenant's push (2026-10-08)

- **`dsh-pinned-summary` seats its band above the deck by writing `padding-top` on the rail** (an `!important` inline longhand — measured on the owner's GUI: `135px = 4 + 131`). The rail's `clientHeight` does not change, so the deck simply starts 131px lower inside it — and **both halves of the pointer→deck mapping subtracted the constant 4px**: every pointer position resolved exactly one row away (pitch 140), so the wave armed on the tile *below* the pointer (hovered #0 → focused #3, #1 → #4) and wherever the shifted point landed in a gap it released a hover that had just engaged — the owner's 「组件区域偶发：鼠标悬浮后组件进入悬浮态又立刻停止」.
- The inset is now **measured off the rail** (`readTopInset`; `padding-top` is read in the same forced-layout frame the oracle already pays for) and the oracle, the peak and the whole-card test all read it. `scripts/verify-rail-interaction.cjs`: **45 checks, 25 FAIL → 45/45 PASS**.
- The whole-card band and the wheel cap were **deliberately left on the authored 4px in that round** — correcting them alone hid the deck's last card for good, because the cap was built on the same constant and concluded “no scrolling needed”. That debt is paid below.

### Fixed — the session-header anchor stopped freezing (2026-10-09)

- The rail's top anchor (`--dsx-rail-top`) is re-probed by a `ResizeObserver` on the session header — except the slot it observed is **`display: contents`, so its box height is always 0 and the observer never fired**. The anchor kept a stale value and the deck's first row sat ~20px under the header. It now observes the real `[class$="_header"]` element (measured 50px) and re-binds when the shell swaps the node. `scripts/verify-rail-hover-alignment.cjs`: **47 checks, all PASS** — first row 50 against header bottom 50, across the plugin boundary. Not reproduced: a session header that *grows*, recorded as a limitation of that probe.

### Fixed — the whole-card band and the wheel cap follow the same measured inset (2026-10-14)

- On a pushed rail the band's floor sat a whole **push** below the rail's real one, so a card sliced by the bottom edge — up to a row's worth of it — was judged whole and painted half off screen (the owner's screenshot, bottom row). `topInset` now feeds `bandOf` / `syncWholeCards` / `overlayBand` **and** the cap (`lastRow = ⌈(contentBottom + inset − RAIL_ROW_SEAT − paneH) / pitch⌉`), so paint, hit test and reachability agree by construction: a card the band hides is exactly one the wheel can still scroll to. With no co-tenant the inset is the authored 4px, so the unpushed geometry is unchanged.
- **The push is tweened** (~260ms of per-frame inline writes) and moves the deck without changing any size the existing observers can see, so the rail's `style` attribute is watched instead (rAF-coalesced, `Object.is` bail-out when the value did not move). The generic hook was chosen over one co-tenant's private variable name.
- **The deeper defect behind it: `paneH` had never been measured.** The `ResizeObserver` was attached in a one-shot `setTimeout(…, 0)` that read a null ref and then **gave up for good**; read off the fiber tree on the owner's GUI, with a 956px-tall rail on screen, RailWave's props were `{ paneH: 0, topInset: 4 }` — the whole-card test was skipped (`paneH <= 0`), the scroll cap was 0 and the wheel could not move the deck at all. The element is now resolved self-healingly (query, then write the element back into the shared ref, exactly as RailWave's own `railElement()` already did) and a body-level `childList` observer re-attaches when the drawer arrives a commit late or is re-created.
- Verified live (`scripts/verify-whole-cards-co-tenant.cjs`, **9/9**, headful against the owner's GUI with `dsh-pinned-summary` actually docked): the real push measured **79px**, and the authored-4 band had been painting one card **19px past the rail's floor**; with the fix every card is hidden exactly while it sticks out; after wheeling to the stop, `scrollTop 152` puts the deepest component whole inside the pane (`bottom 823 ≤ 956`) and further notches move nothing; and a synthetic 131px push reproduces the same three properties on demand.

### Changed — the 设置 tile opens the product's own settings window

- The bottom gear used to open the plugin's own floating panel. It now opens **Settings → Widgets** — the app's one place to install, order and size cards — by performing the two clicks a user would: the sidebar-foot Settings trigger, then our own nav row, located through the same `[class$="_settingsArea"]` / `[class$="_navList"]` / `data-dsx-nav` conventions `settings-nav-glyph.ts` already relies on, waiting for the row when the window has to open first.
- The plugin's panel (Config / Market / Settings tabs, drag-resizable) is **kept in the code as a local spare**, re-armed only by `localStorage['dsx-addpanel-local'] = '1'`; it is no longer on the shipped path. The live probe asserts both directions: the click opens the official window on our section and *not* the panel, and with the flag set the panel still opens.
- The same probe had to insist the rail be **open** for those checks to mean anything: a retired drawer hands React back the same element object by design, which freezes its whole subtree — so the probe asserts `data-retired` / `aria-pressed` before it measures.

### Changed — the two card standards the new units were reviewed against

- **`docs/design/CARD-BODY-POSTURE.md`** (new): a 2×4 whose body is a single line must sit on the **floor**. `task-timer@2x4` was drawn head-aligned and left a **51.6px** empty band (threshold `36 × scale`) — it passed every gate and one review, which is exactly why the requirement is now a machine assertion (`POSTURE` / `INNER-FIT` / `PHASE3` in `scripts/probe-geometry.cjs`). After the change the largest gap across the 24 shipped 2×4 cards is **26px** (`sys-disk@2x4`), and the stopwatch's fit — which overflowed by **3.6–11.7px** at 150 / 190 / 285 — is computed from the content width (`width − 2·pad − 2·inset`) and green at all three tiers.
- **`docs/design/CARD-TITLE-STANDARD.md`** (new): a card may drop its title only when **C1** (the body says what it is) and **C2** (the body fills the card) hold together — `clock`, `calendar` and `quote` qualify. The clock's dial grew **96 → 120px** so that `2·pad + box ≤ unit` (12 + 120 + 12 = 144 ≤ 150) still holds with the title gone, and the visual audit now scores **58 units**.
- `docs/workflow/03-specification.md` gained both fields as **required** spec inputs (`referenceCard`, `bodyPosture`), with the incident recorded as the reason: a missing one is a REWORK, not a judgement call.

### Added — the offline gate suite, and CI that runs it

- `scripts/gates.mjs`: **G1** registry · **G2** units · **G3** types (against a recorded baseline) · **G4** render (byte-identical snapshots, zh + en) · **G5** css (the compiled stylesheet against the baseline — needs a build first) · **G7** published types · **G8** host routes · **G9** source contract (`--prove`) · **G10** external-unit review · **G11** source resolution · **G12** balance fold. Exposed as `pnpm test` / `pnpm test:list`.
- **`.github/workflows/ci.yml`** runs `pnpm run build` and then `pnpm test` on every push to `main` and every pull request, mirroring the local procedure exactly (G5 reads the compiled bundle), so “green here” and “green locally” mean the same thing. Until this release the only workflows were `pages` and `publish`, i.e. a pull request could merge with a stale registry, a drifted render or a new type error.
- Baselines refreshed with it — `docs/architecture/baseline/*.json` — and the card gallery re-shot (`docs/preview/cards/*.png`).

### Fixed — nvidia-smi's MiB are not bytes

- `src/host/sysinfo.ts` passed the driver's memory figures straight through, so the GPU card's “5.9 GB” was 5.9 MiB of the unit nvidia-smi actually reports. Converted at the boundary.

### Changed — the 0.2 line installs without an exemption

- `peerDependencies` widened to `^0.1.0-rc.6 || ^0.2.0-rc.1` for both `@deepseek-ai/dsh-client-ui-primitives` and `…-ui-slots`, so the 0.2 release candidates load without a `compatibility.json` exemption in the profile (the v1.8.1 note that asked for this is closed).

### Verified — what ran, and what is not claimed

- `node scripts/gates.mjs`: **11/11 green** (G1–G12, no G6) on the release commit. `node website/verify.mjs`: **88/88**, including the release-copy assertions — every version string the site shows against `package.json`, every prose count against the unit directories, and the site's own unit count derived from `src/widgets/` rather than hard-coded.
- `scripts/probe-geometry.cjs`: **11/11**, now carrying the posture and inner-fit assertions above; the four 2026-10-14 units were each re-verified at `gates 11/11 + probe-geometry 11/11` plus a live pass by their own production records.
- **Not claimed:** the calendar's and the countdown's schedule-bound reading is only as live as DSH's `schedule` service (see the upstream gaps above); the postures standard covers the 2×4 single-line case that produced it, not every layout; and the site's own `verify.mjs` needs a Chrome/Edge install plus a browser launch, so it stays a release-time and CI check rather than part of `pnpm test`.

## v1.8.4 — the swallow survives a reload, and the fold stops re-laying out the page

> A fix-led patch for the three complaints that survived v1.8.3: **the swallow quietly stopped working after the shell rebuilt its frame**; **the fold animation synchronously forced layout on every frame**; and **the widgets visibly shrank and grew back while the right sidebar opened and closed**. The first two were invisible — the animation curve was right and the frame rate did not drop — and could only be seen in the event log and the DevTools trace; the third was visible but not attributable. A fourth class was folded in afterwards: **switching workspace conversations while the component area was not active painted the whole area onto the conversation**. Eighteen changes, all inside the plugin — the shell was not touched.
>
> On "the conversation area stutters when it slides": this release brings the plugin's own share of a sidebar toggle down to **4.9%** of sampled CPU (V8 profile, three real toggles), and the five hottest JS frames in the trace are now all the official shell's own (ReactDOM's commit phase and the scheduler's work loop). That body is outside the plugin boundary, so **this release does not claim to have fixed it** — see "Not claimed" under Verified below.

### Fixed — the yield beat died whenever the shell rebuilt the frame

- **The listener was bound to an element that stopped existing.** The rail's "yield synchronously, in the same beat as the grid transition" path listened for `transitionrun` on the AppFrame element, cached in a module-level `frameEl` and re-bound only when the *rightbar column* disconnected. When the shell swaps the frame node without disconnecting that column, the old listener sits on a detached node: the `transitionrun(grid-template-columns)` never reaches us, so the beat silently degrades to the 240 ms + 520 ms polling fallback — which is exactly the "works, then stops working after a few reloads" report. The binding is now a single capture-phase listener on `document`, matching the frame by class suffix (`_frame`) rather than by a surviving reference (`src/client/rail/measure.ts`, `install()` / disposer). Measured with `scripts/diag-yield-beat.cjs --rounds 4` across four reload cycles: beat lag **1 ms / 0 ms / 0 ms / 0 ms** against a 120 ms budget, with the control round reporting `transitionrun=null` (the event really is swallowed above us, and the fallback really is what used to be doing the work).
- **`data-yielded` was written every frame.** `updateRailBudget` set the attribute unconditionally on each pass; `dsh-ui-harmonizer` observes that drawer with a MutationObserver and re-runs `refreshMaterial` (12–27 ms per call, 10 calls in the fold trace) for any attribute change. It is now written only when the value actually flips.
- **A `:root` variable write sat between two layout reads.** `measureRailTop` wrote `--dsx-rail-top` before it had finished reading its own rectangles; a custom-property write on `:root` invalidates the whole document's style, so every read after it became a full-document forced reflow — the plugin's single largest self-inflicted layout source. The function is now read-early / write-late (`// ── READS FIRST, WRITES LAST ──`), with all four writes grouped at its tail, and the throttle branch re-publishes `--dsx-rightbar-w` so a deferred re-probe cannot miss a width change.

### Fixed — the fold was forcing layout and style on every frame

- **`syncWholeCards` read `rail.scrollTop` and `rail.clientHeight`.** It re-measured the rail on each `useLayoutEffect` pass to compute the visible band, which meant a synchronous layout per animation frame on the one animation the owner runs continuously. It now takes its parameters and computes the band from values the component already holds; the effect's dependency array was also object-identity based, and `wave-geometry.ts` rebuilds `staticLayout` on every render, so a numeric fingerprint guard replaced it. **Trace proof, before → after** (`--what=fold`, headful 1707×1067 @ DSF 1.5): `bandOf ← syncWholeCards` goes from **9 forced layouts + 14 forced styles to zero**. The remaining forced layout sources are all outside this plugin (inline `check`/`raf`, shell React `merged-chunk:21638`, `dsh-ui-harmonizer`'s own `refreshMaterial`, the product's painted hit-target).
- **Thirteen cards each invalidated their own style.** `CardBody`'s `useLayoutEffect` called `setAttribute` / `removeAttribute` for `data-dsx-overflow` on every pass regardless of whether the overflow state had changed; any attribute mutation invalidates that node's style, so a fold cost 13 extra style recalcs (44.2 ms of effect self-time). Guarded by a ref, so the DOM is touched only on a flip.
- **Three rail variables were written per frame with no equal-value check**, one of them (`--dsx-rail-overshoot`) with no reader anywhere in the repository. All writes now go through a short-circuiting `setRailVar`, the dead variable is gone, and `setRailPaneH` no longer uses a functional updater that had to read `rail.clientHeight` on every commit (that updater was the single largest self-time item in the sidebar-close frame, 130.9 ms).
- **Rail-height truth was being re-derived two different ways.** `.dsx-stats-rail` is `position: fixed; top: var(--dsx-rail-top); bottom: 0; box-sizing: border-box`, so `clientHeight = window.innerHeight − --dsx-rail-top` is an identity — but `rowFitSide` re-read `rail.clientHeight` (a forced layout) and `readChatMeasure` re-ran `getComputedStyle` for a variable that is written inline. The rail's top is now published to a side channel (`noteRailTop` / `clearRailTop`) and read from there, and the chat measure takes an inline fast path first.

- **The fold's own clock re-read the theme on every frame.** `groupDurationMs()` cached `--ds-transition-duration-slow` for 250 ms and invalidated it from a `MutationObserver` watching `<html>`'s `class` / `style` / `data-theme`. The trouble is that **the rail writes inline custom properties on that same `<html>`** (`--dsx-rail-top` / `--dsx-rail-w` / `--dsx-rail-pad` / `--dsx-input-bottom` / `--dsx-rightbar-w` / `--dsx-rail-scroll`), so every anchor write was read as a theme switch and knocked the cache out, re-running `getPropertyValue` — we were dirtying ourselves. The observer now invalidates only when the **old or new `style` value actually mentions that token** (`class` / `data-theme` still invalidate unconditionally, with `attributeOldValue` on so a theme *removing* an inline override is seen too). Paired V8 profile: `getPropertyValue` self-time **41.2 ms → 1.4 ms (−97%)**, and `refreshGroupDurationMs` drops off the forced-layout list from 7 calls to none.
- **Thirteen cards each paid a full-document forced layout during one real sidebar gesture.** `CardBody`'s overflow check reads `scrollHeight` / `clientHeight` on every commit to decide `data-dsx-overflow`. The old ref guard suppressed *writes* when the result had not changed, not the *reads*; it is now a pre-read fingerprint gate (`out` object identity, `unit`, `boxW`, `pinBox`, `pinnedByChart` all equal ⇒ skip), so the DOM is touched only when geometry or content really changed. A fold hands each card a new `boxW` — that is the animation — so the gate opens on cue; this is deliberate precision, not a heuristic. Forced layouts drop **19 → 9**, and `data-dsx-overflow`'s meaning and the one-`console.warn`-per-flip semantics are unchanged.
- **One bare rect read on the yield beat.** `measureRailTop` read `rightbarEl.getBoundingClientRect()` whenever it had no width hint; the observe/rebind moment is when the tree is clean, so the width is seeded into `entryRightbarW` there and the hint-less path never pays that forced layout (49.0 ms of self-time in the 2026-10-03 V8 profile).

### Fixed — the widgets shrank and grew back while the right sidebar opened and closed

- **Inside the swallowed window the geometry was re-derived from the user's PREFERENCE, not from what was being drawn.** When `layout.constrained`, `resolveRailSpace` used `prefs.cardSide` / `prefs.columns` as the card side and column count. But those are the **baseline**: a deck that had auto-grown to 3×140px got snapshotted as the user's 3×120px baseline while covered, so every card shrank and then grew back — which is exactly the shrink-and-grow the owner reported, with `dsx-wave-run` not involved at all (it is not a wave-peak problem). The last geometry resolved **while not covered** is now remembered in `heldDeck` (keyed by `columns:baseline side:padding`) and replayed for the whole covered window: the cards keep their own cells and are only carried out and back by the drawer's `translate`, with no size ever re-solved mid-gesture. **Measured (`scripts/diag-sidebar-deck-zoom.cjs --cycles=1`, 13 cards, headful 1707×1067 @ DSF 1.5): distinct slot widths over one open/close go from 17 (`140 → 119.302 → 119.99 → 120 → … → 140px`) to 1 (a constant 140px), with zero `dsx-wave-run` class changes and one distinct slot transform (`none`).**

### Changed — the 组件 drawer rides the product's own transition tokens

- `panel.module.css` hard-coded `0.28s`; the drawer now uses `var(--ds-transition-duration-slow)` and `var(--ds-ease-in-out)` — the exact curve the official sidebar track animates on — so the swallow and the panel move as one gesture rather than two nearby ones.

### Fixed — the component area jumping back to the top of the page (defensive anchor fallbacks found along the way)

> This section is the **defensive** hardening done while investigating "the component area lands on the page top after switching workspace conversations while it is not hovered": an unpublished `--dsx-rail-top` really does drop both fixed layers to `top: 0`, but it was **not** what reproduced for the owner (the real trigger is the next section). It is kept because it closes a genuinely silent failure path.

- **`--dsx-rail-top` was not written at all on the first measure pass, and without it both fixed layers paint at the top of the page.** The rail and the magnify layer are `position: fixed; top: var(--dsx-rail-top, 0px)`, and that variable has **no CSS-side definition** — `measure.ts` is its only writer. `measureRailTop()` has three "publish only `--dsx-rightbar-w` and return early" shortcuts (the horizontal-only RO tick, the moving-width tick, the 250 ms vertical throttle), and each is sound only while an anchor is **already** published. `install()`'s first call always took one of them: `rightbarEl` is not yet re-bound, so `rightbarW` resolves to `0` while `lastRightbarW` is still `-1` and `rightbarW !== lastRightbarW` is necessarily true. The anchor therefore only appeared on a later, deferred tick. A new `anchorPublished` gate disables all three shortcuts until `--dsx-rail-top` has been written once, so every pass runs the full vertical probe until then. Measured (`scripts/verify-rail-anchor-guard.cjs`): the very first frame that touches the rail already reads `railTop="44px"` / `y=44`.
- **The disposer deleted the anchor.** `measure.ts`'s teardown called `removeProperty('--dsx-rail-top')`, and the shell rebuilding its frame remounts the plugin root: the old instance removed the variable and the new instance's first measure hit the shortcut above — so a single frame painted with a mounted rail dropped the whole component area onto the page top (owner report 2026-10-04: switching workspace conversations while the component area was not hovered left it showing at the top of the page). Teardown now **leaves the variable in place** (a stale-but-real anchor is a few pixels off at worst; a missing one is a full-page jump) and resets `anchorPublished`, `lastRightbarW` and `lastVerticalProbeAt`, so the next install's first pass always runs the full probe.
- **Belt and braces: no anchor, no paint.** `rail.module.css` gains an `html:not([style*='--dsx-rail-top'])` guard (rail / magnify layer / settings drawer) that sets those three layers to `visibility: hidden` while the anchor is unpublished — because `top: 0` is then the only position they could take. The inline write lands in the same commit, so in a healthy session this rule never matches. Both `.dsx-stats-addpanel` and the magnify layer consume the same variable and are covered too.
- One adjacent gap in the same area: a resolved width of `0` may mean "the right column really is closed", but it is also what an unbound layer reports before the shell mounts `[class$='_rightbarCol']`. A new `widthMeasured` test publishes `--dsx-rightbar-w` only in the first case, so a fake `0px` can no longer override the `--dsh-sidebar-width` fallback the offset chain keeps for older installs.

### Fixed — switching workspace conversations while inactive painted the whole component area onto the conversation (the real trigger)

- **The trigger has nothing to do with the anchor: switching conversations makes React unmount the entire mounted drawer and mount a fresh one.** The component area's "a session exists" signal comes from the mount/unmount of the composer collector (the `conversation.composer.dock` registration in `src/client/index.ts`); during a conversation switch it is unmounted for a few frames, `snap.hasSession` briefly goes false, and `keepMounted = retired && snap.hasSession && (everOpenRef.current || prewarm)` in `src/client/rail/rail-view.tsx` therefore goes false — the very next line, `if (retired && !keepMounted) return null`, unmounts the whole drawer. When hasSession returns, the new instance's `everOpenRef` is false again and its 2.5 s `prewarm` timer mounts the drawer back — as a deck that was **never collapsed**: all 13 slots carry inline `transform: none` and sit in the resting grid (x1257 / 1407 / 1557, y50 / 200 / … / 650), which is exactly the "component area appears at the top of the page" the owner saw. Measured (`scripts/.tmp-who-clears.cjs`, wrapping `CSSStyleDeclaration.prototype.transform`'s setter and tagging every node): after the switch the 13 slots are born together in one batch and `[transform writes on deck slots SINCE the switch] 0` — nothing cleared a fold offset, the new nodes simply never had one. "Switch conversations, never move the pointer" reproduces steadily, because none of this path goes through hover or magnify.
- **They were actually painted because of a deliberate `visibility: visible` in `rail.module.css`.** The wave overlay has to swap cards silently, so `.dsx-wave-deck .dsx-stats-card-slot, .dsx-wave-deck .dsx-stats-add { visibility: visible }` is declared explicitly; the comment right there says this is **deliberately not `opacity: 0`** (measured: opacity costs an extra 50–68 ms of pure paint per entry, with ~1 ms of style/layout). But **an explicit `visibility: visible` on a child beats an inherited `hidden`, with no `!important` involved** — so the card painted straight through the hidden deck → rail → surface → drawer-zoom → drawer chain and landed on the conversation. Measured (`scripts/.tmp-closed-rest.cjs`): while closed, `.dsx-stats-card-slot` itself computes `vis=visible` while all seven ancestors above it are `hidden`. The drawer being parked (`[data-retired]`) or the session being gone (`body.dsx-stats-no-session`) now reclaims that explicit `visible` with a later rule at the end of the same file:
  ```css
  .dsx-stats-drawer[data-retired] .dsx-stats-card-slot,
  .dsx-stats-drawer[data-retired] .dsx-stats-add,
  body.dsx-stats-no-session .dsx-stats-drawer .dsx-stats-card-slot,
  body.dsx-stats-no-session .dsx-stats-drawer .dsx-stats-add { visibility: hidden !important }
  ```
  The magnify overlay is portal'ed to `<body>` and is therefore not under `.dsx-stats-drawer`, so the normal hover swap is unaffected (`scripts/verify-rail-covered-hover.cjs` still passes in full).

### Verified — what was measured, and what was not claimed

- **Coverage geometry** (`scripts/verify-swallow-20.cjs`, 9/9): the rail's right edge stays pinned at 1920 for all 83 frames of the open, the rightbar track interpolates 0 → 397 → 864, the drawer leaves as motion (`translateX` samples `[…, 498, 591, 621, 631, 637, 641, 641, …]`) and ends fully off-screen at `[1950, 2551]`, and the close brings it back to `[1309, 1910]` with no hop into the conversation column at any point.
- **Frame budget** (`scripts/diag-anim-perf.cjs`, headful 1707×1067 @ DSF 1.5): cold fold, close, warm fold and pointer-sweep sessions are unchanged at p50 16.7 / p95 16.8, zero frames over 26 ms on the fold scenarios. On the sidebar open/close scenarios the plugin's own contribution drops — LoAF `FrameRequestCallback` **48 ms → 12–14 ms**, and the `ontransitionrun` work moves from `DIV.ontransitionrun` (77 ms charged to a DIV inside the plugin) to `#document.ontransitionrun` (52–61 ms at document capture), which is the new binding point — while the worst frame in the open scenario improves 100.2 ms → 83.4–83.5 ms.
- **The plugin's own share** (`scripts/diag-cpu-profile.cjs`, `CYCLES=3`, conversation pinned by title and proven): of 1280 ms sampled CPU across three real sidebar toggles, the plugin accounts for **63 ms (4.9%)**; in the same `--what=sidebar` trace the five hottest JS frames are now all the official shell (`index-5SrrfWpU.js:56:34474` ReactDOM commit 36×922 ms, `:41:1926` scheduler 28×234 ms, official combo `:73005` 40×140 ms) and the plugin's entries have left the list. On the forced-layout list the plugin is down to `CardBody`'s overflow read (9×) and `measuredRightbarWidth`'s `shellRead` (6×).
- **Anchor guard** (`scripts/verify-rail-anchor-guard.cjs`): `removeProperty('--dsx-rail-top')` followed by a read in the SAME task — the rail's and the magnify layer's computed `top` really do fall back to `0px`, but `visibility` is already `hidden` (the guard holds, nothing is drawn at the page top); the variable is back to `44px` after 600 ms; and a real right-sidebar toggle then samples **157 frames with 0 empty anchors and 0 visible fixed layers at y<40**. The reload path (`.tmp-rail-boot-top.cjs --mode=reload`, 633 frames) and the hero→conversation SPA remount path (`--mode=switch`, 908 frames) both recount to **0 real suspect frames** (the 452 "suspects" the probe used to report were all `inset:0` drawer/zoom wrappers — their `box.top` is always 0, so including them in the predicate is a false positive).
- **No leak when switching conversations with the rail closed** (`scripts/verify-closed-switch-leak.cjs`): first park the rail (and assert the drawer really is retired and the rail element is `visibility: hidden` — several earlier probes measured with the rail OPEN and were therefore measuring the wrong state), then click into another workspace's conversation and count frame by frame. The predicate must be **ancestor-aware**, `el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })`: an element's own `visibility: visible` / `opacity: 1` does not mean it is painted — opacity does not inherit, and the magnify layer blanks its slot subtree at the **layer** level. Measured: **519 frames, all clean (0 frames paint a component area), 0 frames with a component surface at y<40, no page errors**, and the test first asserts the rail was never relit for the whole window, otherwise the filter would exclude exactly the frames being looked for. The same predicate's sensitivity was checked in the other direction: with the rail open it sees **13 slots at y50** (so "0" is not a predicate that can never see anything).
- **The same test run as a negative control** (`scripts/.tmp-leak-negative.cjs`, one-off): injecting a higher-specificity rule (`.dsx-stats-drawer[data-retired] .dsx-wave-deck .dsx-stats-card-slot { visibility: visible !important }`) restores the pre-fix cascade and the identical flow immediately reproduces **786 leaked frames out of 1053** (slots `1257x50 140`, `1407x50 140`, `1557x50 140`, …) — so the 0 above is genuinely held by that CSS, not a blind predicate. The screenshots agree: after the fix the right-hand side is entirely blank, before it the same area held all 11 cards.
- **Not claimed:** no fps improvement is asserted for the fold (52.2 → 49.7 fps across the same five-cycle trace, i.e. unchanged within noise), and the sidebar scenarios' p95 is **not** claimed to have improved: those frame intervals quantise to multiples of the vsync period (16.7 → 33.4 → 50.1), and repeated runs of the same bundle land on both 33.4 and 50.2. The p95 of that animation is set by the official `grid-template-columns` track transition itself, which is main-thread layout and cannot be changed from a plugin. **"The conversation area lags and stutters when it slides" is likewise not claimed fixed**: cutting the widgets from 13 to 3 made the shell's cost *per commit* go from 55.82 ms to 90.05 ms, which shows that cost is independent of how many cards we render — it is decided by the official React commit phase, outside the plugin boundary. The full before/after JSON, including every repeated run, is in `docs/verify-report3/perf-before-after.json`; the reasoning and the industrial references are in `docs/reports/sidebar-animation-performance.md`.

## v1.8.3 — the sidebar swallows the rail again on DSH 0.2, and the plugin list can finally see us

> A fix-led patch that also ships the DSH 0.2 adaptations this repository had been carrying unpublished: the rail is re-aligned against 0.2's stacking and track semantics, the OpenCode feed is fetched only when something renders it, and the plugin finally has its own icon, locale and a short description in DSH's own plugin list.

### Fixed — the right sidebar stopped swallowing the rail on DSH 0.2

- **The root cause was the track syntax.** DSH 0.2 rewrote the AppFrame's inline tracks from `280px minmax(0px, 1fr) 720px` to `… minmax(0px, 864px)`, while the rail's "width the track is heading for" read accepted only a plain `px` last token. Both `readTargetColumnWidth` and `readTargetRightbarWidth` therefore returned `null`, `predictRailBudget` went with them, and the yield degraded to the 240 ms + 520 ms settle debounce: the rail hopped into the freed conversation column, then snapped back under the panel ~500 ms later. Measured at 1920×1080, one press of 打开右侧边栏: before, the grid went 1640px → 776px in a single frame and the frame fired no `transitionrun/start/end` at all; after, 11–13 intermediate widths interpolate frame by frame, the events are all there, and the rail's right edge stays pinned at 1920 the whole way (`scripts/verify-swallow-20.cjs`, 9 assertions).
- **The settings drawer (the 组件 area) is swallowed with it.** Its `right` declaration was overridden by `panel.module.css` (same specificity, imported later) with the raw `--dsx-rightbar-w`, so the drawer stopped following the rail; and because it is portal'ed to `<body>` (z-index 30) it floated *above* the official 0.2 right column, which carries no z-index at all. It now rides the same variable and expresses the swallow as MOTION — sliding out the right edge on the same 0.3 s curve as the sidebar.

### Changed — the OpenCode feed is fetched on demand

- **No widget, no request.** The collector used to pull every data source unconditionally: on a machine with no OpenCode widget installed and the key deleted, `/api/opencode-usage` answered 503 on every refetch, so the browser kept a red line in the console for a feature the user never set up. `hasConsumer(source)` now checks the installed widgets' declared `source` (usage / cc / sys / github) and only asks for what something actually renders; the single-key route is no longer requested on its own (the pool route is always 200 and already carries the primary key's payload).
- **The host no longer says "not configured" with a 5xx**: `503 { error }` became `200 { configured: false }` — an absent optional key is an expected state, not an error for every client to log.

### Added — an icon, a name and a Chinese description in the plugin list

- `icon.svg`: a 36×36 viewBox with a blue→indigo linear gradient and flat filled geometry, at the official padding (content covers 50–60% of the tile, matching the built-in experimental bundles), in the colour family of 智能体团队 (cyan `#45D9E7`, amber `#F2AF63`, the main blue gradient, light blue `#7CB7FF`).
- `locale/zh.json` + `locale/en.json` (`meta.title = 组件系统`) with `exports["./locale/*.json"]`, plus README front-matter as the second channel; `package.json.description` is a short Chinese sentence now — it is the one text channel that also resolves for a `link:`-installed plugin.
- Light and dark App Icons (`docs/icon/app-icon-{light,dark}.svg`).

### Changed — the DSH 0.2 adaptations that ship with this version (previously unpublished)

- The magnify overlay is clipped vertically, so scrolling no longer paints cards over the session header and the top bar; the turn navigator keeps the product's own z-index 7 (the 9 override is gone); the composer dock's info bar is no longer hidden.
- The earlier card and settings/config-page rework ships here for the first time.

## v1.8.2 — the plugin loads on DSH 0.2 again

> A compatibility release. DSH 0.1.7 added a **peer check at install and boot**: a plugin whose declared `@deepseek-ai/dsh-*` peer range does not accept the running runtime is refused outright. This plugin declared `@deepseek-ai/dsh-client-ui-slots: ^0.1.0-rc.6`, which cannot accept `0.2.0-rc.x` (a 0.x minor is a breaking boundary in semver), so every user on the `next` line — and every user of the new desktop build, which ships `0.2.0-rc.x` — was told the plugin is incompatible and never got it loaded. The range now covers both lines, and the support was verified by installing and booting the plugin against real runtimes rather than by reading release notes.

### Fixed — the peer range that locked the plugin out of DSH 0.2

- **Reproduced first, against a clean profile.** `dsh plugin --profile web install` on **0.2.0-rc.2** refused the plugin in exactly the words a user would see: `Plugin dsh-widgets@1.8.1 is incompatible with dsh 0.2.0-rc.2: peerDependencies {"@deepseek-ai/dsh-client-ui-slots":"^0.1.0-rc.6"} …`, then restored the profile and told the user to grant an exact-version exemption by hand. The boot path skipped the bundle with `cannot resolve profile bundle` / `incompatible` in the same run.
- **`peerDependencies` widened to `^0.1.0-rc.6 || ^0.2.0-rc.1`.** A union, not an open-ended `>=`: each line is a boundary this plugin has been exercised against, and the next minor gets its own verification rather than being assumed. `@deepseek-ai/cordis: ^4.0.1` needed no change (0.2.0-rc.2 ships cordis 4.0.4).
- **Verified on both runtimes** with a scratch `DSH_HOME` (the real home was never booted with 0.2, so no Session V4 migration could be triggered): **0.1.7-rc.2** and **0.2.0-rc.2** each install, boot with no skipped bundle, serve the client bundle (`dsh-widgets` present in `__DSH_BOOT__.entries`, `dsx-stats-card` in the payload), register all four slots (`conversation.session.header.utilities`, `conversation.composer.dock`, `conversation.input.overlay`, `settings.section` — each `inject` callback fired), answer the host half (`/api/widgets-state` 200, `/api/sysinfo` 200 with real machine data) and render the **Widgets** settings section, with **no console errors and no failed requests**. The rail inside a live session is the one thing a keyless scratch home cannot show (the session-scoped slots do not mount before a model is configured), so that last step is left to the machine that has one.
- **The supported-range copy followed**: both READMEs' badges and compatibility bullets, and the showcase's kicker, terminal title and JSON-LD `operatingSystem`, now say `0.1.x / 0.2.x` — with `website/verify.mjs` (88 checks) still asserting every hand-written version string against `package.json`, which is what caught the copy the moment the version moved to 1.8.2.

## v1.8.1 — the card waves, one head ladder, and the limits that came back

> A fix-led release that also ships the two card waves committed behind v1.8.0. The registry grew from **40 widget units to 55** (17 added, 2 retired) across three batches — the session and the machine, the device family, cost and the review queue — each fed by a new host channel, and a revision pass reworked nine of the shipped cards to the owner's review. On top of that come the two defects a live rail finally showed: **Command Code's monthly limit disappeared from every card** (three upstream endpoints measured at 14–21 s against one shared 8 s budget), and **two neighbouring ring cards drew visibly different heads** (a rung that comes and goes moved the whole ladder). Every change here was proven by a gate diff or a probe before its baseline was rewritten.

### Added — seventeen new card units (40 → 55)

- **The third batch's four units** (`298cd66`): `usage-mix` (cross-provider quota board), `trajectory-stats` (three-lane shares plus a per-instance big-lane cycle), `heatmap-bars-wide` (30-day bars in 15 two-day buckets) and `peak-pricing-board` (band schedule plus countdown, four pinned-clock preview states). `todo-board` was folded into `task` rather than shipped beside it — same id and order, so an installed `task@2x2` upgrades in place and the market keeps one card named Tasks.
- **Wave 1 — seven cards for what the agent is doing and whether the machine is OK** (`79063f2`): `goal-progress` (goal phase, objective, rounds/max, last change), `guard` (the permission preset in force), `jobs` (background jobs: running, longest, newest), `subagent` (dispatched subagents and how many are resumable), `sys-disk` (disk free plus the DSH session log's size and hourly growth), `window-forecast` (whether the 5h / weekly window hits its cap before reset) and `model-config` (which model route / reasoning effort / preset the agent runs).
- **Wave 2 — the device family, cost, and the review queue** (`cd50a73`): `sys-net` (per-adapter receive/transmit rate from a counter delta, plus the busiest link), `sys-power` (battery vs AC, charge, remaining runtime, active Windows power scheme), `sys-procs` (top processes by working set), `session-cost` (the session's tokens priced through the price table, with the amount's provenance printed beside it) and `github-notify` (the review queue — the one GitHub card that says "someone is waiting on you").
- **`sys-services` — the local dependency board** (`fd66438`): liveness and latency of this machine's key local endpoints (DSH web; Ollama / LM Studio are opt-in and get no row at all when switched off, so an uninstalled runtime cannot raise a permanent "down") plus whether the proxy egress really works — a listening port is not an egress verdict.
- **Two units were retired.** `heatmap-bars-wide` was merged into `heatmap-bars` (`e96acc1`) and `model-config` was dropped (`8be5852`, 324 lines), which is why the registry ends at 55 rather than 57.

### Added — the host channels and the data the new cards read

- **`/api/host/overview`** (`src/host/overview.ts`): network throughput, power source, top processes, local service liveness and proxy egress in one route. It is one route because a single `powershell.exe` spawn already costs ~270 ms of floor (a full pass measured ~1.5 s cold; TCP probes 0.2–0.3 ms per port), so battery + processes + counters + power scheme come from one merged script; `proxy` is stale-while-revalidate and the handler never awaits it, so the route stops blocking on the expensive sections after the first call.
- **The review-queue slice** (`notif=1` in `src/host/github.ts`): GitHub's unread threads with per-reason counts and the newest subject, ETag-aware and authenticated-only.
- **The price-table channel** (`src/host/pricing.ts`, `/api/widgets-pricing`): a READER of `dsh-usage-center`'s stored table (`$DSH_HOME/storages/usage-center/pricing.json`), serving provider scope, effective window, peak windows and provenance (mtime-cached). A missing or unparseable file answers `available: false` — it never falls back to a table this plugin invented.
- **Five session-shape projections** (`src/client/data/session-stats.ts`): `normalizeModelSelection`, `normalizeGoal`, `normalizePermissions` and `normalizeSubagents` read the host projection defensively and DROP unknown members rather than passing them through (the projection's value is `unknown`, and a throwing selector costs every card's data in the slot), keeping `null` ("projection absent") distinct from `[]` ("no children"); `deriveTrajectory` folds settled nodes, running tool calls and the open step timeline into the official Trajectory card's three lanes — in-flight work included, so the card moves while the model is working.
- **The machine half of `sysinfo`** (`src/host/machine.ts`): `createMachineSampler()` reports the fixed disks (`fs.statfs`), the `$DSH_HOME/sessions` total bytes plus the number of files whose mtime falls inside the last hour, and the `dsh web` process's own PID / RSS / CPU / uptime.

### Added — one shared head, value and chart layer

- **The third batch's shared primitives** (`298cd66`): a `breakdown` chart; the donut / `headRing` accessory; the segments tone palette plus value mode; the accent tone (the official model-lane mix, now also read by `lanes.tsx`); the head's type ladder (title / figure / caption builders, so a ring head and a stacked head share one typography); the 42px corner disc; the tile-fits guard (clipped in a preview, grown on the rail, both self-announcing); the value-change figure drop; live-data previews (`buildLiveStats` / `buildPreviewStats`); the capped-arc rule (only 100% closes a ring); `ConfigField.hint`. The batch closed with G1/G2/G3/G3b/G4/G5/G7/G8 green: 44 units, 140 render outputs, 24 routes, 15 preview checks.
- **A bare `headIcon` accessory** (`src/client/render/CardBody.tsx`, `HeadIconName` in the contract): the permission shield and the GitHub mark in the head's right slot with no arc, for a head whose accessory is an identity rather than a dial — a closed vocabulary, because the render output is data the offline gate snapshots as JSON. It shares the slot with `headRing` and with `corner`, and a card may use exactly one.
- **Five value tones** (`64d706a`): `danger` (already wrong), `warn` (heading there), `success` (good because safe), `business` (an informational active state) and `muted` (the big slot holds a lone `—`, which at 20px reads as a redaction bar otherwise). The widget picks the rung; the renderer never infers a colour from a number.
- **`WidgetChart.density`** (`b32ffc6`): a relative size for a chart's rows, clamped 0.8–1.6. It exists because a chart's row metrics are a height budget authored for the tightest card and `scale` cannot express "give this chart more room" — `unit` is the same 150 for 2×2 and 2×4, so a 2×4 that drops its figure has ~44px of dead space and no way to spend it.
- **The peak/off-peak rule lifted into the shared layer** (`7236f16`): both Peak Pricing cards now read `src/client/lib/peak-schedule.ts` and `peak-holidays.ts`, with `ZONE_OFFSET_MINS` exported once; the 2×4 keeps only its render mapping. Proven a pure move: G4 renders 140 outputs with ZERO differences and G5's stylesheet hash is unchanged. The `breakdown[].pulse` field, reserved for a breathing peak row the owner declined, was deleted rather than shipped as a speculative interface.
- **`quota-manage` gained a head ring with an overrun lap** (the owner's ask): `headRing` (`ratio: shown / 100`, `overshoot: true`, tone escalating `primary → warn → danger`), the forecast printed as a whole percent whenever it is a projection or an overrun, a new `gauge` glyph in `src/client/render/icons.tsx`, and `overrun` handling in `src/client/render/charts/donut.tsx` — the first lap closes the ring and the excess is a second dash from the same 12 o'clock origin, with a blurred shadow disc (`blur(stroke * 0.45)`) painted UNDER the band so that only a crescent hugs the tail cap. A second preview state (`simSteps: [{ over: false }, { over: true }]`) ships the overrun at ratio 1.35.
- **`src/client/render/charts/quota-fit.ts`** — the `quotas` chart's cell geometry as a pure module: `quotaGeometry(barW, boxH, rows, labelH, scale)` picks the tallest square cell that fits vertically and then DERIVES the cell count from the width (`QUOTA_MIN_CELLS` 6, `QUOTA_MAX_CELLS` 32, `QUOTA_GAP_RATIO` 0.18, `QUOTA_BREATHING` 6), so a wide tile gets more cells instead of wider ones.
- **A chart renderer is a component** (`src/client/render/charts/registry.ts`): `renderChart` now returns `React.createElement(render, props)` instead of calling `render(props)`, so a renderer's own hooks (the `quotas` measuring hook) belong to its own fibre rather than to `CardBody`'s.

### Changed — the revision round: nine cards reworked to the owner's review

- **`guard`'s figure is the permission name at the 20px rung** (`af6a34d`), coloured by the product's own semantic rungs: `danger` + `valuePulse` for full access, `business` for workspace-write, `success` for read-only — on both the name and the shield, so mark and word never disagree. A label measured not to fit the 124px text column (`Workspace Write`, 149.8px), or a name never measured, falls back to the 10px `sub` rung: a smaller true label beats a truncated big one.
- **`goal-progress`'s empty state moved to the card's left bottom corner** (`af6a34d`): with no goal there are no rows to hold the floor down, so the two lines become `value` + `sub` with `bodyAnchor: 'bottom'` instead of riding the head ladder — measured 13px from the top with 76px of dead tile below the second line.
- **`sys-disk` revision 3** (`160148f`): the 20px `D: free …` figure is deleted outright (it restated the row that prints the same disk with its denominator), the caption collapses to one grey `legend` line (session-log bytes · files added in the last hour), and the disk bars spend the freed room through `density`. The grade colour now appears on the bar only, so one threshold can no longer paint two places.
- **`sys-services`'s caption fits its budget** (`8b491a3`): the egress legend is `null` while a probe is pending and the failure text is cut to the code, because wrapping it as "egress down (code)" consumed ~40px of the 124px caption box and truncated `ECONNRESET`; Ollama / LM Studio became config rows with hints.
- **`github-notify`, `jobs`, `subagent`, `sys-procs` and `sys-power`** were reworked in the same pass — new titles, legends, empty states and hint rows across `src/widgets/<id>/README.md` and `index.ts` — and the website's generated catalogue was regenerated (`website/js/data.js`, `website/index.html`).
- **`dateKey` no longer builds an `Intl.DateTimeFormat` per call** (`src/client/lib/heatmap-accounting.ts`): formatters are cached per timezone, because `dateKey` runs once per heatmap cell and the rail re-renders continuously while a turn streams (measured 13 grid builds/s = 1,196 calls/s; it had become the second hottest function of the idle page at 10.8% of samples, ~75 ms/s of one core).

### Changed — sixteen units retitled to say what they answer

- **The Command Code family** (`src/widgets/*/manifest.json` plus `_shared/locales.json`, regenerated into `src/client/generated.registry.ts`): Usage → **Billing Usage**, Account → **Account Identity**, Windows → **Usage Rings**, Credits → **Usage Bars**, Plan → **Plan Tier**, 5h window → **5h Window**, and the weekly / monthly window cards to **Weekly Window** / **Monthly Window**.
- **Everything else**: Quota Manager → **Quota Forecast**, System Monitor → **System Board**, Rate → **Decode Rate**, Avg TTFT → **First-token Latency**, Issues → **Open Issues**, Pushes → **Latest Push**, GitHub · To Review → **To Review**, the session-cost card to **Session Cost**, and four units whose Chinese label moved to the English one it already carried (Usage Comparison → Usage Bars, Usage Rings, Token Heatmap, Token Bars).
- **The shared dictionary followed**: `cc.account` → "Account identity", `cc.roleUsage` → "Billing usage", `cc.roleWindow` → "Usage rings", `cc.win5h` → "5h Window", and the credits card's rows went from the bare words to their full names — `cc.limit5h` → "5-hour limit", `cc.limitWeek` → "Weekly limit", `cc.limitMonth` → "Monthly limit". Throttle Forecast's copy now says "cap in {d}" instead of "full in {d}" and "no cap · {d}", "Until reset" became "Quota reset", and `usage-mix`'s caption became `{win} · {at}`.

### Changed — the head is a fixed-height ladder

- **A ring head always draws all three rungs** (`src/client/render/CardBody.tsx`: `figureRung(reserve)` / `captionRung(reserve)`). The dial is centred against the ladder's rendered height, so a rung that comes and goes moved the whole head: measured at side 150, the caption-less Plan Overview card drew title 15.7 / figure 35.3 / ring 13 where the identical card **with** a caption drew 13 / 32.6 / 17.3 (the title and figure fell 2.7px, the ring rose 4.3px). A missing rung is now RESERVED with a non-breaking space, which is why two neighbouring cards finally wear the same head; a bare-mark head keeps the old top-aligned rule, because its ladder height moves nothing.
- **Alongside it**: `pinnedByChart` (every chart card is pinned to `unit × unit`), `ELASTIC_CHARTS` (`line`, `lanes`, `quotas`) giving the chart `flex: 1` with `minHeight: 0`, `boxSizing: 'border-box'` on both the card and `SkeletonBody`, and a `flex: 'none'` wrapper around the head so only the body absorbs a shortfall.
- **The `quotas` chart measures its box** (`src/client/render/charts/quotas.tsx`): a `useFittedGeometry` layout effect (ResizeObserver on the box and the first row's label line), the square cell and count from `quota-fit.ts`, the boundary cell painted by a hard-stop `linear-gradient` with both inks baked in via `color-mix(..., transparent)`, no corner radius, and the rows bottom-aligned with the leftover height spent as breathing room.
- **Ring ink follows the painted extent** (`src/client/lib/arc.ts`): `cappedArcInk` now returns `ratio · circumference − stroke` below 1 and `ratio · circumference` at or above it, with `capGap` demoted to a floor; a share thinner than one stroke draws a cap-sized dot (`stroke * 0.5`) at 12 o'clock instead of nothing. This is the fix for a 99% ring that read as 95% — the old `c − stroke − capGap` left a fixed ~7px gap, about 5% of the circle.

### Fixed — the Command Code monthly window (2026-09-30)

- **The monthly limit came back on every card.** `/alpha/whoami`, `/alpha/usage/summary` and `/alpha/billing/subscriptions` were measured at **14–21 s on six consecutive calls** while `/alpha/billing/credits` answers in ~1 s — and 3.6–4.2 s twenty minutes later, i.e. that upstream's latency for those three is variable. The host had ONE 8 s budget, so all three were dropped on every poll and the family degraded exactly as the report showed: **Usage Rings** drew two rings instead of three, **Usage Bars** listed the 5-hour and Weekly limits with no Monthly limit row, **Quota Forecast** fell to `-%` with its Budget row at `—` (its projection needs the subscription's plan and period), and the switcher showed `Key 1` / `Key 2` instead of the account names.
- **`src/host/commandcode.ts` now plans per slice.** `credits` keeps 8 s and is the only slice the route waits for; the slow three get 30 s and are refreshed OUT OF BAND. A per-`(member, endpoint)` slice store keeps the last good answer (TTLs: credits `ROUTE_CACHE_MS`, usage 5 min, subscription 10 min, whoami 60 min), a background refresh never blanks a slice we already know, retry floors are 60 s for a stale slice and 15 s for one never seen, and the store is keyed by `ref:tail` so a key rotation cannot serve the previous account's data. A cold route call waits at most `COMMANDCODE_COLD_WAIT_MS` (1500 ms) for slices it has never seen — measured 1 s of credits plus that, against 8 s before — and the host warms every pool member's four slices at boot.
- **The client half** (`src/client/data/collector.tsx`): the extra 5 s look now fires whenever a reply is degraded — including the FIRST, cold answer, which is partial by design — not only when it would have replaced a complete payload. The month therefore appears within ~25 s of a page load instead of waiting for the 60 s poll.
- **Verified against the real upstream** by `docs/probe-cc-pool.mjs` (repaired in the same pass: the render layer had moved to `families/cc/data.ts` + `renders.ts`, and the probe now waits out the route memo for a COMPLETE pool, tolerates a null slice instead of crashing, and renders Quota Forecast too): **92 assertions, all green** — pooled month 40.5%, Physicolor 78.7%, Sparxie 2.1%, real account labels, and Quota Forecast at 66% with `Ends 10-10` and a real Budget figure, where the same probe failed 24 assertions before the fix. A new `docs/probe-cc-slices.mjs` pins the pipeline itself with a latency-controlled fetch stub (13 assertions: no blocking on a slice we do not hold, out-of-band completion, last-good retention across a failed refresh, per-TTL re-reads, the memo still collapsing a burst) and prints the real upstream's per-endpoint latency with `--live`.
- **G7 stayed green**: with instant stubs the first body is still complete, so all 17 route cases keep their status and shape.

### Fixed — the defects the live rail found

- **Chart cards grew out of their tile.** Measured 2026-09-29, the Command Code Credits card boxed 172.8px in a 160px slot and the tile-fits guard drew its red outline; chart cards are now pinned (`pinnedByChart`) with an elastic body. The same pass fixed the head being squeezed (the `AllUser` caption clipped from 15px to 6.3px while the chart below still overflowed by 10px) and the skeleton, which only set `minHeight` and stood 160 × 181 for a 160px tile.
- **React #310 could clear the whole rail slot.** With a renderer called inline, its hooks landed on `CardBody`'s fibre, so the ordinary "no chart, then a chart" swap (a first 503 from `/api/commandcode-usage`, then success) rendered more hooks than the previous pass and DSH's slot boundary emptied the drawer; `renderChart` now creates a real element.
- **The quota row was graph-paper slivers.** 24 cells across the card's 132px bar came out 3.6 × 12px where the official row's cells are ~28 × 26 at a 740px bar; the count is now derived from the width at the square pitch, and the cell the reading lands inside is filled by its exact share instead of being rounded away or leaving bare card background.
- **Trajectory Share overflowed its tile.** The card carried a legend line the `segments` chart's 76px budget does not include, so it stood 161px in a 150px tile (red guard outline); the lane name moved onto the figure's own 29px row. `usage-mix`'s figure + caption needed 45 + 65px of the ~64px a ring leaves and clipped on the preview, so its caption became its own legend line — the shape its height audit already budgeted.
- **Real defects the third batch caught** (`298cd66`): a duplicated render of the head figure (`headRing` + `headAfter`), milliseconds run through the token formatter, a one-symbol-high pool-ordinal table, and a preview toggle that silently did nothing.
- **The overview's first throughput pair lands in seconds** (`src/host/overview.ts`, `fc6eb61`): PowerShell counters are cumulative, so the sampler now schedules a second snapshot 2.5 s after the first instead of making the first card wait a whole TTL. **Notifications report a capped page** (`src/host/github.ts`): a full 30-thread page makes `count` a floor, and the payload now says so.
- **`lib/client.js` was rebuilt** after a parallel writer changed the cache ring's glyph geometry (`e57da93`), so the bundle no longer lags its source.
- **Two harness assertions were testing nothing**: `docs/verify-i18n.mjs` asserted `card.task.sub` (gone from the dictionary, so it only proved the key still reports MISSING) and now asserts `card.task.small`; `docs/verify-skeleton-shapes.cjs` gained the square-tile assertions (a card must be `w × w`, a 2×4 must not be taller than wide), and `docs/market-test.cjs` follows the renamed heatmap label.

### Changed — gates, probes, previews and docs

- **G7 `scripts/snapshot-host-routes.mjs` gained cases** for `/api/host/overview`, the `notif=1` slice (`98e82ba`) and the pricing channel (`0c41433`), keeping its "a registered route with no case is red" rule honest — 17 cases today.
- **An offline card gallery** (`scripts/preview/gallery.mjs`, `src/client/render/preview/gallery.tsx`, `tsdown.gallery.config.ts`, `28e05db`): one command bundles the render closure and writes a page using the real theme tokens (dumped by `scripts/preview/dump-theme-tokens.cjs`), screenshots every card cell into `docs/preview/cards/`, and writes a **head-ladder fixture page** that `docs/probe-head-ladder.cjs` measures — a ring head with no caption is a state no widget's preview data reaches, and it is the state that shipped the defect. The probe pins 49 assertions across six head shapes at sides 150 and 190 (the title on the content top, the 4px title→figure step, one ring y and one ring diameter across the ring variants, the caption rung reserved rather than dropped, no overflow).
- **The live rail still gets its own run** (`98a2a38`, `be8e7fd`): `docs/probe-batch3-live.cjs` drives the real GUI and checks what the offline gallery cannot — whether each card's data source resolves in a running session, because a card whose projection is missing renders `null` and leaves a hole in the rail. It snapshots and restores the rail layout (`12a9d60`): seeding `localStorage` is not enough, because the app syncs its own layout back.
- **The baselines were refreshed where a change was intended** (`ae0db31`, `695ecc8`, plus the overrun preview state in `docs/architecture/baseline/render-snapshot.json`), and the 96 regenerated card shots under `docs/preview/cards/` are the review surface that comes with them.
- **`docs/architecture/CODE_MAP.md` gained seven invariants (10–16)**: chart cards pinned to the tile, the head never compressed, chart renderers rendered as components, the skeleton being the tile itself, square quota cells with a derived count, renames checked against two fit probes, and `headRing.overshoot` for over-budget dials.
- **Research and worker docs for the batch**: the third batch's brief, specs and two data audits (`cf40933`, `HOST-DEVICE-AUDIT.md`, `PROJECTIONS-AUDIT.md`), the wave-2 device / cost / notification specs (`38c42ce`, `75a1b8b`), the delivery report (`c1d8175`, `docs/research/BATCH-3-REPORT.md`) and the rule that an installed card renders once its source answers — 0 is a reading, not an absence (`85cf904`, `WORKER-BRIEF-V3.md`).
- **`website/` was synced to the retitles and the version** (`website/js/data.js`, `website/js/i18n.js`, `website/index.html`), and `node website/verify.mjs` now asserts every hand-written version string against `package.json` and every count in the prose against the manifest list — the pre-release audit found the structured data still advertising `softwareVersion: 1.6.0` and the copy still claiming 40 widgets, neither of which any check had ever looked at (88 checks now).
- **The English-facing docs were finished** (`9abbe6a`): `website/README.md` still described the pre-refactor tree (`components.tsx`, `lib/*-view.ts`, line ranges into `client/index.ts`) and claimed 34 widgets — every replacement was checked against the current repository, and a final grep refuses to write if a stale name survives; `docs/screenshots/README.md` had an English title over a Chinese body and now ships English throughout. Note for the record: the 1.8.0 tarball on npm was built before this commit, so that one file differs from the published tarball.


## v1.8.0 — the structure release: two monoliths dissolved, zero type errors, published types

> An engineering release with **no card changes**: the rail, the wave, the settings pages and every widget behave exactly as in v1.7.0. What changed is where the code lives, what the gates can prove, and two long-standing publication defects. Every step was verified against the previous build rather than by eye — the render snapshot (110 outputs), the compiled-CSS concatenation, the host-route fingerprints and the live probes all had to come out identical, and where a change was intended it had to be *provably* that change alone.

### Changed — the two god-files are gone

- **`client/index.ts`: 3,795 → 308 lines.** The right rail's slot body (deck grid, magnification-wave assembly, add panel, sliding drawer) moved to `rail/rail-view.tsx` as `createRailView(deps)`, and its measurement layer (anchor probes, right-bar width tracking, the ResizeObserver fan-out, the budget/YIELD beat and the single complete dispose) to `rail/measure.ts` as `createRailMeasure(deps)`. Both receive the bridge handles they used to close over; the two values the composition root itself owns — `railBudget` (part of the bridge snapshot) and `drawerEl` (written by the rail view's ref) — stay there and cross the boundary through accessors. The move is auditable line by line: the rail body differs from the original in **exactly three lines**, all of them event-handler reads that must observe the live prefs binding rather than a render-time snapshot.
- **The host half: one 1,077-line file → nine modules and a 31-line entry.** `src/host/` now holds one module per upstream channel (`opencode` / `commandcode` / `usage-daily` / `github` / `sysinfo`) plus the state file, sharing `http.ts` (the TTL memo and the request-body reader), `exec.ts` (the one subprocess seam) and `context.ts` (the ctx contract); `host/routes.ts` is the registry and `src/index.ts` composes it. Each channel exports `registerX(ctx): () => void` and the root keeps calling `ctx.effect`, so a module never takes over the plugin's lifecycle. With the new G7 harness, the pre-split and post-split bundles produce **byte-identical fingerprints for all 14 route cases**.
- **`lib/contract.ts`: 730 lines → a zero-import `types.ts` plus a `helpers.ts`.** Types were interleaved with the resolver helpers, and the module imported i18n for one of them — so any file that needed a single type dragged the dictionary into its module graph. 68 import statements across 67 files were repointed; no barrel file was kept, because a barrel would have preserved exactly that coupling. One English string inside a widget's own English dictionary named the official trajectory rail in Chinese — a label leaking into the English UI — and is fixed in the same pass.

### Changed — dead code, tokens, and the verification harness

- **An orphaned badge chain removed end to end.** `badgeOf` had no caller, `widgetBadgeLabel` was called only by it, 14 widget units' `badgeLabel` fields were read only through that resolver, and the config page's own comment recorded that the badge had been dropped from the UI. Gone with it: the contract field, the unit declarations, four dictionary keys, an unreferenced icon (`GripIcon`), an unreferenced host constant (`COMMANDCODE_ROUTE`) and three dead CSS rules (`.dsx-macts` / `.dsx-mid` / `.dsx-restore`). A side effect worth having: with `badgeOf` gone, the contract module no longer imports i18n at all.
- **The design tokens have one home.** The two `:root` blocks lifted out of `card.module.css` and `rail.module.css` into `styles/tokens.module.css`, imported first (the import order is the cascade order). This is the one styles change that is not a byte-identical re-slice, so it is verified differently: every untouched sheet is byte-identical, the two sheets that lost a block are byte-identical once that block is removed, and the new sheet declares exactly those two variables.
- **97 probe scripts no longer hard-code a playwright browser revision.** They carried `ms-playwright/chromium-1243/…` while the machine had moved on, so every live probe failed to launch. They now resolve the newest installed build (`scripts/lib/chrome.cjs`, with `CHROME_PATH` as the override).

### Added — two gates for what had none

- **G7 `scripts/snapshot-host-routes.mjs`**: loads the built host bundle with a mock web server, stubbed credentials and fetch and a throwaway `DSH_HOME`, drives all seven routes through 14 cases and compares structural fingerprints (keys, types, status codes). It refuses to run when a registered route has no case. Deliberately broken (405 → 404) and confirmed red before being trusted; G4/G5 never covered the host half at all.
- **G8 `scripts/verify-published-types.mjs`**: builds a throwaway consumer whose `node_modules/dsh-widgets` is a junction to this repository and compiles it with `skipLibCheck: false`, so the real `exports` map is exercised and an unresolved import inside our own declarations is an error rather than something skipped. It caught a wrong reference path on its first run.

### Fixed — the published package, and two gates that were green while testing nothing

- **The package now ships the declarations it always promised.** `package.json` advertised `types` for both entries and shipped `lib/types/**/*.d.ts`, but both build entries had declaration emit off — so a TypeScript consumer resolved nothing. tsdown's own `dts` option is not the route here (measured: it writes a stray `lib/index.ts`), so `scripts/build-types.mjs` emits through the project tsconfig (106 files) and copies the `*.module.css` ambient declaration beside the client entry, which references it. `lib/types/` is build output and now gitignored; `npm pack --dry-run` shows the declarations reaching the tarball.
- **G4's harness never cleaned its output directory.** `tsc` leaves stale files behind, so a module deleted by a refactor kept a ghost build output that satisfied a stale import — which is exactly how G4 stayed green while its own `lib/contract` import was already broken. The directory is now removed before compiling.
- **G3 used to tolerate a brand-new error** whose code happened to be on its growth list (`Cannot find module './contract'` is TS2307, the same code as the pre-existing "missing `@types/node`" family). Tolerance is now limited to signatures that already existed in the baseline: growth of a known missing-types error is fine, a new unresolved module is not.

### Changed — the typecheck is a real gate now

- **35 type errors → 0, so `pnpm check` is green end to end.** Each one was treated at the source: `@types/node` + `@types/react-dom` added; the runtime `slots` service declared in `src/client/slots-service.d.ts` (nothing upstream augments cordis's `Context` with it, and the retired `dsh-client-runtime` package that used to carry the type is gone — the file declares the two methods the shell calls, and says why `SlotCore`'s own generics cannot be used); `document.querySelector<HTMLElement>` where an inline custom property is read; and the settings detail drawer now narrows its selection once instead of leaving a dozen possibly-undefined reads behind.
- **New: `docs/architecture/CODE_MAP.md`** — "I want to change X, where do I go" for every part of the plugin: the widget-unit anatomy (and the nine manifest keys the generator accepts), the four shared-code layers, how the rail is displayed, which file owns which button, the magnification wave split across its four files, the settings surfaces, the data flow, the styles and i18n rules, the eight gates and nine invariants not to break.

## v1.7.0 — every Command Code pool on one card, switched with a tap

> A feature release. The Command Code provider's **Account rotation** card puts several subscriptions behind one install (`COMMANDCODE_API_KEY`, `COMMANDCODE_API_KEY_2` … `_4`), but the widget family only ever read the first one — the rail silently reported one account's quota while the provider was rotating over two. Every Command Code card now carries the whole pool and a tap cycles `AllUser → Physicolor → Sparxie → AllUser`, the pool's total beside each account's own figures. Measured 2026-09-20 against the two live accounts on this machine: `AllUser` month 14.5% (Σ20.24 of Σ140 allowance), Physicolor 28.9%, Sparxie 0.0%; week 7.1% pooled vs 14.2% / 0.0% per account. Everything below is re-checked by `docs/probe-cc-pool.mjs` (68 assertions) against the real upstream.

### Added — the host reads the whole pool, not just the primary key

- **`/api/commandcode-usage` aggregates every configured member.** `COMMANDCODE_API_KEY` plus the `_2 … _4` spares that the provider's Account-rotation card writes are all resolved through the same credentials seam, and each one gets its own four official endpoints (`whoami` / `usage/summary` / `billing/credits` / `billing/subscriptions`), still fetched and timed out independently — one failing endpoint yields `null` for that slice, one failing key yields `data: null` for that member, and the rest still render.
- **Members are labelled with their own account name**, read from that key's `/alpha/whoami` (`name` → `userName`), which is what the card legend shows (`Physicolor`, `Sparxie` — not `Key 2`). A member whose `whoami` did not answer falls back to `Key N`, and a name that repeats gets the key's masked tail appended, so the cycle can never show two indistinguishable entries.
- **The payload stayed backward compatible.** The top-level slices are still the first member's own four slices, exactly the shape every consumer read before pools existed; the pool rides alongside as `keys[]`. A single-key install therefore reads byte-for-byte what it read before (verified: legs of the probe render the same cards from a payload with `keys` stripped).

### Added — tap a card to switch accounts

- **Whole-card tap cycles the pool views.** All eight Command Code cards, plus the Quota Manager projection card that reads the same account, carry a cycle descriptor: `AllUser → <account names> → AllUser`, with the chain in the hover tooltip. The selection persists per card instance in `cardConfigs.ccView` — deliberately a different field from the OpenCode pool's `poolView`, so the two families never share a view and a Command Code tap never fires the multikey pool's `prefer` call (verified: every card's `cycle.store === 'ccView'`).
- **`AllUser` is the pool's total, summed field by field.** A window's percent is `sum(used) / sum(cap)` — a mean of percents would report 50% for one exhausted pool beside an untouched one, when the pair is really half its allowance used (measured: 5h 0.48% → 0.5%, week 7.09% → 7.1% over the two accounts). Usage counts, tokens and spend add; `successRate` is recomputed from the summed counts, because a member with zero requests reports 0 upstream and must not drag the pool's rate down. The account card shows the generic `AllUser` label with the member names beneath it — `AllUser` is deliberately the same literal in both languages.
- **The legend line carries the current view** beside the card's role word (`Account · AllUser`, `Windows · Physicolor`, `5h window · Physicolor`), so which pool a figure belongs to is never ambiguous — including on the single-window cards, whose role word is the only thing that says which window they are.
- **One key configured → no switcher at all.** The cards render exactly as before (bare role word, account name in the body, no cycle), which is what every install that never configured a second account keeps seeing.
- **Verified** by `docs/probe-cc-pool.mjs`: it loads the real host bundle with a fake context, calls the real `/api/commandcode-usage` handler against the live accounts, compiles this repo's `cc-view.ts` and runs the real render factories over that payload in three states — AllUser, each account, and a `keys`-stripped single pool — asserting the labels, the sums (counts, spend, balance, both windows, the month), the legends, the persisted field, the stale-view fallback to AllUser and the unchanged single-pool cards. **ALL PASS, 68 assertions** (`docs/probe-cc-pool-result.json`).

### Changed — the monthly window is summed per member, each against its own plan

- **The pool's month is the sum of its members' months.** The plan → allowance table is keyed by plan id, so reading the pooled balance against one plan's allowance reported **0%** for a pool that was 14.5% used. `monthlyWindow()` now sums each member's own window, measured against **its own** plan (two GOAT accounts = 2 × $70), which is also what the roadmap had asked for as an upstream-independent reading; a member whose plan is unknown keeps its own balance fallback instead of poisoning the pool. The reset shown is the earliest member period end — the next renewal.
- **A stale view can never show another account's numbers under a label it no longer matches**: a persisted `ccView` that no longer resolves (account renamed, key removed) falls back to `AllUser`.
- **The README's monthly-window note was stale** — it still described the abandoned `used / (used + remaining)` conservation form and listed multi-key support as a roadmap item. Both now match the code, and the remaining limitation is stated for what it is: pool discovery follows the `…_API_KEY` / `_2 … _4` naming convention, because the credentials service exposes `resolve(ref)` and has no "list the refs" seam to enumerate them.
- **The single-key probe had been failing on that same stale formula** since the plan-allowance fix; `docs/probe-cc-render.cjs` now asserts the allowance-based figure (`28.9%`, not `34.8%`) and that a single-pool payload carries no switcher.

### Fixed — Quota Manager fills its layout instead of announcing "insufficient data", and its head follows the Context Level shape

- **The card has no "insufficient data" state any more.** Whatever is missing is FILLED: no payload yet → `-%` beside the billing period line when one is known, with `—` for the two figures; a period too young to project → the real consumed percent; an account that has never been used → zeros. The old branch rendered the message into the title row's right slot, which is also what squeezed the blue title down to "Quota Man…" — the title is no longer ellipsized in any state.
- **A young period is a flag, not a null.** `planQuota` now distinguishes MISSING INPUTS (still `null`) from "no pace to extrapolate yet" (`projectable: false`), so the 6-hour floor stops blanking a card whose consumed percent, tokens and budget are all real. Measured on the second pool member (subscription created that morning, elapsed 0.98 h): the card prints `0.0%` + `billing period 10-20` + `Today 0` + `Budget 251M` where it used to print "insufficient data" with no period and no figures.
- **An unused member's zeros are its own.** The daily log is machine-wide, so it is only credited to an account that actually spent in its period (`todayTokens = 0` when its own rate is not derivable); its BUDGET still answers — remaining credits ÷ days left at the POOL's realised local-tokens-per-credit rate — because the balance and the period are both real. The pace never borrows that rate: projecting the machine's usage onto an account that has served nothing would invent a number.
- **The stacked grey block is CENTRED on the figure's line box**, and the two token figures keep the card's FLOOR. Baseline-aligning the block's first line (the Context Level treatment for a single grey line) put ``Ends 10-10`` ~8px BELOW the figure's floor and left only ~12px to the figures row; top-aligning the body put that row right under the billing period line with ~55px of empty tile beneath it. `headAfter.smallLines` now centres, and the new `bodyAnchor: 'bottom'` restores the every-other-card posture for Quota Manager (`marginTop: auto` on the foot) — measured live: figures bottom → card bottom = **14px** against the 13px inner pad, head bottom 132.5 vs figures top 185.2. New standard gap between the title row and the figure row: **4px** at side 150 (the app's 4/8/12/16 rhythm), up from the 2px a plain grey caption needed.
- **`headAfter.big` now honours `valueTone`/`valuePulse`** (`dsx-stats-card-value` + the breathe class) wherever a card renders its figure — the over-budget escalation survives the move out of the title row; asserted in the real React tree, not just the render object.
- **GPU utilization uses the same head**: blue title on top, `0%` on the next row with `53°C · 0.0 GB` to its right, and its elastic sparkline still filling the box exactly (`card 160 = slot 160`). Its "not enough history to draw a line yet" state shows the current utilization + facts instead of a waiting notice.
- **The two figures are the same story again, exactly.** The budget divided by the rounded-up day count while the projection used the exact remaining span, so a pace within ~3–4% of the budget could exceed it while the card still read 98% (measured live 2026-09-20: pace 258.9M vs budget 253.4M, projected 98.5%). Both now use the exact span, which makes `projected > 100% ⇔ pace > budget` an identity rather than an approximation.
- **Verified live** by `docs/probe-card-head.cjs` (45 assertions, Playwright + a minted browser-session cookie): the card is read out of the RESTING deck (the magnify overlay carries stale copies of the same cards), every pool view is reached by a REAL tap, and each one is asserted for the head shape, the 4px gap, the grey-to-the-right geometry, the block staying inside the figure's line box, the figures' floor position, the absent "insufficient data" and the two figures. Measured 2026-09-20: AllUser `49% | AllUser / `Ends 10-10` | 77.2M / 612M`, Physicolor `99% | Physicolor / `Ends 10-10` | 81.2M / 264M`, Sparxie `0.0% | Sparxie / billing period 10-20 | 0 / 251M`; screenshots in `docs/probe-card-head-*.png`. `docs/probe-quota-manage.mjs` covers the math side (per-member rows + the new degradation rules), `docs/verify-quota-manage-render.cjs` the real React tree.
- **The SSR probe's stale preview premise was fixed on the way**: its own `previewStats` had drifted to a ~103% month, so "the preview default is calm" had been failing — HEAD's math projects 102.8% for it (measured by running HEAD's `quota-math` against the same inputs). The preview now lands at ~70%, leaving the over-budget state to the sim toggle it exists for.

### Fixed — Quota Manager's head carries the billing period line alone, bottom-aligned with the figure

- **The pool view name is gone from the head** (`AllUser` / the account name). Two stacked grey lines read as clutter, so the head prints the ``Ends 10-10`` line only — dropped to the head row's FLOOR so its bottom edge lines up with the big figure's. That is a new contract field (`headAfter.smallAlign: 'bottom'`); the default stays baseline-aligned, which is what Context Level's grey line keeps. Tapping the card still cycles the pool and every figure moves with it — the view is simply no longer labelled.
- **Measured live** by `docs/probe-card-head.cjs` (53 assertions, **ALL PASS**): `68% | `Ends 10-10` | 473M / 616M` (AllUser), `132% | `Ends 10-10` | 473M / 257M` (Physicolor), `51% | billing period 10-20 | 473M / 1.0B` (Sparxie); the billing period line's bottom edge lands on the figure's to the pixel (Δ=0px in all three views), no view name appears anywhere on the card, and the two token figures still sit 14px off the card floor. `docs/verify-quota-manage-render.cjs` asserts the same on the rendered React tree (`align-items:flex-end`, no stacked block).
- **The probe's state restore moved into a `finally`** (and its host re-read retries): an ECONNRESET mid-run had left the room persisted on the last view the walk tapped.

### Fixed — Quota Manager measures the Command Code plan, not the whole machine

- **The card's token side is folded to the `commandcode` route.** Its percent, Budget and credits all described ONE plan while Today, the recent pace and the credit→token rate were read off the machine-wide day map — so a parallel OpenCode Go pool was charged to the Command Code allowance (measured 2026-09-20: the card printed Today **758M** where that route itself served **474M**; the machine-wide day was **783M**, of which 309M was `opencode-go`). `stats.commandCodeDaily` now carries the scoped map and the card reads nothing else.
- **`/api/widgets-usage-daily?provider=<route>`** asks dsh-usage-center to fold that provider alone. The mode must be `only`: its `all`/`merge` modes keep the whole window on purpose and would have handed a named route the machine-wide map back. Without the parameter the route answers exactly as before, so the heatmap cards are untouched.
- **The two maps are kept apart, and a stale host degrades instead of lying.** The client requires the scoped answer to ECHO `provider=commandcode`; a host that has not been restarted ignores the parameter, and the card then prints `—` for its token figures rather than silently restoring the mixed caliber.
- **`docs/verify-cc-scope.mjs` arbitrates it independently**: it decodes every raw session log itself (no plugin code), folds per-day tokens PER PROVIDER ROUTE, and asserts the card's own caliber against both host maps and the real account payload — `commandcode` 473.7M vs machine-wide 783.3M on 2026-09-20, with `opencode-go` 309.5M excluded. `docs/verify-usage-daily-route.mjs` covers the route's two calibers offline, `docs/probe-quota-manage.mjs` and `docs/verify-quota-manage-render.cjs` now read the scoped map too.

### Fixed — an upstream slice that did not answer can no longer rescale the pool

- **A holey pool DEGRADES; it never reports a hybrid caliber.** The host route answers a failed upstream call with `null` for that slice (one dead endpoint must not blank the family), and the client used to absorb that silently: a member whose `billing/subscriptions` call dropped fell back to the RETIRED conservation caliber (`used/(used+remaining)`) while the pool's calendar took the surviving member's period, so Quota Manager printed **20.1% + `billing period 10-20` + `Budget 58.6M`** where the whole payload says **16.6% + ``Ends 10-10`` + 645M** (the screenshot that opened this fix, 2026-09-20); a member whose `billing/credits` call dropped collapsed the pool onto the surviving member's window and printed a false **117%** over-budget alarm. `monthlyWindowOf` no longer reads a missing subscription slice as "an exotic plan", and `monthlyWindow` returns `null` the moment any member's month cannot be placed — a partial sum reads LOWER than the truth, which is the one failure a quota card gives the user no way to notice.
- **The pool's calendar is ONE member's pair**, not the earliest start beside the earliest end (the live pool is 09-10 → 10-10 beside 09-20 → 10-20, so two different members supplied the two halves and the card measured a period nobody ever had). It follows the earliest reset still AHEAD, so a member that already rolled does not anchor the month, and the `billing period` line now prints the LOCAL day of that instant instead of the UTC fields of the string.
- **One retry before a slice is reported missing.** `fetchSlice` re-asks once when the failure was fast and retryable (network error / timeout / 5xx / 429) with a shorter budget, so the worst case stays near the original 8 s ceiling; a 4xx is an answer and is not retried.
- **The collector asks again when the payload is degraded.** `ccPayloadDegraded` (a dropped slice, or a period that already ended) schedules ONE re-ask 15 s later, so a transient upstream failure is a blip rather than the session's answer. Verified end-to-end by the new `docs/verify-quota-reask.cjs`, which serves a real frozen payload with one subscription slice removed and samples the live card: `Quota Manager | -% | AllUser | billing period 10-20 | Today 10.1M | Budget —` at 8.4 s → `75% | `Ends 10-10` | Today 480M | Budget 649M` at 38.3 s, with the page asking its second time on its own.
- **The credit→token rate needs a log that COVERS the period.** A log beginning mid-period understates the numerator, and an understated rate does not merely soften the pace — it inflates the pace measured in credits, which is how a freshly loaded page (whose fallback log held only today, before dsh-usage-center's map landed) turned a calm **75%** month into a **247%** red alarm with `Budget 1.5M`. `logCoversSince` now gates the rate, the pace and the borrowed pool rate (the pool rate is summed per member, over each member's OWN period), so the card prints the consumed percent and `—` instead of a number it cannot price.
- **`docs/probe-cc-pool.mjs` grew a pool-integrity section** — both hole shapes make the pool month unknown, the calendar is one member's pair anchored on a live period, the `billing period` label is the local day, and a rolled period is flagged for the re-ask: **88 assertions, ALL PASS** against the live accounts.

### Changed — the Plan card shows a tier badge, and its preview walks the plan ladder

- **The plan is a badge, not the raw id.** `individual-goat` used to be the card's big figure and ran off the tile as `individual-goa…`; the card now prints the tier the official plan map names it (`GOAT`, `PRO`, `MAX`, `ULTRA`, `PROVIDER`, `GO`, `TEAMS PRO`), resolved by LONGEST prefix after normalizing exactly like the provider resolves it (so `individual-pro-v1` beats `individual-pro`). An id outside the table is neither invented into a tier nor printed raw: the vendor prefix is stripped and the rest uppercased, so the badge stays one short word.
- **The grey line is the billing period**, matching the Quota Manager card's ``Ends 10-10`` wording (the Plan role word is gone — the badge already says what the card is); the badge sits on the card's floor, and only a non-active status or a cancelling subscription adds a small note under it.
- **A preview click walks the LADDER.** `WidgetExample` gained `simSteps`: when present, a click on a market / Config preview advances to the next simulated state instead of flipping the single boolean, so the Plan card cycles GOAT → PRO → MAX → ULTRA → PROVIDER → GO → TEAMS PRO → GOAT. Both preview surfaces share one `nextSim()` helper, and peak-pricing / Quota Manager keep their boolean flip untouched.
- **Verified live** by `docs/probe-cc-plan-preview.cjs` (26 assertions): it opens the real market panel, selects the Plan preview dot, and asserts the title, the period line, the badge on the floor (34px vs the legend's 18px), the absence of the raw id — then clicks the card seven times and requires exactly `GOAT → PRO → MAX → ULTRA → PROVIDER → GO → TEAMS PRO → GOAT` with the period line unchanged. `docs/probe-cc-pool.mjs` (73 assertions) covers the same card from the LIVE payload; screenshots in `docs/probe-cc-plan-*.png`.

### Changed — Usage and Credits join the tag caption/floor layout, and the preview stage became a true square

- **Usage (cc-usage): the facts moved to a FIGURES row.** The token total stays the big figure under the title with `tokens` to its right, but the single grey line (`4821 requests / 100% success / spend $26.5`, ~150px in a ~126px slot) was ALWAYS ellipsized before the spend — the three facts are now a figures row (`requests 4821 · success 100% · spend $0.4676`) on the card's floor, with no role word (the figure carries its own unit).
- **Credits (cc-credits): rebuilt as the official site's limit rows.** `69.16 credits` on top, then THREE rows — `5-hour 6%` / `Week 2%` / `Month 1%` (the name on the left, the percent hard right) each over a **24-cell segmented bar** (used cells at the window's urgency tone, the remainder a pale wash of the same tone). The old `kind: 'bars'` twin columns (plus their dashed grid and 25px of labels) are gone, along with the role word and the reset line. A new `WidgetChart` kind `quotas` carries the rows.
- **The preview stage is the tile.** Both preview surfaces now render the card inside a pinned unit square (`pinBox`), so an over-tall card is clipped by its own `overflow: hidden` exactly as the rail seats it — measured before this: the credits card rendered **200×250** in the market and read as a non-square rounded rectangle; now every preview card measures exactly 200×200.
- **The sparkline no longer has its stroke cut off at the bottom.** A 0% sample (an idle GPU is the every-day case) mapped to y = 100 and the plot box's `overflow: hidden` sliced its lower half-stroke — measured on the live utilization card: polyline bottom **754.0 == svg bottom 754.0** with a 2px stroke, which is the"cut off at the bottom"the user reported. The polyline now lives inside `[PAD, 100 − PAD]` (PAD = 3 viewBox units) while the FILL still closes on the true floor; measured after: polyline bottom **751.9** inside a box ending at 754. The time-label row was verified to sit inside the card's padding (bottom 768 vs the 769 floor). **Because the report was intermittent, that is not taken on faith**: `docs/probe-gpu-line-stability.cjs` samples the live card every 1.5s for 45s (30 samples — the stroke stayed 2.1px inside, the labels 1px inside, and the card height never left its slot), and `docs/probe-gpu-line-values.cjs` intercepts the rail's own sysinfo route to serve a synthetic **0 → 100 → 0 ramp**, so one render carries every y the line can take: the plotted range came back exactly `[3, 97]` of the viewBox, i.e. 2.1px of clearance at BOTH ends against a 2px stroke — no value can clip it (screenshot `docs/probe-gpu-line-ramp.png`).
- **The rows were sized to the 2×2 budget rather than guessed.** The first cut of the credits redesign measured 100px of rows in the 81px the 2×2 card has left after title + balance and clipped its last bar against the floor; the shipped metrics (9px label line, 2px gap, 9px bar, 5px between rows ⇒ 73px) were computed from the budget and verified live.
- **The spend figure reads three significant digits** (`$10.1`, `$0.468`) instead of four decimals (`$0.4676`): on a 2×2 tile the fourth decimal is noise, and the figure shares its row with two other facts. Above $100 it drops to whole dollars, below a tenth of a cent it says `<$0.001` rather than rounding a real spend to `$0` (measured live: `$27.35` → `$27.4`, `$0.4676` → `$0.468`).
- **The dashed sparkline: a miss was drawn as a BREAK.** The real cause of the "cut off at the bottom" the user kept seeing (their report was stable, with the GPU completely idle) was not the stroke at all: the host stamps a `null` sample whenever its `nvidia-smi` query times out, and the line renderer broke the polyline there — measured live 2026-09-20: `history.gpu` carried **26 nulls against 0 in `history.cpu`**, and the utilization card rendered **2 polylines (15 + 3 points)**, i.e. a dashed line the user read as cut off. `plotSamples()` now carries the previous reading across a run of up to 4 misses (the poll window IS the averaging window, and the last reading is the best estimate for a utilization graph — Windows' own graph does the same), drops leading nulls instead of inventing them, and still breaks on a longer run (a device that really went away, which the card's own no-GPU state also reports in words). Measured after: **1 polyline, 20 points** on both the rail card and the market preview. Regression-covered from both ends: `docs/probe-gpu-line-values.cjs` feeds a ramp WITH null runs and requires one 20-point line, `docs/probe-gpu-line-stability.cjs` requires `segments === 1` in every live sample.
- **Verified live** by two new GUI probes: `docs/probe-cc-cards-preview.cjs` (23 assertions — the strict 200×200 square, the three facts with the mock values on the floor, the three quota rows with 72 segment cells whose filled count follows the percents: 6%/2%/1% of 24) and the extended `docs/probe-card-head.cjs` (47 assertions, now also asserting the sparkline stays inside its plot box and the time labels inside the card padding). `docs/probe-cc-pool.mjs` (79) and `docs/probe-cc-render.cjs` (24) cover the same two cards from the live payload; screenshots in `docs/probe-cc-cards-*.png`.
- **The probe learned to tell an upstream miss from a layout defect**: the host route fetches each member's four endpoints independently, so a member can come back with a slice missing under rate limiting (observed while running the suite). The period assertion now records that as the upstream state instead of blaming the card, and the `Key N` label fallback is accepted as naming the view by position.

### Changed — the magnification wave is one spring, written every frame

- **The enter is a continuous spring now, not a CSS tween.** The old shape was a 200ms `settle` transition followed by an untweened `follow`, and no parameter could make it hold a *moving* target: writing a new focus on every pointer frame **retargets** a CSS transition, so it restarts from its current value and never gets past the ease ramp's slow start (measured frame by frame on the real GPU: walking the pointer onto a card gave **0.02–1.1 scale/s** where a single jump gave **3.5** — the enter animated at whatever speed the mouse happened to be moved at). Freezing the target so the tween could finish fixed the curve but left the geometry stale, and the card **snapped** to the live position the moment the freeze ended — the reported "wrong position as soon as I move over the widgets".
- **The progress is an explicit factor** written every animation frame: `displayed[i] = 1 + (target[i] − 1) · p`, where `target` is the live pointer geometry (always current, never a reason to restart anything) and `p` runs `WAVE_SPRING` between rest (`0`) and engaged (`1`). Both are continuous, so their product is: enter, follow and leave become ONE uninterrupted motion on one curve, in both directions, and an interrupted enter reverses from the live `p` instead of jumping. The spring is the Apple-parameterised pair (`response 200ms`, `bounce 0.1`) shared with `dsh-usage-center`'s chart tween — ≈250ms to settle, ~0.15% overshoot (≈0.4px at the default tier). `prefers-reduced-motion` writes `p` in a single frame.
- **The phase machine is gone**: `animPhase` / `settle` / `follow` / `return`, `setPhaseNow` / `setPhaseAfter` / `phaseTimer`, `MORPH_MS` / `SETTLE_MS`. `leaveRail` is now just `armed = false; focus = null` — releasing is the same spring read backwards, so there is no second path to keep in sync and no "tween finished?" boundary to land on the wrong side of.
- **The enter's remaining cost was a compositing one, and the fix is a permanent hint.** The overlay's slots and the add button now carry `will-change: transform` **always**, so their 15 layers exist before the pointer arrives. Measured with the hint gated on the hover (the old shape): the natural enter opened with a **63ms** layer-promotion + raster frame; with it permanent the same enter ran **16ms with zero dropped frames** (`scripts/diag-hover-isolate.cjs`, P4 vs P5). The 64ms "raster prewarm" that used to flip the overlay visible while the rail was idle is REMOVED — it almost never coincided with a real hover, and the stall came back on every enter without the permanent hint.
- **Verified frame by frame** by `docs/verify-rail-morph.cjs` (`HEADFUL=1` for the real GPU, `REDUCED=1` for the reduced-motion path): it samples every frame of a stepped enter, a direct enter and a stepped leave and reports the per-frame speed envelope, so a retargeted curve or a snapped hand-over cannot pass unnoticed — a walked enter peaks at `maxFrameStep` **0.022** against **0.181** for the CSS version at the same geometry, and the reduced-motion enter arrives in one frame. The state machine and the scroll are regression-covered by `scripts/diag-rail-hover-release.cjs` (11 assertions) and `scripts/diag-rail-scroll-perf.cjs`.

### Added — the Settings nav cell wears the app's own glyph

- **The Widgets cell in Settings no longer shows the generic gear.** The settings shell picks a nav cell's glyph from the section ID and only knows its three built-in IDs (`models` / `agent-presets` / `plugins`); every other section —this one included —falls back to the settings gear, and the section contract carries no icon field (only `id` / `order` / `label`). The client half therefore marks OUR row itself: it finds the nav button whose label is our own registered section label and stamps `data-dsx-nav="widgets"` on it, and `widgets.module.css` hides the gear and paints the app icon's four-tile motif as a 16px `mask` in `currentColor`.
- **The mark is scoped and reversible.** Only that one row is touched —matched by our own label, never by position, so no other plugin's cell is styled —it is re-applied on a language switch (the label is localized), and it is removed with the owning effect, so closing the panel or unloading the plugin leaves no marked node behind. The observer only scans when a `_navList` is inserted, so the transcript's per-frame mutations never pay for it.
- **It had to be restored, not invented**: the CSS half and this mark were written together earlier, but only the CSS landed in a commit —the marking effect was lost with an uncommitted `index.ts` rewrite, which left a rule that matched nothing and put the gear back. (Same failure mode as the loading skeletons below; both halves now live in the tree and in the probes.)
- **Verified live** by `docs/verify-nav-glyph.cjs`: it drives the real settings panel and asserts that exactly ONE row carries the attribute and that the row's label is our registered section label, that the gear svg on that row computes to `display: none`, that its `::before` is a 16×16 inline-SVG mask, that no other nav row is marked, and that zero marked nodes survive the panel closing (screenshot `docs/verify-nav-glyph/nav-glyph.png`).

### Fixed — loading skeletons draw the silhouette of the card they stand in for

- **Every data-backed card had collapsed onto the same generic placeholder** (a value pill and two thin lines), so a loading rail said "something is loading here" but not WHICH cards were about to arrive — and the loading → loaded swap re-shaped each tile. `SkeletonBody` has supported the seven silhouettes all along (and `WidgetRenderOut.skeletonShape` / `skeletonCount` / `skeletonRows` are part of the public contract), but the shell-side `SKELETON_SHAPE` table and the wiring that fills it in were LOST with the same uncommitted rewrite as the nav glyph above: the loader only ever set `skeleton: true` plus a per-source row count, so `skeletonShape` defaulted to `'text'` for all 19 live-source widgets. The CSS-free half was there; the producer was not.
- **The table is restored, and it is per widget**: `usage-rings` / `cc-windows` draw three squares (rings), `sys-rings` two, the 2×4 `sys-board` four; `usage-bars` and `sys-gpu-line` draw ONE large rounded rectangle (a plot area); `cc-usage` a three-figure row on the floor, `quota-manage` a two-figure row, `cc-credits` three stacked quota bars; and the remaining big-figure cards (`usage-rolling` / `weekly` / `monthly`, `cc-whoami`, `cc-subscription`, `cc-window-*`, `sys-cpu`, `sys-gpu`) draw the value pill plus **one** body line — the honest placeholder for cards whose real body is a single grey line (a reset date, a billing period, a memory line), where two rows promised content that never arrives.
- **Verified against all 19 instances**, not just the ones installed on this rail: `docs/verify-skeleton-shapes.cjs` holds the four live routes open forever, injects every data-backed `widget@size`, measures each skeleton's blocks (count, geometry, row/column order, radius, overflow) and judges it against the expected silhouette — **19/19 PASS** — then restores the user's own rail state from a snapshot taken before the run (`docs/lib/widgets-state.cjs`); screenshots in `docs/verify-skeleton-shapes/`.

### Added — continuous corner curvature (continuous corner curvature)

- **Card corners are superellipses, not circular arcs**: `corner-shape: squircle` ramps the curvature in from the straight edges, which is what makes an iOS/Figma-style rounded rectangle read as soft rather than cut. It is native CSS, so the card's border and `box-shadow` follow the same outline (a `clip-path` squircle would slice both off), and an engine without `corner-shape` degrades to a plain round corner with no other change.
- **Two controls, in Settings → Components → Widgets**: the switch (default **ON** — the setting exists so a user can compare) and the **corner gear**, the radius as a share of the card's SHORT side (`12 / 16 / 20 / 24 %`, default 16%). Percent, not px, because the reference keeps the corner-to-side ratio: a 150px card and its magnified 190px copy must not get the same corner. `12%` reproduces the old fixed 16px at side 150.
- **The gear reaches everything that draws a card**: the rail (resting deck and the magnified overlay), the market / Config previews, and the loading skeletons. That wiring is what had been missing — `Prefs` declared both fields, the settings rows wrote them and the previews read them, but the rail's own `CardBody` call passed neither, so the setting only ever changed the previews; `DEFAULTS` was missing the two fields as well (`tsc` reported it).
- **Verified live** by `docs/verify-corner-shape.cjs`: on a 15-card rail it asserts every card carries `dsx-squircle` with the switch ON and the measured radius equal to `shortSide × gear%` (26px at side 160 for the 16% gear), that flipping the switch OFF removes the class from all 15 and flipping it back restores all 15, and that selecting the 24% gear widens every radius to 38px — then restores the values it found.

### Fixed — the Command Code cards no longer freeze on their mount-time numbers

- **Quota Manager's Today / Budget were permanently `—`**: the card reads the day map folded to the `commandcode` route (`stats.commandCodeDaily`), and the client NEVER requested that URL (`/api/widgets-usage-daily?provider=commandcode`) — the contract, the host route and the host's fold were all in place; only the line that asks for it was missing, so `daily` stayed `undefined` and both figures degraded to the dash that means "no such number exists". Wired up; on the real payload the card now renders Today **363M** / Budget **726M** (head 35%).
- **The Command Code monthly ring had vanished, and the quota card's head read `-%`**: one DEGRADED reply (HTTP 200 with a `null` upstream slice) makes `monthlyWindow()` return `null` on purpose — a partial sum would read low — and the client OVERWROTE the last complete payload with it, then only re-asked on mount / turn-settle. `ccPayloadDegraded()` was exported for exactly that "ask once more, shortly" and was never called. One poll in three sampled exactly that way (`keys[].data.*` all `null`, labels fallen back to `Key N`). A degraded reply now never replaces a complete one and schedules one retry 5 s out, and a failed request keeps the previous numbers instead of blanking them (stale-while-error).
- **Idle numbers never moved at all**: these windows are ACCOUNT-wide — another client spending against the same pool moves them with no turn HERE to hang a refetch on — while the old collector pulled only on mount and when a turn settled. That is exactly why the Usage Center moved and the rail did not: usage-center runs its own 2 s poll. The rail now pulls the account payload every 60 s while it is open with a Command Code card installed, pauses while the tab is hidden, and refreshes the moment the tab returns.
- **The cost is measured, not assumed** (2026-09-23): one account tick is four upstream reads per pooled key — 8 reads, 1.06–1.6 s, 4.7 KB on this two-key pool, ≈480 reads/hour — and the `provider=commandcode` day map is a usage-center memo key nothing else asks for, so a cold call re-folds the session logs: **10.6 s and 21.2 s** against 34 ms warm. It therefore runs on the turn-settle and mount paths only, never in a poll loop. All host routes gained a 20 s TTL + single-flight memo, so several tabs, the degraded retry and the two scopes of one settling turn collapse into one upstream pass / one fold (measured cold 1063 ms → 0 ms cached, 6 requests folding 3 times).

## v1.6.1 — the rail scrolls by whole rows, stops at its own content, and releases the hover every time

> A fix release. Three rail defects that survived v1.6.0 are gone: the wheel scrolled in pixels (and could strand a row half up, or refuse to move at all), the deck could be scrolled past its last component into empty space, and the magnification wave could stay engaged after the pointer had left the widgets. Every claim below was measured against the live GUI at 1578×1000 (15 widgets, 2 columns, pitch 184px) and is re-checked by three probe scripts that ship in `scripts/`.

### Fixed — the wheel scrolls in whole rows

- **The last rows could never reach the top.** The rail's scrollable range was the deck's own height minus the viewport, which is shorter than the last row's offset (measured: range 750px with 8 rows × pitch 184), so the browser clamped the final detents and the deck stopped moving while the wheel kept turning. The scrollable content is now derived from the geometry — `contentH = max(rows · pitch, stackHeight) + railViewportHeight` — with the difference reserved by a non-interactive tail inside the rail, so every row can top out and the last one does.
- **Half-scrolled rests.** The wheel's own native scroll lands on the compositor *before* the event reaches the main thread (measured: `scrollTop` was already 120 when the handler ran), so an interrupted animation could leave the deck a third of a row up. The rail now runs its own 240ms tween that always ends on a detent, plus a row guard that snaps any off-grid rest position back onto `2 + row · pitch`.
- **The dependency array that cancelled the tween.** The wheel effect listed `railElRef` — a plain `{ current }` object rebuilt on every parent render — so every parent re-render tore the effect down and its cleanup called `stopTween()` mid-flight. The ref is a real `React.useRef` now and the effect depends only on the card size and gap.
- **Line- and page-mode wheels did nothing or jumped nine rows.** LINE/PAGE deltas are discrete: exactly one event is one detent, whatever its magnitude. Previously a 3-line event (48px) fell below the pixel threshold and a page event (raw `clientHeight`) skipped nine rows.
- **Verified** by `scripts/verify-rail-wheel-matrix.cjs` (single event, a notch split in two, small trackpad ticks, a flick, line mode, page mode, upward flick, long flick to the end) and `scripts/verify-rail-scroll.cjs` (each notch = one pitch, every rest position a whole row, a whole row at the top everywhere, the last row reachable).

### Added — the scroll stops when the components end

- **A threshold instead of an endless tail.** A row may top out only while its own cards still reach into the viewport: `lastRow = ceil((contentBottom + viewportHeight − rowSeat) / pitch) − 1`. Measured: 8 rows, deepest card bottom 1450px, viewport 936px → the deck stops at the 7th detent (1290px) and further notches change nothing (`1290 → 1290`). Before this, four extra detents pulled up nothing but blank space.

### Fixed — the magnification wave always ends when the pointer leaves

- **The safety net was cancelling itself.** A window-level `mousemove` guard had been added to end the wave when the pointer is no longer over a tile — but its effect had no dependency array, so while the wave was live (a re-render per frame) every cleanup cleared the pending release timer. It could never fire. The guard now mounts once and reads its state from refs.
- **"On the widgets" now means ON A TILE, not "inside the rail".** The rail's box is 372 × 936 and mostly empty — its padding, the 24px gaps between cards and every blank run below the last row are all inside it — so the old box test kept the wave alive over gaps, above the first row and right of the last card. Tiles are tested individually with a 7px halo (the gap is 24px, magnified neighbours float ~12px apart), which covers a gap crossing without swallowing the blank areas.
- **Verified on six exit paths** by `scripts/verify-rail-interaction.cjs` — out of the leftmost card, out of the rightmost, down an empty column, down into the band below the deck, above the first row, and a fast diagonal — each one checked with `document.elementFromPoint` so an endpoint that is genuinely over a card is never mistaken for a defect.

## v1.6.0 — the rail obeys the transcript measure, and the panel truly swallows it

> This release ships everything that was tracked as "unreleased" in the previous README — the rail layout rewrite and the measurement/hit-test fixes measured against the live GUI on 2026-09-13 and 2026-09-17 — plus two commits that had been pushed to GitHub but never published (the `context-water` 2×2 slot fix and the website design-audit refactor). It is the first release where the rail derives its own width from the product's transcript measure instead of claiming a fixed 372 px.

### Added — the rail sizes itself from the product's own transcript measure

- **Budget-derived geometry.** The rail may only claim the margin the reading measure leaves over: `budget = official conversation column width − official transcript measure − 74px box inset`. Both numbers are read from the product's own published variables (`--dsh-conversation-column-width`, `--dsh-chat-content-width`), with a live `getBoundingClientRect` read preferred when it exists — the product writes those variables *after* the track transition settles, so a single debounced read can latch a mid-animation value (that is what once drove the budget to 0 and left the rail hidden until the window was resized).
- **A degradation ladder instead of a fixed claim.** Preferred layout → one column fewer → a narrowed single column (side ≥ 100px) → yield the field entirely (`--dsx-rail-w: 0`, nothing rendered, the user's preferences untouched, and the rail returns by itself when the window grows again).
- **The rail can no longer squeeze the reading column.** The inset rule lost its `!important`; because the claim is computed from the measure it can never exceed the leftover margin, so the only possible failure mode is collapsing — never compressing the transcript below the product's `clamp(680px, …, 920px)` floor.
- **The header capsule reports the same state.** With no room it renders `data-space="tight"` (dimmed, `aria-disabled`, `cursor: not-allowed`) instead of openable-but-empty.
- **Measured** (viewport → rail / transcript): 1578 → 372 / 748 px, 1400 → 198 / 748 px, 1280 → 154 / 748 px, 1120 → yielded / 664 px. Every one of those transcript widths is the product's own measure for that column; before this change the rail hard-claimed 372 px and drove the transcript to 554 px (1280) and 394 px (1120).

### Changed — the right panel now swallows the rail instead of the rail fading beside it

- **Layer order was the real problem, not opacity.** Probed with `elementFromPoint` over a fixed test block injected into each candidate host: inside `shell.overlay` the block paints *above* the official panel (z-20 vs the panel's z-10), inside the conversation subtree it paints *below* it. The rail's host moved from `shell.overlay` to `conversation.input.overlay` — the official list slot for "floating entries rendered inside the resident composer card" — so the panel sweeps across the rail and hides it segment by segment, exactly like a drawer being covered.
- **Pinning instead of animating.** While a panel exists (opening, open, or closing) the rail pins to the viewport's right edge via one variable, `--dsx-rail-right`; with no panel that resolves to the same value as the conversation-column anchor, so the switch is invisible. Closing retreats the panel and re-exposes the rail progressively. The variable also drives the magnify overlay and the settings drawer, so no rail-owned surface can stay parked over the panel.
- **Panel presence is read as `max(inline target, measured width)`.** On the first frame of the close animation the frame's inline grid target already reads `0px`; trusting it would snap the rail back under a panel that is still covering it.
- **One curve for track and occupant.** The transcript inset now uses the official `--ds-transition-duration-slow` / `--ds-ease-in-out` tokens instead of `0.2s ease`, matching the track's own animation — the layout package lists "track and occupant share one curve" as an invariant, and 0.2s/ease was visibly out of step with the 0.3s track. The composer-overlay seat (which is positioned against the scroll body's padding box, so it follows the rail's width) is on the same rule.
- **Invisible surfaces stop eating pointer events.** `[data-yielded]` (faded out because the panel owns the space) and `.dsx-stats-drawer[data-no-room]` (faded out with nothing covering it) now set `pointer-events: none`, so a see-through rail can no longer swallow hovers and clicks meant for the transcript or for the product's column-width drag band.

### Changed — native rail follow via CSS anchor positioning

- **The rail rides the product's own layout pass.** `[data-phase='active']` (the conversation host — measured to share the center column's box exactly; the hashed `_centerCol` class remains as a fallback, selected only when that seam is absent, so exactly one element ever owns the name) carries `anchor-name: --dsx-center`, and the rail's `right` is `anchor(--dsx-center right, var(--dsx-rightbar-w, var(--dsh-sidebar-width, 0px)))`. Anchor resolution happens in the same style→layout pass that animates the shell's `grid-template-columns` track. Measured per frame: `|center column right − rail right| = 0.00px` over a full open/close.
- **The JS follow path stopped writing a `:root` variable every frame** (a per-frame root-variable write invalidates style for the whole document); it is now only the fallback for browsers without anchor positioning, and it writes the settled value once, 200ms after the motion ends.
- **The ResizeObserver is re-bound lazily on every measure** (`observeMeasured()`), which is idempotent and self-heals when the shell re-creates its nodes. The old observer was constructed during `apply()` — before the Web UI mounts its frame — so every `querySelector` returned `null` and it observed **zero** elements (verified in-browser: 19 live `ResizeObserver`s on the page, none on `[class$='_rightbarCol']`). That is why the rail used to sit still through the sidebar's 300ms track animation and then jump into place.
- **Start and end with the panel.** The AppFrame's `transitionrun` for `grid-template-columns` plus the frame's inline grid target let the rail predict the final column width on the animation's first frame (the observer's first delivery trails it by ~100ms), so the rail's retreat and the panel's push run on the same beat instead of the rail arriving late.
- **Boot no longer plays the enter animation** when the preference says "open" but the session is not known yet: that first-paint slide had no valid containing block for the anchor (a transformed ancestor), so `anchor()` resolved to nothing and the rail painted at the left edge for ~2.6s before jumping. Restored layouts now appear in place; a user-initiated open still glides.

### Fixed — hovering the rail re-rendered the entire rail on every pointer frame

- **Measured before** (headed Edge, 1600×900): p50 frame 12.5ms, p95 41.8ms, max 129ms, 15 frames > 26ms, 598ms of JS across 2.6s of hovering — against a 4.2ms idle baseline. **After**: p50 4.2ms, p95 8.3ms, 1 frame > 26ms, 193ms of JS, i.e. back at the idle baseline.
- **Root cause**: the magnification wave owned its focus state in the same component that also built the resting deck, the widget list and the always-mounted add panel, so every pointer frame re-rendered every widget's `render()` output and the settings page inside the add panel.
- **Fix**: the wave is now its own `RailWave` component; the deck and the add panel are built by the parent, whose element identity stays stable while the pointer moves, so React bails out of those subtrees entirely (only the overlay's 7–15 slot divs reconcile per frame).
- **Fix**: magnified copies are sized with `transform: scale()` about a `top-right` origin instead of `width`/`height` — the same geometry, verified slot by slot (visual width == `baseW × scale`, right-edge delta 0), with the per-frame cost moved to the compositor.
- **Fix**: the resting deck fades through a `.dsx-wave-deck.dsx-wave-on` class (cards and the add button) instead of an inline per-slot opacity, so engaging the wave no longer re-renders the deck at all.

### Fixed — three symptoms reported against the live GUI (2026-09-17)

Full measurements, raw receipts and probe scripts: [`docs/verify-report3/FIX-2026-09-17.md`](docs/verify-report3/FIX-2026-09-17.md).

- **New conversation left the rail painted over the hero (5/5 reproductions).** The bridge used `useState` + a subscription effect, and the new conversation's `conversation.input.overlay` entry mounts in the same frame window in which the previous session's `conversation.composer.dock` unmounts — if the `hasSession: false` emit landed between "new entry rendered" and "effect flushed", the fresh instance never saw it and kept `hasSession: true` (rail painted, `opacity: 1`, hit-testing, while the module-level subscriber had already removed `body.dsx-stats-active` — two consumers contradicting each other). The bridge is now `React.useSyncExternalStore` (re-reads the snapshot on subscribe, so a missed emit cannot strand an instance), the snapshot is rebuilt per emit (stable reference), and `body.dsx-stats-no-session` sets `visibility: hidden` on the whole drawer so a session hand-off hides instantly (a user-initiated close still glides). Measured: before, 5/5 runs left the rail on the page (observed up to 6s, still intercepting pointer events); after, 5/5 non-interactive within ~300ms and unmounted within 1.5s.
- **The turn navigator vanished under the rail.** The product hides its own navigator with a container query on the transcript scroller (`@container (width <= 900px)`) — the very box the rail's yield narrows. This is a real, accepted cost of the space contract: with the rail open at ≤1600px the scroller drops below 900px and DSH hides its own navigator. Keeping both (a 2-column rail *and* an always-visible navigator) would require narrowing only the message content and not the scroll box — a structural change deliberately not made in this release.
- **Hovering the navigator showed the column-resize cursor and started a drag.** The product paints the invisible 40px column-width drag band at `z-index: 8` and the navigator's sticky slot at `7`, so wherever they overlap the band wins the hit test — this reproduces with the rail closed, because the product's own `CONTENT_EDGE_BUDGET` (176px per side) reserves that gutter for the band and not for the navigator. The fix corrects the order, not the geometry: `[data-conversation-scroll] :has(> nav[aria-label]) { z-index: 9 }` — addressed by the `nav` it contains rather than a hashed class, so a product rebuild cannot silently drop the rule. Verified at the overlapping geometry (1600px viewport, 1200px transcript): hover resolves to the navigator (`isNav: true`), a click no longer starts a width drag (`width-drag started=false`), and the rail's own sizes are unchanged (1440 → 198, 1600 → 372, 1760+ → 372).

### Fixed — small text no longer inherits the transcript's typography, and date labels no longer wrap

- **Typography.** The rail now lives inside the conversation subtree, so it inherited the transcript's 14px/24px; card rules that only declare a `font-size` (the 10–11px grey legends, the date labels) then got a 24px line box and blew up the card's text height. `.dsx-stats-rail` now pins the app-level typography (`16px` / `line-height: normal`, both measured on the app root); card rules that declare their own line-height still win.
- **`heatmap-bars` date labels.** "9.11" is 17px wide at 9px type, and the 7-column layout leaves each column ~14px, so the first/last date wrapped onto two lines, pushed the bars up and overflowed the 150px card (second line clipped by the card's `overflow: hidden`). The labeled columns, the line chart's corner labels and the heatmap's corner dates now carry an explicit `white-space: nowrap`; the labelled columns are the outermost ones, so the ~3px spill is symmetric and stays inside the card padding. Verified against the real DOM: 17×10px single line (was 14×15/14×18px two lines), seven label children with `scrollWidth == rect width == 17`, card height back to 150px.

### Fixed — `context-water` 2×2 no longer bursts its 150px slot

- The segments chart is the only chart whose body is three stacked legend rows, so it is what sizes the card. With the (now explicitly pinned) `normal` line-height the legend line box resolved to 16px — 20px per row — so the bar plus rows needed 85px where the slot leaves 79px, and the card grew to ~155–156px and overlapped the row below it.
- The legend rows now carry an explicit `1.2` line-height (font-stack independent) and the segmented bar's bottom gap drops from 10px to 6px (top gap stays 8px). Measured over CDP in Edge: card 150px (was 156px), chart 76.17px against 79px available.

### Fixed — the Components capsule kept its place after a bundle reload

- Placement in the header utility list is decided by `order` alone (registration sequence only breaks a tie), and `dsh-better-sidebar` registers its bottom-panel toggle at `order: 10`. Sharing 10 tied the two entries, so the capsule's position followed whichever fiber re-registered last — a tsdown/HMR reload or a market toggle pushed it past the toggle to the row's right end. The capsule now registers at `order: 5`: right of the official export control (0) and open-in-app (−10), left of that toggle, independent of load order.

### Website — from component gallery to design-system site

The showcase (`website/`, live at <https://physicolor.github.io/dsh-widgets/>) is no longer a gallery of cards. Full description: [`website/README.md`](website/README.md).

- **Design philosophy**, six principles, each bound to a real widget and a real geometry value from the source.
- **DSH Widget Design Grammar** — the real constants (`cardSide 150`, `panelPadding 24`, `magnify 1.2`, `scale = unit/150`, `innerPad round(12*scale)`, `radius 16`, type `13/20/10`, foot gap 6, corner `8*scale`, `stepScale peak/r3/t^1.6`) plus an interactive rail that runs the plugin's own magnification curve and right-anchored reflow; the sliders are the product's own settings.
- **Widget Anatomy** — a real card at a fitted unit, with padding, radius, type scale, title→content step and action inset annotated from measured DOM rectangles (the label set is reduced on narrow stages).
- **DSH Visual Audit** — 13 rules in two explicit layers: geometric measurement via `getBoundingClientRect` on the rendered cards, and declared design heuristics, scored as `round(100 * sum(w)/n)` over all 33 widgets, each measured at the size it ships in. The exception list reports the real findings (`context-water` +4px, `sys-gpu-line` +29px and `cc-credits` +33px past the magnified unit, the `cc-window-monthly` copy/implementation mismatch, the `sys-board` 2×4-only size contract). No claim of a beauty metric.
- **Component detail** — a per-widget dialog with both size variants, measured geometry, the rule results and the source entry.
- **Discoverability** — canonical, Open Graph, Twitter card and a generated OG image; JSON-LD (`WebSite` + `SoftwareApplication` + an `ItemList` of all 33 widgets); `robots.txt` + `sitemap.xml`; a title/description that names the project, DeepSeek Harness and the design-system framing. The gallery markup, `js/data.js` and the `ItemList` are now generated from the manifests by `website/gen-site.mjs` (`--check` wired into `verify`), so widget names/ids/categories/sizes/descriptions are present in the HTML source for crawlers and no-JS readers. The temporary `website/sync-data.mjs` (hardcoded path) is gone.
- **Page fixes found while auditing** — grid tracks can no longer be widened by content (a 2×4 preview used to blow out the 4th gallery column and made every 4th card wider than its row), the hero wordmark no longer out-weighs the widget array, and widget units are previewed at their natural size.
- **Verify**: 80/80 checks (static, SEO surface, crawlability, grammar math, anatomy measurement, audit, dialog, i18n, responsive, console/network) plus section screenshots.

### Known costs and assumptions (accepted)

- With the rail open at ≤1600px, the yield pushes the transcript scroller below the product's `900px` container query, so DSH hides its own turn navigator (see above).
- The rail's follow strategy prefers the official `data-phase` seam and falls back to the hashed `_centerCol` class; a future shell that renames both would need a new seam.
- `body.dsx-hide-statsline` still hides the whole composer-dock seat rather than the official `stats` entry; the rail does not yet suppress its transition while the official right bar is being dragged.

## v1.5.0

> Everything since v1.4.1 ships at once: the Command Code account widget family and its host aggregate route, DeepSeek Harness 0.1.5 compatibility and official right-bar adaptation, the new **Quota Manager** widget, the daily-usage freshness fix, and a unified text-level alert. The `###` sections below shipped in this release too (they were tracked as `Working tree (unreleased)` in the previous README).

**New — Quota Manager (2×2, Coding Plan group):**

- 📊 **Monthly-window layout**: title left, **projected month-end percent top-right**, the billing period on the second line (``Ends 10-10``), and two figures at the bottom: **today's usage** vs **today's budget** (the remaining balance split over the days left).
- 📈 **Projection**: `used% + recent pace × days left`, the pace taken over the last **3 day-equivalents** (the two previous whole days plus today prorated; today only counts after 6 h). This makes the projection and the budget **the same statement**: projected > 100% ⇔ pace > budget.
- 🔴 **Past 100%**: the figure turns red and breathes (1.6 s) — the old card-wide glow is gone.
- 🧮 **Caliber**: the balance is converted at the period's realised local-tokens-per-credit rate, matching the daily log; the provider's token counter is not mixed in.
- 🚫 **No invented numbers**: missing percentage/allowance, an ended period or <6 h elapsed renders "insufficient data"; a missing rate side degrades only today's budget to `—`.
- 🧩 New `figures` chart kind (a row of label/value pairs: first flush left, last flush right, sharing the head row's gutters).

**Fixed — daily-usage freshness:**

- ⚡ **Today's figure now moves with every turn**: the day map comes from usage-center's log fold, which rescans on its own ~30 s cadence, so a finished turn kept showing the previous figure (measured: the index lagged an independent log fold by ~0.4M at the same instant). The client now keeps its per-step local counter running and takes `max(index, local)` for **today** only — history stays purely authoritative, because this browser only ever sees the sessions it had open.
- 🔄 The host route `/api/widgets-usage-daily?refresh=1` asks usage-center to rescan immediately when a turn settles (5 s throttle, fully optional). The client half works after a page refresh; the parameter is host code and needs a dsh web restart.

**Fixed — Command Code monthly-window denominator:**

- 🧾 The monthly window divided by `used / (used + remaining)` — the sum of two unrelated snapshots (measured 17.26 + 59.01 = 76.27 on a $70 plan), so the card read 22.6% where the account page read ~16%. It is now `(allowance − remaining) / allowance` with the published plan table (GOAT = $70); an unknown plan falls back to the API's own used figure.

**Changed — one text-level alert:**

- 🔔 The whole card-glow mechanism was removed (`alert` field + `.dsx-peak-alert` + keyframes). Peak pricing's **EXPENSIVE** and Quota Manager's **over-100%** now turn the figure red and breathe (1.6 s, static under `prefers-reduced-motion`); the card frame never changes.

**Layout — the card head row was restructured:**

- 📐 The head row is now **two independent, top-aligned slots** (title left, value right). Previously the title shared one baseline with a 20px value, which stretched the line box and pushed the 13px title ~5px lower than on every card without a head value; the right slot now cancels its own extra line height with a negative bottom margin, so the period line hugs the title. Measured: the quota card's title top matches a plain card (11px), and `context-water@2×4` is back to a 16px head row.

**Showcase website:**

- 🌐 The gallery is complete at **33 widgets** (it had stalled at 24, missing the whole 8-widget Command Code family), with new Command Code and Device filters and matching Chinese/English copy. The widget table is generated from the manifests by `website/gen-site.mjs`. Headless site verification passes 80/80.

**Compatibility — DSH 0.1.5 session-snapshot split:**

- 🧩 **Data source migration (skipping it silently loses every card's data)**: 0.1.5 split the session snapshot in two — `useSession` keeps lifecycle state (`running`, …) while chat data (`nodes` / `timeline` / `runningCalls`) moved to the new `useChat` hook. The composer-dock collector read `useSession(s => s.chat.legacy.nodes)`; on 0.1.5 that selector throws during render, the slot error boundary abdicates the entry, and the whole rail loses its data (and disappears, because `hasSession` never flips). The collector now prefers `useChat` and falls back to `useSession`, with optional-chained selectors so a slice that no longer exists resolves to `undefined` instead of throwing.
- 🔌 **Types and packaging follow the official layout**: `@deepseek-ai/dsh-client-runtime` was retired in 0.1.5, so the client context type now comes from `@deepseek-ai/cordis`'s `Context`; the package was dropped from peer/dev dependencies and from the tsdown platform module table.
- 🔍 **Audit result (no code change needed)**: all four slots (`shell.overlay` / `conversation.composer.dock` / `conversation.session.header.utilities` / `settings.section`), the `register({name,id,order,label})` options, the `__ModuleLoader__` client bundle protocol, the five projection keys, the six host routes and the `locale` usage are unchanged in 0.1.5; the official stats row still registers under the `stats` entry id.
- ✅ **Measured**: in an isolated 0.1.5-rc.2 instance the client bundle loads alongside the other 62 entries with no exception.
- 🧭 **Official right-bar adaptation (added 2026-09-13)**: DSH 0.1.5 makes the right side a third grid column (`[class$='_rightbarCol']`, occupied by `dsh-client-ui-sidebar-right`), while the rail anchored on `--dsh-sidebar-width` — a variable published by better-sidebar ≤0.14 that 0.19 stopped setting once it moved to the official `ctx.sidebarRight`. The rail therefore fell back to `right: 0` and painted straight over the panel. The rail and the add panel now anchor on a new `--dsx-rightbar-w` (measured from the `_rightbarCol` width, kept in step by a ResizeObserver across open/close/fullscreen/drag), with the old variable kept as fallback so both assemblies yield correctly.
- 📌 Known follow-ups: `body.dsx-hide-statsline` still hides the whole composer-dock seat and could be narrowed to the official `stats` entry; the rail does not yet suppress its transition while the official right bar is being dragged.

### Heatmap token totals now come from the usage-center caliber

> Fix: the Token Heatmap card's "window total" had long disagreed with the usage center's total tokens. Measured 2026-09-12: the card read **7.72G** against a real **6.28G** (+23%).

**Fix — the heatmap's daily source becomes the authoritative caliber (reusing dsh-usage-center, no second implementation):**

- 🔍 Root cause (three layers, all on the dsh-widgets side): ① the old `seedHeatmapIfNeeded` / the later `HEATMAP_RECOVERED` seeded **invented constants** into `localStorage` as if they were history (2026-08-14/15/16 held 244.19M / 1639.55M / 1319.26M where the logs say 75.24M / 373.37M / 1204.72M); ② the migration only "filled zeros, never overwrote", so a wrong value stayed forever; ③ per-step live accounting ran only while a browser was open AND that session had been opened, so idle days went unrecorded entirely (8/18 was short by 190.5M).
- 🧮 Arbitration evidence: the independent recount script `docs/verify-token-total-independent.mjs` decompresses all 175 `session.jsonl.zstd` files (frame-by-frame zstd, the LAST usage report per (turn, step), four buckets summed, bucketed into days in local time): **6,275,649,773**; the usage-center index caliber **6,276,176,394** (0.01% apart); the old heatmap **7,715,756,948** (+23%). Day-by-day comparison in `docs/compare-token-accounts.mjs`.
- 🔗 The fix (**no rewritten arithmetic**): a new host route `/api/widgets-usage-daily` calls dsh-usage-center's `getActivity()` through the Cordis service `ctx.get('usageCenter')` when it is installed (that method already folds the session logs into per-day totals) and forwards `activity[].totalTokens` unchanged; not installed / an empty index / a throwing service all return `available:false`. `usageCenter` is **not a hard dependency** (`inject` unchanged), so dsh-widgets still installs standalone.
- 🖥 client: `state.usageDaily` is the **preferred** daily source (on mount + after every turn + a 60 s poll), and while authoritative data exists the local accounting is **skipped entirely**; when it is missing, the old per-step accounting takes over. The heatmap and the heatmap-bars cards share that caliber, so their numbers match the usage center day for day.
- 🧹 Cleanup: the baked-in history constants and `migrateHeatmapV2()` (including its "only fill zeros" trap) are gone, replaced by a one-shot `loadHeatmapStore()` that removes the 8 fabricated days (2026-08-14…08-21) and **leaves every genuinely accumulated day alone**. The fallback path prefers empty over fabricated.
- ✅ Verified: `docs/verify-usage-daily-route.mjs` drives the **built artifact** `lib/index.js`; 10 assertions all PASS (service present → pass-through, service absent → `available:false`, empty payload / throw / half-row → degrade without crashing).
- ⚠️ A `dsh web` restart is needed before the new host route loads (until then it 404s, the cards fall back to the old accounting, and nothing errors).

### Command Code monthly window + three single-window figure widgets + title standardisation

> Following the previous batch: monthly usage is new, the 5h / weekly / monthly single-window figure widgets are split out, and every card title becomes a short title plus a grey role word.

**Feat — the Command Code window family grows:**

- 🌙 **Monthly usage (new)**: `billing/credits` only exposes hard caps for 5h and weekly — **there is no monthly window object** — so the monthly window is derived by conservation: `used = usage.totalMonthlyCredits` (this period's monthly credits spent), `cap = used + credits.monthlyCredits` (used + remaining = the plan's allowance; measured 0.47 + 69.16 ≈ 69.63 ≈ a $70 plan), percent = used / cap, and the reset time is the subscription's `currentPeriodEnd`. Either half missing degrades to a placeholder — a number is never invented.
- 🍩 **cc-windows: two rings → three**: the 5h / weekly / monthly window rings (matching OpenCode's rolling / weekly / monthly shape).
- 🔤 **The rings' numbers now stand alone**: the three rings no longer carry a grey `5h window / Week window / Month window` label — the rings themselves are the window set (fixed order 5h → Week → Month) and each number stands by itself; the window name moved into the hover tooltip (the same convention as OpenCode's usage-rings). Ring numbers keep **one decimal** (1.8%, 10.0%) instead of rounding to a whole number. `WidgetChart.rings` gained two optional fields accordingly: `decimals` (precision, default 0 — the other ring charts are unaffected) and `name` (the tooltip-only name).
- 🔢 **Three new single-window figure widgets** (matching the OpenCode single-window card shape: one big percentage + the reset date):
  - `cc-window-5h`: 5-hour window usage percent + reset date;
  - `cc-window-weekly`: weekly window usage percent + reset date;
  - `cc-window-monthly`: monthly (billing-period) usage percent + period end.

**Polish — card titles standardised (all 8 members of the family):**

- 📏 The product name is too long, so the title is always `Command Code` and the role word moved into a **grey legend line under the title**: cc-whoami "Account", cc-usage "Usage", cc-credits "Credits", cc-windows "Windows", cc-subscription "Plan", and the three figure cards "5h window / Week window / Month window".
- 🏷 The component names in the market / config lists shortened to the same role words (Account / Usage / Credits / Windows / Plan / 5h window / Week window / Month window); the group name stays Command Code and no longer repeats the long prefix.
- ✅ Verified: a new self-contained probe `docs/probe-cc-render.cjs` (compiles the real render layer + pulls real account data for all 8 cards) — **21 assertions all PASS**: every title is `Command Code`, the role word lands in the legend, cc-windows has three rings **with no grey label** (empty label, the window name only in `name`/tooltip), ring values keep one decimal, all three figure cards keep one decimal and carry a reset row, and the monthly caliber = used/(used+remaining). The static script `docs/verify-commandcode.cjs` grew to 8 components plus the shared title keys (20 PASS, including a live 200).

### Command Code widget family + the auto-read fix

> Follow-up to the Command Code batch above: the misleading "COMMANDCODE_API_KEY not configured" copy is fixed — the key is read entirely by the host, so the user neither needs nor should type anything.

**Fix — the Command Code key auto-read chain + the status copy:**

- 🔍 Root cause: the widget said "not configured" because the host route `/api/commandcode-usage` had not loaded yet (dsh web not restarted → 404) and a failed client fetch all fell into the same "not configured" copy — **nothing to do with the key**; `COMMANDCODE_API_KEY` was in `.credentials.yaml` the whole time.
- 🧬 The key-resolution chain, verified (credentials-local): `resolve` reads process env → `$DSH_HOME/.credentials.yaml` (version+refs layout) → `$DSH_HOME/.env`, three levels, **never into the browser and never typed into a widget or a setting**. Measured: no such key in env/.env, the yaml hit (tail WWna), and all four official endpoints returned 200.
- 🩹 The fix (two layers):
  1. **Error states are passed through**: when the client fetches `/api/commandcode-usage` it now distinguishes 404 (host not restarted → `unloaded`) / 503 (genuinely no key → `unconfigured`) / a network failure (`unavailable`) and stores it in `stats.commandCodeError`;
  2. **Accurate copy**: each widget's "not configured" became a state-specific message — "dsh web has not been restarted, waiting for the host route (auto-refreshes after a restart)" / "COMMANDCODE_API_KEY not configured — the host auto-reads env / .credentials.yaml / .env; effective after a restart".
- ✅ Verified: `docs/verify-commandcode.cjs` static 13 PASS; live 404 is the expected shape (it turns PASS after a restart and leaves the receipt automatically).

### Command Code account-usage widget family

> This is the Command Code widget batch, independent of the GPU height fix above (both were kept in the working tree). Five new 2×2 widgets reusing the OpenCode usage widgets' host-proxy pattern.

**Feat — the Command Code account-usage family (cc-whoami / cc-usage / cc-credits / cc-windows / cc-subscription):**

- 🔌 **New host route `/api/commandcode-usage`**: aggregates the four **official Command Code account endpoints** (`/alpha/whoami`, `/alpha/usage/summary`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`) with the credential resolved through `credentials.resolve('COMMANDCODE_API_KEY')` (the same seam as OpenCode, never leaving the host); each endpoint is fetched independently with its own 8 s timeout, so one failure nulls only that slice while the rest still renders.
- 🧩 **Five new 2×2 widgets** (group `commandcode`, sharing the render layer `src/client/lib/cc-view.ts`, defensive parsing: null → `—`):
  - `cc-whoami`: the current account identity (user / email / org);
  - `cc-usage`: a requests / success rate / total tokens / spend (`$`) summary;
  - `cc-credits`: the Credits balance (monthly / free / purchased) + the 5h / weekly usage bars (colour follows the level: danger/warn/success);
  - `cc-windows`: the 5h / weekly window usage rings (OpenCode usage-rings' three-ring style, two-ring variant);
  - `cc-subscription`: plan (planId), status, billing-period end, and whether it cancels at period end.
- 🎨 i18n: `_shared/locales.json` gained `badge.commandcode` / `group.commandcode` / `cc.*` — 12 keys × 2 languages; the market group shows "Command Code".
- 🖼 Preview data: `PREVIEW_STATS` gained a `commandCode` simulated payload, so the market / config previews are not blank.
- ✅ The four endpoints were measured against real accounts (whoami=Physicolor, plan=individual-goat, 5h cap=14 / weekly cap=35, response shape matching the docs); the build passes (registry 29 components, tsdown node 11.3kB / client 204.7kB).

### GPU utilization card height fix

> A local fix, not yet version-bumped or released. Rollback baseline: `git HEAD 2fdacf8` (v1.4.1); the disk snapshot is `docs/backup/2026-08-31-gpu-line-height/`.

**Fix — sys-gpu-line's card height blew up (~176px) on hover:**

- 🐛 Root cause: the GPU utilization card's **intrinsic content height was ≈178px** (bottom-left big number + sub + a fixed 68px sparkline + the title row), over the 2×2 card's 150px box — the card only sets `minHeight`, so content decided the height and even at rest it overflowed the slot by 28px; magnified (1.25) it got worse (overlay card 212px vs slot 186px), which is the "the card suddenly grows to ~176 on hover" that was reported.
- 🩹 The fix (two layers):
  1. **Elastic sparkline**: the line chart became a `flex:1` + `height:100%` container, and a card using `stretchChart` (line-chart cards only) fixes `height: unit`, so the sparkline eats the remaining space — content can never exceed the box, at any cardSide or magnification factor.
  2. `stretchChart`'s declaration position (TDZ): `body.push` evaluates immediately, so the declaration has to come before its use (the first build threw `Cannot access 'stretchChart' before initialization`; fixed and re-verified).
- ✅ Measured (real :3080 page + playwright, `docs/probe-gpu-height.cjs`, this machine: cardSide=150 / magnify=1.25 / realtime):
  - at rest: GPU card slot 150 = card 150 (was 178); the other 10+ cards are all 150.
  - hovered: overlay slot 186 = card 186 (was 212); the sweep decays smoothly 185→177 and goes out on exit.
  - the settings preview (same CardBody path): 150×150 (was 234).
  - `CONSOLE_ERRORS: []`, and the `verify-*` regressions are unaffected (no other chart branch or registration contract was touched).
- 📝 Known edge: `Context Used` (context-water) is cardH=156 at rest (6px over — little content, visually harmless, not part of this report); the line branch's comments were updated with it.

## v1.4.1

> This release ships the whole working tree: the **System monitor family** (below), the **rail drawer animation**, and the **usage decimal fix** — all previously unreleased work (logs under the old `v1.4.2` / `v1.5.0 working tree` headers).

**Fix — sparkline↔time-label spacing:**

- 📏 The GPU utilization sparkline now keeps a **3px** gap between the chart area and its bottom time labels — identical to the barsV bar→date-label spacing (outer 4px lead-in unchanged).

**Fix — sys-gpu-line stuck on "Waiting for device data":**

- 🐛 The sparkline read the host's `history` ring buffer — a field only the NEWEST host build serves. A host that was restarted before that field landed (or not restarted since) never returns it, so the card waited forever.
- 🩹 The collector now ingests every successful poll into a **client-side fallback history** (module ring buffer, ≤120 samples): the sparkline works on ANY host from the moment the page loads — curve appears after the second poll. When the host is restarted, its (longer, reload-surviving) history takes precedence automatically.
- 📏 **Sparkline sample window** (Config): 10 / 15 / 20 / 25 / 30 points, default **20** — only the most recent N samples are drawn, so the line never compresses into a blob no matter how long the host has been sampling.
- ✅ `verify-sysinfo.mjs` (12/12) + `verify-usage-guard.mjs` (5/5) stay green.

**Polish — ring-to-caption spacing:**

- 📏 All ring charts (usage-rings 3-ring, CPU·GPU twin rings, the 2×4 board) now keep a **4px** gap between the ring and its caption row — the same rhythm as bar→label in the bars charts. The old 2px glued the percent to the ring; the breathing room matters most on the 2×4 board's small rings.

**Hotfix — one crashing widget took the WHOLE rail down (P1):**

- 🐛 A malformed OpenCode usage payload (one window missing/null, e.g. an upstream partial response) made `usage-rings`/`usage-bars` throw on `u.rolling.percent`; the uncaught render error killed the entire `shell.overlay` slot entry — every widget disappeared until the next hard refresh ("only visible briefly after refresh").
- 🛡️ **Defense 1 — data layer**: every usage window now reads through `winPct()` (missing / null / non-numeric → the card degrades to a `—` placeholder, never throws); `usageRender` got the same guard.
- 🛡️ **Defense 2 — render layer**: the rail wraps EVERY card render in try/catch — a crashing widget renders as a `Render error` placeholder card while the rest of the rail stays alive; the same isolation covers the config/market previews. A single bad widget can no longer hide the rail, ever.
- ✅ New regression probe `docs/verify-usage-guard.mjs` asserts both defenses exist in the built bundle (5/5); `verify-sysinfo.mjs` stays 12/12.

**Polish — system widgets round 3 (layouts, big-figure switch, sparkline):**

- 🧹 **sys-board**: the `0/0 GB` sub line is gone (ambiguous); the GPU model + temperature stay at the title row's right end. Ring labels now share ONE row with their percent (`43% CPU`) — the 2×4 board has room for names horizontally.
- 🚫 **usage-rings**: the rolling/week/month label line under the rings is removed (user preference) — rings show just the percent again, names surface on hover.
- 🔄 **sys-gpu / sys-cpu big-figure switch**: clicking the card cycles the big number (GPU: VRAM → temp → utilization; CPU: utilization → used memory) and the same selection is available as a config dropdown ("Big figure"). The cycle persists per-instance via a new `cycle.store` field (`bigMetric`), so sys cycles never collide with the usage pool view nor fire multikey `prefer` calls.
- 📈 **New `sys-gpu-line` widget** (2×2): Windows-task-manager style GPU utilization sparkline — filled area + polyline, same 7-row footprint/paddings as the barsV chart, time labels on the bottom corners. The host `/api/sysinfo` now returns a rolling `history` ring buffer (≤120 samples of cpu/gpu utilization).
- ✅ Verification grew to 12 checks (history arrays parallel, first cpu sample null, gpu entries bounded); live probe unaffected.

**Fix — sys-board rendered as 2×2 (sizes double-source mismatch):**

- 🐛 `sys-board` declared `2×4` in its `manifest.json`, but the runtime reads the DESCRIPTOR's `sizes` — which was missing, so `sizesOf()` fell back to `['2x2']`; the market listed and the rail rendered a bogus 2×2 instance. Fixed by adding `sizes: ['2x4']` to the descriptor; persisted `sys-board@2x2` instances auto-migrate to `sys-board@2x4` on load.
- 🛡️ New build-time guard in `gen-registry.mjs`: the manifest sizes and the descriptor `sizes` literal must now agree (descriptor default = 2×2), so this class of drift fails the build instead of shipping.
- 🔌 Live probe `docs/probe-sysinfo-live.cjs` relaxed: on a live host the first request usually already has a delta baseline (the browser collector polls continuously), so it accepts either `null` (fresh host) or a numeric util; the pristine first-sample-null assertion stays in `docs/verify-sysinfo.mjs`.

**New — System monitor family (local hardware widgets):**

- 🖥️ **Four new widgets** reading the MACHINE's hardware through a new host route `/api/sysinfo` (CPU utilization = delta of `os.cpus()` totals across two polls, memory = `os.totalmem/freemem`, GPU = one `nvidia-smi` query): `sys-cpu` (CPU % big number + memory line), `sys-gpu` (VRAM big number + utilization/temperature — no model name, the value stays the bottom-left figure), `sys-rings` (CPU / GPU utilization twin donuts) and `sys-board` (2×4 dashboard: CPU / memory / GPU utilization / VRAM rings + the short GPU model in the title row's right end). All four sit in their own `device` marketplace group ("Device" — distinct from the harness-system group); shared family logic lives in `src/client/lib/sys-view.ts`.
- ⏱️ **Per-widget refresh interval** (Config): 5 / 10 / 30 / 60 s presets + a custom numeric field, default 10 s. The collector polls at the SHORTEST interval among installed sys-* instances (clamped 5–60); the host caches ~1 s so widgets sharing one tick still trigger a single `nvidia-smi` spawn.
- 🚫 **CPU temperature deliberately absent** — researched, then abandoned: Windows exposes no reliable, privilege-free CPU temperature source (WMI thermal zones are unavailable on most boards — verified on the dev machine; LibreHardwareMonitor would be an external runtime dependency). GPU temperature comes from `nvidia-smi` and works out of the box; widgets degrade gracefully ("No NVIDIA GPU detected") without one.
- 🎨 Ring charts now render their label under the percent (9px tertiary, ellipsized) — the usage-rings cards gain their window names (Rolling / Week / Month) in the same stroke.
- 🗂️ The system widgets form their own marketplace group `device` ("Device") instead of riding the harness `system` group, which is about DeepSeek Harness internals, not the machine.
- ✅ Self-contained verification `docs/verify-sysinfo.mjs` drives the REAL host route with a mocked webServer (no running DSH needed): payload shape, first-sample `cpu.util: null`, ~1 s cache hit, delta utilization on the second window, disposer cleanliness — 9/9 green on the dev machine.
- 🔌 `docs/probe-sysinfo-live.cjs` checks the RUNNING DSH service instead (bundle coherence + live `/api/sysinfo` 200 + delta utilization). While the old host process is still up it fails with 404 — the expected "restart the web host" evidence when cards stay on "Waiting for device data".

**Also in v1.4.1 — rail drawer open/close animation (matching dsh-better-sidebar's slide language):**

- 🎬 Opening glides the widget rail in from the **right** (`translateX(+railW)` → 0, moving leftwards into its resting slot); closing is the reverse (0 → `translateX(+railW)`, sliding out to the right), using the same `--ds-transition-duration-slow` + `--ds-ease-in-out` tokens as the sidebar panels. The rail, the magnify overlay, and the add panel move as **one surface** via a `position:fixed inset:0` wrapper (a transformed fixed ancestor becomes the children's containing block, but the wrapper spans the viewport so every child's coordinates stay identical).
- 🔁 CSS transitions interrupt natively: a rapid open→close→open re-toggle animates from the current intermediate geometry straight to the new target — no snap, no desync. The rail unmounts only after the closing slide finishes (`prefers-reduced-motion` closes instantly).
- 🎯 Travel distance is the rail's own pixel width (+24px margin), **not** a percentage — `translateX(%)` on a full-viewport wrapper resolves against the whole viewport width and would slide a screen-width over the same 0.3s (far too fast).
- 🖱️ The add panel (market / config / settings overlay) explicitly re-enables `pointer-events: auto`: the drawer wrapper is click-transparent, and without the opt-in the panel was unhit-testable — clicks fell through to the rail's cards.
- ✅ New self-contained verification `docs/verify-rail-drawer.cjs` (playwright-core + headless Chromium against the live host): open glides in from the right with 60+ intermediate frames, close slides past the viewport then unmounts, interrupt (rapid open→close→open) never unmounts and has zero hard step jumps, plus add-panel / card-render / hover smoke and a mid-slide screenshot; `docs/verify-ui2.cjs` and `docs/smoke-widgets.cjs` regressions stay green.

**Also in v1.4.1 — usage decimal fix:**

- **Fix** — the Rolling / Weekly / Monthly Usage cards keep ONE decimal on their percentage (e.g. 42% → 42.5%), matching the OpenCode site; the Usage Bars and Usage Rings number formats are unchanged.

## v1.4.0 (project website — first public release)

- 🚀 **Published as v1.4.0** — `dsh-widgets@1.4.0` is live on GitHub and npm. The project website is now public at `https://physicolor.github.io/dsh-widgets/` via GitHub Pages. No plugin code changes; version bumped solely to ship the website.
- 🌐 **Project website / widget showcase** added in [`website/`](website/) — a single-page static site (HTML + CSS + vanilla JS, no framework): hero with a 3-row widget-rail animation, one-command install terminal with copy, gallery of **all 19 real widgets** (filter by 5 real categories), design-philosophy good-vs-bad comparison, five-step how-to-create section + requirement form to widget-spec generator, and a slim contribution section with docs/issues links.
- ✅ **Self-contained verification** [`website/verify.mjs`](website/verify.mjs): JS/CSS/HTML syntax checks + Edge-headless CDP render — 44/44 checks green across desktop (1440/1920), tablet, and mobile viewports, including default light theme, default Chinese language, full zh↔EN toggle, dark theme persistence, real-widget token checks, i18n key completeness, liquid-glass sheen, and console/network monitoring.
- 🎨 **Widget previews are the real widget rendering** — `previews.js` ports the plugin's own `PREVIEW_STATS`, `format.ts` helpers, per-unit `render()`, and the `CardBody` / `ChartBlock` scale formula 1:1; colors are the real DSH tokens extracted from the live UI (`deepseek-500` light / `deepseek-400` dark). No invented design system; no plugin code touched.
- 🖼️ **Hero = a ~1150px first screen**: left story column (title / bilingual description / CTA / stats), right = a real widget array (plugin grid rules: `cardSide 150`, `panelPadding 24`, 2-col rail width 372px, 2×4 wide = 324px); install terminal as the hero's footer.
- 🪷 **Liquid-glass navigation** — real refraction + sheen: `backdrop-filter` blur+saturate + diagonal highlight (`::before`) + slowly drifting light band (`::after`, `nav-sheen` 11s). Only decorative layers move; text and icons stay stable.
- 🧹 **Playground and Demo removed** — the website now has five sections (Hero, Widgets, Design, Create, Contribute). All widget display is unified through the single `DASH_PREVIEWS.render()` adapter; no "simulated" labels remain.
- 🌏 **Full Chinese / English bilingual** — first visit defaults to Chinese + Light; a nav toggle switches the entire site through one dictionary (`i18n.js`). Dark stays opt-in and persisted.
- 🧮 **Hero stat numbers** use HarmonyOS Sans (not monospace); `tabular-nums` kept for alignment.
- 🧾 **Footer simplified** to a single row (brand + links only).
- 🔧 **Bug fix**: usage-bars chart labels (x-axis) no longer overflow the chart container — chart wrapper now sizes to content, matching the real `ChartBlock`.
- 🔗 Zero plugin code touched — no build/registry impact.

## v1.3.0
**Architecture — widget units + build-time discovery (ARCH-001: widget unitization, contract, low-conflict registry, multi-agent isolation):**

- 🧱 **Every widget is now an independent unit** under `src/widgets/<id>/` (`manifest.json` + `index.ts`). The old monolith `src/client/widgets.ts` (all 19 widgets + all renders + the hand-maintained `WIDGETS` array) is gone; i18n strings for widgets moved out of the shared `i18n.ts` dictionary into each unit's manifest.
- 🔎 **The registry is generated, not edited**: `scripts/gen-registry.mjs` scans the unit dirs at build time and emits `src/client/generated.registry.ts` (`WIDGETS` / `ALL_IDS` / `ALL_INSTANCES` / `STATS_WIDGET_IDS` / `DEFAULT_INSTALLED` / merged `WIDGET_LOCALES`) with a three-way id consistency check (dir name === manifest.id === index.ts id literal). `pnpm build` regenerates first; `pnpm check:registry` (and `pnpm check`) fails loudly when the registry is stale.
- 🤖 **Parallel-agent safe**: creating Widget A never requires editing Widget B's files or any central registry — a worker touches only its own unit dir; registration follows automatically at build. Verified end-to-end with two concurrent worker agents creating TEST-A/TEST-B units (parallel-creation test, units removed after the proof).
- 🧩 **Shared layer split** (`src/client/lib/`): `contract.ts` (Widget contract + resolvers), `format.ts` (pure formatters / grid builders), `usage-view.ts` (OpenCode usage family renders), `heatmap-accounting.ts` (heatmap self-accounting moved verbatim out of the shell entry).
- 🌐 **Per-widget i18n**: widget strings live in each unit's `manifest.json` (usage-family strings once in `src/widgets/_shared/locales.json`); the shell dictionary keeps only shell UI. Merge + registration happen at apply() time via `WIDGET_LOCALES`.
- 🖼️ **Preview mock (Example) is widget-owned**: the market/config preview quirks that used to be hard-coded in `components.tsx` (heatmap window-aligned grid, quote placeholder, peak-pricing sim base) now live in each unit's `example` field; the shell applies it generically. New widgets with custom preview data no longer touch shared code.
- 🗂️ **Template**: `src/widgets-template/` holds the skeleton + contract guide; physically outside the discovery root, so the template can never be registered.
- 🎨 Per-widget CSS is now safe: `tsdown` CSS-module tag ids use the src-relative path instead of the bare basename (two units shipping `index.module.css` no longer collide).
- 🏷️ Market group labels are dictionary-driven (`group.<group-id>`, fallback to the widget name) instead of a hard-coded map.

## v1.2.4
**Fix — rail open/close glide returns to main-thread `right` transition for perfect lockstep (with dsh-ui-harmonizer v0.8.3):**

- 🤝 v1.2.3 moved the rail glide to a compositor transform (zero dropped frames), but when the conversation column's per-frame margin reflow overran a frame the rail kept gliding while the column stalled — the two surfaces **visibly split** ("rail glides first, conversation lags"). This is not a performance issue but a fundamental difference in animation paths: compositor and main-thread animations will always diverge on busy frames.
- 🔁 The fix puts the rail back on the **same animation path** as the conversation column (`transition: right`, same variable / duration / easing): same-path animations cannot split by definition — both surfaces advance in the same style→layout pass every frame, so a busy frame slows **both** together and they never separate. v1.2.3's other two optimizations are preserved (overlay card lazy rendering, no persistent `will-change`); the rail subtree is already cheap enough that per-frame reflow cost is negligible.
- 📐 Measured (playwright + local Edge, heavy session, widget rail open + panel toggle): rail↔conversation right-edge per-frame offset **std = 0 (perfect lockstep)**; dropped frames 0%; left-edge drift 0; steps more uniform (largest single-frame step 220px→71px — that 220px jump in v1.2.3 was the telltale sign of the two paths splitting).
- ✔️ Verified via `../harness-ui-enhancer/scripts/verify-glide.cjs` (lockstep / drops / left-edge / scroll convergence).

## v1.2.3
**Perf — right-panel open/close animation jank fix (coordinated with dsh-better-sidebar / dsh-ui-harmonizer):**

- 🧊 rail and magnify-overlay shift changed from `right` property animation to **compositor transform glide** (`translateX(calc(var(--dsh-sidebar-width) * -1))`, `right:0`): when better-sidebar's panel opens/closes the entire rail subtree translates on the compositor — cards and heatmap incur zero per-frame reflow; the old `transition: right` reflowed the whole rail subtree every animation frame, stacking with the conversation column's margin animation to produce frame drops proportional to conversation DOM size, plus visual desync.
- 🗑️ Removed persistent `will-change: top,width,height` from card slots: previously two deck sets (static + magnify overlay) × N cards all held individual compositing layers, inflating GPU memory and per-frame compositing cost; short tweens are auto-promoted by the browser.
- ✂️ Magnify-overlay card body now renders **only while actually magnifying** (slot div stays mounted for seamless geometry tween): at rest the rail's resident DOM is halved (heavy widgets like heatmaps no longer rendered twice).
- 🔗 Shares the same variable / duration / easing as dsh-better-sidebar and dsh-ui-harmonizer (`--dsh-sidebar-width` + `--ds-transition-duration-slow` + `--ds-ease-in-out`); dragging (`body[data-dsh-sidebar-dragging]`) still tracks instantly; `prefers-reduced-motion` disables transitions.

**Dropped-frame comparison (playwright + local Edge, better-sidebar right-panel open/close window):**

| Scenario | Before | After |
| --- | --- | --- |
| Heavy-session animation dropped-frame rate (frame interval >26ms) | 20–31% | ≈ 0–1.4% (rail open / closed / animation disabled — no meaningful difference, i.e. noise) |
| Main-thread long tasks | up to several per animation, 60–210ms each | 0 |
| Widget rail open vs closed delta | noticeable (jank when open) | none (open = zero extra cost) |
| Glide path | `right` / `margin` per-frame layout (full-tree reflow) | transform compositor glide |
| Persistent compositing layers | every card × two deck sets permanently held | 0 (tween auto-promoted, released on completion) |

→ The widget rail added no more frames dropped: with the rail always open, toggling the right panel is just as smooth as with the rail closed (typical sessions 60 fps throughout). Heavy sessions (thousands of DOM nodes) still show ~10% frame drops from the conversation-column width transition — this is unrelated to the widgets (present even with the rail closed) and belongs to the UI coordination layer; listed on the Roadmap.

- ✔️ Self-contained verification: `scripts/verify-sidebar-anim.cjs` (playwright-core + local Edge, connected to 3080): rail `transition-property=transform`; after panel open, rail right edge = viewport width − panel width; ablation test — disabling rail / conversation-column animation drops 1.4% / 0.6%, confirming the rail contribution is zero.

**New — multi-key usage linkage (pairs with dsh-multikey-pool):**

- 🔑 New host endpoint `/api/opencode-usage-multi`: parses all pool keys (`OPENCODE_GO_API_KEY` primary + `OPENCODE_GO_POOL_2..9` backups), pulls per-key usage, and computes a "pooled total" (rolling / weekly / monthly windows averaged by available-key ratio; status and reset follow the most-used key).
- 🔄 Usage rings / usage bars / rolling usage / weekly usage / monthly usage widgets support **single-click card cycling**: total key → key 1 → key 2 → … → total key; the current view renders as a legend right under the heading ("All keys", "Key 1", "Key 2", …), and the selection is persisted to `cardConfigs.<instance>.poolView` — survives refresh and cross-browser.
- 🍩 Press spring animation: clicking a clickable widget gives a momentary `scale(0.93)` ease-in, then springs back on a bounce curve (`cubic-bezier(0.34,1.56,0.64,1)`), matching the native button feel; `prefers-reduced-motion` is respected.
- 🎯 Click syncs to real usage: switching to key N sends a prefer request to the multi-key pool to make that key primary (`/api/multikey`); switching back to "All keys" clears the preference — what you see is what you use.
- 🧩 Single-key environments degrade automatically: when the pool holds only the primary key the widgets display primary-key data normally with no switching UI.

**New — full Chinese / English locale adaptation (follows Settings → Language, instant, no reload):**

- 🌐 Hooks into the official `locale` service (`ctx.get('locale')`): all user-facing strings are now dictionary-driven — Settings pages (component settings / component market / component configuration), the right-hand widget rail (card titles / values / legends / corner buttons / add button / aria), market cards, configSchema forms, peak-pricing window, task / context / quote cards, OpenCode usage (all-keys / per-key / reset) and more; when the `locale` service is absent, a built-in zh/en dictionary is used automatically (detection matches the official pipeline: `localStorage('dsh-language')` → `<html lang>` → `navigator.language` fallback).
- 🔑 Fix: `installLocale` now **registers** the zh/en dictionaries with the official `locale` service (`register(ns, locale, dict)`) before `bind` — previously only `bind` was called, so the UI showed raw key strings (e.g. `ui.capsule`, `card.contextWater.system`); after registration the active locale selects the correct translation with no raw keys.
- ♻️ The persistent UI (widget rail, header capsule) subscribes to `locale/change` and re-renders immediately; the Settings page nav label "Components" becomes a **label thunk** (`SlotLabel` contract) that updates when the language changes, with no re-registration needed.
- 📖 Every widget's name / description / badge / preview-toggle labels support both Chinese and English; `WIDGETS` name/desc/badgeLabel/simToggle/configSchema are thunks resolved at render time.
- 🧩 Zero hard dependency: if the `locale` service is not installed the built-in dictionaries apply, matching prior behavior.
- ✔️ Self-contained verification `docs/verify-i18n.mjs` (runs under Node `--experimental-strip-types`): bilingual switching, no raw keys, unload fallback all green.

## v1.2.2
**New — peak-pricing (market widget):**

- ⏱️ New market widget peak-pricing (2×2 only): shows whether right now is inside a DeepSeek V4 peak-pricing window. Hard-coded to Beijing time (Mon–Fri **09:00–12:00** & **14:00–18:00**, the UTC 01:00–04:00 / 06:00–10:00 windows); a custom-schedule setting is on the roadmap.
- 💰 Bottom-left big label mirrors the cache/tokens card (same font, size, position): red **EXPENSIVE** inside a peak window, **CHEAP** otherwise.
- 🟥 During a peak window the whole card glows with a gentle breathing red inner glow (scheme B — bleeds in from the edges, centre stays readable, never a solid fill; 2.2s, modest swing, pure urgency, no click bait); `prefers-reduced-motion` users get the static steady glow.
- 🔵 The two window rows under the title reuse the token-bar legend font: the live row lights up brand-blue and scales up slightly (10px→12px, 500→600), the other stays faint.
- ⏲️ A 30s always-on tick rebuilds stats even with no turn running, so a peak/off-peak flip at a window boundary lands promptly (the previous 1s tick only existed while a turn was running).

**New — OpenCode usage rings widget:**

- 🍩 New market widget usage-rings (OpenCode Go group): one donut per window (rolling / weekly / monthly) side by side — the same data as the usage-bars bars chart, in circle form.
- ⭕ The ring centres stay clean (no in-ring text), so the rings can be drawn thick and full (5px stroke, maximised diameter); each percent sits directly under its ring in a larger weight, and the window name + exact value surface on hover via the title tooltip (same urgency colours as the bars chart: ≥95 red, ≥75 amber, else green). Ring-to-ring spacing equals the card inner padding (12px on a 2×2) — the rings tighten to keep the three-across footprint — and the number-to-ring gap is slightly wider than snug (4px) so the layout carries over cleanly to planned 2×1 wide cards.
- 🧭 The existing usage-bars bars widget is untouched — both presentations coexist and install independently.

**Changed — the OpenCode usage bars are now proportioned like a proper data-viz bar chart:**

- 📊 The usage-bars component's three bars no longer use a fixed ~12px width spread by `space-around`. Each bar's column now flexes to an equal share of the card width (the same elastic columns as the usage-bars daily token bars) with the same 4px gutter, and each bar fills ~60% of its column — ≈24px on a 2×2 card, proportionate to its 56px height (a full-width 100% version read as fat blocks).
- 🟣 Bars are fully rounded (5px corners) — without a baseline track underneath, square bottoms read as overly sharp.
- 📏 No value labels on the bars (small-chart convention — labels on a 3-bar mini chart read as chartjunk); the exact percent surfaces on hover via the native title tooltip, and faint dashed 25/50/75% reference lines behind the bars let each bar's height be eyeballed against a quarter scale at a glance.

**Improved — preview state toggling + dark-mode select arrow fix:**

- 🖱️ Stateful widgets (currently peak-pricing) now let you **click the preview card to flip its state** (peak/off-peak) in both the Component Settings and Component Market previews — no need to wait for the real window to review the EXPENSIVE red glow and the CHEAP look; a "Click to flip: Peak / Off-peak" hint shows under the card. Declared per-widget via the `simToggle` descriptor, so future stateful widgets just add one line.
- 🔽 Fixed `.dsx-select` chevron not rendering/not following the dark theme: `fill='currentColor'` in a background-image data-URI SVG draws nothing (SVG-as-background-image resolves in an isolated image context), so the arrow now uses explicit fills — mid-grey in light mode, near-white under `body[data-ds-dark-theme]`.

**Fixed — filled action buttons are readable in dark mode again:**

- 🌗 Filled primary buttons (`dsx-btn-primary` — Added / Add / View Details), the pressed state of the Components stats capsule, and widget-card action buttons (primary/danger kinds) painted `var(--dsw-alias-brand-primary)` behind hard-coded white text. In dark mode the brand token renders near-white, so the label merged into the fill and became invisible. Primary now fills with `var(--dsw-alias-state-business-primary)` and danger with `var(--dsw-alias-state-error-primary)` — the same token pair the official UI uses for filled action buttons — so the white label stays legible in both light and dark themes.

**Fixed — the add-panel height no longer collapses when dsh-better-sidebar's right panel is open:**

- 📐 The temporary add panel's `bottom` offset tracked `--dsh-sidebar-width` — the better-sidebar *width* variable that pushes `#root` aside when the right panel is open. With the right sidebar open (e.g. 320px) the bottom lifted by that whole width while `top` stayed fixed, halving the visible panel; it reproduced regardless of open order. It now anchors to the input-box breathing gap (`--dsx-input-bottom`), the intent the rail-measure comment always stated — the right offset still follows the sidebar, the vertical one never does. Headless-verified: panel height is identical with the sidebar off / 320px / 480px, vs the old rule dropping 886→566px at 320px.

**Fixed — 2×4 tiles are correctly masked in a 1-column layout:**

- 🧱 In 1-column mode a 2×4 tile (two cells wide) has nowhere to sit. The rail now hides installed 2×4 instances (temporarily — switching back to 2/4 columns restores them as-is), and the market says so: the 2×4 entry's title is struck through with a yellow "Unavailable" capsule beside it and its add button disabled. The `right` offset still follows the sidebar width; only height no longer does.
- 🧪 Headless end-to-end: added heatmap@2×4 on a 2-column rail (324px slot), switched to 1 column → title struck through + capsule shown + add disabled + wide slot gone (150px only); user state restored afterwards.

## v1.2.1
**Fixed — the last edit is now flushed to the host store when the page closes, so widget state survives ANY desktop shell and every browser/device:**

- **Root cause.** Widget config is written to two channels: `localStorage` (fast path) and the host file via a **400 ms debounced** PUT to `/api/widgets-state` (authoritative, origin-independent). The debounced write had **no unload flush**: if the window/tab closed within that 400 ms window (or while the PUT was still in flight), the request died with the page. On shells that spawn a fresh random loopback origin per launch (e.g. DSH Desktop builds), `localStorage` is a brand-new realm on every boot, so that single missed PUT meant the edit was lost for good — "changes don't save" on desktop while the fixed-port local web (stable origin) masked the same defect invisibly.
- 💾 **Unload flush.** A `pagehide` listener now calls `flushPendingState()` the moment the page starts tearing down. It sends any state that has not yet reached the host store via `navigator.sendBeacon` (delivered by the browser even as the page is destroyed) with a keepalive-fetch fallback; the host route already accepted POST as well as PUT, so the same endpoint copes with it. A quick close after an edit can no longer lose the change, on any desktop shell, browser origin, private mode, or cleared-site-data session.
- 🛡️ The debounced PUT also gained `keepalive: true`, so a write already in flight survives page teardown as well.
- 🧪 Headless-verified end-to-end against the real host store: capsule-click (a real `setPrefs` → `saveState`) followed by an **immediate** `pagehide` (≈80 ms, well inside the 400 ms debounce) produced a real beacon; the host file's `savedAt` advanced to match `localStorage`, the debounce fetch did not re-fire, and the test restored the user's true state afterwards.

## v1.2.0
**Fixed**
- 🗓️ Heatmap no longer over-credits today with a previous session's whole history. The fallback anchor now tracks the per-step-credited cumulative, and the fallback only diffs growth when the active session actually has a step that began today. Reopening yesterday's session (or the projection lag right after a new-session switch) used to diff the entire prior total — e.g. 106M — into today's cell.
- 🌐 Heatmap day attribution now honors a configurable timezone (**accounting timezone** in the heatmap card config), defaulting to Beijing UTC+8 (the day rolls at 08:00 UTC). Options: Beijing (UTC+8) / System / UTC. Previously attribution followed the browser clock, so the day boundary shifted whenever the system timezone was not UTC+8.
- 🧹 One-shot cleanup drops an already-polluted today value so the live collector rebuilds it cleanly.
- 📊 Token-usage bar chart now normalizes bar heights to the **max within the shown 7-day window** (rolling and weekly) instead of the whole history: the tallest bar of the week always reaches full height and the rest scale proportionally, so the chart stays full even when an older day (e.g. the 1.2G outlier) would otherwise flatten the window.

## v1.1.6
**Fixed — card-anchored magnification, wave-following add button, smooth enter/exit:**

- **Card-anchored trigger (all modes).** The wave engages only when the pointer actually hits a card; crossing the gaps keeps it engaged AND the peak keeps gliding with the pointer (discrete mode: snapped to the quantized grid, so it still moves while you cross a gap; realtime: follows the pointer every frame). Only leaving the rail disarms it.
- **Add button rides the wave, position included.** Its placement is recomputed from the focused (scaled) rows, so when the cards above grow taller the button moves down with the magnified deck bottom / last-row gap, and its size follows the same bell curve at that position (previously only its size scaled, pinned to the resting grid).
- **Right edge stays aligned; gaps stay exact.** Overlay card positions (`top`/`right`) are INSTANT and, in the realtime FOLLOW phase, the size transition is disabled entirely — every frame lands directly on the steady-state right-anchored geometry, so fast pointer movement never lingers in a non-steady intermediate pose (the historic cause of a drifting right edge AND uneven inter-card gaps). The enter/exit phases (and the discrete style's grid gliding, which changes targets at grid frequency) keep a 0.15 s width/height tween for smooth grow/shrink.
- **Smooth enter/exit.** The overlay is always mounted (hidden by opacity), so entering/leaving magnifies via the CSS size tween instead of popping in at the target size — no flicker; exiting shrinks back to the resting size the same way.
- 🧪 Headless-verified (playwright, both modes): visibility flips only on card hit / gap-cross / rail-leave as specified; overlay rightmost == static rightmost (diff 0); gap movement keeps the wave changing; the add button sits below the resting position (702 → 753 px) and grows to 166 px under the wave; control console clean.
- 🧰 **Market/config rework — add-only, no install/uninstall zone.** Every widget ships bundled, so the market no longer has "download/uninstall": opening a group lets you pick the concrete widget, choose its size with left/right arrows (no dropdown — e.g. the Coding-Plan heatmap/bars flip 2×2 ↔ 2×4 that way), and hit **Add** to append `widget@size` straight into the rail (already-added instances show a disabled Added). The config tab lost the "Uninstalled (click to restore)" zone: removing a row deletes the instance entirely (installed + order + its config). Market groups are **Built-in** (all built-ins), OpenCode Go (rolling/weekly/monthly quota), Coding Plan usage (heatmap + bars) and **Misc** (quote of the day, to be re-classified later).
- 🧩 **Market cards** show the group name (bold) + widget count (capsule badge) on one line, a single description line, then actions — no id line.
- 🧮 **Every size is its own market instance.** Multi-size widgets (heatmap 2×2 / 2×4, context-waterline 2×2 / 2×4, …) appear as independent selectable entries — first the 2×2, then the 2×4 — instead of a size switcher; the count badge counts instances, not widgets.
- 🎨 **Preview now matches the real render.** The preview stats build the heatmap through the same `buildRollingGrid` path the live collector uses (7 week-rows × 13 day-columns — the old preview built it transposed, swapping width and height), so the 2×2 preview is a square card again, and the quote preview shows sample content (never persisted) so it isn't blank. All previews are fed concrete values (never blank).
- 📐 **2×4 previews scale to fit.** Wide cards preview at `scale(0.85)` centred in a fixed-width stage, so the right-edge buttons stay visible and the prev/next arrows never shift.
- 🗂️ **Config preview uses free space.** The selected widget's preview fills the remaining panel height below a top-LEFT title (extra room becomes vertical padding), and the preview size control is a dropdown beside the title — same `dsx-select` format as the Window alignment field.
- 🙈 **Stats-line switch hides text only.** Enabling it keeps the official bar's space and layout untouched and makes just its labels transparent — matching manual "hide the text" setups; off shows the bar normally.
- 📊 **Usage bars align per week.** The usage-bars window option is now Rolling (last 7 days) / **Weekly aligned** (Sunday-aligned current week), instead of the misplaced quarter mode.
- ✅ **Tasks never vanish.** Without a todos projection the task card shows **No tasks · 0 in progress · 0 pending** instead of disappearing.
- ✂️ Removed the divider line above the Custom section (per-card schema) block in the config preview.
- 🔧 **Capsule button styling restored.** The CSS file carried a UTF-8 BOM that leaked into the first rule's selector at build time (a junk prefix before `.dsx-stats-capsule{…}`), silently killing the Components capsule's base style (border-radius, padding, background, height). Rewrote the file as BOM-free UTF-8; verified the capsule computes `border-radius:14px / height:28px / background / padding / 1px border` again.
- 📐 **4-column add button no longer overlaps cards.** Row-band packing leaves the last row's gap at the LEFT edge (right-anchored), but the add button was anchored off the LAST item — on a left-packed 4-column row that dropped the button into the row's own cards. Placement now anchors the row's LEFTMOST card and falls back below the deck when the leftover gap is narrower than the button. The fit decision uses the STATIC widths, so hovering (which widens that row's cards) never flips the button to the deck bottom-right — it stays in its gap slot, gliding with the row.
- 🏠 **Fresh installs pre-load only the stats-line family** (turns · LLM/tool time · TTFT · rate · cache · tokens — mirroring the official composer stats bar); everything else is a market add. Existing users' arrangements are untouched by design.
- 🙈 **New personal-preference switch** in Component Settings: "Hide the stats line below input box" hides the official composer stats bar under the input box (the rail shows the same data). Default OFF so other users keep their bar.
- 💬 **Quote card renders nothing without a custom text** (no default filler that used to rotate on every render), and it lives in its own Misc group for now.
- ⚠️ The "limit reached" warning is now a floating centered pill that never consumes layout height.
- 🧪 Upgrade-fidelity regression (`docs/state-fidelity.cjs`): a hand-arranged legacy config (custom installed/order/cardSide/quote text, no new fields) loads with everything preserved — nothing reset, nothing re-added, `hideStatsLine` defaulted off; quote with no text renders zero cards. Tests snapshot the real host state and restore it, so they never touch a user's saved arrangement.

## v1.1.5
**Fixed — widget state now survives restarts (root cause: browser `localStorage` only):**

- Widget configuration (`installed` / `order` / per-card configs / sizes / panel and magnification settings) was kept **only** in each browser's `localStorage` — a per-origin, per-browser cache. It silently reset to defaults whenever the browser origin changed (`localhost:3080` vs `127.0.0.1:3080` are different localStorage realms), on private-mode or cleared-site-data sessions, or after a write silently failed (the old `saveState` swallowed errors) — and it **never followed to another device**, where the state is simply absent.
- ⚙️ The host half now registers `/api/widgets-state`: the rail state is persisted **atomically** (tmp + rename) to `profiles/web/dsh-widgets-state.json` under the profile data dir — one authoritative copy per DSH service, shared by every browser/address that reaches it.
- 🔄 On boot the client syncs with the host store: whichever side (localStorage vs host file) holds the newer `savedAt` wins, so any origin/browser converges to the last saved configuration instead of resetting; every change is written to both channels (localStorage immediately, host via a 400 ms debounced PUT).
- 🖥️ Cross-tab + visibility re-sync: a `storage` event re-reads the configuration in sibling tabs of the same origin, and switching back to a tab re-pulls the host store — multi-window/multi-origin sessions converge live, not only on the next boot.
- 💾 Existing `harness-widgets.*` localStorage keys are untouched; the token-usage heatmap ledger stays per-browser (it is high-frequency bookkeeping), while the UI configuration is now device-stable. Devices stay independent by design: each machine running its own DSH service keeps its own state file (no cloud sync).
- 🧪 No host dependency on bygone contract details — route/body handling matches the verified pattern already used for the OpenCode proxy.

## v1.1.4
**Meta — renamed package to `dsh-widgets`:**
- 📦 npm package renamed `harness-widgets` → `dsh-widgets` (dsh- prefix matches the ecosystem norm and npm search; `dsh-ui-enhancer`-style queries now hit this package). Old package is deprecated and redirects here.
- 🔀 GitHub repo renamed `Physicolor/harness-widgets` → `Physicolor/dsh-widgets` (old URL auto-redirects; stars/forks/issues preserved).
- ♻️ Install command is now `dsh plugin --profile web add dsh-widgets`.
- 💾 No data impact: localStorage keys (`harness-widgets.*`) stay unchanged, so heatmap and widget state are carried over.

## v1.1.3
**Meta:**
- 🏷️ Added npm `keywords` (deepseek-harness / dsh / cordis / plugin / web-ui / widgets / dashboard / heatmap) so the package shows up in npm search; no code change.
- 🪧 GitHub repo topics expanded (deepseek-harness, cordis, cordis-plugin, browser-extension, web-ui, widgets, dashboard, heatmap).

## v1.1.2
**Fixed**
- 🔢 Token-usage heatmap now accounts **per assistant step by its own start time** (v2), with a cumulative-anchor fallback when a host omits per-node `usage`. A day's cell = exactly the tokens of steps that began that day (LOCAL time), so sessions spanning midnight split correctly across both days; element dedup by `turn:step:start` keeps remounts / session switches / compaction idempotent.
- 🧹 **Boot-time repair**: a one-shot fix clears polluted live-day values (8/22 had shown 145M–181M from a fixed seed double-counting with live accumulation) and resets the dedup set, so the live path rebuilds the day exactly; a marker keeps it one-shot so later live values are never wiped.
- 📚 Non-live past days (8/14–8/21: 74.32M / 367.79M / 1195.70M / 161.49M / 292.34M / 352.36M / 214.85M / 44.55M) are backfilled from the authoritative per-event session logs (official delta algorithm, LOCAL-time attribution), whose sessions have ended — never double-counted. 8/22 is live-accumulated (≈114.87M and growing). Manual one-shot recovery: `docs/heatmap-recovery.js`.

## v1.1.1
**Fixed**
- 🔢 Token-usage heatmap accounting reworked to **per-conversation-step crediting**: every assistant step is credited exactly once, by its OWN start time (`timing.stepStartTime`), so a day's cell holds exactly the tokens of steps that *began* that day — the old daily-reset-baseline diffing credited yesterday's whole total (e.g. 47M→117M) to today whenever a session continued across midnight. Steps are deduped by `turn:step:start` (remounts, session switches, compaction, cross-midnight sessions all behave). A cumulative-anchor fallback covers hosts where the folded surface omits per-node `usage`, re-anchoring only on a genuine cumulative reset (new session), never on a bare day change.
- ⚠️ Migration previously rebuilt the table keeping only the demo seed (8/14–16), discarding real history on other days. The migration is now **preservation-only + backfill**: existing day values are kept untouched; non-live past days (8/14–8/21, whose sessions have ended) are backfilled from the authoritative per-event session logs (official delta algorithm, attributed by each usage event's LOCAL time). The live day (8/22) is NOT seeded — the real-time per-step accounting accumulates it, so no double count (a prior version seeded 8/22 and produced 145M–181M). A one-shot repair clears polluted 8/21/8/22 values, resets the dedup set, then re-backfills 8/21 so live accumulation rebuilds 8/22 exactly. Manual one-shot recovery: `docs/heatmap-recovery.js`.

## v1.1.0
**New**
- Usage-heatmap widget now supports **2×4**: a ~7-month (30-week) rolling grid showing every recent token-usage point, derived fresh from the raw daily log, horizontally centred with the today/window figures on the title row's right.
- New **last-7-days bar chart** widget (`heatmap-bars`, 2×2): vertical bars for the past 7 days, whose bar area height exactly matches the 2×2 calendar grid's content height (so the bars occupy the same vertical footprint as the day-rows they replace).

**Changed**
- Bar chart axis labels are now short month.day dates (e.g. `8.28`) instead of weekday chars; bars are ~1.5× wider with a fuller corner radius; the legend is two plain figures (today / 7-day total, no "today / 7-day total" words); only the first and last date labels are drawn on the bottom corners (no x-axis baseline). The widget is now named **usage-bars** (was "7-day bars").
- Heatmap legend drops the "today" prefix (two figures: today / window total), and the chart's bottom-left/right corners show the window's earliest date and today's date.
- The 2×4 heatmap grid is wider (30 weeks) and horizontally centred; its figures move to the title row's right end.
- The 2×4 **token heatmap** and **context waterline** charts are now bottom-aligned (a title-row headRight figure no longer forces top alignment).
- The rail's top padding grows 2px → 4px so the first card keeps clear of the enhancer rounded-card's top shadow; the magnify overlay mirrors it. No header rules live here anymore — the header's opaque rectangle (masking the rail's top) is harness-ui-enhancer's job.

## v1.0.0
**New**
- Settings → Components: add a "Realtime follow (continuous)" switch exposing the real-time continuous magnification mode (peak follows the pointer every animation frame).
- Truly stepless magnification: every card's scale is driven by its own continuous Euclidean distance to the pointer (rail-content coords) instead of a discrete nearest-card anchor, so the peak glides smoothly between cards on any pointer movement.
- Discrete mode now REUSES the same stepless geometry: the live pointer coordinates are snapped onto a discrete grid of row/column centres plus the midpoints between adjacent ones (rows → 2·rows−1 Y points, cols → 2·cols−1 X points), and the 0.2s tween glides the peak between those grid points. Both modes therefore share one right-edge-anchored posture.

**Fixed**
- Hover magnification no longer widens the rail or pushes the conversation column right (bell-curve overshoot removed from `--dsx-rail-w`); a magnified card's leftward growth is painted by a fixed overlay OUTSIDE the rail's scroll-clip box, so it escapes clipping while the resting rail width and conversation distance stay unchanged.
- The magnify overlay mirrors the rail's exact box model (same padding/box-sizing + inner deck), so magnified cards stay flush with the resting rail's right edge with no extra hit-test cost.
- The rail's add button fades with the static cards while magnifying; the overlay mirrors it at its resting position so it stays visible and right-aligned.
- Stepless mode snaps to its steady right-anchored geometry every frame (`transition: none`) — a tween left cards in a non-steady intermediate pose while the pointer moved, letting the right edge stray past the rail until the pointer stopped. Discrete mode keeps its 0.2s settle tween.

## v0.3.0
**New**
- Multi-column grid: 1 / 2 / 4 columns (2 by default), magnification supported.
- 2×4 tiles: context-waterline 2×4 version (top-right % + extended segment bar); the same widget can be installed as both 2×2 and 2×4.
- Component marketplace with system widgets + per-instance (`widget@size`) install; installed and preview both support 2×2 ↔ 2×4 (auto-dedup).
- Continuous wave animation: hover magnification changed from discrete steps to continuous exponential decay, responding smoothly as the pointer moves in X/Y.
- Gap-free packing (best-fit) — no holes at any drag order.

**Fixes**
- 2×4 card height wrongly filled by width, causing abnormal occupancy.
- Switching sizes no longer duplicates; deleting no longer removes same-name/same-size instances.
- Magnification didn't respond vertically at horizontal peak transitions.

## v0.2.2
- Fix daily usage not resetting across days (token cumulative baseline bound to the date; auto-clears across days).

## v0.2.1
- Fix heatmap count spikes (ledger baseline persisted; re-mount only counts genuine new increments).
- Fix seed update not applying (forced overwrite; version raised to .3).

## v0.2.0
- macOS-Dock-style hover magnification (discrete steps + layout swap).
- Per-card config: quote text/alignment/wrapping, heatmap window alignment.
- New widgets: tasks, one-click compact, context waterline, usage heatmap (self-tracked), quote of the day.
- Brand-blue title; one-click compact button moved to the bottom-right.

## v0.1.1
- Widget rail transparent background, hidden scrollbar (cross-browser), removed top padding.

## v0.1.0
- Right widget rail + 7 built-in stat widgets + 3 OpenCode Go usage widgets;
- Settings → widgets page (preview / install / reorder);
- In-progress turn LLM/tool time refreshed every second.
