dsh-fonttune 0.3.5 → 0.3.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/CHANGELOG.en.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to **dsh-fonttune** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.3.6] - 2026-09-29
6
+
7
+ ### Fixes
8
+ - **A weight change took a long time to take effect**: an adjustment now submits the setting once, and consecutive adjustments are merged.
9
+ - **The text briefly jumped back while the weight was adjusted**: the page keeps the value you chose until the write is confirmed.
10
+ - **A "not written to the document" notice appeared while the write was still going**: the notice now appears only when the host actually refuses the write.
11
+
12
+ ### Changes
13
+ - **A weight change is reflected on the page immediately**, instead of after the setting has been written.
14
+
5
15
  ## [0.3.5] - 2026-09-29
6
16
 
7
17
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  **dsh-fonttune** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.3.6] - 2026-09-29
6
+
7
+ ### 修复
8
+ - **调整字重挡位后等待较久才生效**:一次调整只写入一次设置,连续调整会合并为一次,等待明显缩短。
9
+ - **调整字重时文字会短暂回跳**:写入确认到达之前,页面保持你最后选择的值,不再来回变动。
10
+ - **写入尚未完成时出现「未写入文档」提示**:该提示现在只在宿主确实拒绝写入时出现。
11
+
12
+ ### 变更
13
+ - **调整字重后页面立即反映新值**,不再等待设置写入完成。
14
+
5
15
  ## [0.3.5] - 2026-09-29
6
16
 
7
17
  ### 修复
package/README.en.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | Plugin version | Supported DSH versions |
28
28
  | --- | --- |
29
- | **0.3.x** (latest, 0.3.5) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1 |
29
+ | **0.3.x** (latest, 0.3.6) | 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1, 0.2.0-rc.2 |
30
30
  | 0.2.x (0.2.8) | 0.1.5-rc.3, 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2 |
31
31
 
32
32
  Each major version lists only its newest patch: defects a feature introduced were fixed in the patches after it, so within a major version the newest patch is the one to use; older patches keep working, because the plugin does not break its existing interfaces.
package/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | 插件版本 | 支持的 DSH 版本 |
28
28
  | --- | --- |
29
- | **0.3.x**(最新,0.3.5) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1 |
29
+ | **0.3.x**(最新,0.3.6) | 0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1、0.2.0-rc.2 |
30
30
  | 0.2.x(0.2.8) | 0.1.5-rc.3、0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2 |
31
31
 
32
32
  每个大版本只列该系列最新的一个补丁版本(新功能引入的缺陷都在其后的补丁里修掉了,所以同一个大版本内直接用最新补丁即可;旧补丁仍可继续用,插件不破坏既有接口)。
package/lib/client.js CHANGED
@@ -713,6 +713,69 @@ function migratePresetWeights(presets) {
713
713
  return { presets: out, changed: changed };
714
714
  }
715
715
 
716
+ /** The two shapes one locally painted move takes. */
717
+ var PENDING_SET = "set";
718
+ var PENDING_UNSET = "unset";
719
+
720
+ /**
721
+ * Merge the values the card painted locally over the document's own values.
722
+ *
723
+ * The card paints a move the moment the user lets go, while the settings document
724
+ * only echoes it after the host's debounced write. Painting the DOCUMENT's value
725
+ * in the meantime would show the previous one again — and an echo of an older move
726
+ * arriving after a newer one would visibly step the page backwards. So the local
727
+ * values win until the document catches up (`reconcilePendingValues` decides when
728
+ * that is).
729
+ *
730
+ * @param {unknown} document - the raw settings value.
731
+ * @param {Record<string, {op: string, value: unknown}>} pending - local moves.
732
+ * @returns {Record<string, unknown>} the values to render with.
733
+ */
734
+ function overlayPendingValues(document, pending) {
735
+ var source = document !== null && typeof document === "object" ? document : {};
736
+ var merged = {};
737
+ for (var key in source) {
738
+ if (Object.prototype.hasOwnProperty.call(source, key)) merged[key] = source[key];
739
+ }
740
+ var local = pending !== null && typeof pending === "object" ? pending : {};
741
+ for (var field in local) {
742
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
743
+ if (local[field].op === PENDING_UNSET) delete merged[field];
744
+ else merged[field] = local[field].value;
745
+ }
746
+ return merged;
747
+ }
748
+
749
+ /**
750
+ * Keep only the local moves that are still worth painting.
751
+ *
752
+ * A paint is deliberately NOT retired when the document agrees with it: the host
753
+ * debounces its writes, so a patch built EARLIER can land after a newer one and
754
+ * drag the document back to an older value. Retiring on agreement made exactly
755
+ * that echo visible — the page jumped back and forth and only ended up right at
756
+ * the end. The card corrects the document instead (its re-assert), and the paint
757
+ * stays until the patience runs out. A refused write is retired by the caller,
758
+ * which learns about it before this rule sees the document again.
759
+ *
760
+ * @param {Record<string, {op: string, value: unknown, at: number}>} pending - local moves.
761
+ * @param {unknown} document - the raw settings value (kept for the caller's shape).
762
+ * @param {number} now - current time in ms.
763
+ * @param {number} patienceMs - how long to keep an unconfirmed move.
764
+ * @returns {Record<string, object>} the entries still worth painting.
765
+ */
766
+ function reconcilePendingValues(pending, document, now, patienceMs) {
767
+ var local = pending !== null && typeof pending === "object" ? pending : {};
768
+ var kept = {};
769
+ for (var field in local) {
770
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
771
+ var entry = local[field];
772
+ var stale = typeof entry.at === "number" && now - entry.at > patienceMs;
773
+ if (stale) continue;
774
+ kept[field] = entry;
775
+ }
776
+ return kept;
777
+ }
778
+
716
779
  /**
717
780
  * Parse and validate the durable `presets` JSON string.
718
781
  * @param {unknown} value - candidate JSON text or array.
@@ -2923,6 +2986,10 @@ var shared = {
2923
2986
  CODE_WEIGHT_FIELD: CODE_WEIGHT_FIELD,
2924
2987
  WEIGHT_OFFSETS_FIELD: WEIGHT_OFFSETS_FIELD,
2925
2988
  WEIGHT_FIELDS: WEIGHT_FIELDS,
2989
+ PENDING_SET: PENDING_SET,
2990
+ PENDING_UNSET: PENDING_UNSET,
2991
+ overlayPendingValues: overlayPendingValues,
2992
+ reconcilePendingValues: reconcilePendingValues,
2926
2993
  migratePresetWeights: migratePresetWeights,
2927
2994
  LINE_HEIGHT_FIELD: LINE_HEIGHT_FIELD,
2928
2995
  LINE_HEIGHT_DIALOG_FIELD: LINE_HEIGHT_DIALOG_FIELD,
@@ -3966,6 +4033,93 @@ var weightProbeCanvas = null;
3966
4033
  /** Cache of measured slider shapes, keyed by the resolved family stack. */
