dsh-background-by-model 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -55,12 +55,21 @@ window.__ModuleLoader__.load({
55
55
  tryoutEmpty: "输入模型名即可试跑",
56
56
  autoExtract: "选图后自动取主题色",
57
57
  ruleAdd: "新增规则",
58
+ ruleNoneHint: "还没有规则,点上面的「新增规则」新建一条",
58
59
  ruleFallbackBadge: "兜底",
59
60
  ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
60
61
  ruleEnabled: "启用",
61
62
  ruleUp: "上移",
62
63
  ruleDown: "下移",
63
64
  ruleRemove: "删除规则",
65
+ tabListLabel: "规则",
66
+ tabPick: "选择规则",
67
+ tabMore: "更多规则",
68
+ tabUnnamed: "未命名规则",
69
+ tabLive: "正在使用",
70
+ tabRotating: "轮换中",
71
+ tabOff: "已停用",
72
+ ruleRemoveConfirm: "再点一次删除",
64
73
  ruleNoImage: "尚未选择图片",
65
74
  ruleFromUrl: "从网址",
66
75
  ruleUrlPlaceholder: "粘贴图片网址 https://…",
@@ -112,7 +121,6 @@ window.__ModuleLoader__.load({
112
121
  rotEvery1m: "1 分钟",
113
122
  rotEvery5m: "5 分钟",
114
123
  rotEvery30m: "30 分钟",
115
- rotEvery1h: "1 小时",
116
124
  bgModeFit: "适应",
117
125
  bgModeFill: "填充",
118
126
  bgModeStretch: "拉伸",
@@ -142,6 +150,24 @@ window.__ModuleLoader__.load({
142
150
  toastExportDone: "配置文件已开始下载",
143
151
  importDone: "已导入配置",
144
152
  importFail: "导入失败,文件格式不正确",
153
+ recCardTitle: "推荐配置",
154
+ recCardDesc: "从项目素材库下载作者维护的一整套配置(规则、壁纸与接口设置),一键替换当前的全部内容",
155
+ recAdopt: "采用推荐配置",
156
+ recBusy: "正在下载",
157
+ recConfirmTitle: "替换当前的全部配置?",
158
+ recConfirmBody: "会先删除你现在的全部规则与壁纸,再写入这份推荐配置。删除的文件无法恢复。",
159
+ recStatRules: "条规则",
160
+ recStatImages: "张壁纸",
161
+ recConfirmOk: "删除并替换",
162
+ recConfirmCancel: "取消",
163
+ recDone: "已采用推荐配置",
164
+ recFailNetwork: "推荐配置下载失败,请检查网络后重试",
165
+ recFailNotProfile: "素材库返回的内容不是配置,请稍后重试",
166
+ recFailNewer: "这份推荐配置需要更新的插件版本,未做任何改动",
167
+ recFailTooMany: "这份推荐配置列出的图片数量超出限制,未做任何改动",
168
+ recFailTooLarge: "这份推荐配置体积过大,未做任何改动",
169
+ recFailImage: "这张壁纸没拿到(素材库里缺这个文件):",
170
+ recFailApply: "未能写入推荐配置,你原来的配置没有被改动",
145
171
  footerTag: "按模型换背景插件",
146
172
  repoLink: "在 GitHub 上打开仓库",
147
173
  holidayTitle: "节日特殊背景",
@@ -199,12 +225,21 @@ window.__ModuleLoader__.load({
199
225
  tryoutEmpty: "Type a model name to test the list",
200
226
  autoExtract: "Extract the theme color when an image is chosen",
201
227
  ruleAdd: "Add rule",
228
+ ruleNoneHint: "No rules yet — use “Add rule” in the tab strip above",
202
229
  ruleFallbackBadge: "fallback",
203
230
  ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
204
231
  ruleEnabled: "Enabled",
205
232
  ruleUp: "Move up",
206
233
  ruleDown: "Move down",
207
234
  ruleRemove: "Remove rule",
235
+ tabListLabel: "Rules",
236
+ tabPick: "Pick a rule",
237
+ tabMore: "More rules",
238
+ tabUnnamed: "Unnamed rule",
239
+ tabLive: "In use",
240
+ tabRotating: "Rotating",
241
+ tabOff: "Disabled",
242
+ ruleRemoveConfirm: "Click again to delete",
208
243
  ruleNoImage: "No image yet",
209
244
  ruleFromUrl: "From URL",
210
245
  ruleUrlPlaceholder: "Paste an image URL https://…",
@@ -256,7 +291,6 @@ window.__ModuleLoader__.load({
256
291
  rotEvery1m: "1 min",
257
292
  rotEvery5m: "5 min",
258
293
  rotEvery30m: "30 min",
259
- rotEvery1h: "1 h",
260
294
  bgModeFit: "Fit",
261
295
  bgModeFill: "Fill",
262
296
  bgModeStretch: "Stretch",
@@ -286,6 +320,24 @@ window.__ModuleLoader__.load({
286
320
  toastExportDone: "Export started",
287
321
  importDone: "Profile imported",
288
322
  importFail: "Import failed — invalid file",
323
+ recCardTitle: "Recommended profile",
324
+ recCardDesc: "Download the author's own full setup — rules, wallpapers and interface settings — and replace everything with it",
325
+ recAdopt: "Use recommended profile",
326
+ recBusy: "Downloading",
327
+ recConfirmTitle: "Replace your entire profile?",
328
+ recConfirmBody: "Every rule and wallpaper you have is deleted first, then this profile is written in its place. Deleted files cannot be recovered.",
329
+ recStatRules: "rules",
330
+ recStatImages: "wallpapers",
331
+ recConfirmOk: "Delete and replace",
332
+ recConfirmCancel: "Cancel",
333
+ recDone: "Recommended profile applied",
334
+ recFailNetwork: "Could not download the recommended profile — check your connection and retry",
335
+ recFailNotProfile: "The asset host answered with something that is not a profile — try again later",
336
+ recFailNewer: "This profile needs a newer plugin version. Nothing was changed.",
337
+ recFailTooMany: "The recommended profile names more images than the plugin will fetch. Nothing was changed.",
338
+ recFailTooLarge: "The recommended profile is too large. Nothing was changed.",
339
+ recFailImage: "Could not fetch this wallpaper — the asset host has no such file:",
340
+ recFailApply: "Could not write the recommended profile — your own is untouched",
289
341
  footerTag: "Per-model wallpaper plugin",
290
342
  repoLink: "Open the repository on GitHub",
291
343
  holidayTitle: "Holiday backgrounds",
@@ -914,7 +966,7 @@ window.__ModuleLoader__.load({
914
966
  * is still what the rule paints once its last picture is gone.
915
967
  *
916
968
  * The layout mode makes the same trip in the other direction, and this is the
917
- * last release that has to: a config written before 0.8 keeps `bgMode` at the RULE
969
+ * last release that has to: a config written before 0.7.5 keeps `bgMode` at the RULE
918
970
  * level, where one value covered every picture of a rotation, so it is read here
919
971
  * once and handed to each image as its inherited mode. What the user chose
920
972
  * therefore survives the move the same way it survived the color's — the mode is
@@ -1033,50 +1085,6 @@ window.__ModuleLoader__.load({
1033
1085
  holidays: normalizeHolidays(r.holidays)
1034
1086
  };
1035
1087
  }
1036
- //#endregion
1037
- //#region src/client/state.ts
1038
- const PALETTE = [
1039
- [
1040
- 356,
1041
- .72,
1042
- .55
1043
- ],
1044
- [
1045
- 24,
1046
- .78,
1047
- .55
1048
- ],
1049
- [
1050
- 44,
1051
- .8,
1052
- .55
1053
- ],
1054
- [
1055
- 152,
1056
- .62,
1057
- .5
1058
- ],
1059
- [
1060
- 174,
1061
- .68,
1062
- .48
1063
- ],
1064
- [
1065
- 208,
1066
- .72,
1067
- .55
1068
- ],
1069
- [
1070
- 252,
1071
- .68,
1072
- .6
1073
- ],
1074
- [
1075
- 300,
1076
- .64,
1077
- .58
1078
- ]
1079
- ];
1080
1088
  freshThemeConfig();
1081
1089
  let cfg = freshThemeConfig();
1082
1090
  const images = /* @__PURE__ */ new Map();
@@ -1128,6 +1136,10 @@ window.__ModuleLoader__.load({
1128
1136
  return null;
1129
1137
  }
1130
1138
  }
1139
+ function clearImages() {
1140
+ for (const slot of Array.from(displayUrls.keys())) revokeDisplay(slot);
1141
+ images.clear();
1142
+ }
1131
1143
  let activeRuleId = null;
1132
1144
  let activeMatched = false;
1133
1145
  let modelLabel = "";
@@ -1371,6 +1383,14 @@ window.__ModuleLoader__.load({
1371
1383
  }
1372
1384
  rotIndex = 0;
1373
1385
  }
1386
+ function resetConfig() {
1387
+ cfg = freshThemeConfig();
1388
+ clearImages();
1389
+ activeRuleId = null;
1390
+ activeMatched = false;
1391
+ modelLabel = "";
1392
+ rotIndex = 0;
1393
+ }
1374
1394
  //#endregion
1375
1395
  //#region src/client/rotation.ts
1376
1396
  /** Dwell-time presets the settings UI offers, in ms. */
@@ -1394,10 +1414,6 @@ window.__ModuleLoader__.load({
1394
1414
  {
1395
1415
  ms: 18e5,
1396
1416
  key: "rotEvery30m"
1397
- },
1398
- {
1399
- ms: 36e5,
1400
- key: "rotEvery1h"
1401
1417
  }
1402
1418
  ];
1403
1419
  /**
@@ -1601,6 +1617,52 @@ window.__ModuleLoader__.load({
1601
1617
  error: v?.error ?? "failed"
1602
1618
  };
1603
1619
  }
1620
+ /**
1621
+ * The profile's manifest and config, in one answer. Reads nothing, writes nothing.
1622
+ *
1623
+ * Raw rather than `rpcCall` for the same reason `fetchImageUrl` is: the node half
1624
+ * answers this one with a value that CARRIES its own failure, and the `error`
1625
+ * inside it is the only thing worth showing the user — the generic "rpc failed"
1626
+ * log would throw that distinction away.
1627
+ */
1628
+ async function fetchPresetConfig() {
1629
+ return unwrapPreset(await rpcRaw("fetchPresetConfig", {}));
1630
+ }
1631
+ /** One of the profile's wallpapers, addressed by the slot the config names. */
1632
+ async function fetchPresetImage(slot) {
1633
+ return unwrapPreset(await rpcRaw("fetchPresetImage", { slot }));
1634
+ }
1635
+ /**
1636
+ * The envelope both profile calls share: a transport failure and a value-level
1637
+ * failure have to come out the same shape, because the page renders one message
1638
+ * either way and must not have to know which layer said no.
1639
+ */
1640
+ function unwrapPreset(res) {
1641
+ if (!res) return {
1642
+ ok: false,
1643
+ error: "no response"
1644
+ };
1645
+ if (res.ok !== true) return {
1646
+ ok: false,
1647
+ error: res.error?.message ?? "request failed"
1648
+ };
1649
+ const v = res.value;
1650
+ if (v?.ok !== true) return {
1651
+ ok: false,
1652
+ error: typeof v?.error === "string" ? v.error : "failed"
1653
+ };
1654
+ return v;
1655
+ }
1656
+ /**
1657
+ * Empty the store: the config and every rule image.
1658
+ *
1659
+ * Only the recommended profile uses this, and only after its download has
1660
+ * already succeeded — see `applyRecommended`. Nothing else in the plugin deletes
1661
+ * the user's configuration wholesale.
1662
+ */
1663
+ async function resetStore() {
1664
+ return await rpcCall("resetStore", {}) === true;
1665
+ }
1604
1666
  /** Host default model — used only when the per-session services are absent. */
1605
1667
  async function readDefaultModel() {
1606
1668
  const data = await rpcCall("defaultModel", {});
@@ -2042,6 +2104,34 @@ window.__ModuleLoader__.load({
2042
2104
  .dab-root{position:relative;color:var(--dsw-alias-label-primary);animation:dab-fade-in .35s ease both;container-type:inline-size;display:flex;flex-direction:column;align-items:center;width:100%;min-width:0;--dab-mono:ui-monospace,"Cascadia Mono","SF Mono",Consolas,"Courier New",monospace}
2043
2105
  .dab-root *,.dab-root *::before,.dab-root *::after{box-sizing:border-box}
2044
2106
  .dab-root button{font-family:inherit}
2107
+ /* ── scrollbars: the same bar as the settings panel we sit inside ────────────
2108
+ * The host styles scrollbars globally off its --dsh-scrollbar-* tokens, but those
2109
+ * declarations describe the app shell and its own scroll containers; a strip
2110
+ * nested this deep got the platform default instead — a thick light bar with
2111
+ * arrows, which is what the tab strip used to show beside the panel's own slim
2112
+ * vertical one.
2113
+ *
2114
+ * So the plugin declares the block for its OWN scroll containers. The values are
2115
+ * the host theme package's, for the layer-2 surface this panel is painted in:
2116
+ * 5px, rounded, layer-2 tint, transparent track. Two notes for whoever edits it:
2117
+ *
2118
+ * - the tokens go ON THE SCROLLER (the selector that carries the overflow), not
2119
+ * on .dab-root. A container that declares scrollbar-width:thin (as the nav
2120
+ * rail does on narrow panels) paints its own standard bar regardless of the
2121
+ * webkit block, and the host's standard-property fallback reads the thumb
2122
+ * token from that same element.
2123
+ * - every selector here is scoped to a dab- class: the stylesheet is injected
2124
+ * into the host page once, so a bare webkit-scrollbar selector would restyle
2125
+ * the whole harness. */
2126
+ .dab-tabs,.dab-pick-menu{--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--dsh-scrollbar-width:5px}
2127
+ .dab-tabs::-webkit-scrollbar,.dab-pick-menu::-webkit-scrollbar{width:var(--dsh-scrollbar-width,5px);height:var(--dsh-scrollbar-width,5px)}
2128
+ .dab-tabs::-webkit-scrollbar-track,.dab-pick-menu::-webkit-scrollbar-track{background:transparent}
2129
+ .dab-tabs::-webkit-scrollbar-thumb,.dab-pick-menu::-webkit-scrollbar-thumb{background:var(--dsh-scrollbar-thumb);background-clip:content-box;border-radius:999px}
2130
+ .dab-tabs::-webkit-scrollbar-thumb:hover,.dab-pick-menu::-webkit-scrollbar-thumb:hover{background:var(--dsh-scrollbar-thumb-hover)}
2131
+ .dab-tabs::-webkit-scrollbar-corner,.dab-pick-menu::-webkit-scrollbar-corner{background:transparent}
2132
+ /* The host hides the platform's stepper arrows; without this the strip keeps the
2133
+ little os arrows at both ends of the track while the panel's bar has none. */
2134
+ .dab-tabs::-webkit-scrollbar-button,.dab-pick-menu::-webkit-scrollbar-button{display:none}
2045
2135
 
2046
2136
  /* ── shell: nav rail + page body ─────────────────────────────────────────── */
2047
2137
  .dab-shell{display:grid;grid-template-columns:158px minmax(0,1fr);gap:26px;align-items:start;padding-bottom:8px;width:100%;max-width:980px;margin:0 auto}
@@ -2067,7 +2157,10 @@ window.__ModuleLoader__.load({
2067
2157
  .dab-head{margin:2px 0 5px}
2068
2158
  .dab-overline{font-size:10.5px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--dsw-alias-brand-primary);opacity:.9}
2069
2159
  .dab-h1{margin:4px 0 0;font-size:21px;font-weight:700;letter-spacing:-.01em}
2070
- .dab-desc{margin:6px 0 0;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary);max-width:56ch}
2160
+ /* No max-width on the page description: it is a single line of context under the
2161
+ title, and capping it at a prose measure left it wrapping early with dead space
2162
+ to its right while the cards below ran the full width of the panel. */
2163
+ .dab-desc{margin:6px 0 0;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}
2071
2164
  .dab-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:18px}
2072
2165
  .dab-card-hover{transition:transform .28s ease,box-shadow .28s ease,border-color .28s ease}
2073
2166
  .dab-card-hover:hover{transform:translateY(-2px);box-shadow:0 10px 28px -12px rgba(0,0,0,.28)}
@@ -2084,10 +2177,6 @@ window.__ModuleLoader__.load({
2084
2177
 
2085
2178
  /* ── swatches ────────────────────────────────────────────────────────────── */
2086
2179
  .dab-swatch-title{font-size:12px;font-weight:600;margin-bottom:10px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
2087
- .dab-swatches{display:flex;flex-wrap:wrap;gap:9px}
2088
- .dab-swatch{width:25px;height:25px;border-radius:50%;border:0;padding:0;cursor:pointer;box-shadow:inset 0 0 0 1px rgba(0,0,0,.1);transition:transform .22s cubic-bezier(.34,1.56,.64,1),box-shadow .22s ease}
2089
- .dab-swatch:hover{transform:scale(1.2)}
2090
- .dab-swatch.is-on{box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-1),0 0 0 4px var(--dsw-alias-brand-primary)}
2091
2180
 
2092
2181
  /* ── wheel card ──────────────────────────────────────────────────────────── */
2093
2182
  .dab-wheel-card{position:relative;display:flex;align-items:center;justify-content:center;gap:28px;flex-wrap:wrap;padding:24px 18px}
@@ -2137,7 +2226,7 @@ window.__ModuleLoader__.load({
2137
2226
  .dab-btn-danger{color:var(--dsw-alias-state-error-primary)}
2138
2227
  .dab-btn-ghost{background:transparent;border-color:transparent;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
2139
2228
  .dab-btn-ghost:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);box-shadow:none}
2140
- .dab-btn:focus-visible,.dab-nav-item:focus-visible,.dab-seg-item:focus-visible,.dab-swatch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
2229
+ .dab-btn:focus-visible,.dab-nav-item:focus-visible,.dab-seg-item:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
2141
2230
  .dab-chip-row{display:flex;flex-wrap:wrap;gap:8px}
2142
2231
  .dab-chip{height:30px;padding:0 14px;border-radius:99px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:all .22s ease}
2143
2232
  .dab-chip:hover{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
@@ -2225,12 +2314,83 @@ window.__ModuleLoader__.load({
2225
2314
  .dab-modal-card{animation:dab-zoom-in .3s cubic-bezier(.22,1,.36,1) both;max-width:calc(100vw - 40px);max-height:calc(100vh - 120px);overflow:auto}
2226
2315
  .dab-overlay .dab-btn{background:rgba(255,255,255,.94);color:#14161a;border-color:transparent}
2227
2316
  .dab-overlay .dab-btn-primary{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-inverted)}
2317
+ /* The destructive choice has to survive the white-on-dark treatment above: a
2318
+ confirmation whose "delete everything" button looks exactly like its "cancel"
2319
+ is the one place where losing the distinction actually costs something. */
2320
+ .dab-overlay .dab-btn-danger{background:#dc2626;color:#fff;border-color:transparent}
2228
2321
  .dab-crash{display:flex;flex-direction:column;gap:10px;align-items:center;padding:28px 18px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px}
2229
2322
  .dab-crash-title{font-size:15px;font-weight:650}
2230
2323
  .dab-crash-desc{font-size:12px;color:var(--dsw-alias-label-tertiary);text-align:center;line-height:1.5}
2231
2324
 
2232
2325
  /* ── model rules ─────────────────────────────────────────────────────────── */
2233
- .dab-rules{display:flex;flex-direction:column;gap:12px}
2326
+ /* ── rule tab strip ──────────────────────────────────────────────────────────
2327
+ * The rules are edited ONE at a time: every rule is a tab in a single sideways
2328
+ * scrolling row, and the page renders exactly one editor under it. The height of
2329
+ * this panel is therefore a function of one card, not of the rule count — which
2330
+ * is what the stacked accordion could not promise.
2331
+ *
2332
+ * The strip scrolls instead of wrapping for the same reason: a wrapped strip
2333
+ * would grow a row taller with every rule and hand the page height straight back
2334
+ * to the list. Tabs are fixed-height and the match label elides, so switching
2335
+ * rules can never reflow the row either. */
2336
+ /* Deliberately NOT sticky: the horizontal scroller above the tabs clips on BOTH
2337
+ axes, so the "which rule" menu — an absolutely positioned child — would be cut
2338
+ off at the strip's own bottom edge instead of floating over the page.
2339
+ Switching rules stays a click near the top of the panel, which is one screen
2340
+ away at every realistic rule count. */
2341
+ /* The tiles are aligned by their TOP edge, not by centering: the strip's box is
2342
+ taller than a tab (its height includes the horizontal scrollbar, which no other
2343
+ tile in the row has), so centering against the row would put the two icon
2344
+ buttons a few pixels below the tabs they sit beside. Top alignment makes the
2345
+ strip's top and the buttons' top the same line; the strip's own 3px top padding
2346
+ is mirrored on the buttons, and everything below the bar comes from the strip's
2347
+ bottom padding — so the bar keeps the same distance from the TAB bottoms and
2348
+ from the icon tiles' bottoms. */
2349
+ .dab-tabs-row{display:flex;align-items:flex-start;gap:8px;padding-bottom:0;margin-bottom:6px}
2350
+ /* The strip is the scroller the scrollbar block above is written for. Its bottom
2351
+ padding is the whole gap under the tiles: the bar is painted at the bottom of
2352
+ this box, so this is what keeps it off the tabs (and, because the row adds
2353
+ nothing here, off the icon buttons beside them too). */
2354
+ .dab-tabs{flex:1;min-width:0;display:flex;align-items:center;gap:6px;overflow-x:auto;overscroll-behavior-x:contain;padding:3px 2px 8px}
2355
+ .dab-tab{height:34px;flex:none;display:flex;align-items:center;gap:7px;padding:0 11px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:12.5px;cursor:pointer;max-width:196px;transition:color .2s,border-color .2s,background .2s,opacity .2s}
2356
+ .dab-tab:hover{color:var(--dsw-alias-label-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,var(--dsw-alias-border-l2))}
2357
+ .dab-tab:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
2358
+ /* The selected tab reads like the nav rail's own indicator: a brand tint, a
2359
+ brand border, and the label lifted to full contrast. */
2360
+ .dab-tab.is-active{color:var(--dsw-alias-brand-primary);font-weight:600;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 13%,transparent)}
2361
+ .dab-tab.is-off{opacity:.5}
2362
+ .dab-tab:not(.is-active).is-off:hover{opacity:.75}
2363
+ .dab-tab-thumb{width:20px;height:20px;flex:none;border-radius:5px;object-fit:cover;border:1px solid var(--dsw-alias-border-l2)}
2364
+ .dab-tab-num{font-family:var(--dab-mono);font-size:10.5px;font-weight:700;opacity:.8}
2365
+ .dab-tab-match{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2366
+ .dab-tab-live{width:6px;height:6px;flex:none;border-radius:50%;background:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);animation:dab-pulse 1.8s ease-in-out infinite}
2367
+ .dab-tab-rot{flex:none;font-size:11px;line-height:1;opacity:.75}
2368
+ /* The strip's two actions — add a rule, open the rule list — are square icon
2369
+ tiles beside the scroller rather than tiles inside it: inside, a long rule list
2370
+ pushed them off the end, so the one control that ADDS a rule could only be
2371
+ reached by scrolling to the bottom of the rules. They keep the tab's box so the
2372
+ row stays one height, and the icon is the whole label (title + aria-label). */
2373
+ /* The top margin mirrors the strip's own padding-top, so the icon inside the tile
2374
+ sits on the same line as the text inside the tabs. */
2375
+ .dab-tab-icon{width:34px;margin-top:3px;padding:0;justify-content:center;color:var(--dsw-alias-label-tertiary);max-width:none}
2376
+ .dab-tab-icon:hover{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent)}
2377
+
2378
+ /* ── "which rule" picker (the strip's overflow menu) ───────────────────────── */
2379
+ .dab-pick{position:relative;flex:none}
2380
+ .dab-pick-scrim{position:fixed;inset:0;z-index:40;background:transparent}
2381
+ .dab-pick-menu{position:absolute;right:0;top:calc(100% + 6px);z-index:41;min-width:220px;max-height:284px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;padding:5px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:0 18px 44px -14px rgba(0,0,0,.45);animation:dab-rise-in .18s ease both}
2382
+ .dab-pick-item{display:flex;align-items:center;gap:9px;padding:7px 9px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary));font-size:12.5px;text-align:left;cursor:pointer}
2383
+ .dab-pick-item:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
2384
+ .dab-pick-item.is-active{color:var(--dsw-alias-brand-primary);font-weight:600;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
2385
+ .dab-pick-match{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2386
+ .dab-pick-n{flex:none;font-family:var(--dsw-mono);font-size:10.5px;opacity:.7}
2387
+
2388
+ /* ── one rule editor (the tab panel's content) ─────────────────────────────── */
2389
+ .dab-rule-panel{min-height:0}
2390
+ /* The armed delete button stops being a 28px icon box and says what the second
2391
+ click will do — a worded state, not only a colour one. */
2392
+ .dab-icon-btn.is-armed{width:auto;padding:0 9px;color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}
2393
+ .dab-confirm-txt{font-size:11px;font-weight:600;white-space:nowrap}
2234
2394
  .dab-rule{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:14px;transition:border-color .25s,box-shadow .25s,opacity .25s}
2235
2395
  .dab-rule.is-active{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 16%,transparent)}
2236
2396
  .dab-rule.is-off{opacity:.55}
@@ -4163,6 +4323,18 @@ window.__ModuleLoader__.load({
4163
4323
  className,
4164
4324
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5.7 4.3 2.4 8l3.3 3.7M10.3 4.3 13.6 8l-3.3 3.7" })
4165
4325
  });
4326
+ /** Appends a rule — the tab strip's own action, beside the strip it adds to. */
4327
+ const PlusIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Glyph, {
4328
+ size,
4329
+ className,
4330
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 3.4v9.2M3.4 8h9.2" })
4331
+ });
4332
+ /** Opens the "which rule" menu: down means a list drops out under the button. */
4333
+ const ChevronDownIcon = ({ size, className }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Glyph, {
4334
+ size,
4335
+ className,
4336
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4.3 6.4 8 10.1l3.7-3.7" })
4337
+ });
4166
4338
  //#endregion
4167
4339
  //#region src/client/components/ErrorBoundary.tsx
4168
4340
  /**
@@ -4490,6 +4662,42 @@ window.__ModuleLoader__.load({
4490
4662
  })] });
4491
4663
  }
4492
4664
  //#endregion
4665
+ //#region src/client/rules-view.ts
4666
+ /**
4667
+ * The rule the editor should show.
4668
+ *
4669
+ * `sel` wins whenever it is still in the list: the selection is the user's, and
4670
+ * a model switch, a reorder or an image edit must not move it. The fallbacks run
4671
+ * only when there is no selection at all, or when the rule it named is gone
4672
+ * (deleted here, or replaced wholesale by an import or by the persisted config
4673
+ * landing after the first render — whose ids are not the ones we started with).
4674
+ * The active rule is preferred over the first one so that opening the panel
4675
+ * lands on the rule the current model is using.
4676
+ */
4677
+ function resolveTab(rules, sel, activeId) {
4678
+ if (rules.length === 0) return null;
4679
+ if (sel !== null) {
4680
+ const held = rules.find((r) => r.id === sel);
4681
+ if (held !== void 0) return held;
4682
+ }
4683
+ if (activeId !== null) {
4684
+ const live = rules.find((r) => r.id === activeId);
4685
+ if (live !== void 0) return live;
4686
+ }
4687
+ return rules[0] ?? null;
4688
+ }
4689
+ /**
4690
+ * The rule to show once `id` has been removed: the next one, or the previous one
4691
+ * when the removed rule was last. Deliberately NOT the first rule — the user is
4692
+ * working down a list, and jumping to the top loses their place — and `null`
4693
+ * when it was the only rule, which the panel renders as "no rules yet".
4694
+ */
4695
+ function neighbourAfter(rules, id) {
4696
+ const i = rules.findIndex((r) => r.id === id);
4697
+ if (i < 0) return null;
4698
+ return rules[i + 1] ?? rules[i - 1] ?? null;
4699
+ }
4700
+ //#endregion
4493
4701
  //#region src/client/utils/image.ts
4494
4702
  /**
4495
4703
  * Read a chosen image file as a data URL WITHOUT re-encoding: the original
@@ -5266,16 +5474,6 @@ window.__ModuleLoader__.load({
5266
5474
  .55,
5267
5475
  .25
5268
5476
  ];
5269
- /**
5270
- * Failing-hop codes the model watcher can report, mapped to their copy. Only
5271
- * these are rendered: `waiting` and `fallback` already have their own hints.
5272
- */
5273
- const MODEL_NOTE_KEYS = {
5274
- "no-service": "statusNoteNoService",
5275
- "no-session": "statusNoteNoSession",
5276
- "no-projection": "statusNoteNoProjection",
5277
- "empty-selection": "statusNoteEmptySelection"
5278
- };
5279
5477
  function toHex(rgb) {
5280
5478
  return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
5281
5479
  }
@@ -5283,152 +5481,287 @@ window.__ModuleLoader__.load({
5283
5481
  const { t, useStore } = p;
5284
5482
  const store = useStore((s) => s);
5285
5483
  const rules = cfg.rules;
5286
- const [text, setText] = (0, react.useState)("");
5287
- const [touched, setTouched] = (0, react.useState)(false);
5484
+ const [selId, setSelId] = (0, react.useState)(() => resolveTab(rules, null, store.activeRuleId)?.id ?? null);
5485
+ /** True once the user has picked a tab themselves, which stops the follow above. */
5486
+ const pickedRef = (0, react.useRef)(false);
5288
5487
  (0, react.useEffect)(() => {
5289
- if (!touched) setText(store.model);
5290
- }, [store.model, touched]);
5291
- const probe = text.trim();
5292
- const result = (() => {
5293
- if (probe === "") return null;
5294
- const hit = matchRule(rules, probe);
5295
- const rule = hit.rule;
5296
- if (rule === null) return null;
5297
- const n = rules.findIndex((r) => r.id === rule.id) + 1;
5298
- return `${hit.matched ? t("tryoutHit") : t("tryoutFallback")} ${n}`;
5299
- })();
5300
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5301
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
5302
- className: "dab-head dab-rise",
5303
- style: { "--d": 0 },
5304
- children: [
5305
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5306
- className: "dab-overline",
5307
- children: "Rules"
5308
- }),
5309
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
5310
- className: "dab-h1",
5311
- children: t("pageModelBg")
5312
- }),
5313
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5314
- className: "dab-desc",
5315
- children: t("descModelBg")
5316
- })
5317
- ]
5318
- }),
5319
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
5320
- className: "dab-card dab-rise",
5321
- style: { "--d": 1 },
5322
- children: [
5323
- store.hostStale ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5324
- className: "dab-hint dab-stale",
5325
- style: { marginBottom: 10 },
5326
- children: t("hostStaleHint")
5327
- }) : null,
5328
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5329
- className: "dab-swatch-title",
5330
- children: t("tryoutTitle")
5331
- }),
5332
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5333
- style: {
5334
- marginTop: 10,
5335
- display: "flex",
5336
- flexWrap: "wrap",
5337
- gap: 8,
5338
- alignItems: "center"
5339
- },
5340
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5341
- type: "text",
5342
- className: "dab-urlinput",
5343
- value: text,
5344
- placeholder: t("tryoutPlaceholder"),
5345
- onChange: (e) => {
5346
- setTouched(true);
5347
- setText(e.target.value);
5348
- }
5349
- }), store.model !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5350
- type: "button",
5351
- className: "dab-btn",
5352
- onClick: () => {
5353
- setTouched(true);
5354
- setText(store.model);
5355
- },
5356
- children: t("tryoutUseCurrent")
5357
- }) : null]
5358
- }),
5359
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5360
- className: "dab-hint",
5361
- style: { marginTop: 9 },
5362
- children: probe === "" ? t("tryoutEmpty") : result === null ? t("tryoutNone") : result
5363
- }),
5364
- store.model === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5365
- className: "dab-hint",
5366
- style: { marginTop: 6 },
5367
- children: t("statusUnknownHint")
5368
- }) : null,
5369
- store.model !== "" && store.modelSource === "default" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5370
- className: "dab-hint",
5371
- style: { marginTop: 6 },
5372
- children: t("statusSourceDefaultHint")
5373
- }) : null,
5374
- MODEL_NOTE_KEYS[store.modelNote] !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5488
+ if (pickedRef.current) return;
5489
+ const held = resolveTab(rules, selId, store.activeRuleId);
5490
+ if (held !== null && held.id !== selId) setSelId(held.id);
5491
+ }, [
5492
+ store.rulesRev,
5493
+ store.activeRuleId,
5494
+ selId,
5495
+ rules
5496
+ ]);
5497
+ const active = resolveTab(rules, selId, store.activeRuleId)?.id ?? null;
5498
+ const [selImage, setSelImage] = (0, react.useState)({});
5499
+ const selIdxOf = (id) => selImage[id] ?? 0;
5500
+ const setSelIdxOf = (id, i) => setSelImage((m) => m[id] === i ? m : {
5501
+ ...m,
5502
+ [id]: i
5503
+ });
5504
+ /** Select a rule as the edited one; keyboard stepping shares this. */
5505
+ const selectRule = (id) => {
5506
+ pickedRef.current = true;
5507
+ setSelId(id);
5508
+ };
5509
+ /** Delete a rule and land on its neighbour — never on the first rule. */
5510
+ const removeRule = (id) => {
5511
+ if (id === active) {
5512
+ const next = neighbourAfter(rules, id);
5513
+ pickedRef.current = true;
5514
+ setSelId(next?.id ?? null);
5515
+ }
5516
+ setSelImage((m) => {
5517
+ const n = { ...m };
5518
+ delete n[id];
5519
+ return n;
5520
+ });
5521
+ p.removeRule(id);
5522
+ };
5523
+ const addRule = () => {
5524
+ pickedRef.current = true;
5525
+ setSelId(p.addRule());
5526
+ };
5527
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
5528
+ className: "dab-head dab-rise",
5529
+ style: { "--d": 0 },
5530
+ children: [
5531
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5532
+ className: "dab-overline",
5533
+ children: "Rules"
5534
+ }),
5535
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
5536
+ className: "dab-h1",
5537
+ children: t("pageModelBg")
5538
+ }),
5539
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5540
+ className: "dab-desc",
5541
+ children: t("descModelBg")
5542
+ })
5543
+ ]
5544
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
5545
+ className: "dab-rise",
5546
+ style: { "--d": 1 },
5547
+ children: [
5548
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5549
+ className: "dab-chip-row",
5550
+ style: {
5551
+ marginBottom: 12,
5552
+ alignItems: "center"
5553
+ },
5554
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5555
+ type: "button",
5556
+ className: `dab-toggle${cfg.autoExtract ? " is-on" : ""}`,
5557
+ role: "switch",
5558
+ "aria-checked": cfg.autoExtract,
5559
+ title: t("autoExtract"),
5560
+ onClick: () => p.setAutoExtract(!cfg.autoExtract),
5561
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5562
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5375
5563
  className: "dab-hint",
5376
- style: { marginTop: 6 },
5377
- children: t(MODEL_NOTE_KEYS[store.modelNote])
5378
- }) : null
5379
- ]
5380
- }),
5381
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
5382
- className: "dab-rise",
5383
- style: { "--d": 2 },
5384
- children: [
5385
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5386
- className: "dab-chip-row",
5387
- style: {
5388
- marginBottom: 12,
5389
- alignItems: "center"
5390
- },
5391
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5392
- type: "button",
5393
- className: `dab-toggle${cfg.autoExtract ? " is-on" : ""}`,
5394
- role: "switch",
5395
- "aria-checked": cfg.autoExtract,
5396
- title: t("autoExtract"),
5397
- onClick: () => p.setAutoExtract(!cfg.autoExtract),
5398
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5399
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5564
+ children: t("autoExtract")
5565
+ })]
5566
+ }),
5567
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RulesTabs, {
5568
+ rules,
5569
+ activeId: active,
5570
+ liveId: store.activeRuleId,
5571
+ p,
5572
+ onSelect: selectRule,
5573
+ onAdd: addRule
5574
+ }),
5575
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5576
+ className: "dab-rule-panel",
5577
+ role: "tabpanel",
5578
+ id: active === null ? void 0 : `dab-rule-panel-${active}`,
5579
+ "aria-labelledby": active === null ? void 0 : `dab-rule-tab-${active}`,
5580
+ children: (() => {
5581
+ const idx = rules.findIndex((r) => r.id === active);
5582
+ const rule = idx < 0 ? void 0 : rules[idx];
5583
+ if (rule === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5400
5584
  className: "dab-hint",
5401
- children: t("autoExtract")
5402
- })]
5403
- }),
5404
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5405
- className: "dab-rules",
5406
- children: rules.map((rule, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RuleCard, {
5585
+ style: { padding: "4px 2px" },
5586
+ children: t("ruleNoneHint")
5587
+ });
5588
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RuleCard, {
5407
5589
  p,
5408
5590
  rule,
5409
- index: i,
5591
+ index: idx,
5410
5592
  total: rules.length,
5411
5593
  liveIndex: rule.id === store.activeRuleId ? store.rotIndex : -1,
5412
5594
  active: rule.id === store.activeRuleId,
5413
- notify
5414
- }, rule.id))
5415
- }),
5416
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5417
- type: "button",
5418
- className: "dab-btn dab-btn-primary",
5419
- style: { marginTop: 12 },
5420
- onClick: () => {
5421
- p.addRule();
5422
- },
5423
- children: ["+ ", t("ruleAdd")]
5595
+ notify,
5596
+ sel: selIdxOf(rule.id),
5597
+ onSel: (i) => setSelIdxOf(rule.id, i),
5598
+ onRemove: () => removeRule(rule.id)
5599
+ }, rule.id);
5600
+ })()
5601
+ })
5602
+ ]
5603
+ })] });
5604
+ }
5605
+ /**
5606
+ * The rule tab strip: every rule is one tab, and the tab at the end appends a
5607
+ * new rule — so what the user browses and what they add to live in one row.
5608
+ *
5609
+ * A tab carries what the card head used to: its number, a thumbnail of the first
5610
+ * image, the match string, and two states that are worth knowing WITHOUT opening
5611
+ * it — whether the rule is the one in use, and whether it rotates or is off. The
5612
+ * strip scrolls sideways rather than wrapping, because a wrapped strip would put
5613
+ * the page height back in the hands of the rule count, which is the whole thing
5614
+ * this layout exists to prevent.
5615
+ */
5616
+ function RulesTabs({ rules, activeId, liveId, p, onSelect, onAdd }) {
5617
+ const { t } = p;
5618
+ const items = (0, react.useRef)([]);
5619
+ const [pick, setPick] = (0, react.useState)(false);
5620
+ /** Select a rule and bring its tab into view — the strip is wider than the page. */
5621
+ const go = (i) => {
5622
+ const rule = rules[i];
5623
+ if (rule === void 0) return;
5624
+ onSelect(rule.id);
5625
+ items.current[i]?.scrollIntoView({
5626
+ block: "nearest",
5627
+ inline: "nearest"
5628
+ });
5629
+ };
5630
+ const onKeys = (e) => {
5631
+ const i = rules.findIndex((r) => r.id === activeId);
5632
+ if (i < 0) return;
5633
+ if (e.key === "ArrowRight") {
5634
+ e.preventDefault();
5635
+ go(Math.min(i + 1, rules.length - 1));
5636
+ } else if (e.key === "ArrowLeft") {
5637
+ e.preventDefault();
5638
+ go(Math.max(i - 1, 0));
5639
+ } else if (e.key === "Home") {
5640
+ e.preventDefault();
5641
+ go(0);
5642
+ } else if (e.key === "End") {
5643
+ e.preventDefault();
5644
+ go(rules.length - 1);
5645
+ }
5646
+ };
5647
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5648
+ className: "dab-tabs-row",
5649
+ children: [
5650
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5651
+ className: "dab-tabs",
5652
+ role: "tablist",
5653
+ "aria-label": t("tabListLabel"),
5654
+ onKeyDown: onKeys,
5655
+ children: rules.map((rule, i) => {
5656
+ const on = rule.id === activeId;
5657
+ const live = rule.id === liveId;
5658
+ const thumb = rule.images[0] === void 0 ? null : p.imageOf(rule.images[0].slot);
5659
+ const match = rule.match.trim();
5660
+ const title = [
5661
+ match === "" ? t("tabUnnamed") : match,
5662
+ `${rule.images.length}`,
5663
+ live ? t("tabLive") : null,
5664
+ rule.images.length >= 2 && rule.rotate.enabled ? t("tabRotating") : null,
5665
+ rule.enabled ? null : t("tabOff")
5666
+ ].filter((x) => x !== null).join(" · ");
5667
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5668
+ type: "button",
5669
+ role: "tab",
5670
+ "aria-selected": on,
5671
+ id: `dab-rule-tab-${rule.id}`,
5672
+ "aria-controls": `dab-rule-panel-${rule.id}`,
5673
+ tabIndex: on ? 0 : -1,
5674
+ ref: (el) => {
5675
+ items.current[i] = el;
5676
+ },
5677
+ className: `dab-tab${on ? " is-active" : ""}${rule.enabled ? "" : " is-off"}${live ? " is-live" : ""}`,
5678
+ title,
5679
+ onClick: () => go(i),
5680
+ children: [
5681
+ thumb === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5682
+ className: "dab-tab-thumb",
5683
+ src: thumb,
5684
+ alt: "",
5685
+ draggable: false
5686
+ }),
5687
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5688
+ className: "dab-tab-num",
5689
+ children: i + 1
5690
+ }),
5691
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5692
+ className: "dab-tab-match",
5693
+ children: match === "" ? t("tabUnnamed") : match
5694
+ }),
5695
+ rule.enabled && live ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5696
+ className: "dab-tab-live",
5697
+ title: t("tabLive")
5698
+ }) : null,
5699
+ rule.enabled && rule.images.length >= 2 && rule.rotate.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5700
+ className: "dab-tab-rot",
5701
+ title: t("tabRotating"),
5702
+ children: "⟳"
5703
+ }) : null
5704
+ ]
5705
+ }, rule.id);
5424
5706
  })
