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/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,13 +37,71 @@ 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"];
35
65
  var TOP_NUMBERS = ["askingHoldMs", "doneHoldMs"];
66
+ // The states that get a detail row in the card. Idle is deliberately NOT
67
+ // one of them: its single color is the top-level "default color" field and
68
+ // it takes no animation, so it has no effect / colors / cycle UI at all.
69
+ // The similarity warning compares the default color against exactly these
70
+ // states (a new state added here joins the check automatically).
71
+ var EDITABLE_STATES = STATE_NAMES.filter(function (name) { return name !== "idle"; });
72
+ // Colors an effect actually uses: `blink` / `breath` read a second color,
73
+ // `rainbow` only seeds its starting hue from colors[0] and is otherwise not
74
+ // configurable, everything else paints colors[0] alone. The card shows — and
75
+ // saves — only this many colors, so a state can never appear to have a
76
+ // second color its effect ignores.
77
+ var EFFECT_COLOR_COUNT = { static: 1, blink: 2, breath: 2, rainbow: 1, heartbeat: 1, bounce: 1 };
78
+ // Rainbow chip artwork. The wheel is a conic gradient: at 14px a hue ring
79
+ // reads as "every colour", where side-by-side stripes read as a barcode. The
80
+ // diagonal ramp sits in `background` first so an engine without
81
+ // conic-gradient support keeps a (dropped) gradient instead of an empty chip.
82
+ var RAINBOW_RAMP = "linear-gradient(135deg, #ff5d5d, #ffb257, #ffe066, #6fe08a, #59b7ff, #9b7bff, #ff7ad9)";
83
+ var RAINBOW_WHEEL = "conic-gradient(from 0deg, #ff0000, #ffb800, #ffff00, #4ade80, #22d3ee, #3b82f6, #a855f7, #ff00cc, #ff0000)";
84
+ // Built-in per-state config, mirrored from DEFAULTS.states in lib/index.js.
85
+ // The host ALWAYS merges these per state (resolveConfig), while the settings
86
+ // scope's `states` dict can be PARTIAL — the card itself writes only the
87
+ // states it drafted, and the schema default only applies while the whole key
88
+ // is absent. Without this table the card would show "static / no colours" for
89
+ // a state whose icon is actually blinking, and a colour-only edit would then
90
+ // save that wrong effect over the user's config.
91
+ var DEFAULT_STATES = {
92
+ idle: { effect: "static", colors: ["#1a1a1a"] },
93
+ running: { effect: "static", colors: ["#FACC15"] },
94
+ asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
95
+ done: { effect: "static", colors: ["#22A06B"] },
96
+ };
97
+ /** Built-in colours of a state (mirrors DEFAULTS.states[name].colors). */
98
+ function defaultColorsOf(name) {
99
+ return (DEFAULT_STATES[name] && DEFAULT_STATES[name].colors) || ["#1a1a1a"];
100
+ }
101
+ /** Built-in speed of a state, when it ships one (only `asking` does). */
102
+ function defaultSpeedOf(name) {
103
+ return DEFAULT_STATES[name] && typeof DEFAULT_STATES[name].speed === "number" ? DEFAULT_STATES[name].speed : null;
104
+ }
36
105
  // Localized label key per effect identifier (value stays the identifier).
