dsh-web-icon-indicator 0.4.2 → 0.5.1

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 (non-volatile): composition entry only.
43
44
  * iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
45
+ * Registration-time (non-volatile): composition entry only.
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
+ * user settings layer) a different value to tell their browser
53
+ * tabs 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? }
@@ -57,12 +67,23 @@
57
67
  * - speed: optional per-state cycle in ms (blink toggle interval too).
58
68
  * Default 1200.
59
69
  *
60
- * The whole config surface is also registered with the DSH settings service
61
- * (namespace `web-icon-indicator`): validated against a schemastery schema,
62
- * persisted to the profile's `settings.yaml`, and editable from
63
- * 设置 → 插件 → 插件配置 in the Web GUI (see the `dsh.client` browser half).
64
- * While the profile composes no settings service, the plugin keeps working
65
- * exactly as before, reading the composition entry directly.
70
+ * The whole config surface is the plugin's Cordis `Config` schema (exported as
71
+ * both `Config` and `CONFIG_SCHEMA`), and the same schema is registered with
72
+ * the LEGACY settings service when the running host still exposes
73
+ * `installSection` — one bundle serves every published host generation:
74
+ *
75
+ * - DSH ≥ 0.1.7: the settings service projects the schema's `.volatile()`
76
+ * fields into a live form, persisted into the profile patch
77
+ * (`~/.dsh/profiles/<profile>/cordis.patch.yml`); a write mutates the
78
+ * running plugin's config references in place and emits
79
+ * `loader/volatile-update` instead of restarting it.
80
+ * - DSH ≤ 0.1.6-alpha.1: the plugin registers the namespace itself under
81
+ * `web-icon-indicator` and the service drives it through
82
+ * `setSource`/`onChange`, persisted into `~/.dsh/settings.yaml`.
83
+ *
84
+ * `statusPath` / `iconPathPrefix` stay non-volatile in both. When the profile
85
+ * composes no settings service at all, the plugin keeps working on the
86
+ * composition entry + defaults it was mounted with.
66
87
  *
67
88
  * @module dsh-web-icon-indicator
68
89
  */
@@ -94,21 +115,54 @@ const EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "boun
94
115
  const STATE_NAMES = ["idle", "running", "asking", "done"];
95
116
 
96
117
  /**
97
- * Settings namespace under which this plugin's config is registered (the
98
- * `web-icon-indicator:` section of the profile's settings.yaml, surfaced in
99
- * 设置 → 插件 → 插件配置). Lowercase kebab, per the settings domain's rule.
118
+ * Settings namespace of this plugin on the MODERN host line (DSH ≥ 0.1.7):
119
+ * the settings namespace IS the profile entry id (`schema(entry)` reads
120
+ * `entry.fiber.runtime.Config` and `describe()` keys every form by
121
+ * `entry.options.id`), and this plugin's bundle patch declares exactly one row:
122
+ * `dsh-web-icon-indicator`. The browser half reads the same namespace through
123
+ * `ctx.configForms.get(...)`, so the two halves must keep this literal in sync
124
+ * (it is also the row id in `cordis.patch.yml`).
125
+ */
126
+ const SETTINGS_NAMESPACE = "dsh-web-icon-indicator";
127
+
128
+ /**
129
+ * Settings namespace on the LEGACY host line (DSH ≤ 0.1.6-alpha.1), where the
130
+ * consumer-owned namespace is an arbitrary string it passes to
131
+ * `settings.installSection` and the settings document section is named after
132
+ * it. Kept at the historical value so an existing 0.5.x user's
133
+ * `web-icon-indicator:` section keeps working after this upgrade; the browser
134
+ * half binds its legacy scope to the same string.
135
+ */
136
+ const LEGACY_SETTINGS_NAMESPACE = "web-icon-indicator";
137
+
138
+ /**
139
+ * Mark one field as LIVE when the resolved schemastery supports it.
100
140
  *
101
- * Since DSH 0.1.2 the namespace is a plain string literal (the type-level
102
- * `SettingsNamespace` brand exists only at compile time; the runtime value is
103
- * the same string), so no helper import is needed here.
141
+ * `.volatile()` exists since schemastery 3.18.3 and is what makes the modern
142
+ * settings service render a field at all (`volatileForm()` keeps volatile
143
+ * fields only) and what makes a settings write mutate the running plugin's
144
+ * references + emit `loader/volatile-update` instead of restarting it. On the
145
+ * legacy host line the installed schemastery (3.18.2) has no `.volatile()`,
146
+ * and the legacy settings service owns the whole live-form lifecycle through
147
+ * `installSection`/`setSource`/`onChange`, so the same schema object works
148
+ * unwrapped there.
104
149
  */
105
- const SETTINGS_NAMESPACE = "web-icon-indicator";
150
+ const LIVE = (schema) => (typeof schema.volatile === "function" ? schema.volatile() : schema);
106
151
 
107
152
  /**
108
- * Schemastery schema mirroring `DEFAULTS`. Registered with the settings
109
- * service so the config is validated, persisted, and editable from the Web
110
- * settings page; resolution order is schema defaults → composition `base`
111
- * → user layer (`~/.dsh/settings.yaml`).
153
+ * Schemastery schema mirroring `DEFAULTS`. Exported as the plugin's `Config`,
154
+ * which is what the Cordis loader validates the composition row against and
155
+ * what the modern host settings service projects into its live form
156
+ * (`volatileForm` keeps only `.volatile()` fields, so the settings page can
157
+ * never advertise a key it cannot honor).
158
+ *
159
+ * `.volatile()` marks a field as LIVE: the loader commits a settings write
160
+ * into the running fiber's references and emits `loader/volatile-update`
161
+ * instead of restarting the plugin (see `apply`). Ordinary (non-volatile)
162
+ * fields — `statusPath` / `iconPathPrefix` — are baked into the route table
163
+ * and the injected script at registration time, so a document change to them
164
+ * could never take effect; keeping them non-volatile removes them from the
165
+ * live form while still validating the composition entry.
112
166
  */