5425
- ]
5426
- })
5427
- ] });
5707
+ }),
5708
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5709
+ type: "button",
5710
+ className: "dab-tab dab-tab-icon",
5711
+ onClick: onAdd,
5712
+ title: t("ruleAdd"),
5713
+ "aria-label": t("ruleAdd"),
5714
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlusIcon, { size: 16 })
5715
+ }),
5716
+ rules.length > 3 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5717
+ className: "dab-pick",
5718
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5719
+ type: "button",
5720
+ className: "dab-tab dab-tab-icon",
5721
+ "aria-haspopup": "listbox",
5722
+ "aria-expanded": pick,
5723
+ title: t("tabPick"),
5724
+ "aria-label": t("tabPick"),
5725
+ onClick: () => setPick((o) => !o),
5726
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDownIcon, { size: 16 })
5727
+ }), pick ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5728
+ className: "dab-pick-scrim",
5729
+ onClick: () => setPick(false)
5730
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5731
+ className: "dab-pick-menu",
5732
+ role: "listbox",
5733
+ "aria-label": t("tabMore"),
5734
+ children: rules.map((rule, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5735
+ type: "button",
5736
+ role: "option",
5737
+ "aria-selected": rule.id === activeId,
5738
+ className: `dab-pick-item${rule.id === activeId ? " is-active" : ""}`,
5739
+ onClick: () => {
5740
+ setPick(false);
5741
+ go(i);
5742
+ },
5743
+ children: [
5744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5745
+ className: "dab-tab-num",
5746
+ children: i + 1
5747
+ }),
5748
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5749
+ className: "dab-pick-match",
5750
+ children: rule.match.trim() === "" ? t("tabUnnamed") : rule.match
5751
+ }),
5752
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5753
+ className: "dab-pick-n",
5754
+ children: rule.images.length
5755
+ })
5756
+ ]
5757
+ }, rule.id))
5758
+ })] }) : null]
5759
+ }) : null
5760
+ ]
5761
+ });
5428
5762
  }