3967
4034
  var weightProfileCache = {};
3968
4035
 
4036
+ /**
4037
+ * Values the card has moved but the settings document has not confirmed yet.
4038
+ *
4039
+ * The card paints these over the document's own values the moment the user lets go
4040
+ * (see `repaint`), and the document's later echo replaces them one by one. Without
4041
+ * the overlay a late echo of the PREVIOUS value would snap the page back for a
4042
+ * moment — the same "it jumped back" flicker the slider ledger exists to prevent.
4043
+ */
4044
+ var pendingLocalValues = {};
4045
+
4046
+ /** How long a locally painted value is trusted while the document has not echoed it. */
4047
+ var PAINT_PATIENCE_MS = 45000;
4048
+
4049
+ /**
4050
+ * When a control gives the user's value back on a host that answers NOTHING.
4051
+ *
4052
+ * A refusal is handled by result (`refuseField`), never by a clock. This is the last
4053
+ * resort for a write whose answer never arrives at all, so it sits far above the
4054
+ * slowest answer measured on this host line: one settings write took 1.0-5.14 s on
4055
+ * an idle instance (median 1.3 s), and the 7-8 s a loaded instance needs for a click
4056
+ * is two writes one after the other, i.e. about 4 s each. At 30 s this cannot fire
4057
+ * for a write that is merely slow — which is exactly what the 6 s patience it
4058
+ * replaces did, on writes that were all accepted.
4059
+ */
4060
+ var WRITE_FALLBACK_MS = 30000;
4061
+
4062
+ /**
4063
+ * How long one settings call may stay unanswered before the next one is sent.
4064
+ *
4065
+ * One call is in flight at a time (see the card's write queue), so a settings call
4066
+ * that never answers would stall every later click; this releases the queue. It is
4067
+ * the same 30 s as the control's own fallback and deliberately not shorter: a queue
4068
+ * released while its call is still in flight would put two patches for one field on
4069
+ * the wire, and the older answer can then arrive last.
4070
+ */
4071
+ var WRITE_SETTLE_TIMEOUT_MS = 30000;
4072
+
4073
+ /**
4074
+ * How long the card waits for the user to stop before it writes.
4075
+ *
4076
+ * Kept at 0 for this release: one move is written straight away, which is the
4077
+ * behaviour that has been tried on a real page. A quiet window that folds a whole
4078
+ * run of clicks into ONE write (300 ms) is implemented and covered by offline
4079
+ * cases, but it has not been checked on a real page, so it waits for the next
4080
+ * release.
4081
+ */
4082
+ var WRITE_QUIET_MS = 0;
4083
+
4084
+ /**
4085
+ * How long a scheduled local paint may wait for its frame before it paints anyway.
4086
+ *
4087
+ * A frame is the right moment to paint (several moves inside one frame cost one
4088
+ * rebuild), but frame production is not guaranteed: a throttled or backgrounded tab
4089
+ * can stop it for as long as it likes, and a paint that waits forever is a page that
4090
+ * looks frozen on the old value while the control shows the new one.
4091
+ */
4092
+ var PAINT_BACKSTOP_MS = 250;
4093
+
4094
+ /**
4095
+ * Repaint the injected stylesheet from the document plus the pending values.
4096
+ *
4097
+ * Set by `apply`, which owns the `<style>` element; the card calls it through
4098
+ * `requestAnimationFrame` when it queues a move, so a burst of moves paints once
4099
+ * with the latest value instead of once per click.
4100
+ */
4101
+ var repaint = null;
4102
+
4103
+ /**
4104
+ * Record one step of a slider's local-value ledger when the page is being probed.
4105
+ *
4106
+ * `globalThis.__DFP_PROBE__` is the same switch `apply` uses to describe the scope it
4107
+ * bound. With it on, every commit, reconciliation, refusal and fallback is
4108
+ * timestamped into `__DFP_PROBE_LOG__`, so a live run can be read back instead of
4109
+ * inferred from what the page ended up showing. Inert in a normal session.
4110
+ * @param {string} kind - which step of the ledger this is.
4111
+ * @param {object} detail - what the ledger held at that moment.
4112
+ */
4113
+ function probeLog(kind, detail) {
4114
+ if (globalThis.__DFP_PROBE__ !== true) return;
4115
+ var log = globalThis.__DFP_PROBE_LOG__;
4116
+ if (!Array.isArray(log)) {
4117
+ log = [];
4118
+ globalThis.__DFP_PROBE_LOG__ = log;
4119
+ }
4120
+ log.push({ at: Date.now(), kind: kind, detail: detail });
4121
+ }
4122
+
3969
4123
  /**
3970
4124
  * Render the sample at one weight and describe the result.
3971
4125
  *
@@ -4279,6 +4433,12 @@ function NumberSlider(props) {
4279
4433
  awaiting: awaitingRef.current,
4280
4434
  confirmed: props.value,
4281
4435
  });
4436
+ probeLog("reconcile", {
4437
+ pending: pendingRef.current,
4438
+ awaiting: awaitingRef.current,
4439
+ confirmed: props.value,
4440
+ kept: next,
4441
+ });
4282
4442
  awaitingRef.current = next.awaiting;
4283
4443
  setLocal(next.pending);
4284
4444
  return undefined;
@@ -4286,6 +4446,7 @@ function NumberSlider(props) {
4286
4446
  [props.value]
4287
4447
  );
4288
4448
  var commit = function (value) {
4449
+ probeLog("commit", { value: value });
4289
4450
  awaitingRef.current = value;
4290
4451
  setReverted(false);
4291
4452
  props.onChange(value);
@@ -4297,19 +4458,45 @@ function NumberSlider(props) {
4297
4458
  * stops the "bounce back, then settle" flicker — but a write the host REFUSES
4298
4459
  * (an out-of-range value against an older schema, a read-only deployment) never
4299
4460
  * echoes, and the slider would then sit on a value the document does not have,
4300
- * i.e. it would look like it jumped somewhere else and stayed there. The
4301
- * document is the truth, so after a few seconds the local value yields to it.
4461
+ * i.e. it would look like it jumped somewhere else and stayed there.
4462
+ *
4463
+ * Which is why the decision is made by RESULT, not by a clock. The card raises
4464
+ * `refusedToken` the moment the host answers "no" (`false` or a rejection), and
4465
+ * that is the only thing that takes the user's value off the screen right away.
4466
+ * A write that is merely still in flight keeps showing what the user chose,
4467
+ * however long the host takes: a timer short enough to fire during a healthy
4468
+ * write was the whole reason a slow instance looked like it "reverted to the
4469
+ * stored value, then moved again".
4470
+ *
4471
+ * The timer that remains only covers a host that answers NOTHING at all, so it
4472
+ * sits far above the slowest answer ever measured here ({@link WRITE_FALLBACK_MS}).
4302
4473
  */
