@nonamelego/dsh-catppuccin 0.5.5 → 0.5.6-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -134,6 +134,7 @@ window.__ModuleLoader__.load({
134
134
  const style = (0, react.useSyncExternalStore)(subscribePrefs, shikiStyle);
135
135
  const [overridesOpen, setOverridesOpen] = (0, react.useState)(false);
136
136
  const [draftRows, setDraftRows] = (0, react.useState)([]);
137
+ const nextDraftId = (0, react.useRef)(0);
137
138
  const isTokenKey = (key) => key.trim().startsWith("--");
138
139
  /** Commit a draft row on blur. If both fields are valid, promote to persisted;
139
140
  * otherwise keep in draft state. Item XX: changed from onChange to onBlur so
@@ -457,11 +458,13 @@ window.__ModuleLoader__.load({
457
458
  children: "✕"
458
459
  })
459
460
  ]
460
- }, `draft-${index}`)),
461
+ }, row.id)),
461
462
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
462
463
  type: "button",
463
464
  onClick: () => {
465
+ nextDraftId.current += 1;
464
466
  setDraftRows([...draftRows, {
467
+ id: nextDraftId.current,
465
468
  key: "",
466
469
  value: ""
467
470
  }]);
@@ -1959,6 +1962,21 @@ window.__ModuleLoader__.load({
1959
1962
  for (const [key, value] of Object.entries(input)) if (typeof value === "string" && key.startsWith("--")) out[key] = value;
1960
1963
  return out;
1961
1964
  }
1965
+ /**
1966
+ * Whether a raw overrides map carries entries {@link sanitizeOverrides} would
1967
+ * drop — a key that is not a `--` token, or a non-string value.
1968
+ *
1969
+ * The settings-document schema types `overrides` as a dict of strings only, so
1970
+ * a hand-edited document can hold such entries. The client reads them away but
1971
+ * never wrote the cleaned map back, so every settings publish re-ran the
1972
+ * "document wins" adoption for a difference nothing could clear (audit F7).
1973
+ * The caller uses this to push the sanitized shape back once, so the document
1974
+ * converges instead of drifting forever.
1975
+ */
1976
+ function hasUnpersistableOverrides(raw) {
1977
+ if (typeof raw !== "object" || raw === null) return false;
1978
+ return Object.entries(raw).some(([key, value]) => typeof value !== "string" || !key.startsWith("--"));
1979
+ }
1962
1980
  /** Clamp a finite number into [min, max]; non-finite values fall back. */
1963
1981
  function clampFinite(value, min, max, fallback) {
1964
1982
  return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
@@ -2057,6 +2075,13 @@ window.__ModuleLoader__.load({
2057
2075
  frost: "dsh.catppuccin.glass.frost",
2058
2076
  brightness: "dsh.catppuccin.glass.brightness"
2059
2077
  };
2078
+ /** The localStorage names of the numeric knobs — the reverse of
2079
+ * `NUMERIC_KEYS`. Used by the cross-tab `storage` handler: the event's `key`
2080
+ * is a localStorage name (`dsh.catppuccin.glass.blur`), so it must be matched
2081
+ * against the VALUES, never with `key in NUMERIC_KEYS` (that tests the object's
2082
+ * property names — `blur`/`frost`/`brightness` — and is false for every real
2083
+ * storage key, which silently killed cross-tab knob sync; audit F1). */
2084
+ const NUMERIC_STORAGE_KEYS = Object.values(NUMERIC_KEYS);
2060
2085
  const MODE_KEY = "dsh.catppuccin.glass.mode";
2061
2086
  /** Clamp a numeric knob into its sane range. */
2062
2087
  function clampSetting(key, value) {
@@ -2156,7 +2181,7 @@ window.__ModuleLoader__.load({
2156
2181
  } else if (event.key === "dsh.catppuccin.glass.enabled") {
2157
2182
  this.enabled = readEnabled();
2158
2183
  this.sync();
2159
- } else if (event.key === MODE_KEY || event.key in NUMERIC_KEYS) {
2184
+ } else if (event.key === MODE_KEY || NUMERIC_STORAGE_KEYS.includes(event.key)) {
2160
2185
  this.reloadSettings();
2161
2186
  if (this.enabled) this.applySettings();
2162
2187
  this.publish();
@@ -2446,8 +2471,10 @@ window.__ModuleLoader__.load({
2446
2471
  const { label, value, options, onSelect } = props;
2447
2472
  const [focused, setFocused] = (0, react.useState)(null);
2448
2473
  const refs = (0, react.useRef)(/* @__PURE__ */ new Map());
2474
+ const matchesValue = options.some((option) => option.id === value);
2475
+ const anchor = focused ?? (matchesValue ? value : options[0]?.id);
2449
2476
  const moveFocus = (direction) => {
2450
- const current = focused ?? value;
2477
+ const current = anchor;
2451
2478
  const index = options.findIndex((option) => option.id === current);
2452
2479
  let next;
2453
2480
  if (direction === "home") next = 0;
@@ -2491,7 +2518,7 @@ window.__ModuleLoader__.load({
2491
2518
  type: "button",
2492
2519
  className: option.id === value ? GlassRow_module_css_default.segActive : GlassRow_module_css_default.seg,
2493
2520
  "aria-pressed": option.id === value,
2494
- tabIndex: option.id === (focused ?? value) ? 0 : -1,
2521
+ tabIndex: option.id === anchor ? 0 : -1,
2495
2522
  onFocus: () => {
2496
2523
  setFocused(option.id);
2497
2524
  },
@@ -2807,13 +2834,13 @@ window.__ModuleLoader__.load({
2807
2834
  }
2808
2835
  setRetryRemaining(null);
2809
2836
  };
2810
- const runCheck = async (withChannel = channelValue) => {
2837
+ const runCheck = async (withChannel) => {
2811
2838
  cancelRetry();
2812
2839
  setPhase("checking");
2813
2840
  setCopied(false);
2814
2841
  let next;
2815
2842
  try {
2816
- next = await check(withChannel);
2843
+ next = await check(withChannel ?? channel());
2817
2844
  } catch {
2818
2845
  next = {
2819
2846
  ok: false,
@@ -3359,6 +3386,39 @@ window.__ModuleLoader__.load({
3359
3386
  pendingTimer = void 0;
3360
3387
  pendingWrite = void 0;
3361
3388
  }
3389
+ /**
3390
+ * Capture-once holder for the revision a debounced write is based on (audit
3391
+ * F2, 2026-09-22).
3392
+ *
3393
+ * The read-side guard in {@link persistStateToScope} only means anything if
3394
+ * `baseRevision` is the revision the LOCAL state was derived from — i.e.
3395
+ * sampled when the change was SCHEDULED. Reading it inside the flush instead
3396
+ * made the comparison a tautology: the flush and the guard's own
3397
+ * `scope.getSnapshot()` run in the same synchronous block, so the two
3398
+ * revisions are always equal and `'stale'` is unreachable. The effect was a
3399
+ * silently lost read-side protection: with two windows open, an external edit
3400
+ * landing inside the 300 ms debounce window was overwritten by the stale local
3401
+ * state and the conflict banner never appeared.
3402
+ *
3403
+ * `capture` keeps the FIRST revision of a burst (`??=`, not assignment): a
3404
+ * slider drag emits several changes, and the local state each of them is based
3405
+ * on is still the one that was current when the burst started. `take` clears
3406
+ * the slot as it reads, so the next burst starts fresh — including when the
3407
+ * flush bails out (unusable scope), where the value must not leak forward.
3408
+ */
3409
+ function createBaseRevisionTracker() {
3410
+ let pending;
3411
+ return {
3412
+ capture(read) {
3413
+ if (pending === void 0) pending = read();
3414
+ },
3415
+ take() {
3416
+ const value = pending;
3417
+ pending = void 0;
3418
+ return value;
3419
+ }
3420
+ };
3421
+ }
3362
3422
  //#endregion
3363
3423
  //#region src/client/index.ts
3364
3424
  /** Locale namespace owned by this plugin. */
@@ -3396,6 +3456,21 @@ window.__ModuleLoader__.load({
3396
3456
  return "off";
3397
3457
  }
3398
3458
  }
3459
+ /** Whether localStorage carries an EXPLICIT `off` flavour choice.
3460
+ *
3461
+ * `readFlavor()` maps both "no value" and "off" to `off`, and the two are not
3462
+ * the same thing: DSH Desktop boots the GUI on a fresh random loopback port, so
3463
+ * its localStorage starts empty on every launch and the settings document —
3464
+ * which may legitimately hold a flavour — is what should win. Only a literal
3465
+ * `off` in storage proves the user (or another window) actually turned the
3466
+ * flavour off, which is the case the cross-tab restore must act on (audit F4). */
3467
+ function readExplicitFlavorOff() {
3468
+ try {
3469
+ return localStorage.getItem(FLAVOR_STORAGE_KEY) === "off";
3470
+ } catch {
3471
+ return false;
3472
+ }
3473
+ }
3399
3474
  /** Persist the flavour choice (storage failures keep the in-memory state). */
3400
3475
  function writeFlavor(choice) {
3401
3476
  try {
@@ -3637,11 +3712,15 @@ window.__ModuleLoader__.load({
3637
3712
  };
3638
3713
  let lastWrittenSection;
3639
3714
  let handleStaleConflict;
3715
+ const baseRevisionTracker = createBaseRevisionTracker();
3716
+ const queuePersist = () => {
3717
+ baseRevisionTracker.capture(() => scope.getSnapshot().revision);
3718
+ scheduleDurablePersist(persistLocal);
3719
+ };
3640
3720
  const persistLocal = () => {
3641
- const snapshot = scope.getSnapshot();
3642
- if (!isScopeUsable(snapshot)) return;
3721
+ const baseRevision = baseRevisionTracker.take();
3722
+ if (!isScopeUsable(scope.getSnapshot())) return;
3643
3723
  const state = buildLocalState();
3644
- const baseRevision = snapshot.revision;
3645
3724
  persistStateToScope(scope, state, {
3646
3725
  baseRevision,
3647
3726
  lastWrittenSection
@@ -3682,7 +3761,11 @@ window.__ModuleLoader__.load({
3682
3761
  const applyDesired = () => {
3683
3762
  rememberBuiltinPreference(theme.getTheme().preference);
3684
3763
  const desired = readFlavor();
3685
- if (desired === "off") return;
3764
+ if (desired === "off") {
3765
+ const preference = theme.getTheme().preference;
3766
+ if (readExplicitFlavorOff() && flavorFromThemeId(preference) !== "off") scheduleRestore(readRestoredPreference());
3767
+ return;
3768
+ }
3686
3769
  const preference = theme.getTheme().preference;
3687
3770
  if (preference === desired) return;
3688
3771
  if (builtinPickWins(preference, liveBuiltinPick)) return;
@@ -3698,11 +3781,16 @@ window.__ModuleLoader__.load({
3698
3781
  try {
3699
3782
  theme.setTheme(next);
3700
3783
  } catch {}
3784
+ return;
3701
3785
  }
3786
+ if (flavorFromThemeId(theme.getTheme().preference) === "off") return;
3787
+ try {
3788
+ theme.setTheme(readRestoredPreference());
3789
+ } catch {}
3702
3790
  };
3703
3791
  window.addEventListener("storage", onStorage);
3704
3792
  const offGlass = glass.subscribe(() => {
3705
- scheduleDurablePersist(persistLocal);
3793
+ queuePersist();
3706
3794
  });
3707
3795
  const applyScopeSnapshot = () => {
3708
3796
  const snapshot = scope.getSnapshot();
@@ -3710,9 +3798,10 @@ window.__ModuleLoader__.load({
3710
3798
  if (state === null) return;
3711
3799
  const local = buildLocalState();
3712
3800
  if (snapshot.user === void 0) {
3713
- if (!isDefaultState(local)) scheduleDurablePersist(persistLocal);
3801
+ if (!isDefaultState(local)) queuePersist();
3714
3802
  return;
3715
3803
  }
3804
+ if (hasUnpersistableOverrides(snapshot.value?.overrides)) queuePersist();
3716
3805
  if (settingsSectionsEqual(settingsSectionFromState(local), settingsSectionFromState(state))) return;
3717
3806
  writeFlavor(state.flavor);
3718
3807
  glass.applyRemote(state.glass);
@@ -3776,7 +3865,7 @@ window.__ModuleLoader__.load({
3776
3865
  if (choice === "off") {
3777
3866
  writeFlavor("off");
3778
3867
  theme.setTheme(readRestoredPreference());
3779
- scheduleDurablePersist(persistLocal);
3868
+ queuePersist();
3780
3869
  return;
3781
3870
  }
3782
3871
  const flavor = flavorInfo(choice);
@@ -3784,21 +3873,21 @@ window.__ModuleLoader__.load({
3784
3873
  writeFlavor(flavor.themeId);
3785
3874
  ensureThemeRegistered(flavor.themeId);
3786
3875
  theme.setTheme(flavor.themeId);
3787
- scheduleDurablePersist(persistLocal);
3876
+ queuePersist();
3788
3877
  },
3789
3878
  overrides: overridesSnapshot,
3790
3879
  setOverrides: (overrides) => {
3791
3880
  writeOverrides(overrides);
3792
3881
  emitPrefs();
3793
3882
  reapplyThemePrefs();
3794
- scheduleDurablePersist(persistLocal);
3883
+ queuePersist();
3795
3884
  },
3796
3885
  shikiStyle: () => readShikiStyle(),
3797
3886
  setShikiStyle: (style) => {
3798
3887
  writeShikiStyle(style);
3799
3888
  emitPrefs();
3800
3889
  reapplyThemePrefs();
3801
- scheduleDurablePersist(persistLocal);
3890
+ queuePersist();
3802
3891
  },
3803
3892
  subscribePrefs: (listener) => {
3804
3893
  prefsListeners.add(listener);
@@ -3859,14 +3948,14 @@ window.__ModuleLoader__.load({
3859
3948
  setAutoCheck: (value) => {
3860
3949
  writeAutoCheck(value);
3861
3950
  emitPrefs();
3862
- scheduleDurablePersist(persistLocal);
3951
+ queuePersist();
3863
3952
  armAutoCheck?.();
3864
3953
  },
3865
3954
  channel: () => readUpdateChannel(),
3866
3955
  setChannel: (channel) => {
3867
3956
  writeUpdateChannel(channel);
3868
3957
  emitPrefs();
3869
- scheduleDurablePersist(persistLocal);
3958
+ queuePersist();
3870
3959
  },
3871
3960
  subscribePrefs: (listener) => {
3872
3961
  prefsListeners.add(listener);
@@ -3909,6 +3998,7 @@ window.__ModuleLoader__.load({
3909
3998
  exports.inject = inject;
3910
3999
  exports.overridesSnapshot = overridesSnapshot;
3911
4000
  exports.readAutoCheck = readAutoCheck;
4001
+ exports.readExplicitFlavorOff = readExplicitFlavorOff;
3912
4002
  exports.readFlavor = readFlavor;
3913
4003
  exports.readOverrides = readOverrides;
3914
4004
  exports.readRestoredPreference = readRestoredPreference;