5429
- function RuleCard({ p, rule, index, total, active, liveIndex, notify }) {
5763
+ function RuleCard({ p, rule, index, total, active, liveIndex, notify, sel, onSel, onRemove }) {
5430
5764
  const { t } = p;
5431
- const [open, setOpen] = (0, react.useState)(active);
5432
5765
  const [dragOver, setDragOver] = (0, react.useState)(false);
5433
5766
  const pendingDrag = (0, react.useRef)(null);
5434
5767
  /** Insertion slot of the drag in flight (index of the tile it goes BEFORE). */
@@ -5444,10 +5777,11 @@ window.__ModuleLoader__.load({
5444
5777
  const [pickerOpen, setPickerOpen] = (0, react.useState)(false);
5445
5778
  const [extracting, setExtracting] = (0, react.useState)(false);
5446
5779
  const [secDraft, setSecDraft] = (0, react.useState)(null);
5780
+ /** Two-step delete: this card is the only place the rule can be removed from. */
5781
+ const [confirmRemove, setConfirmRemove] = (0, react.useState)(false);
5447
5782
  /** Whether the next file picked replaces the selected image or is added. */
5448
5783
  const [replace, setReplace] = (0, react.useState)(false);
5449
5784
  const fileRef = (0, react.useRef)(null);
5450
- const [sel, setSel] = (0, react.useState)(0);
5451
5785
  const selIdx = rule.images.length === 0 ? 0 : Math.min(sel, rule.images.length - 1);
5452
5786
  const image = rule.images[selIdx];
5453
5787
  const url = image === void 0 ? null : p.imageOf(image.slot);
@@ -5459,9 +5793,8 @@ window.__ModuleLoader__.load({
5459
5793
  const [h, s, l] = ownColor ?? SEED_COLOR;
5460
5794
  const wheel = hslToHsv(h, s, l);
5461
5795
  (0, react.useEffect)(() => {
5462
- if (open) p.loadRuleImages(rule.id);
5796
+ p.loadRuleImages(rule.id);
5463
5797
  }, [
5464
- open,
5465
5798
  rule.id,
5466
5799
  rule.images.length,
5467
5800
  p
@@ -5493,7 +5826,7 @@ window.__ModuleLoader__.load({
5493
5826
  pending--;
5494
5827
  if (pending === 0) {
5495
5828
  p.addRuleImages(rule.id, out.filter((x) => typeof x === "string"));
5496
- setSel(first);
5829
+ onSel(first);
5497
5830
  }
5498
5831
  });
5499
5832
  });
@@ -5528,7 +5861,7 @@ window.__ModuleLoader__.load({
5528
5861
  setUrlBusy(false);
5529
5862
  setUrlOpen(false);
5530
5863
  setUrlVal("");
5531
- setSel(first);
5864
+ onSel(first);
5532
5865
  };
5533
5866
  const onExtract = async () => {
5534
5867
  if (url === null || extracting || image === void 0) return;
@@ -5618,13 +5951,13 @@ window.__ModuleLoader__.load({
5618
5951
  const to = end.at > pen.from ? end.at - 1 : end.at;
5619
5952
  if (to === pen.from) return;
5620
5953
  p.moveRuleImageTo(rule.id, pen.slot, to);
5621
- setSel(to);
5954
+ onSel(to);
5622
5955
  };
5623
5956
  const removeSel = () => {
5624
5957
  if (selSlot === null) return;
5625
5958
  const at = selIdx;
5626
5959
  p.removeRuleImage(rule.id, selSlot);
5627
- setSel(Math.max(0, Math.min(at, rule.images.length - 2)));
5960
+ onSel(Math.max(0, Math.min(at, rule.images.length - 2)));
5628
5961
  };
5629
5962
  /** Commit the custom dwell time the moment it is typed (seconds → ms). */
5630
5963
  const commitSeconds = () => {
@@ -5640,14 +5973,6 @@ window.__ModuleLoader__.load({
5640
5973
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5641
5974
  className: "dab-rule-head",
5642
5975
  children: [
5643
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5644
- type: "button",
5645
- className: "dab-icon-btn",
5646
- onClick: () => setOpen((o) => !o),
5647
- title: open ? "Collapse" : "Expand",
5648
- "aria-expanded": open,
5649
- children: open ? "▾" : "▸"
5650
- }),
5651
5976
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5652
5977
  className: "dab-rule-num",
5653
5978
  children: index + 1
@@ -5689,14 +6014,25 @@ window.__ModuleLoader__.load({
5689
6014
  }),
5690
6015
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5691
6016
  type: "button",
5692
- className: "dab-icon-btn dab-icon-btn-danger",
5693
- title: t("ruleRemove"),
5694
- onClick: () => p.removeRule(rule.id),
5695
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 13 })
6017
+ className: `dab-icon-btn dab-icon-btn-danger${confirmRemove ? " is-armed" : ""}`,
6018
+ title: confirmRemove ? t("ruleRemoveConfirm") : t("ruleRemove"),
6019
+ onBlur: () => setConfirmRemove(false),
6020
+ onClick: () => {
6021
+ if (!confirmRemove) {
6022
+ setConfirmRemove(true);
6023
+ return;
6024
+ }
6025
+ setConfirmRemove(false);
6026
+ onRemove();
6027
+ },
6028
+ children: confirmRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6029
+ className: "dab-confirm-txt",
6030
+ children: t("ruleRemoveConfirm")
6031
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, { size: 13 })
5696
6032
  })
5697
6033
  ]
5698
6034
  }),
5699
- open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6035
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5700
6036
  className: "dab-rule-body",
5701
6037
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5702
6038
  className: "dab-rule-cols",
@@ -5756,7 +6092,7 @@ window.__ModuleLoader__.load({
5756
6092
  draggedRef.current = false;
5757
6093
  return;
5758
6094
  }
5759
- setSel(i);
6095
+ onSel(i);
5760
6096
  },
5761
6097
  onDoubleClick: () => {
5762
6098
  if (!active) {
@@ -5905,140 +6241,6 @@ window.__ModuleLoader__.load({
5905
6241
  style: { marginTop: 8 },
5906
6242
  children: t("ruleImagePendingHint")
5907
6243
  }) : null,
5908
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5909
- style: { marginTop: 14 },
5910
- children: [
5911
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5912
- className: "dab-rule-section-title",
5913
- children: t("rotTitle")
5914
- }),
5915
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5916
- className: "dab-chip-row",
5917
- style: { alignItems: "center" },
5918
- children: [
5919
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5920
- type: "button",
5921
- className: `dab-toggle${rule.rotate.enabled ? " is-on" : ""}`,
5922
- role: "switch",
5923
- "aria-checked": rule.rotate.enabled,
5924
- title: t("rotEnable"),
5925
- disabled: !rotatable,
5926
- onClick: () => p.setRuleRotation(rule.id, { enabled: !rule.rotate.enabled }),
5927
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
5928
- }),
5929
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5930
- className: "dab-hint",
5931
- style: { padding: 0 },
5932
- children: t("rotEnable")
5933
- }),
5934
- !rotatable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5935
- className: "dab-hint",
5936
- style: { padding: 0 },
5937
- children: t("rotNeedTwo")
5938
- }) : null
5939
- ]
5940
- }),
5941
- rotatable && rule.rotate.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5942
- className: "dab-rot-panel",
5943
- children: [
5944
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5945
- className: "dab-rule-section-title",
5946
- children: t("rotEvery")
5947
- }),
5948
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5949
- className: "dab-chip-row",
5950
- children: [
5951
- ROTATE_PRESETS.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5952
- type: "button",
5953
- className: `dab-chip${item.ms === rule.rotate.intervalMs ? " is-active" : ""}`,
5954
- onClick: () => p.setRuleRotation(rule.id, { intervalMs: item.ms }),
5955
- children: t(item.key)
5956
- }, item.ms)),
5957
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5958
- type: "number",
5959
- className: "dab-num dab-num-sec",
5960
- min: 5,
5961
- max: 86400,
5962
- step: 5,
5963
- value: secDraft ?? (preset === void 0 ? Math.round(rule.rotate.intervalMs / 1e3) : ""),
5964
- placeholder: t("rotCustom"),
5965
- onChange: (e) => setSecDraft(e.target.value),
5966
- onBlur: commitSeconds,
5967
- onKeyDown: (e) => {
5968
- if (e.key === "Enter") {
5969
- e.preventDefault();
5970
- commitSeconds();
5971
- }
5972
- }
5973
- }),
5974
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5975
- className: "dab-hint",
5976
- style: { padding: 0 },
5977
- children: t("rotSeconds")
5978
- })
5979
- ]
5980
- }),
5981
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5982
- className: "dab-rule-section-title",
5983
- style: { marginTop: 12 },
5984
- children: t("rotOrder")
5985
- }),
5986
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5987
- className: "dab-chip-row",
5988
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5989
- type: "button",
5990
- className: `dab-chip${rule.rotate.order === "order" ? " is-active" : ""}`,
5991
- onClick: () => p.setRuleRotation(rule.id, { order: "order" }),
5992
- children: t("rotOrderSeq")
5993
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5994
- type: "button",
5995
- className: `dab-chip${rule.rotate.order === "shuffle" ? " is-active" : ""}`,
5996
- onClick: () => p.setRuleRotation(rule.id, { order: "shuffle" }),
5997
- children: t("rotOrderShuffle")
5998
- })]
5999
- }),
6000
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6001
- className: "dab-chip-row",
6002
- style: {
6003
- marginTop: 12,
6004
- alignItems: "center"
6005
- },
6006
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6007
- type: "button",
6008
- className: `dab-toggle${rule.rotate.advanceOnSwitch ? " is-on" : ""}`,
6009
- role: "switch",
6010
- "aria-checked": rule.rotate.advanceOnSwitch,
6011
- title: t("rotOnSwitch"),
6012
- onClick: () => p.setRuleRotation(rule.id, { advanceOnSwitch: !rule.rotate.advanceOnSwitch }),
6013
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
6014
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6015
- className: "dab-hint",
6016
- style: { padding: 0 },
6017
- children: t("rotOnSwitch")
6018
- })]
6019
- }),
6020
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6021
- className: "dab-chip-row",
6022
- style: {
6023
- marginTop: 12,
6024
- alignItems: "center"
6025
- },
6026
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6027
- type: "button",
6028
- className: "dab-btn",
6029
- disabled: !active,
6030
- onClick: () => p.rotateNow(rule.id),
6031
- children: t("rotNext")
6032
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6033
- className: "dab-hint",
6034
- style: { padding: 0 },
6035
- children: active ? `${t("rotShowing")} ${rule.images.length === 0 ? 0 : Math.min(liveIndex + 1, rule.images.length)} / ${rule.images.length}` : t("rotNextInactive")
6036
- })]
6037
- })
6038
- ]
6039
- }) : null
6040
- ]
6041
- }),
6042
6244
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6043
6245
  style: { marginTop: 14 },
