dsh-web-icon-indicator 0.4.1 → 0.5.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/lib/index.js CHANGED
@@ -40,10 +40,20 @@
40
40
  * Configuration object (all optional):
41
41
  * iconsDir Absolute directory holding base.svg. Default `<package>/icons/`.
42
42
  * statusPath JSON status endpoint path. Default `/dsh-web-icon-status.json`.
43
+ * Registration-time: composition entry only (never settings.yaml).
43
44
  * iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
45
+ * Registration-time: composition entry only (never settings.yaml).
44
46
  * askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
45
47
  * doneHoldMs How long the done state stays before falling back to idle.
46
48
  * Default 5000.
49
+ * defaultColor Default icon color — the idle whale's primary color, as
50
+ * `#rgb` / `#rrggbb`. Absent = the idle state's own colors[0]
51
+ * (today's behavior). Give each DSH instance (its own profile /
52
+ * settings.yaml) a different value to tell their browser tabs
53
+ * apart. A value perceptually too close to another state's
54
+ * color raises a WARNING — surfaced in the settings card, in
55
+ * the host log, and as `warnings` on the status endpoint — but
56
+ * is still honored.
47
57
  * states Per-state visual config, keyed by state name:
48
58
  * states[idle] = { effect, colors[], speed? }
49
59
  * states[running] = { effect, colors[], speed? }
