dsh-fonttune 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1194,6 +1194,15 @@ function floatBoundsPick(rects, viewport) {
1194
1194
  if (!isFinite(vh) || vh <= 0) vh = FLOAT_VIEWPORT_H;
1195
1195
  var full = { left: 0, top: 0, right: vw, bottom: vh, width: vw, height: vh };
1196
1196
  var list = Array.isArray(rects) ? rects : [];
1197
+ // A region narrower than one dot used to count as "no region at all", and the
1198
+ // fallback was the WHOLE viewport — so in a narrow window the dot parked on the
1199
+ // window's own corner, over the sidebar, instead of on the conversation's. The
1200
+ // largest readable box is kept as a fallback, and it names itself in `index` so
1201
+ // the watcher holds the box whose size actually defines the region. `full` is
1202
+ // left for a page with no region at all — or only degenerate 0-area rects.
1203
+ var best = null;
1204
+ var bestIndex = -1;
1205
+ var bestArea = -1;
1197
1206
  for (var index = 0; index < list.length; index += 1) {
1198
1207
  var box = floatBoxOf(list[index]);
1199
1208
  if (box === null) continue;
@@ -1203,12 +1212,18 @@ function floatBoundsPick(rects, viewport) {
1203
1212
  var bottom = Math.max(0, Math.min(vh, box.bottom));
1204
1213
  var width = right - left;
1205
1214
  var height = bottom - top;
1215
+ if (width > 0 && height > 0 && width * height > bestArea) {
1216
+ bestArea = width * height;
1217
+ bestIndex = index;
1218
+ best = { left: left, top: top, right: right, bottom: bottom, width: width, height: height };
1219
+ }
1206
1220
  if (width < FLOAT_BOUNDS_MIN || height < FLOAT_BOUNDS_MIN) continue;
1207
1221
  return {
1208
1222
  index: index,
1209
1223
  bounds: { left: left, top: top, right: right, bottom: bottom, width: width, height: height },
1210
1224
  };
1211
1225
  }
1226
+ if (best !== null) return { index: bestIndex, bounds: best };
1212
1227
  return { index: -1, bounds: full };
1213
1228
  }
1214
1229
 
@@ -1327,6 +1342,36 @@ function floatTransformOrigin(quadrant) {
1327
1342
  return "100% 100%";
1328
1343
  }
1329
1344
 
1345
+ /**
1346
+ * Which side the open CARD grows towards: the one with more room for it.
1347
+ *
1348
+ * The card hangs off the dot, so it can only extend one way from it, and the
1349
+ * side that holds more of the card is the side the conversation has room for.
1350
+ * Each axis compares the room beyond the dot's own box on either side and sends
1351
+ * the card to the larger one — which is also the side its middle ends up on, so
1352
+ * "the panel goes to the corner it sits in" holds by construction.
1353
+ *
1354
+ * This is the CARD's rule, not the dot's: the dot keeps the corner it remembers
1355
+ * living in (see `floatCornerPoint`), while a rule shared by both is what made
1356
+ * the card jump whenever the dot crossed a line the card had not. The answer
1357
+ * depends on the dot and the region and on nothing else — never on a card it
1358
+ * just placed — so it cannot vote itself back and forth.
1359
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1360
+ * @param {number} x - the dot's left edge, in CSS pixels.
1361
+ * @param {number} y - the dot's top edge, in CSS pixels.
1362
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1363
+ * @returns {string} one of "tl", "tr", "bl", "br" — the quadrant the card is
1364
+ * anchored on, which is the corner it grows away from.
1365
+ */
1366
+ function floatCardCorner(bounds, x, y, dot) {
1367
+ var box = floatBoxOf(bounds);
1368
+ if (box === null) box = { left: 0, top: 0, right: FLOAT_VIEWPORT_W, bottom: FLOAT_VIEWPORT_H };
1369
+ var size = dot === undefined ? FLOAT_DOT : dot;
1370
+ var growRight = box.right - x >= x + size - box.left;
1371
+ var growDown = box.bottom - y >= y + size - box.top;
1372
+ return (growDown ? "t" : "b") + (growRight ? "l" : "r");
1373
+ }
1374
+
1330
1375
  /**
1331
1376
  * Which corner of the card lands on the dot, and where the expansion starts.
1332
1377
  *
@@ -1853,6 +1898,47 @@ function normalizeDarkValues(value, relative) {
1853
1898
  /** The three axes that share the weight scale (and its marker). */
1854
1899
  var WEIGHT_FIELDS = [WEIGHT_FIELD, WEIGHT_DIALOG_FIELD, CODE_WEIGHT_FIELD];
1855
1900
 
1901
+ /**
1902
+ * Return a copy of a sparse dark-theme override map with one field changed.
1903
+ *
1904
+ * The map holds only the fields the reader overrode for the dark theme; an
1905
+ * absent field inherits the light value. `undefined` therefore means "stop
1906
+ * overriding", not "set to empty" — that distinction is what lets the card show
1907
+ * "follows the light set" instead of a value the reader never chose.
1908
+ * @param {unknown} map - the current sparse map (anything object-shaped).
1909
+ * @param {string} field - a settings field name.
1910
+ * @param {string|number|boolean|undefined} value - the override, or undefined to clear it.
1911
+ * @returns {Record<string, string|number|boolean>} a new map; the input is untouched.
1912
+ */
1913
+ function withDarkOverride(map, field, value) {
1914
+ var source = map !== null && typeof map === "object" && !Array.isArray(map) ? map : {};
1915
+ var out = {};
1916
+ var key;
1917
+ for (key in source) {
1918
+ if (Object.prototype.hasOwnProperty.call(source, key)) out[key] = source[key];
1919
+ }
1920
+ if (value === undefined) delete out[field];
1921
+ else out[field] = value;
1922
+ return out;
1923
+ }
1924
+
1925
+ /**
1926
+ * Serialize a dark override map for storage, with its keys in a stable order.
1927
+ *
1928
+ * Stable order matters twice: the settings document is diffed as text (an
1929
+ * equivalent map written in a different key order would look like a change and
1930
+ * cost a whole-document write), and the preset snapshot compares the text too.
1931
+ * @param {unknown} map - the sparse map.
1932
+ * @returns {string} the JSON text the document stores.
1933
+ */
1934
+ function darkValuesText(map) {
1935
+ var source = map !== null && typeof map === "object" && !Array.isArray(map) ? map : {};
1936
+ var keys = Object.keys(source).sort();
1937
+ var out = {};
1938
+ for (var index = 0; index < keys.length; index += 1) out[keys[index]] = source[keys[index]];
1939
+ return JSON.stringify(out);
1940
+ }
1941
+
1856
1942
  /**
1857
1943
  * Convert the weight fields of saved preset snapshots to offsets.
1858
1944
  *
@@ -4582,6 +4668,7 @@ var shared = {
4582
4668
  floatExpandDirection: floatExpandDirection,
4583
4669
  floatTransformOrigin: floatTransformOrigin,
4584
4670
  floatCardAnchor: floatCardAnchor,
4671
+ floatCardCorner: floatCardCorner,
4585
4672
  floatClipStart: floatClipStart,
4586
4673
  floatClipRest: floatClipRest,
4587
4674
  isRestClip: isRestClip,
@@ -4685,6 +4772,8 @@ var shared = {
4685
4772
  quoteFamily: quoteFamily,
4686
4773
  normalizeConfig: normalizeConfig,
4687
4774
  normalizeDarkValues: normalizeDarkValues,
4775
+ withDarkOverride: withDarkOverride,
4776
+ darkValuesText: darkValuesText,
4688
4777
  normalizePresets: normalizePresets,
4689
4778
  normalizeValueSet: normalizeValueSet,
4690
4779
  clampOffset: clampOffset,
@@ -5188,6 +5277,15 @@ var DICTS = {
5188
5277
  "preset.import": "Import",
5189
5278
  "preset.importPlaceholder": "Paste an exported preset JSON here",
5190
5279
  "preset.label": "Presets",
5280
+ "theme.label": "Light/dark sync",
5281
+ "theme.surface": "Which theme is being edited",
5282
+ "theme.shared": "One set",
5283
+ "theme.split": "Separate",
5284
+ "theme.light": "Light",
5285
+ "theme.dark": "Dark",
5286
+ "theme.sharedHint": "Light and dark share this one set of settings.",
5287
+ "theme.splitHint": "Editing the dark set: only the items you change are overridden, the rest follow the light set.",
5288
+ "theme.darkOnly": "Dark",
5191
5289
  "preset.select": "Choose a preset",
5192
5290
  "preset.rename": "Rename",
5193
5291
  "preset.renamePlaceholder": "New name",
@@ -5378,6 +5476,15 @@ var DICTS = {
5378
5476
  "preset.import": "导入",
5379
5477
  "preset.importPlaceholder": "粘贴导出的方案内容",
5380
5478
  "preset.label": "预设方案",
5479
+ "theme.label": "亮暗同步",
5480
+ "theme.surface": "正在编辑哪一套",
5481
+ "theme.shared": "一起设置",
5482
+ "theme.split": "亮暗分开",
5483
+ "theme.light": "亮色",
5484
+ "theme.dark": "暗色",
5485
+ "theme.sharedHint": "亮色与暗色共用同一套排印设置。",
5486
+ "theme.splitHint": "正在编辑暗色那一套:只覆盖你调过的项,其余跟随亮色。",
5487
+ "theme.darkOnly": "暗色",
5381
5488
  "preset.select": "选择方案",
5382
5489
  "preset.rename": "改名",
5383
5490
  "preset.renamePlaceholder": "新名字",
@@ -5514,6 +5621,14 @@ var CARD_CSS = [
5514
5621
  ".dfp-miniButton{appearance:none;font:inherit;cursor:pointer;white-space:nowrap;flex:none;border-radius:8px;padding:3px 8px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);background:0 0;border:.5px solid var(--dsw-alias-border-l3)}",
5515
5622
  ".dfp-miniButton:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-specific-sidebar-nav-item-hover)}",
5516
5623
  ".dfp-miniButton:disabled{opacity:.4;cursor:default}",
5624
+ // The theme surface picker: always mounted, so turning the sync switch on and
5625
+ // off slides and fades this row instead of snapping the card's height. The grid
5626
+ // row is the height animation (0fr -> 1fr), the opacity rides along.
5627
+ ".dfp-themeSplit{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .24s ease,opacity .24s ease}",
5628
+ ".dfp-themeSplitOn{grid-template-rows:1fr;opacity:1}",
5629
+ ".dfp-themeSplitClip{min-height:0;overflow:hidden}",
5630
+ ".dfp-themeSplitRow{display:flex;align-items:center;gap:10px;margin-top:8px}",
5631
+ ".dfp-themeNote{flex:1;min-width:0;text-align:right;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
5517
5632
  ".dfp-textInput{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;font:inherit;font-size:13px;line-height:18px}",
5518
5633
  // The auto-save hint and the transient message share one row: the hint holds
5519
5634
  // the left, the message fades in on the right. The row is always mounted and
@@ -5615,7 +5730,7 @@ var CARD_CSS = [
5615
5730
  // the dot coming back: the whole fold, not the 120 ms the way out took. The
5616
5731
  // after-change transition is the one that counts, so this rule alone decides
5617
5732
  // the fade-in, and it is the fold's own length and curve.
5618
- ".dfp-dotBack{transition:opacity .42s cubic-bezier(.4,0,.2,1),transform .2s ease}",
5733
+ ".dfp-dotBack{transition:opacity .46s cubic-bezier(.45,0,.25,1),transform .2s ease}",
5619
5734
  // The shadow is a FILTER, not a box-shadow: a box-shadow is painted outside
5620
5735
  // the border box, so every frame that carries a clip (both animations) would
5621
5736
  // cut it away.
@@ -5629,12 +5744,57 @@ var CARD_CSS = [
5629
5744
  // filter is applied to the card's already-clipped shape and follows it frame
5630
5745
  // by frame. It is a pure paint layer: nothing about it takes a pointer, and
5631
5746
  // the card it holds keeps every part of the animation.
5632
- ".dfp-floatShadow{position:absolute;z-index:1;box-sizing:border-box;pointer-events:none;width:min(264px,calc(100vw - 32px));filter:drop-shadow(0 10px 32px rgba(0,0,0,.28))}",
5747
+ // No `filter` here any more: an ancestor that filters is a backdrop root, and
5748
+ // the glass layer's blur would then sample only this wrapper's own subtree —
5749
+ // i.e. the panel itself — instead of the conversation behind it. The drop
5750
+ // shadow moved onto the glass, which is the layer that actually paints now.
5751
+ ".dfp-floatShadow{position:absolute;z-index:1;box-sizing:border-box;pointer-events:none;width:min(264px,calc(100vw - 32px))}",
5633
5752
  // The card fills the wrapper in normal flow, so the wrapper's anchored corner
5634
5753
  // IS the card's corner: the clip, the scale and the fade still ride the card
5635
5754
  // (and every measurement still reads it), while the anchored edge, the size
5636
5755
  // and the shadow belong to the wrapper around it.
5637
- ".dfp-floatCard{position:relative;box-sizing:border-box;display:flex;flex-direction:column;width:100%;height:100%;max-height:calc(100vh - 24px);overflow:hidden;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l3);border-radius:16px;padding:10px 12px 12px;pointer-events:auto;clip-path:inset(0px 0px 0px 0px round 16px);transition:clip-path .28s cubic-bezier(.22,1,.28,1),opacity .28s cubic-bezier(.22,1,.28,1),transform .28s cubic-bezier(.22,1,.28,1)}",
5756
+ ".dfp-floatCard{position:relative;z-index:1;box-sizing:border-box;display:flex;flex-direction:column;width:100%;height:100%;max-height:calc(100vh - 24px);overflow:hidden;background:transparent;border:1px solid var(--dsw-alias-border-l3);border-radius:16px;padding:10px 12px 12px;pointer-events:auto;clip-path:inset(0px 0px 0px 0px round 16px);transition:clip-path .32s cubic-bezier(.45,0,.25,1),opacity .32s cubic-bezier(.45,0,.25,1),transform .32s cubic-bezier(.45,0,.25,1)}",
5757
+ // The drop shadow, on a sibling of the card: it must NOT sit on an ancestor of
5758
+ // the blur layer (`filter` makes an ancestor a backdrop root, and the blur would
5759
+ // then sample the panel itself), and it must not sit on the card (Chromium carves
5760
+ // an element's own shadow away with its `clip-path` for the whole unfold).
5761
+ // It FADES with the unfold rather than mirroring the clip: two independent clip
5762
+ // transitions get retargeted a frame apart by Chrome (measured: 73 ms against the
5763
+ // card's 280 ms), and a shadow that outran the panel it belongs to reads worse
5764
+ // than one that arrives with it. Invisible while the card is still a dot, and
5765
+ // gone before the element is dropped — never a bare frame left hanging in the
5766
+ // conversation, which is exactly what a full-size, never-animated shadow layer
5767
+ // looked like.
5768
+ ".dfp-floatShade{position:absolute;inset:0;z-index:0;border-radius:16px;pointer-events:none;box-shadow:0 10px 32px rgba(0,0,0,.28);opacity:1;transition:opacity .32s cubic-bezier(.45,0,.25,1)}",
5769
+ // The surface: a CHILD of the card, so the card's own clip gives it the panel's
5770
+ // silhouette for free — the unfold, the fold and every future shape change are
5771
+ // the card's business, not another layer's to keep in step. (A sibling was tried
5772
+ // first: two independent clip transitions, retargeted a frame apart by Chrome,
5773
+ // made the surface race ahead of the panel it was painting behind.)
5774
+ // It sits at z-index -1 so it paints under the rows, and it must never be given
5775
+ // opacity below 1 — that would make it a backdrop root and switch off its own
5776
+ // blur.
5777
+ // A SIBLING of the card, not a child. Measured: inside the card the blur is
5778
+ // computed but has no effect — the text behind the panel stayed crisp — because
5779
+ // a backdrop-filter inside the clipped, animated wrapper samples that wrapper's
5780
+ // own subtree instead of the page. quick-toc reached the same conclusion the hard
5781
+ // way and put its twin at the shell level for exactly this reason.
5782
+ // Its clip is copied from the card every frame by `mirrorGlassClip`: two
5783
+ // independent CSS clip transitions get retargeted a frame apart (measured 73 ms
5784
+ // against the card's 280 ms), and a surface that outruns the panel it is painting
5785
+ // behind is precisely the "transparent box grows first" artefact.
5786
+ ".dfp-floatGlass{position:absolute;inset:0;z-index:0;border-radius:16px;pointer-events:none;background:var(--dsw-alias-bg-layer-2);clip-path:inset(0px 0px 0px 0px round 16px)}",
5787
+ // 82 % leaves the blurred conversation readable as colour and light behind the
5788
+ // rows, which is where quick-toc landed for a surface you read IN (its curtain,
5789
+ // meant to be seen through, goes lighter still).
5790
+ // Plain `rgba`, no `color-mix`. The mix version followed the theme's own surface
5791
+ // colour, but a browser without it drops the declaration outright and the opaque
5792
+ // fill underneath wins — "still opaque, and no blur visible" is exactly what that
5793
+ // looks like. A fixed 62 % of white (light) or of the dark surface (below) is the
5794
+ // same material in every browser, which is worth more here than matching a custom
5795
+ // palette exactly.
5796
+ ".dfp-floatGlassOn{background:rgba(255,255,255,0.50);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}",
5797
+ "body[data-ds-dark-theme] .dfp-floatGlassOn{background:rgba(26,28,34,0.50)}",
5638
5798
  // The body owns the scrolling: seven sliders and the status block do not fit a panel
5639
5799
  // that was resized to its minimum, and a clipped slider is worse than a scrollbar.
5640
5800
  ".dfp-floatHead{flex:0 0 auto}",
@@ -5643,14 +5803,27 @@ var CARD_CSS = [
5643
5803
  // smaller and dimmer, and the transition (started by dropping this class two
5644
5804
  // frames later) carries all three home at once. Once it has arrived the card
5645
5805
  // drops the clip entirely (see `releaseClip` in the client).
5646
- ".dfp-floatCardEnter{clip-path:var(--dfp-clipStart,inset(0px 0px 0px 0px round 16px));opacity:.6;transform:scale(.985)}",
5806
+ ".dfp-floatCardEnter{clip-path:var(--dfp-clipStart,inset(0px 0px 0px 0px round 16px));transform:scale(.985)}",
5647
5807
  // The leave frame: the same rectangle the enter came from, so the fold is the
5648
5808
  // expand played backwards — but on its OWN, longer curve. The unfold is a
5649
5809
  // fast, eager ease-out; the fold starts gently and spends most of its extra
5650
5810
  // time in the tail, which is what makes shutting down read as deliberate
5651
5811
  // instead of abrupt. The card is already invisible but must not swallow
5652
5812
  // clicks aimed at the page underneath while it is still in the DOM.
5653
- ".dfp-floatCardLeave{clip-path:var(--dfp-clipStart,inset(0px 0px 0px 0px round 16px));opacity:0;transform:scale(.985);pointer-events:none;transition:clip-path .42s cubic-bezier(.4,0,.2,1),opacity .42s cubic-bezier(.4,0,.2,1),transform .42s cubic-bezier(.4,0,.2,1)}",
5813
+ ".dfp-floatCardLeave{clip-path:var(--dfp-clipStart,inset(0px 0px 0px 0px round 16px));opacity:0;transform:scale(.985);pointer-events:none;transition:clip-path .46s cubic-bezier(.45,0,.25,1),opacity .46s cubic-bezier(.45,0,.25,1),transform .46s cubic-bezier(.45,0,.25,1)}",
5814
+ // The shade's own two frames, AFTER the card's so a bare `.dfp-floatCardEnter`
5815
+ // lookup still finds the card's rule first. Opacity only: the shade has to leave
5816
+ // and arrive with the panel, but it must not mirror the clip (see above).
5817
+ //
5818
+ // It does NOT hang off the enter class: that class is the ARMING frame and is
5819
+ // dropped two frames in (see `settle`), so a shade tied to it blinked out and
5820
+ // straight back on while the card was still a dot. It is pinned to 0 for those
5821
+ // two frames instead — so the 0 has painted before the transition starts — and
5822
+ // then released, which fades it in over the very same 280 ms the card takes to
5823
+ // grow. The shadow therefore arrives exactly with the panel: not before the card
5824
+ // is there, and not as a separate appearance after it has landed.
5825
+ ".dfp-floatShade.dfp-floatShadeHidden{opacity:0;transition:none}",
5826
+ ".dfp-floatShade.dfp-floatCardLeave{opacity:0;transition:opacity .46s cubic-bezier(.45,0,.25,1)}",
5654
5827
  // the arming frame, on for the two frames the enter class is on: measuring the
5655
5828
  // card for `--dfp-clipStart` RESOLVES its style, and without this the resting
5656
5829
  // clip resolved there would be the "before" of a transition the moment the
@@ -5667,30 +5840,42 @@ var CARD_CSS = [
5667
5840
  // so the title never runs underneath it; there is no slot for the dot any
5668
5841
  // more — the dot is faded out while the card is open.
5669
5842
  ".dfp-floatHead{display:flex;align-items:center;gap:8px;cursor:grab;user-select:none;touch-action:none}",
5670
- ".dfp-floatTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:22px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
5843
+ ".dfp-floatTitle{flex:1;min-width:0;color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:24px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
5671
5844
  // the close: a round icon button in the quick-toc style — same tint at rest,
5672
5845
  // the next tint step on hover; the rounded bar inside is the close icon
5673
- ".dfp-floatClose{position:absolute;top:8px;right:8px;z-index:2;appearance:none;cursor:pointer;display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:none;border-radius:50%;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));transition:background .15s ease,color .15s ease,opacity .2s ease}",
5846
+ ".dfp-floatClose{position:absolute;top:11px;right:8px;z-index:2;appearance:none;cursor:pointer;display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:none;border-radius:50%;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));transition:background .15s ease,color .15s ease,opacity .2s ease}",
5674
5847
  ".dfp-floatClose:hover{background:var(--dsw-alias-interactive-bg-active,rgba(79,140,255,.24))}",
5675
5848
  ".dfp-floatCloseCross{display:block;pointer-events:none;color:currentColor}",
5676
5849
  // the settings entry: the VERY same circle as the close (same diameter, same
5677
5850
  // tint, same hover step), pinned to the card's corner and pushed one diameter
5678
5851
  // plus FLOAT_SETTINGS_GAP to its left. Both radii are 50% and the boxes are
5679
5852
  // square, so neither can read as an ellipse.
5680
- ".dfp-floatSettings{position:absolute;top:8px;right:38px;z-index:2;appearance:none;cursor:pointer;display:flex;align-items:center;justify-content:center;width:22px;height:22px;min-width:0;padding:0;border:none;border-radius:50%;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));transition:background .15s ease,color .15s ease,opacity .2s ease}",
5853
+ ".dfp-floatSettings{position:absolute;top:11px;right:38px;z-index:2;appearance:none;cursor:pointer;display:flex;align-items:center;justify-content:center;width:22px;height:22px;min-width:0;padding:0;border:none;border-radius:50%;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));transition:background .15s ease,color .15s ease,opacity .2s ease}",
5681
5854
  ".dfp-floatSettings:hover{background:var(--dsw-alias-interactive-bg-active,rgba(79,140,255,.24))}",
5682
5855
  ".dfp-floatSettingsGlyph{display:block;pointer-events:none;color:currentColor}",
5683
5856
  // the host's own button reset cannot inflate either circle: the two share one
5684
5857
  // box model, so "the same size" is a property of the pair, not of a wish.
5685
5858
  ".dfp-floatClose,.dfp-floatSettings{box-sizing:border-box;aspect-ratio:1/1;flex:0 0 auto}",
5686
5859
  ".dfp-floatResize{position:absolute;z-index:3;touch-action:none}",
5860
+ // Width only. The bottom and corner grips let a press land on the panel's own
5861
+ // last row instead of on the control there, which reads as a dead slider; the
5862
+ // height is content-driven anyway (the card caps itself at the viewport), so
5863
+ // the two vertical grips cost more than they gave.
5687
5864
  ".dfp-floatResizeR{top:0;right:0;width:8px;height:100%;cursor:ew-resize}",
5688
- ".dfp-floatResizeB{left:0;bottom:0;width:100%;height:8px;cursor:ns-resize}",
5689
- ".dfp-floatResizeBR{right:0;bottom:0;width:14px;height:14px;cursor:nwse-resize}",
5690
5865
  ".dfp-floatRow{margin-top:8px}",
5691
5866
  ".dfp-floatLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;display:flex;justify-content:space-between;gap:8px}",
5692
5867
  ".dfp-floatValue{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}",
5693
- ".dfp-floatSlider{width:100%;box-sizing:border-box;accent-color:var(--dsw-alias-brand-primary)}",
5868
+ // `margin:0` is load-bearing: Chromium's UA sheet gives `input[type=range]` a
5869
+ // 2px margin on every side, which sits OUTSIDE the `width:100%` box. The row is
5870
+ // then 4px wider than the scrolling body, whose `overflow-x:hidden` shaves the
5871
+ // slider's right end (measured: scrollWidth 288 vs clientWidth 284, slider
5872
+ // ending 2px past the body). `display:block` keeps it from adding a line box.
5873
+ ".dfp-floatSlider{display:block;margin:0;width:100%;box-sizing:border-box;accent-color:var(--dsw-alias-brand-primary)}",
5874
+ // The preset picker shares the head row: the title takes the slack, so this
5875
+ // sits against the right edge of the head's own content box — i.e. just left of
5876
+ // the two round buttons the head reserves space for.
5877
+ ".dfp-floatPreset{flex:none;max-width:118px;font:inherit;font-size:12px;line-height:18px;padding:2px 4px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;cursor:pointer}",
5878
+ ".dfp-floatPreset:disabled{opacity:.5;cursor:default}",
5694
5879
  // the write-failure line takes no room at all while it carries no message
5695
5880
  ".dfp-floatStatus{margin-top:0;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}",
5696
5881
  ".dfp-floatStatus:empty{display:none}",
@@ -8150,12 +8335,21 @@ function FontCard(props) {
8150
8335
  /** The pending auto-hide / fade-out timers of the preset message. */
8151
8336
  var statusIdRef = useRef(0);
8152
8337
  var statusTimerRef = useRef(null);
8338
+ /**
8339
+ * Which surface the controls edit when the two themes are set separately.
8340
+ *
8341
+ * Off means the flat fields (the light set), on means the dark overrides. This
8342
+ * is view state only — it never reaches the document — and it is ignored while
8343
+ * `perTheme` is off, because the two sets are then the same object.
8344
+ */
8345
+ var [editingDark, setEditingDark] = useState(false);
8153
8346
  var snapshot = useScopeSnapshot(scope);
8154
8347
  var config = normalizeConfig(snapshot.value);
8155
- // Light and dark share one value set in this release: the stored flag (if
8156
- // any) is ignored and the dark fields stay dormant in the document.
8157
- config[PER_THEME_FIELD] = false;
8348
+ // Per-theme is honoured exactly as stored (0.7.0). It used to be forced off
8349
+ // here because nothing in the UI could edit the dark set; `editingDark` is
8350
+ // the control that made forcing it unnecessary.
8158
8351
  var sets = resolveAxes(config);
8352
+ var themesSplit = config[PER_THEME_FIELD] === true;
8159
8353
  var user = snapshot.user !== null && typeof snapshot.user === "object" ? snapshot.user : {};
8160
8354
  var writable = snapshot.writable === true;
8161
8355
  var presets = config[PRESETS_FIELD];
@@ -8176,9 +8370,9 @@ function FontCard(props) {
8176
8370
  }
8177
8371
  if (!activeKnown) activeName = presetList[0].name;
8178
8372
 
8179
- // Which value set the controls below edit and display: light and dark share
8180
- // one set, so it is always the light (flat) fields.
8181
- var editing = sets.light;
8373
+ // Which value set the controls below edit and display. With the split off the
8374
+ // two sets are the same object, so the condition costs nothing.
8375
+ var editing = themesSplit && editingDark ? sets.dark : sets.light;
8182
8376
 
8183
8377
  var changeView = function (next) {
8184
8378
  setView(next);
@@ -8204,6 +8398,11 @@ function FontCard(props) {
8204
8398
  // Chrome (the floating panel's switch and position) never rides a preset:
8205
8399
  // switching presets must not hide or move the panel.
8206
8400
  if (VALUE_FIELDS.indexOf(field) < 0) return;
8401
+ // A dark edit must not ride the preset either. The snapshot stores the LIGHT
8402
+ // set, so mirroring a dark value into a flat field would apply that value to
8403
+ // the light set the next time this preset is picked — the preset would slowly
8404
+ // turn into a copy of the dark overrides.
8405
+ if (themesSplit && editingDark) return;
8207
8406
  if (!writable || applyingRef.current === true) {
8208
8407
  probeLog("mirror-skipped", { field: field, writable: writable, applying: applyingRef.current });
8209
8408
  return;
@@ -8570,10 +8769,60 @@ function FontCard(props) {
8570
8769
  pendingOps[field] = { op: op, value: value };
8571
8770
  scheduleFlush();
8572
8771
  };
8772
+ /**
8773
+ * Whether a write for this field belongs to the dark override layer.
8774
+ *
8775
+ * Only value axes can be overridden per theme: the theme flag itself, the
8776
+ * preset storage, the weight scale marker and the panel chrome are not part
8777
+ * of either set, so each of them writes its own field whatever surface is
8778
+ * being edited.
8779
+ * @param {string} field - a settings field name.
8780
+ * @returns {boolean} true when the write must go into `darkValues`.
8781
+ */
8782
+ var writesDark = function (field) {
8783
+ if (!themesSplit || !editingDark) return false;
8784
+ return VALUE_FIELDS.indexOf(field) >= 0;
8785
+ };
8786
+ /**
8787
+ * The dark map a write should merge into, queued writes included.
8788
+ *
8789
+ * Reading the document alone would lose a value the user moved one frame ago:
8790
+ * the batch has not been flushed yet, so the document still holds the older
8791
+ * map and the second click would overwrite the first one's override.
8792
+ * @returns {Record<string, string|number|boolean>} the sparse map to merge into.
8793
+ */
8794
+ var pendingDarkMap = function () {
8795
+ var queued = pendingOps[DARK_VALUES_FIELD];
8796
+ return shared.normalizeDarkValues(queued !== undefined ? queued.value : config[DARK_VALUES_FIELD]);
8797
+ };
8798
+ /**
8799
+ * Write one axis into the dark override layer instead of the flat fields.
8800
+ *
8801
+ * The document stores the whole map as one JSON string, so one axis edit is
8802
+ * one whole-map write; `undefined` removes the override and hands the field
8803
+ * back to the light set.
8804
+ * @param {string} field - a value-axis field name.
8805
+ * @param {string|number|boolean|undefined} value - the override, or undefined to clear it.
8806
+ */
8807
+ var submitDark = function (field, value) {
8808
+ var next = shared.withDarkOverride(pendingDarkMap(), field, value);
8809
+ // A dark override in a document that has the split switched off would render
8810
+ // nothing at all, so the flag rides the same batch.
8811
+ if (config[PER_THEME_FIELD] !== true) queuePatch(PER_THEME_FIELD, shared.PENDING_SET, true);
8812
+ queuePatch(DARK_VALUES_FIELD, shared.PENDING_SET, shared.darkValuesText(next));
8813
+ };
8573
8814
  var submit = function (field, value) {
8815
+ if (writesDark(field)) {
8816
+ submitDark(field, value);
8817
+ return;
8818
+ }
8574
8819
  queuePatch(field, shared.PENDING_SET, value);
8575
8820
  };
8576
8821
  var clear = function (field) {
8822
+ if (writesDark(field)) {
8823
+ submitDark(field, undefined);
8824
+ return;
8825
+ }
8577
8826
  queuePatch(field, shared.PENDING_UNSET, undefined);
8578
8827
  };
8579
8828
  /** The frame handle for the next local paint; null when no paint is scheduled. */
@@ -9629,6 +9878,62 @@ function FontCard(props) {
9629
9878
  : null
9630
9879
  ),
9631
9880
 
9881
+ // Per-theme. The sync switch follows every other on/off row in this
9882
+ // card (label left, joined segmented pushed right); the surface picker
9883
+ // is a second row under the description, and it fades in and out with
9884
+ // the switch — both rows are always mounted so nothing else moves.
9885
+ h(
9886
+ FieldShell,
9887
+ {
9888
+ t: t,
9889
+ label: t("theme.label"),
9890
+ hint: t("theme.sharedHint"),
9891
+ disabled: !writable,
9892
+ inline: true,
9893
+ },
9894
+ h(Segmented, {
9895
+ label: t("theme.label"),
9896
+ // "On" is the shared set: that is the default, and it is what the
9897
+ // switch means to a reader — syncing the two themes.
9898
+ value: themesSplit ? 0 : 1,
9899
+ disabled: !writable,
9900
+ options: [
9901
+ { value: 0, label: t("shape.off") },
9902
+ { value: 1, label: t("shape.on") },
9903
+ ],
9904
+ onChange: function (next) {
9905
+ var synced = next === 1;
9906
+ submit(PER_THEME_FIELD, !synced);
9907
+ if (synced) setEditingDark(false);
9908
+ },
9909
+ })
9910
+ ),
9911
+ h(
9912
+ "div",
9913
+ { className: "dfp-themeSplit" + (themesSplit ? " dfp-themeSplitOn" : "") },
9914
+ h(
9915
+ "div",
9916
+ { className: "dfp-themeSplitClip" },
9917
+ h(
9918
+ "div",
9919
+ { className: "dfp-themeSplitRow" },
9920
+ h(Segmented, {
9921
+ label: t("theme.surface"),
9922
+ value: editingDark ? 1 : 0,
9923
+ disabled: !writable,
9924
+ options: [
9925
+ { value: 0, label: t("theme.light") },
9926
+ { value: 1, label: t("theme.dark") },
9927
+ ],
9928
+ onChange: function (next) {
9929
+ setEditingDark(next === 1);
9930
+ },
9931
+ }),
9932
+ h("span", { className: "dfp-themeNote" }, t("theme.splitHint"))
9933
+ )
9934
+ )
9935
+ ),
9936
+
9632
9937
  // the accordion, conversation first (it owns every axis), then the
9633
9938
  // interface (which follows it), then code
9634
9939
  h(
@@ -9966,7 +10271,7 @@ var FLOAT_OPEN_KEY = "dsh-fonttune.float.v1";
9966
10271
  *
9967
10272
  * Used to know when the card's resting clip may be dropped for good.
9968
10273
  */
9969
- var FLOAT_UNFOLD_MS = 280;
10274
+ var FLOAT_UNFOLD_MS = 320;
9970
10275
 
9971
10276
  /**
9972
10277
  * The fold's length, in milliseconds (mirrors `.dfp-floatCardLeave`).
@@ -9986,7 +10291,7 @@ var FLOAT_FOLD_MS = 420;
9986
10291
  * for the two frames the class waits for (see `startLeave`) so the card is
9987
10292
  * never dropped mid-fold.
9988
10293
  */
9989
- var FLOAT_LEAVE_MS = 480;
10294
+ var FLOAT_LEAVE_MS = 460;
9990
10295
 
9991
10296
  /**
9992
10297
  * How long the enter frame may wait for its two frames before a timer stands
@@ -10095,7 +10400,32 @@ function floatStoreOpen(open) {
10095
10400
  * the settings button performs at click time.
10096
10401
  * @returns {{destroy: () => void, toggle: () => void}} the panel's handles.
10097
10402
  */
10403
+ /**
10404
+ * Whether this browser can blur what is behind an element.
10405
+ *
10406
+ * Asked once, at load: the answer cannot change while the page lives, and the
10407
+ * fallback (the same surface, opaque) is what the sheet already draws when the
10408
+ * flag class is absent.
10409
+ * @returns {boolean} true when `backdrop-filter` is usable.
10410
+ */
10411
+ var GLASS_OK = (function () {
10412
+ try {
10413
+ return (
10414
+ typeof CSS !== "undefined" &&
10415
+ typeof CSS.supports === "function" &&
10416
+ (CSS.supports("backdrop-filter", "blur(12px)") ||
10417
+ CSS.supports("-webkit-backdrop-filter", "blur(12px)"))
10418
+ );
10419
+ } catch (error) {
10420
+ return false;
10421
+ }
10422
+ })();
10423
+
10098
10424
  function mountFloatPanel(scope, t, hostCtx) {
10425
+ /** How far the panel's drop shadow reaches past its own box (10px offset + blur). */
10426
+ var SHADOW_REACH = 56;
10427
+ /** The dot's own snap slide, in ms — the stylesheet's `.dfp-floatSnap` transition. */
10428
+ var FLOAT_SNAP_MS = 160;
10099
10429
  var noop = function () {};
10100
10430
  if (typeof document === "undefined" || !document.body) return { destroy: noop, toggle: noop };
10101
10431
  // The offline stand-ins hand out element stubs without listeners or
@@ -10166,8 +10496,11 @@ function mountFloatPanel(scope, t, hostCtx) {
10166
10496
  // The document value `pos` was last reconciled with: a snapshot carrying
10167
10497
  // anything else is an edit from elsewhere and is adopted. Our own writes
10168
10498
  // set `pos` first, so their echo always matches and is a no-op.
10169
- var lastSeenDoc = null;
10170
- // The same for the corner: the document value `corner` was last reconciled
10499
+ /** Watches the theme attribute so the panel re-renders on a theme switch. */
10500
+ var themeWatcher = null;
10501
+
10502
+
10503
+ var lastSeenDoc = null; // The same for the corner: the document value `corner` was last reconciled
10171
10504
  // with, and the one our own write is still carrying.
10172
10505
  var lastSeenCorner = null;
10173
10506
  var lastSeenSize = null;
@@ -10184,6 +10517,19 @@ function mountFloatPanel(scope, t, hostCtx) {
10184
10517
  // end on the same small rectangle the unfold started from, and the card's own
10185
10518
  // measurement only exists while it is mounted.
10186
10519
  var liveQuadrant = null;
10520
+ // The CARD's own side, re-decided when the panel opens (and when the dot is
10521
+ // dropped or the region changes shape). It is NOT the dot's corner: the card
10522
+ // goes wherever it has the most room, while the dot keeps the corner it
10523
+ // remembers living in. One rule shared by both is what made the card jump
10524
+ // whenever the dot crossed a line the card had not.
10525
+ var cardQuadrant = null;
10526
+ // Whether the NEXT render follows a snap. The card then keeps the place it
10527
+ // already had on screen and TRAVELS to the new corner with the dot, instead of
10528
+ // jumping a panel-width to the other side of it (see the glide in `render`).
10529
+ var anchorGlide = false;
10530
+ // The card's own offset inside the host, as laid out last time: the glide is
10531
+ // the difference between where the new side puts it and that.
10532
+ var lastAnchor = null;
10187
10533
 
10188
10534
  var el = function (tag, className, text) {
10189
10535
  var node = document.createElement(tag);
@@ -10562,6 +10908,11 @@ function mountFloatPanel(scope, t, hostCtx) {
10562
10908
  return null;
10563
10909
  };
10564
10910
 
10911
+ // Whether the last region reading was a real region or the whole-window
10912
+ // stand-in (see `floatBounds`). A stand-in never moves the dot: it means "no
10913
+ // conversation to measure right now", not "the conversation is the window".
10914
+ var boundsAreReal = false;
10915
+
10565
10916
  /** The window size the bounds are cut to, with the shared fallback. */
10566
10917
  var floatViewport = function () {
10567
10918
  return {
@@ -10691,10 +11042,19 @@ function mountFloatPanel(scope, t, hostCtx) {
10691
11042
  * The conversation when it can be found, the whole window when it cannot (a
10692
11043
  * settings page has no prose at all): the window is what this used to be
10693
11044
  * everywhere, and it stays the last resort rather than the rule.
11045
+ *
11046
+ * WHICH one answered is remembered in `boundsAreReal`, because the two are not
11047
+ * interchangeable for a MOVE: the whole window is a stand-in for "there is no
11048
+ * conversation to measure at this instant" — a session still mounting, a
11049
+ * message list laid out at 0×0 — and a dot parked on the window's corner over
11050
+ * a page that does have a conversation is exactly the reported "it sits on the
11051
+ * window's left edge instead of the conversation's corner".
10694
11052
  * @returns {object} the bounds, in viewport coordinates.
10695
11053
  */
10696
11054
  var floatBounds = function () {
10697
- return shared.floatBoundsFrom(floatBoundsRects(), floatViewport());
11055
+ var picked = shared.floatBoundsPick(floatBoundsRects(), floatViewport());
11056
+ boundsAreReal = picked.index >= 0;
11057
+ return picked.bounds;
10698
11058
  };
10699
11059
 
10700
11060
  /**
@@ -10735,16 +11095,206 @@ function mountFloatPanel(scope, t, hostCtx) {
10735
11095
  } catch (error) {
10736
11096
  // An unmeasurable card keeps the flat (rest) clip; only the shape is lost.
10737
11097
  }
11098
+ var start = shared.floatClipStart(width, height, quadrant, shared.FLOAT_DOT);
10738
11099
  try {
10739
- card.style.setProperty(
10740
- "--dfp-clipStart",
10741
- shared.floatClipStart(width, height, quadrant, shared.FLOAT_DOT)
10742
- );
11100
+ card.style.setProperty("--dfp-clipStart", start);
10743
11101
  } catch (error) {
10744
11102
  // A card without a style face falls back to the flat clip in the sheet.
10745
11103
  }
10746
11104
  };
10747
11105
 
11106
+ /**
11107
+ * Copy the card's clip onto the glass layer, frame by frame.
11108
+ *
11109
+ * The glass is a sibling (a blur inside the card samples nothing — see the sheet),
11110
+ * so the card's clip does not reach it, and two CSS transitions on the same
11111
+ * property are NOT one animation: Chrome retargets them independently and the
11112
+ * glass came out at a quarter of the card's duration. Reading the card's computed
11113
+ * clip and writing it down is the only way to guarantee one shape at every frame.
11114
+ * @param {object} card - the live card element.
11115
+ * @param {object} glass - the blur layer beside it.
11116
+ * @param {number} ms - how long to keep copying (the animation's own length).
11117
+ */
11118
+ /**
11119
+ * The same `inset()` shape, grown outwards by `grow` pixels on every side.
11120
+ *
11121
+ * Negative insets are legal and paint beyond the border box, which is the only
11122
+ * way an element can follow a silhouette while its own shadow — which lies
11123
+ * outside that box — still survives the clip.
11124
+ * @param {string} clip - a computed inset() clip.
11125
+ * @param {number} grow - how far to push every edge outwards.
11126
+ * @returns {string} the grown clip, or the input when it is not an inset().
11127
+ */
11128
+ var growClip = function (clip, grow) {
11129
+ var found = /^inset\((-?[\d.]+)px (-?[\d.]+)px (-?[\d.]+)px (-?[\d.]+)px( round .*)?\)$/.exec(clip);
11130
+ if (found === null) return clip;
11131
+ return (
11132
+ "inset(" +
11133
+ (Number(found[1]) - grow) +
11134
+ "px " +
11135
+ (Number(found[2]) - grow) +
11136
+ "px " +
11137
+ (Number(found[3]) - grow) +
11138
+ "px " +
11139
+ (Number(found[4]) - grow) +
11140
+ "px" +
11141
+ (found[5] || "") +
11142
+ ")"
11143
+ );
11144
+ };
11145
+
11146
+ /**
11147
+ * The shade's scale for the panel's CURRENT visible box.
11148
+ *
11149
+ * Read from the card's own clip every frame, not driven by a CSS transition: the
11150
+ * small value only exists for the two arming frames, and a transition beginning
11151
+ * there is cleared before it can run (measured: the inline value was gone and
11152
+ * `transform` was `none` 138 ms into the unfold, with the shadow at full size the
11153
+ * whole way). Written per frame, the shadow is exactly as big as what the panel
11154
+ * is showing, on every frame, by construction.
11155
+ * @param {object} card - the live card.
11156
+ * @param {string} clip - its computed clip-path.
11157
+ * @returns {string} the inline transform, or "" for the resting panel.
11158
+ */
11159
+ var shadeFromClip = function (card, clip) {
11160
+ if (clip === "none") return "";
11161
+ var found = /^inset\((-?[\d.]+)px (-?[\d.]+)px (-?[\d.]+)px (-?[\d.]+)px/.exec(clip);
11162
+ if (found === null) return "";
11163
+ var width = 0;
11164
+ var height = 0;
11165
+ try {
11166
+ width = card.offsetWidth;
11167
+ height = card.offsetHeight;
11168
+ } catch (error) {
11169
+ return "";
11170
+ }
11171
+ if (!(width > 0) || !(height > 0)) return "";
11172
+ var visibleW = Math.max(1, width - Number(found[2]) - Number(found[4]));
11173
+ var visibleH = Math.max(1, height - Number(found[1]) - Number(found[3]));
11174
+ return "scale(" + (visibleW / width).toFixed(4) + ", " + (visibleH / height).toFixed(4) + ")";
11175
+ };
11176
+ var mirrorGlassClip = function (card, glass, ms) { if (glass === null || glass === undefined) return;
11177
+ var shade = host.querySelector(".dfp-floatShade");
11178
+ var until = Date.now() + ms;
11179
+ var step = function () {
11180
+ if (card.parentNode === null || glass.parentNode === null) return;
11181
+ var clip = "none";
11182
+ try {
11183
+ clip = globalThis.getComputedStyle(card).clipPath;
11184
+ } catch (error) {
11185
+ return;
11186
+ }
11187
+ glass.style.clipPath = clip;
11188
+ // The shade is SCALED to the panel's visible box, frame by frame (see
11189
+ // `shadeFromClip`): its shadow is painted from its own box, so a layer left at
11190
+ // the full panel size shows a full-size shadow for the whole unfold — the
11191
+ // "second edge" beside the panel's own. Scaling the layer scales the shadow.
11192
+ if (shade !== null && shade !== undefined) shade.style.transform = shadeFromClip(card, clip);
11193
+ if (Date.now() < until) globalThis.requestAnimationFrame(step);
11194
+ };
11195
+ if (typeof globalThis.requestAnimationFrame === "function") globalThis.requestAnimationFrame(step);
11196
+ };
11197
+
11198
+ /**
11199
+ * Give the shade layer the card's unfold class.
11200
+ *
11201
+ * The shade is the sibling that carries the panel's drop shadow, and it has to
11202
+ * leave and arrive with the card: without this it sat at its full resting size
11203
+ * through the whole unfold and the whole fold and was then dropped with the
11204
+ * element, which is what a bare frame hanging around the conversation looked
11205
+ * like. It takes the class for its OPACITY rules only — it deliberately does not
11206
+ * mirror the clip (see the note on `.dfp-floatShade`).
11207
+ * @param {string} name - "dfp-floatCardEnter" or "dfp-floatCardLeave".
11208
+ * @param {boolean} on - true to add, false to remove.
11209
+ */
11210
+ var setShadeClass = function (name, on) {
11211
+ var shade = host.querySelector(".dfp-floatShade");
11212
+ if (shade === null || shade === undefined || typeof shade.classList === "undefined") return;
11213
+ if (on) shade.classList.add(name);
11214
+ else shade.classList.remove(name);
11215
+ };
11216
+
11217
+ /**
11218
+ * Whether the page is showing the dark theme right now.
11219
+ *
11220
+ * Read from the attribute DSH itself switches (`body[data-ds-dark-theme]`),
11221
+ * never from a media query: the reader can pick a theme that disagrees with the
11222
+ * system preference, and the rules this panel edits are the ones that attribute
11223
+ * prefixes.
11224
+ * @returns {boolean} true while the dark theme is on screen.
11225
+ */
11226
+ var pageIsDark = function () {
11227
+ try {
11228
+ return document.body.hasAttribute("data-ds-dark-theme");
11229
+ } catch (error) {
11230
+ return false;
11231
+ }
11232
+ };
11233
+
11234
+ /**
11235
+ * The configuration the panel edits: the themed view when the split is on.
11236
+ *
11237
+ * With `perTheme` on, the panel edits whichever set the page is showing — the
11238
+ * reader asked for "light only edits light, dark only edits dark". The view is
11239
+ * the flat configuration with every value axis replaced by the resolved set, so
11240
+ * every `config[field]` read in this file follows the theme without knowing
11241
+ * about it; storage fields (presets, the dark map itself) are untouched.
11242
+ * @param {Record<string, unknown>} config - a normalized configuration.
11243
+ * @returns {Record<string, unknown>} the view to edit and display.
11244
+ */
11245
+ var themeView = function (config) {
11246
+ if (config[PER_THEME_FIELD] !== true || !pageIsDark()) return config;
11247
+ var dark = resolveAxes(config).dark;
11248
+ var view = {};
11249
+ var key;
11250
+ for (key in config) {
11251
+ if (Object.prototype.hasOwnProperty.call(config, key)) view[key] = config[key];
11252
+ }
11253
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
11254
+ var field = VALUE_FIELDS[index];
11255
+ if (dark[field] !== undefined) view[field] = dark[field];
11256
+ }
11257
+ return view;
11258
+ };
11259
+
11260
+ /**
11261
+ * Route one batch of panel changes to the set the page is showing.
11262
+ *
11263
+ * Light: the batch passes through untouched. Dark: the value axes are merged
11264
+ * into the sparse `darkValues` map and the batch becomes one whole-map write,
11265
+ * which is exactly what the card does — the two surfaces must not disagree
11266
+ * about where a slider writes.
11267
+ * @param {Record<string, unknown>} changes - field to value.
11268
+ * @param {Record<string, unknown>} config - the stored (not themed) configuration.
11269
+ * @returns {Record<string, unknown>} the batch to send.
11270
+ */
11271
+ var routeForTheme = function (changes, config) {
11272
+ if (config[PER_THEME_FIELD] !== true || !pageIsDark()) return changes;
11273
+ var axes = {};
11274
+ var rest = {};
11275
+ var touched = false;
11276
+ var key;
11277
+ for (key in changes) {
11278
+ if (!Object.prototype.hasOwnProperty.call(changes, key)) continue;
11279
+ if (VALUE_FIELDS.indexOf(key) >= 0) {
11280
+ axes[key] = changes[key];
11281
+ touched = true;
11282
+ } else {
11283
+ rest[key] = changes[key];
11284
+ }
11285
+ }
11286
+ if (!touched) return changes;
11287
+ var map = config[DARK_VALUES_FIELD];
11288
+ for (key in axes) {
11289
+ if (Object.prototype.hasOwnProperty.call(axes, key)) {
11290
+ map = shared.withDarkOverride(map, key, axes[key]);
11291
+ }
11292
+ }
11293
+ rest[DARK_VALUES_FIELD] = shared.darkValuesText(map);
11294
+ if (config[PER_THEME_FIELD] !== true) rest[PER_THEME_FIELD] = true;
11295
+ return rest;
11296
+ };
11297
+
10748
11298
  /**
10749
11299
  * The state the panel shows: the document overlaid with the local moves,
10750
11300
  * so the panel always agrees with the page.
@@ -10757,7 +11307,8 @@ function mountFloatPanel(scope, t, hostCtx) {
10757
11307
  // An unready scope reads as an empty, read-only document.
10758
11308
  }
10759
11309
  var raw = snap.value !== null && typeof snap.value === "object" ? snap.value : {};
10760
- var config = normalizeConfig(shared.overlayPendingValues(raw, pendingLocalValues));
11310
+ var stored = normalizeConfig(shared.overlayPendingValues(raw, pendingLocalValues));
11311
+ var config = themeView(stored);
10761
11312
  var stored = config[PRESETS_FIELD];
10762
11313
  var list = Array.isArray(stored) && stored.length > 0
10763
11314
  ? stored
@@ -10985,6 +11536,56 @@ function mountFloatPanel(scope, t, hostCtx) {
10985
11536
  return fallback;
10986
11537
  };
10987
11538
 
11539
+ /**
11540
+ * The middle of the CARD as it is on the page, or null while it is not.
11541
+ *
11542
+ * Read from the layer the card sits in (the wrapper carries the anchored edge
11543
+ * and the size), so the unfold's own scale — which rides the card inside it —
11544
+ * cannot make a growing panel look like a moving one.
11545
+ * @returns {{x: number, y: number}|null} its middle in viewport coordinates.
11546
+ */
11547
+ var cardMiddle = function () {
11548
+ var layer = null;
11549
+ try {
11550
+ layer = host.querySelector(".dfp-floatShadow");
11551
+ } catch (error) {
11552
+ layer = null;
11553
+ }
11554
+ if (layer === null || layer === undefined) return null;
11555
+ var rect = rectOf(layer);
11556
+ if (rect === null || !(rect.width > 0) || !(rect.height > 0)) return null;
11557
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
11558
+ };
11559
+
11560
+ /**
11561
+ * Re-decide which side of the conversation the card opens towards.
11562
+ *
11563
+ * The rule is the card's OWN, and it is about ROOM: the card hangs off the
11564
+ * dot, so it can only grow one way from it, and the side that holds more of it
11565
+ * is the side the conversation has room for. Per axis the two rooms on either
11566
+ * side of the dot are compared and the card is sent to the larger one, which
11567
+ * is also the side its middle ends up on — the panel flies to the corner it
11568
+ * sits in, measured against the conversation rather than against the dot.
11569
+ *
11570
+ * The dot keeps its own rule — the corner it remembers living in — and that
11571
+ * difference is the point: a rule shared by both made the card jump whenever
11572
+ * the dot crossed a line the card had not. This answer depends on the dot and
11573
+ * the region and on nothing else, so it cannot vote itself back and forth.
11574
+ *
11575
+ * Called at the moments a side can legitimately change — an open, when the
11576
+ * card does not exist yet, a release that lands the dot on another corner, and
11577
+ * a region that changed shape — and never from `render`. A caller that changes
11578
+ * the side must also place the card WITHOUT a slide: a side is an anchored
11579
+ * edge, so the card moves by its own width when it changes.
11580
+ * @param {object} [bounds] - the region, when the caller has it already.
11581
+ */
11582
+ var voteCardQuadrant = function (bounds, at) {
11583
+ var region = bounds === undefined ? floatBounds() : bounds;
11584
+ var origin = at === undefined ? pos : at;
11585
+ if (origin === null || origin === undefined) return;
11586
+ cardQuadrant = shared.floatCardCorner(region, origin.x, origin.y);
11587
+ };
11588
+
10988
11589
  /**
10989
11590
  * Snap the dot to the corner it LIVES in, with a short slide.
10990
11591
  *
@@ -11007,8 +11608,15 @@ function mountFloatPanel(scope, t, hostCtx) {
11007
11608
  * to send the dot to the top-left corner the user never asked for.
11008
11609
  * @returns {{x: number, y: number}} the corner the dot now sits on.
11009
11610
  */
11010
- var snapToCorner = function () {
11611
+ var snapToCorner = function (instant) {
11011
11612
  var bounds = floatBounds();
11613
+ // A stand-in region is not a home: the window's corner is what the dot would
11614
+ // land on over a page whose conversation is merely not measurable yet, which
11615
+ // is the reported "it snaps to the window's left edge instead of the
11616
+ // conversation's corner". A dot the document already placed keeps the corner
11617
+ // the last REAL region gave it; the observer settles it again the moment one
11618
+ // exists (see `reflowIntoBounds`).
11619
+ if (!boundsAreReal && pos !== null) return pos;
11012
11620
  var snapped;
11013
11621
  if (pos !== null) {
11014
11622
  snapped = cornerHome(bounds, pos);
@@ -11030,7 +11638,11 @@ function mountFloatPanel(scope, t, hostCtx) {
11030
11638
  }
11031
11639
  pos = snapped;
11032
11640
  try {
11033
- host.classList.add("dfp-floatSnap");
11641
+ // An instant settle must also DROP a snap class left over from an earlier
11642
+ // move, or the very same `left`/`top` write is animated and the panel appears
11643
+ // to drift over from where it used to be.
11644
+ if (instant === true) host.classList.remove("dfp-floatSnap");
11645
+ else host.classList.add("dfp-floatSnap");
11034
11646
  } catch (error) {
11035
11647
  // A host without classes still lands on the snapped corner.
11036
11648
  }
@@ -11141,9 +11753,15 @@ function mountFloatPanel(scope, t, hostCtx) {
11141
11753
  * dot rather than the document handing it a position.
11142
11754
  * @returns {boolean} true when the dot was moved onto its corner.
11143
11755
  */
11144
- var settleOnCorner = function (regionMoved) {
11756
+ var settleOnCorner = function (regionMoved, instant) {
11145
11757
  if (drag !== null || resize !== null) return false;
11146
11758
  var bounds = floatBounds();
11759
+ // Nothing real to settle onto: a shut panel on a settings page, or a
11760
+ // conversation that is not measurable at this instant, leaves the dot where
11761
+ // it is — moving it onto the window's corner is the bug, not the fallback.
11762
+ // With nothing stored at all there is no home to keep, so the stylesheet's
11763
+ // own corner is pinned as before.
11764
+ if (!boundsAreReal && pos !== null) return false;
11147
11765
  var at = pos;
11148
11766
  if (at === null) {
11149
11767
  var rect = rectOf(host);
@@ -11183,7 +11801,7 @@ function mountFloatPanel(scope, t, hostCtx) {
11183
11801
  at.y + shared.FLOAT_DOT <= bounds.bottom + 0.5
11184
11802
  );
11185
11803
  pos = { x: Math.round(at.x), y: Math.round(at.y) };
11186
- snapToCorner();
11804
+ snapToCorner(instant);
11187
11805
  // Both fields in ONE write: the dot is on the corner and the document is
11188
11806
  // told which corner that is, in the same `mutate`. A region that changed
11189
11807
  // shape is the caller that has to store the move it just made, wherever the
@@ -11208,8 +11826,22 @@ function mountFloatPanel(scope, t, hostCtx) {
11208
11826
  * `settleOnCorner`).
11209
11827
  */
11210
11828
  var reflowIntoBounds = function () {
11211
- if (!settleOnCorner(true)) return;
11212
- if (open) render();
11829
+ var bounds = floatBounds();
11830
+ var wasSide = cardQuadrant;
11831
+ if (pos !== null) {
11832
+ // The card's side belongs to the corner the dot is going to, so it is
11833
+ // decided BEFORE the dot is moved — the two then land in the same frame,
11834
+ // and a changed side never sweeps across the middle behind a slide.
11835
+ var home = cornerHome(bounds, pos);
11836
+ cardQuadrant = shared.floatCardCorner(bounds, home.x, home.y);
11837
+ }
11838
+ var moved = settleOnCorner(true);
11839
+ if (open && (moved || cardQuadrant !== wasSide)) {
11840
+ // The region moved the dot and possibly the card's side with it: follow it
11841
+ // in one motion rather than in a jump and a slide (see `anchorGlide`).
11842
+ anchorGlide = moved;
11843
+ render();
11844
+ }
11213
11845
  };
11214
11846
 
11215
11847
  /**
@@ -11420,6 +12052,11 @@ function mountFloatPanel(scope, t, hostCtx) {
11420
12052
  // again here rather than trusted from the render that built the card.
11421
12053
  armClipStart(card);
11422
12054
  card.classList.add("dfp-floatCardLeave");
12055
+ setShadeClass("dfp-floatCardLeave", true);
12056
+ // The glass folds with it: same clip on both, frame by frame (see
12057
+ // `mirrorGlassClip`). Looked up here rather than captured, because this runs
12058
+ // long after the render that built the card.
12059
+ mirrorGlassClip(card, host.querySelector(".dfp-floatGlass"), FLOAT_LEAVE_MS + 80);
11423
12060
  // The dot comes back on the same two frames and over the same span as the
11424
12061
  // fold, so the two cross-fades end together. It is a live element (the
11425
12062
  // render that opened the panel built it), never a rebuilt one.
@@ -11498,7 +12135,10 @@ function mountFloatPanel(scope, t, hostCtx) {
11498
12135
  // browser cancelled) would otherwise unfold the card in the middle of the
11499
12136
  // conversation. An ordinary open — the dot already on its corner — moves
11500
12137
  // nothing at all.
11501
- settleOnCorner();
12138
+ settleOnCorner(false, true);
12139
+ // Which side the unfold goes to: the card's own vote, taken once, here —
12140
+ // the one moment before it exists on the page (see `voteCardQuadrant`).
12141
+ voteCardQuadrant();
11502
12142
  playEnter = true;
11503
12143
  if (closeTimer !== null) {
11504
12144
  globalThis.clearTimeout(closeTimer);
@@ -11509,13 +12149,46 @@ function mountFloatPanel(scope, t, hostCtx) {
11509
12149
  render();
11510
12150
  };
11511
12151
 
12152
+ /**
12153
+ * Apply one preset from the panel.
12154
+ *
12155
+ * Every value axis the entry stores, or that axis's neutral default when it
12156
+ * does not — the card's `switchPreset` contract, so the two surfaces agree on
12157
+ * what a preset means. It is ONE batch: a switch that landed field by field
12158
+ * would repaint the page through every intermediate state. `mirror: false`
12159
+ * because picking a preset is a selection, not an edit of that preset.
12160
+ * @param {{name: string, values: object}} entry - the picked preset.
12161
+ * @param {{presetList: object[], writable: boolean}} state - the panel's read.
12162
+ */
12163
+ var applyPreset = function (entry, state) {
12164
+ if (!state.writable) return;
12165
+ var changes = {};
12166
+ for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
12167
+ var field = VALUE_FIELDS[index];
12168
+ changes[field] = Object.prototype.hasOwnProperty.call(entry.values, field)
12169
+ ? entry.values[field]
12170
+ : DEFAULTS[field];
12171
+ }
12172
+ changes[ACTIVE_PRESET_FIELD] = entry.name;
12173
+ writeSingleShot(scope, state.presetList, entry.name, changes, {
12174
+ mirror: false,
12175
+ onError: reportError,
12176
+ });
12177
+ render();
12178
+ };
12179
+
11512
12180
  var commit = function (changes, options) {
11513
12181
  var state = readState();
11514
12182
  if (!state.writable) return;
11515
12183
  var settings = options !== null && typeof options === "object" ? options : {};
11516
12184
  if (settings.mirror === undefined) settings.mirror = true;
12185
+ var routed = routeForTheme(changes, state.config);
12186
+ // A dark batch never rides a preset: the snapshot is the light set (the card's
12187
+ // mirror rule says the same), so mirroring it would store dark values as light
12188
+ // ones and the preset would drift.
12189
+ if (routed !== changes) settings.mirror = false;
11517
12190
  settings.onError = reportError;
11518
- writeSingleShot(scope, state.presetList, state.activeName, changes, settings);
12191
+ writeSingleShot(scope, state.presetList, state.activeName, routed, settings);
11519
12192
  render();
11520
12193
  };
11521
12194
 
@@ -11759,10 +12432,35 @@ function mountFloatPanel(scope, t, hostCtx) {
11759
12432
  // region is where it lives from now on. Every other path reads the
11760
12433
  // remembered corner instead (see `settleOnCorner`), which is what keeps a
11761
12434
  // region change from re-deciding it from stale coordinates.
11762
- corner = shared.floatNearestCorner(floatBounds(), pos.x, pos.y);
12435
+ var bounds = floatBounds();
12436
+ // Which corner the dot goes to is read from the PANEL's own middle while
12437
+ // the panel is up — a panel dragged by its head hangs a full card away
12438
+ // from the dot, so asking where the DOT is made the user haul it far past
12439
+ // the conversation's own middle to reach the far side. The conversation's
12440
+ // middle is the origin either way: above it is up, right of it is right.
12441
+ var ref = cardMiddle();
12442
+ if (ref === null) {
12443
+ ref = { x: pos.x + shared.FLOAT_DOT / 2, y: pos.y + shared.FLOAT_DOT / 2 };
12444
+ }
12445
+ corner = shared.floatQuadrantIn(bounds, ref.x, ref.y);
12446
+ // The card's side is decided for the corner the dot LANDS on, not for
12447
+ // where the hand let go: a dot moved to the opposite corner has to take
12448
+ // the card with it, or the panel hangs off the conversation on the side
12449
+ // the old corner had room on.
12450
+ var home = shared.floatCornerPoint(bounds, corner);
12451
+ // Voted for the corner the dot is LANDING on, without moving the dot
12452
+ // first: a stand-in region refuses to move the dot, and a `pos` written
12453
+ // here would then claim a corner the host never went to.
12454
+ voteCardQuadrant(bounds, home);
12455
+ // The snap itself is always animated. The card re-anchors in the same
12456
+ // frame, so a side that changed is already on its new edge while the dot
12457
+ // slides — one reposition at the release point rather than a card dragged
12458
+ // across the screen with its far edge pinned.
11763
12459
  snapToCorner();
11764
12460
  var live = readState();
11765
12461
  writeLivePos(live);
12462
+ // The rebuild that follows rides the snap: a side that changed glides.
12463
+ anchorGlide = open;
11766
12464
  render();
11767
12465
  };
11768
12466
  var up = function () {
@@ -11842,19 +12540,14 @@ function mountFloatPanel(scope, t, hostCtx) {
11842
12540
  var move = function (at) {
11843
12541
  if (resize === null) return;
11844
12542
  resize.moved = true;
11845
- var w = resize.w;
11846
- var h = resize.h;
11847
- if (resize.mode === "w" || resize.mode === "wh") {
11848
- w = Math.min(shared.PANEL_SIZE_MAX_W, Math.max(shared.PANEL_SIZE_MIN_W, Math.round(resize.w + at.clientX - resize.startX)));
11849
- }
11850
- if (resize.mode === "h" || resize.mode === "wh") {
11851
- h = Math.min(shared.PANEL_SIZE_MAX_H, Math.max(shared.PANEL_SIZE_MIN_H, Math.round(resize.h + at.clientY - resize.startY)));
11852
- }
11853
- liveSize = { w: w, h: h };
11854
- // The viewport cap rides the live size as well: what the pointer shows is
11855
- // what the release stores, and the card can never leave the screen.
12543
+ var w = Math.min(
12544
+ shared.PANEL_SIZE_MAX_W,
12545
+ Math.max(shared.PANEL_SIZE_MIN_W, Math.round(resize.w + at.clientX - resize.startX))
12546
+ );
12547
+ liveSize = { w: w, h: 0 };
12548
+ // Width only: the height follows the content, so writing one here would pin
12549
+ // the panel to whatever it happened to measure at pointerdown.
11856
12550
  box.style.width = "min(" + w + "px,calc(100vw - 32px))";
11857
- box.style.height = "min(" + h + "px,calc(100vh - 24px))";
11858
12551
  };
11859
12552
  var end = function (write) {
11860
12553
  globalThis.removeEventListener("pointermove", move);
@@ -11863,7 +12556,8 @@ function mountFloatPanel(scope, t, hostCtx) {
11863
12556
  var wasMoved = resize !== null && resize.moved;
11864
12557
  resize = null;
11865
12558
  if (write && wasMoved && liveSize !== null) {
11866
- var cur = liveSize.w + "," + liveSize.h;
12559
+ // Height 0 means "no cap": the panel is as tall as its content.
12560
+ var cur = liveSize.w + ",0";
11867
12561
  if (cur !== pendingSizeWrite) {
11868
12562
  var live = readState();
11869
12563
  if (live.writable) {
@@ -11978,12 +12672,23 @@ function mountFloatPanel(scope, t, hostCtx) {
11978
12672
  if (open && !(playEnter && !motionReduced()) && typeof dot.classList !== "undefined") {
11979
12673
  dot.classList.add("dfp-dotAway");
11980
12674
  }
11981
- if (!open) return;
12675
+ if (!open) {
12676
+ // A shut panel has no card to glide; a glide armed by a snap is spent here
12677
+ // rather than left to surprise the next open.
12678
+ anchorGlide = false;
12679
+ return;
12680
+ }
11982
12681
 
11983
12682
  // The shadow's own layer: the card is clipped while it animates, and a clip
11984
12683
  // on the card would clip the card's own filter away with it (see the sheet).
11985
12684
  var shadow = el("div", "dfp-floatShadow");
11986
12685
  var card = el("div", "dfp-floatCard");
12686
+ // The translucent surface behind the card. A SIBLING, never a child: a blur
12687
+ // inside the card would sample the card's own contents (the card fades to .6
12688
+ // and then 0 while it unfolds, and an element below opacity 1 is a backdrop
12689
+ // root). Mounted directly before the card at the end of this render.
12690
+ var glass = el("div", "dfp-floatGlass" + (GLASS_OK ? " dfp-floatGlassOn" : ""));
12691
+ // Mounted as the card's first child at the end of this render.
11987
12692
  // The bar the dot sits in decides the expand direction, and that bar is the
11988
12693
  // CONVERSATION's: `floatBounds` measures it, so a dot parked in the region's
11989
12694
  // top-right grows left-down even where the window would call it bottom-right.
@@ -11998,15 +12703,24 @@ function mountFloatPanel(scope, t, hostCtx) {
11998
12703
  // first one is a decision, not a coincidence.
11999
12704
  anchor = { x: bounds.right, y: bounds.bottom };
12000
12705
  }
12001
- var quadrant = shared.floatQuadrantIn(bounds, anchor.x, anchor.y);
12002
- // The remembered corner is the answer the card is really anchored on: a
12003
- // region narrow enough puts a corner's coordinates on the other side of its
12004
- // own middle, and the card still has to grow AWAY from the corner the dot
12005
- // lives in rather than flip around it. A dot that is not on its remembered
12006
- // corner (mid-drag, or a position just adopted) is read from where it is.
12007
- // Asked through a helper: `corner` below is the CARD's anchored corner, and
12008
- // a local of that name here would shadow the dot's identity.
12009
- quadrant = dotQuadrant(bounds, anchor, quadrant);
12706
+ var quadrant;
12707
+ if (cardQuadrant !== null) {
12708
+ // The card's side is its own answer (see `voteCardQuadrant`): the side of
12709
+ // the dot that holds more of the card, voted when a side may change.
12710
+ quadrant = cardQuadrant;
12711
+ } else {
12712
+ // A card that has never been placed anywhere unfolds out of the dot, which
12713
+ // is also where a first panel ends up.
12714
+ quadrant = shared.floatQuadrantIn(bounds, anchor.x, anchor.y);
12715
+ // The remembered corner is the answer the card is really anchored on: a
12716
+ // region narrow enough puts a corner's coordinates on the other side of its
12717
+ // own middle, and the card still has to grow AWAY from the corner the dot
12718
+ // lives in rather than flip around it. A dot that is not on its remembered
12719
+ // corner (mid-drag, or a position just adopted) is read from where it is.
12720
+ // Asked through a helper: `corner` below is the CARD's anchored corner, and
12721
+ // a local of that name here would shadow the dot's identity.
12722
+ quadrant = dotQuadrant(bounds, anchor, quadrant);
12723
+ }
12010
12724
  liveQuadrant = quadrant;
12011
12725
  var expand = shared.floatExpandDirection(quadrant);
12012
12726
  try {
@@ -12040,15 +12754,12 @@ function mountFloatPanel(scope, t, hostCtx) {
12040
12754
  // a card sized on a big screen must still fit a small one. The card fills
12041
12755
  // the wrapper, so the wrapper is the box that gets the size.
12042
12756
  shadow.style.width = "min(" + sized.w + "px,calc(100vw - 32px))";
12043
- // The stored HEIGHT is a cap on the card, not a fixed box for it. As a fixed box it
12044
- // left a hole wherever the content had been: folding the panel's own block kept the
12045
- // card at its stored height with 260px of nothing under the sliders (measured). As a
12046
- // cap the panel is exactly as tall as its content up to that height, so folding gives
12047
- // the space back — while a panel dragged shorter than its content still scrolls,
12048
- // because the card is a flex column whose body owns the overflow. The wrapper is left
12049
- // to size itself, except mid-drag, where the pointer's own height is in charge.
12050
- card.style.maxHeight =
12051
- sized.h > 0 ? "min(" + sized.h + "px,calc(100vh - 24px))" : "";
12757
+ // The height is the CONTENT's, always: the panel is exactly as tall as what
12758
+ // it holds, and the card's own `max-height: calc(100vh - 24px)` keeps a long
12759
+ // one inside the window. A stored height is no longer applied — the two
12760
+ // vertical grips that produced one are gone, and honouring an old value
12761
+ // would leave such a panel short with a scrollbar it cannot grow out of.
12762
+ card.style.maxHeight = "";
12052
12763
  if (resize === null) shadow.style.height = "";
12053
12764
  }
12054
12765
  var head = el("div", "dfp-floatHead");
@@ -12067,6 +12778,32 @@ function mountFloatPanel(scope, t, hostCtx) {
12067
12778
  shared.FLOAT_CARD_PAD
12068
12779
  ) + "px";
12069
12780
  head.appendChild(el("span", "dfp-floatTitle", t("panel.title")));
12781
+ // The preset picker lives in the head, right of the title: the panel is where
12782
+ // the page actually gets tuned, so switching a whole setup should not need a
12783
+ // trip to the settings sheet, and the head is the one row always on screen.
12784
+ if (state.presetList.length > 0) {
12785
+ var picker = el("select", "dfp-floatPreset");
12786
+ picker.setAttribute("aria-label", t("preset.label"));
12787
+ picker.title = t("preset.label");
12788
+ if (!state.writable) picker.disabled = true;
12789
+ for (var pickIndex = 0; pickIndex < state.presetList.length; pickIndex += 1) {
12790
+ var pickEntry = state.presetList[pickIndex];
12791
+ var pickOption = el("option", null, pickEntry.name);
12792
+ pickOption.value = pickEntry.name;
12793
+ if (pickEntry.name === state.activeName) pickOption.selected = true;
12794
+ picker.appendChild(pickOption);
12795
+ }
12796
+ picker.addEventListener("change", function () {
12797
+ var picked = readState();
12798
+ for (var scan = 0; scan < picked.presetList.length; scan += 1) {
12799
+ if (picked.presetList[scan].name === picker.value) {
12800
+ applyPreset(picked.presetList[scan], picked);
12801
+ return;
12802
+ }
12803
+ }
12804
+ });
12805
+ head.appendChild(picker);
12806
+ }
12070
12807
  // The settings entry sits IMMEDIATELY LEFT of the close: the same circle, one
12071
12808
  // diameter plus FLOAT_SETTINGS_GAP along the corner (see the stylesheet).
12072
12809
  // Its own rule puts it there, so the pair stays 8px apart at every card size.
@@ -12102,8 +12839,13 @@ function mountFloatPanel(scope, t, hostCtx) {
12102
12839
  // this guard missing, the click the close button waits for never arrived.
12103
12840
  head.addEventListener("pointerdown", function (event) {
12104
12841
  var pressed = event.target;
12842
+ // Anything the reader can operate owns its own press: the two round buttons,
12843
+ // and the preset picker that now shares this row. A press that reached the
12844
+ // drag instead would preventDefault it, and a prevented pointerdown
12845
+ // suppresses the compatibility mouse events — the click the control waits
12846
+ // for would never arrive.
12105
12847
  if (pressed !== null && pressed !== undefined && typeof pressed.closest === "function" &&
12106
- pressed.closest("button") !== null) {
12848
+ pressed.closest("button,select,input,textarea,a") !== null) {
12107
12849
  return;
12108
12850
  }
12109
12851
  startDrag(event, head, state.writable);
@@ -12298,27 +13040,56 @@ function mountFloatPanel(scope, t, hostCtx) {
12298
13040
  notches: shape.max - shape.min + 1,
12299
13041
  });
12300
13042
  card.appendChild(body);
12301
- // Edge resize handles: right, bottom and the corner. The card follows the
12302
- // pointer live; one size write lands on release (see `startResize`).
13043
+ // One resize grip: the right edge, width only. It sits INSIDE the card, so a
13044
+ // press near the panel's right edge is still a resize and never a stray click
13045
+ // on whatever the row put there.
12303
13046
  (function () {
12304
- var edges = [
12305
- { mode: "w", className: "dfp-floatResize dfp-floatResizeR" },
12306
- { mode: "h", className: "dfp-floatResize dfp-floatResizeB" },
12307
- { mode: "wh", className: "dfp-floatResize dfp-floatResizeBR" },
12308
- ];
12309
- for (var edgeIndex = 0; edgeIndex < edges.length; edgeIndex += 1) {
12310
- (function (edge) {
12311
- var grip = el("div", edge.className);
12312
- grip.setAttribute("data-dfp-resize", edge.mode);
12313
- grip.addEventListener("pointerdown", function (event) {
12314
- startResize(event, edge.mode, shadow, card);
12315
- });
12316
- card.appendChild(grip);
12317
- })(edges[edgeIndex]);
12318
- }
13047
+ var grip = el("div", "dfp-floatResize dfp-floatResizeR");
13048
+ grip.setAttribute("data-dfp-resize", "w");
13049
+ grip.addEventListener("pointerdown", function (event) {
13050
+ startResize(event, "w", shadow, card);
13051
+ });
13052
+ card.appendChild(grip);
12319
13053
  })();
13054
+ var shadeEl = el("div", "dfp-floatShade");
13055
+ shadeEl.style.transformOrigin = cardCorner.origin;
13056
+ shadow.appendChild(shadeEl);
13057
+ shadow.appendChild(glass);
12320
13058
  shadow.appendChild(card);
12321
13059
  host.appendChild(shadow);
13060
+ // A side is an anchored EDGE, so switching it moves the card by its own
13061
+ // width in one frame. On a snap that would be a jump to the middle of the
13062
+ // screen FOLLOWED by the slide to the corner — two motions. The card is put
13063
+ // back where it already was on screen with a translate, and that translate
13064
+ // is released over the dot's own 160 ms: the panel then travels from where
13065
+ // the user let go straight to the corner, in one motion, with the dot.
13066
+ var anchorLeft = 0;
13067
+ var anchorTop = 0;
13068
+ try {
13069
+ anchorLeft = shadow.offsetLeft;
13070
+ anchorTop = shadow.offsetTop;
13071
+ } catch (error) {
13072
+ // An unmeasurable layer keeps no glide; the side change is instant.
13073
+ }
13074
+ if (anchorGlide && lastAnchor !== null) {
13075
+ anchorGlide = false;
13076
+ var glideX = lastAnchor.left - anchorLeft;
13077
+ var glideY = lastAnchor.top - anchorTop;
13078
+ if (glideX !== 0 || glideY !== 0) {
13079
+ try {
13080
+ shadow.style.transform = "translate(" + glideX + "px," + glideY + "px)";
13081
+ // One forced layout commits that frame, so the next write has a value
13082
+ // to leave FROM; without it both writes land in one recalc and the
13083
+ // glide is the same jump it was meant to remove.
13084
+ shadow.getBoundingClientRect();
13085
+ shadow.style.transition = "transform " + FLOAT_SNAP_MS + "ms ease";
13086
+ shadow.style.transform = "none";
13087
+ } catch (error) {
13088
+ shadow.style.transform = "";
13089
+ }
13090
+ }
13091
+ }
13092
+ lastAnchor = { left: anchorLeft, top: anchorTop };
12322
13093
  // Enter animation, only on a real shut->open transition: the card starts
12323
13094
  // clipped to the dot's own rectangle, a hair smaller and dimmer, and the
12324
13095
  // next frames let the transition carry all three home (clip/transform/
@@ -12333,6 +13104,8 @@ function mountFloatPanel(scope, t, hostCtx) {
12333
13104
  card.classList.add("dfp-floatCardArm");
12334
13105
  armClipStart(card);
12335
13106
  card.classList.add("dfp-floatCardEnter");
13107
+ setShadeClass("dfp-floatShadeHidden", true);
13108
+ mirrorGlassClip(card, glass, FLOAT_UNFOLD_MS + 80);
12336
13109
  var settled = false;
12337
13110
  var settleTimer = null;
12338
13111
  /**
@@ -12366,6 +13139,7 @@ function mountFloatPanel(scope, t, hostCtx) {
12366
13139
  return;
12367
13140
  }
12368
13141
  card.style.clipPath = "none";
13142
+ if (glass !== null && glass !== undefined) glass.style.clipPath = "none";
12369
13143
  };
12370
13144
  var settle = function () {
12371
13145
  if (settled) return;
@@ -12377,6 +13151,9 @@ function mountFloatPanel(scope, t, hostCtx) {
12377
13151
  if (card.parentNode !== null) {
12378
13152
  card.classList.remove("dfp-floatCardArm");
12379
13153
  card.classList.remove("dfp-floatCardEnter");
13154
+ // Two frames in, with the pinned 0 already painted: releasing it now runs
13155
+ // the shade's fade over exactly the span the card spends growing.
13156
+ setShadeClass("dfp-floatShadeHidden", false);
12380
13157
  }
12381
13158
  // The dot goes out on the same two frames, so its own 1 paints first
12382
13159
  // and the cross-fade runs with the unfold rather than before it.
@@ -12464,6 +13241,21 @@ function mountFloatPanel(scope, t, hostCtx) {
12464
13241
  } catch (error) {
12465
13242
  // A host without an event target still watches through the observer.
12466
13243
  }
13244
+ // The panel edits whichever set the page is showing, so a theme switch has to
13245
+ // rebuild it: without this, flipping to dark left every slider on the light
13246
+ // values, and the next drag wrote those back into the light set — the exact
13247
+ // opposite of what the reader asked for.
13248
+ try {
13249
+ themeWatcher = new MutationObserver(function () {
13250
+ render();
13251
+ });
13252
+ themeWatcher.observe(document.body, {
13253
+ attributes: true,
13254
+ attributeFilter: ["data-ds-dark-theme"],
13255
+ });
13256
+ } catch (error) {
13257
+ // A host without MutationObserver keeps the panel on the set it opened with.
13258
+ }
12467
13259
  watchRegion();
12468
13260
  reflowIntoBounds();
12469
13261
  // Armed AFTER the mount settle: the first reading is only the baseline the next
@@ -12503,6 +13295,14 @@ function mountFloatPanel(scope, t, hostCtx) {
12503
13295
  }
12504
13296
  pollTimer = null;
12505
13297
  }
13298
+ if (themeWatcher !== null) {
13299
+ try {
13300
+ themeWatcher.disconnect();
13301
+ } catch (error) {
13302
+ // Already gone with the page.
13303
+ }
13304
+ themeWatcher = null;
13305
+ }
12506
13306
  pollBounds = null;
12507
13307
  armed = false;
12508
13308
  leaving = false;
@@ -12853,10 +13653,13 @@ function apply(ctx) {
12853
13653
  Date.now(),
12854
13654
  PAINT_PATIENCE_MS
12855
13655
  );
12856
- // Light and dark share one value set in this release: the stored flag (if
12857
- // any) is ignored, so no prefixed dark rules are ever emitted.
13656
+ // Per-theme is honoured exactly as stored (0.7.0). This used to be forced to
13657
+ // `false` here because nothing in the UI could edit the dark set, so a stored
13658
+ // flag could only produce rules the reader had no way to understand. The
13659
+ // card's theme control is that way now: `normalizeConfig` has already coerced
13660
+ // the flag and decoded the map, and `buildFontCss` emits the second,
13661
+ // theme-prefixed ruleset whenever the two sets actually differ.
12858
13662
  var unified = normalizeConfig(shared.overlayPendingValues(snapshot.value, pendingLocalValues));
12859
- unified[PER_THEME_FIELD] = false;
12860
13663
  probeLog("sync", {
12861
13664
  overlay: Object.keys(pendingLocalValues).map(function (key) {
12862
13665
  return key + "=" + String(pendingLocalValues[key].value);