6044
6246
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -6109,71 +6311,53 @@ window.__ModuleLoader__.load({
6109
6311
  style: { marginTop: 6 },
6110
6312
  children: t("ruleColorPendingImageHint")
6111
6313
  })
6112
- ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
6113
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6114
- style: {
6115
- display: "flex",
6116
- gap: 16,
6117
- alignItems: "center",
6118
- flexWrap: "wrap"
6119
- },
6120
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorWheel, {
6314
+ ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6315
+ style: {
6316
+ display: "flex",
6317
+ gap: 16,
6318
+ alignItems: "center",
6319
+ flexWrap: "wrap"
6320
+ },
6321
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorWheel, {
6322
+ hue: wheel[0],
6323
+ sat: wheel[1],
6324
+ lit: wheel[2],
6325
+ onChange: onColor
6326
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6327
+ className: "dab-inputs",
6328
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorInputs, {
6121
6329
  hue: wheel[0],
6122
6330
  sat: wheel[1],
6123
6331
  lit: wheel[2],
6124
6332
  onChange: onColor
6125
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6126
- className: "dab-inputs",
6127
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ColorInputs, {
6128
- hue: wheel[0],
6129
- sat: wheel[1],
6130
- lit: wheel[2],
6131
- onChange: onColor
6132
- })
6133
- })]
6134
- }),
6135
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6136
- className: "dab-chip-row",
6137
- style: { marginTop: 12 },
6138
- children: [
6139
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6140
- type: "button",
6141
- className: "dab-btn",
6142
- disabled: url === null || extracting,
6143
- onClick: () => void onExtract(),
6144
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SparkleIcon, { size: 13 }), extracting ? t("extracting") : t("ruleColorExtract")]
6145
- }),
6146
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6147
- type: "button",
6148
- className: "dab-btn",
6149
- disabled: url === null,
6150
- onClick: () => setPickerOpen(true),
6151
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PipetteIcon, { size: 13 }), t("eyedropper")]
6152
- }),
6153
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6154
- type: "button",
6155
- className: "dab-btn dab-btn-ghost",
6156
- onClick: () => setColor(null),
6157
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SunIcon, { size: 13 }), t("ruleColorNone")]
6158
- })
6159
- ]
6160
- }),
6161
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6162
- className: "dab-swatches",
6163
- style: { marginTop: 12 },
6164
- children: PALETTE.map(([sh, ss, sl], i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6333
+ })
6334
+ })]
6335
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6336
+ className: "dab-chip-row",
6337
+ style: { marginTop: 12 },
6338
+ children: [
6339
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6165
6340
  type: "button",
6166
- className: "dab-swatch",
6167
- style: { background: `hsl(${sh} ${Math.round(ss * 100)}% ${Math.round(sl * 100)}%)` },
6168
- title: toHex(hslToRgb(sh, ss, sl)).toUpperCase(),
6169
- onClick: () => setColor([
6170
- sh,
6171
- ss,
6172
- sl
6173
- ])
6174
- }, i))
6175
- })
6176
- ] }),
6341
+ className: "dab-btn",
6342
+ disabled: url === null || extracting,
6343
+ onClick: () => void onExtract(),
6344
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SparkleIcon, { size: 13 }), extracting ? t("extracting") : t("ruleColorExtract")]
6345
+ }),
6346
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6347
+ type: "button",
6348
+ className: "dab-btn",
6349
+ disabled: url === null,
6350
+ onClick: () => setPickerOpen(true),
6351
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PipetteIcon, { size: 13 }), t("eyedropper")]
6352
+ }),
6353
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6354
+ type: "button",
6355
+ className: "dab-btn dab-btn-ghost",
6356
+ onClick: () => setColor(null),
6357
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SunIcon, { size: 13 }), t("ruleColorNone")]
6358
+ })
6359
+ ]
6360
+ })] }),
6177
6361
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6178
6362
  style: { marginTop: 16 },
