dsh-web-icon-indicator 0.4.2 → 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/CHANGELOG.md +19 -0
- package/README.md +88 -14
- package/README.zh.md +67 -13
- package/lib/client.js +715 -98
- package/lib/index.js +399 -48
- package/lib/types/index.d.ts +73 -4
- package/package.json +3 -3
- package/test/loader-hooks.mjs +21 -0
- package/test/stubs/dsh-settings.mjs +15 -0
- package/test/stubs/schemastery.mjs +12 -0
- package/test/verify.js +2026 -0
- package/docs/safari-favicon-research.md +0 -114
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
|
-
|
|
125
|
-
|
|
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.
|
|
@@ -156,6 +176,23 @@ const INJECTED_SCRIPT = `
|
|
|
156
176
|
// Whale geometry (used as the transform pivot for scale/translate effects).
|
|
157
177
|
var CX = 27.889625, CY = 24.952640;
|
|
158
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
|
+
|
|
159
196
|
// Capture the shell's own favicon ONCE, together with an offline-safe
|
|
160
197
|
// data-URI copy of it. The copy matters: while the DSH host is stopped
|
|
161
198
|
// the original href (a URL served by that same host) is unreachable, so
|
|
@@ -175,7 +212,7 @@ const INJECTED_SCRIPT = `
|
|
|
175
212
|
// keeps the last plugin frame instead of risking a dead URL.
|
|
176
213
|
try {
|
|
177
214
|
if (!href || typeof Blob === "undefined" || typeof FileReader === "undefined") return;
|
|
178
|
-
|
|
215
|
+
timedFetch(href)
|
|
179
216
|
.then(function (r) { if (!r.ok) throw new Error("orig " + r.status); return r.blob(); })
|
|
180
217
|
.then(function (blob) {
|
|
181
218
|
return new Promise(function (resolve, reject) {
|
|
@@ -263,6 +300,27 @@ const INJECTED_SCRIPT = `
|
|
|
263
300
|
}
|
|
264
301
|
|
|
265
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
|
+
|
|
266
324
|
// Full-frame activity count ("满幅数字", per demo/badge.html bigNum
|
|
267
325
|
// channel): while more than one agent is active the whole icon becomes a
|
|
268
326
|
// state-colored rounded block with a bold count — no whale is drawn, so
|
|
@@ -275,22 +333,28 @@ const INJECTED_SCRIPT = `
|
|
|
275
333
|
// cropping (mirrors the demo's svgFrame showWhale=false branch).
|
|
276
334
|
function bigNumUri(fill) {
|
|
277
335
|
var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
'<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
});
|
|
288
348
|
}
|
|
289
349
|
|
|
290
350
|
// Reuses the fetched base template; replaces its __COLOR__ token (and,
|
|
291
351
|
// for scale/translate effects, wraps the whale in a <g transform>).
|
|
292
352
|
function frameUri(fill, effect, t, speed) {
|
|
293
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
|
+
}
|
|
294
358
|
var inner = BASE.replace(RE, fill);
|
|
295
359
|
if (effect === "heartbeat" || effect === "bounce") {
|
|
296
360
|
var gattr = "";
|
|
@@ -353,7 +417,12 @@ const INJECTED_SCRIPT = `
|
|
|
353
417
|
// requestAnimationFrame in hidden tabs, so a background tab would freeze
|
|
354
418
|
// on the last frame: repaint here instead — animated states get a
|
|
355
419
|
// wall-clock frame (coarse ~1 Hz animation), static states repaint
|
|
356
|
-
// (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.
|
|
357
426
|
if (BASE && state + "|" + ACTIVE === PREV_KEY) {
|
|
358
427
|
if (isStatic(state)) {
|
|
359
428
|
var sframe = frameAt(state, 0);
|
|
@@ -366,8 +435,12 @@ const INJECTED_SCRIPT = `
|
|
|
366
435
|
}
|
|
367
436
|
PREV_KEY = state + "|" + ACTIVE;
|
|
368
437
|
stopAnim();
|
|
369
|
-
|
|
370
|
-
|
|
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" })
|
|
371
444
|
.then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
|
|
372
445
|
.then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
|
|
373
446
|
.catch(function () {});
|
|
@@ -409,7 +482,7 @@ const INJECTED_SCRIPT = `
|
|
|
409
482
|
PREV_KEY = null; // force a full repaint on the next apply
|
|
410
483
|
}
|
|
411
484
|
function poll() {
|
|
412
|
-
|
|
485
|
+
timedFetch(location.origin + STATUS_PATH, { cache: "no-store" })
|
|
413
486
|
.then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
|
|
414
487
|
.then(function (j) {
|
|
415
488
|
syncCfg(j);
|
|
@@ -437,6 +510,16 @@ const INJECTED_SCRIPT = `
|
|
|
437
510
|
captureOriginal();
|
|
438
511
|
TIMER = setInterval(poll, 1000);
|
|
439
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);
|
|
440
523
|
window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
|
|
441
524
|
} catch (e) { }
|
|
442
525
|
})();
|
|
@@ -453,19 +536,178 @@ function resolveIconsDir(configured) {
|
|
|
453
536
|
return join(__dirname, "..", "icons");
|
|
454
537
|
}
|
|
455
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
|
+
|
|
456
697
|
/**
|
|
457
698
|
* Cordis plugin entry. Returns the standard `{ apply, inject, config }`
|
|
458
699
|
* shape so the host composition can mount it once at startup.
|
|
459
700
|
*/
|
|
460
701
|
export default {
|
|
461
702
|
name: "dsh-web-icon-indicator",
|
|
462
|
-
inject: ["webServer", "timer", "agents", "fs"
|
|
703
|
+
inject: ["webServer", "timer", "agents", "fs"],
|
|
463
704
|
config: {
|
|
464
705
|
askingHoldMs: DEFAULTS.askingHoldMs,
|
|
465
706
|
doneHoldMs: DEFAULTS.doneHoldMs,
|
|
466
707
|
statusPath: DEFAULTS.statusPath,
|
|
467
708
|
iconPathPrefix: DEFAULTS.iconPathPrefix,
|
|
468
709
|
iconsDir: undefined,
|
|
710
|
+
defaultColor: undefined,
|
|
469
711
|
states: DEFAULTS.states,
|
|
470
712
|
},
|
|
471
713
|
/** Settings namespace + validation schema (exported for reuse/tooling). */
|
|
@@ -486,6 +728,7 @@ export default {
|
|
|
486
728
|
// normalization when the profile composes no settings service.
|
|
487
729
|
const resolveConfig = (raw) => {
|
|
488
730
|
const stateConfigs = {};
|
|
731
|
+
const warnings = [];
|
|
489
732
|
for (const name of STATE_NAMES) {
|
|
490
733
|
const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
|
|
491
734
|
// Guard against unknown effect names: fall back to "static" so the
|
|
@@ -496,15 +739,75 @@ export default {
|
|
|
496
739
|
let cols = merged.colors;
|
|
497
740
|
if (typeof cols === "string") cols = [cols];
|
|
498
741
|
if (!Array.isArray(cols)) cols = [];
|
|
499
|
-
|
|
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()));
|
|
500
747
|
merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
|
|
501
748
|
// speed: must be a positive number; otherwise use the default (1200).
|
|
502
749
|
if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
|
|
503
750
|
stateConfigs[name] = merged;
|
|
504
751
|
}
|
|
505
|
-
|
|
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) {}
|
|
506
808
|
};
|
|
507
809
|
let cfg = resolveConfig(entry);
|
|
810
|
+
logWarnings(cfg.warnings);
|
|
508
811
|
// The injected script bakes config at injection time; rebuild it whenever
|
|
509
812
|
// the settings section changes so the NEXT page load picks the new values
|
|
510
813
|
// up (reload the tab — same contract as before this registration).
|
|
@@ -531,24 +834,30 @@ export default {
|
|
|
531
834
|
// Settings service mounted, or the section changed: re-resolve, then
|
|
532
835
|
// mutate cfg in place so the state-machine closures (asking/done hold)
|
|
533
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).
|
|
534
841
|
const next = resolveConfig(source());
|
|
535
842
|
cfg.askingHoldMs = next.askingHoldMs;
|
|
536
843
|
cfg.doneHoldMs = next.doneHoldMs;
|
|
537
|
-
cfg.statusPath = next.statusPath;
|
|
538
|
-
cfg.iconPathPrefix = next.iconPathPrefix;
|
|
539
844
|
if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
|
|
540
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;
|
|
541
851
|
iconsDir = resolveIconsDir(cfg.iconsDir);
|
|
542
852
|
script = buildScript(cfg);
|
|
853
|
+
logWarnings(cfg.warnings);
|
|
543
854
|
},
|
|
544
855
|
});
|
|
545
856
|
});
|
|
546
857
|
const webServer = ctx.webServer;
|
|
547
858
|
const agents = ctx.agents;
|
|
548
859
|
const fs = ctx.fs;
|
|
549
|
-
const sp = ctx.sandboxPolicy;
|
|
550
860
|
const lastSeen = new Map();
|
|
551
|
-
void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
|
|
552
861
|
|
|
553
862
|
// -- State machine -------------------------------------------------------
|
|
554
863
|
const states = new Map(); // agentId -> { state, since }
|
|
@@ -620,31 +929,46 @@ export default {
|
|
|
620
929
|
// leaves `running`, so a lost `tools/result` cannot pin `asking` forever.
|
|
621
930
|
const scheduleAskCheck = (id) => {
|
|
622
931
|
const prevHandle = askTimers.get(id);
|
|
623
|
-
|
|
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;
|
|
624
942
|
const handle = ctx.timer.timeout(() => {
|
|
943
|
+
running = true;
|
|
625
944
|
askTimers.delete(id);
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
}
|
|
636
962
|
asking.delete(id);
|
|
637
963
|
askDone.delete(id);
|
|
964
|
+
const live = agents.get(id);
|
|
638
965
|
if (live) setState(id, live.status === "running" ? "running" : "idle");
|
|
639
966
|
else setState(id, "idle");
|
|
640
|
-
|
|
967
|
+
} finally {
|
|
968
|
+
running = false;
|
|
641
969
|
}
|
|
642
|
-
asking.delete(id);
|
|
643
|
-
askDone.delete(id);
|
|
644
|
-
const live = agents.get(id);
|
|
645
|
-
if (live) setState(id, live.status === "running" ? "running" : "idle");
|
|
646
|
-
else setState(id, "idle");
|
|
647
970
|
}, cfg.askingHoldMs);
|
|
971
|
+
handle.running = () => running;
|
|
648
972
|
askTimers.set(id, handle);
|
|
649
973
|
};
|
|
650
974
|
|
|
@@ -696,6 +1020,7 @@ export default {
|
|
|
696
1020
|
asking.delete(id);
|
|
697
1021
|
askDone.delete(id);
|
|
698
1022
|
pendingApprovals.delete(id);
|
|
1023
|
+
approvalCursor.delete(id);
|
|
699
1024
|
lastSeen.delete(id);
|
|
700
1025
|
cancelDoneHold(id);
|
|
701
1026
|
const t = askTimers.get(id);
|
|
@@ -707,17 +1032,34 @@ export default {
|
|
|
707
1032
|
// 0.1.2-alpha.4 `Session.events` is gone — read the log on demand through
|
|
708
1033
|
// `session.snapshotEvents()` (a frozen half-open range snapshot). Guard on
|
|
709
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> }
|
|
710
1042
|
const hasPendingApproval = (live) => {
|
|
711
1043
|
const session = live?.session;
|
|
712
1044
|
if (!session || typeof session.snapshotEvents !== "function") return false;
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
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;
|
|
716
1055
|
for (const ev of events) {
|
|
717
|
-
if (ev
|
|
718
|
-
|
|
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);
|
|
719
1059
|
}
|
|
720
|
-
|
|
1060
|
+
cursor.seq = seq;
|
|
1061
|
+
if (id != null) approvalCursor.set(id, cursor);
|
|
1062
|
+
return cursor.open.size > 0;
|
|
721
1063
|
};
|
|
722
1064
|
|
|
723
1065
|
// Reconcile running->idle transitions on every status request.
|
|
@@ -789,7 +1131,16 @@ export default {
|
|
|
789
1131
|
res.statusCode = 200;
|
|
790
1132
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
791
1133
|
res.setHeader("Cache-Control", "no-store");
|
|
792
|
-
|
|
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
|
+
}));
|
|
793
1144
|
},
|
|
794
1145
|
}));
|
|
795
1146
|
|