dsh-better-notice 5.1.9 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ dsh plugin --profile <profile> add dsh-better-notice
13
13
  ```
14
14
 
15
15
  - Package: `dsh-better-notice` (module id is the same string)
16
- - Version: `5.1.9` (see [CHANGELOG.md](CHANGELOG.md))
16
+ - Version: `5.2.0` (see [CHANGELOG.md](CHANGELOG.md))
17
17
  - License: MIT
18
18
  - Settings live in **account menu → Settings → “Better Notice”** (a `settings.section` entry, id `hotlist`, order `30`)
19
19
 
@@ -46,7 +46,7 @@ dsh plugin --profile <profile> add dsh-better-notice
46
46
 
47
47
  ## What it does
48
48
 
49
- Four surfaces, one shared decision:
49
+ Five surfaces, one shared decision:
50
50
 
51
51
  1. **Conversation tab row** — each hot session takes one tab seat; click it to switch. The current session does not take a seat (it *is* the “Chat” tab).
52
52
 
@@ -60,6 +60,8 @@ Four surfaces, one shared decision:
60
60
 
61
61
  4. **On state change** — one sound plus one popout image that slides in from a window edge.
62
62
 
63
+ 5. **The whole screen, opt-in** — with one of the three ring switches on, the amber ring rises above every window for a few seconds. Off by default. See [The full-screen ring](#the-full-screen-ring).
64
+
63
65
  The glyph can also be your own square picture instead of an emoji:
64
66
 
65
67
  ![Uploaded square images used as sidebar icons](screenshots/uploaded-glyph.png)
@@ -70,15 +72,17 @@ The glyph can also be your own square picture instead of an emoji:
70
72
 
71
73
  **Only three states pop a picture: `error`, `pending`, `completed`.** In words: *something broke*, *you have to approve / choose / review*, *it just finished*. `pinned`, `running` and `idle` never pop — an idle session leaning into your screen would be noise, not news. A popout stays for a few seconds and then leaves on its own (`popout.displayMs`, default 7 s, configurable per category).
72
74
 
73
- **The bundled artwork follows colour semantics**, and it is **the author's placeholder art**, not a finished design:
75
+ **The bundled artwork is animated and follows the interruption semantics**, and it is **the author's placeholder art**, not a finished design:
74
76
 
75
- | State | Character | Pose | Why this colour |
77
+ | State | Clip | What moves | Why this pose |
76
78
  |---|---|---|---|
77
- | `error` | **red Meru** | `meru2-0-opinion` — leaning out sideways | red reads as warning: something needs you |
78
- | `pending` | **red Meru** | `meru2-0-id` — holding up an ID | red again: *you* have to act |
79
- | `completed` | **blue Lan** | `lan2-0-sleepy` | blue reads as calm / settled: done |
79
+ | `error` | smack | the character punches the big red button | a hard whack reads as *something broke* |
80
+ | `pending` | smack | same clip | a whack at you: **you** have to act |
81
+ | `completed` | wave | the character waves hello | a wave reads as calm / settled: done |
82
+
83
+ Only those three states ship raster art (4 tiers each: 64 / 320 / 780 / 1560 px, `.webp`), and the art is **animated WebP** — no still frames to swap per tick, the browser plays it. The source clips are 500×500, which is 1:1 with the popout's default 390 CSS px at 1.25 DPR, so the `780` and `1560` slots hold the same 500 px animation (there is no more source resolution to be had; the browser scales it up if you enlarge the popout). The `320` tier is the same clip downscaled, and `64` — used only as a settings thumbnail — is a still from frame 0.
80
84
 
81
- Only those three states ship raster art (4 tiers each: 64 / 320 / 780 / 1560 px, `.webp`). `pinned`, `running` and `idle` have no raster art — their popouts fall back to the packaged SVG and then to the emoji glyph. That is why the package is ~700 KB instead of ~1.6 MB, and it costs nothing functionally: those three do not pop by default anyway.
85
+ `pinned`, `running` and `idle` have no raster art — their popouts fall back to the packaged SVG and then to the emoji glyph, which costs nothing functionally: those three do not pop by default anyway. The animated clips are the reason the artwork is ~1.6 MB: a 10-frame 500 px clip cannot be compressed down to a still's size, and trimming frames only makes the motion look broken.
82
86
 
83
87
  **You are meant to replace all of it.** Settings → the category card → *Popout → upload image*, or `window.__HOTLIST__.assets.add(category, 'popout', { name, dataUrl })`. Any square transparent PNG/JPG/WebP/GIF works; SVG is rejected.
84
88
 
@@ -125,10 +129,10 @@ dsh plugin --profile <profile> add dsh-better-notice
125
129
  dsh --profile <profile>
126
130
  ```
127
131
 
