# 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.
---
## đ Table of Contents
- [The Problem](#-the-problem)
- [Claiming Styles, Not Redrawing](#-claiming-styles-not-redrawing)
- [The Host Half and Its Module Cache](#-the-host-half-and-its-module-cache)
- [Keeping the Preview Plain Text](#-keeping-the-preview-plain-text)
- [The Gradient Curve](#-the-gradient-curve)
- [Verification Status](#-verification-status)
- [Known Boundaries](#-known-boundaries)
- [Version History](#-version-history)
---
## đ The Problem
DSH Web 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"]` that also contains a `._mark` child. The
observed prefix is `eGxaPq`, but nothing depends on that value.
`railStyleText(prefix, config)` then generates the override rules from the user's
configuration. Every declaration carries `!important`, deliberately: the plugin injects its
stylesheet during page startup, which can be earlier than `ui-chat`'s own stylesheets, so the
rules must not depend on insertion order to win.
If the rail has not rendered yet â fewer than two turns, a narrow container, a non-conversation
view â the plugin does not throw and does not retry in a tight loop. A single
`MutationObserver` throttled to 400ms adds the rules once the rail appears, and recomputes them
if the prefix changes.
## đ§ The Host Half and Its Module Cache
Client settings scopes can only be derived from a namespace the host has already registered, so
the plugin has two halves. The host half, `index.js`, does exactly one thing:
`ctx.settings.register('chat-locator', schema)`, with defaults and range validation in the
schema. The browser half then binds to it with
`ctx.settingsScope.bind({ namespace: 'chat-locator' })`.
### Why there is no bare import of schemastery
`index.js` deliberately does **not** contain `import z from '@deepseek-ai/schemastery'`. When
the bundle is installed from a workspace directory, `plugin_manager` records the dependency as
`link:`, and Node resolves that package's dependencies from its real path on
disk. A bare specifier therefore fails with `ERR_MODULE_NOT_FOUND` â measured, and the failure
mode is severe: the entire plugin row fails to mount, not just the settings registration.
The fix is `loadSchemastery(ctx)`, which resolves the module from the configuration tree's base
URL (the profile directory, `ctx.baseUrl`) using
`createRequire(anchor)('@deepseek-ai/schemastery')`, honoring Node's own lookup order. This
works for `link:` installs and for ordinary copied installs alike. If resolution fails, the
plugin still mounts normally and only the settings namespace is unregistered; the settings page
tells the user "the host settings document is currently unavailable" rather than failing
silently.
### The module cache, and what it costs
**Changes to the host half do not take effect automatically.** Node caches successfully imported
modules, and the host row here is a `link:` install whose workspace directory is not in HMR's
watch roots. This was established experimentally, not assumed: a file-write probe plus a
"throw if a marker file exists" probe showed that after editing `index.js`, neither HMR nor
toggling the bundle caused a re-import. The running host process kept the old schema.
Rather than pretend otherwise, the plugin handles it in three ways:
1. **Fields the host schema does not know about stay session-local.** If a new version adds
settings that an older running host has not registered, those choices still take effect on
screen and are not silently discarded by the stale schema.
2. **The settings page says so.** It shows an honest notice: restart the DSH host (`dsh web`)
once, and these values will be written to the settings document and persist.
3. **`adopt()` self-heals.** Once the host restarts and the field really appears, `adopt()`
notices and back-fills the choices accumulated during the session.
## đŦ Keeping the Preview Plain Text
The hover preview is built entirely from the built-in turn outline â the `turnOutline`
projection plus the navigation items of the loaded window. The plugin introduces no other text
source. Concretely:
* Only text blocks are read. Thinking content is not a text block, so it cannot reach the
preview by construction rather than by filtering.
* All whitespace runs (`\s+`) collapse to a single space, so a preview can never contain a
blank line.
* Text is truncated with an ellipsis under fixed character budgets (50 for the prompt, 120 for
the response).
The plugin decides only whether the preview is shown, how many lines it has, its font size and
width, the card height, and which side it expands to. `white-space: normal` and
`overflow-wrap: anywhere` pin down the two failure modes that would otherwise come back:
blank lines produced by wrapping, and horizontal overflow.
## đ The Gradient Curve
With the preview enabled, the tick under the pointer becomes the longest one, and the ticks
around it taper back to the shipped width:
| Distance from the hovered tick | 0 | Âą1 | Âą2 | âĨ3 |
| :--- | :--- | :--- | :--- | :--- |
| Width | 32px | 21px | 14px | 12px |
| Drop per step | â | 11px | 7px | 2px |
The table is generated by `buildGradientWidths()` from a power curve:
```js
12 + 20 * Math.pow(1 - d / 3, 2) // d = distance from the hovered tick, 0..2
```
The result is a hook that bends *toward* the rail: the adjacent tick gives up the most width,
then progressively less, flattening out as it rejoins the rail. Two independent knobs control
it. `GRADIENT_REACH` is the number of levels, and it is also the denominator of the curve, so
removing a level recomputes the middle widths rather than simply dropping the last one.
`GRADIENT_CURVE_EXPONENT` controls how bent the curve is: `1` is a straight line and `2.5` is a
cliff. Both are currently `2`, which is a coincidence, not a coupling.
The anchor is the hovered tick (`_markPreview`), not the selected turn. The active turn's tick
(`_markActive`) is never rewritten.
### Tuning history
The exponent was tuned against real screenshots, and each value is recorded so the reasoning
survives:
| 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** | The compromise in use. First step 11px â still inside the "bent but not steep" 9â12px band â while the hovered tick is 1.52x its neighbour. |
### The clip box
For a long time the hovered tick appeared not to stand out at all, and the cause was not the
curve. The built-in rail frame is 28px wide, and the scroller inside it sets
`overflow-y: auto`. Per the CSS specification, a `visible` value on the other axis computes to
`auto` in that case â **the frame is the ticks' clip box**. Any peak wider than 28px was
clipped, so the curve degenerated into a short diagonal.
The plugin therefore widens the frame to "longest tick + 4px" (currently 32 + 4 = 36px). The
ticks are right-aligned, so nothing moves; the extra width is pure clipping headroom. The
frame is also the rail's hover and click surface, which is the one visible side effect â see
[Known Boundaries](#-known-boundaries).
## â
Verification Status
`node test/verify-client.mjs` runs **121 assertions**, all passing, with no network, no browser,
and no install step. Coverage:
* **Rail discovery** â including decoy frames, a missing rail, and a missing `document`.
* **The gradient** â the width table `32 / 21 / 14`, the decrement sequence `11 / 7 / 2` and its
monotonicity, the first step pinned to the "bent but not steep" 9â12px band, the peak never
exceeding the frame width, the frame width equal to peak + 4 = 36px, two affected ticks per
side, `12px` from the third tick outward, and `_markActive` never being rewritten.
* **Override CSS generation** â thickness, left and right side, the preview toggle, the height
variable tracking line count and font size, `line-clamp`, font size paired with line height,
the container-clamped width, rule convergence when the preview is off, brace balance, and the
absence of `undefined` or `NaN`.
* **Config normalization** â out-of-range clamping, invalid-value fallback, default detection.
* **A full `apply()` against stub services** â dictionary registration, settings scope binding,
style mounting and refresh on config change, settings page registration (including `order`),
write receipts, the legacy-host session-staging path and its notice, the automatic back-fill
when the host catches up, the seven `unset` calls behind "Restore defaults" and the disabled
button state, and cleanup.
* **The settings page component rendered directly** â the seven-tick sample, the width sequence
`12 / 14 / 21 / 32 / 21 / 14 / 12`, the preview card clearing the longest tick by 48px, card
heights of 144px / 198px / 252px, and font size, width, and line count following the config.
`index.js` was exercised against a real profile: it resolves schemastery and builds the schema
(defaults and out-of-range rejection verified by hand), and the registration call is verified
against a stub settings service.
**Not verified, and you should know it:** pixel-level aesthetics â the actual arc of the
gradient, how the font size and card width feel â have not been checked by a machine, because
no browser automation was available in the development environment. Judge those with your eyes.
At the time of writing, a running host process can still hold the older 5-field schema; see
[The Host Half and Its Module Cache](#-the-host-half-and-its-module-cache).
## đ§ Known Boundaries
* **Upstream coupling.** The only structural contract is "a `_frame` that contains a
`_mark`", plus the state classes `_markPreview` (hover) and `_markPosition` (the
wrapper around each tick). If upstream renames these, the gradient stops working silently â
no error, and no effect on conversations themselves.
* **`CSS :has()` is required** for the gradient (Chromium 105+). Browsers without it simply
ignore those rules and ticks fall back to the built-in widths.
* **The rail hides itself 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. That frame is the rail's hover and click surface, so the invisible interactive
strip on the right edge of the conversation is 8px wider: hovering there shows a preview card,
and clicking there jumps to a turn.
* **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 host module cache.** After editing `index.js` you must restart `dsh web`. This is a
consequence of Node's module cache combined with a `link:` install, and the plugin cannot work
around it.
## đ Version History
### 1.2.0
1. **A longer, curved length gradient, with the clip box opened up.** The gradient went from 4
levels (24 / 20 / 16 / 12) to 6, then back down to 4 levels (32 / 21 / 14 / 12) over two
rounds of "one level fewer", affecting 2 ticks per side. The levels are computed from the
power curve, and the frame width is overridden from 28px to 36px so the peak is not clipped.
The anchor is the hovered (preview) tick; the active turn's width is not rewritten.
2. **Preview font size and preview card width.** Font size and line height are rewritten as a
pair, because the built-in line height is a fixed pixel value and changing only the font size
would crowd the lines. Card height is `46px + lineHeight * lines`, so the 12px / 3-line
default is exactly the built-in 100px and the default appearance is unchanged, while 18px /
6 lines is 208px. Width reuses the built-in `width: min(Npx, 100cqw - 120px)` form, so a
narrow window cannot push the card off screen.
3. **"Restore defaults".** All seven fields are `unset` individually, which clears the user
layer and falls back to the schema defaults rather than writing the defaults back as values.
When every field is already at its default, the button is disabled and says so.
### 1.1.0
* The preview line count actually takes effect â the height variable tracks both line count and
font size.
* The length gradient anchors on the preview state rather than on the selected turn.
---
## đ License
[MIT](../LICENSE)