37
106
  var EFFECT_LABEL_KEYS = {
38
107
  static: "effectStatic",
@@ -43,8 +112,17 @@ window.__ModuleLoader__.load({
43
112
  bounce: "effectBounce",
44
113
  };
45
114
 
46
- /** Cordis fiber services this browser plugin injects. */
47
- 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"];
48
126
 
49
127
  // ---------------------------------------------------------------------------
50
128
  // Locale copy
@@ -58,6 +136,13 @@ window.__ModuleLoader__.load({
58
136
  askingHoldMsHint: "Minimum visibility of the asking icon before it settles.",
59
137
  doneHoldMs: "Done hold (ms)",
60
138
  doneHoldMsHint: "How long the done icon stays before returning to idle.",
139
+ defaultColor: "Default icon color",
140
+ defaultColorHint: "Color of the idle whale — idle paints one color and never animates, so this is its only setting. Give each DSH instance a different value to tell their browser tabs apart; blank falls back to the idle state's own color.",
141
+ statesHint: "Detailed configuration for the states that animate or carry a signal. Idle is not listed: it uses the default icon color above.",
142
+ palette: "Palette",
143
+ defaultColorWarn: "Too close to the {state} color {color} (ΔE {de}) — the two are easy to confuse at tab size.",
144
+ defaultColorWarnStrong: "Nearly identical to the {state} color {color} (ΔE {de}).",
145
+ defaultColorWarnRainbow: "The {state} state uses the rainbow effect, which sweeps every hue — any colored default will collide with it at some phase.",
61
146
  stateIdle: "Idle",
62
147
  stateRunning: "Running",
63
148
  stateAsking: "Asking",
@@ -65,16 +150,20 @@ window.__ModuleLoader__.load({
65
150
  effect: "Effect",
66
151
  effectHint: "Animation for this state.",
67
152
  colors: "Colors",
68
- colorsHint: "Comma-separated hex colors; the first is the primary color.",
153
+ colorsHint: "Click a chip to pick that color — the hex value lives in the picker. Only the colors this effect uses are listed.",
154
+ rainbowHint: "The rainbow effect sweeps every hue; the chip beside the wheel only sets its starting hue.",
69
155
  speed: "Cycle (ms)",
70
- speedHint: "Per-state cycle length; blank keeps the current value. Static states have no cycle.",
156
+ speedHint: "Per-state cycle length; blank falls back to the built-in default for that effect. Static states have no cycle.",
71
157
  effectStatic: "Static",
72
158
  effectBlink: "Blink",
73
159
  effectBreath: "Breath",
74
160
  effectRainbow: "Rainbow",
75
161
  effectHeartbeat: "Heartbeat",
76
162
  effectBounce: "Bounce",
77
- editColor: "Edit color",
163
+ editColor: "Pick color",
164
+ addColor: "Add a second color",
165
+ removeColor: "Remove this color",
166
+ clearOverride: "Clear override",
78
167
  unsaved: "Unsaved",
79
168
  readOnly: "This deployment stores settings read-only.",
80
169
  save: "Save",
@@ -94,6 +183,13 @@ window.__ModuleLoader__.load({
94
183
  askingHoldMsHint: "提问图标的最短可见时长。",
95
184
  doneHoldMs: "完成驻留(毫秒)",
96
185
  doneHoldMsHint: "完成图标停留多久后回到待机。",
186
+ defaultColor: "默认图标颜色",
187
+ defaultColorHint: "待机鲸鱼的颜色——待机只画一种颜色、不做动画,所以这就是它唯一的设置项。每个 DSH 实例设成不同颜色即可区分各自的浏览器标签页;留空则沿用待机状态自己的颜色。",
188
+ statesHint: "需要动画或承载状态信号的状态的详细配置。待机不在此列:它使用上方的默认图标颜色。",
189
+ palette: "配色一览",
190
+ defaultColorWarn: "与「{state}」的颜色 {color} 过于接近(ΔE {de}),在标签页尺寸下容易混淆。",
191
+ defaultColorWarnStrong: "与「{state}」的颜色 {color} 几乎相同(ΔE {de})。",
192
+ defaultColorWarnRainbow: "「{state}」状态使用彩虹特效,会扫过所有色相,任何有色默认色都会在某些相位与其撞色。",
97
193
  stateIdle: "待机",
98
194
  stateRunning: "运行中",
99
195
  stateAsking: "提问",
@@ -101,16 +197,20 @@ window.__ModuleLoader__.load({
101
197
  effect: "特效",
102
198
  effectHint: "该状态的动画效果。",
103
199
  colors: "颜色",
104
- colorsHint: "逗号分隔的十六进制颜色;第一个为主色。",
200
+ colorsHint: "点击色块即可选色(十六进制值在取色弹窗里);这里只列出当前特效实际会用到的颜色。",
201
+ rainbowHint: "彩虹特效会扫过所有色相;色环旁的色块只决定起始色相。",
105
202
  speed: "周期(毫秒)",
106
- speedHint: "该状态的动画周期;留空保持当前值。静态状态无周期。",
203
+ speedHint: "该状态的动画周期;留空则回退到该特效的内置默认值。静态状态无周期。",
107
204
  effectStatic: "静态",
108
205
  effectBlink: "闪烁",
109
206
  effectBreath: "呼吸",
110
207
  effectRainbow: "彩虹",
111
208
  effectHeartbeat: "心跳",
112
209
  effectBounce: "弹跳",
113
- editColor: "编辑颜色",
210
+ editColor: "选择颜色",
211
+ addColor: "添加第二色",
212
+ removeColor: "删除该颜色",
213
+ clearOverride: "清除覆盖",
114
214
  unsaved: "未保存",
115
215
  readOnly: "本部署的设置为只读。",
116
216
  save: "保存",
@@ -141,18 +241,31 @@ window.__ModuleLoader__.load({
141
241
  invalidText: { color: "var(--dsw-alias-label-error)", fontSize: 12, lineHeight: "1.5", margin: 0 },
142
242
  select: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: 34, borderRadius: 8, padding: "0 8px", fontSize: 13, fontFamily: "inherit", color: "var(--dsw-alias-label-primary)" },
143
243
  statesBlock: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: 4, paddingTop: 4 },
144
- dot: { display: "inline-block", width: 10, height: 10, borderRadius: 999, border: "1px solid var(--dsw-alias-border-l2)" },
244
+ statesHint: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12, lineHeight: "1.5", margin: 0, padding: "12px 0 4px" },
245
+ chipGroup: { display: "inline-flex", alignItems: "center", gap: 2, flex: "none" },
246
+ chipRemove: { appearance: "none", border: 0, background: "0 0", color: "var(--dsw-alias-label-tertiary)", cursor: "pointer", fontSize: 13, lineHeight: "14px", padding: "0 2px" },
247
+ chipClear: { appearance: "none", border: 0, background: "0 0", color: "var(--dsw-alias-label-secondary)", cursor: "pointer", fontSize: 12, lineHeight: "14px", padding: "0 0 0 6px", textDecoration: "underline" },
248
+ chipBox: { position: "relative", display: "inline-block", boxSizing: "border-box", width: 14, height: 14, borderRadius: 4, border: "1px solid var(--dsw-alias-border-l2)", overflow: "hidden", verticalAlign: "middle", flex: "none", lineHeight: 0 },
249
+ swatchAdd: { display: "inline-flex", alignItems: "center", justifyContent: "center", width: 14, height: 14, borderRadius: 4, border: "1px dashed var(--dsw-alias-border-l2)", color: "var(--dsw-alias-label-tertiary)", fontSize: 11, lineHeight: 1 },
250
+ swatchEmpty: { background: "transparent", borderStyle: "dashed" },
145
251
  summary: { flex: 1, minWidth: 0, marginLeft: 8, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, lineHeight: "24px" },
146
252
  statePanel: { padding: "2px 0 12px 22px" },
147
- inputRow: { display: "flex", alignItems: "center", gap: 8 },
148
253
  swatches: { display: "inline-flex", alignItems: "center", gap: 4, flex: "none" },
149
254
  swatch: { display: "inline-block", width: 14, height: 14, borderRadius: 4, border: "1px solid var(--dsw-alias-border-l2)" },
150
- swatchLabel: { display: "inline-flex", position: "relative", width: 14, height: 14, cursor: "pointer" },
151
- colorInput: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", opacity: 0, cursor: "pointer", border: 0, padding: 0, margin: 0 },
152
- badges: { display: "inline-flex", alignItems: "center", gap: 8 },
255
+ swatchLabel: { display: "inline-flex", position: "relative", flex: "none", width: 14, height: 14, borderRadius: 4, overflow: "hidden", lineHeight: 0, cursor: "pointer" },
256
+ // The native color input is only the hit target: it must be an invisible
257
+ // ABSOLUTE overlay filling its 14x14 label. Without this style the browser
258
+ // renders its UA color widget at its own size, which overflows the input
259
+ // row (the swatch next to a text field then shows up as a stray control).
260
+ colorInput: { position: "absolute", top: 0, left: 0, display: "block", width: "100%", height: "100%", margin: 0, padding: 0, border: 0, opacity: 0, appearance: "none", WebkitAppearance: "none", cursor: "pointer" },
153
261
  badge: { whiteSpace: "nowrap", background: "var(--dsw-alias-bg-module-platform)", color: "var(--dsw-alias-label-secondary)", borderRadius: 999, padding: "1px 8px", fontSize: 11, fontWeight: 500, lineHeight: "17px" },
154
262
  footer: { borderTop: "1px solid var(--dsw-alias-border-l2)", display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, padding: "12px 0 4px" },
155
263
  failed: { minWidth: 0, color: "var(--dsw-alias-label-error)", flex: 1, margin: 0, fontSize: 12, lineHeight: "1.5" },
264
+ warnText: { color: "var(--dsw-alias-state-warn-label)", fontSize: 12, lineHeight: "1.5", margin: 0 },
265
+ warnStrongText: { color: "var(--dsw-alias-label-error)", fontSize: 12, lineHeight: "1.5", margin: 0 },
266
+ warnBlock: { display: "flex", flexDirection: "column", gap: 2, paddingTop: 6 },
267
+ paletteRow: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: 12, paddingTop: 2, paddingBottom: 4 },
268
+ paletteItem: { display: "inline-flex", alignItems: "center", gap: 6, color: "var(--dsw-alias-label-tertiary)", fontSize: 12, lineHeight: "16px", whiteSpace: "nowrap" },
156
269
  };
157
270
 
158
271
  // Layout shim for primitives.Input inside the card: make the wrapper fill
@@ -190,7 +303,7 @@ window.__ModuleLoader__.load({
190
303
 
191
304
  function ValueInput(props) {
192
305
  // props: { id, label, hint, invalidLabel, invalid, separated, disabled, value,
193
- // placeholder, numeric, trailing, onChange }
306
+ // placeholder, numeric, onChange }
194
307
  var input = createElement(primitives.Input, {
195
308
  id: props.id,
196
309
  type: "text",
@@ -205,7 +318,7 @@ window.__ModuleLoader__.load({
205
318
  return createElement(
206
319
  LabeledField,
207
320
  { label: props.label, hint: props.hint, invalid: props.invalid, invalidLabel: props.invalidLabel, separated: props.separated, htmlFor: props.id },
208
- props.trailing ? createElement("div", { style: styles.inputRow }, input, props.trailing) : input
321
+ input
209
322
  );
210
323
  }
211
324
 
@@ -227,11 +340,6 @@ window.__ModuleLoader__.load({
227
340
  );
228
341
  }
229
342
 
230
- /** Filled dot previewing one state's primary color (colors[0]). */
231
- function ColorDot(props) {
232
- return createElement("span", { style: Object.assign({}, styles.dot, { background: props.color }) });
233
- }
234
-
235
343
  /** Expand a 3-digit hex (#abc) to 6-digit (#aabbcc) for <input type="color">. */
236
344
  function to6(hex) {
237
345
  var h = String(hex).replace("#", "");
@@ -240,47 +348,214 @@ window.__ModuleLoader__.load({
240
348
  }
241
349
 
242
350
  /**
243
- * One swatch per parsed color. With an `onChange` it becomes a native
244
- * color picker: each swatch is a label with a visually hidden
245
- * <input type="color"> overlay; picking a color replaces that entry in the
246
- * text. Without an onChange it renders a static preview (aria-hidden).
351
+ * Chip previewing the rainbow effect: it sweeps every hue, so the chip shows
352
+ * the hue wheel itself. Drawn as a layer inside the padding box (same reason
353
+ * as the color bands: the chip's border is translucent).
247
354
  */
248
- function ColorSwatches(props) {
249
- var colors = parseColors(props.text);
250
- if (colors === null) return null;
251
- if (typeof props.onChange !== "function") {
355
+ function rainbowChip(key, size) {
356
+ // Always a disc: a hue wheel reads as "every colour" in a circle, and it
357
+ // stays recognisable at 14px in the row header (the colour chips stay
358
+ // rounded squares).
359
+ var box = Object.assign({}, styles.chipBox, { borderRadius: 999 }, size ? { width: size, height: size } : {});
360
+ return createElement(
361
+ "span",
362
+ { key: key, "aria-hidden": "true", style: box },
363
+ createElement("span", {
364
+ style: {
365
+ position: "absolute",
366
+ top: 0,
367
+ bottom: 0,
368
+ left: 0,
369
+ width: "calc(100% + 1px)",
370
+ background: RAINBOW_RAMP,
371
+ backgroundImage: RAINBOW_WHEEL,
372
+ },
373
+ })
374
+ );
375
+ }
376
+
377
+ /**
378
+ * One chip previewing a state's colors: the row-header icon slot is a
379
+ * fixed-size box, so a multi-color state (asking is red ⇄ yellow) splits the
380
+ * chip into side-by-side bands separated by a 1px seam.
381
+ *
382
+ * The bands are absolutely positioned layers INSIDE the padding box, not a
383
+ * background gradient: a gradient paints under the chip's translucent border
384
+ * (--dsw-alias-border-l2 is #0000001a) and a hard stop there blends into a
385
+ * stray colour at the edge. Layers never reach the border, so it stays
386
+ * neutral, and the seam keeps the two colors unmistakable.
387
+ */
388
+ function colorChip(colors, key) {
389
+ var list = (colors || []).filter(Boolean);
390
+ if (list.length === 0) list = ["transparent"];
391
+ var bands = list.map(function (color, index) {
392
+ var share = 100 / list.length;
393
+ var last = index === list.length - 1;
394
+ return createElement("span", {
395
+ key: "band" + index,
396
+ style: {
397
+ position: "absolute",
398
+ top: 0,
399
+ bottom: 0,
400
+ left: "calc(" + (index * share) + "% + 0px)",
401
+ // Every band but the last stops 1px short; the last one overflows by
402
+ // 1px so no gap opens at the right edge (the chip clips it).
403
+ width: "calc(" + share + "% " + (last ? "+" : "-") + " 1px)",
404
+ background: color,
405
+ },
406
+ });
407
+ });
408
+ return createElement("span", { key: key, "aria-hidden": "true", style: styles.chipBox }, bands);
409
+ }
410
+
411
+ /**
412
+ * Color editing field. Colors are shown as chips ONLY — the card never
413
+ * renders a hex string: clicking a chip opens the native color picker (which
414
+ * is where the hex value is displayed and can be typed) and picking replaces
415
+ * that entry. `canAdd` offers a "+" chip for the effects that read a second
416
+ * color; every entry after the first gets a remove button.
417
+ *
418
+ * props: { id, label, hint, invalidLabel, invalid, separated, disabled,
419
+ * colors (array; empty = unusable stored value), canAdd, canRemove,
420
+ * pickLabel, addLabel, removeLabel, clearLabel, onColors(next[]),
421
+ * onClear }
422
+ */
423
+ function ColorField(props) {
424
+ var list = Array.isArray(props.colors) ? props.colors : [];
425
+ // `rainbow` takes no colour list — just the hue wheel plus the optional
426
+ // starting-hue chip (the browser seeds its sweep from colors[0]).
427
+ if (props.rainbow) {
428
+ var seed = list.length ? list[0] : null;
252
429
  return createElement(
253
- "span",
254
- { style: styles.swatches, "aria-hidden": "true" },
255
- colors.map(function (color) {
256
- return createElement("span", { key: color, style: Object.assign({}, styles.swatch, { background: color }) });
257
- })
430
+ "div",
431
+ { style: fieldStyle(props.separated) },
432
+ createElement("label", { style: styles.label }, props.label),
433
+ createElement(
434
+ "span",
435
+ { style: styles.swatches },
436
+ rainbowChip("rainbow", 20),
437
+ seed === null ? null : createElement(
438
+ "label",
439
+ { key: "seed", style: styles.swatchLabel, title: props.pickLabel, "aria-label": props.pickLabel },
440
+ createElement("input", {
441
+ type: "color",
442
+ style: styles.colorInput,
443
+ value: to6(seed),
444
+ disabled: props.disabled,
445
+ onChange: function (event) { props.onColors([event.target.value]); },
446
+ }),
447
+ createElement("span", { style: Object.assign({}, styles.swatch, { background: seed }) })
448
+ )
449
+ ),
450
+ createElement("p", { style: props.invalid ? styles.invalidText : styles.hint }, props.invalid ? props.invalidLabel : props.hint)
258
451
  );
259
452
  }
260
- return createElement(
261
- "span",
262
- { style: styles.swatches },
263
- colors.map(function (color, index) {
264
- return createElement(
265
- "label",
266
- {
267
- key: index,
268
- style: styles.swatchLabel,
269
- title: props.label || "",
270
- "aria-label": props.label || "",
453
+ var chips = [];
454
+ list.forEach(function (color, index) {
455
+ var chip = createElement(
456
+ "label",
457
+ { key: "c" + index, style: styles.swatchLabel, title: props.pickLabel, "aria-label": props.pickLabel },
458
+ createElement("input", {
459
+ type: "color",
460
+ style: styles.colorInput,
461
+ value: to6(color),
462
+ disabled: props.disabled,
463
+ onChange: function (event) {
464
+ var next = list.slice();
465
+ next[index] = event.target.value;
466
+ props.onColors(next);
271
467
  },
272
- createElement("input", {
273
- type: "color",
274
- value: to6(color),
275
- onChange: function (event) {
276
- var next = colors.slice();
277
- next[index] = event.target.value;
278
- props.onChange(next.join(", "));
468
+ }),
469
+ createElement("span", { style: Object.assign({}, styles.swatch, { background: color }) })
470
+ );
471
+ if (!props.canRemove || index === 0) {
472
+ chips.push(chip);
473
+ return;
474
+ }
475
+ chips.push(createElement(
476
+ "span",
477
+ { key: "c" + index, style: styles.chipGroup },
478
+ chip,
479
+ createElement(
480
+ "button",
481
+ {
482
+ type: "button",
483
+ style: styles.chipRemove,
484
+ disabled: props.disabled,
485
+ title: props.removeLabel,
486
+ "aria-label": props.removeLabel,
487
+ onClick: function () {
488
+ var next = list.slice();
489
+ next.splice(index, 1);
490
+ props.onColors(next);
279
491
  },
280
- }),
281
- createElement("span", { style: Object.assign({}, styles.swatch, { background: color }) })
282
- );
283
- })
492
+ },
493
+ "×"
494
+ )
495
+ ));
496
+ });
497
+ // A stored value that cannot be parsed (hand-written settings.yaml) gets a
498
+ // single dashed picker chip: picking a color replaces it outright.
499
+ if (chips.length === 0) {
500
+ chips.push(createElement(
501
+ "label",
502
+ { key: "empty", style: styles.swatchLabel, title: props.pickLabel, "aria-label": props.pickLabel },
503
+ createElement("input", {
504
+ type: "color",
505
+ style: styles.colorInput,
506
+ value: to6("#888888"),
507
+ disabled: props.disabled,
508
+ onChange: function (event) { props.onColors([event.target.value]); },
509
+ }),
510
+ createElement("span", { style: Object.assign({}, styles.swatch, styles.swatchEmpty) })
511
+ ));
512
+ }
513
+ if (props.canAdd) {
514
+ // The picker opens on the colour the browser would derive anyway
515
+ // (`frameColor` uses mix(colors[0], black, 0.35) when colors[1] is
516
+ // absent), and an already-present colour is a no-op — confirming the
517
+ // dialog without touching anything must not append a duplicate band.
518
+ var seedColor = list.length ? mixHex(list[0], "#000000", 0.35) : "#888888";
519
+ chips.push(createElement(
520
+ "label",
521
+ { key: "add", style: styles.swatchLabel, title: props.addLabel, "aria-label": props.addLabel },
522
+ createElement("input", {
523
+ type: "color",
524
+ style: styles.colorInput,
525
+ value: to6(seedColor),
526
+ disabled: props.disabled,
527
+ onChange: function (event) {
528
+ var picked = to6(event.target.value).toLowerCase();
529
+ // `#F00` and `#ff0000` are the same colour: a hand-written 3-digit
530
+ // value must not let the picker append an identical second band.
531
+ var known = list.some(function (color) { return to6(color).toLowerCase() === picked; });
532
+ if (known) return;
533
+ props.onColors(list.concat([event.target.value]));
534
+ },
535
+ }),
536
+ createElement("span", { style: styles.swatchAdd }, "+")
537
+ ));
538
+ }
539
+ if (typeof props.onClear === "function") {
540
+ chips.push(createElement(
541
+ "button",
542
+ {
543
+ type: "button",
544
+ style: styles.chipClear,
545
+ disabled: props.disabled,
546
+ title: props.clearLabel,
547
+ "aria-label": props.clearLabel,
548
+ onClick: props.onClear,
549
+ },
550
+ props.clearLabel
551
+ ));
552
+ }
553
+ return createElement(
554
+ "div",
555
+ { style: fieldStyle(props.separated) },
556
+ createElement("label", { style: styles.label }, props.label),
557
+ createElement("span", { style: styles.swatches, role: "group", "aria-label": props.label }, chips),
558
+ createElement("p", { style: props.invalid ? styles.invalidText : styles.hint }, props.invalid ? props.invalidLabel : props.hint)
284
559
  );
285
560
  }
286
561
 
@@ -295,7 +570,14 @@ window.__ModuleLoader__.load({
295
570
  return Array.isArray(colors) && colors.length > 0 ? colors.join(", ") : "";
296
571
  }
297
572
 
298
- /** Parse a comma/space separated list of #hex colors; null when malformed. */
573
+ /**
574
+ * Parse a comma/space separated list of #hex colors; null when malformed.
575
+ * All-or-nothing, so it is only used to validate a staged DRAFT (the chips
576
+ * can only produce valid entries). For a stored colours field use
577
+ * parseColorList(), which drops bad entries one by one exactly like the
578
+ * host's resolveConfig does — a single typo must not hide the colours the
579
+ * host still paints.
580
+ */
299
581
  function parseColors(text) {
300
582
  var parts = String(text).split(/[\s,]+/).filter(function (part) { return part.length > 0; });
301
583
  if (parts.length === 0) return null;
@@ -308,6 +590,89 @@ window.__ModuleLoader__.load({
308
590
  return colors;
309
591
  }
310
592
 
593
+ /**
594
+ * Per-entry parse of a colours field: every valid `#rgb` / `#rrggbb` token
595
+ * survives, malformed entries are dropped. The host's resolveConfig filters
596
+ * the same way, so `["#FF0000","red"]` means "paint #FF0000" on BOTH sides —
597
+ * parseColors() would reject the whole list and make the card reason about a
598
+ * colour the icon never paints.
599
+ */
600
+ function parseColorList(text) {
601
+ return String(text)
602
+ .split(/[\s,]+/)
603
+ .map(function (part) { return part.trim(); })
604
+ .filter(function (part) { return part.length > 0 && isHexColor(part); });
605
+ }
606
+
607
+ // ---- perceptual color distance ------------------------------------------
608
+ // CIE76 ΔE in CIELAB plus the fill-candidate logic the host uses for its
609
+ // own similarity check. DELIBERATELY DUPLICATED from lib/index.js: the two
610
+ // halves run in different processes, there is no shared module and no build
611
+ // step (docs/main.js carries a third copy of the color math for the same
612
+ // reason). Keep the thresholds in sync.
613
+ var DEFAULT_COLOR_RX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
614
+ /** ΔE below which two colors are practically the same. */
615
+ var SIMILAR_DE_STRONG = 12;
616
+ /** ΔE below which two colors are easily confused at favicon size. */
617
+ var SIMILAR_DE_WARN = 25;
618
+ /** Lab chroma above which a default color collides with a rainbow sweep. */
619
+ var RAINBOW_CHROMA_MIN = 15;
620
+
621
+ /** The default-color field accepts only #rgb / #rrggbb. */
622
+ function isHexColor(text) {
623
+ return DEFAULT_COLOR_RX.test(String(text).trim());
624
+ }
625
+
626
+ function hexToRgb(hex) {
627
+ var body = String(hex).replace("#", "");
628
+ if (body.length === 3) body = body.charAt(0) + body.charAt(0) + body.charAt(1) + body.charAt(1) + body.charAt(2) + body.charAt(2);
629
+ var n = parseInt(body, 16);
630
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
631
+ }
632
+
633
+ function rgbToHex(r, g, b) {
634
+ return "#" + [r, g, b].map(function (v) {
635
+ var c = Math.max(0, Math.min(255, Math.round(v)));
636
+ return ("0" + c.toString(16)).slice(-2);
637
+ }).join("");
638
+ }
639
+
640
+ function mixHex(a, b, t) {
641
+ var ca = hexToRgb(a);
642
+ var cb = hexToRgb(b);
643
+ return rgbToHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
644
+ }
645
+
646
+ function labOf(hex) {
647
+ var rgb = hexToRgb(hex);
648
+ var lin = function (v) {
649
+ var c = v / 255;
650
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
651
+ };
652
+ var r = lin(rgb[0]);
653
+ var g = lin(rgb[1]);
654
+ var b = lin(rgb[2]);
655
+ var x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047;
656
+ var y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
657
+ var z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883;
658
+ var f = function (v) { return v > 216 / 24389 ? Math.cbrt(v) : (841 / 108) * v + 4 / 29; };
659
+ var fx = f(x);
660
+ var fy = f(y);
661
+ var fz = f(z);
662
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
663
+ }
664
+
665
+ function deltaE(a, b) {
666
+ var la = labOf(a);
667
+ var lb = labOf(b);
668
+ return Math.sqrt(Math.pow(la[0] - lb[0], 2) + Math.pow(la[1] - lb[1], 2) + Math.pow(la[2] - lb[2], 2));
669
+ }
670
+
671
+ function labChroma(hex) {
672
+ var l = labOf(hex);
673
+ return Math.sqrt(l[1] * l[1] + l[2] * l[2]);
674
+ }
675
+
311
676
  function isValidNumber(text, min) {
312
677
  var trimmed = text.trim();
313
678
  if (trimmed === "") return { kind: "clear" };
@@ -316,12 +681,17 @@ window.__ModuleLoader__.load({
316
681
  return { kind: "set", value: parsed };
317
682
  }
318
683
 
319
- /** 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
+ */
320
690
  function useScopeSnapshot(scope) {
321
691
  return useSyncExternalStore(
322
- function (listener) { return scope.subscribe(listener); },
323
- function () { return scope.getSnapshot(); },
324
- 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; }
325
695
  );
326
696
  }
327
697
 
@@ -375,36 +745,56 @@ window.__ModuleLoader__.load({
375
745
  var fieldValue = function (key) {
376
746
  return Object.prototype.hasOwnProperty.call(drafts, key) ? drafts[key] : formatNumber(value[key]);
377
747
  };
748
+ /**
749
+ * A state's effective value for a field: the draft, else the resolved
750
+ * entry, else the built-in one. The fallback matters because `value.states`
751
+ * can omit states entirely (the host still paints their defaults).
752
+ */
378
753
  var stateFieldValue = function (name, kind) {
379
754
  var key = "states." + name + "." + kind;
380
755
  if (Object.prototype.hasOwnProperty.call(drafts, key)) return drafts[key];
381
756
  var state = stateOf(name);
382
- if (kind === "effect") return state.effect || "static";
383
- if (kind === "colors") return formatColors(state.colors);
384
- return formatNumber(state.speed);
757
+ var builtIn = DEFAULT_STATES[name] || {};
758
+ if (kind === "effect") return state.effect || builtIn.effect || "static";
759
+ if (kind === "colors") {
760
+ var stored = formatColors(state.colors);
761
+ return stored !== "" ? stored : formatColors(builtIn.colors);
762
+ }
763
+ var speed = formatNumber(state.speed);
764
+ return speed !== "" ? speed : formatNumber(builtIn.speed);
385
765
  };
386
766
 
387
767
  var stateTitleKey = function (name) {
388
768
  return "state" + name.charAt(0).toUpperCase() + name.slice(1);
389
769
  };
390
- /** One-line collapsed summary: "Blink · #E5484D ⇄ #FACC15 · 400ms" (draft-aware). */
770
+ /**
771
+ * Draft-aware colors of a state: per-entry validation ([] when nothing
772
+ * usable survives) so a partially invalid stored list behaves like the
773
+ * host's, which keeps the valid entries.
774
+ */
775
+ var stateColorList = function (name) {
776
+ return parseColorList(stateFieldValue(name, "colors"));
777
+ };
778
+ /** Colors the current effect uses (1 for every single-color effect). */
779
+ var colorCountOf = function (effect) {
780
+ return EFFECT_COLOR_COUNT[effect] || 1;
781
+ };
782
+ /** Does this state currently run the hue-sweeping rainbow effect? */
783
+ var isRainbowEffect = function (name) {
784
+ return stateFieldValue(name, "effect") === "rainbow";
785
+ };
786
+ /**
787
+ * One-line collapsed summary: "Blink · 400ms" (draft-aware). Colors are NOT
788
+ * repeated here — the row header already shows them as a chip, and chips in
789
+ * the ellipsized summary overflowed the row and pushed the cycle out.
790
+ */
391
791
  var stateSummary = function (name) {
392
792
  var effect = stateFieldValue(name, "effect");
393
- var parts = [t(EFFECT_LABEL_KEYS[effect] || effect)];
394
- var colorsText = stateFieldValue(name, "colors").trim();
395
- if (colorsText !== "") {
396
- var parsed = parseColors(colorsText);
397
- parts.push(parsed === null ? colorsText : parsed.join(" ⇄ "));
398
- }
399
793
  var speedText = stateFieldValue(name, "speed").trim();
794
+ var parts = [t(EFFECT_LABEL_KEYS[effect] || effect)];
400
795
  if (speedText !== "" && effect !== "static") parts.push(speedText + "ms");
401
796
  return parts.join(" · ");
402
797
  };
403
- /** Primary color for the row's dot; transparent while the draft is unparseable. */
404
- var statePrimaryColor = function (name) {
405
- var parsed = parseColors(stateFieldValue(name, "colors"));
406
- return parsed === null ? "transparent" : parsed[0];
407
- };
408
798
  /** Draft-aware: is this state currently static (thus has no cycle)? */
409
799
  var isEffectStatic = function (name) {
410
800
  return stateFieldValue(name, "effect") === "static";
@@ -416,6 +806,153 @@ window.__ModuleLoader__.load({
416
806
 
417
807
  var dirty = Object.keys(drafts).length > 0;
418
808
 
809
+ // ---- default color + similarity warning --------------------------------
810
+ /**
811
+ * Current text of the default-color field: the draft when present,
812
+ * otherwise the effective color (the idle primary — the host folds
813
+ * `defaultColor` into `states.idle.colors[0]`, so the field always shows
814
+ * what the icon actually paints). Blank means "inherit the idle color".
815
+ */
816
+ var defaultColorText = function () {
817
+ if (Object.prototype.hasOwnProperty.call(drafts, "defaultColor")) return drafts["defaultColor"];
818
+ // The settings scope resolves `states` and `defaultColor` independently
819
+ // (the fold only exists inside the host's resolveConfig), so the scope's
820
+ // idle color is NOT the configured default — read the key itself first,
821
+ // otherwise the field, the palette and every warning would judge the
822
+ // wrong color right after a save.
823
+ if (typeof value.defaultColor === "string" && value.defaultColor.trim() !== "") return value.defaultColor;
824
+ var idle = parseColorList(stateFieldValue("idle", "colors"));
825
+ return idle.length ? idle[0] : "";
826
+ };
827
+ /** The color the warning check judges; null only when nothing is paintable. */
828
+ var effectiveDefaultColor = function () {
829
+ var text = defaultColorText().trim();
830
+ if (text !== "" && isHexColor(text)) return text;
831
+ // Cleared, never set, or a value the host would reject: the icon falls
832
+ // back to the idle state's own primary (what the scope reports in
833
+ // states.idle.colors), so the warning must judge THAT colour — otherwise
834
+ // a hand-written typo would silently disable the check.
835
+ // …and if even that is unusable, the built-in idle colour the host
836
+ // falls back to — never silence the check.
837
+ var idle = stateColorList("idle");
838
+ if (idle.length) return idle[0];
839
+ return defaultColorsOf("idle")[0];
840
+ };
841
+ /**
842
+ * Chips for the default-color field. It always previews a paintable colour:
843
+ * an explicit "clear override" draft (or a stored value the host would
844
+ * reject) falls back to the inherited idle colour, which is exactly what
845
+ * the icon will show.
846
+ */
847
+ var defaultColorColors = function () {
848
+ var text = defaultColorText().trim();
849
+ if (text !== "" && isHexColor(text)) return [text];
850
+ var fallback = effectiveDefaultColor();
851
+ return fallback === null ? [] : [fallback];
852
+ };
853
+ /** Chip for a state: the hue wheel for rainbow, its color bands otherwise. */
854
+ var stateChip = function (name, key) {
855
+ if (isRainbowEffect(name)) return rainbowChip(key);
856
+ return colorChip(paletteColors(name), key);
857
+ };
858
+ /**
859
+ * Palette chips for a state: idle shows the effective default color, every
860
+ * other state ALL of its own colors (so asking shows its red and yellow).
861
+ */
862
+ var paletteColors = function (name) {
863
+ if (name === "idle") {
864
+ var base = effectiveDefaultColor();
865
+ if (base !== null) return [base];
866
+ }
867
+ var colors = stateColorList(name);
868
+ if (colors.length === 0) colors = defaultColorsOf(name).slice();
869
+ // Cap at what the effect paints: a stored extra colour must not show up
870
+ // in the chip while the editor and the save both cap it (EFFECT_COLOR_COUNT).
871
+ return colors.slice(0, colorCountOf(stateFieldValue(name, "effect")));
872
+ };
873
+ /** Draft-aware fills a state paints; mirrors the host's `stateFills()`. */
874
+ var stateFillsOf = function (name) {
875
+ var effect = stateFieldValue(name, "effect");
876
+ // Per-entry validation, then the same built-in fallback the host uses —
877
+ // otherwise the card would warn about a colour the host never paints, or
878
+ // miss one it does.
879
+ var colors = parseColorList(stateFieldValue(name, "colors"));
880
+ if (colors.length === 0) colors = defaultColorsOf(name).slice();
881
+ // A rainbow state paints no literal colors[0] (only its starting hue),
882
+ // so it carries no comparable fills — the chroma rule covers it.
883
+ if (effect === "rainbow") return { fills: [], rainbow: true };
884
+ var c0 = colors[0];
885
+ var c1 = colors[1] || mixHex(c0, "#000000", 0.35);
886
+ if (effect === "blink") return { fills: [c0, c1], rainbow: false };
887
+ if (effect === "breath") {
888
+ // Same density as the host (lib/index.js stateFills): the browser paints
889
+ // the continuous mix, so 5 samples could miss a collision outright.
890
+ var samples = [];
891
+ for (var i = 0; i <= 32; i += 1) samples.push(mixHex(c0, c1, i / 32));
892
+ return { fills: samples, rainbow: false };
893
+ }
894
+ return { fills: [c0], rainbow: false };
895
+ };
896
+ /**
897
+ * The same check the host runs in `colorWarnings()` (lib/index.js) — it is
898
+ * re-implemented here because the card cannot reach the status endpoint
899
+ * (statusPath is registration-time only). Only the default is compared
900
+ * with the other states: the shipped defaults deliberately share #FACC15
901
+ * between `running` and the `asking` blink.
902
+ */
903
+ var colorWarnings = function () {
904
+ var list = [];
905
+ var base = effectiveDefaultColor();
906
+ if (base === null) return list;
907
+ // Idle itself on `rainbow` (composition entry only — the card never lets
908
+ // you set it): the idle icon sweeps every hue, so the default colour can
909
+ // never stay distinguishable from it. One advisory instead of the
910
+ // pairwise pass, which would judge a colour idle never paints literally.
911
+ if (isRainbowEffect("idle")) {
912
+ if (labChroma(base) >= RAINBOW_CHROMA_MIN) {
913
+ list.push({ code: "rainbow-overlap", state: "idle", level: "warn" });
914
+ }
915
+ return list;
916
+ }
917
+ for (var i = 0; i < EDITABLE_STATES.length; i += 1) {
918
+ var name = EDITABLE_STATES[i];
919
+ var info = stateFillsOf(name);
920
+ if (info.rainbow && labChroma(base) >= RAINBOW_CHROMA_MIN) {
921
+ list.push({ code: "rainbow-overlap", state: name, level: "warn" });
922
+ }
923
+ var closest = null;
924
+ for (var j = 0; j < info.fills.length; j += 1) {
925
+ var de = deltaE(base, info.fills[j]);
926
+ if (closest === null || de < closest.deltaE) closest = { color: info.fills[j], deltaE: de };
927
+ }
928
+ if (closest !== null && closest.deltaE < SIMILAR_DE_WARN) {
929
+ list.push({
930
+ code: "color-too-close",
931
+ state: name,
932
+ color: closest.color,
933
+ deltaE: Math.round(closest.deltaE * 10) / 10,
934
+ level: closest.deltaE < SIMILAR_DE_STRONG ? "strong" : "warn",
935
+ });
936
+ }
937
+ }
938
+ return list;
939
+ };
940
+ /** Localized one-liner for a warning entry. */
941
+ var warningText = function (warning) {
942
+ var stateLabel = t(stateTitleKey(warning.state));
943
+ if (warning.code === "rainbow-overlap") {
944
+ return t("defaultColorWarnRainbow").replace("{state}", stateLabel);
945
+ }
946
+ var key = warning.level === "strong" ? "defaultColorWarnStrong" : "defaultColorWarn";
947
+ return t(key)
948
+ .replace("{state}", stateLabel)
949
+ .replace("{color}", warning.color)
950
+ .replace("{de}", String(warning.deltaE));
951
+ };
952
+ // Warnings are advisory: they never disable Save (the user may want the
953
+ // palette on purpose), they just say what the tab will look like.
954
+ var warnings = colorWarnings();
955
+
419
956
  var save = function () {
420
957
  if (!writable || saving || !dirty) return;
421
958
  var ops = [];
@@ -430,6 +967,21 @@ window.__ModuleLoader__.load({
430
967
  if (parsed.kind === "clear") { if (userHas(key)) ops.push({ op: "unset", field: key }); }
431
968
  else if (parsed.value !== value[key]) ops.push({ op: "set", field: key, value: parsed.value });
432
969
  }
970
+ // Default icon color: a top-level string field. Blank clears the
971
+ // override (back to the idle state's own color); a malformed value is
972
+ // rejected exactly like a malformed colors entry.
973
+ if (Object.prototype.hasOwnProperty.call(drafts, "defaultColor")) {
974
+ var defaultDraft = drafts["defaultColor"].trim();
975
+ var resolvedDefault = typeof value.defaultColor === "string" ? value.defaultColor : "";
976
+ if (defaultDraft === "") {
977
+ if (userHas("defaultColor")) ops.push({ op: "unset", field: "defaultColor" });
978
+ } else if (!isHexColor(defaultDraft)) {
979
+ setFailed("invalidColors");
980
+ return;
981
+ } else if (defaultDraft.toLowerCase() !== resolvedDefault.toLowerCase()) {
982
+ ops.push({ op: "set", field: "defaultColor", value: defaultDraft });
983
+ }
984
+ }
433
985
  // Per-state visuals: rebuild `states` as the user layer's current
434
986
  // entries with the drafts applied. `scope.set` REPLACES the whole
435
987
  // field (no deep merge), so untouched entries — overrides saved
@@ -440,8 +992,8 @@ window.__ModuleLoader__.load({
440
992
  if (userLayer !== void 0 && userLayer !== null && typeof userLayer.states === "object" && userLayer.states !== null) {
441
993
  nextStates = Object.assign({}, userLayer.states);
442
994
  }
443
- for (i = 0; i < STATE_NAMES.length; i += 1) {
444
- var name = STATE_NAMES[i];
995
+ for (i = 0; i < EDITABLE_STATES.length; i += 1) {
996
+ var name = EDITABLE_STATES[i];
445
997
  var effKey = "states." + name + ".effect";
446
998
  var colKey = "states." + name + ".colors";
447
999
  var spdKey = "states." + name + ".speed";
@@ -450,7 +1002,11 @@ window.__ModuleLoader__.load({
450
1002
  var hasSpd = Object.prototype.hasOwnProperty.call(drafts, spdKey);
451
1003
  if (!hasEff && !hasCol && !hasSpd) continue;
452
1004
  statesDirty = true;
453
- var current = stateOf(name);
1005
+ // The effective entry: the resolved/partial entry over the built-in
1006
+ // config. Without the merge a state missing from `value.states` would
1007
+ // be saved as "static" (and lose the built-in colours) merely because
1008
+ // the user touched its colour chip.
1009
+ var current = Object.assign({}, DEFAULT_STATES[name] || {}, stateOf(name));
454
1010
  var effect = hasEff ? drafts[effKey] : current.effect || "static";
455
1011
  var colors = current.colors;
456
1012
  if (hasCol) {
@@ -464,39 +1020,68 @@ window.__ModuleLoader__.load({
464
1020
  if (speedParse === null) { setFailed("invalidNumber"); return; }
465
1021
  speed = speedParse.kind === "clear" ? void 0 : speedParse.value;
466
1022
  }
1023
+ // Keep only the colors this effect uses, so the stored config always
1024
+ // matches the chips the card showed: switching blink -> static drops
1025
+ // the now-unused second color, and rainbow keeps colors[0] as its
1026
+ // starting hue.
1027
+ var keep = colorCountOf(effect);
1028
+ if (Array.isArray(colors) && colors.length > keep) colors = colors.slice(0, keep);
467
1029
  var next = { effect: effect, colors: colors };
468
1030
  if (speed !== void 0) next.speed = speed;
469
1031
  nextStates[name] = next;
470
1032
  }
471
1033
  if (statesDirty) ops.push({ op: "set", field: "states", value: nextStates });
472
1034
 
473
- if (ops.length === 0) return;
1035
+ if (ops.length === 0) {
1036
+ // Nothing to write — the draft already matches the resolved value, or
1037
+ // "blank" already means "inherit": settle the form instead of leaving
1038
+ // the Unsaved badge stuck on a save that wrote nothing.
1039
+ setDrafts({});
1040
+ setFailed(null);
1041
+ return;
1042
+ }
474
1043
  setSaving(true);
475
1044
  setFailed(null);
476
- var run = Promise.resolve();
477
- var ok = true;
478
- for (i = 0; i < ops.length; i += 1) {
479
- (function (op) {
480
- run = run.then(function () {
481
- if (!ok) return;
482
- if (op.op === "set") return scope.set(op.field, op.value);
483
- return scope.unset(op.field);
484
- }).then(void 0, function () { ok = false; });
485
- })(ops[i]);
486
- }
487
- run.then(function () {
1045
+ // One atomic namespace mutation, not N field writes: the settings
1046
+ // contract makes every op in a `mutate` share ONE revision fence,
1047
+ // validation pass, persistence decision and recovery read. Saving the
1048
+ // card is a single edit, so a concurrent change on another surface must
1049
+ // be accepted or refused as a whole instead of landing half applied.
1050
+ scope.mutate(ops.map(function (op) {
1051
+ return op.op === "unset"
1052
+ ? { op: "unset", path: [op.field] }
1053
+ : { op: "set", path: [op.field], value: op.value };
1054
+ })).then(function () {
488
1055
  setSaving(false);
489
- if (ok) { setDrafts({}); setFailed(null); }
490
- else setFailed("saveFailed");
1056
+ setDrafts({});
1057
+ setFailed(null);
1058
+ }, function () {
1059
+ setSaving(false);
1060
+ setFailed("saveFailed");
491
1061
  });
492
1062
  };
493
1063
 
494
1064
  var resetAll = function () {
495
1065
  if (!writable || saving) return;
496
1066
  setSaving(true);
1067
+ // A reset is a removal, not an edit: one `unset` per key re-inherits the
1068
+ // composition layer (the mutate path above cannot express "clear").
1069
+ // `defaultColor` needs care: when the composition entry (base layer) is
1070
+ // what sets it, there is nothing in the user layer to remove and the
1071
+ // field would look untouched after a reset. Write the idle state's own
1072
+ // colour instead — that IS "no custom default colour" — which also puts
1073
+ // an explicit user-layer value in place, so the clear-override control
1074
+ // reappears and the deployment value stays one click away.
1075
+ var baseOverride = !userHas("defaultColor") &&
1076
+ typeof value.defaultColor === "string" && value.defaultColor.trim() !== "";
1077
+ var idleOwnColor = stateColorList("idle")[0] || null;
1078
+ var defaultReset = baseOverride && idleOwnColor !== null
1079
+ ? scope.set("defaultColor", idleOwnColor)
1080
+ : scope.unset("defaultColor");
497
1081
  Promise.all([
498
1082
  scope.unset("askingHoldMs"),
499
1083
  scope.unset("doneHoldMs"),
1084
+ defaultReset,
500
1085
  scope.unset("states"),
501
1086
  ]).then(function () {
502
1087
  setSaving(false);
@@ -509,11 +1094,18 @@ window.__ModuleLoader__.load({
509
1094
  };
510
1095
 
511
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";
512
1104
 
513
1105
  return createElement(
514
1106
  "div",
515
1107
  { style: styles.card },
516
- createElement(
1108
+ pageView ? null : createElement(
517
1109
  "button",
518
1110
  {
519
1111
  type: "button",
@@ -532,10 +1124,10 @@ window.__ModuleLoader__.load({
532
1124
  createElement(
533
1125
  "span",
534
1126
  { style: Object.assign({}, styles.chevron, open ? styles.chevronOpen : {}) },
535
- createElement(primitives.IconChevronDownOutline14, {})
1127
+ createElement(ChevronDownIcon, {})
536
1128
  )
537
1129
  ),
538
- open ? createElement(
1130
+ (open || pageView) ? createElement(
539
1131
  "div",
540
1132
  { style: styles.body },
541
1133
  !writable ? createElement("p", { style: styles.hint }, t("readOnly")) : null,
@@ -563,17 +1155,88 @@ window.__ModuleLoader__.load({
563
1155
  value: fieldValue("doneHoldMs"),
564
1156
  onChange: function (text) { edit("doneHoldMs", text); },
565
1157
  }),
1158
+ createElement(ColorField, {
1159
+ id: "plugin-config-icon-default-color",
1160
+ label: t("defaultColor"),
1161
+ hint: t("defaultColorHint"),
1162
+ invalidLabel: t("invalidColors"),
1163
+ invalid: failed === "invalidColors",
1164
+ disabled: disabled,
1165
+ separated: true,
1166
+ colors: defaultColorColors(),
1167
+ pickLabel: t("editColor"),
1168
+ onColors: function (next) { if (next.length) edit("defaultColor", next[0]); },
1169
+ // Only a user-layer override can be cleared back to the composition
1170
+ // layer, so the affordance appears exactly when it can be honored.
1171
+ clearLabel: userHas("defaultColor") ? t("clearOverride") : null,
1172
+ onClear: userHas("defaultColor") ? function () { edit("defaultColor", ""); } : null,
1173
+ }),
1174
+ // The whole palette side by side: that is what "distinguishable at tab
1175
+ // size" actually looks like for this configuration.
1176
+ createElement(
1177
+ "div",
1178
+ { style: styles.paletteRow },
1179
+ createElement("span", { style: styles.paletteItem }, t("palette")),
1180
+ ["idle"].concat(EDITABLE_STATES).map(function (name) {
1181
+ return createElement(
1182
+ "span",
1183
+ { key: name, style: styles.paletteItem },
1184
+ t(stateTitleKey(name)),
1185
+ stateChip(name, "palette-" + name)
1186
+ );
1187
+ })
1188
+ ),
1189
+ warnings.length > 0
1190
+ ? createElement(
1191
+ "div",
1192
+ { style: styles.warnBlock },
1193
+ warnings.map(function (warning, index) {
1194
+ return createElement(
1195
+ "p",
1196
+ {
1197
+ key: index,
1198
+ role: "status",
1199
+ style: warning.level === "strong" ? styles.warnStrongText : styles.warnText,
1200
+ },
1201
+ warningText(warning)
1202
+ );
1203
+ })
1204
+ )
1205
+ : null,
566
1206
  createElement(
567
1207
  "div",
568
1208
  { style: styles.statesBlock },
569
- STATE_NAMES.map(function (name) {
1209
+ // Idle has no row here on purpose: its single color is the default
1210
+ // color field above, and it takes no animation.
1211
+ createElement("p", { style: styles.statesHint }, t("statesHint")),
1212
+ EDITABLE_STATES.map(function (name) {
570
1213
  var isOpen = openName === name;
1214
+ var effect = stateFieldValue(name, "effect");
1215
+ var rainbow = effect === "rainbow";
1216
+ // Only the colors the effect consumes are shown (and saved): a
1217
+ // single-color effect must not look like it has a second color.
1218
+ // `rainbow` always offers its starting hue, so an unusable stored
1219
+ // value falls back to the built-in color instead of an empty chip.
1220
+ var storedColors = stateColorList(name);
1221
+ var rowColors = rainbow && storedColors.length === 0
1222
+ ? defaultColorsOf(name).slice()
1223
+ : storedColors;
1224
+ rowColors = rowColors.slice(0, colorCountOf(effect));
1225
+ // Room for another colour? A full list (blink with two colours)
1226
+ // gets no "+" chip: anything it appended would be invisible and
1227
+ // trimmed away on save. An EMPTY list (unusable stored value) gets
1228
+ // no "+" either — the dashed chip is there to be replaced, and
1229
+ // "add a second color" would be nonsense with zero colours.
1230
+ var canAdd = colorCountOf(effect) > rowColors.length && rowColors.length > 0;
571
1231
  return createElement(
572
1232
  primitives.DisclosureRow,
573
1233
  {
574
1234
  key: name,
575
1235
  className: "dsh-wii-state",
576
- icon: createElement(ColorDot, { color: statePrimaryColor(name) }),
1236
+ // The icon slot is a fixed-size box: use ONE chip — split into
1237
+ // the state's colors (asking = red|yellow), or the hue wheel
1238
+ // for the rainbow effect.
1239
+ icon: stateChip(name, "row-" + name),
577
1240
  title: t(stateTitleKey(name)),
578
1241
  open: isOpen,
579
1242
  expandable: true,
@@ -595,21 +1258,26 @@ window.__ModuleLoader__.load({
595
1258
  onChange: function (text) { edit("states." + name + ".effect", text); },
596
1259
  })
597
1260
  ),
598
- createElement(ValueInput, {
1261
+ createElement(ColorField, {
599
1262
  id: "plugin-config-icon-" + name + "-colors",
600
1263
  label: t("colors"),
601
- hint: t("colorsHint"),
1264
+ hint: rainbow ? t("rainbowHint") : t("colorsHint"),
602
1265
  invalidLabel: t("invalidColors"),
603
1266
  invalid: failed === "invalidColors",
604
1267
  disabled: disabled,
605
1268
  separated: true,
606
- value: stateFieldValue(name, "colors"),
607
- trailing: createElement(ColorSwatches, {
608
- text: stateFieldValue(name, "colors"),
609
- label: t("editColor"),
610
- onChange: function (text) { edit("states." + name + ".colors", text); },
611
- }),
612
- onChange: function (text) { edit("states." + name + ".colors", text); },
1269
+ // rainbow has nothing to configure: the field renders the hue
1270
+ // wheel instead of pickers.
1271
+ rainbow: rainbow,
1272
+ colors: rowColors,
1273
+ // Only blink / breath read a second color, so only they offer
1274
+ // the "+" chip; every extra entry stays removable.
1275
+ canAdd: canAdd,
1276
+ canRemove: true,
1277
+ pickLabel: t("editColor"),
1278
+ addLabel: t("addColor"),
1279
+ removeLabel: t("removeColor"),
1280
+ onColors: function (next) { edit("states." + name + ".colors", next.join(", ")); },
613
1281
  }),
614
1282
  // `speed` is meaningless for a static effect (the browser paints
615
1283
  // one frame and never loops), so hide it — switching to an
@@ -657,23 +1325,81 @@ window.__ModuleLoader__.load({
657
1325
  // ---------------------------------------------------------------------------
658
1326
  // Plugin entry
659
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
+
660
1349
  function apply(ctx) {
661
1350
  var t = ctx.locale.bind(LOCALE);
662
1351
  ctx.effect(function () {
663
1352
  return ctx.locale.register(LOCALE, { zh: zh, en: en });
664
- }, "dsh-web-icon-indicator: settings card dictionaries");
665
- var scope = ctx.settingsScope.bind({ namespace: NS });
666
- ctx.slots.inject("settings.plugin.item", function () {
667
- return ctx.slots.register(
668
- {
669
- name: "settings.plugin.item",
670
- key: NS,
671
- locale: LOCALE,
672
- inject: function () { return { scope: scope, t: t }; },
673
- },
674
- IconConfigCard
675
- );
676
- });
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
+ }
677
1403
  }
678
1404
 
679
1405
  exports.apply = apply;