dsh-background-by-model 0.7.6 → 0.7.8

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
@@ -648,11 +648,11 @@ window.__ModuleLoader__.load({
648
648
  "input"
649
649
  ];
650
650
  const DEFAULT_PART_OPACITIES = {
651
- bg: .85,
652
- sidebar: .93,
651
+ bg: 0,
652
+ sidebar: .1,
653
653
  card: 1,
654
- code: 1,
655
- input: 1
654
+ code: .81,
655
+ input: .88
656
656
  };
657
657
  /**
658
658
  * Interface blur (px, 0..60), global (Interface page):
@@ -683,7 +683,7 @@ window.__ModuleLoader__.load({
683
683
  chat: 0,
684
684
  trajectory: 0,
685
685
  rightbar: 0,
686
- input: 0
686
+ input: 60
687
687
  };
688
688
  /** Longest allowed switch duration; 0 is a hard cut. */
689
689
  const TRANSITION_MAX_MS = 3e3;
@@ -742,6 +742,9 @@ window.__ModuleLoader__.load({
742
742
  function ruleCanPaint(rule) {
743
743
  return rule.enabled && (ruleSlots(rule).length > 0 || rule.color !== null);
744
744
  }
745
+ const DEFAULT_CHAT_TEXT_OPACITY = .35;
746
+ const DEFAULT_TRAJECTORY_OPACITY = .73;
747
+ const DEFAULT_RIGHTBAR_OPACITY = .45;
745
748
  /**
746
749
  * `#RRGGBB` → the `[h, s, l]` triple every rule stores (`s` and `l` in 0..1), or
747
750
  * null when the string is not a hex color.
@@ -810,20 +813,58 @@ window.__ModuleLoader__.load({
810
813
  function defaultHolidayRules() {
811
814
  return HOLIDAYS.map(defaultHolidayRule);
812
815
  }
813
- /** A default config with independent nested objects (never hand out the shared ones). */
816
+ /**
817
+ * A rule with nothing in it yet: no picture, no match string, no color.
818
+ *
819
+ * Two states need this factory and they are the same state — a fresh install
820
+ * (see `freshThemeConfig` and `normalizeConfig`), and the "add rule" tile one
821
+ * entry further down the list.
822
+ *
823
+ * Such a rule is deliberately valid rather than half-built: it is skipped by
824
+ * matching until it has something of its own to paint (`ruleCanPaint` — a picture,
825
+ * or a theme color), which is what makes a rule nobody has filled in yet invisible
826
+ * instead of letting an empty background steal the fallback from rule 1.
827
+ *
828
+ * It used to be born with an allocated slot and an empty image entry, which is
829
+ * what put a blank tile at position 1 of a new rule's strip — and, worse, stopped
830
+ * the picture the user added next from being the first one (a rule paints its
831
+ * FIRST image). The slot is allocated when the first picture arrives.
832
+ */
833
+ function blankRule(id) {
834
+ return {
835
+ id,
836
+ images: [],
837
+ match: "",
838
+ enabled: true,
839
+ color: null,
840
+ wallpaperOpacity: 1,
841
+ blur: 0,
842
+ rotate: defaultRotation()
843
+ };
844
+ }
845
+ /**
846
+ * A default config with independent nested objects (never hand out the shared ones).
847
+ *
848
+ * The rule list is seeded with one blank rule rather than left empty: "no rules at
849
+ * all" is not a state the panel should ever open in, because the first thing a new
850
+ * user has to do is name a match string and drop a picture in — and the drop target
851
+ * for that is the rule card, which an empty list has no way to show. The empty
852
+ * state is still reachable (deleting the last rule is a thing the user can mean),
853
+ * and is described by `ruleNoneHint`.
854
+ */
814
855
  function freshThemeConfig() {
815
856
  return {
816
- rules: [],
857
+ rules: [blankRule("r1")],
817
858
  opacities: { ...DEFAULT_PART_OPACITIES },
818
859
  blurs: { ...DEFAULT_PART_BLURS },
819
860
  transition: defaultTransition(),
820
861
  settingsOpacity: 1,
821
- chatTextOpacity: 0,
822
- trajectoryOpacity: 1,
823
- rightbarOpacity: null,
862
+ chatTextOpacity: DEFAULT_CHAT_TEXT_OPACITY,
863
+ trajectoryOpacity: DEFAULT_TRAJECTORY_OPACITY,
864
+ rightbarOpacity: DEFAULT_RIGHTBAR_OPACITY,
824
865
  autoExtract: true,
825
866
  holidays: {
826
- enabled: true,
867
+ enabled: false,
827
868
  items: defaultHolidayRules()
828
869
  }
829
870
  };
@@ -1058,14 +1099,36 @@ window.__ModuleLoader__.load({
1058
1099
  if (typeof id === "string") stored.set(id, item);
1059
1100
  }
1060
1101
  return {
1061
- enabled: r.enabled !== false,
1102
+ enabled: typeof r.enabled === "boolean" ? r.enabled : false,
1062
1103
  items: HOLIDAYS.map((def) => normalizeHolidayRule(def, stored.get(def.id)))
1063
1104
  };
1064
1105
  }
1106
+ /**
1107
+ * The file-preview panel's own opacity.
1108
+ *
1109
+ * Three states, and the middle one is why this is a function rather than the
1110
+ * one-line fallback every other field gets: a real number is the card's own
1111
+ * value, `null` is "never owned, so follow `opacities.bg`", and a KEY that is not
1112
+ * there at all — a hand-written config, or one written before the option existed
1113
+ * — is the shipped profile's 45% (see DEFAULT_RIGHTBAR_OPACITY).
1114
+ *
1115
+ * `null` has to survive as ITSELF instead of falling through to the default:
1116
+ * every release so far has written it — a panel the user never dragged is
1117
+ * persisted as an explicit `null` — so treating it as "missing" would hand the
1118
+ * shipped 45% to every configuration whose preview panel was following the main
1119
+ * background, a silent change to a setup nobody asked to change. It is also
1120
+ * unreachable from the UI (no control returns to it); it exists for configs that
1121
+ * were written while the shipped default was `null`.
1122
+ */
1123
+ function normalizeRightbarOpacity(v) {
1124
+ if (v === null) return null;
1125
+ if (typeof v === "number" && isFinite(v)) return clamp01(v, 1);
1126
+ return DEFAULT_RIGHTBAR_OPACITY;
1127
+ }
1065
1128
  /** Coerce an unknown persisted value into a valid ThemeConfig, falling back per-field. */
1066
1129
  function normalizeConfig(raw) {
1067
1130
  const r = raw ?? {};
1068
- const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [];
1131
+ const rules = Array.isArray(r.rules) ? r.rules.map(normalizeRule).filter((x) => x !== null) : [blankRule("r1")];
1069
1132
  const ops = r.opacities ?? {};
1070
1133
  const bl = r.blurs ?? {};
1071
1134
  const blurs = {};
@@ -1078,9 +1141,9 @@ window.__ModuleLoader__.load({
1078
1141
  blurs,
1079
1142
  transition: normalizeTransition(r.transition, legacyFadeMs(r.rules)),
1080
1143
  settingsOpacity: clamp01(r.settingsOpacity, 1),
1081
- chatTextOpacity: clamp01(r.chatTextOpacity, 0),
1082
- trajectoryOpacity: clamp01(r.trajectoryOpacity, 1),
1083
- rightbarOpacity: typeof r.rightbarOpacity === "number" && isFinite(r.rightbarOpacity) ? clamp01(r.rightbarOpacity, 1) : null,
1144
+ chatTextOpacity: clamp01(r.chatTextOpacity, DEFAULT_CHAT_TEXT_OPACITY),
1145
+ trajectoryOpacity: clamp01(r.trajectoryOpacity, DEFAULT_TRAJECTORY_OPACITY),
1146
+ rightbarOpacity: normalizeRightbarOpacity(r.rightbarOpacity),
1084
1147
  autoExtract: r.autoExtract !== false,
1085
1148
  holidays: normalizeHolidays(r.holidays)
1086
1149
  };
@@ -1223,28 +1286,6 @@ window.__ModuleLoader__.load({
1223
1286
  function holidayById(id) {
1224
1287
  return cfg.holidays.items.find((r) => r.id === id) ?? null;
1225
1288
  }
1226
- /**
1227
- * A fresh rule, holding NO image yet.
1228
- *
1229
- * It used to be born with an allocated slot and an empty entry, which is what put
1230
- * a blank tile at position 1 of a new rule's strip — and, worse, stopped the
1231
- * picture the user added next from being the first one (a rule paints its first
1232
- * image). A rule with no pictures is a valid state: `ruleCanPaint` (./schema) skips
1233
- * it — it has no image and no color yet — until either arrives, and the slot is
1234
- * allocated when the first picture does.
1235
- */
1236
- function newRule(id) {
1237
- return {
1238
- id,
1239
- images: [],
1240
- match: "",
1241
- enabled: true,
1242
- color: null,
1243
- wallpaperOpacity: 1,
1244
- blur: 0,
1245
- rotate: defaultRotation()
1246
- };
1247
- }
1248
1289
  /** Every slot any rule owns — the user's rules and the holiday entries alike. */
1249
1290
  function takenSlots() {
1250
1291
  const taken = /* @__PURE__ */ new Set();
@@ -1364,10 +1405,10 @@ window.__ModuleLoader__.load({
1364
1405
  return clamp01(cfg.settingsOpacity, 1);
1365
1406
  }
1366
1407
  function rChatTextOpacity() {
1367
- return clamp01(cfg.chatTextOpacity, 0);
1408
+ return clamp01(cfg.chatTextOpacity, DEFAULT_CHAT_TEXT_OPACITY);
1368
1409
  }
1369
1410
  function rTrajectoryOpacity() {
1370
- return clamp01(cfg.trajectoryOpacity, 1);
1411
+ return clamp01(cfg.trajectoryOpacity, DEFAULT_TRAJECTORY_OPACITY);
1371
1412
  }
1372
1413
  /** Own opacity of the file-preview panel; while unowned it IS the main background's. */
1373
1414
  function rRightbarOpacity() {
@@ -1392,6 +1433,64 @@ window.__ModuleLoader__.load({
1392
1433
  rotIndex = 0;
1393
1434
  }
1394
1435
  //#endregion
1436
+ //#region src/preset.ts
1437
+ /**
1438
+ * JSON with object keys in a fixed (sorted) order — the same string for the same
1439
+ * value, whatever order the object that carried it happened to build its keys in.
1440
+ *
1441
+ * `JSON.stringify` alone is not an identity for a value that came off the wire and
1442
+ * through a sanitizer: key order is an accident of how the object was assembled,
1443
+ * and a resume that missed on it would silently re-download everything (the safe
1444
+ * direction) while a resume that MATCHED on a value it should not have would be
1445
+ * the unsafe one — so this is deliberately total as well: anything JSON cannot
1446
+ * carry (a function, a `Symbol`, `undefined`) serializes as `null` and an
1447
+ * `undefined` property is skipped, exactly as `JSON.stringify` treats it.
1448
+ */
1449
+ function canonical(value) {
1450
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1451
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
1452
+ return JSON.stringify(value) ?? "null";
1453
+ }
1454
+ /**
1455
+ * The identity of one revision of the profile: the manifest's shape version plus
1456
+ * the config, in canonical form.
1457
+ *
1458
+ * The profile is served from a branch, so "the profile" is not a fixed thing: the
1459
+ * bytes a previous attempt is holding are only known to belong to what is being
1460
+ * installed when that config comes back the same. Order-insensitivity is what lets
1461
+ * this be a comparison of VALUES rather than of spellings — `normalizeConfig`
1462
+ * (./schema) rebuilds the config it reads, so pinning the key to the exact key
1463
+ * order the wire happened to use would make the hold useless the moment anything
1464
+ * upstream normalized differently.
1465
+ */
1466
+ function presetKey(version, config) {
1467
+ return `${version}\n${canonical(config)}`;
1468
+ }
1469
+ /**
1470
+ * The bytes of a failed attempt that may be reused for the revision `key` names.
1471
+ *
1472
+ * A fresh object every time, never the held record itself: the caller fills it in
1473
+ * as the rest of the profile arrives, and handing out the same mutable record
1474
+ * would let a re-fetch of one file edit the state the NEXT attempt resumes from.
1475
+ * An empty answer — nothing held, or held for a different revision — is the honest
1476
+ * one either way: the pool then asks for every slot the config names.
1477
+ */
1478
+ function presetResume(held, key) {
1479
+ if (held === null || held.key !== key) return {};
1480
+ return { ...held.images };
1481
+ }
1482
+ /**
1483
+ * The slots of `slots` that `images` does not hold yet, in the config's own order.
1484
+ *
1485
+ * Order matters in the only way it can here: it decides which file a worker takes
1486
+ * first, and the files are not the same size. Only names the CONFIG asked for are
1487
+ * ever returned, so a held picture nothing points at — a slot the author removed
1488
+ * from the profile — can never be fetched again or shipped.
1489
+ */
1490
+ function presetMissing(slots, images) {
1491
+ return slots.filter((slot) => images[slot] === void 0);
1492
+ }
1493
+ //#endregion
1395
1494
  //#region src/client/rotation.ts
1396
1495
  /** Dwell-time presets the settings UI offers, in ms. */
1397
1496
  const ROTATE_PRESETS = [
@@ -2143,7 +2242,10 @@ window.__ModuleLoader__.load({
2143
2242
  .dab-brand:hover .dab-brand-name{color:var(--dsw-alias-brand-primary)}
2144
2243
  .dab-brand-tile{width:30px;height:30px;flex:none;border-radius:9px;display:grid;place-items:center;color:var(--dsw-alias-label-primary-inverted);background:var(--dsw-alias-brand-primary);box-shadow:0 4px 14px -4px var(--dsw-alias-brand-primary)}
2145
2244
  .dab-brand-name{font-size:13px;font-weight:650;letter-spacing:.01em;line-height:1.25}
2146
- .dab-brand-tag{font-size:9px;letter-spacing:.16em;font-weight:600;color:var(--dsw-alias-label-quaternary,var(--dsw-alias-label-tertiary));text-transform:uppercase}
2245
+ /* The weak token was written as quaternary-WITH-a-fallback, so this tag has
2246
+ always painted tertiary: the dead name is dropped rather than being kept as
2247
+ decoration nothing resolves to (check:ui rejects the name outright now). */
2248
+ .dab-brand-tag{font-size:9px;letter-spacing:.16em;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase}
2147
2249
  .dab-nav-list{position:relative;display:flex;flex-direction:column;gap:4px}
2148
2250
  .dab-nav-ind{position:absolute;left:0;right:0;top:0;height:38px;border-radius:11px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 13%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);transition:transform .38s cubic-bezier(.22,1,.36,1)}
2149
2251
  .dab-nav-item{position:relative;z-index:1;display:flex;align-items:center;gap:10px;height:38px;padding:0 12px;border:0;background:none;border-radius:11px;color:var(--dsw-alias-label-tertiary);font-size:13px;cursor:pointer;text-align:left;transition:color .22s ease}
@@ -2196,8 +2298,20 @@ window.__ModuleLoader__.load({
2196
2298
  .dab-num::-webkit-outer-spin-button,.dab-num::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
2197
2299
  .dab-num{-moz-appearance:textfield;appearance:textfield}
2198
2300
  .dab-urlinput{flex:1;min-width:180px;height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12.5px;outline:none;transition:border-color .2s}
2199
- .dab-urlinput::placeholder{color:var(--dsw-alias-label-quaternary)}
2200
2301
  .dab-urlinput:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
2302
+ /* Placeholder text is the field's PREDICTION, never its content: the weak caption
2303
+ token, one step below the text it sits in, so an empty box cannot read as a
2304
+ filled one. All three inputs that carry one are listed here (the rule's match
2305
+ field lives further down, in the rule card) rather than each deciding for
2306
+ itself what "empty" looks like.
2307
+ This used to name --dsw-alias-label-quaternary, which this harness defines
2308
+ NOWHERE: an undefined token drops the declaration, and the placeholder then
2309
+ INHERITS the input's own label-primary — the brightest text in the panel,
2310
+ inside a field the user has not typed in. check:ui now fails on any --dsw-…
2311
+ name the installed theme does not publish, so that one cannot come back.
2312
+ label-caption is the token PLACEHOLDER_RULE (../wallpaper) already uses for the
2313
+ composer, so the plugin answers this question in one voice. */
2314
+ .dab-num::placeholder,.dab-urlinput::placeholder,.dab-rule-match::placeholder{color:var(--dsw-alias-label-caption)}
2201
2315
  /* Prints the current hex on the color itself. Without the value this was an
2202
2316
  * empty bordered rectangle sitting in the same column as the H/S/L number
2203
2317
  * fields, which made it read as one more — blank — text input; nothing is ever
@@ -2254,7 +2368,6 @@ window.__ModuleLoader__.load({
2254
2368
  .dab-part-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}
2255
2369
  .dab-part-ico{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
2256
2370
  .dab-part-name{font-size:13.5px;font-weight:600}
2257
- .dab-part-badge{margin-left:auto;font-family:var(--dab-mono);font-size:11px;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2);border-radius:99px;padding:3px 9px}
2258
2371
 
2259
2372
  /* ── segmented control ───────────────────────────────────────────────────── */
2260
2373
  .dab-seg{position:relative;display:inline-flex;padding:3px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:11px}
@@ -2383,7 +2496,7 @@ window.__ModuleLoader__.load({
2383
2496
  .dab-pick-item:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
2384
2497
  .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
2498
  .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}
2499
+ .dab-pick-n{flex:none;font-family:var(--dab-mono);font-size:10.5px;opacity:.7}
2387
2500
 
2388
2501
  /* ── one rule editor (the tab panel's content) ─────────────────────────────── */
2389
2502
  .dab-rule-panel{min-height:0}
@@ -2398,7 +2511,6 @@ window.__ModuleLoader__.load({
2398
2511
  .dab-rule-num{font-family:var(--dab-mono);font-size:11px;font-weight:700;color:var(--dsw-alias-label-tertiary);min-width:16px}
2399
2512
  .dab-rule-badge{font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);border-radius:99px;padding:2px 8px}
2400
2513
  .dab-rule-match{flex:1;min-width:150px;height:32px;padding:0 11px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-family:var(--dab-mono);font-size:12.5px;outline:none;transition:border-color .2s,box-shadow .2s}
2401
- .dab-rule-match::placeholder{color:var(--dsw-alias-label-quaternary)}
2402
2514
  .dab-rule-match:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent)}
2403
2515
  .dab-icon-btn{width:28px;height:28px;flex:none;display:grid;place-items:center;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;transition:color .2s,border-color .2s,background .2s}
2404
2516
  .dab-icon-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-brand-primary)}
@@ -4573,20 +4685,13 @@ window.__ModuleLoader__.load({
4573
4685
  children: [
4574
4686
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4575
4687
  className: "dab-part-head",
4576
- children: [
4577
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4578
- className: "dab-part-ico",
4579
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { size: 16 })
4580
- }),
4581
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4582
- className: "dab-part-name",
4583
- children: t(labelKey)
4584
- }),
4585
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4586
- className: "dab-part-badge",
4587
- children: [Math.round(opacity * 100), "%"]
4588
- })
4589
- ]
4688
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4689
+ className: "dab-part-ico",
4690
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { size: 16 })
4691
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4692
+ className: "dab-part-name",
4693
+ children: t(labelKey)
4694
+ })]
4590
4695
  }),
4591
4696
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LiveSlider, {
4592
4697
  label: t("uiOpacity"),
@@ -7775,6 +7880,20 @@ window.__ModuleLoader__.load({
7775
7880
  applyActive();
7776
7881
  return true;
7777
7882
  };
7883
+ /**
7884
+ * What the last FAILED profile attempt already had in hand.
7885
+ *
7886
+ * Only a failure leaves anything here — a download that completes is applied or
7887
+ * thrown away whole, and holding megabytes of base64 for a re-click that a
7888
+ * second re-download would settle is not a trade this makes. What it buys is the
7889
+ * one case that had no answer at all before: a profile whose sixth of seven files
7890
+ * failed now costs ONE file on the next press instead of seven.
7891
+ *
7892
+ * Held across section re-injections on purpose (it is declared here, in `apply`,
7893
+ * not inside `sectionInject`): the retry is usually the next click of the same
7894
+ * button, but the panel can be closed and reopened between them.
7895
+ */
7896
+ let presetHeld = null;
7778
7897
  const sectionInject = (actions) => {
7779
7898
  bound = actions;
7780
7899
  sync();
@@ -7785,7 +7904,7 @@ window.__ModuleLoader__.load({
7785
7904
  readModelFacts,
7786
7905
  imageOf: (slot) => displayImageOf(slot),
7787
7906
  addRule: () => {
7788
- const rule = newRule(nextRuleId());
7907
+ const rule = blankRule(nextRuleId());
7789
7908
  cfg.rules.push(rule);
7790
7909
  rulesRev++;
7791
7910
  persistConfig();
@@ -8120,15 +8239,18 @@ window.__ModuleLoader__.load({
8120
8239
  * request, up to `PRESET_CONCURRENCY` of them at a time. What the fan-out
8121
8240
  * buys is wall-clock time — the real profile measured 8.3 s fetched one at
8122
8241
  * 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.
8242
+ * mirror fallback, its own retry rounds (the node half's business, see
8243
+ * ./preset), its own validation, and its own slot name in a failure.
8124
8244
  *
8125
8245
  * STRICT, and the pool is what makes that a decision rather than a
8126
8246
  * formality: the first wallpaper that cannot be had stops the queue, so the
8127
8247
  * 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.
8248
+ * in flight finishes and is thrown away — but what it FETCHED is kept for the
8249
+ * next press (`presetHeld` below), which is the difference between retrying
8250
+ * one file and retrying the lot. Nothing has been touched at this point, so
8251
+ * "the author renamed a file" arrives as an error naming the file rather
8252
+ * than as a profile that is quietly missing a picture — and that loudness is
8253
+ * the whole reason this feature is allowed to follow a branch.
8132
8254
  */
8133
8255
  fetchRecommended: async (onProgress) => {
8134
8256
  const head = await fetchPresetConfig();
@@ -8137,17 +8259,22 @@ window.__ModuleLoader__.load({
8137
8259
  error: head.error
8138
8260
  };
8139
8261
  const slots = head.slots;
8140
- const images = {};
8262
+ const key = presetKey(head.version, head.config);
8263
+ const images = presetResume(presetHeld, key);
8264
+ let bytes = 0;
8265
+ for (const url of Object.values(images)) bytes += url.length;
8266
+ const missing = presetMissing(slots, images);
8267
+ /** Files already in hand from the attempt this one is resuming. */
8268
+ const held = slots.length - missing.length;
8141
8269
  let next = 0;
8142
8270
  let done = 0;
8143
- let bytes = 0;
8144
8271
  /** Latched by the first worker to fail, so the rest stop asking. */
8145
8272
  let stopped = false;
8146
- onProgress?.(0, slots.length);
8273
+ onProgress?.(held, slots.length);
8147
8274
  const worker = async () => {
8148
8275
  for (;;) {
8149
- if (stopped || next >= slots.length) return null;
8150
- const slot = slots[next++];
8276
+ if (stopped || next >= missing.length) return null;
8277
+ const slot = missing[next++];
8151
8278
  const got = await fetchPresetImage(slot);
8152
8279
  if (!got.ok) {
8153
8280
  stopped = true;
@@ -8156,21 +8283,28 @@ window.__ModuleLoader__.load({
8156
8283
  slot
8157
8284
  };
8158
8285
  }
8159
- images[slot] = got.dataUrl;
8160
- bytes += got.dataUrl.length;
8161
- if (bytes > PRESET_TOTAL_MAX) {
8286
+ if (bytes + got.dataUrl.length > PRESET_TOTAL_MAX) {
8162
8287
  stopped = true;
8163
8288
  return { error: "too large" };
8164
8289
  }
8165
- onProgress?.(++done, slots.length);
8290
+ images[slot] = got.dataUrl;
8291
+ bytes += got.dataUrl.length;
8292
+ onProgress?.(held + ++done, slots.length);
8166
8293
  }
8167
8294
  };
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
- };
8295
+ const failure = (await Promise.all(Array.from({ length: Math.min(PRESET_CONCURRENCY, missing.length) }, worker))).find((r) => r !== null) ?? null;
8296
+ if (failure !== null) {
8297
+ presetHeld = {
8298
+ key,
8299
+ images
8300
+ };
8301
+ return {
8302
+ ok: false,
8303
+ error: failure.error,
8304
+ slot: failure.slot
8305
+ };
8306
+ }
8307
+ presetHeld = null;
8174
8308
  return {
8175
8309
  ok: true,
8176
8310
  preset: {