6179
6363
  children: [
@@ -6201,10 +6385,136 @@ window.__ModuleLoader__.load({
6201
6385
  children: t("ruleBlurHint")
6202
6386
  })
6203
6387
  ]
6388
+ }),
6389
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6390
+ style: { marginTop: 14 },
6391
+ children: [
6392
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6393
+ className: "dab-rule-section-title",
6394
+ children: t("rotTitle")
6395
+ }),
6396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6397
+ className: "dab-chip-row",
6398
+ style: { alignItems: "center" },
6399
+ children: [
6400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6401
+ type: "button",
6402
+ className: `dab-toggle${rule.rotate.enabled ? " is-on" : ""}`,
6403
+ role: "switch",
6404
+ "aria-checked": rule.rotate.enabled,
6405
+ title: t("rotEnable"),
6406
+ disabled: !rotatable,
6407
+ onClick: () => p.setRuleRotation(rule.id, { enabled: !rule.rotate.enabled }),
6408
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
6409
+ }),
6410
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6411
+ className: "dab-hint",
6412
+ style: { padding: 0 },
6413
+ children: t("rotEnable")
6414
+ }),
6415
+ !rotatable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6416
+ className: "dab-hint",
6417
+ style: { padding: 0 },
6418
+ children: t("rotNeedTwo")
6419
+ }) : null,
6420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6421
+ type: "button",
6422
+ className: "dab-btn",
6423
+ disabled: !active || !rotatable,
6424
+ onClick: () => p.rotateNow(rule.id),
6425
+ children: t("rotNext")
6426
+ }),
6427
+ rotatable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6428
+ className: "dab-hint",
6429
+ style: { padding: 0 },
6430
+ children: active ? `${t("rotShowing")} ${Math.min(liveIndex + 1, rule.images.length)} / ${rule.images.length}` : t("rotNextInactive")
6431
+ }) : null
6432
+ ]
6433
+ }),
6434
+ rotatable && rule.rotate.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6435
+ className: "dab-rot-panel",
6436
+ children: [
6437
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6438
+ className: "dab-rule-section-title",
6439
+ children: t("rotEvery")
6440
+ }),
6441
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6442
+ className: "dab-chip-row",
6443
+ children: [
6444
+ ROTATE_PRESETS.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6445
+ type: "button",
6446
+ className: `dab-chip${item.ms === rule.rotate.intervalMs ? " is-active" : ""}`,
6447
+ onClick: () => p.setRuleRotation(rule.id, { intervalMs: item.ms }),
6448
+ children: t(item.key)
6449
+ }, item.ms)),
6450
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6451
+ type: "number",
6452
+ className: "dab-num dab-num-sec",
6453
+ min: 5,
6454
+ max: 86400,
6455
+ step: 5,
6456
+ value: secDraft ?? (preset === void 0 ? Math.round(rule.rotate.intervalMs / 1e3) : ""),
6457
+ placeholder: t("rotCustom"),
6458
+ onChange: (e) => setSecDraft(e.target.value),
6459
+ onBlur: commitSeconds,
6460
+ onKeyDown: (e) => {
6461
+ if (e.key === "Enter") {
6462
+ e.preventDefault();
6463
+ commitSeconds();
6464
+ }
6465
+ }
6466
+ }),
6467
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6468
+ className: "dab-hint",
6469
+ style: { padding: 0 },
6470
+ children: t("rotSeconds")
6471
+ })
6472
+ ]
6473
+ }),
6474
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6475
+ className: "dab-rule-section-title",
6476
+ style: { marginTop: 12 },
6477
+ children: t("rotOrder")
6478
+ }),
6479
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6480
+ className: "dab-chip-row",
6481
+ style: { alignItems: "center" },
6482
+ children: [
6483
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6484
+ type: "button",
6485
+ className: `dab-chip${rule.rotate.order === "order" ? " is-active" : ""}`,
6486
+ onClick: () => p.setRuleRotation(rule.id, { order: "order" }),
6487
+ children: t("rotOrderSeq")
6488
+ }),
6489
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6490
+ type: "button",
6491
+ className: `dab-chip${rule.rotate.order === "shuffle" ? " is-active" : ""}`,
6492
+ onClick: () => p.setRuleRotation(rule.id, { order: "shuffle" }),
6493
+ children: t("rotOrderShuffle")
6494
+ }),
6495
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6496
+ type: "button",
6497
+ className: `dab-toggle${rule.rotate.advanceOnSwitch ? " is-on" : ""}`,
6498
+ role: "switch",
6499
+ "aria-checked": rule.rotate.advanceOnSwitch,
6500
+ title: t("rotOnSwitch"),
6501
+ onClick: () => p.setRuleRotation(rule.id, { advanceOnSwitch: !rule.rotate.advanceOnSwitch }),
6502
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-toggle-knob" })
6503
+ }),
6504
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6505
+ className: "dab-hint",
6506
+ style: { padding: 0 },
6507
+ children: t("rotOnSwitch")
6508
+ })
6509
+ ]
6510
+ })
6511
+ ]
6512
+ }) : null
6513
+ ]
6204
6514
  })