@@ -121,8 +131,18 @@ const STATE_CONFIG_SCHEMA = z.object({
121
131
  const CONFIG_SCHEMA = z.object({
122
132
  askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
123
133
  doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
124
- statusPath: z.string().default(DEFAULTS.statusPath),
125
- iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
134
+ // Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
135
+ // default: an absent value keeps the idle state's own colors[0], exactly as
136
+ // before this key existed. `resolveConfig` folds a valid value into
137
+ // `states.idle.colors[0]` and drops an invalid one with a warning.
138
+ defaultColor: z.string(),
139
+ // `statusPath` / `iconPathPrefix` are deliberately NOT part of this schema.
140
+ // They are baked at registration time (the route table and the injected
141
+ // script's URLs are built in apply()), so a settings-document change to them
142
+ // could never take effect: the browser would poll the new path while the
143
+ // server still served the old one — a dead favicon. Keeping them out of the
144
+ // schema means the settings surface cannot advertise a key it cannot honor;
145
+ // they stay composition-entry only (see the README config table).
126
146
  iconsDir: z.string(),
127
147
  // dict (not a four-key object): the user layer may override only some
128
148
  // states; the plugin merges the resolved value over DEFAULTS anyway.
@@ -143,6 +163,7 @@ const INJECTED_SCRIPT = `
143
163
  var BIG_NUM_MIN = 2; // show the full-frame number while more than one agent is active
144
164
  var BIG_NUM_RX = 11; // count-block corner radius (demo/badge.html bigNum channel)
145
165
  var ANIM_START = null; // wall-clock start of the current loop (hidden-tab fallback)
166
+ var FAILED = false; // status poll currently failing (restore once per outage)
146
167
  var STATUS_PATH = "__STATUS_PATH__";
147
168
  var BASE_PATH = "__BASE_PATH__";
148
169
  var CFG = __CFG__; // { states: { idle:{effect,colors,speed}, running:…, asking:…, done:… } }
@@ -155,13 +176,68 @@ const INJECTED_SCRIPT = `
155
176
  // Whale geometry (used as the transform pivot for scale/translate effects).
156
177
  var CX = 27.889625, CY = 24.952640;
157
178
 
179
+ // Every request this script makes is deadline-bounded: a fetch that hangs
180
+ // (half-open TCP, a suspended host, a captive portal) would otherwise hold
181
+ // the poll chain and the base.svg load forever, freezing the icon with no
182
+ // recovery. Aborting surfaces as a rejection, so the existing retry paths
183
+ // (poll keeps its interval; base.svg retries on the next tick) take over.
184
+ function timedFetch(url, init) {
185
+ if (typeof AbortController === "undefined") return fetch(url, init);
186
+ var ctrl = new AbortController();
187
+ var timer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
188
+ var opts = init || {};
189
+ opts.signal = ctrl.signal;
190
+ return fetch(url, opts).then(
191
+ function (r) { clearTimeout(timer); return r; },
192
+ function (e) { clearTimeout(timer); throw e; }
193
+ );
194
+ }
195
+
196
+ // Capture the shell's own favicon ONCE, together with an offline-safe
197
+ // data-URI copy of it. The copy matters: while the DSH host is stopped
198
+ // the original href (a URL served by that same host) is unreachable, so
199
+ // writing it back would blank the tab — the "backend stopped, icon lost"
200
+ // bug. data: URIs render without any network, so restore() below only
201
+ // ever writes those.
158
202
  function captureOriginal() {
203
+ if (ORIGINAL) return;
159
204
  var link = document.querySelector("link[rel='icon']");
160
- if (link && ORIGINAL === null) ORIGINAL = { href: link.getAttribute("href") || "", type: link.getAttribute("type") || "" };
205
+ if (!link) return;
206
+ var href = link.getAttribute("href") || "";
207
+ ORIGINAL = { href: href, type: link.getAttribute("type") || "", data: null };
208
+ if (/^data:/i.test(href)) { ORIGINAL.data = href; return; }
209
+ // Best effort: fetch the original icon and convert it to a data: URI
210
+ // (any image type — blob -> FileReader). Environments without
211
+ // Blob/FileReader, or a failed fetch, leave data null; restore() then
212
+ // keeps the last plugin frame instead of risking a dead URL.
213
+ try {
214
+ if (!href || typeof Blob === "undefined" || typeof FileReader === "undefined") return;
215
+ timedFetch(href)
216
+ .then(function (r) { if (!r.ok) throw new Error("orig " + r.status); return r.blob(); })
217
+ .then(function (blob) {
218
+ return new Promise(function (resolve, reject) {
219
+ var fr = new FileReader();
220
+ fr.onload = function () { resolve(String(fr.result)); };
221
+ fr.onerror = function () { reject(fr.error || new Error("read failed")); };
222
+ fr.readAsDataURL(blob);
223
+ });
224
+ })
225
+ .then(function (uri) {
226
+ if (ORIGINAL && uri && uri.lastIndexOf("data:", 0) === 0) ORIGINAL.data = uri;
227
+ })
228
+ .catch(function () {});
229
+ } catch (e) {}
161
230
  }
231
+ // Offline-safe restore: prefer the cached data-URI copy of the original
232
+ // icon, then the original href when it is itself a data: URI. With
233
+ // neither available, keep the current frame (also a data: URI) — never
234
+ // write a server URL here, it is unreachable exactly when this runs.
162
235
  function restore() {
163
236
  var link = document.querySelector("link[rel='icon']");
164
- if (link && ORIGINAL) { link.setAttribute("href", ORIGINAL.href); if (ORIGINAL.type) link.setAttribute("type", ORIGINAL.type); }
237
+ if (!link || !ORIGINAL) return;
238
+ var uri = ORIGINAL.data || (/^data:/i.test(ORIGINAL.href) ? ORIGINAL.href : null);
239
+ if (!uri) return;
240
+ try { link.setAttribute("href", uri); if (ORIGINAL.type) link.setAttribute("type", ORIGINAL.type); } catch (e) {}
165
241
  }
166
242
  function linkEl() { return document.querySelector("link[rel='icon']"); }
167
243
  function setHref(uri) {
@@ -224,6 +300,27 @@ const INJECTED_SCRIPT = `
224
300
  }
225
301
 
226
302
  // ---- svg frame builder --------------------------------------------------
303
+ // Frame-URI cache. The base template is re-read and re-encoded on every
304
+ // animation frame, but most effects cycle through a tiny set of fills
305
+ // (static → 1, blink → 2, breath/geometric effects → 1 per color pair), so
306
+ // the encoded data URI is stable and can be memoized. rainbow is the
307
+ // exception — its fill is a fresh hue every frame — and is left uncached so
308
+ // the map can never grow without bound.
309
+ var URI_CACHE = new Map();
310
+ function cachedUri(kind, fill, build) {
311
+ var key = kind + "|" + fill;
312
+ var hit = URI_CACHE.get(key);
313
+ if (hit === undefined) {
314
+ hit = build();
315
+ // Keep the cache small (state count × a handful of colors); a settings
316
+ // edit can introduce new colors, and this script runs for the life of
317
+ // the tab.
318
+ if (URI_CACHE.size > 64) URI_CACHE.clear();
319
+ URI_CACHE.set(key, hit);
320
+ }
321
+ return hit;
322
+ }
323
+
227
324
  // Full-frame activity count ("满幅数字", per demo/badge.html bigNum
228
325
  // channel): while more than one agent is active the whole icon becomes a
229
326
  // state-colored rounded block with a bold count — no whale is drawn, so
@@ -236,22 +333,28 @@ const INJECTED_SCRIPT = `
236
333
  // cropping (mirrors the demo's svgFrame showWhale=false branch).
237
334
  function bigNumUri(fill) {
238
335
  var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
239
- var chars = text.length;
240
- var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
241
- var rgb = hexToRgb(fill);
242
- var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
243
- var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
244
- var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
245
- '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
246
- '<text x="25" y="' + (25 + fs * 0.34).toFixed(1) + '" text-anchor="middle" font-family="system-ui, sans-serif" font-size="' + fs + '" font-weight="800" fill="' + fg + '">' + text + '</text>' +
247
- '</svg>';
248
- return "data:image/svg+xml," + encodeURIComponent(svg);
336
+ return cachedUri("n" + text, fill, function () {
337
+ var chars = text.length;
338
+ var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
339
+ var rgb = hexToRgb(fill);
340
+ var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
341
+ var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
342
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
343
+ '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
344
+ '<text x="25" y="' + (25 + fs * 0.34).toFixed(1) + '" text-anchor="middle" font-family="system-ui, sans-serif" font-size="' + fs + '" font-weight="800" fill="' + fg + '">' + text + '</text>' +
345
+ '</svg>';
346
+ return "data:image/svg+xml," + encodeURIComponent(svg);
347
+ });
249
348
  }
250
349
 
251
350
  // Reuses the fetched base template; replaces its __COLOR__ token (and,
252
351
  // for scale/translate effects, wraps the whale in a <g transform>).
253
352
  function frameUri(fill, effect, t, speed) {
254
353
  if (!BASE) return null;
354
+ if (effect === "static") {
355
+ // No time component: one URI per color for the life of the tab.
356
+ return cachedUri("w", fill, function () { return "data:image/svg+xml," + encodeURIComponent(BASE.replace(RE, fill)); });
357
+ }
255
358
  var inner = BASE.replace(RE, fill);
256
359
  if (effect === "heartbeat" || effect === "bounce") {
257
360
  var gattr = "";
@@ -314,7 +417,12 @@ const INJECTED_SCRIPT = `
314
417
  // requestAnimationFrame in hidden tabs, so a background tab would freeze
315
418
  // on the last frame: repaint here instead — animated states get a
316
419
  // wall-clock frame (coarse ~1 Hz animation), static states repaint
317
- // (self-heal / pick up changes that happened while hidden).
420
+ // (self-heal / pick up changes that happened while hidden). A state
421
+ // change that happens while the tab is away (like the done hold
422
+ // expiring) paints on the next poll; the visibilitychange listener at
423
+ // the bottom fetches immediately when the tab comes back to the
424
+ // foreground, so that revert shows at once instead of waiting for a
425
+ // background-throttled tick.
318
426
  if (BASE && state + "|" + ACTIVE === PREV_KEY) {
319
427
  if (isStatic(state)) {
320
428
  var sframe = frameAt(state, 0);
@@ -327,8 +435,12 @@ const INJECTED_SCRIPT = `
327
435
  }
328
436
  PREV_KEY = state + "|" + ACTIVE;
329
437
  stopAnim();
330
- if (!BASE) { // base.svg not loaded yet — fetch once, then start
331
- fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
438
+ // The count block is drawn from scratch, so it needs no base.svg: only the
439
+ // whale path waits for the template. Loading it here (and retrying on the
440
+ // next poll, since PREV_KEY was already set) keeps a failed/timed-out
441
+ // template fetch from blanking the tab while agents are busy.
442
+ if (!BASE && ACTIVE < BIG_NUM_MIN) {
443
+ timedFetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
332
444
  .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
333
445
  .then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
334
446
  .catch(function () {});
@@ -370,7 +482,7 @@ const INJECTED_SCRIPT = `
370
482
  PREV_KEY = null; // force a full repaint on the next apply
371
483
  }
372
484
  function poll() {
373
- fetch(location.origin + STATUS_PATH, { cache: "no-store" })
485
+ timedFetch(location.origin + STATUS_PATH, { cache: "no-store" })
374
486
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
375
487
  .then(function (j) {
376
488
  syncCfg(j);
@@ -379,16 +491,35 @@ const INJECTED_SCRIPT = `
379
491
  // known count (initial 0 → whale only).
380
492
  if (typeof j.active === "number") ACTIVE = j.active;
381
493
  apply(j.state || "idle");
494
+ FAILED = false; // back online — the next outage may restore again
382
495
  })
383
- // Transient failures (host restarting, network blip) must NOT kill the
384
- // poll: restore the original icon and retry on the next tick. The SPA
385
- // reconnects in place, so the tab survives a host restart — the icon
386
- // has to come back on its own once the endpoint returns.
387
- .catch(function () { stopAnim(); restore(); });
496
+ // Transient failures (host stopped/restarting, network blip) must NOT
497
+ // kill the poll: swap the shell's own icon back in ONCE per outage via
498
+ // the offline-safe restore() and keep retrying every tick. The SPA
499
+ // reconnects in place, so the live icon returns by itself once the
500
+ // endpoint answers again. restore() only ever writes data: URIs —
501
+ // never the original server URL, which is unreachable exactly while
502
+ // the host is down — so the tab cannot lose its icon mid-outage.
503
+ .catch(function () {
504
+ stopAnim();
505
+ if (FAILED) return; // already restored for this outage
506
+ FAILED = true;
507
+ restore();
508
+ });
388
509
  }
389
510
  captureOriginal();
390
511
  TIMER = setInterval(poll, 1000);
391
512
  poll();
513
+ // A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
514
+ // ~1/min after ~5 min hidden) and rAF is paused, so a state change that
515
+ // happened while the tab was away may take a while to reach the favicon —
516
+ // e.g. the green "done" frame sits there until the next poll finally fires.
517
+ // The moment the tab becomes visible again, fetch the fresh status and
518
+ // repaint right away instead of waiting for the next (possibly throttled)
519
+ // poll tick. Cheap, idempotent, and it also covers browsers that froze the
520
+ // page entirely while hidden (their first task on unfreeze is this poll).
521
+ function onVisible() { if (document.visibilityState === "visible") poll(); }
522
+ window.addEventListener("visibilitychange", onVisible);
392
523
  window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
393
524
  } catch (e) { }
394
525
  })();
@@ -405,19 +536,178 @@ function resolveIconsDir(configured) {
405
536
  return join(__dirname, "..", "icons");
406
537
  }
407
538
 
539
+ // ---- Color math for the default-color similarity check ---------------------
540
+ // lib/client.js carries its OWN copy of this block: the two halves run in
541
+ // different processes, there is no shared module and no build step (docs/main.js
542
+ // already duplicates the same color math for the showcase site). Keep the
543
+ // thresholds in sync when tuning either copy.
544
+ /** Accepted defaultColor shape: `#rgb` or `#rrggbb` (case-insensitive). */
545
+ const DEFAULT_COLOR_RX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
546
+ /** CIE76 ΔE below which two colors are practically the same. */
547
+ const SIMILAR_DE_STRONG = 12;
548
+ /** CIE76 ΔE below which two colors are easily confused at favicon size. */
549
+ const SIMILAR_DE_WARN = 25;
550
+ /** Lab chroma above which a default color collides with a rainbow sweep. */
551
+ const RAINBOW_CHROMA_MIN = 15;
552
+ /** The states a default color is compared against (idle is the default itself). */
553
+ const OTHER_STATES = STATE_NAMES.filter((name) => name !== "idle");
554
+
555
+ /** `#rgb` / `#rrggbb` → lowercase 6-digit hex, or null when malformed. */
556
+ function normalizeHex(value) {
557
+ if (typeof value !== "string") return null;
558
+ const text = value.trim();
559
+ if (!DEFAULT_COLOR_RX.test(text)) return null;
560
+ const body = text.slice(1).toLowerCase();
561
+ return body.length === 3
562
+ ? "#" + body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
563
+ : "#" + body;
564
+ }
565
+
566
+ function hexToRgb(hex) {
567
+ let body = String(hex).replace("#", "");
568
+ // Expand #rgb exactly like the browser half does — every host caller feeds
569
+ // normalizeHex()'s 6-digit output today, but the shortcut is a trap for the
570
+ // next caller (and this copy must stay equivalent to lib/client.js's).
571
+ if (body.length === 3) body = body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
572
+ const n = parseInt(body, 16);
573
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
574
+ }
575
+
576
+ function rgbToHex(r, g, b) {
577
+ return "#" + [r, g, b].map((v) => {
578
+ const c = Math.max(0, Math.min(255, Math.round(v)));
579
+ return ("0" + c.toString(16)).slice(-2);
580
+ }).join("");
581
+ }
582
+
583
+ /** Linear blend between two hex colors (mirrors the browser's `mix()`). */
584
+ function mixHex(a, b, t) {
585
+ const ca = hexToRgb(a);
586
+ const cb = hexToRgb(b);
587
+ return rgbToHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
588
+ }
589
+
590
+ /** CIELAB (D65) for an sRGB hex color. */
591
+ function labOf(hex) {
592
+ const rgb = hexToRgb(hex);
593
+ const lin = (v) => {
594
+ const c = v / 255;
595
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
596
+ };
597
+ const r = lin(rgb[0]);
598
+ const g = lin(rgb[1]);
599
+ const b = lin(rgb[2]);
600
+ const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
601
+ const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
602
+ const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
603
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29);
604
+ const fx = f(x);
605
+ const fy = f(y);
606
+ const fz = f(z);
607
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
608
+ }
609
+
610
+ /** CIE76 ΔE (Euclidean distance in CIELAB). */
611
+ function deltaE(a, b) {
612
+ const la = labOf(a);
613
+ const lb = labOf(b);
614
+ return Math.sqrt(
615
+ Math.pow(la[0] - lb[0], 2) + Math.pow(la[1] - lb[1], 2) + Math.pow(la[2] - lb[2], 2)
616
+ );
617
+ }
618
+
619
+ function labChroma(hex) {
620
+ const l = labOf(hex);
621
+ return Math.sqrt(l[1] * l[1] + l[2] * l[2]);
622
+ }
623
+
624
+ /**
625
+ * The fills a state actually paints, mirroring the browser's `frameColor()`:
626
+ * `blink` toggles colors[0] ⇄ colors[1] (a darker second color is derived when
627
+ * colors[1] is omitted), `breath` interpolates between them, everything else
628
+ * uses colors[0]. `rainbow` is flagged instead — it sweeps every hue.
629
+ */
630
+ function stateFills(state) {
631
+ const cols = Array.isArray(state?.colors) ? state.colors : [];
632
+ const effect = state?.effect || "static";
633
+ const c0 = normalizeHex(cols[0] || "");
634
+ const rainbow = effect === "rainbow";
635
+ // A rainbow state never paints its own colors[0] literally (that value is
636
+ // only the starting hue — the sweep covers the whole wheel), so it has no
637
+ // comparable fills: the chroma rule in colorWarnings() covers it instead.
638
+ if (!c0 || rainbow) return { fills: [], rainbow };
639
+ const c1 = normalizeHex(cols[1] || "") || mixHex(c0, "#000000", 0.35);
640
+ if (effect === "blink") return { fills: [c0, c1], rainbow: false };
641
+ if (effect === "breath") {
642
+ // The browser paints the CONTINUOUS mix, so a sparse sample can only ever
643
+ // over-estimate the distance: a blue->green breath was reported ΔE 37 away
644
+ // from a default colour it actually passes through (ΔE 0). 33 samples keep
645
+ // the worst reported-vs-true gap around 5 ΔE (see lib/client.js's copy).
646
+ const fills = [];
647
+ for (let i = 0; i <= 32; i += 1) fills.push(mixHex(c0, c1, i / 32));
648
+ return { fills, rainbow: false };
649
+ }
650
+ return { fills: [c0], rainbow };
651
+ }
652
+
653
+ /**
654
+ * Similarity warnings for the effective default color (the idle primary).
655
+ * Only the default is compared against the other states — NOT the states
656
+ * against each other: the shipped defaults deliberately share #FACC15 between
657
+ * `running` and the `asking` blink, so a pairwise check would warn forever.
658
+ */
659
+ function colorWarnings(states) {
660
+ const warnings = [];
661
+ const base = normalizeHex(states?.idle?.colors?.[0] || "");
662
+ if (!base) return warnings;
663
+ // Idle itself on `rainbow` (composition entry / hand-written settings.yaml —
664
+ // the card never exposes it): the idle icon sweeps every hue, so the default
665
+ // colour can never stay distinguishable from it. One advisory replaces the
666
+ // pairwise pass, which would judge a colour idle never paints literally.
667
+ if ((states?.idle?.effect || "static") === "rainbow") {
668
+ if (labChroma(base) >= RAINBOW_CHROMA_MIN) {
669
+ warnings.push({ code: "rainbow-overlap", state: "idle", base, level: "warn" });
670
+ }
671
+ return warnings;
672
+ }
673
+ for (const name of OTHER_STATES) {
674
+ const { fills, rainbow } = stateFills(states?.[name]);
675
+ if (rainbow && labChroma(base) >= RAINBOW_CHROMA_MIN) {
676
+ warnings.push({ code: "rainbow-overlap", state: name, base, level: "warn" });
677
+ }
678
+ let closest = null;
679
+ for (const fill of fills) {
680
+ const de = deltaE(base, fill);
681
+ if (closest === null || de < closest.deltaE) closest = { color: fill, deltaE: de };
682
+ }
683
+ if (closest && closest.deltaE < SIMILAR_DE_WARN) {
684
+ warnings.push({
685
+ code: "color-too-close",
686
+ state: name,
687
+ base,
688
+ color: closest.color,
689
+ deltaE: Math.round(closest.deltaE * 10) / 10,
690
+ level: closest.deltaE < SIMILAR_DE_STRONG ? "strong" : "warn",
691
+ });
692
+ }
693
+ }
694
+ return warnings;
695
+ }
696
+
408
697
  /**
409
698
  * Cordis plugin entry. Returns the standard `{ apply, inject, config }`
410
699
  * shape so the host composition can mount it once at startup.
411
700
  */
412
701
  export default {
413
702
  name: "dsh-web-icon-indicator",
414
- inject: ["webServer", "timer", "agents", "fs", "sandboxPolicy"],
703
+ inject: ["webServer", "timer", "agents", "fs"],
415
704
  config: {
416
705
  askingHoldMs: DEFAULTS.askingHoldMs,
417
706
  doneHoldMs: DEFAULTS.doneHoldMs,
418
707
  statusPath: DEFAULTS.statusPath,
419
708
  iconPathPrefix: DEFAULTS.iconPathPrefix,
420
709
  iconsDir: undefined,
710
+ defaultColor: undefined,
421
711
  states: DEFAULTS.states,
422
712
  },
423
713
  /** Settings namespace + validation schema (exported for reuse/tooling). */
@@ -438,6 +728,7 @@ export default {
438
728
  // normalization when the profile composes no settings service.
439
729
  const resolveConfig = (raw) => {
440
730
  const stateConfigs = {};
731
+ const warnings = [];
441
732
  for (const name of STATE_NAMES) {
442
733
  const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
443
734
  // Guard against unknown effect names: fall back to "static" so the
@@ -448,15 +739,75 @@ export default {
448
739
  let cols = merged.colors;
449
740
  if (typeof cols === "string") cols = [cols];
450
741
  if (!Array.isArray(cols)) cols = [];
451
- cols = cols.filter((c) => typeof c === "string" && /^#[0-9a-fA-F]{3,6}$/.test(c.trim()));
742
+ // Strict `#rgb` / `#rrggbb` only — the same rule normalizeHex() and the
743
+ // card use. The old lenient 3-6 digit test let a "#1234" through and
744
+ // parseInt() painted a bogus colour from it, which the card (correctly)
745
+ // refused to reason about.
746
+ cols = cols.filter((c) => typeof c === "string" && DEFAULT_COLOR_RX.test(c.trim()));
452
747
  merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
453
748
  // speed: must be a positive number; otherwise use the default (1200).
454
749
  if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
455
750
  stateConfigs[name] = merged;
456
751
  }
457
- return { ...DEFAULTS, ...raw, states: stateConfigs };
752
+ // `undefined` values must not shadow a DEFAULTS entry: the plugin's own
753
+ // static `config` row carries `iconsDir: undefined` by design, and a
754
+ // schema-resolved settings value may omit optional keys entirely. Only
755
+ // keys the caller actually set may override a default.
756
+ const defined = {};
757
+ for (const [key, value] of Object.entries(raw || {})) {
758
+ if (value !== undefined) defined[key] = value;
759
+ }
760
+ const resolved = { ...DEFAULTS, ...defined, states: stateConfigs };
761
+ // `defaultColor` is sugar for the idle state's PRIMARY color. Fold it into
762
+ // `states.idle.colors[0]` here instead of teaching the browser a second
763
+ // rendering path: the injected script, the baked `__CFG__` payload and
764
+ // even an already-baked older bundle therefore keep working unchanged,
765
+ // and the live settings sync (`cfg.states = next.states`) carries it for
766
+ // free. A malformed value is dropped with a warning, never painted.
767
+ delete resolved.defaultColor;
768
+ const requestedDefault = typeof defined.defaultColor === "string"
769
+ ? defined.defaultColor.trim()
770
+ : defined.defaultColor;
771
+ // "" / whitespace written by hand into the composition entry means "not
772
+ // set" — not a malformed color to warn about. Neither does an empty YAML
773
+ // value, which parses as null (`defaultColor:`).
774
+ if (requestedDefault != null && requestedDefault !== "") {
775
+ const normalized = normalizeHex(requestedDefault);
776
+ if (normalized) {
777
+ const idle = stateConfigs.idle;
778
+ stateConfigs.idle = { ...idle, colors: [normalized, ...(idle.colors || []).slice(1)] };
779
+ resolved.defaultColor = normalized;
780
+ } else {
781
+ warnings.push({ code: "invalid-color", value: String(requestedDefault) });
782
+ }
783
+ }
784
+ warnings.push(...colorWarnings(stateConfigs));
785
+ resolved.warnings = warnings;
786
+ return resolved;
787
+ };
788
+ // Surface the similarity / validity warnings on the host as well: the
789
+ // settings card computes its own live copy, while this covers configs that
790
+ // arrive through the composition entry (a hand-written cordis.yml) and
791
+ // leaves a server-side record. `ctx.logger` is cordis' core logging
792
+ // service — guard it, since the zero-dep test harness drives `apply()`
793
+ // with a ctx that has none.
794
+ const logWarnings = (list) => {
795
+ if (!Array.isArray(list) || list.length === 0) return;
796
+ try {
797
+ if (typeof ctx.logger !== "function") return;
798
+ const logger = ctx.logger("dsh-web-icon-indicator");
799
+ for (const w of list) {
800
+ const text = w.code === "invalid-color"
801
+ ? `defaultColor ${JSON.stringify(w.value)} is not a 3- or 6-digit hex color — ignored`
802
+ : w.code === "rainbow-overlap"
803
+ ? `defaultColor ${w.base} is too close to the "${w.state}" state: its rainbow effect sweeps every hue`
804
+ : `defaultColor ${w.base} is ${w.level === "strong" ? "nearly identical to" : "too close to"} the "${w.state}" state color ${w.color} (ΔE ${w.deltaE})`;
805
+ if (logger && typeof logger.warn === "function") logger.warn(text);
806
+ }
807
+ } catch (e) {}
458
808
  };
459
809
  let cfg = resolveConfig(entry);
810
+ logWarnings(cfg.warnings);
460
811
  // The injected script bakes config at injection time; rebuild it whenever
461
812
  // the settings section changes so the NEXT page load picks the new values
462
813
  // up (reload the tab — same contract as before this registration).
@@ -483,24 +834,30 @@ export default {
483
834
  // Settings service mounted, or the section changed: re-resolve, then
484
835
  // mutate cfg in place so the state-machine closures (asking/done hold)
485
836
  // and the injected script see the new values immediately.
837
+ // `statusPath` / `iconPathPrefix` are NOT taken from the settings
838
+ // source: they are baked into the route table and the injected script
839
+ // at registration time, so honoring a settings edit here would break
840
+ // the live poll (see CONFIG_SCHEMA).
486
841
  const next = resolveConfig(source());
487
842
  cfg.askingHoldMs = next.askingHoldMs;
488
843
  cfg.doneHoldMs = next.doneHoldMs;
489
- cfg.statusPath = next.statusPath;
490
- cfg.iconPathPrefix = next.iconPathPrefix;
491
844
  if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
492
845
  cfg.states = next.states;
846
+ // `defaultColor` / `warnings` ride along with `states`: the color is
847
+ // already folded into `states.idle.colors[0]`, so the browser needs no
848
+ // new key — they are kept for observability (status echo + log).
849
+ cfg.defaultColor = next.defaultColor;
850
+ cfg.warnings = next.warnings;
493
851
  iconsDir = resolveIconsDir(cfg.iconsDir);
494
852
  script = buildScript(cfg);
853
+ logWarnings(cfg.warnings);
495
854
  },
496
855
  });
497
856
  });
498
857
  const webServer = ctx.webServer;
499
858
  const agents = ctx.agents;
500
859
  const fs = ctx.fs;
501
- const sp = ctx.sandboxPolicy;
502
860
  const lastSeen = new Map();
503
- void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
504
861
 
505
862
  // -- State machine -------------------------------------------------------
506
863
  const states = new Map(); // agentId -> { state, since }
@@ -572,31 +929,46 @@ export default {
572
929
  // leaves `running`, so a lost `tools/result` cannot pin `asking` forever.
573
930
  const scheduleAskCheck = (id) => {
574
931
  const prevHandle = askTimers.get(id);
575
- if (prevHandle) { try { prevHandle(); } catch (e) {} askTimers.delete(id); }
932
+ // Never cancel a handle whose callback is executing right now: it is the
933
+ // very call re-arming the pin, and its `finally` would then delete the
934
+ // fresh handle and leave the session pinned to `asking` with no timer to
935
+ // release it. A stale (non-running) handle is still cancelled, so a
936
+ // repeated pre-execute cannot stack two timers.
937
+ if (prevHandle && !prevHandle.running()) {
938
+ try { prevHandle(); } catch (e) {}
939
+ askTimers.delete(id);
940
+ }
941
+ let running = false;
576
942
  const handle = ctx.timer.timeout(() => {
943
+ running = true;
577
944
  askTimers.delete(id);
578
- if (!asking.has(id)) return;
579
- if (!askDone.has(id)) {
580
- const live = agents.get(id);
581
- // Result not yet reported AND the agent is still mid-turn: the user
582
- // may still be deciding — keep the pin (no re-arm budget; a human
583
- // question can legitimately outlast many hold windows).
584
- if (live && live.status === "running") { scheduleAskCheck(id); return; }
585
- // Result never arrived and the turn ended (agent idle / gone): a lost
586
- // tools/result cannot keep the icon blinking forever — force-release
587
- // the pin against live status.
945
+ try {
946
+ if (!asking.has(id)) return;
947
+ if (!askDone.has(id)) {
948
+ const live = agents.get(id);
949
+ // Result not yet reported AND the agent is still mid-turn: the user
950
+ // may still be deciding — keep the pin (no re-arm budget; a human
951
+ // question can legitimately outlast many hold windows).
952
+ if (live && live.status === "running") { scheduleAskCheck(id); return; }
953
+ // Result never arrived and the turn ended (agent idle / gone): a lost
954
+ // tools/result cannot keep the icon blinking forever — force-release
955
+ // the pin against live status.
956
+ asking.delete(id);
957
+ askDone.delete(id);
958
+ if (live) setState(id, live.status === "running" ? "running" : "idle");
959
+ else setState(id, "idle");
960
+ return;
961
+ }
588
962
  asking.delete(id);
589
963
  askDone.delete(id);
964
+ const live = agents.get(id);
590
965
  if (live) setState(id, live.status === "running" ? "running" : "idle");
591
966
  else setState(id, "idle");
592
- return;
967
+ } finally {
968
+ running = false;
593
969
  }
594
- asking.delete(id);
595
- askDone.delete(id);
596
- const live = agents.get(id);
597
- if (live) setState(id, live.status === "running" ? "running" : "idle");
598
- else setState(id, "idle");
599
970
  }, cfg.askingHoldMs);
971
+ handle.running = () => running;
600
972
  askTimers.set(id, handle);
601
973
  };
602
974
 
@@ -648,6 +1020,7 @@ export default {
648
1020
  asking.delete(id);
649
1021
  askDone.delete(id);
650
1022
  pendingApprovals.delete(id);
1023
+ approvalCursor.delete(id);
651
1024
  lastSeen.delete(id);
652
1025
  cancelDoneHold(id);
653
1026
  const t = askTimers.get(id);
@@ -659,17 +1032,34 @@ export default {
659
1032
  // 0.1.2-alpha.4 `Session.events` is gone — read the log on demand through
660
1033
  // `session.snapshotEvents()` (a frozen half-open range snapshot). Guard on
661
1034
  // the method so older/newer hosts degrade gracefully.
1035
+ //
1036
+ // The fold is INCREMENTAL: `snapshotEvents()` deep-freezes every event it
1037
+ // returns, and reconcile runs on every 1 s status poll, so re-reading the
1038
+ // whole log per poll would clone the entire history each second. A per-agent
1039
+ // cursor keeps only the delta (the log is append-only, so the open-approval
1040
+ // set is a pure fold over the events seen so far).
1041
+ const approvalCursor = new Map(); // agentId -> { seq, open: Set<approvalId> }
662
1042
  const hasPendingApproval = (live) => {
663
1043
  const session = live?.session;
664
1044
  if (!session || typeof session.snapshotEvents !== "function") return false;
665
- const events = session.snapshotEvents();
666
- if (!Array.isArray(events)) return false;
667
- const open = new Set();
1045
+ const id = live?.id ?? null;
1046
+ const cursor = (id != null && approvalCursor.get(id)) || { seq: 0, open: new Set() };
1047
+ let events;
1048
+ try {
1049
+ events = session.snapshotEvents(cursor.seq);
1050
+ } catch (e) {
1051
+ return cursor.open.size > 0; // keep the last good fold on a read failure
1052
+ }
1053
+ if (!Array.isArray(events)) return cursor.open.size > 0;
1054
+ let seq = cursor.seq;
668
1055
  for (const ev of events) {
669
- if (ev.type === "approval/asked") open.add(ev.data?.id);
670
- else if (ev.type === "approval/decided") open.delete(ev.data?.id);
1056
+ if (typeof ev?.seq === "number" && ev.seq >= seq) seq = ev.seq + 1;
1057
+ if (ev?.type === "approval/asked") cursor.open.add(ev.data?.id);
1058
+ else if (ev?.type === "approval/decided") cursor.open.delete(ev.data?.id);
671
1059
  }
672
- return open.size > 0;
1060
+ cursor.seq = seq;
1061
+ if (id != null) approvalCursor.set(id, cursor);
1062
+ return cursor.open.size > 0;
673
1063
  };
674
1064
 
675
1065
  // Reconcile running->idle transitions on every status request.
@@ -741,7 +1131,16 @@ export default {
741
1131
  res.statusCode = 200;
742
1132
  res.setHeader("Content-Type", "application/json; charset=utf-8");
743
1133
  res.setHeader("Cache-Control", "no-store");
744
- res.end(JSON.stringify({ ...aggregate(), states: cfg.states }));
1134
+ // `defaultColor` + `warnings` are additive: older browser bundles ignore
1135
+ // unknown keys, and the live config sync only reads `states`.
1136
+ // `defaultColor` is echoed as null when none is configured so the
1137
+ // documented shape has the key unconditionally.
1138
+ res.end(JSON.stringify({
1139
+ ...aggregate(),
1140
+ states: cfg.states,
1141
+ defaultColor: cfg.defaultColor ?? null,
1142
+ warnings: cfg.warnings || [],
1143
+ }));
745
1144
  },
746
1145
  }));
747
1146