4303
- var COMMIT_PATIENCE_MS = 6000;
4474
+ var refusedToken = typeof props.refusedToken === "number" ? props.refusedToken : 0;
4475
+ useEffect(
4476
+ function () {
4477
+ if (refusedToken === 0) return undefined;
4478
+ // The host looked at this write and said no: no echo is coming.
4479
+ probeLog("refused", { token: refusedToken, awaiting: awaitingRef.current });
4480
+ awaitingRef.current = null;
4481
+ setLocal(null);
4482
+ setReverted(true);
4483
+ return undefined;
4484
+ },
4485
+ [refusedToken]
4486
+ );
4304
4487
  useEffect(
4305
4488
  function () {
4306
4489
  if (awaitingRef.current === null) return undefined;
4307
4490
  var timer = setTimeout(function () {
4491
+ // The ledger is the authority, not this timer: a value confirmed while the
4492
+ // timer was pending must not report anything, whatever the timer was told.
4493
+ if (awaitingRef.current === null) return;
4494
+ probeLog("fallback", { awaiting: awaitingRef.current, pending: pendingRef.current });
4308
4495
  awaitingRef.current = null;
4309
4496
  setLocal(null);
4310
- // The document is the truth and it never took this value: say so once.
4497
+ // Nothing came back at all, so the document is the only truth there is.
4311
4498
  setReverted(true);
4312
- }, COMMIT_PATIENCE_MS);
4499
+ }, WRITE_FALLBACK_MS);
4313
4500
  return function () {
4314
4501
  clearTimeout(timer);
4315
4502
  };
@@ -4324,7 +4511,10 @@ function NumberSlider(props) {
4324
4511
  // The guard compares values instead of "is anything outstanding" — a
4325
4512
  // commit still waiting for the document must not swallow the next drag's
4326
4513
  // release, which silently dropped that adjustment on a slow host.
4327
- if (awaitingRef.current === pending) return;
4514
+ if (awaitingRef.current === pending) {
4515
+ probeLog("release-ignored", { pending: pending, awaiting: awaitingRef.current });
4516
+ return;
4517
+ }
4328
4518
  commit(pending);
4329
4519
  };
4330
4520
  window.addEventListener("pointerup", release, true);