128
- - **Published on npm:** [dsh-better-notice](https://www.npmjs.com/package/dsh-better-notice) (current `5.1.9`). If npm is unreachable, the same tarball is attached to the [latest release](https://github.com/MeruSu/better-notice/releases) — `dsh plugin --profile <profile> add <absolute path>/dsh-better-notice-5.1.9.tgz` gives the same result and the same restart.
132
+ - **Published on npm:** [dsh-better-notice](https://www.npmjs.com/package/dsh-better-notice) (current `5.2.0`). If npm is unreachable, the same tarball is attached to the [latest release](https://github.com/MeruSu/better-notice/releases) — `dsh plugin --profile <profile> add <absolute path>/dsh-better-notice-5.2.0.tgz` gives the same result and the same restart.
129
133
  - **Use an absolute path, forward slashes** when you install from a local directory instead of the registry: `dsh plugin` forwards the argument to pnpm *inside the profile directory*, so a relative path points into the profile.
130
134
  - Installing from a directory uses `link:` — code edits need no reinstall. `client.js` changes apply on page refresh; `index.js` / `cordis.patch.yml` / `package.json` changes need a GUI restart.
131
- - Install from a git checkout when you also want the TTS helper (see [Sounds](#sounds-and-the-bundled-tts-tool)). The npm package's `files` field only ships `index.js`, `client.js`, `cordis.patch.yml` and `assets`, so `tools/` is **not** in it.
135
+ - Install from a git checkout when you also want the TTS helper (see [Sounds](#sounds-and-the-bundled-tts-tool)). The npm package's `files` field ships `index.js`, `client.js`, `cordis.patch.yml`, `assets`, `locale`, `icon.svg` and `tools/attn-ring`; `tools/meru-tts` is **not** in it.
132
136
 
133
137
  Uninstall:
134
138
 
@@ -150,7 +154,7 @@ The host route is created during process start-up, so a page refresh is not enou
150
154
 
151
155
  In the GUI's browser console:
152
156
 
153
- 1. `window.__HOTLIST_VERSION__` → the version string, e.g. `'5.1.9'`.
157
+ 1. `window.__HOTLIST_VERSION__` → the version string, e.g. `'5.2.0'`.
154
158
  2. `Object.keys(window.__HOTLIST__)` → `['version','settings','assets']`.
155
159
  3. Settings → the left nav has **“Better Notice”**, and the page shows a global block plus **6** collapsible category cards.
156
160
  4. Session rows in the sidebar now start with a status glyph.
@@ -224,6 +228,57 @@ All three modes **strip the emoji already in the session title**, so you never g
224
228
 
225
229
  Settings shows this as six chips on the second row of the category card, and the whole row disappears while the mode is `text` or `icon`, because it would do nothing there.
226
230
 
231
+ ## The full-screen ring
232
+
233
+ Every surface above draws inside the page. When a session needs you and the GUI
234
+ is buried behind something else, this plugin can also ring the **whole screen**:
235
+ an amber ring with a glow rises above every window for a few seconds.
236
+
237
+ It is **off by default** — three switches in their own group right below the
238
+ global block, all `false`:
239
+
240
+ | Switch | Rings when | Default |
241
+ |---|---|---|
242
+ | `ring.pending` | a session starts waiting for your approval / answer / plan review | `false` |
243
+ | `ring.error` | a session fails its turn | `false` |
244
+ | `ring.completed` | a session just finishes | `false` |
245
+
246
+ - It fires on the **moment a session enters that state**, not on every repaint.
247
+ The judgement is the same edge detector the sounds use: blank sessions and
248
+ subagents are skipped, and the order is `error > pending > completed`. It has
249
+ its own 1500 ms throttle, so a burst of state changes cannot stack windows —
250
+ and the first sync after a page load only arms it, so opening the GUI never
251
+ rings.
252
+ - It stays a few seconds and leaves on its own. **A click or `Esc` closes it at
253
+ once.**
254
+ - It never takes keyboard focus, so typing in the session underneath keeps
255
+ working. The click that dismisses it is consumed by the ring — that one click
256
+ does not reach the window behind it.
257
+ - Turning a switch on is a **user choice**: it is recorded in `choices`, so the
258
+ factory-preset upgrade rule will not switch it back off.
259
+
260
+ **Why it is a separate process.** A page cannot draw outside its own window;
261
+ covering the Windows taskbar and other applications is exactly the thing a DOM
262
+ element cannot do. So the ring is drawn by `tools/attn-ring/Show-AttnRing.ps1` —
263
+ PowerShell + WPF, zero dependencies — and it **ships inside the npm package**
264
+ (`files` includes `tools/attn-ring`), so an installed copy can ring on its own.
265
+
266
+ The page only asks the host half to start it:
267
+
268
+ ```
269
+ GET /hotlist-ring?seconds=4&scope=Primary&anim=Expand
270
+ x-hotlist-ring: 1 # required; without it the route refuses
271
+ ```
272
+
273
+ | Gate | Answer | Why |
274
+ |---|---|---|
275
+ | no `x-hotlist-ring: 1` header | `403` | a bare `<img src>` cannot set a header, so no web page can pop a full-screen window |
276
+ | a second request within 2.5 s | `429` | a burst of state changes must not stack processes |
277
+ | the script is missing from the install | `503` | a partial install stays silent instead of throwing |
278
+
279
+ Windows only, and it covers the primary display — see
280
+ [Known limitations](#known-limitations).
281
+
227
282
  ## Customization index
228
283
 
229
284
  Field reference tables are below; this section is organized by intent. Everything here is editable in **Settings → “Better Notice”** and applies immediately, no reload. The few rows marked *API only* have no control yet — use `window.__HOTLIST_SETTINGS__.set(...)`.
@@ -314,6 +369,7 @@ Field-name-level documentation for people writing scripts.
314
369
  - **Collapsed card:** glyph preview, category name, `· order N`, five read-only chips (Tab / Bar / Top / Pop / Snd — highlighted = on), caret. Click the row to expand; **several cards may be open at once**. Open state lives in `sessionStorage` (`dsh.hotlist.open`), never in settings.
315
370
  - **Expanded area** (compact multi-column): ① glyph (source / upload / value / frequency slider); ② motion (`motion` + target `glyph`/`row`); ③ popout (enabled / image / entrance·ambient·exit / side·display time); ④ sound (enabled / source / preview); ⑤ order.
316
371
  - **Global block:** scope (workspace / all / smart), seat limit, **pinned seats**, completed TTL in minutes, global volume, **recent window** (minutes), `positions.tab`, `positions.topbar`, `positions.sidebar` (API only), `takeOverStatusDot`. The band block (style / label display / pill count / pill width / web preview) only exists while `positions.topbar` is on.
372
+ - **Full-screen ring block**, directly below the global block: `ring.pending`, `ring.error`, `ring.completed` — all off. See [The full-screen ring](#the-full-screen-ring).
317
373
  - Out-of-range writes are clamped. A failed write (localStorage quota) is reported in the page footer and through `window.__HOTLIST_SETTINGS__.lastError()`.
318
374
 
319
375
  ### Global defaults
@@ -334,6 +390,7 @@ Field-name-level documentation for people writing scripts.
334
390
  | `topbar.customScope` | all `true` | six booleans, only read in `custom` mode |
335
391
  | `choices` | `{}` | keys the user actually picked; written by `writeSettings()`, never by hand |
336
392
  | `takeOverStatusDot` | `true` | take over the status dot drawn by other plugins (`dsh-sessions-manager`) |
393
+ | `ring.pending` / `ring.error` / `ring.completed` | all `false` | one ring on top of the screen when that state appears; Windows only, primary display only — see [The full-screen ring](#the-full-screen-ring) |
337
394
  | `hideNativeDot` | `true` | hide the host's native status slot (no settings row since 4.4; field kept) |
338
395
  | `collect.completedTtlMs` | `420000` (7 min) | 1–1440 minutes |
339
396
  | `collect.recentWindowMinutes` | `17` | **minutes**. Settings offers `1`–`120`; API also accepts `0` (= hide `idle`) |
@@ -412,7 +469,7 @@ Rules for agents:
412
469
 
413
470
  The **GitHub repository** contains `tools/meru-tts/`: a single-file Python script that turns one sentence into an mp3 or wav. It is **not part of the running plugin** — it only produces assets.
414
471
 
415
- > **Limit up front:** `dsh plugin add` **cannot** give you this tool — the npm package has no `tools/` directory (`files` only lists `index.js`, `client.js`, `cordis.patch.yml`, `assets`). “Install the plugin” and “build your own cues” are two separate paths; the tool requires cloning the repo.
472
+ > **Limit up front:** the npm package now ships `tools/attn-ring` — the full-screen ring script has to travel with the plugin, otherwise an installed copy cannot ring — but it still does **not** ship `tools/meru-tts`: 1.6 MB of `pylibs` has no business on every machine that installs a notification plugin. So “install the plugin” and “build your own cues” remain two separate paths, and this one still requires cloning the repo.
416
473
 
417
474
  ### 2. Where the API key comes from (there is a free tier)
418
475
 
@@ -500,7 +557,7 @@ s.lastError() // last storage write error (string or null)
500
557
 
501
558
  // settings + the three asset pools
502
559
  const api = window.__HOTLIST__;
503
- api.version // '5.1.9'
560
+ api.version // '5.2.0'
504
561
  api.settings.get() / .set(patch) / .reset() / .subscribe(fn)
505
562
  api.settings.backups() // → [{ at, version }]
506
563
  api.settings.restore(index) // → { ok, at? }
@@ -541,6 +598,7 @@ More examples (bulk import, loading assets from URLs, the three preview paths) i
541
598
  10. **Motion is sampled, not always-on.** Each category rolls its own frequency; on a hit, one seat of that category plays one animation. At most 4 animate at once (`MOTION_CONCURRENCY`).
542
599
  11. **Sounds have a 1.5 s global throttle.** A cue arriving less than 1500 ms after the previous one is dropped; the first round after a cold start only records. This is verified by source and unit test (group `sound player dedupe`) — on a real machine it is hard to produce two genuine transitions that fast.
543
600
  12. **Settings are bound to the page origin.** `localStorage` belongs to the origin, so a different GUI port means a different origin.
601
+ 13. **The full-screen ring is Windows-only and covers the primary display.** The script is PowerShell + WPF, and the page can only *ask* the host to run it: on other platforms the switch saves your choice and nothing rises. It also covers the primary screen only, so a second monitor does not flash.
544
602
 
545
603
  ## Modifying this plugin
546
604
 
@@ -554,8 +612,8 @@ One npm package, two halves:
554
612
 
555
613
  | File | Runs in | Does |
556
614
  |---|---|---|
557
- | `index.js` | Host (Node) | **One job only**: registers the `/hotlist-sound` prefix route that serves `assets/sounds/*.wav`, because the host's shared asset route only accepts images (`.wav` / `.mp3` would 404 there). |
558
- | `client.js` | Browser | **All UI logic** (~4,700 lines, single file, no build): tab seats, sidebar glyphs, popout, sounds, settings page. Registered via `window.__ModuleLoader__.load({ id: '<package name>' })`. |
615
+ | `index.js` | Host (Node) | **Two prefix routes, nothing else**: `/hotlist-sound` serves `assets/sounds/*.wav` (the host's shared asset route only accepts images, so audio would 404 there) and `/hotlist-ring` starts the full-screen ring script. No UI, no state. |
616
+ | `client.js` | Browser | **All UI logic** (~7,000 lines, single file, no build): tab seats, sidebar glyphs, top bar, popout, sounds, ring triggers, settings page. Registered via `window.__ModuleLoader__.load({ id: '<package name>' })`. |
559
617
  | `cordis.patch.yml` | Config | The bundle insert line that makes a profile aware of the package. |
560
618
 
561
619
  `client.js` pulls in no dependencies except `require('react')`; DOM is written with `h()` (an alias of `React.createElement`). No bundling and no `npm install` are needed to change it.
@@ -569,24 +627,24 @@ ctx.get('uiWorkspace').workspaces.list ← workspace grouping + pinn
569
627
  ctx.remote.$on('api-session/error', …) ← agent-level errors
570
628
  │
571
629
  ▼
572
- classify() category + token client.js:1900
573
- sortSeats() ordering within a category client.js:1977
574
- buildSeatPlan() apply limits, assign order client.js:2000
630
+ classify() category + token client.js:2082
631
+ sortSeats() ordering within a category client.js:2148
632
+ buildSeatPlan() apply limits, assign order client.js:2171
575
633
  │
576
634
  ┌───────────┴────────────┬──────────────────┐
577
635
  ▼ ▼ ▼
578
636
  mountTabSeats() createSidebarAdapter() HotlistSettingsPage()
579
637
  tab seats (conversation.view) sidebar DOM settings page (settings.section)
580
- client.js:5233 client.js:2633 client.js:3368
638
+ client.js:5582 client.js:2993 client.js:3741
581
639
  ```
582
640
 
583
- The three pure functions (`classify` / `sortSeats` / `buildSeatPlan`) never touch the DOM and can be fed fake data — they are the safest entry point for behaviour changes. Their results are also the only debugging handle besides `window.__HOTLIST__`: the export table at the end of `client.js` (`client.js:6036` onwards) exposes `classify`, `sortSeats`, `buildSeatPlan`, `readSettings`, `defaultSettings`, `sanitizeSettings`, `sidebarStateOf`, `HotlistSettingsPage` and more, and the unit tests run the real source through it.
641
+ The three pure functions (`classify` / `sortSeats` / `buildSeatPlan`) never touch the DOM and can be fed fake data — they are the safest entry point for behaviour changes. Their results are also the only debugging handle besides `window.__HOTLIST__`: the export table at the end of `client.js` (`client.js:6932` onwards) exposes `classify`, `sortSeats`, `buildSeatPlan`, `readSettings`, `defaultSettings`, `sanitizeSettings`, `sidebarStateOf`, `HotlistSettingsPage` and more, and the unit tests run the real source through it.
584
642
 
585
643
  ### Touch points: change one thing, change these
586
644
 
587
645
  **1) Add a category**
588
646
 
589
- `CATEGORY_IDS` (`client.js:180`) → `CATEGORY_ORDER` (`client.js:181`) → `CATEGORY_GLYPHS` (`client.js:200`) → `CATEGORY_MOTION` (`client.js:201`) → `CATEGORY_SURFACES` (`client.js:238`, value is `{tab, sidebar, topbar}`) → defaults in `defaultCategory()` (`:246`) → accept the key in `sanitizeSettings()` (`:764`) → one rule in `classify()` (`:1477`) → one entry each in the dictionaries `ZH` (`:307`) / `EN` (`:431`) → one new group in `test/classify.test.mjs`. Missing `CATEGORY_GLYPHS` usually just means a blank icon; missing `CATEGORY_SURFACES` throws inside `defaultCategory()`.
647
+ `CATEGORY_IDS` (`client.js:184`) → `CATEGORY_ORDER` (`client.js:185`) → `CATEGORY_GLYPHS` (`client.js:204`) → `CATEGORY_MOTION` (`client.js:205`) → `CATEGORY_SURFACES` (`client.js:258`, value is `{tab, sidebar, topbar}`) → defaults in `defaultCategory()` (`:359`) → accept the key in `sanitizeSettings()` (`:1192`) → one rule in `classify()` (`:2082`) → one entry each in the dictionaries `ZH` (`:497`) / `EN` (`:696`) → one new group in `test/classify.test.mjs`. Missing `CATEGORY_GLYPHS` usually just means a blank icon; missing `CATEGORY_SURFACES` throws inside `defaultCategory()`.
590
648
 
591
649
  **2) Add a setting**
592
650
 
@@ -594,7 +652,7 @@ Five places, or you get “writes but does not read” / “Chinese leaking into
594
652
 
595
653
  **3) Add a motion**
596
654
 
597
- Add the name to `MOTION_NAMES` (`client.js:247`) → a duration to `MOTION_MS` (`client.js:2375`) → a `@keyframes hl-<name>` to `SIDEBAR_STYLE` (`client.js:2397`) (animate `transform` / `opacity` only, never layout-triggering properties) → for the title variant, one entry each in `TEXT_MOTION` (`client.js:2384`) and `TEXT_BURST` (`client.js:2391`) plus `@keyframes hl-text-<name>`. Concurrency is capped by `MOTION_CONCURRENCY` (`client.js:286`).
655
+ Add the name to `MOTION_NAMES` (`client.js:267`) → a duration to `MOTION_MS` (`client.js:2725`) → a `@keyframes hl-<name>` to `SIDEBAR_STYLE` (`client.js:2747`) (animate `transform` / `opacity` only, never layout-triggering properties) → for the title variant, one entry each in `TEXT_MOTION` (`client.js:2734`) and `TEXT_BURST` (`client.js:2741`) plus `@keyframes hl-text-<name>`. Concurrency is capped by `MOTION_CONCURRENCY` (`client.js:305`).
598
656
 
599
657
  **4) Add a popout-art slot**
600
658
 
@@ -602,11 +660,11 @@ Art is `assets/<category>-<tier>.webp` (tiers 64 / 320 / 780 / 1560) for the thr
602
660
 
603
661
  **5) Replace a bundled sound**
604
662
 
605
- `BUILTIN_SOUND` (`client.js:306`) is the only table mapping category → filename (`SOUND_CATEGORIES` (`client.js:308`) is derived from it). Put the new wav in `assets/sounds/`, edit the table, keep `assets/sounds/manifest.json` in sync. The URL is built from `SOUND_URL` (`client.js:305`) as `/hotlist-sound/<file>`; the actual serving is `serveSound()` in `index.js` — a **GUI restart is mandatory** (host routes register at start-up).
663
+ `BUILTIN_SOUND` (`client.js:325`) is the only table mapping category → filename (`SOUND_CATEGORIES` (`client.js:327`) is derived from it). Put the new wav in `assets/sounds/`, edit the table, keep `assets/sounds/manifest.json` in sync. The URL is built from `SOUND_URL` (`client.js:324`) as `/hotlist-sound/<file>`; the actual serving is `serveSound()` in `index.js` — a **GUI restart is mandatory** (host routes register at start-up).
606
664
 
607
665
  **6) Change ordering or priority**
608
666
 
609
- - **Display order:** `CATEGORY_ORDER` (`client.js:181`) plus each category's `order`, consumed by `sortSeats()` (`:1541`). Placement only, never classification.
667
+ - **Display order:** `CATEGORY_ORDER` (`client.js:185`) plus each category's `order`, consumed by `sortSeats()` (`:2148`). Placement only, never classification.
610
668
  - **Classification priority:** the order of the `if`s inside `classify()` (`:1477`). A session belongs to one category; **first match wins**.
611
669
  - They are separate on purpose: users may change `order` but cannot decide who wins a session. Editing `classify` changes semantics — always re-run the `category order` and `sidebar priority` test groups.
612
670
 
@@ -616,10 +674,10 @@ Sidebar titles can carry a CSS animation; **tab seats cannot** — the tab label
616
674
 
617
675
  - Field: `settings.categories.<id>.glyph.motionTarget`
618
676
  - Values: `'glyph'` = icon only; `'row'` = icon + title, started and stopped together.
619
- - **Since 4.6.0 the default is `'row'`** (`defaultGlyph()`, `client.js:327`).
677
+ - **Since 4.6.0 the default is `'row'`** (`defaultGlyph()`, `client.js:346`).
620
678
 
621
679
  ```js
622
- // Option A: change the default for every category (source edit, defaultGlyph() at client.js:327)
680
+ // Option A: change the default for every category (source edit, defaultGlyph() at client.js:346)
623
681
  function defaultGlyph(category) {
624
682
  return {
625
683
  kind: 'emoji',
@@ -634,7 +692,7 @@ function defaultGlyph(category) {
634
692
  ```
635
693
 
636
694
  ```js
637
- // Option B: per category (source edit, override inside defaultCategory() at client.js:271)
695
+ // Option B: per category (source edit, override inside defaultCategory() at client.js:359)
638
696
  // e.g. only "pending" animates its title, everything else animates the icon only:
639
697
  glyph: { ...defaultGlyph(category), motionTarget: category === 'pending' ? 'row' : 'glyph' },
640
698
  ```
@@ -648,16 +706,16 @@ window.__HOTLIST_SETTINGS__.set({
648
706
 
649
707
  **Option C has a trap you must know:** `set` merges only two levels, and `glyph` / `popout` / `sound` are replaced as whole blocks — the line above resets `pending.glyph`'s `value`, `motion` and `frequency`. Do `get()`, edit the whole object, `set()`.
650
708
 
651
- Adding a new title motion: one entry each in `TEXT_MOTION` (`client.js:2384`, looping) and `TEXT_BURST` (`client.js:2391`, one-shot), a duration in `MOTION_MS` (`client.js:2375`), and `@keyframes hl-text-<name>` in `SIDEBAR_STYLE` (`client.js:2397`). Title keyframes **only animate `opacity` and 1–2 px of translation, never `scale`** (text goes blurry); concurrency is still capped by `MOTION_CONCURRENCY` (`client.js:286`) and the rate is the per-category `glyph.frequency` (0–1, `0` = never).
709
+ Adding a new title motion: one entry each in `TEXT_MOTION` (`client.js:2734`, looping) and `TEXT_BURST` (`client.js:2741`, one-shot), a duration in `MOTION_MS` (`client.js:2725`), and `@keyframes hl-text-<name>` in `SIDEBAR_STYLE` (`client.js:2747`). Title keyframes **only animate `opacity` and 1–2 px of translation, never `scale`** (text goes blurry); concurrency is still capped by `MOTION_CONCURRENCY` (`client.js:305`) and the rate is the per-category `glyph.frequency` (0–1, `0` = never).
652
710
 
653
711
  ### Upgrades never overwrite user settings
654
712
 
655
- **Promise: upgrading the plugin never changes a setting the user already chose.** A value that still equals one of our own earlier factory defaults is not a choice — it follows the current default (the `LEGACY_VALUES` bullet below). Everything else on disk wins. A field the user *did* pick is his even when the value coincides with a preset: `choices` (`client.js:1805`) records those picks and the preset rule reads it before rewriting anything.
713
+ **Promise: upgrading the plugin never changes a setting the user already chose.** A value that still equals one of our own earlier factory defaults is not a choice — it follows the current default (the `LEGACY_VALUES` bullet below). Everything else on disk wins. A field the user *did* pick is his even when the value coincides with a preset: `choices` (`client.js:1967`) records those picks and the preset rule reads it before rewriting anything.
656
714
 
657
715
  How that is guaranteed:
658
716
 
659
717
  - **Defaults only fill gaps**; anything on disk wins. `sanitizeSettings()` starts from the defaults and then, field by field, keeps any valid stored value — so changing `CATEGORY_ORDER` or `defaultGlyph()` is invisible to existing users.
660
- - **Factory presets are ours, not the user's.** A stored value that only ever equalled a default an earlier release shipped — `💤` for the idle glyph, `blink` / `none` motion, `motionTarget: 'glyph'`, frequency `1`, sound off — follows the current default, so an existing install does eventually see the ice cube and the 4.6.3 motion table without the user re-doing anything. The table is `LEGACY_VALUES` (`client.js:210`); the same boundary already used for a category `order` that equals a historical default (`LEGACY_DEFAULTS`). As soon as the user edits that field the value is theirs and we never touch it again, and a value nobody ever shipped as a default is kept byte for byte. "Edited" is recorded explicitly: `choices` `{ "<path>": true }` (`client.js:702-737`) is written by `writeSettings()` from the incoming patch only, so a value the preset rule rewrote can never pass for a choice; `resetSettings()` clears the whole map because a reset is a fresh acceptance of the presets.
718
+ - **Factory presets are ours, not the user's.** A stored value that only ever equalled a default an earlier release shipped — `💤` for the idle glyph, `blink` / `none` motion, `motionTarget: 'glyph'`, frequency `1`, sound off — follows the current default, so an existing install does eventually see the ice cube and the 4.6.3 motion table without the user re-doing anything. The table is `LEGACY_VALUES` (`client.js:214`); the same boundary already used for a category `order` that equals a historical default (`LEGACY_DEFAULTS`). As soon as the user edits that field the value is theirs and we never touch it again, and a value nobody ever shipped as a default is kept byte for byte. "Edited" is recorded explicitly: `choices` `{ "<path>": true }` (`client.js:1009-1050`) is written by `writeSettings()` from the incoming patch only, so a value the preset rule rewrote can never pass for a choice; `resetSettings()` clears the whole map because a reset is a fresh acceptance of the presets.
661
719
  - **Adding a field:** a fallback in `sanitize*` is enough, **do not bump `SETTINGS_VERSION`**. A version bump claims “a migration is required”, and a wrong migration silently wipes user data — no structural change, no version change.
662
720
  - **Real structural changes** (dropping a field, changing its meaning) must keep the chain “read old → convert → drop the old field” (today that lives in `sanitizeSettings()`; the v3.1 → v4 migration went through it) and bump `SETTINGS_VERSION` by the smallest step. The `migrate v3 to v4` test group is the acceptance test for that chain.
663
721
 
@@ -684,7 +742,7 @@ The same ground as the pitfall list at the bottom of this file, in checklist for
684
742
  ```powershell
685
743
  node --check client.js # syntax. client.js lives inside a module loader, so a syntax error only explodes when the real page boots
686
744
  node --check index.js
687
- node test/classify.test.mjs # expect a final line: PASS — 107 groups
745
+ node test/classify.test.mjs # expect a final line: PASS — 109 groups
688
746
  ```
689
747
 
690
748
  Plus a **static gate before saving**, available on the maintainer's machine only (not shipped with the repo):
@@ -700,7 +758,7 @@ cd <the directory you cloned into>
700
758
 
701
759
  node --check client.js # client.js lives inside a module loader, so a syntax error only explodes when the real page boots
702
760
  node --check index.js
703
- node test/classify.test.mjs # expect a final line: PASS — 107 groups
761
+ node test/classify.test.mjs # expect a final line: PASS — 109 groups
704
762
  ```
705
763
 
706
764
  The unit tests read the real `client.js` source, wrap it in a stub `window.__ModuleLoader__` and run it: the fake browser is hand-written (`window` / `document`, no jsdom, `React` is a stub too), the code under test is the real source. They also include a few static scans: undeclared constants, CSS selector legality, and “shortcut labels must be thunks”.
@@ -715,8 +773,9 @@ index.js host side: registers the /hotlist-sound/ audio route
715
773
  cordis.patch.yml bundle insert line
716
774
  assets/ 3 categories with 4 raster tiers of popout art + svg for all six
717
775
  assets/sounds/ bundled cues (attention.wav / complete.wav)
718
- test/classify.test.mjs unit tests (107 groups)
776
+ test/classify.test.mjs unit tests (109 groups)
719
777
  docs/ architecture, testing, extending
778
+ tools/attn-ring/ full-screen ring script (PowerShell + WPF; ships in the package)
720
779
  tools/meru-tts/ TTS helper (generates cues; not part of the running plugin)
721
780
  ```
722
781
 
@@ -724,7 +783,7 @@ tools/meru-tts/ TTS helper (generates cues; not part of the running plugi
724
783
 
725
784
  Eleven things that have already cost real debugging time — including a broken release. Each one says what happens if you ignore it.
726
785
 
727
- 1. **Callbacks handed to the host must be thunks, not values.** The host calls `command.label()` itself inside `refreshLabels()` (`dsh-client-shortcuts/lib/client.js:1190`). Pass a string and you get `TypeError: command.label is not a function`, which takes `uiWorkspace` down with it: **the whole Web UI boots to `Failed to load plugins` with roughly twenty plugin entries stuck pending.** That is exactly how v4.3.0 shipped broken. The settings section's `label: () => t('settings.nav')` (`client.js:3987`) is a thunk for the same reason. The unit test cannot catch it because the stubbed `shortcuts` service never invokes `label()` — **if the host will call a callback you pass in, make the stub actually call it.**
786
+ 1. **Callbacks handed to the host must be thunks, not values.** The host calls `command.label()` itself inside `refreshLabels()` (`dsh-client-shortcuts/lib/client.js:1190`). Pass a string and you get `TypeError: command.label is not a function`, which takes `uiWorkspace` down with it: **the whole Web UI boots to `Failed to load plugins` with roughly twenty plugin entries stuck pending.** That is exactly how v4.3.0 shipped broken. The settings section's `label: () => t('settings.nav')` (`client.js:5230`) is a thunk for the same reason. The unit test cannot catch it because the stubbed `shortcuts` service never invokes `label()` — **if the host will call a callback you pass in, make the stub actually call it.**
728
787
  *If you ignore it:* the plugin does not just fail to work, it prevents the entire page from loading.
729
788
 
730
789
  2. **Package name, module id and bundle entry must all be the same string.** `package.json`'s `name` = the `id` in `window.__ModuleLoader__.load({ id })` = the entry recorded in the profile's `dsh.profile.bundles`.
@@ -736,7 +795,7 @@ Eleven things that have already cost real debugging time — including a broken
736
795
  4. **The static routes this plugin registers are unauthenticated.** `/hotlist-sound/*` and `/plugins/**/assets/*` do not check a token — anyone who can reach the port can fetch them.
737
796
  *If you ignore it:* you publish a path that leaks whatever you put behind it. Public assets only, never user data.
738
797
 
739
- 5. **User settings may never be overwritten.** `sanitizeSettings()` (`client.js:1085`) is pure and falls back **per field, never wholesale**; unknown keys are parked in `PRESERVED_KEY` (`client.js:165`) and come back on the next read; out-of-range values are recorded (`outOfRangeOf`, `client.js:1530`) and restored; the last five writes live in a rolling backup ring (`BACKUP_KEY`, `client.js:167`, `BACKUP_LIMIT = 5`). Never rebuild the settings object from `defaultSettings()` inside a migration — that silently wipes every custom value the user has.
798
+ 5. **User settings may never be overwritten.** `sanitizeSettings()` (`client.js:1192`) is pure and falls back **per field, never wholesale**; unknown keys are parked in `PRESERVED_KEY` (`client.js:169`) and come back on the next read; out-of-range values are recorded (`outOfRangeOf`, `client.js:1692`) and restored; the last five writes live in a rolling backup ring (`BACKUP_KEY`, `client.js:171`, `BACKUP_LIMIT = 5`). Never rebuild the settings object from `defaultSettings()` inside a migration — that silently wipes every custom value the user has.
740
799
  *If you ignore it:* a user loses their whole configuration and cannot tell when it happened. Recovery is only `window.__HOTLIST__.settings.backups()` / `.restore(i)`.
741
800
 
742
801
  6. **Only bump `SETTINGS_VERSION` when the data structure really changed — and then write the migration in the same commit.** Cosmetic defaults and new optional fields need a fallback in `sanitize*`, not a version bump.
@@ -745,10 +804,10 @@ Eleven things that have already cost real debugging time — including a broken
745
804
  7. **A `client.js` change needs a page refresh; everything else needs a GUI restart.** `index.js`, `package.json` and `cordis.patch.yml` are read at host start-up (the `/hotlist-sound` route is created there). Loaded client modules are not hot-replaced either.
746
805
  *If you ignore it:* you debug stale code and conclude the change did nothing.
747
806
 
748
- 8. **Three hard caps are hard-coded, not settings.** `PLAN_MAX_ROWS = 37` (`client.js:203`) trims from the tail so the priority head always survives; `MOTION_CONCURRENCY = 4` (`client.js:217`) limits simultaneous animation; `PINNED_SEATS = {min: 0, max: 12}` (`client.js:200`).
807
+ 8. **Three hard caps are hard-coded, not settings.** `PLAN_MAX_ROWS = 37` (`client.js:292`) trims from the tail so the priority head always survives; `MOTION_CONCURRENCY = 4` (`client.js:305`) limits simultaneous animation; `PINNED_SEATS = {min: 0, max: 12}` (`client.js:289`).
749
808
  *If you ignore it:* you "fix" a truncated plan by raising `maxSeats`, which cannot exceed the row cap.
750
809
 
751
- 9. **There is exactly one source of truth for priority, and it is two functions.** `classify()` (`client.js:1900-1969`) decides tab seats and popouts; `sidebarStateOf()` (`client.js:2605-2631`) decides the sidebar glyph. They share the order `error > pending > completed > running > pinned > idle` but apply different extra conditions.
810
+ 9. **There is exactly one source of truth for priority, and it is two functions.** `classify()` (`client.js:2082-2140`) decides tab seats and popouts; `sidebarStateOf()` (`client.js:2955-2991`) decides the sidebar glyph. They share the order `error > pending > completed > running > pinned > idle` but apply different extra conditions.
752
811
  *If you ignore it:* you edit one and the same session shows two different states on two surfaces — that was a real bug before 4.6.0.
753
812
 
754
813
  10. **`dsh plugin add` on an already-installed plugin does not rewrite the bundle entry, and a running GUI holds the files open.** Remove first, then add; and close that profile's GUI before installing over it, otherwise the copy fails with `EPERM`.
@@ -765,6 +824,7 @@ Eleven things that have already cost real debugging time — including a broken
765
824
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — module structure, classification and ordering semantics, and the constraints that look dirty but would get worse if “cleaned up”
766
825
  - [docs/TESTING.md](docs/TESTING.md) — what each unit-test group covers, and the real-machine acceptance checklist
767
826
  - [docs/EXTENDING.md](docs/EXTENDING.md) — public API, settings shape, asset limits, bulk import
827
+ - [tools/attn-ring/README.md](tools/attn-ring/README.md) — the ring script's own docs (ships in the package)
768
828
  - [tools/meru-tts/README.md](tools/meru-tts/README.md) — the TTS helper's own docs (only when you cloned the repo)
769
829
  - [CHANGELOG.md](CHANGELOG.md) — version history
770
830