6205
6515
  ] })]
6206
6516
  })
6207
- }) : null,
6517
+ }),
6208
6518
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6209
6519
  ref: fileRef,
6210
6520
  type: "file",
@@ -6263,12 +6573,57 @@ window.__ModuleLoader__.load({
6263
6573
  "ease-out": "trEasingOut",
6264
6574
  "ease-in-out": "trEasingInOut"
6265
6575
  };
6576
+ /**
6577
+ * Failing-hop codes the model watcher can report, mapped to their copy. Only
6578
+ * these are rendered: `waiting` and `fallback` already have their own hints.
6579
+ */
6580
+ const MODEL_NOTE_KEYS = {
6581
+ "no-service": "statusNoteNoService",
6582
+ "no-session": "statusNoteNoSession",
6583
+ "no-projection": "statusNoteNoProjection",
6584
+ "empty-selection": "statusNoteEmptySelection"
6585
+ };
6586
+ /**
6587
+ * The ways the recommended-profile download can fail, mapped to their copy.
6588
+ *
6589
+ * They are worth telling apart because they ask different things of the user and
6590
+ * of whoever maintains the assets repository: nothing answering is a network to
6591
+ * retry, a mirror that answered with something that is not a profile is not
6592
+ * going to start working on a retry, a profile written for a newer plugin is a
6593
+ * "update this build", and too many images is a profile nobody should install.
6594
+ * A failure that names a SLOT is a fifth case and is rendered separately — that
6595
+ * one is the author's file naming, and only the slot tells them which.
6596
+ */
6597
+ const REC_ERROR_KEYS = {
6598
+ "download failed": "recFailNetwork",
6599
+ "not a profile": "recFailNotProfile",
6600
+ "newer version": "recFailNewer",
6601
+ "too many images": "recFailTooMany",
6602
+ "too large": "recFailTooLarge"
6603
+ };
6604
+ /** Size of a download, at the precision a person reads it at. */
6605
+ const fmtBytes = (n) => n >= 1048576 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
6266
6606
  function ProfilePage({ p, notify }) {
6267
6607
  const { t, exportTheme, importTheme, setTransition } = p;
6268
6608
  const importRef = (0, react.useRef)(null);
6269
6609
  p.useStore((s) => s.rev + s.rulesRev);
6270
6610
  const holidays = cfg.holidays;
6271
6611
  const tr = cfg.transition;
6612
+ const store = p.useStore((s) => s);
6613
+ const [text, setText] = (0, react.useState)("");
6614
+ const [touched, setTouched] = (0, react.useState)(false);
6615
+ (0, react.useEffect)(() => {
6616
+ if (!touched) setText(store.model);
6617
+ }, [store.model, touched]);
6618
+ const probe = text.trim();
6619
+ const result = (() => {
6620
+ if (probe === "") return null;
6621
+ const hit = matchRule(cfg.rules, probe);
6622
+ const rule = hit.rule;
6623
+ if (rule === null) return null;
6624
+ const n = cfg.rules.findIndex((r) => r.id === rule.id) + 1;
6625
+ return `${hit.matched ? t("tryoutHit") : t("tryoutFallback")} ${n}`;
6626
+ })();
6272
6627
  const previewRef = (0, react.useRef)(null);
6273
6628
  /** The OS preference, read at render: it vetoes the real switch (see
6274
6629
  * `transitionPlan`), so the card has to say so rather than show a preview of
@@ -6309,6 +6664,47 @@ window.__ModuleLoader__.load({
6309
6664
  notify(t("importFail"), false);
6310
6665
  }
6311
6666
  };
6667
+ const [pending, setPending] = (0, react.useState)(null);
6668
+ const [recBusy, setRecBusy] = (0, react.useState)(false);
6669
+ const [recDone, setRecDone] = (0, react.useState)(0);
6670
+ const [recTotal, setRecTotal] = (0, react.useState)(0);
6671
+ /** What the confirmation describes: countable facts about the download. */
6672
+ const recStats = (() => {
6673
+ if (pending === null) return null;
6674
+ const rules = pending.config?.rules;
6675
+ const images = pending.images ?? {};
6676
+ let bytes = 0;
6677
+ for (const url of Object.values(images)) bytes += url.length;
6678
+ return {
6679
+ rules: Array.isArray(rules) ? rules.length : 0,
6680
+ images: Object.keys(images).length,
6681
+ bytes
6682
+ };
6683
+ })();
6684
+ const onRecommended = async () => {
6685
+ setRecBusy(true);
6686
+ setRecDone(0);
6687
+ setRecTotal(0);
6688
+ const res = await p.fetchRecommended((done, total) => {
6689
+ setRecDone(done);
6690
+ setRecTotal(total);
6691
+ });
6692
+ setRecBusy(false);
6693
+ if (!res.ok) {
6694
+ notify(res.slot === void 0 ? t(REC_ERROR_KEYS[res.error] ?? "recFailNetwork") : `${t("recFailImage")} ${res.slot}`, false);
6695
+ return;
6696
+ }
6697
+ setPending(res.preset);
6698
+ };
6699
+ const onConfirmRecommended = async () => {
6700
+ const preset = pending;
6701
+ if (preset === null) return;
6702
+ setRecBusy(true);
6703
+ const ok = await p.applyRecommended(preset);
6704
+ setRecBusy(false);
6705
+ setPending(null);
6706
+ notify(ok ? t("recDone") : t("recFailApply"), ok);
6707
+ };
6312
6708
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
6313
6709
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
6314
6710
  className: "dab-head dab-rise",
@@ -6440,70 +6836,210 @@ window.__ModuleLoader__.load({
6440
6836
  }) : null
6441
6837
  ]
6442
6838
  }),
