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/CHANGELOG.md +25 -0
- package/README.md +88 -14
- package/README.zh.md +67 -13
- package/lib/client.js +715 -98
- package/lib/index.js +453 -54
- 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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
'<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
384
|
-
// poll:
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
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
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|