dsh-web-icon-indicator 0.4.2 → 0.5.0

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