dsh-web-icon-indicator 0.5.0 → 0.5.2

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/client.js CHANGED
@@ -2,15 +2,26 @@
2
2
  * Browser half of dsh-web-icon-indicator (dsh.client bundle, ModuleLoader
3
3
  * factory format — hand-written, no build step).
4
4
  *
5
- * Registers one card into the shared "plugin configuration" surface
6
- * (`settings.plugin.item`, keyed by the `web-icon-indicator` settings
7
- * namespace). The card binds the namespace's settings scope, stages edits
8
- * locally, and writes them through `scope.set` / `scope.unset` (validated and
9
- * persisted host-side into the profile's settings.yaml).
5
+ * Registers one configuration page and serves BOTH host generations from one
6
+ * bundle:
10
7
  *
11
- * The tab that dispatches this slot (`dsh-client-ui-settings-plugins`) only
12
- * renders the card while the host serves the namespace, so this bundle is
13
- * inert on deployments that do not compose the host plugin.
8
+ * - DSH ≥ 0.1.7 (modern): page slot `plugins.row.config` keyed
9
+ * `<package name>#<row id>`, bound to the profile-entry settings namespace
10
+ * through `configForms` (`ctx.configForms.get(entryId)`).
11
+ * - DSH ≤ 0.1.6-alpha.1 (legacy): page slot `settings.plugin.item` keyed by the
12
+ * legacy `web-icon-indicator` namespace, bound through `settingsScope`.
13
+ *
14
+ * Neither settings provider is declared in `inject` — both are read weakly at
15
+ * render time (`resolveScope`) and the slot registration is attempted for both
16
+ * names (`slots.inject` is a no-op for a slot the host never declares). The
17
+ * card body is generation-agnostic: the modern controller and the legacy scope
18
+ * expose the same snapshot + `mutate` / `unset` face. The card stages edits
19
+ * locally and writes them through that scope (validated, revision-fenced, and
20
+ * persisted host-side).
21
+ *
22
+ * The page is inert on deployments that do not compose the host plugin: on the
23
+ * modern line registration is gated on `configForms.whileServed`, and the
24
+ * legacy tab only dispatches namespaces the host actually serves.
14
25
  */
15
26
  window.__ModuleLoader__.load({
16
27
  id: "dsh-web-icon-indicator",
@@ -26,9 +37,28 @@ window.__ModuleLoader__.load({
26
37
  // Official UI primitives (same source the shell's own plugin cards use,
27
38
  // e.g. dsh-client-ui-settings-plugins): the standard collapse chevron.
28
39
  var primitives = require("@deepseek-ai/dsh-client-ui-primitives");
40
+ // The primitives icon set moved from size-suffixed names (`…Outline14`) to
41
+ // stroke-weight names (`…OutlineRegular` / `…OutlineMedium`); fall back so
42
+ // one bundle keeps working on both host lines.
43
+ var ChevronDownIcon = primitives.IconChevronDownOutlineRegular || primitives.IconChevronDownOutline14;
29
44
 
30
- /** Settings namespace owned by the host plugin (must match lib/index.js). */
31
- var NS = "web-icon-indicator";
45
+ /**
46
+ * Settings namespace owned by the host plugin on the MODERN host line
47
+ * (DSH ≥ 0.1.7): the settings service keys every form by the profile entry
48
+ * id, and the bundle patch in `cordis.patch.yml` inserts exactly one row
49
+ * with this id. Keep it in sync with `SETTINGS_NAMESPACE` in lib/index.js
50
+ * and the row id in the patch.
51
+ */
52
+ var NS = "dsh-web-icon-indicator";
53
+ /**
54
+ * Settings namespace the LEGACY host line (DSH ≤ 0.1.6-alpha.1) serves: the
55
+ * plugin registers it through `settings.installSection`, and this half binds
56
+ * the legacy `settingsScope` to the same string. Also the key the legacy
57
+ * settings tab dispatches in `settings.plugin.item`.
58
+ */
59
+ var LEGACY_NS = "web-icon-indicator";
60
+ /** `plugins.row.config` key: `<bundle package name>#<row id as the patch declares it>`. */
61
+ var ROW_KEY = "dsh-web-icon-indicator#dsh-web-icon-indicator";
32
62
  var LOCALE = "dsh-web-icon-indicator";
33
63
  var STATE_NAMES = ["idle", "running", "asking", "done"];
34
64
  var EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "bounce"];
@@ -82,8 +112,17 @@ window.__ModuleLoader__.load({
82
112
  bounce: "effectBounce",
83
113
  };
84
114
 
85
- /** Cordis fiber services this browser plugin injects. */
86
- var inject = ["slots", "settingsScope", "locale"];
115
+ /**
116
+ * Cordis fiber services this browser plugin injects.
117
+ *
118
+ * Deliberately only the two services every host line provides: the settings
119
+ * provider itself is read weakly at render time (`resolveScope`), so one
120
+ * bundle serves both the modern `configForms` line (DSH ≥ 0.1.7) and the
121
+ * legacy `settingsScope` line (≤ 0.1.6-alpha.1). A hard `inject` on either
122
+ * name would leave the fiber un-activated on the other line, which is what
123
+ * broke the 0.5.x bundle on 0.1.7.
124
+ */
125
+ var inject = ["slots", "locale"];
87
126
 
88
127
  // ---------------------------------------------------------------------------
89
128
  // Locale copy
@@ -642,12 +681,17 @@ window.__ModuleLoader__.load({
642
681
  return { kind: "set", value: parsed };
643
682
  }
644
683
 
645
- /** Subscribe a React component to the scope's snapshot store. */
684
+ /**
685
+ * Subscribe a React component to the scope's snapshot store. `scope` may be
686
+ * null while the host serves no settings provider for our namespace (or
687
+ * before one appears); the hook count stays identical either way, and a
688
+ * null scope yields a null snapshot that renders nothing.
689
+ */
646
690
  function useScopeSnapshot(scope) {
647
691
  return useSyncExternalStore(
648
- function (listener) { return scope.subscribe(listener); },
649
- function () { return scope.getSnapshot(); },
650
- function () { return scope.getSnapshot(); }
692
+ function (listener) { return scope ? scope.subscribe(listener) : function () {}; },
693
+ function () { return scope ? scope.getSnapshot() : null; },
694
+ function () { return scope ? scope.getSnapshot() : null; }
651
695
  );
652
696
  }
653
697
 
@@ -1050,11 +1094,18 @@ window.__ModuleLoader__.load({
1050
1094
  };
1051
1095
 
1052
1096
  var disabled = !writable || saving;
1097
+ // The Plugins page asks a configuration entry twice: `view: 'summary'`
1098
+ // for the row's one-liner and `view: 'page'` for the body of its own
1099
+ // page (the page already heads it with the plugin's title and
1100
+ // description). A host that renders this card standalone passes no
1101
+ // `view` and keeps the collapsible header.
1102
+ if (props.view === "summary") return t("description");
1103
+ var pageView = props.view === "page";
1053
1104
 
1054
1105
  return createElement(
1055
1106
  "div",
1056
1107
  { style: styles.card },
1057
- createElement(
1108
+ pageView ? null : createElement(
1058
1109
  "button",
1059
1110
  {
1060
1111
  type: "button",
@@ -1073,10 +1124,10 @@ window.__ModuleLoader__.load({
1073
1124
  createElement(
1074
1125
  "span",
1075
1126
  { style: Object.assign({}, styles.chevron, open ? styles.chevronOpen : {}) },
1076
- createElement(primitives.IconChevronDownOutline14, {})
1127
+ createElement(ChevronDownIcon, {})
1077
1128
  )
1078
1129
  ),
1079
- open ? createElement(
1130
+ (open || pageView) ? createElement(
1080
1131
  "div",
1081
1132
  { style: styles.body },
1082
1133
  !writable ? createElement("p", { style: styles.hint }, t("readOnly")) : null,
@@ -1274,23 +1325,81 @@ window.__ModuleLoader__.load({
1274
1325
  // ---------------------------------------------------------------------------
1275
1326
  // Plugin entry
1276
1327
  // ---------------------------------------------------------------------------
1328
+
1329
+ /**
1330
+ * The settings form this card binds, on whichever host line is running.
1331
+ *
1332
+ * - DSH ≥ 0.1.7 exposes `configForms`, one controller per profile entry id
1333
+ * (snapshot + revision-fenced `mutate` / `unset`); the provider owns the
1334
+ * remote write, so this plugin never declares `remote` itself.
1335
+ * - DSH ≤ 0.1.6-alpha.1 exposes `settingsScope`; binding it to the legacy
1336
+ * namespace yields the same snapshot/write face the card already uses.
1337
+ *
1338
+ * Read weakly on purpose: the two services are mutually exclusive, and the
1339
+ * caller's `inject` list must stay valid on both lines.
1340
+ */
1341
+ function resolveScope(ctx) {
1342
+ var forms = ctx.get("configForms");
1343
+ if (forms && typeof forms.get === "function") return forms.get(NS);
1344
+ var legacy = ctx.get("settingsScope");
1345
+ if (legacy && typeof legacy.bind === "function") return legacy.bind({ namespace: LEGACY_NS });
1346
+ return null;
1347
+ }
1348
+
1277
1349
  function apply(ctx) {
1278
1350
  var t = ctx.locale.bind(LOCALE);
1279
1351
  ctx.effect(function () {
1280
1352
  return ctx.locale.register(LOCALE, { zh: zh, en: en });
1281
- }, "dsh-web-icon-indicator: settings card dictionaries");
1282
- var scope = ctx.settingsScope.bind({ namespace: NS });
1283
- ctx.slots.inject("settings.plugin.item", function () {
1284
- return ctx.slots.register(
1285
- {
1286
- name: "settings.plugin.item",
1287
- key: NS,
1288
- locale: LOCALE,
1289
- inject: function () { return { scope: scope, t: t }; },
1290
- },
1291
- IconConfigCard
1292
- );
1293
- });
1353
+ }, "dsh-web-icon-indicator: settings page dictionaries");
1354
+ // The scope is resolved per render (see the inject face below), so the
1355
+ // card follows whichever provider is mounted and renders nothing while
1356
+ // neither is.
1357
+ var pageFace = function () { return { scope: resolveScope(ctx), t: t }; };
1358
+ // Register on BOTH page slots. `ctx.slots.inject(key, …)` is a no-op for a
1359
+ // slot this host never declares, so each line runs exactly the branch it
1360
+ // understands:
1361
+ // - `plugins.row.config` (≥0.1.6-alpha.2): keyed `<package>#<row id>`,
1362
+ // the row's configure control comes from this registration;
1363
+ // - `settings.plugin.item` (≤0.1.6-alpha.1): keyed by the legacy
1364
+ // settings namespace, dispatched by the settings tab per served
1365
+ // namespace.
1366
+ var start = function () {
1367
+ var disposeRow = ctx.slots.inject("plugins.row.config", function () {
1368
+ return ctx.slots.register(
1369
+ {
1370
+ name: "plugins.row.config",
1371
+ key: ROW_KEY,
1372
+ locale: LOCALE,
1373
+ inject: pageFace,
1374
+ },
1375
+ IconConfigCard
1376
+ );
1377
+ });
1378
+ var disposeItem = ctx.slots.inject("settings.plugin.item", function () {
1379
+ return ctx.slots.register(
1380
+ {
1381
+ name: "settings.plugin.item",
1382
+ key: LEGACY_NS,
1383
+ locale: LOCALE,
1384
+ inject: pageFace,
1385
+ },
1386
+ IconConfigCard
1387
+ );
1388
+ });
1389
+ return function () { disposeRow(); disposeItem(); };
1390
+ };
1391
+ // On the modern line, gate registration on the served namespace so an
1392
+ // uncomposed plugin shows no page; `whileServed` owns the returned
1393
+ // disposer, so `start` must hand one back. The legacy line needs no gate:
1394
+ // its tab only dispatches namespaces the host serves.
1395
+ var forms = ctx.get("configForms");
1396
+ if (forms && typeof forms.whileServed === "function") {
1397
+ ctx.effect(function () {
1398
+ return forms.whileServed([NS], start);
1399
+ }, "dsh-web-icon-indicator: settings page");
1400
+ } else {
1401
+ ctx.effect(start, "dsh-web-icon-indicator: settings page");
1402
+ }
1294
1403
  }
1295
1404
 
1296
1405
  exports.apply = apply;
package/lib/index.js CHANGED
@@ -40,17 +40,17 @@
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
+ * Registration-time (non-volatile): composition entry only.
44
44
  * iconPathPrefix URL prefix for static icon files. Default `/dsh-web-icon-indicator`.
45
- * Registration-time: composition entry only (never settings.yaml).
45
+ * Registration-time (non-volatile): composition entry only.
46
46
  * askingHoldMs Minimum visibility for the asking state (ms). Default 3500.
47
47
  * doneHoldMs How long the done state stays before falling back to idle.
48
48
  * Default 5000.
49
49
  * defaultColor Default icon color — the idle whale's primary color, as
50
50
  * `#rgb` / `#rrggbb`. Absent = the idle state's own colors[0]
51
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
52
+ * user settings layer) a different value to tell their browser
53
+ * tabs apart. A value perceptually too close to another state's
54
54
  * color raises a WARNING — surfaced in the settings card, in
55
55
  * the host log, and as `warnings` on the status endpoint — but
56
56
  * is still honored.
@@ -67,12 +67,23 @@
67
67
  * - speed: optional per-state cycle in ms (blink toggle interval too).
68
68
  * Default 1200.
69
69
  *
70
- * The whole config surface is also registered with the DSH settings service
71
- * (namespace `web-icon-indicator`): validated against a schemastery schema,
72
- * persisted to the profile's `settings.yaml`, and editable from
73
- * 设置 → 插件 → 插件配置 in the Web GUI (see the `dsh.client` browser half).
74
- * While the profile composes no settings service, the plugin keeps working
75
- * 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.
76
87
  *
77
88
  * @module dsh-web-icon-indicator
78
89
  */
@@ -104,21 +115,54 @@ const EFFECT_NAMES = ["static", "blink", "breath", "rainbow", "heartbeat", "boun
104
115
  const STATE_NAMES = ["idle", "running", "asking", "done"];
105
116
 
106
117
  /**
107
- * Settings namespace under which this plugin's config is registered (the
108
- * `web-icon-indicator:` section of the profile's settings.yaml, surfaced in
109
- * 设置 → 插件 → 插件配置). 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.
110
140
  *
111
- * Since DSH 0.1.2 the namespace is a plain string literal (the type-level
112
- * `SettingsNamespace` brand exists only at compile time; the runtime value is
113
- * 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.
114
149
  */
115
- const SETTINGS_NAMESPACE = "web-icon-indicator";
150
+ const LIVE = (schema) => (typeof schema.volatile === "function" ? schema.volatile() : schema);
116
151
 
117
152
  /**
118
- * Schemastery schema mirroring `DEFAULTS`. Registered with the settings
119
- * service so the config is validated, persisted, and editable from the Web
120
- * settings page; resolution order is schema defaults → composition `base`
121
- * → 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.
122
166
  */
123
167
  const STATE_CONFIG_SCHEMA = z.object({
124
168
  effect: z.union(EFFECT_NAMES).default("static"),
@@ -129,24 +173,24 @@ const STATE_CONFIG_SCHEMA = z.object({
129
173
  });
130
174
 
131
175
  const CONFIG_SCHEMA = z.object({
132
- askingHoldMs: z.number().min(0).default(DEFAULTS.askingHoldMs),
133
- 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)),
134
178
  // Default icon color (`#rgb` / `#rrggbb`). Deliberately WITHOUT a schema
135
179
  // default: an absent value keeps the idle state's own colors[0], exactly as
136
180
  // before this key existed. `resolveConfig` folds a valid value into
137
181
  // `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).
146
- iconsDir: z.string(),
182
+ defaultColor: LIVE(z.string()),
183
+ iconsDir: LIVE(z.string()),
147
184
  // dict (not a four-key object): the user layer may override only some
148
- // states; the plugin merges the resolved value over DEFAULTS anyway.
149
- states: z.dict(STATE_CONFIG_SCHEMA).default(DEFAULTS.states),
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.
192
+ statusPath: z.string().default(DEFAULTS.statusPath),
193
+ iconPathPrefix: z.string().default(DEFAULTS.iconPathPrefix),
150
194
  });
151
195
 
152
196
  /** Browser script injected into every served index.html response. */
@@ -193,6 +237,36 @@ const INJECTED_SCRIPT = `
193
237
  );
194
238
  }
195
239
 
240
+ // Every DSH shell up to 0.1.6-alpha.2 carried ONE <link rel=icon>. From
241
+ // 0.1.7-alpha.1 the shell ships a THEME-SCOPED PAIR instead:
242
+ // <link rel=icon href=favicon-dark.svg media="(prefers-color-scheme: dark)">
243
+ // <link rel=icon href=favicon.svg media="(prefers-color-scheme: light)">
244
+ // Both links carry rel=icon, so querying just the first one and repainting
245
+ // it was silently useless: the browser resolves the favicon to the LAST
246
+ // connected link whose media condition matches, i.e. the second one — the
247
+ // one this script never touched. The tab then kept the shell's icon forever
248
+ // while the status endpoint changed underneath (the "icon never changes"
249
+ // bug). Normalize to ONE always-matching link first, then keep painting it.
250
+ function normalizeIconLinks() {
251
+ try {
252
+ var all = document.querySelectorAll("[rel~='icon']");
253
+ if (!all || !all.length) return;
254
+ var pick = all[0];
255
+ for (var i = 1; i < all.length; i++) {
256
+ // Prefer the variant the current scheme actually selects, so a
257
+ // dark-mode tab keeps the shell's dark artwork as its resting state.
258
+ var m = all[i].getAttribute("media");
259
+ if (m && window.matchMedia && window.matchMedia(m).matches) pick = all[i];
260
+ }
261
+ for (var j = 0; j < all.length; j++) {
262
+ var node = all[j];
263
+ if (node !== pick && typeof node.remove === "function") { try { node.remove(); } catch (e) {} }
264
+ }
265
+ // Drop the media condition: a link that no longer matches the scheme
266
+ // would be dead weight, and the frame we paint is scheme-agnostic.
267
+ try { pick.removeAttribute("media"); } catch (e) {}
268
+ } catch (e) {}
269
+ }
196
270
  // Capture the shell's own favicon ONCE, together with an offline-safe
197
271
  // data-URI copy of it. The copy matters: while the DSH host is stopped
198
272
  // the original href (a URL served by that same host) is unreachable, so
@@ -508,6 +582,7 @@ const INJECTED_SCRIPT = `
508
582
  });
509
583
  }
510
584
  captureOriginal();
585
+ normalizeIconLinks();
511
586
  TIMER = setInterval(poll, 1000);
512
587
  poll();
513
588
  // A BACKGROUND tab's timers are throttled (Chrome clamps setInterval to
@@ -695,34 +770,49 @@ function colorWarnings(states) {
695
770
  }
696
771
 
697
772
  /**
698
- * Cordis plugin entry. Returns the standard `{ apply, inject, config }`
699
- * shape so the host composition can mount it once at startup.
773
+ * Cordis plugin entry. Returns the standard `{ apply, inject, Config }`
774
+ * shape so the host composition can mount it once at startup. `Config` (the
775
+ * schemastery schema) is what the loader validates the row against and what
776
+ * the host settings service projects into its live form — a plugin without it
777
+ * has no server-declared configuration surface at all. One bundle serves both
778
+ * host lines: the legacy `settings.installSection` path is feature-detected at
779
+ * runtime (see `apply`).
700
780
  */
701
781
  export default {
702
782
  name: "dsh-web-icon-indicator",
703
783
  inject: ["webServer", "timer", "agents", "fs"],
704
- config: {
705
- askingHoldMs: DEFAULTS.askingHoldMs,
706
- doneHoldMs: DEFAULTS.doneHoldMs,
707
- statusPath: DEFAULTS.statusPath,
708
- iconPathPrefix: DEFAULTS.iconPathPrefix,
709
- iconsDir: undefined,
710
- defaultColor: undefined,
711
- states: DEFAULTS.states,
712
- },
713
- /** Settings namespace + validation schema (exported for reuse/tooling). */
784
+ Config: CONFIG_SCHEMA,
785
+ /** Settings namespace (the profile entry id on ≥0.1.7) + schema, for tooling. */
714
786
  SETTINGS_NAMESPACE,
787
+ /** Settings namespace used on the legacy (≤0.1.6-alpha.1) settings service. */
788
+ LEGACY_SETTINGS_NAMESPACE,
715
789
  CONFIG_SCHEMA,
716
790
  apply(ctx, config) {
717
- // Cordis passes the resolved composition config as the second argument
718
- // (`apply(ctx, config)`). `config` is the row's own `config` from the
719
- // profile composition (merged over the schema only when a `Config` schema
720
- // is declared); this plugin merges its `DEFAULTS` itself via `resolveConfig`,
721
- // so an absent row config simply yields the defaults. Fall back to the old
722
- // `ctx.get("config")` read for compatibility (it is undefined on the host
723
- // but kept for the zero-dep test harness and older hosts).
724
- const entry = config ?? ctx.get("config") ?? {};
725
- let source = () => entry;
791
+ // Cordis passes the schema-resolved config as the second argument
792
+ // (`apply(ctx, config)`). Every `.volatile()` field arrives as a live
793
+ // reference (`config.states.get()`), and the loader mutates those
794
+ // references in place when a settings write lands — that is what
795
+ // `loader/volatile-update` below reacts to. Unwrap the references once per
796
+ // read so the rest of this file keeps working on plain values. The
797
+ // `ctx.get("config")` fallback keeps the zero-dependency test harness (and
798
+ // any host that hands the row config through the service container) working.
799
+ const readRef = (value) =>
800
+ value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
801
+ const readConfig = (raw) => {
802
+ const out = {};
803
+ for (const [key, value] of Object.entries(raw || {})) {
804
+ const plain = readRef(value);
805
+ if (plain !== undefined) out[key] = plain;
806
+ }
807
+ return out;
808
+ };
809
+ // Legacy host line (DSH ≤ 0.1.6-alpha.1): `settings.installSection` owns the
810
+ // namespace lifecycle and hands this plugin a live source through
811
+ // `setSource`. Until it does, the composition config passed to `apply` (or
812
+ // the zero-dep test harness's `ctx.get("config")`) is the source.
813
+ let legacySource = null;
814
+ const source = () =>
815
+ readConfig(legacySource ? legacySource() : config ?? ctx.get("config") ?? {});
726
816
  // Merge each configured state over its default: { effect, colors, speed }.
727
817
  // Idempotent on schema-resolved values, so it doubles as the fallback
728
818
  // normalization when the profile composes no settings service.
@@ -806,54 +896,72 @@ export default {
806
896
  }
807
897
  } catch (e) {}
808
898
  };
809
- let cfg = resolveConfig(entry);
899
+ let cfg = resolveConfig(source());
810
900
  logWarnings(cfg.warnings);
811
901
  // The injected script bakes config at injection time; rebuild it whenever
812
- // the settings section changes so the NEXT page load picks the new values
813
- // up (reload the tab — same contract as before this registration).
902
+ // the live config changes so the NEXT page load picks the new values up
903
+ // (reload the tab — the running tab is already carried by the status poll's
904
+ // `states` echo).
814
905
  const buildScript = (c) => INJECTED_SCRIPT
815
906
  .replace("__STATUS_PATH__", c.statusPath)
816
907
  .replace("__BASE_PATH__", c.iconPathPrefix + "/base.svg")
817
908
  .replace("__CFG__", JSON.stringify({ states: c.states }));
818
909
  let script = buildScript(cfg);
819
910
  let iconsDir = resolveIconsDir(cfg.iconsDir);
820
- // Register the settings namespace with the host `settings` service. Since
821
- // DSH 0.1.2 the standalone `installSettingsSection` export is gone — the
822
- // host exposes a `settings` service whose `installSection(owner, ns,
823
- // schema, entry, hooks)` carries the same attach/detach lifecycle (base
824
- // layer + `setSource` sink + `onChange` notification). The host's own
825
- // plugins all use `ctx.inject(["settings"], …)` so the section attaches as
826
- // soon as the provider is available — a plain `ctx.get("settings")` can run
827
- // before the provider is ready and silently skip the registration. When no
828
- // settings provider is composed the callback never fires and the plugin
829
- // keeps working on its composition entry + DEFAULTS only.
830
- ctx.inject(["settings"], (settingsCtx) => {
831
- settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, CONFIG_SCHEMA, entry, {
832
- setSource: (next) => { source = next; },
833
- onChange: () => {
834
- // Settings service mounted, or the section changed: re-resolve, then
835
- // mutate cfg in place so the state-machine closures (asking/done hold)
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).
841
- const next = resolveConfig(source());
842
- cfg.askingHoldMs = next.askingHoldMs;
843
- cfg.doneHoldMs = next.doneHoldMs;
844
- if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
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;
851
- iconsDir = resolveIconsDir(cfg.iconsDir);
852
- script = buildScript(cfg);
853
- logWarnings(cfg.warnings);
854
- },
911
+ // Live settings, modern line (DSH ≥ 0.1.7). The host owns the whole
912
+ // configuration lifecycle: the loader validates the composition row against
913
+ // `Config`, the `settings` service (`SettingsForms`) derives the live form
914
+ // from the schema's `.volatile()` fields and writes them into the profile
915
+ // patch, and a volatile-only write is committed into the running fiber's
916
+ // references with a `loader/volatile-update` event instead of a restart.
917
+ // This listener rebuilds the cached `cfg` and the injected script from the
918
+ // same references; the legacy line below drives it through `onChange`
919
+ // instead. `statusPath` / `iconPathPrefix` are NOT volatile: they are baked
920
+ // into the route table and the injected script at registration time, so a
921
+ // document change to them re-applies the whole plugin instead (they stay
922
+ // outside the live form — see CONFIG_SCHEMA).
923
+ const refreshFromConfig = () => {
924
+ const next = resolveConfig(source());
925
+ cfg.askingHoldMs = next.askingHoldMs;
926
+ cfg.doneHoldMs = next.doneHoldMs;
927
+ if (next.iconsDir !== undefined) cfg.iconsDir = next.iconsDir;
928
+ cfg.states = next.states;
929
+ // `defaultColor` / `warnings` ride along with `states`: the color is
930
+ // already folded into `states.idle.colors[0]`, so the browser needs no
931
+ // new key — they are kept for observability (status echo + log).
932
+ cfg.defaultColor = next.defaultColor;
933
+ cfg.warnings = next.warnings;
934
+ iconsDir = resolveIconsDir(cfg.iconsDir);
935
+ script = buildScript(cfg);
936
+ logWarnings(cfg.warnings);
937
+ };
938
+ // A plain `ctx.on` listener is enough: the loader emits this event on the
939
+ // owning fiber's context only, so a volatile write to another plugin's
940
+ // config never reaches here. Guard for the zero-dep test harness, whose
941
+ // fake ctx carries no event bus.
942
+ if (typeof ctx.on === "function") ctx.on("loader/volatile-update", refreshFromConfig);
943
+ // Legacy host line (DSH ≤ 0.1.6-alpha.1): the `settings` service exposes
944
+ // `installSection`, and the plugin must register its namespace itself —
945
+ // that service method was removed in 0.1.7, where the exported `Config`
946
+ // schema plus `loader/volatile-update` carry the same lifecycle. Feature-
947
+ // detect instead of switching on a version: whichever service the running
948
+ // host provides decides the path, so one bundle serves both lines.
949
+ // The base layer is a RESOLVED PLAIN COPY (`resolveConfig(source())`), not
950
+ // the raw `apply` config: the legacy loader deep-freezes the config it
951
+ // resolved against the exported `Config` schema, and the legacy settings
952
+ // service resolves the schema over the base IN PLACE (its `mergeLayers`
953
+ // returns the base unchanged when no user section exists) — handing it a
954
+ // frozen object throws "Cannot assign to read only property".
955
+ if (typeof ctx.inject === "function") {
956
+ ctx.inject(["settings"], (settingsCtx) => {
957
+ const settings = settingsCtx.settings;
958
+ if (!settings || typeof settings.installSection !== "function") return;
959
+ settings.installSection(ctx, LEGACY_SETTINGS_NAMESPACE, CONFIG_SCHEMA, resolveConfig(source()), {
960
+ setSource: (next) => { legacySource = next; },
961
+ onChange: refreshFromConfig,
962
+ });
855
963
  });
856
- });
964
+ }
857
965
  const webServer = ctx.webServer;
858
966
  const agents = ctx.agents;
859
967
  const fs = ctx.fs;