@@ -4359,7 +4549,11 @@ function NumberSlider(props) {
4359
4549
  disabled: props.disabled,
4360
4550
  "aria-label": props.label,
4361
4551
  onChange: function (event) {
4362
- setPending(Number(event.target.value));
4552
+ // Through `setLocal`, not `setPending`: the ref is what the reconciliation
4553
+ // effect reads, and a ref that lags the state makes that effect clear a
4554
+ // value the user is still holding — and leave the fallback timer of the
4555
+ // cleared value running, which then reports a write that had been taken.
4556
+ setLocal(Number(event.target.value));
4363
4557
  },
4364
4558
  onKeyUp: function () {
4365
4559
  if (pending !== null) commit(pending);
@@ -5436,6 +5630,14 @@ function FontCard(props) {
5436
5630
  /** True while the card itself writes a whole preset's values: the edits
5437
5631
  * must not mirror back into the very preset being applied. */
5438
5632
  var applyingRef = useRef(false);
5633
+ /**
5634
+ * Per field, how many times the host has refused a write for it.
5635
+ *
5636
+ * A counter rather than a flag so a control can tell one refusal from the next;
5637
+ * see `refuseField`. This is what makes "give the user's value back" a result and
5638
+ * not a guess about how long a healthy write may take.
5639
+ */
5640
+ var [refused, setRefused] = useState({});
5439
5641
  /** The pending auto-hide / fade-out timers of the preset message. */
5440
5642
  var statusIdRef = useRef(0);
5441
5643
  var statusTimerRef = useRef(null);
@@ -5480,27 +5682,27 @@ function FontCard(props) {
5480
5682
 
5481
5683
  /**
5482
5684
  * Mirror one axis value into the active preset's snapshot — the auto-save
5483
- * contract: with a preset selected, every change the user makes IS that
5484
- * preset from now on. Skipped while the card itself applies a whole preset.
5685
+ * contract: with a preset selected, every change the user makes IS that preset
5686
+ * from now on. Skipped while the card itself applies a whole preset.
5687
+ *
5688
+ * Only the INTENT is recorded here. The snapshot itself is built when the batch
5689
+ * is sent (`presetSnapshotOp`), so a burst of clicks stores the values the user
5690
+ * stopped on, and a write that would only move `savedAt` is not made at all.
5485
5691
  * @param {string} field - an axis field name.
5486
5692
  * @param {string|number|boolean} value - the value to store.
5487
5693
  */
5488
5694
  var mirrorPreset = function (field, value) {
5489
- if (!writable || applyingRef.current === true) return;
5490
- var index = -1;
5491
- for (var i = 0; i < presetList.length; i += 1) {
5492
- if (presetList[i].name === activeName) index = i;
5695
+ if (!writable || applyingRef.current === true) {
5696
+ probeLog("mirror-skipped", { field: field, writable: writable, applying: applyingRef.current });
5697
+ return;
5493
5698
  }
5494
- if (index < 0) return;
5495
- var entry = presetList[index];
5496
- var values = {};
5497
- for (var key in entry.values) {
5498
- if (Object.prototype.hasOwnProperty.call(entry.values, key)) values[key] = entry.values[key];
5699
+ if (activePresetEntry() === undefined) {
5700
+ probeLog("mirror-no-preset", { field: field, activeName: activeName, presets: presetList.length });
5701
+ return;
5499
5702
  }
5500
- values[field] = value;
5501
- var next = presetList.slice();
5502
- next[index] = { name: entry.name, values: values, savedAt: Date.now() };
5503
- writePresets(next);
5703
+ pendingPresetValues[field] = { op: shared.PENDING_SET, value: value };
5704
+ if (pendingPreset === null) pendingPreset = { kind: "mirror" };
5705
+ scheduleFlush();
5504
5706
  };
5505
5707
 
5506
5708
  /**
@@ -5533,6 +5735,30 @@ function FontCard(props) {
5533
5735
  if (message.length > 160) message = message.slice(0, 160) + "…";
5534
5736
  showStatus(t("preset.writeFailed", { message: message }));
5535
5737
  };
5738
+ /**
5739
+ * Mark the controls a refused write belonged to, so each gives the user's value
5740
+ * back and says so.
5741
+ *
5742
+ * A refusal is the one moment a local paint is provably wrong: the host looked at
5743
+ * the patch and answered "no", so no echo is coming. It is also the ONLY thing
5744
+ * allowed to take a value off the screen before the document echoes it — a write
5745
+ * that is merely still in flight must keep showing what the user chose.
5746
+ * @param {string[]} fields - the fields the refused write carried.
5747
+ */
5748
+ var refuseField = function (fields) {
5749
+ setRefused(function (previous) {
5750
+ var next = {};
5751
+ for (var key in previous) {
5752
+ if (Object.prototype.hasOwnProperty.call(previous, key)) next[key] = previous[key];
5753
+ }
5754
+ for (var index = 0; index < fields.length; index += 1) {
5755
+ // A token, not a timestamp: two refusals inside one millisecond must still
5756
+ // be two changes, or the control would ignore the second one.
5757
+ next[fields[index]] = (previous[fields[index]] || 0) + 1;
5758
+ }
5759
+ return next;
5760
+ });
5761
+ };
5536
5762
  /**
5537
5763
  * Follow one queued settings write.
5538
5764
  *
@@ -5540,20 +5766,29 @@ function FontCard(props) {
5540
5766
  * a promise that rejects on a transport failure, while 0.1.7-alpha.x resolves
5541
5767
  * `false` for a write the host refused (a stale revision, say). Both are
5542
5768
  * reported, and a refused write is never left looking saved.
5769
+ * @param {string[]} fields - the fields this write carried.
5543
5770
  * @param {unknown} result - what the scope returned.
5771
+ * @param {(applied: boolean) => void} [onSettled] - called once with the verdict.
5544
5772
  */
5545
- var followWrite = function (result) {
5773
+ var followWrite = function (fields, result, onSettled) {
5774
+ var done = function (applied, error) {
5775
+ if (applied === false) {
5776
+ refuseField(fields);
5777
+ writeFailed(error);
5778
+ }
5779
+ if (typeof onSettled === "function") onSettled(applied);
5780
+ };
5546
5781
  if (result === false) {
5547
- writeFailed(result);
5782
+ done(false, result);
5548
5783
  return;
5549
5784
  }
5550
5785
  if (result && typeof result.then === "function") {
5551
5786
  result.then(function (accepted) {
5552
- if (accepted === false) writeFailed(accepted);
5787
+ done(accepted !== false, accepted);
5553
5788
  }, function (error) {
5554
5789
  // The scope reloads the Host state itself; the status row is what
5555
5790
  // keeps the user from thinking the change stuck.
5556
- writeFailed(error);
5791
+ done(false, error);
5557
5792
  });
5558
5793
  return;
5559
5794
  }
@@ -5562,30 +5797,379 @@ function FontCard(props) {
5562
5797
  writeFailed(error);
5563
5798
  });
5564
5799
  }
5800
+ done(true);
5565
5801
  };
5566
- var submit = function (field, value) {
5567
- var result;
5568
- try {
5569
- result = scope.set(field, value);
5570
- } catch (error) {
5571
- writeFailed(error);
5802
+ /**
5803
+ * Our own writes: one batch in flight, newest value per field.
5804
+ *
5805
+ * Two measured facts shape this. A settings call is expensive — the host answers
5806
+ * one by rewriting the whole profile document and re-applying the whole patch list
5807
+ * to the loader, 1.0-5.14 s per call and one document write each — so a click must
5808
+ * not spend two of them, and a burst of clicks must not spend one per click. And
5809
+ * DSH validates a patch against the revision it was built from, so patches that
5810
+ * overlap in flight can be refused as stale or land out of order.
5811
+ *
5812
+ * Hence: intents are collected per field (the newest wins), everything one turn
5813
+ * produced goes out as ONE call, and the next call waits for this one's answer.
5814
+ */
5815
+ var pendingOps = {};
5816
+ /** The order fields were touched in, so a batch keeps a stable order. */
5817
+ var pendingOrder = [];
5818
+ /** The preset snapshot the pending axis values imply; `null` when none is due. */
5819
+ var pendingPreset = null;
5820
+ /** The axis values the pending preset snapshot must end up holding. */
5821
+ var pendingPresetValues = {};
5822
+ /** One call at a time: a batch that arrives while one is in flight waits. */
5823
+ var writing = false;
5824
+ /** The quiet window's timer; null when no window is armed. */
5825
+ var flushTimer = null;
5826
+ /**
5827
+ * Stop painting a local move once its fate is known.
5828
+ * @param {string} field - the field the patch was for.
5829
+ */
5830
+ var retireLocalMove = function (field) {
5831
+ delete pendingLocalValues[field];
5832
+ };
5833
+ /**
5834
+ * The preset entry the axis edits mirror into, if there is one.
5835
+ * @returns {{name: string, values: object}|undefined}
5836
+ */
5837
+ var activePresetEntry = function () {
5838
+ for (var index = 0; index < presetList.length; index += 1) {
5839
+ if (presetList[index].name === activeName) return presetList[index];
5840
+ }
5841
+ return undefined;
5842
+ };
5843
+ /**
5844
+ * The preset snapshot the pending axis values imply, as one op — or `null` when
5845
+ * there is nothing to store.
5846
+ *
5847
+ * The snapshot is derived HERE rather than where the click happened, because a
5848
+ * burst is one write: what must land is the snapshot the LAST value implies, not
5849
+ * the one the first click saw. `savedAt` alone never justifies a write — that is
5850
+ * why the stored values are compared before one is built.
5851
+ * @returns {{op: string, path: string[], value: unknown}|null}
5852
+ */
5853
+ var presetSnapshotOp = function () {
5854
+ if (pendingPreset === null) return null;
5855
+ if (pendingPreset.kind === "json") {
5856
+ // An explicit list (save, delete, import, migration) is the user's own edit
5857
+ // and wins over the mirror.
5858
+ probeLog("preset-op", { kind: "json" });
5859
+ return { op: shared.PENDING_SET, path: [PRESETS_FIELD], value: pendingPreset.value };
5860
+ }
5861
+ var entry = activePresetEntry();
5862
+ if (entry === undefined) {
5863
+ probeLog("preset-op", { kind: "mirror", why: "no active entry" });
5864
+ return null;
5865
+ }
5866
+ var values = {};
5867
+ var key;
5868
+ for (key in entry.values) {
5869
+ if (Object.prototype.hasOwnProperty.call(entry.values, key)) values[key] = entry.values[key];
5870
+ }
5871
+ var changed = false;
5872
+ for (key in pendingPresetValues) {
5873
+ if (!Object.prototype.hasOwnProperty.call(pendingPresetValues, key)) continue;
5874
+ var move = pendingPresetValues[key];
5875
+ if (move.op === shared.PENDING_UNSET) {
5876
+ if (Object.prototype.hasOwnProperty.call(values, key)) {
5877
+ delete values[key];
5878
+ changed = true;
5879
+ }
5880
+ continue;
5881
+ }
5882
+ if (values[key] !== move.value) {
5883
+ values[key] = move.value;
5884
+ changed = true;
5885
+ }
5886
+ }
5887
+ // Nothing moved: the only difference would be the `savedAt` timestamp, and a
5888
+ // whole-document rewrite for a timestamp is a write nobody asked for.
5889
+ if (!changed) {
5890
+ probeLog("preset-op", { kind: "mirror", why: "unchanged", values: values });
5891
+ return null;
5892
+ }
5893
+ var next = presetList.slice();
5894
+ for (var index = 0; index < next.length; index += 1) {
5895
+ if (next[index].name === entry.name) {
5896
+ next[index] = { name: entry.name, values: values, savedAt: Date.now() };
5897
+ }
5898
+ }
5899
+ return { op: shared.PENDING_SET, path: [PRESETS_FIELD], value: JSON.stringify(next) };
5900
+ };
5901
+ /**
5902
+ * Everything one flush must write, as ordered settings ops.
5903
+ * @returns {{op: string, path: string[], value?: unknown}[]}
5904
+ */
5905
+ var collectOps = function () { var ops = [];
5906
+ for (var index = 0; index < pendingOrder.length; index += 1) {
5907
+ var field = pendingOrder[index];
5908
+ if (field === PRESETS_FIELD) continue; // always rebuilt from the newest axes
5909
+ var patch = pendingOps[field];
5910
+ if (patch === undefined) continue;
5911
+ ops.push({ op: patch.op, path: [field], value: patch.value });
5912
+ }
5913
+ var snapshot = presetSnapshotOp();
5914
+ if (snapshot !== null) ops.push(snapshot);
5915
+ probeLog("batch", {
5916
+ ops: ops.map(function (op) {
5917
+ return op.path[0] + "=" + String(op.value).slice(0, 24);
5918
+ }),
5919
+ preset: pendingPreset === null ? "none" : pendingPreset.kind,
5920
+ });
5921
+ return ops;
5922
+ };
5923
+ /**
5924
+ * Send one batch, through `mutate` when the running host offers it.
5925
+ *
5926
+ * 0.1.7-alpha.x's config form exposes `mutate(ops)`, which the host applies as ONE
5927
+ * patch — that is what lets the axis value and the preset snapshot that mirrors it
5928
+ * share a single document write. A host whose scope only takes one field at a time
5929
+ * still works: the batch is written field by field there and the last verdict is
5930
+ * the batch's.
5931
+ * @param {{op: string, path: string[], value?: unknown}[]} ops - the batch.
5932
+ * @param {(applied: boolean) => void} done - called once with the verdict.
5933
+ */
5934
+ var sendOps = function (ops, done) {
5935
+ var fields = [];
5936
+ for (var index = 0; index < ops.length; index += 1) {
5937
+ if (ops[index].path[0] !== PRESETS_FIELD) fields.push(ops[index].path[0]);
5938
+ }
5939
+ var canBatch =
5940
+ typeof scope.canMutate === "function" ? scope.canMutate() : typeof scope.mutate === "function";
5941
+ if (canBatch) {
5942
+ var batch = null;
5943
+ try {
5944
+ batch = scope.mutate(ops);
5945
+ } catch (error) {
5946
+ writeFailed(error);
5947
+ done(false);
5948
+ return;
5949
+ }
5950
+ followWrite(fields, batch, done);
5572
5951
  return;
5573
5952
  }
5574
- followWrite(result);
5953
+ var at = 0;
5954
+ var step = function () {
5955
+ if (at >= ops.length) {
5956
+ done(true);
5957
+ return;
5958
+ }
5959
+ var op = ops[at];
5960
+ at += 1;
5961
+ var single = null;
5962
+ try {
5963
+ single = op.op === shared.PENDING_UNSET ? scope.unset(op.path[0]) : scope.set(op.path[0], op.value);
5964
+ } catch (error) {
5965
+ writeFailed(error);
5966
+ done(false);
5967
+ return;
5968
+ }
5969
+ followWrite(op.path[0] === PRESETS_FIELD ? fields : [op.path[0]], single, function (applied) {
5970
+ if (applied === false) {
5971
+ done(false);
5972
+ return;
5973
+ }
5974
+ step();
5975
+ });
5976
+ };
5977
+ step();
5978
+ };
5979
+ /** Send what is pending, if the previous batch has been answered. */
5980
+ var flushWrites = function () {
5981
+ if (writing) return;
5982
+ var ops = collectOps();
5983
+ if (ops.length === 0) return;
5984
+ var sent = [];
5985
+ for (var index = 0; index < pendingOrder.length; index += 1) {
5986
+ if (pendingOrder[index] !== PRESETS_FIELD) sent.push(pendingOrder[index]);
5987
+ }
5988
+ pendingOps = {};
5989
+ pendingOrder = [];
5990
+ pendingPreset = null;
5991
+ pendingPresetValues = {};
5992
+ // A local move that is now on the wire is not re-painted from the document: the
5993
+ // overlay keeps the user's value until the echo arrives.
5994
+ writing = true;
5995
+ var settled = false;
5996
+ var watchdog = globalThis.setTimeout(function () {
5997
+ // Nothing came back at all. Release the queue for later clicks; the control
5998
+ // yields to the document through its own fallback, not through this.
5999
+ finish(true);
6000
+ }, WRITE_SETTLE_TIMEOUT_MS);
6001
+ var finish = function (applied) {
6002
+ if (settled) return;
6003
+ settled = true;
6004
+ globalThis.clearTimeout(watchdog);
6005
+ writing = false;
6006
+ if (applied === false) {
6007
+ for (var at = 0; at < sent.length; at += 1) retireLocalMove(sent[at]);
6008
+ }
6009
+ // What arrived while this one was in flight is the next batch — but only once
6010
+ // the user has stopped: while a quiet window is armed IT decides, so a burst
6011
+ // that straddles two writes still ends on one write carrying its last value.
6012
+ if (flushTimer === null && hasPending()) flushWrites();
6013
+ };
6014
+ sendOps(ops, finish);
6015
+ };
6016
+ /** Whether anything is waiting to be written. */
6017
+ var hasPending = function () {
6018
+ return pendingOrder.length > 0 || pendingPreset !== null;
6019
+ };
6020
+ /**
6021
+ * Wait for the user to stop, then write everything they did as ONE patch.
6022
+ *
6023
+ * Every queued move re-arms the window, so a run of clicks — on one control or
6024
+ * across several — leaves exactly one settings call behind, holding the value the
6025
+ * user stopped on. That is what takes the last of the jumping out: the document
6026
+ * used to take on the first click's value and every intermediate one, and each of
6027
+ * those states echoed back into the page.
6028
+ *
6029
+ * The window is short enough to stay invisible (the page already shows the change
6030
+ * the moment the user lets go — see `rememberLocalMove`) and long enough to span a
6031
+ * person clicking faster than they can read the number. What it costs is covered
6032
+ * by `flushPendingWrites`: a page that goes away inside the window.
6033
+ */
6034
+ var scheduleFlush = function () {
6035
+ if (flushTimer !== null) globalThis.clearTimeout(flushTimer);
6036
+ flushTimer = globalThis.setTimeout(function () {
6037
+ flushTimer = null;
6038
+ flushWrites();
6039
+ }, WRITE_QUIET_MS);
6040
+ };
6041
+ /** Write what is pending right now, whatever the window says. */
6042
+ var flushPendingWrites = function () {
6043
+ if (flushTimer !== null) {
6044
+ globalThis.clearTimeout(flushTimer);
6045
+ flushTimer = null;
6046
+ }
6047
+ flushWrites();
6048
+ };
6049
+ /**
6050
+ * Record one move: paint it now, write it with the next batch.
6051
+ * @param {string} field - a settings field name.
6052
+ * @param {string} op - `PENDING_SET` or `PENDING_UNSET`.
6053
+ * @param {string|number|boolean} value - the value to paint and send.
6054
+ */
6055
+ var queuePatch = function (field, op, value) {
6056
+ rememberLocalMove(field, op, value);
6057
+ if (pendingOps[field] === undefined) pendingOrder.push(field);
6058
+ pendingOps[field] = { op: op, value: value };
6059
+ scheduleFlush();
6060
+ };
6061
+ var submit = function (field, value) {
6062
+ queuePatch(field, shared.PENDING_SET, value);
5575
6063
  };
5576
6064
  var clear = function (field) {
5577
- var result;
5578
- try {
5579
- result = scope.unset(field);
5580
- } catch (error) {
5581
- writeFailed(error);
6065
+ queuePatch(field, shared.PENDING_UNSET, undefined);
6066
+ };
6067
+ /** The frame handle for the next local paint; null when no paint is scheduled. */
6068
+ var paintFrame = null;
6069
+ /** The backstop timer for that paint; null when none is armed. */
6070
+ var paintBackstop = null;
6071
+ /**
6072
+ * Paint one move locally, right now.
6073
+ *
6074
+ * This is what makes a click land immediately: the settings document only echoes
6075
+ * the value after the host itself has taken a whole write (measured 1.0-5.1 s),
6076
+ * and until that echo arrives the page would keep showing the previous value —
6077
+ * clicking through several notches then stalls once per notch, each stall as long
6078
+ * as the write took.
6079
+ *
6080
+ * The paint is deferred to the next frame, so several moves inside one frame paint
6081
+ * once (the latest wins) instead of once per move. It carries a timer as well
6082
+ * because a frame the browser never produces (a throttled or backgrounded tab)
6083
+ * would otherwise take the paint with it — and leave the handle set, which skips
6084
+ * every LATER move too, on a page that looks like it stopped following.
6085
+ * @param {string} field - a settings field name.
6086
+ * @param {string} op - `PENDING_SET` or `PENDING_UNSET`.
6087
+ * @param {string|number|boolean} value - the value to paint.
6088
+ */
6089
+ var rememberLocalMove = function (field, op, value) {
6090
+ // A snapshot or a selection is not a value the stylesheet can show, so it is
6091
+ // never painted over the document's own.
6092
+ if (field === PRESETS_FIELD || field === ACTIVE_PRESET_FIELD) return;
6093
+ // A re-assert of the SAME value keeps its counter; a fresh choice starts over.
6094
+ var previous = pendingLocalValues[field];
6095
+ var same = previous !== undefined && previous.op === op && previous.value === value;
6096
+ pendingLocalValues[field] = {
6097
+ op: op,
6098
+ value: value,
6099
+ at: Date.now(),
6100
+ tries: same ? previous.tries || 0 : 0,
6101
+ };
6102
+ if (paintFrame !== null || typeof globalThis.requestAnimationFrame !== "function") {
6103
+ probeLog("remember-skipped", {
6104
+ field: field,
6105
+ value: value,
6106
+ pendingFrame: paintFrame !== null,
6107
+ hasRaf: typeof globalThis.requestAnimationFrame === "function",
6108
+ });
5582
6109
  return;
5583
6110
  }
5584
- followWrite(result);
6111
+ var done = false;
6112
+ var paintNow = function (why) {
6113
+ if (done) return;
6114
+ done = true;
6115
+ paintFrame = null;
6116
+ if (paintBackstop !== null) {
6117
+ globalThis.clearTimeout(paintBackstop);
6118
+ paintBackstop = null;
6119
+ }
6120
+ probeLog("paint", { field: field, why: why, painter: typeof repaint === "function" });
6121
+ if (typeof repaint === "function") repaint();
6122
+ };
6123
+ paintFrame = globalThis.requestAnimationFrame(function () {
6124
+ paintNow("frame");
6125
+ });
6126
+ paintBackstop = globalThis.setTimeout(function () {
6127
+ paintNow("backstop");
6128
+ }, PAINT_BACKSTOP_MS);
5585
6129
  };
5586
6130
  var overridden = function (field) {
5587
6131
  return Object.prototype.hasOwnProperty.call(user, field);
5588
6132
  };
6133
+ /**
6134
+ * The queue as the LAST render saw it, for listeners that outlive that render.
6135
+ *
6136
+ * Everything this card queues lives in render-scoped bindings (`flushTimer`,
6137
+ * `pendingOps`, `pendingOrder`), and a function component gets a fresh set of them
6138
+ * on every render. The listeners below are registered once, by the render that
6139
+ * mounted the card, so their `flushPendingWrites` reads the queue THAT render had
6140
+ * — which by the time a user has moved a control is the empty one it started with.
6141
+ * Measured: a move queued in render 2, `pagehide` at +91 ms flushed render 1's
6142
+ * empty queue, and the write only left at +306 ms when the window expired. The ref
6143
+ * is refreshed on every render, so the early close always closes the live window.
6144
+ */
6145
+ var flushRef = useRef(null);
6146
+ flushRef.current = flushPendingWrites;
6147
+ /**
6148
+ * Close the quiet window early whenever the page is about to stop being able to.
6149
+ *
6150
+ * Waiting for the user to stop is only safe if "the user stopped" cannot be
6151
+ * followed by "the page went away" before the window expires. Three moments cover
6152
+ * that: the settings sheet closing (this effect's cleanup), the page being hidden
6153
+ * (a tab switch, a minimise, most ways of leaving), and `pagehide` (navigation,
6154
+ * bfcache). What remains is a hard kill with no event at all, which no client-side
6155
+ * debounce can cover — the exposure is the window itself, a few hundred ms.
6156
+ */
6157
+ useEffect(function () {
6158
+ var closeWindow = function () {
6159
+ // Through the ref: this closure is the mount render's, the queue is not.
6160
+ if (typeof flushRef.current === "function") flushRef.current();
6161
+ };
6162
+ var onHidden = function () {
6163
+ if (document.visibilityState === "hidden") closeWindow();
6164
+ };
6165
+ globalThis.addEventListener("pagehide", closeWindow);
6166
+ document.addEventListener("visibilitychange", onHidden);
6167
+ return function () {
6168
+ globalThis.removeEventListener("pagehide", closeWindow);
6169
+ document.removeEventListener("visibilitychange", onHidden);
6170
+ closeWindow();
6171
+ };
6172
+ }, []);
5589
6173
 
5590
6174
  // ---- family stack handlers ----
5591
6175
  var pickWest = function (field, family) {
@@ -5619,6 +6203,7 @@ function FontCard(props) {
5619
6203
  disabled: !writable,
5620
6204
  label: t(props.labelKey),
5621
6205
  revertedText: t("write.reverted"),
6206
+ refusedToken: refused[props.field] || 0,
5622
6207
  readout: props.text + " " + t("size.unit"),
5623
6208
  minLabel: SIZE_MIN + " " + t("size.unit"),
5624
6209
  maxLabel: "+" + SIZE_MAX + " " + t("size.unit"),
@@ -5674,6 +6259,7 @@ function FontCard(props) {
5674
6259
  disabled: !writable,
5675
6260
  label: t(props.labelKey),
5676
6261
  revertedText: t("write.reverted"),
6262
+ refusedToken: refused[props.field] || 0,
5677
6263
  // One notch is one family step, so every notch changes what is on screen.
5678
6264
  step: 1,
5679
6265
  // The readout is the STEP COUNT, not the weight: "+2" is two faces
@@ -5719,6 +6305,7 @@ function FontCard(props) {
5719
6305
  disabled: !writable,
5720
6306
  label: t(props.labelKey),
5721
6307
  revertedText: t("write.reverted"),
6308
+ refusedToken: refused[props.field] || 0,
5722
6309
  readout: props.value + " " + t("line.unit"),
5723
6310
  minLabel: LINE_HEIGHT_MIN + " " + t("line.unit"),
5724
6311
  maxLabel: LINE_HEIGHT_MAX + " " + t("line.unit"),
@@ -5756,6 +6343,7 @@ function FontCard(props) {
5756
6343
  disabled: !writable,
5757
6344
  label: t("line.codeLabel"),
5758
6345
  revertedText: t("write.reverted"),
6346
+ refusedToken: refused[CODE_LINE_HEIGHT_FIELD] || 0,
5759
6347
  readout: (value > 0 ? "+" + value : String(value)) + "px",
5760
6348
  minLabel: CODE_LINE_HEIGHT_MIN + "px",
5761
6349
  maxLabel: "+" + CODE_LINE_HEIGHT_MAX + "px",
@@ -5895,9 +6483,17 @@ function FontCard(props) {
5895
6483
  [presetStatus.id]
5896
6484
  );
5897
6485
 
6486
+ /**
6487
+ * Write a whole preset list the caller built (save, delete, import, migration).
6488
+ *
6489
+ * An explicit list, so it is not compared against what is stored: the caller
6490
+ * decided, and a no-op here would silently drop a rename or a deletion.
6491
+ * @param {{name: string, values: object}[]} next - the list to store.
6492
+ */
5898
6493
  var writePresets = function (next) {
5899
6494
  if (next.length > shared.MAX_PRESETS) next = next.slice(0, shared.MAX_PRESETS);
5900
- submit(PRESETS_FIELD, JSON.stringify(next));
6495
+ pendingPreset = { kind: "json", value: JSON.stringify(next) };
6496
+ scheduleFlush();
5901
6497
  };
5902
6498
 
5903
6499
  /**
@@ -5910,27 +6506,45 @@ function FontCard(props) {
5910
6506
  * convert whatever is still absolute (the three fields and every saved snapshot),
5911
6507
  * then set the marker.
5912
6508
  *
6509
+ * The values are read from the RAW settings document, not from the normalized
6510
+ * `config`: normalization has already converted a legacy absolute weight
6511
+ * (`450 → 50`), so looking for one on the converted side finds nothing — and the
6512
+ * marker would then re-label the stored `450` as +450, turning a +50 code weight
6513
+ * into +450 in one click.
6514
+ *
5913
6515
  * Values first, marker last: a stylesheet rebuild between the two still sees
5914
6516
  * values that render exactly as before, and a marked document is never left
5915
6517
  * holding an unconverted absolute weight.
5916
6518
  *
5917
- * The three fields are read from `config`, not from the expanded `editing` set:
5918
- * while the interface follows the conversation, `editing` mirrors the
5919
- * conversation's weight into the interface's own field, and writing that back
5920
- * would silently freeze the follow.
6519
+ * The three fields are read from the document rather than from the expanded
6520
+ * `editing` set too: while the interface follows the conversation, `editing`
6521
+ * mirrors the conversation's weight into the interface's own field, and writing
6522
+ * that back would silently freeze the follow.
5921
6523
  */
5922
6524
  var adoptWeightScale = function () {
5923
6525
  if (config[WEIGHT_OFFSETS_FIELD] === true) return;
6526
+ var raw = snapshot.value !== null && typeof snapshot.value === "object" ? snapshot.value : {};
5924
6527
  for (var index = 0; index < shared.WEIGHT_FIELDS.length; index += 1) {
5925
6528
  var field = shared.WEIGHT_FIELDS[index];
5926
- var stored = Number(config[field]);
6529
+ var stored = Number(raw[field]);
5927
6530
  if (!isFinite(stored) || stored < shared.WEIGHT_MIN || stored > shared.WEIGHT_MAX) {
5928
6531
  continue;
5929
6532
  }
5930
6533
  submit(field, stored - shared.WEIGHT_BASE);
5931
6534
  }
5932
- var migrated = shared.migratePresetWeights(config[PRESETS_FIELD]);
5933
- if (migrated.changed) writePresets(migrated.presets);
6535
+ // The snapshots are migrated from their stored JSON for the same reason: the
6536
+ // normalized list has already been converted, so it would report no change.
6537
+ var rawPresets = raw[PRESETS_FIELD];
6538
+ if (typeof rawPresets === "string" && rawPresets.trim() !== "") {
6539
+ var parsed = null;
6540
+ try {
6541
+ parsed = JSON.parse(rawPresets);
6542
+ } catch (error) {
6543
+ parsed = null;
6544
+ }
6545
+ var migrated = shared.migratePresetWeights(parsed);
6546
+ if (migrated.changed) writePresets(migrated.presets);
6547
+ }
5934
6548
  submit(WEIGHT_OFFSETS_FIELD, true);
5935
6549
  };
5936
6550
 
@@ -6756,6 +7370,26 @@ function apply(ctx) {
6756
7370
  }
6757
7371
  return target.set(field, value);
6758
7372
  },
7373
+ /**
7374
+ * The one-call batch, when the adopted scope has it.
7375
+ *
7376
+ * Answered at CALL time, not at bind time: the seat is decided while the host
7377
+ * is still describing itself, and the older `settingsScope` dialect has no
7378
+ * `mutate` at all. `canMutate` is what the card asks first, so a host without
7379
+ * it takes the one-field-per-call path instead of an error.
7380
+ */
7381
+ canMutate: function () {
7382
+ return target !== null && typeof target.mutate === "function";
7383
+ },
7384
+ mutate: function (ops) {
7385
+ if (target === null) {
7386
+ return Promise.reject(new Error("the settings service is still starting"));
7387
+ }
7388
+ if (typeof target.mutate !== "function") {
7389
+ return Promise.reject(new Error("this host writes one field at a time"));
7390
+ }
7391
+ return target.mutate(ops);
7392
+ },
6759
7393
  unset: function (field) {
6760
7394
  if (target === null) {
6761
7395
  return Promise.reject(new Error("the settings service is still starting"));
@@ -6892,10 +7526,26 @@ function apply(ctx) {
6892
7526
  var sync = function () {
6893
7527
  var snapshot = scope.getSnapshot();
6894
7528
  if (snapshot.value === undefined) return;
7529
+ // A move the card already painted wins until the document echoes it, so a late
7530
+ // echo of an OLDER value neither steps the page backwards nor costs a repaint.
7531
+ pendingLocalValues = shared.reconcilePendingValues(
7532
+ pendingLocalValues,
7533
+ snapshot.value,
7534
+ Date.now(),
7535
+ PAINT_PATIENCE_MS
7536
+ );
6895
7537
  // Light and dark share one value set in this release: the stored flag (if
6896
7538
  // any) is ignored, so no prefixed dark rules are ever emitted.
6897
- var unified = normalizeConfig(snapshot.value);
7539
+ var unified = normalizeConfig(shared.overlayPendingValues(snapshot.value, pendingLocalValues));
6898
7540
  unified[PER_THEME_FIELD] = false;
7541
+ probeLog("sync", {
7542
+ overlay: Object.keys(pendingLocalValues).map(function (key) {
7543
+ return key + "=" + String(pendingLocalValues[key].value);
7544
+ }),
7545
+ dialog: unified[WEIGHT_DIALOG_FIELD],
7546
+ followed: unified[WEIGHT_FIELD],
7547
+ size: unified[SIZE_DIALOG_FIELD],
7548
+ });
6899
7549
  applyCss(unified);
6900
7550
  applyTokenOverrides(unified);
6901
7551
  };
@@ -6906,6 +7556,8 @@ function apply(ctx) {
6906
7556
  "dsh-fonttune: settings adoption"
6907
7557
  );
6908
7558
  sync();
7559
+ // The card paints a move the moment the user lets go; this is that painter.
7560
+ repaint = sync;
6909
7561
 
6910
7562
  // Which selectors the sheet can be narrowed to depends on what the page
6911
7563
  // contains, and the conversation renders AFTER this bundle activates: the
package/lib/shared.cjs CHANGED
@@ -704,6 +704,69 @@ function migratePresetWeights(presets) {
704
704
  return { presets: out, changed: changed };
705
705
  }
706
706
 
707
+ /** The two shapes one locally painted move takes. */
708
+ var PENDING_SET = "set";
709
+ var PENDING_UNSET = "unset";
710
+
711
+ /**
712
+ * Merge the values the card painted locally over the document's own values.
713
+ *
714
+ * The card paints a move the moment the user lets go, while the settings document
715
+ * only echoes it after the host's debounced write. Painting the DOCUMENT's value
716
+ * in the meantime would show the previous one again — and an echo of an older move
717
+ * arriving after a newer one would visibly step the page backwards. So the local
718
+ * values win until the document catches up (`reconcilePendingValues` decides when
719
+ * that is).
720
+ *
721
+ * @param {unknown} document - the raw settings value.
722
+ * @param {Record<string, {op: string, value: unknown}>} pending - local moves.
723
+ * @returns {Record<string, unknown>} the values to render with.
724
+ */
725
+ function overlayPendingValues(document, pending) {
726
+ var source = document !== null && typeof document === "object" ? document : {};
727
+ var merged = {};
728
+ for (var key in source) {
729
+ if (Object.prototype.hasOwnProperty.call(source, key)) merged[key] = source[key];
730
+ }
731
+ var local = pending !== null && typeof pending === "object" ? pending : {};
732
+ for (var field in local) {
733
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
734
+ if (local[field].op === PENDING_UNSET) delete merged[field];
735
+ else merged[field] = local[field].value;
736
+ }
737
+ return merged;
738
+ }
739
+
740
+ /**
741
+ * Keep only the local moves that are still worth painting.
742
+ *
743
+ * A paint is deliberately NOT retired when the document agrees with it: the host
744
+ * debounces its writes, so a patch built EARLIER can land after a newer one and
745
+ * drag the document back to an older value. Retiring on agreement made exactly
746
+ * that echo visible — the page jumped back and forth and only ended up right at
747
+ * the end. The card corrects the document instead (its re-assert), and the paint
748
+ * stays until the patience runs out. A refused write is retired by the caller,
749
+ * which learns about it before this rule sees the document again.
750
+ *
751
+ * @param {Record<string, {op: string, value: unknown, at: number}>} pending - local moves.
752
+ * @param {unknown} document - the raw settings value (kept for the caller's shape).
753
+ * @param {number} now - current time in ms.
754
+ * @param {number} patienceMs - how long to keep an unconfirmed move.
755
+ * @returns {Record<string, object>} the entries still worth painting.
756
+ */
757
+ function reconcilePendingValues(pending, document, now, patienceMs) {
758
+ var local = pending !== null && typeof pending === "object" ? pending : {};
759
+ var kept = {};
760
+ for (var field in local) {
761
+ if (!Object.prototype.hasOwnProperty.call(local, field)) continue;
762
+ var entry = local[field];
763
+ var stale = typeof entry.at === "number" && now - entry.at > patienceMs;
764
+ if (stale) continue;
765
+ kept[field] = entry;
766
+ }
767
+ return kept;
768
+ }
769
+
707
770
  /**
708
771
  * Parse and validate the durable `presets` JSON string.
709
772
  * @param {unknown} value - candidate JSON text or array.
@@ -2914,6 +2977,10 @@ var shared = {
2914
2977
  CODE_WEIGHT_FIELD: CODE_WEIGHT_FIELD,
2915
2978
  WEIGHT_OFFSETS_FIELD: WEIGHT_OFFSETS_FIELD,
2916
2979
  WEIGHT_FIELDS: WEIGHT_FIELDS,
2980
+ PENDING_SET: PENDING_SET,
2981
+ PENDING_UNSET: PENDING_UNSET,
2982
+ overlayPendingValues: overlayPendingValues,
2983
+ reconcilePendingValues: reconcilePendingValues,
2917
2984
  migratePresetWeights: migratePresetWeights,
2918
2985
  LINE_HEIGHT_FIELD: LINE_HEIGHT_FIELD,
2919
2986
  LINE_HEIGHT_DIALOG_FIELD: LINE_HEIGHT_DIALOG_FIELD,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fonttune",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Font plugin for the DeepSeek Harness Web GUI: the conversation gets its own font, size, line height and weight, the interface font and weight follow it, code keeps its own axis with ligature control, and whole setups save as presets.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -34,7 +34,8 @@
34
34
  "0.1.7-alpha.2": "compatible",
35
35
  "0.1.7-rc.1": "compatible",
36
36
  "0.1.7-rc.2": "compatible",
37
- "0.2.0-rc.1": "compatible"
37
+ "0.2.0-rc.1": "compatible",
38
+ "0.2.0-rc.2": "compatible"
38
39
  }
39
40
  }
40
41
  },