6839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6840
+ className: "dab-card dab-rise",
6841
+ style: { "--d": 2 },
6842
+ children: [
6843
+ store.hostStale ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6844
+ className: "dab-hint dab-stale",
6845
+ style: { marginBottom: 10 },
6846
+ children: t("hostStaleHint")
6847
+ }) : null,
6848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6849
+ className: "dab-swatch-title",
6850
+ children: t("tryoutTitle")
6851
+ }),
6852
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6853
+ style: {
6854
+ marginTop: 10,
6855
+ display: "flex",
6856
+ flexWrap: "wrap",
6857
+ gap: 8,
6858
+ alignItems: "center"
6859
+ },
6860
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6861
+ type: "text",
6862
+ className: "dab-urlinput",
6863
+ value: text,
6864
+ placeholder: t("tryoutPlaceholder"),
6865
+ onChange: (e) => {
6866
+ setTouched(true);
6867
+ setText(e.target.value);
6868
+ }
6869
+ }), store.model !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6870
+ type: "button",
6871
+ className: "dab-btn",
6872
+ onClick: () => {
6873
+ setTouched(true);
6874
+ setText(store.model);
6875
+ },
6876
+ children: t("tryoutUseCurrent")
6877
+ }) : null]
6878
+ }),
6879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6880
+ className: "dab-hint",
6881
+ style: { marginTop: 9 },
6882
+ children: probe === "" ? t("tryoutEmpty") : result === null ? t("tryoutNone") : result
6883
+ }),
6884
+ store.model === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6885
+ className: "dab-hint",
6886
+ style: { marginTop: 6 },
6887
+ children: t("statusUnknownHint")
6888
+ }) : null,
6889
+ store.model !== "" && store.modelSource === "default" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6890
+ className: "dab-hint",
6891
+ style: { marginTop: 6 },
6892
+ children: t("statusSourceDefaultHint")
6893
+ }) : null,
6894
+ MODEL_NOTE_KEYS[store.modelNote] !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6895
+ className: "dab-hint",
6896
+ style: { marginTop: 6 },
6897
+ children: t(MODEL_NOTE_KEYS[store.modelNote])
6898
+ }) : null
6899
+ ]
6900
+ }),
6443
6901
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6444
6902
  className: "dab-profile-grid dab-rise",
6445
6903
  style: { "--d": 3 },
6446
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6447
- className: "dab-card dab-card-hover",
6448
- children: [
6449
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6450
- className: "dab-profile-ico",
6451
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, { size: 17 })
6452
- }),
6453
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6454
- className: "dab-profile-title",
6455
- children: t("exportCardTitle")
6456
- }),
6457
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6458
- className: "dab-profile-desc",
6459
- children: t("exportCardDesc")
6460
- }),
6461
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6462
- type: "button",
6463
- className: "dab-btn dab-btn-primary",
6464
- onClick: () => {
6465
- exportTheme();
6466
- notify(t("toastExportDone"));
6904
+ children: [
6905
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6906
+ className: "dab-card dab-card-hover",
6907
+ children: [
6908
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6909
+ className: "dab-profile-ico",
6910
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SparkleIcon, { size: 17 })
6911
+ }),
6912
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6913
+ className: "dab-profile-title",
6914
+ children: t("recCardTitle")
6915
+ }),
6916
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6917
+ className: "dab-profile-desc",
6918
+ children: t("recCardDesc")
6919
+ }),
6920
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6921
+ type: "button",
6922
+ className: "dab-btn dab-btn-primary",
6923
+ disabled: recBusy || store.hostStale,
6924
+ title: store.hostStale ? t("hostStaleHint") : t("recAdopt"),
6925
+ onClick: () => void onRecommended(),
6926
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SparkleIcon, { size: 14 }), !recBusy ? t("recAdopt") : recTotal > 0 ? `${t("recBusy")} ${recDone}/${recTotal}` : t("recBusy")]
6927
+ })
6928
+ ]
6929
+ }),
6930
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6931
+ className: "dab-card dab-card-hover",
6932
+ children: [
6933
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6934
+ className: "dab-profile-ico",
6935
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, { size: 17 })
6936
+ }),
6937
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6938
+ className: "dab-profile-title",
6939
+ children: t("exportCardTitle")
6940
+ }),
6941
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6942
+ className: "dab-profile-desc",
6943
+ children: t("exportCardDesc")
6944
+ }),
6945
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6946
+ type: "button",
6947
+ className: "dab-btn dab-btn-primary",
6948
+ onClick: () => {
6949
+ exportTheme();
6950
+ notify(t("toastExportDone"));
6951
+ },
6952
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, { size: 14 }), t("exportTheme")]
6953
+ })
6954
+ ]
6955
+ }),
6956
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6957
+ className: "dab-card dab-card-hover",
6958
+ children: [
6959
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6960
+ className: "dab-profile-ico",
6961
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 17 })
6962
+ }),
6963
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6964
+ className: "dab-profile-title",
6965
+ children: t("importCardTitle")
6966
+ }),
6967
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6968
+ className: "dab-profile-desc",
6969
+ children: t("importCardDesc")
6970
+ }),
6971
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6972
+ type: "button",
6973
+ className: "dab-btn",
6974
+ onClick: () => importRef.current?.click(),
6975
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 14 }), t("importTheme")]
6976
+ }),
6977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6978
+ ref: importRef,
6979
+ type: "file",
6980
+ accept: "application/json,.json",
6981
+ style: { display: "none" },
6982
+ onChange: (e) => {
6983
+ const f = e.target.files?.[0];
6984
+ if (!f) return;
6985
+ onImport(f);
6986
+ e.target.value = "";
6987
+ }
6988
+ })
6989
+ ]
6990
+ })
6991
+ ]
6992
+ }),
6993
+ pending !== null && recStats !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Portal, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6994
+ className: "dab-overlay",
6995
+ onClick: (e) => {
6996
+ if (e.target === e.currentTarget && !recBusy) setPending(null);
6997
+ },
6998
+ children: [
6999
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7000
+ className: "dab-overlay-title",
7001
+ children: t("recConfirmTitle")
7002
+ }),
7003
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7004
+ className: "dab-modal-card",
7005
+ style: {
7006
+ maxWidth: "min(92vw, 460px)",
7007
+ textAlign: "center"
7008
+ },
7009
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7010
+ className: "dab-overlay-hint",
7011
+ style: { lineHeight: 1.7 },
7012
+ children: t("recConfirmBody")
7013
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7014
+ className: "dab-overlay-hint",
7015
+ style: {
7016
+ marginTop: 10,
7017
+ fontFamily: "var(--dab-mono, monospace)"
6467
7018
  },
6468
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, { size: 14 }), t("exportTheme")]
6469
- })
6470
- ]
6471
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
6472
- className: "dab-card dab-card-hover",
6473
- children: [
6474
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6475
- className: "dab-profile-ico",
6476
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 17 })
6477
- }),
6478
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6479
- className: "dab-profile-title",
6480
- children: t("importCardTitle")
6481
- }),
6482
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6483
- className: "dab-profile-desc",
6484
- children: t("importCardDesc")
6485
- }),
6486
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7019
+ children: `${recStats.rules} ${t("recStatRules")} · ${recStats.images} ${t("recStatImages")} · ${fmtBytes(recStats.bytes)}`
7020
+ })]
7021
+ }),
7022
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7023
+ style: {
7024
+ display: "flex",
7025
+ gap: 10
7026
+ },
7027
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6487
7028
  type: "button",
6488
7029
  className: "dab-btn",