113
167
  const STATE_CONFIG_SCHEMA = z.object({
114
168
  effect: z.union(EFFECT_NAMES).default("static"),
@@ -119,14 +173,24 @@ const STATE_CONFIG_SCHEMA = z.object({
119
173
  });
120
174
 
121
175
  const CONFIG_SCHEMA = z.object({
122
- askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
123
- doneHoldMs: z.number().min(0).default(DEFAULTS.doneHoldMs),
176
+ askingHoldMs: LIVE(z.number().min(0).default(DEFAULTS.askingHoldMs)),
177
+ doneHoldMs: LIVE(z.number().min(0).default(DEFAULTS.doneHoldMs)),
178
+ // Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
179
+ // default: an absent value keeps the idle state's own colors[0], exactly as
180
+ // before this key existed. `resolveConfig` folds a valid value into
181
+ // `states.idle.colors[0]` and drops an invalid one with a warning.
182
+ defaultColor: LIVE(z.string()),
183
+ iconsDir: LIVE(z.string()),
184
+ // dict (not a four-key object): the user layer may override only some
185
+ // states; the plugin merges the resolved value over DEFAULTS anyway. The
186
+ // whole dict is one volatile field, so the custom card can stage and write
187
+ // it atomically through `scope.mutate([{ op: 'set', path: ['states'], … }])`.
188
+ states: LIVE(z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states)),
189
+ // Registration-time keys: route table + injected script URLs are built in
190
+ // apply(), so they are composition-entry only (non-volatile ⇒ absent from
191
+ // the live settings form). See the README config table.
124
192
  statusPath: z.string().default(DEFAULTS.statusPath),
125
193
  iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
126
- iconsDir: z.string(),
127
- // dict (not a four-key object): the user layer may override only some
128
- // states; the plugin merges the resolved value over DEFAULTS anyway.
129
- states: z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states),
130
194
  });
131
195
 
132
196
  /** Browser script injected into every served index.html response. */
@@ -156,6 +220,23 @@ const INJECTED_SCRIPT = `
156
220
  // Whale geometry (used as the transform pivot for scale/translate effects).
157
221
  var CX = 27.889625, CY = 24.952640;
158
222
 
223
+ // Every request this script makes is deadline-bounded: a fetch that hangs
224
+ // (half-open TCP, a suspended host, a captive portal) would otherwise hold
225
+ // the poll chain and the base.svg load forever, freezing the icon with no
226
+ // recovery. Aborting surfaces as a rejection, so the existing retry paths
227
+ // (poll keeps its interval; base.svg retries on the next tick) take over.
228
+ function timedFetch(url, init) {
229
+ if (typeof AbortController === "undefined") return fetch(url, init);
230
+ var ctrl = new AbortController();
231
+ var timer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
232
+ var opts = init || {};
233
+ opts.signal = ctrl.signal;
234
+ return fetch(url, opts).then(
235
+ function (r) { clearTimeout(timer); return r; },
236
+ function (e) { clearTimeout(timer); throw e; }
237
+ );
238
+ }
239
+
159
240
  // Capture the shell's own favicon ONCE, together with an offline-safe
160
241
  // data-URI copy of it. The copy matters: while the DSH host is stopped
161
242
  // the original href (a URL served by that same host) is unreachable, so
@@ -175,7 +256,7 @@ const INJECTED_SCRIPT = `
175
256
  // keeps the last plugin frame instead of risking a dead URL.
