# Design Notes

Why `dsh-chat-locator` is built the way it is: the three implementation constraints, the
gradient tuning history, the verification status, and the boundaries you should know about
before relying on it.

<div align="center">
  <p><strong>Language:</strong> <a href="design-notes.md">English</a> | <a href="../zh-CN/docs/design-notes.md">简体中文</a></p>
</div>

---

## 📋 Table of Contents

- [The Problem](#-the-problem)
- [Claiming Styles, Not Redrawing](#-claiming-styles-not-redrawing)
- [Settings in the Browser, Not the Host](#-settings-in-the-browser-not-the-host)
- [Styling the Built-In Plain-Text Preview](#-styling-the-built-in-plain-text-preview)
- [The Gradient Curve](#-the-gradient-curve)
- [Verification Status](#-verification-status)
- [Known Boundaries](#-known-boundaries)
- [Version History](#-version-history)

---

## 🔍 The Problem

DSH draws a turn rail — one tick per conversation turn — down the right edge of a
conversation. It is rendered by `ChatView` inside `@deepseek-ai/dsh-client-ui-chat`, directly
into the conversation scroll container (`TurnNavigator`). The Slot system gives it no seat, and
it exposes no configuration for tick thickness or which side the rail sits on.

Two options follow from that. Redraw a rail of our own, or claim the styles of the one that is
already there. This plugin does the second. A replacement rail would have to re-implement the
built-in jump-to-turn behavior, unloaded-turn paging, and active-turn following — three
features that are easy to get subtly wrong and that users notice immediately when they break.
Overlaying styles keeps every one of them intact.

The cost is coupling. The plugin needs to know which class name the rail is using, and that
name is generated by the bundler and changes between builds. So the plugin discovers it at
runtime instead of hardcoding it.

## 🔧 Claiming Styles, Not Redrawing

`discoverRail()` walks the live DOM and finds the real CSS Module prefix by pattern: it looks
for an element matching `[class*="_frame"]`, splits its `class` attribute into tokens, keeps a
token ending in `_frame` whose prefix starts with a letter, and requires a `.<prefix>_mark`
descendant. Inside DSH 0.2.1-rc.2 the rail is
`nav.<prefix>_frame > div.<prefix>_scroller > div.<prefix>_marks > button.<prefix>_mark`, with
the preview a sibling of the scroller; the observed prefix on that build is `xpvNua`, and it
changes between builds, so nothing depends on the value.

`railStyleText(prefix, config)` then generates the override rules from the user's
configuration. Every declaration carries `!important`, deliberately: DSH 0.2.1-rc.2 injects the
plugin's sheet at head index 16, *before* the built-in `ui-chat` TurnNavigator sheet at index
115 — the adversarial order — so the rules must not depend on insertion order to win. The same
order is why the enter animation no longer redeclares the shipped `@keyframes` (a same-name
redeclaration loses to whichever sheet comes later): it uses an owned keyframes name and
overrides `animation` with `!important`, so the mirrored animation is order-independent, with
its own `prefers-reduced-motion` guard.

If the rail has not rendered yet — fewer than two turns, a non-conversation view — the plugin does
not throw and does not retry in a tight loop. A single `MutationObserver` on
`document.documentElement` wakes on every mutation batch, but it stays cheap: the rescan for the
rail is gated to 400ms, and the override rule text is memoized on its two inputs (the discovered
prefix and the settings snapshot), so the CSS string is rebuilt only when one of them actually
changes — never once per mutation batch. If a mutation batch arrives inside that 400ms gate,
one delayed rescan is scheduled, so a rail that mounts during the window is still found; the
timer is cleared on dispose, and re-mounting replaces the plugin's style tag rather than
stacking a second sheet.

## 💾 Settings in the Browser, Not the Host

Up to 1.3.0 there were two live halves: the host half registered a `chat-locator` schema, and the
browser half bound to it, so settings lived in the DSH settings document and followed you between
machines. DSH 0.1.7 removed the client `settingsScope` service and with it the host half's only
job. Since 1.4.0 the browser half owns the settings itself.

**The trade-off, in one line:** settings now survive browser restarts but no longer sync across
browsers, profiles, or machines, and anything older versions wrote into the settings document is
ignored — the first run after upgrading shows factory values.

Since 1.5.0 the store is one small policy (`createSettingsPolicy()`) over
`createSnapshotStore` from `@deepseek-ai/dsh-client-store`, persisted under
`dsh.chat-locator.settings`. Two behaviours are worth knowing, because both were bugs first:

* **The stored layer holds only deviations from the factory values.** Setting a field back to its
  default deletes that key. So "the button is greyed out because every value is at its default"
  and "there is nothing stored to clear" always mean the same thing — otherwise an explicit
  default-valued key could never be removed by the button meant to remove it.
* **Every write copies the record before touching it.** `set()` deep-freezes its argument and
  hands it to zustand, which bails when the new state is the same reference as the old one.
  Mutating the object from `getSnapshot()` and setting it back notifies nobody and persists
  nothing — settings looked applied, then reverted on reload. The test stub now models that
  contract (freeze, then reference bail) and asserts it, so the suite cannot drift back to a
  permissive fake.

Defaults, ranges, steps, normalization, and each settings row's control type and copy keys all
live in one `FIELDS` table; `DEFAULTS`, `normalizeSettings()`, and the rendered rows derive from
it, so a new field is added in one place. `reset()` clears the whole layer in one write, which
also drops keys left by older versions.

### Why `index.js` still exists

`cordis.patch.yml` resolves the plugin row to the package entry, `main: ./index.js`, so deleting
the file takes the whole row down. Since 1.4.0 it is a documented no-op stub. Only the browser
half is live; the page picks it up on reload.

## 💬 Styling the Built-In Plain-Text Preview

The hover preview is the built-in one. It is built entirely from the built-in turn outline — the
`turnOutline` projection plus the navigation items of the loaded window — and it is upstream, not
this plugin, that reads only text blocks, collapses whitespace runs, and truncates long text with
an ellipsis. **The plugin never reads turn text**: its CSS decides only how many lines the preview
shows, its font size and width, the card height, and which side it expands to.

Card height is derived from the font size and line count:
`26 + promptLineHeight + lineHeight × lines`, where `lineHeight = round(fontSize × 1.5)`,
`promptLineHeight = round((fontSize + 1) × 1.5)`, and 26px is 10+10px vertical padding, the 4px
prompt margin, and 2px of spare. The default 12px / 3 lines stays at the built-in 100px;
16px / 4 lines is 148px, 18px / 6 lines is 217px, and 10px / 1 line is 58px. Before 1.5.0 the
height ignored the prompt line height, so the closing line(s) of the card were clipped at larger
sizes — measured at 7px clipped for 18px / 6 lines.

`white-space: normal` and `overflow-wrap: anywhere` are the plugin's own contribution to keeping
the card readable. They pin down the two failure modes that would otherwise come back: blank lines
produced by wrapping, and horizontal overflow.

## 📐 The Gradient Curve

The tick under the pointer becomes the longest one; its neighbours taper back toward the shipped
width.

| Distance from the hovered tick | 0 | ±1 | ±2 | ≥3 |
| :--- | :--- | :--- | :--- | :--- |
| Width | **32px** | 21px | 14px | 12px |
| Drop per step | — | 11px | 7px | 2px |

That table comes from one power curve, `12 + 20 * (1 - d/3)^2`. Two knobs shape it:
`GRADIENT_REACH` (levels, and the curve's denominator — dropping a level recomputes the middle
widths rather than lopping off the last one) and `GRADIENT_CURVE_EXPONENT` (how bent: `1` is a
straight line, `2.5` reads as a cliff). They are currently `3` and `2`.

| Exponent | Neighbour widths (±1, ±2) | Verdict |
| :--- | :--- | :--- |
| 2.5 | 24, 17 | First step dropped 15px — read as a cliff, not a curve. |
| 1.5 | 23, 16 | Smooth, but the neighbours stayed too long; the hovered tick did not stand out. |
| **2.0** | **21, 14** | In use. First step 11px — inside the "bent but not steep" 9–12px band — and the hovered tick is 1.52× its neighbour. |

### Two things that were wrong at first

**The anchor missed the active turn.** The built-in `TurnMark` puts `markActive` **or**
`markPreview` on a tick — an `if`/`else`, never both. An anchor of `_markPreview` alone therefore
matched nothing while you hovered the turn you were on, and the whole taper vanished. The anchor is
now `:is(.<prefix>_markPreview, .<prefix>_mark:hover)`. Neighbour selectors still carry
`:not(.<prefix>_markActive)`, so the active tick is never resized *as a neighbour* — measured in
headless Chrome: hovering it gives 32 / 21 / 14, guard intact.

**The frame clipped the peak.** The built-in frame is 28px wide and its scroller sets
`overflow-y: auto`; per spec that computes the other axis to `auto`, so **the frame is the ticks'
clip box**. Anything past 28px was cut off and the curve degenerated into a short diagonal. The
plugin widens the frame to longest tick + 4px (36px). Ticks stay right-aligned, so nothing moves;
the extra width is pure headroom. The frame is also the rail's click surface, which is the one
visible side effect — see [Known Boundaries](#-known-boundaries).

### Left mode, and why it is more than a mirror

The frame's containing block is the rail slot's padding box, which spans the whole conversation
area, so the built-in `right:12px` mirrors cleanly to `left:12px`. The pre-1.5.0 rule instead
subtracted the composer-side clearance and measured `left:-20px`, putting the frame outside the
chat area where the sticky layout clipped it away entirely.

Beyond the frame, four things have to move together: `::before` goes to `left:0` **and** its
`transform-origin` to `0 50%` (the built-in origin is `100%`, so without this mirror normal ticks
sit 8px in from the edge, unloaded ones 12px); the keyboard focus ring moves to the left; the
button keeps the full frame width as the hover/click surface, so only `::before` moves; and the
preview opens on the right of the rail. The enter animation uses the plugin's own keyframes name
with `animation !important` and a `prefers-reduced-motion` guard, so it does not depend on
stylesheet order.

### The gradient switch

The gradient is decorative — the rail works without it — so it has its own row, unlike the hover
preview whose switch was removed in 1.3.0. Off, the whole width family disappears **including the
frame widening** (no 32px peak needs room, and the extra invisible click strip goes with it).

Off returns the ticks to their **built-in** lengths, which are not uniform: 12px normally, 8px for
unloaded turns, 20px for the active turn, and 18px on the hovered tick (the built-in hover state is
`20px × scaleX(.9)`) — so the settings sample shows an 18px hovered tick, not a flattened 12px
row.

## ✅ Verification Status

`node test/verify-client.mjs` runs **151 assertions** in under a second — no network, no browser,
no install step. `npm test` syntax-checks both halves first, and the release workflow runs the
same command before publishing. Everything is exercised against stubs, except the geometry.

What the suite does **not** prove, and what was checked elsewhere:

| Area | How it is covered |
| :--- | :--- |
| CSS generation, config normalization, settings page | 151 Node assertions against stubs — exact strings and numbers are pinned deliberately, so a deliberate change must update them. |
| Rendered geometry | Measured in headless Chrome against the exact built-in CSS/DOM, in both the sticky and absolute layout variants. Left mode lands at `chatFrame.x + 12`; ticks align; the hovered active tick tapers 32 / 21 / 14. |
| Live DSH page | Verified against DSH 0.2.1-rc.2 for layout variant, stylesheet order (plugin before `ui-chat`), and the observed class prefix. |
| Aesthetics, and the last mile of a live CSS injection | **Not machine-checked.** Whether the arc looks right, and whether the 1.5.0 left-hand CSS injects cleanly into a real conversation, are for your eyes — the headless client could not reopen a multi-turn session for that final check. |

The Node suite pins generated CSS as text; it renders no page, so it cannot catch cascade,
hover, or pixel regressions. That is what the headless runs above were for.

## 🚧 Known Boundaries

* **Upstream coupling.** The only structural contract is a class token ending in `_frame` that
  contains `<prefix>_mark` buttons as adjacent siblings, plus the built-in state classes
  `_markPreview` (hover) and `_markActive` on the tick itself. The plugin also anchors on the
  tick's native `:hover`, because the built-in marks carry `_markPreview` **or** `_markActive`,
  never both. The `_markPosition` wrapper the distance selectors used before 0.1.7 is gone
  upstream and is not coming back. If upstream renames these, the gradient stops working
  silently — no error, and no effect on conversations themselves.
* **`CSS :has()` is required for the distance chain** of the gradient (modern Chromium, Firefox,
  and Safari all ship it). The 32px peak rule and the "below" chain have no `:has()`, so without
  it the hovered tick still lengthens and ticks below the pointer still taper; only the
  `:has()`-based "above" chain is skipped.
* **The rail hides itself at or below 900px** of container width, via a shipped `@container`
  rule. The plugin does not force it back.
* **Thickness caps at 8px** because the tick row height is fixed at 10px; thicker ticks would
  overlap each other.
* **The frame widening has a side effect.** To avoid clipping the 32px peak, the frame goes from
  28px to 36px — but only while the gradient is on. That frame is the rail's hover and click
  surface, so the invisible interactive strip on the edge of the conversation is 8px wider:
  hovering there shows a preview card, and clicking there jumps to a turn. Switching the gradient
  off removes the widening and the strip returns to its shipped width.
* **The hovered tick is always the anchor.** The active turn's tick is excluded from the
  neighbour rules, but when the pointer is over it, it is the anchor itself and grows to 32px.
  Neighbours still taper 21 / 14.
* **Preview font size and width affect the card only.** They do not touch the rail's own
  typography or the 28px of layout space it occupies, so enlarging them never squeezes the
  conversation text.
* **Nothing changes from the third tick outward.** Ticks at distance ≥ 3 keep their built-in
  widths — 12px normally, 8px for unloaded turns, 20px for the active turn — so the rail as a
  whole still looks like the one DSH ships. The neighbour rules do not exclude `_markUnloaded`:
  within two ticks an unloaded tick is widened to 21 / 14px; from distance 3 outward it keeps the
  built-in 8px.
* **Installing or removing the bundle needs a host restart.** The composed plugin tree is built
  at startup, so installing or removing the bundle takes effect after `dsh web` restarts — a
  browser reload alone is not the reliable path. Current DSH builds can hot-compose client
  entries, so re-enabling an already-installed bundle can show up without one; restarting remains
  the safe answer. (The settings themselves no longer wait for anything: they are browser-local
  since 1.4.0.)

## 📚 Version History

Full detail for every release lives in the repository's git log and GitHub releases. Below is
the short version — what changed for a user, and which version to upgrade to.

| Version | What it means for you |
| :--- | :--- |
| **1.5.1** | The current release. No behaviour change: a documentation release. The README now opens in Chinese (English moved to `README.en.md`), it gained screenshots of the settings page and the length gradient, and the badge row carries the Awesome DSH Plugin and powered-by-dsh badges. The "Web" qualifier is gone from the title, the platform badge and `package.json`'s description — DSH runs in the browser and as the desktop app, and this plugin works in both. A repo-root `screenshots.json` hands the two captures to the Awesome DSH Plugin listing. Published so npm's package page shows the same README — npm renders the published tarball's copy, which cannot be edited after the fact. |
| 1.5.0 | Four fixes you can feel: hovering the turn you are on now tapers the gradient (before, that taper vanished); left mode is actually visible and its ticks line up (before, the rail sat outside the chat area, clipped); a larger preview font no longer clips the last line; settings now survive a reload reliably (a write-path bug could silently drop them). Also: one settings table instead of three parallel lists, re-mount no longer stacks a second stylesheet, and a malformed stored value falls back to defaults instead of throwing. |
| 1.4.3 | No behaviour change — the first release published by CI with npm provenance. |
| 1.4.2 | No behaviour change — published locally while the trusted-publisher setup was pending, so it carries no provenance. |
| 1.4.1 | Docs corrected to describe what the plugin actually does, package ships its own docs, and the active turn's tick was excluded from the neighbour rules. |
| 1.4.0 | Settings moved into the browser (they no longer touch the DSH settings document), and the host half became a no-op stub. The gradient selectors were rewritten for DSH 0.1.7's DOM. |
| 1.3.0 | The hover-preview on/off switch was removed: the rail exists to be previewed. |
| 1.2.0 | The curved, longer gradient with the widened clip box; preview font size and card width; "Restore defaults". |
| 1.1.0 | Preview line count actually took effect; the gradient anchored on the hovered tick. |

If you are upgrading from 1.3.x or older: settings are now browser-local, so any `chat-locator:`
section in the DSH settings document is ignored and you will see factory values on first run.
Set them once more on the settings page.

## 📄 License

[MIT](../LICENSE)