6489
- onClick: () => importRef.current?.click(),
6490
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 14 }), t("importTheme")]
6491
- }),
6492
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
6493
- ref: importRef,
6494
- type: "file",
6495
- accept: "application/json,.json",
6496
- style: { display: "none" },
6497
- onChange: (e) => {
6498
- const f = e.target.files?.[0];
6499
- if (!f) return;
6500
- onImport(f);
6501
- e.target.value = "";
6502
- }
6503
- })
6504
- ]
6505
- })]
6506
- }),
7030
+ disabled: recBusy,
7031
+ onClick: () => setPending(null),
7032
+ children: t("recConfirmCancel")
7033
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7034
+ type: "button",
7035
+ className: "dab-btn dab-btn-danger",
7036
+ disabled: recBusy,
7037
+ onClick: () => void onConfirmRecommended(),
7038
+ children: t("recConfirmOk")
7039
+ })]
7040
+ })
7041
+ ]
7042
+ }) }) : null,
6507
7043
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("footer", {
6508
7044
  className: "dab-footer dab-rise",
6509
7045
  style: { "--d": 4 },
@@ -6637,6 +7173,33 @@ window.__ModuleLoader__.load({
6637
7173
  "connection"
6638
7174
  ];
6639
7175
  const CUSTOM_ID = "custom-color";
7176
+ /**
7177
+ * Ceiling on what one recommended profile may add up to, across every wallpaper.
7178
+ *
7179
+ * The node half caps each file and the number of files, but it answers one
7180
+ * independent call at a time and never sees a running total — the client is the
7181
+ * only side that does, so the total is bounded here. The real profile is 2.2 MB
7182
+ * of images across seven of them, so this is generous by design: it exists to
7183
+ * stop a config that names sixty-four eight-megabyte slots from turning one
7184
+ * button press into half a gigabyte, not to second-guess the author.
7185
+ */
7186
+ const PRESET_TOTAL_MAX = 33554432;
7187
+ /**
7188
+ * How many wallpapers of a profile are in flight at once.
7189
+ *
7190
+ * Measured rather than guessed. The real seven-file profile, fetched against the
7191
+ * live asset host: 8.3 s one at a time, 4.8 s with four in flight (25 s when the
7192
+ * CDN edge was still cold). What is being paid is per-request latency, and the
7193
+ * host speaks HTTP/2 — the bytes are identical either way and the connection is
7194
+ * reused — so overlapping them buys wall-clock time and risks nothing but a
7195
+ * little contention.
7196
+ *
7197
+ * Four rather than all seven: a burst that wide competes for the same bandwidth
7198
+ * and makes the per-file timeout harder to reason about, and four already hides
7199
+ * most of the latency. Whichever worker finishes first takes the next file, so
7200
+ * the pool stays busy to the end rather than idling on the largest one.
7201
+ */
7202
+ const PRESET_CONCURRENCY = 4;
6640
7203
  function apply(ctx) {
6641
7204
  initRpc((endpoint, payload) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload).then((res) => res));
6642
7205
  let customDispose = null;
@@ -6982,10 +7545,12 @@ window.__ModuleLoader__.load({
6982
7545
  /**
6983
7546
  * The slot of the holiday that could paint TODAY, or null.
6984
7547
  *
6985
- * Gated on the CALENDAR, not merely on the switch. Now that the override is on
6986
- * by default and invisible, a profile that simply is not on a holiday must not
6987
- * pull ~730 KB of bundled art (≈970 KB of base64 over this channel) at every
6988
- * boot just because the feature is armed.
7548
+ * Gated on the CALENDAR, not merely on the switch — and this gate is worth more
7549
+ * than it used to be. The art does not ship in the package any more: the first
7550
+ * read on the day sends the node half to the network, so a profile that is
7551
+ * simply not on a holiday must not merely avoid ~730 KB of base64 over this
7552
+ * channel, it must avoid starting a download at all. On every other day this
7553
+ * returns null and nothing is fetched, cached or paid for.
6989
7554
  */
6990
7555
  const todayHolidaySlot = () => {
6991
7556
  if (!cfg.holidays.enabled) return null;
@@ -7163,6 +7728,53 @@ window.__ModuleLoader__.load({
7163
7728
  matched: activeMatched
7164
7729
  };
7165
7730
  };
7731
+ /**
7732
+ * Write a whole theme file into the store: its rules, its wallpapers, nothing
7733
+ * else. The ONE door both imports come through — the file the user picked and
7734
+ * the profile downloaded from the project's asset host — so a downloaded
7735
+ * profile cannot arrive by a laxer route than a hand-picked file does.
7736
+ *
7737
+ * `wipeFirst` is the entire difference between them, and it belongs to the
7738
+ * recommended profile alone. `nextSlot` hands slots out as `m1`, `m2`, … — a
7739
+ * counter, not a random token — so a downloaded profile and the store it lands
7740
+ * in collide on those names as a matter of course rather than by coincidence.
7741
+ * Emptying the store first is what makes "replace everything with this"
7742
+ * literally true, instead of overwriting whichever of the user's own pictures
7743
+ * happened to share a name. The file path keeps the older behaviour: it
7744
+ * replaces the rules but pre-emptively deletes nothing.
7745
+ *
7746
+ * The wipe happens HERE rather than before the download, and that ordering is
7747
+ * the one thing worth being pedantic about: an unreachable CDN must not cost
7748
+ * the user their configuration on the way to a profile that never arrived. By
7749
+ * the time this runs the bytes are already in hand, so the caller has nothing
7750
+ * left to fail at.
7751
+ */
7752
+ const applyThemeFile = async (data, wipeFirst) => {
7753
+ if (typeof data.version === "number" && data.version > 6) return false;
7754
+ if (typeof data.config !== "object" || data.config === null) return false;
7755
+ if (wipeFirst) {
7756
+ await resetStore();
7757
+ resetConfig();
7758
+ }
7759
+ adoptConfig(data.config);
7760
+ const incoming = data.images ?? {};
7761
+ const keep = /* @__PURE__ */ new Set();
7762
+ for (const slot of cfg.rules.flatMap(ruleSlots)) {
7763
+ keep.add(slot);
7764
+ const raw = incoming[slot];
7765
+ if (typeof raw === "string" && /^data:image\//.test(raw)) {
7766
+ setImage(slot, raw);
7767
+ writeImage(slot, raw);
7768
+ } else {
7769
+ setImage(slot, null);
7770
+ deleteImage(slot);
7771
+ }
7772
+ }
7773
+ for (const slot of Object.keys(incoming)) if (!keep.has(slot) && /^[A-Za-z0-9_-]{1,32}$/.test(slot)) deleteImage(slot);
7774
+ persistConfig();
7775
+ applyActive();
7776
+ return true;
7777
+ };
7166
7778
  const sectionInject = (actions) => {
7167
7779
  bound = actions;
7168
7780
  sync();
@@ -7490,29 +8102,93 @@ window.__ModuleLoader__.load({
7490
8102
  try {
7491
8103
  const data = JSON.parse(await file.text());
7492
8104
  if (!data || typeof data !== "object") return false;
7493
- const d = data;
7494
- if (typeof d.config !== "object" || d.config === null) return false;
7495
- adoptConfig(d.config);
7496
- const incoming = d.images ?? {};
7497
- const keep = /* @__PURE__ */ new Set();
7498
- for (const slot of cfg.rules.flatMap(ruleSlots)) {
7499
- keep.add(slot);
7500
- const raw = incoming[slot];
7501
- if (typeof raw === "string" && /^data:image\//.test(raw)) {
7502
- setImage(slot, raw);
7503
- writeImage(slot, raw);
7504
- } else {
7505
- setImage(slot, null);
7506
- deleteImage(slot);
7507
- }
7508
- }
7509
- for (const slot of Object.keys(incoming)) if (!keep.has(slot) && /^[A-Za-z0-9_-]{1,32}$/.test(slot)) deleteImage(slot);
7510
- persistConfig();
7511
- applyActive();
7512
- return true;
8105
+ return await applyThemeFile(data, false);
7513
8106
  } catch {
7514
8107
  return false;
7515
8108
  }
8109
+ },
8110
+ /**
8111
+ * Fetch the project's recommended profile. Downloads only.
8112
+ *
8113
+ * Split from applying it on purpose: the page has to ask before anything is
8114
+ * destroyed, and a confirmation can only describe what is actually in hand
8115
+ * — "7 rules, 7 wallpapers, 2.9 MB" — which means the download comes first
8116
+ * and the question second.
8117
+ *
8118
+ * The profile is a DIRECTORY now, so this is a pool rather than one call:
8119
+ * the manifest and config say which slots exist, and each wallpaper is one
8120
+ * request, up to `PRESET_CONCURRENCY` of them at a time. What the fan-out
8121
+ * buys is wall-clock time — the real profile measured 8.3 s fetched one at
8122
+ * a time against 4.8 s with four in flight — while each file keeps its own
8123
+ * mirror fallback, its own validation, and its own slot name in a failure.
8124
+ *
8125
+ * STRICT, and the pool is what makes that a decision rather than a
8126
+ * formality: the first wallpaper that cannot be had stops the queue, so the
8127
+ * files nobody has asked for yet are never asked for. Whatever was already
8128
+ * in flight finishes and is thrown away. Nothing has been touched at this
8129
+ * point, so "the author renamed a file" arrives as an error naming the file
8130
+ * rather than as a profile that is quietly missing a picture — and that
8131
+ * loudness is the whole reason this feature is allowed to follow a branch.
8132
+ */
8133
+ fetchRecommended: async (onProgress) => {
8134
+ const head = await fetchPresetConfig();
8135
+ if (!head.ok) return {
8136
+ ok: false,
8137
+ error: head.error
8138
+ };
8139
+ const slots = head.slots;
8140
+ const images = {};
8141
+ let next = 0;
8142
+ let done = 0;
8143
+ let bytes = 0;
8144
+ /** Latched by the first worker to fail, so the rest stop asking. */
8145
+ let stopped = false;
8146
+ onProgress?.(0, slots.length);
8147
+ const worker = async () => {
8148
+ for (;;) {
8149
+ if (stopped || next >= slots.length) return null;
8150
+ const slot = slots[next++];
8151
+ const got = await fetchPresetImage(slot);
8152
+ if (!got.ok) {
8153
+ stopped = true;
8154
+ return {
8155
+ error: got.error,
8156
+ slot
8157
+ };
8158
+ }
8159
+ images[slot] = got.dataUrl;
8160
+ bytes += got.dataUrl.length;
8161
+ if (bytes > PRESET_TOTAL_MAX) {
8162
+ stopped = true;
8163
+ return { error: "too large" };
8164
+ }
8165
+ onProgress?.(++done, slots.length);
8166
+ }
8167
+ };
8168
+ const failure = (await Promise.all(Array.from({ length: Math.min(PRESET_CONCURRENCY, slots.length) }, worker))).find((r) => r !== null) ?? null;
8169
+ if (failure !== null) return {
8170
+ ok: false,
8171
+ error: failure.error,
8172
+ slot: failure.slot
8173
+ };
8174
+ return {
8175
+ ok: true,
8176
+ preset: {
8177
+ version: head.version,
8178
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8179
+ config: head.config,
8180
+ images
8181
+ }
8182
+ };
8183
+ },
8184
+ /**
8185
+ * Replace EVERYTHING with a downloaded profile: empty the store, then write
8186
+ * the profile into it. Irreversible by design, which is why the page asks
8187
+ * first and why nothing here is offered as an undo.
8188
+ */
8189
+ applyRecommended: async (preset) => {
8190
+ if (!canWriteConfig()) return false;
8191
+ return await applyThemeFile(preset, true);
7516
8192
  }
7517
8193
  };
7518
8194
  };