176
257
  try {
177
258
  if (!href || typeof Blob === "undefined" || typeof FileReader === "undefined") return;
178
- fetch(href)
259
+ timedFetch(href)
179
260
  .then(function (r) { if (!r.ok) throw new Error("orig " + r.status); return r.blob(); })
180
261
  .then(function (blob) {
181
262
  return new Promise(function (resolve, reject) {
@@ -263,6 +344,27 @@ const INJECTED_SCRIPT = `
263
344
  }
264
345
 
265
346
  // ---- svg frame builder --------------------------------------------------
347
+ // Frame-URI cache. The base template is re-read and re-encoded on every
348
+ // animation frame, but most effects cycle through a tiny set of fills
349
+ // (static → 1, blink → 2, breath/geometric effects → 1 per color pair), so
350
+ // the encoded data URI is stable and can be memoized. rainbow is the
351
+ // exception — its fill is a fresh hue every frame — and is left uncached so
352
+ // the map can never grow without bound.
353
+ var URI_CACHE = new Map();
354
+ function cachedUri(kind, fill, build) {
355
+ var key = kind + "|" + fill;
356
+ var hit = URI_CACHE.get(key);
357
+ if (hit === undefined) {
358
+ hit = build();
359
+ // Keep the cache small (state count × a handful of colors); a settings
360
+ // edit can introduce new colors, and this script runs for the life of
361
+ // the tab.
362
+ if (URI_CACHE.size > 64) URI_CACHE.clear();
363
+ URI_CACHE.set(key, hit);
364
+ }
365
+ return hit;
366
+ }
367
+
266
368
  // Full-frame activity count ("满幅数字", per demo/badge.html bigNum
267
369
  // channel): while more than one agent is active the whole icon becomes a
268
370
  // state-colored rounded block with a bold count — no whale is drawn, so
@@ -275,22 +377,28 @@ const INJECTED_SCRIPT = `
275
377
  // cropping (mirrors the demo's svgFrame showWhale=false branch).
276
378
  function bigNumUri(fill) {
277
379
  var text = String(ACTIVE > 99 ? "99+" : ACTIVE);
278
- var chars = text.length;
279
- var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
280
- var rgb = hexToRgb(fill);
281
- var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
282
- var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
283
- var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
284
- '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
285
- '<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>' +
286
- '</svg>';
287
- return "data:image/svg+xml," + encodeURIComponent(svg);
380
+ return cachedUri("n" + text, fill, function () {
381
+ var chars = text.length;
382
+ var fs = chars === 1 ? 26 : chars === 2 ? 20 : 15.5;
383
+ var rgb = hexToRgb(fill);
384
+ var lum = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
385
+ var fg = lum > 0.6 ? "#111111" : "#FFFFFF"; // auto-contrast on the state color
386
+ var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">' +
387
+ '<rect x="0" y="0" width="50" height="50" rx="' + BIG_NUM_RX + '" fill="' + fill + '"/>' +
388
+ '<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>' +
389
+ '</svg>';
390
+ return "data:image/svg+xml," + encodeURIComponent(svg);
391
+ });
288
392
  }
289
393
 
290
394
  // Reuses the fetched base template; replaces its __COLOR__ token (and,
291
395
  // for scale/translate effects, wraps the whale in a <g transform>).
292
396
  function frameUri(fill, effect, t, speed) {
293
397
  if (!BASE) return null;
398
+ if (effect === "static") {
399
+ // No time component: one URI per color for the life of the tab.
400
+ return cachedUri("w", fill, function () { return "data:image/svg+xml," + encodeURIComponent(BASE.replace(RE, fill)); });
401
+ }
294
402
  var inner = BASE.replace(RE, fill);
295
403
  if (effect === "heartbeat" || effect === "bounce") {
296
404
  var gattr = "";
@@ -353,7 +461,12 @@ const INJECTED_SCRIPT = `
353
461
  // requestAnimationFrame in hidden tabs, so a background tab would freeze
354
462
  // on the last frame: repaint here instead — animated states get a
355
463
  // wall-clock frame (coarse ~1 Hz animation), static states repaint
356
- // (self-heal / pick up changes that happened while hidden).
464
+ // (self-heal / pick up changes that happened while hidden). A state
465
+ // change that happens while the tab is away (like the done hold
466
+ // expiring) paints on the next poll; the visibilitychange listener at
467
+ // the bottom fetches immediately when the tab comes back to the
468
+ // foreground, so that revert shows at once instead of waiting for a
469
+ // background-throttled tick.
357
470
  if (BASE && state + "|" + ACTIVE === PREV_KEY) {
358
471
  if (isStatic(state)) {
359
472
  var sframe = frameAt(state, 0);
@@ -366,8 +479,12 @@ const INJECTED_SCRIPT = `
366
479
  }
367
480
  PREV_KEY = state + "|" + ACTIVE;
368
481
  stopAnim();
369
- if (!BASE) { // base.svg not loaded yet — fetch once, then start
370
- fetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
482
+ // The count block is drawn from scratch, so it needs no base.svg: only the
483
+ // whale path waits for the template. Loading it here (and retrying on the
484
+ // next poll, since PREV_KEY was already set) keeps a failed/timed-out
485
+ // template fetch from blanking the tab while agents are busy.
486
+ if (!BASE && ACTIVE < BIG_NUM_MIN) {
487
+ timedFetch(location.origin + BASE_PATH + "?t=" + Date.now(), { cache: "no-store" })
371
488
  .then(function (r) { if (!r.ok) throw new Error("base " + r.status); return r.text(); })
372
489
  .then(function (txt) { BASE = txt; PREV_KEY = null; apply(state); })
373
490
  .catch(function () {});
@@ -409,7 +526,7 @@ const INJECTED_SCRIPT = `
409
526
  PREV_KEY = null; // force a full repaint on the next apply
410
527
  }
411
528
  function poll() {
412
- fetch(location.origin + STATUS_PATH, { cache: "no-store" })
529
+ timedFetch(location.origin + STATUS_PATH, { cache: "no-store" })
413
530
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
414
531
  .then(function (j) {
415
532
  syncCfg(j);
@@ -437,6 +554,16 @@ const INJECTED_SCRIPT = `
437
554
  captureOriginal();
438
555
  TIMER = setInterval(poll, 1000);
439
556
  poll();
557
+ // A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
558
+ // ~1/min after ~5 min hidden) and rAF is paused, so a state change that
559
+ // happened while the tab was away may take a while to reach the favicon —
560
+ // e.g. the green "done" frame sits there until the next poll finally fires.
561
+ // The moment the tab becomes visible again, fetch the fresh status and
562
+ // repaint right away instead of waiting for the next (possibly throttled)
563
+ // poll tick. Cheap, idempotent, and it also covers browsers that froze the
564
+ // page entirely while hidden (their first task on unfreeze is this poll).
565
+ function onVisible() { if (document.visibilityState === "visible") poll(); }
566
+ window.addEventListener("visibilitychange", onVisible);
440
567
  window.addEventListener("beforeunload", function () { stopAnim(); if (TIMER) clearInterval(TIMER); restore(); });
441
568
  } catch (e) { }
442
569
  })();
@@ -453,39 +580,214 @@ function resolveIconsDir(configured) {
453
580
  return join(__dirname, "..", "icons");
454
581
  }
455
582
 
583
+ // ---- Color math for the default-color similarity check ---------------------
584
+ // lib/client.js carries its OWN copy of this block: the two halves run in
585
+ // different processes, there is no shared module and no build step (docs/main.js
586
+ // already duplicates the same color math for the showcase site). Keep the
587
+ // thresholds in sync when tuning either copy.
588
+ /** Accepted defaultColor shape: `#rgb` or `#rrggbb` (case-insensitive). */
589
+ const DEFAULT_COLOR_RX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
590
+ /** CIE76 ΔE below which two colors are practically the same. */
591
+ const SIMILAR_DE_STRONG = 12;
592
+ /** CIE76 ΔE below which two colors are easily confused at favicon size. */
593
+ const SIMILAR_DE_WARN = 25;
594
+ /** Lab chroma above which a default color collides with a rainbow sweep. */
595
+ const RAINBOW_CHROMA_MIN = 15;
596
+ /** The states a default color is compared against (idle is the default itself). */
597
+ const OTHER_STATES = STATE_NAMES.filter((name) => name !== "idle");
598
+
599
+ /** `#rgb` / `#rrggbb` → lowercase 6-digit hex, or null when malformed. */
600
+ function normalizeHex(value) {
601
+ if (typeof value !== "string") return null;
602
+ const text = value.trim();
603
+ if (!DEFAULT_COLOR_RX.test(text)) return null;
604
+ const body = text.slice(1).toLowerCase();
605
+ return body.length === 3
606
+ ? "#" + body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
607
+ : "#" + body;
608
+ }
609
+
610
+ function hexToRgb(hex) {
611
+ let body = String(hex).replace("#", "");
612
+ // Expand #rgb exactly like the browser half does — every host caller feeds
613
+ // normalizeHex()'s 6-digit output today, but the shortcut is a trap for the
614
+ // next caller (and this copy must stay equivalent to lib/client.js's).
615
+ if (body.length === 3) body = body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
616
+ const n = parseInt(body, 16);
617
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
618
+ }
619
+
620
+ function rgbToHex(r, g, b) {
621
+ return "#" + [r, g, b].map((v) => {
622
+ const c = Math.max(0, Math.min(255, Math.round(v)));
623
+ return ("0" + c.toString(16)).slice(-2);
624
+ }).join("");
625
+ }
626
+
627
+ /** Linear blend between two hex colors (mirrors the browser's `mix()`). */
628
+ function mixHex(a, b, t) {
629
+ const ca = hexToRgb(a);
630
+ const cb = hexToRgb(b);
631
+ return rgbToHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
632
+ }
633
+
634
+ /** CIELAB (D65) for an sRGB hex color. */
635
+ function labOf(hex) {
636
+ const rgb = hexToRgb(hex);
637
+ const lin = (v) => {
638
+ const c = v / 255;
639
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
640
+ };
641
+ const r = lin(rgb[0]);
642
+ const g = lin(rgb[1]);
643
+ const b = lin(rgb[2]);
644
+ const x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
645
+ const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
646
+ const z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
647
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29);
648
+ const fx = f(x);
649
+ const fy = f(y);
650
+ const fz = f(z);
651
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
652
+ }
653
+
654
+ /** CIE76 ΔE (Euclidean distance in CIELAB). */
655
+ function deltaE(a, b) {
656
+ const la = labOf(a);
657
+ const lb = labOf(b);
658
+ return Math.sqrt(
659
+ Math.pow(la[0] - lb[0], 2) + Math.pow(la[1] - lb[1], 2) + Math.pow(la[2] - lb[2], 2)
660
+ );
661
+ }
662
+
663
+ function labChroma(hex) {
664
+ const l = labOf(hex);
665
+ return Math.sqrt(l[1] * l[1] + l[2] * l[2]);
666
+ }
667
+
668
+ /**
669
+ * The fills a state actually paints, mirroring the browser's `frameColor()`:
670
+ * `blink` toggles colors[0] ⇄ colors[1] (a darker second color is derived when
671
+ * colors[1] is omitted), `breath` interpolates between them, everything else
672
+ * uses colors[0]. `rainbow` is flagged instead — it sweeps every hue.
673
+ */
674
+ function stateFills(state) {
675
+ const cols = Array.isArray(state?.colors) ? state.colors : [];
676
+ const effect = state?.effect || "static";
677
+ const c0 = normalizeHex(cols[0] || "");
678
+ const rainbow = effect === "rainbow";
679
+ // A rainbow state never paints its own colors[0] literally (that value is
680
+ // only the starting hue — the sweep covers the whole wheel), so it has no
681
+ // comparable fills: the chroma rule in colorWarnings() covers it instead.
682
+ if (!c0 || rainbow) return { fills: [], rainbow };
683
+ const c1 = normalizeHex(cols[1] || "") || mixHex(c0, "#000000", 0.35);
684
+ if (effect === "blink") return { fills: [c0, c1], rainbow: false };
685
+ if (effect === "breath") {
686
+ // The browser paints the CONTINUOUS mix, so a sparse sample can only ever
687
+ // over-estimate the distance: a blue->green breath was reported ΔE 37 away
688
+ // from a default colour it actually passes through (ΔE 0). 33 samples keep
689
+ // the worst reported-vs-true gap around 5 ΔE (see lib/client.js's copy).
690
+ const fills = [];
691
+ for (let i = 0; i <= 32; i += 1) fills.push(mixHex(c0, c1, i / 32));
692
+ return { fills, rainbow: false };
693
+ }
694
+ return { fills: [c0], rainbow };
695
+ }
696
+
456
697
  /**
457
- * Cordis plugin entry. Returns the standard `{ apply, inject, config }`
458
- * shape so the host composition can mount it once at startup.
698
+ * Similarity warnings for the effective default color (the idle primary).
699
+ * Only the default is compared against the other states — NOT the states
700
+ * against each other: the shipped defaults deliberately share #FACC15 between
701
+ * `running` and the `asking` blink, so a pairwise check would warn forever.
702
+ */
703
+ function colorWarnings(states) {
704
+ const warnings = [];
705
+ const base = normalizeHex(states?.idle?.colors?.[0] || "");
706
+ if (!base) return warnings;
707
+ // Idle itself on `rainbow` (composition entry / hand-written settings.yaml —
708
+ // the card never exposes it): the idle icon sweeps every hue, so the default
709
+ // colour can never stay distinguishable from it. One advisory replaces the
710
+ // pairwise pass, which would judge a colour idle never paints literally.
711
+ if ((states?.idle?.effect || "static") === "rainbow") {
712
+ if (labChroma(base) >= RAINBOW_CHROMA_MIN) {
713
+ warnings.push({ code: "rainbow-overlap", state: "idle", base, level: "warn" });
714
+ }
715
+ return warnings;
716
+ }
717
+ for (const name of OTHER_STATES) {
718
+ const { fills, rainbow } = stateFills(states?.[name]);
719
+ if (rainbow && labChroma(base) >= RAINBOW_CHROMA_MIN) {
720
+ warnings.push({ code: "rainbow-overlap", state: name, base, level: "warn" });
721
+ }
722
+ let closest = null;
723
+ for (const fill of fills) {
724
+ const de = deltaE(base, fill);
725
+ if (closest === null || de < closest.deltaE) closest = { color: fill, deltaE: de };
726
+ }
727
+ if (closest && closest.deltaE < SIMILAR_DE_WARN) {
728
+ warnings.push({
729
+ code: "color-too-close",
730
+ state: name,
731
+ base,
732
+ color: closest.color,
733
+ deltaE: Math.round(closest.deltaE * 10) / 10,
734
+ level: closest.deltaE < SIMILAR_DE_STRONG ? "strong" : "warn",
735
+ });
736
+ }
737
+ }
738
+ return warnings;
739
+ }
740
+
741
+ /**
742
+ * Cordis plugin entry. Returns the standard `{ apply, inject, Config }`
743
+ * shape so the host composition can mount it once at startup. `Config` (the
744
+ * schemastery schema) is what the loader validates the row against and what
745
+ * the host settings service projects into its live form — a plugin without it
746
+ * has no server-declared configuration surface at all. One bundle serves both
747
+ * host lines: the legacy `settings.installSection` path is feature-detected at
748
+ * runtime (see `apply`).
459
749
  */
460
750
  export default {
461
751
  name: "dsh-web-icon-indicator",
462
- inject: ["webServer", "timer", "agents", "fs", "sandboxPolicy"],
463
- config: {
464
- askingHoldMs: DEFAULTS.askingHoldMs,
465
- doneHoldMs: DEFAULTS.doneHoldMs,
466
- statusPath: DEFAULTS.statusPath,
467
- iconPathPrefix: DEFAULTS.iconPathPrefix,
468
- iconsDir: undefined,
469
- states: DEFAULTS.states,
470
- },
471
- /** Settings namespace + validation schema (exported for reuse/tooling). */
752
+ inject: ["webServer", "timer", "agents", "fs"],
753
+ Config: CONFIG_SCHEMA,
754
+ /** Settings namespace (the profile entry id on ≥0.1.7) + schema, for tooling. */
472
755
  SETTINGS_NAMESPACE,
756
+ /** Settings namespace used on the legacy (≤0.1.6-alpha.1) settings service. */
757
+ LEGACY_SETTINGS_NAMESPACE,
473
758
  CONFIG_SCHEMA,
474
759
  apply(ctx, config) {
475
- // Cordis passes the resolved composition config as the second argument
476
- // (`apply(ctx, config)`). `config` is the row's own `config` from the
477
- // profile composition (merged over the schema only when a `Config` schema
478
- // is declared); this plugin merges its `DEFAULTS` itself via `resolveConfig`,
479
- // so an absent row config simply yields the defaults. Fall back to the old
480
- // `ctx.get("config")` read for compatibility (it is undefined on the host
481
- // but kept for the zero-dep test harness and older hosts).
482
- const entry = config ?? ctx.get("config") ?? {};
483
- let source = () => entry;
760
+ // Cordis passes the schema-resolved config as the second argument
761
+ // (`apply(ctx, config)`). Every `.volatile()` field arrives as a live
762
+ // reference (`config.states.get()`), and the loader mutates those
763
+ // references in place when a settings write lands — that is what
764
+ // `loader/volatile-update` below reacts to. Unwrap the references once per
765
+ // read so the rest of this file keeps working on plain values. The
766
+ // `ctx.get("config")` fallback keeps the zero-dependency test harness (and
767
+ // any host that hands the row config through the service container) working.
768
+ const readRef = (value) =>
769
+ value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
770
+ const readConfig = (raw) => {
771
+ const out = {};
772
+ for (const [key, value] of Object.entries(raw || {})) {
773
+ const plain = readRef(value);
774
+ if (plain !== undefined) out[key] = plain;
775
+ }
776
+ return out;
777
+ };
778
+ // Legacy host line (DSH ≤ 0.1.6-alpha.1): `settings.installSection` owns the
779
+ // namespace lifecycle and hands this plugin a live source through
780
+ // `setSource`. Until it does, the composition config passed to `apply` (or
781
+ // the zero-dep test harness's `ctx.get("config")`) is the source.
782
+ let legacySource = null;
783
+ const source = () =>
784
+ readConfig(legacySource ? legacySource() : config ?? ctx.get("config") ?? {});
484
785
  // Merge each configured state over its default: { effect, colors, speed }.
485
786
  // Idempotent on schema-resolved values, so it doubles as the fallback
486
787
  // normalization when the profile composes no settings service.
487
788
  const resolveConfig = (raw) => {
488
789
  const stateConfigs = {};
790
+ const warnings = [];
489
791
  for (const name of STATE_NAMES) {
490
792
  const merged = { ...(DEFAULTS.states[name] || {}), ...(raw.states?.[name] || {}) };
491
793
  // Guard against unknown effect names: fall back to "static" so the
@@ -496,59 +798,143 @@ export default {
496
798
  let cols = merged.colors;
497
799
  if (typeof cols === "string") cols = [cols];
498
800
  if (!Array.isArray(cols)) cols = [];
499
- cols = cols.filter((c) => typeof c === "string" && /^#[0-9a-fA-F]{3,6}$/.test(c.trim()));
801
+ // Strict `#rgb` / `#rrggbb` only — the same rule normalizeHex() and the
802
+ // card use. The old lenient 3-6 digit test let a "#1234" through and
803
+ // parseInt() painted a bogus colour from it, which the card (correctly)
804
+ // refused to reason about.
805
+ cols = cols.filter((c) => typeof c === "string" && DEFAULT_COLOR_RX.test(c.trim()));
500
806
  merged.colors = cols.length ? cols : [...(DEFAULTS.states[name]?.colors || ["#1a1a1a"])];
501
807
  // speed: must be a positive number; otherwise use the default (1200).
502
808
  if (typeof merged.speed !== "number" || !(merged.speed > 0)) delete merged.speed;
503
809
  stateConfigs[name] = merged;
504
810
  }
505
- return { ...DEFAULTS, ...raw, states: stateConfigs };
811
+ // `undefined` values must not shadow a DEFAULTS entry: the plugin's own
812
+ // static `config` row carries `iconsDir: undefined` by design, and a
813
+ // schema-resolved settings value may omit optional keys entirely. Only
814
+ // keys the caller actually set may override a default.
815
+ const defined = {};
816
+ for (const [key, value] of Object.entries(raw || {})) {
817
+ if (value !== undefined) defined[key] = value;
818
+ }
819
+ const resolved = { ...DEFAULTS, ...defined, states: stateConfigs };
820
+ // `defaultColor` is sugar for the idle state's PRIMARY color. Fold it into
821
+ // `states.idle.colors[0]` here instead of teaching the browser a second
822
+ // rendering path: the injected script, the baked `__CFG__` payload and
823
+ // even an already-baked older bundle therefore keep working unchanged,
824
+ // and the live settings sync (`cfg.states = next.states`) carries it for
825
+ // free. A malformed value is dropped with a warning, never painted.
826
+ delete resolved.defaultColor;
827
+ const requestedDefault = typeof defined.defaultColor === "string"
828
+ ? defined.defaultColor.trim()
829
+ : defined.defaultColor;
830
+ // "" / whitespace written by hand into the composition entry means "not
831
+ // set" — not a malformed color to warn about. Neither does an empty YAML
832
+ // value, which parses as null (`defaultColor:`).
833
+ if (requestedDefault != null && requestedDefault !== "") {
834
+ const normalized = normalizeHex(requestedDefault);
835
+ if (normalized) {
836
+ const idle = stateConfigs.idle;
837
+ stateConfigs.idle = { ...idle, colors: [normalized, ...(idle.colors || []).slice(1)] };
838
+ resolved.defaultColor = normalized;
839
+ } else {
840
+ warnings.push({ code: "invalid-color", value: String(requestedDefault) });
841
+ }
842
+ }
843
+ warnings.push(...colorWarnings(stateConfigs));
844
+ resolved.warnings = warnings;
845
+ return resolved;
846
+ };
847
+ // Surface the similarity / validity warnings on the host as well: the
848
+ // settings card computes its own live copy, while this covers configs that
849
+ // arrive through the composition entry (a hand-written cordis.yml) and
850
+ // leaves a server-side record. `ctx.logger` is cordis' core logging
851
+ // service — guard it, since the zero-dep test harness drives `apply()`
852
+ // with a ctx that has none.
853
+ const logWarnings = (list) => {
854
+ if (!Array.isArray(list) || list.length === 0) return;
855
+ try {
856
+ if (typeof ctx.logger !== "function") return;
857
+ const logger = ctx.logger("dsh-web-icon-indicator");
858
+ for (const w of list) {
859
+ const text = w.code === "invalid-color"
860
+ ? `defaultColor ${JSON.stringify(w.value)} is not a 3- or 6-digit hex color — ignored`
861
+ : w.code === "rainbow-overlap"
862
+ ? `defaultColor ${w.base} is too close to the "${w.state}" state: its rainbow effect sweeps every hue`
863
+ : `defaultColor ${w.base} is ${w.level === "strong" ? "nearly identical to" : "too close to"} the "${w.state}" state color ${w.color} (ΔE ${w.deltaE})`;
864
+ if (logger && typeof logger.warn === "function") logger.warn(text);
865
+ }
866
+ } catch (e) {}
506
867
  };
507
- let cfg = resolveConfig(entry);
868
+ let cfg = resolveConfig(source());
869
+ logWarnings(cfg.warnings);
508
870
  // The injected script bakes config at injection time; rebuild it whenever
509
- // the settings section changes so the NEXT page load picks the new values
510
- // up (reload the tab — same contract as before this registration).
871
+ // the live config changes so the NEXT page load picks the new values up
872
+ // (reload the tab — the running tab is already carried by the status poll's
873
+ // `states` echo).
511
874
  const buildScript = (c) => INJECTED_SCRIPT
512
875
  .replace("__STATUS_PATH__", c.statusPath)
513
876
  .replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
514
877
  .replace("__CFG__", JSON.stringify({ states: c.states }));
515
878
  let script = buildScript(cfg);
516
879
  let iconsDir = resolveIconsDir(cfg.iconsDir);
517
- // Register the settings namespace with the host `settings` service. Since
518
- // DSH 0.1.2 the standalone `installSettingsSection` export is gone — the
519
- // host exposes a `settings` service whose `installSection(owner, ns,
520
- // schema, entry, hooks)` carries the same attach/detach lifecycle (base
521
- // layer + `setSource` sink + `onChange` notification). The host's own
522
- // plugins all use `ctx.inject(["settings"], …)` so the section attaches as
523
- // soon as the provider is available — a plain `ctx.get("settings")` can run
524
- // before the provider is ready and silently skip the registration. When no
525
- // settings provider is composed the callback never fires and the plugin
526
- // keeps working on its composition entry + DEFAULTS only.
527
- ctx.inject(["settings"], (settingsCtx) => {
528
- settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, CONFIG_SCHEMA, entry, {
529
- setSource: (next) => { source = next; },
530
- onChange: () => {
531
- // Settings service mounted, or the section changed: re-resolve, then
532
- // mutate cfg in place so the state-machine closures (asking/done hold)
533
- // and the injected script see the new values immediately.
534
- const next = resolveConfig(source());
535
- cfg.askingHoldMs = next.askingHoldMs;
536
- cfg.doneHoldMs = next.doneHoldMs;
537
- cfg.statusPath = next.statusPath;
538
- cfg.iconPathPrefix = next.iconPathPrefix;
539
- if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
540
- cfg.states = next.states;
541
- iconsDir = resolveIconsDir(cfg.iconsDir);
542
- script = buildScript(cfg);
543
- },
880
+ // Live settings, modern line (DSH ≥ 0.1.7). The host owns the whole
881
+ // configuration lifecycle: the loader validates the composition row against
882
+ // `Config`, the `settings` service (`SettingsForms`) derives the live form
883
+ // from the schema's `.volatile()` fields and writes them into the profile
884
+ // patch, and a volatile-only write is committed into the running fiber's
885
+ // references with a `loader/volatile-update` event instead of a restart.
886
+ // This listener rebuilds the cached `cfg` and the injected script from the
887
+ // same references; the legacy line below drives it through `onChange`
888
+ // instead. `statusPath` / `iconPathPrefix` are NOT volatile: they are baked
889
+ // into the route table and the injected script at registration time, so a
890
+ // document change to them re-applies the whole plugin instead (they stay
891
+ // outside the live form — see CONFIG_SCHEMA).
892
+ const refreshFromConfig = () => {
893
+ const next = resolveConfig(source());
894
+ cfg.askingHoldMs = next.askingHoldMs;
895
+ cfg.doneHoldMs = next.doneHoldMs;
896
+ if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
897
+ cfg.states = next.states;
898
+ // `defaultColor` / `warnings` ride along with `states`: the color is
899
+ // already folded into `states.idle.colors[0]`, so the browser needs no
900
+ // new key — they are kept for observability (status echo + log).
901
+ cfg.defaultColor = next.defaultColor;
902
+ cfg.warnings = next.warnings;
903
+ iconsDir = resolveIconsDir(cfg.iconsDir);
904
+ script = buildScript(cfg);
905
+ logWarnings(cfg.warnings);
906
+ };
907
+ // A plain `ctx.on` listener is enough: the loader emits this event on the
908
+ // owning fiber's context only, so a volatile write to another plugin's
909
+ // config never reaches here. Guard for the zero-dep test harness, whose
910
+ // fake ctx carries no event bus.
911
+ if (typeof ctx.on === "function") ctx.on("loader/volatile-update", refreshFromConfig);
912
+ // Legacy host line (DSH ≤ 0.1.6-alpha.1): the `settings` service exposes
913
+ // `installSection`, and the plugin must register its namespace itself —
914
+ // that service method was removed in 0.1.7, where the exported `Config`
915
+ // schema plus `loader/volatile-update` carry the same lifecycle. Feature-
916
+ // detect instead of switching on a version: whichever service the running
917
+ // host provides decides the path, so one bundle serves both lines.
918
+ // The base layer is a RESOLVED PLAIN COPY (`resolveConfig(source())`), not
919
+ // the raw `apply` config: the legacy loader deep-freezes the config it
920
+ // resolved against the exported `Config` schema, and the legacy settings
921
+ // service resolves the schema over the base IN PLACE (its `mergeLayers`
922
+ // returns the base unchanged when no user section exists) — handing it a
923
+ // frozen object throws "Cannot assign to read only property".
924
+ if (typeof ctx.inject === "function") {
925
+ ctx.inject(["settings"], (settingsCtx) => {
926
+ const settings = settingsCtx.settings;
927
+ if (!settings || typeof settings.installSection !== "function") return;
928
+ settings.installSection(ctx, LEGACY_SETTINGS_NAMESPACE, CONFIG_SCHEMA, resolveConfig(source()), {
929
+ setSource: (next) => { legacySource = next; },
930
+ onChange: refreshFromConfig,
931
+ });
544
932
  });
545
- });
933
+ }
546
934
  const webServer = ctx.webServer;
547
935
  const agents = ctx.agents;
548
936
  const fs = ctx.fs;
549
- const sp = ctx.sandboxPolicy;
550
937
  const lastSeen = new Map();
551
- void sp; // sandboxPolicy injection is currently unused — reserved for future file-access policy
552
938
 
553
939
  // -- State machine -------------------------------------------------------
554
940
  const states = new Map(); // agentId -> { state, since }
@@ -620,31 +1006,46 @@ export default {
620
1006
  // leaves `running`, so a lost `tools/result` cannot pin `asking` forever.
621
1007
  const scheduleAskCheck = (id) => {
622
1008
  const prevHandle = askTimers.get(id);
623
- if (prevHandle) { try { prevHandle(); } catch (e) {} askTimers.delete(id); }
1009
+ // Never cancel a handle whose callback is executing right now: it is the
1010
+ // very call re-arming the pin, and its `finally` would then delete the
1011
+ // fresh handle and leave the session pinned to `asking` with no timer to
1012
+ // release it. A stale (non-running) handle is still cancelled, so a
1013
+ // repeated pre-execute cannot stack two timers.
1014
+ if (prevHandle && !prevHandle.running()) {
1015
+ try { prevHandle(); } catch (e) {}
1016
+ askTimers.delete(id);
1017
+ }
1018
+ let running = false;
624
1019
  const handle = ctx.timer.timeout(() => {
1020
+ running = true;
625
1021
  askTimers.delete(id);
626
- if (!asking.has(id)) return;
627
- if (!askDone.has(id)) {
628
- const live = agents.get(id);
629
- // Result not yet reported AND the agent is still mid-turn: the user
630
- // may still be deciding — keep the pin (no re-arm budget; a human
631
- // question can legitimately outlast many hold windows).
632
- if (live && live.status === "running") { scheduleAskCheck(id); return; }
633
- // Result never arrived and the turn ended (agent idle / gone): a lost
634
- // tools/result cannot keep the icon blinking forever — force-release
635
- // the pin against live status.
1022
+ try {
1023
+ if (!asking.has(id)) return;
1024
+ if (!askDone.has(id)) {
1025
+ const live = agents.get(id);
1026
+ // Result not yet reported AND the agent is still mid-turn: the user
1027
+ // may still be deciding — keep the pin (no re-arm budget; a human
1028
+ // question can legitimately outlast many hold windows).
1029
+ if (live && live.status === "running") { scheduleAskCheck(id); return; }
1030
+ // Result never arrived and the turn ended (agent idle / gone): a lost
1031
+ // tools/result cannot keep the icon blinking forever — force-release
1032
+ // the pin against live status.
1033
+ asking.delete(id);
1034
+ askDone.delete(id);
1035
+ if (live) setState(id, live.status === "running" ? "running" : "idle");
1036
+ else setState(id, "idle");
1037
+ return;
1038
+ }
636
1039
  asking.delete(id);
637
1040
  askDone.delete(id);
1041
+ const live = agents.get(id);
638
1042
  if (live) setState(id, live.status === "running" ? "running" : "idle");
639
1043
  else setState(id, "idle");
640
- return;
1044
+ } finally {
1045
+ running = false;
641
1046
  }
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
1047
  }, cfg.askingHoldMs);
1048
+ handle.running = () => running;
648
1049
  askTimers.set(id, handle);
649
1050
  };
650
1051
 
@@ -696,6 +1097,7 @@ export default {
696
1097
  asking.delete(id);
697
1098
  askDone.delete(id);
698
1099
  pendingApprovals.delete(id);
1100
+ approvalCursor.delete(id);
699
1101
  lastSeen.delete(id);
700
1102
  cancelDoneHold(id);
701
1103
  const t = askTimers.get(id);
@@ -707,17 +1109,34 @@ export default {
707
1109
  // 0.1.2-alpha.4 `Session.events` is gone — read the log on demand through
708
1110
  // `session.snapshotEvents()` (a frozen half-open range snapshot). Guard on
709
1111
  // the method so older/newer hosts degrade gracefully.
1112
+ //
1113
+ // The fold is INCREMENTAL: `snapshotEvents()` deep-freezes every event it
1114
+ // returns, and reconcile runs on every 1 s status poll, so re-reading the
1115
+ // whole log per poll would clone the entire history each second. A per-agent
1116
+ // cursor keeps only the delta (the log is append-only, so the open-approval
1117
+ // set is a pure fold over the events seen so far).
1118
+ const approvalCursor = new Map(); // agentId -> { seq, open: Set<approvalId> }
710
1119
  const hasPendingApproval = (live) => {
711
1120
  const session = live?.session;
712
1121
  if (!session || typeof session.snapshotEvents !== "function") return false;
713
- const events = session.snapshotEvents();
714
- if (!Array.isArray(events)) return false;
715
- const open = new Set();
1122
+ const id = live?.id ?? null;
1123
+ const cursor = (id != null && approvalCursor.get(id)) || { seq: 0, open: new Set() };
1124
+ let events;
1125
+ try {
1126
+ events = session.snapshotEvents(cursor.seq);
1127
+ } catch (e) {
1128
+ return cursor.open.size > 0; // keep the last good fold on a read failure
1129
+ }
1130
+ if (!Array.isArray(events)) return cursor.open.size > 0;
1131
+ let seq = cursor.seq;
716
1132
  for (const ev of events) {
717
- if (ev.type === "approval/asked") open.add(ev.data?.id);
718
- else if (ev.type === "approval/decided") open.delete(ev.data?.id);
1133
+ if (typeof ev?.seq === "number" && ev.seq >= seq) seq = ev.seq + 1;
1134
+ if (ev?.type === "approval/asked") cursor.open.add(ev.data?.id);
1135
+ else if (ev?.type === "approval/decided") cursor.open.delete(ev.data?.id);
719
1136
  }
720
- return open.size > 0;
1137
+ cursor.seq = seq;
1138
+ if (id != null) approvalCursor.set(id, cursor);
1139
+ return cursor.open.size > 0;
721
1140
  };
722
1141
 
723
1142
  // Reconcile running->idle transitions on every status request.
@@ -789,7 +1208,16 @@ export default {
789
1208
  res.statusCode = 200;
790
1209
  res.setHeader("Content-Type", "application/json; charset=utf-8");
791
1210
  res.setHeader("Cache-Control", "no-store");
792
- res.end(JSON.stringify({ ...aggregate(), states: cfg.states }));
1211
+ // `defaultColor` + `warnings` are additive: older browser bundles ignore
1212
+ // unknown keys, and the live config sync only reads `states`.
1213
+ // `defaultColor` is echoed as null when none is configured so the
1214
+ // documented shape has the key unconditionally.
1215
+ res.end(JSON.stringify({
1216
+ ...aggregate(),
1217
+ states: cfg.states,
1218
+ defaultColor: cfg.defaultColor ?? null,
1219
+ warnings: cfg.warnings || [],
1220
+ }));
793
1221
  },
794
1222
  }));
795
1223