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/CHANGELOG.md +19 -0
- package/README.md +88 -14
- package/README.zh.md +67 -13
- package/lib/client.js +715 -98
- package/lib/index.js +399 -48
- package/lib/types/index.d.ts +73 -4
- package/package.json +3 -3
- package/test/loader-hooks.mjs +21 -0
- package/test/stubs/dsh-settings.mjs +15 -0
- package/test/stubs/schemastery.mjs +12 -0
- package/test/verify.js +2026 -0
- package/docs/safari-favicon-research.md +0 -114
package/lib/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: "
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
|
|
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
|
|
249
|
-
var
|
|
250
|
-
if (
|
|
251
|
-
|
|
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
|
-
"
|
|
254
|
-
{ style:
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
383
|
-
if (kind === "
|
|
384
|
-
|
|
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
|
-
/**
|
|
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 <
|
|
444
|
-
var name =
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|