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