dsh-fonttune 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/shared.cjs CHANGED
@@ -119,6 +119,19 @@ var NO_SYNTHETIC_ITALIC_FIELD = "noSyntheticItalic";
119
119
  /** Disable synthetic (faux) bold, for faces without a real bold. */
120
120
  var NO_SYNTHETIC_BOLD_FIELD = "noSyntheticBold";
121
121
 
122
+ /**
123
+ * How the browser draws the strokes: `SMOOTHING_AUTO` leaves the system's own
124
+ * rendering alone, `SMOOTHING_SHARP` asks for the crisp subpixel kind and
125
+ * `SMOOTHING_SMOOTH` for the soft grayscale kind. The same face reads firmer
126
+ * under one and softer under the other, so this is a look the reader feels
127
+ * rather than measures — durable, but never part of a preset snapshot.
128
+ */
129
+ var SMOOTHING_FIELD = "fontSmoothing";
130
+ var SMOOTHING_AUTO = "auto";
131
+ var SMOOTHING_SHARP = "sharp";
132
+ var SMOOTHING_SMOOTH = "smooth";
133
+ var SMOOTHING_VALUES = [SMOOTHING_AUTO, SMOOTHING_SHARP, SMOOTHING_SMOOTH];
134
+
122
135
  /** Whether the dark theme keeps its own value set (sparse overrides). */
123
136
  var PER_THEME_FIELD = "perTheme";
124
137
 
@@ -143,6 +156,43 @@ var ACTIVE_PRESET_FIELD = "activePreset";
143
156
  */
144
157
  var UI_FOLLOWS_FIELD = "uiFollowsDialog";
145
158
 
159
+ /**
160
+ * The floating panel's master switch. Chrome, not typography: it never rides
161
+ * a preset snapshot, so switching presets cannot hide or move the panel.
162
+ */
163
+ var PANEL_ENABLED_FIELD = "panelEnabled";
164
+
165
+ /**
166
+ * The floating panel's last position, as `"x,y"` in CSS pixels from the
167
+ * viewport's top-left (empty = the default corner). Chrome, not typography:
168
+ * kept out of presets for the same reason as the master switch.
169
+ */
170
+ var PANEL_POS_FIELD = "panelPos";
171
+
172
+ /**
173
+ * The corner the floating panel LIVES in: one of `"tl"`, `"tr"`, `"bl"`, `"br"`,
174
+ * or "" while no corner has been chosen yet.
175
+ *
176
+ * The corner is the dot's identity and the position is only its consequence. A
177
+ * region that changes shape — a sidebar folding away, a top bar appearing, a
178
+ * window resize — MOVES the corner the dot sits on, and the dot follows that
179
+ * corner; it does not re-decide which one it belongs to from the coordinates the
180
+ * old region gave it. Only a release the user made themselves re-decides (the
181
+ * nearest corner, see `floatNearestCorner`), and this field is what carries that
182
+ * decision across every later region change. Chrome, not typography: kept out of
183
+ * presets for the same reason as the master switch.
184
+ */
185
+ var PANEL_CORNER_FIELD = "panelCorner";
186
+
187
+ /**
188
+ * The floating panel's last size, as `"w,h"` in CSS pixels (empty = default).
189
+ * Chrome, not typography: persisted next to the position, never snapshotted.
190
+ */
191
+ var PANEL_SIZE_FIELD = "panelSize";
192
+
193
+ /** The four corners a remembered identity may name, in reading order. */
194
+ var PANEL_CORNERS = ["tl", "tr", "bl", "br"];
195
+
146
196
  /* ------------------------------------------------------------------ *
147
197
  * ranges and constants
148
198
  * ------------------------------------------------------------------ */
@@ -288,11 +338,16 @@ var DEFAULTS = {
288
338
  codeFeatures: "",
289
339
  noSyntheticItalic: false,
290
340
  noSyntheticBold: false,
341
+ fontSmoothing: SMOOTHING_AUTO,
291
342
  perTheme: false,
292
343
  darkValues: "{}",
293
344
  presets: "[]",
294
345
  activePreset: "",
295
346
  uiFollowsDialog: true,
347
+ panelEnabled: true,
348
+ panelPos: "",
349
+ panelCorner: "",
350
+ panelSize: "",
296
351
  };
297
352
 
298
353
  /**
@@ -337,7 +392,14 @@ var RETIRED_FIELDS = [SIZE_FIELD, LINE_HEIGHT_FIELD];
337
392
  * axis — a preset must never carry it, or applying that preset would re-label a
338
393
  * document still holding absolute weights).
339
394
  */
340
- var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS, [WEIGHT_OFFSETS_FIELD]);
395
+ var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS, [
396
+ WEIGHT_OFFSETS_FIELD,
397
+ PANEL_ENABLED_FIELD,
398
+ PANEL_POS_FIELD,
399
+ PANEL_CORNER_FIELD,
400
+ PANEL_SIZE_FIELD,
401
+ SMOOTHING_FIELD,
402
+ ]);
341
403
 
342
404
  /**
343
405
  * Longest accepted font stack, in characters (mirrored by the host schema).
@@ -451,6 +513,1018 @@ function sanitizeFeatures(value) {
451
513
  /** Characters a `font-feature-settings` value cannot contain. */
452
514
  var FEATURES_UNSAFE = /[^a-zA-Z0-9"' ,]/g;
453
515
 
516
+ /** The widest panel coordinate the position field accepts, in CSS pixels. */
517
+ var PANEL_POS_MAX = 5000;
518
+
519
+ /** The floating panel's default and clamped size, in CSS pixels. */
520
+ var PANEL_SIZE_DEFAULT_W = 264;
521
+ var PANEL_SIZE_DEFAULT_H = 0;
522
+ var PANEL_SIZE_MIN_W = 200;
523
+ var PANEL_SIZE_MAX_W = 520;
524
+ var PANEL_SIZE_MIN_H = 160;
525
+ var PANEL_SIZE_MAX_H = 800;
526
+
527
+ /** The dot's own diameter, the snap margin, and the click-vs-drag threshold. */
528
+ var FLOAT_DOT = 26;
529
+ var FLOAT_MARGIN = 16;
530
+ var FLOAT_DRAG_THRESHOLD = 6;
531
+
532
+ /** The window size a page that reports no usable viewport is measured against. */
533
+ var FLOAT_VIEWPORT_W = 1024;
534
+ var FLOAT_VIEWPORT_H = 768;
535
+
536
+ /**
537
+ * The smallest rectangle the dot can be snapped inside, in CSS pixels.
538
+ *
539
+ * One snapped dot needs its own diameter PLUS a margin on both sides, so
540
+ * anything smaller is not a region the dot can live in: the candidate is
541
+ * rejected and the next one (the window, last) stands in for it.
542
+ */
543
+ var FLOAT_BOUNDS_MIN = FLOAT_DOT + 2 * FLOAT_MARGIN;
544
+
545
+ /**
546
+ * The air the title keeps beside the round close button, in CSS pixels.
547
+ *
548
+ * The close is out of flow in the card's own top-right corner, so the header
549
+ * carries a plain strip on that side: no per-corner arithmetic, no slot for the
550
+ * dot (the dot is faded out while the card is open, so nothing has to be kept
551
+ * clear for it).
552
+ */
553
+ var FLOAT_CLOSE_GAP = 8;
554
+
555
+ /** The round close button's diameter, in CSS pixels (its stylesheet uses it). */
556
+ var FLOAT_CLOSE = 22;
557
+
558
+ /** How far the close button sits from the card's own corner, in CSS pixels. */
559
+ var FLOAT_CLOSE_INSET = 8;
560
+
561
+ /**
562
+ * The air between the two round buttons in the card's head, in CSS pixels.
563
+ *
564
+ * Both are out of flow and pinned to the card's own top-right corner, so the
565
+ * settings entry sits one diameter plus this gap to the LEFT of the close. It
566
+ * is the same 8px the header already uses between its rows, so the pair reads
567
+ * as one group rather than as two unrelated circles.
568
+ */
569
+ var FLOAT_SETTINGS_GAP = 8;
570
+
571
+ /** The card's own horizontal padding, in CSS pixels (mirrors `.dfp-floatCard`). */
572
+ var FLOAT_CARD_PAD = 12;
573
+
574
+ /** The radius the expanded card rests at, in CSS pixels (`.dfp-floatCard`). */
575
+ var FLOAT_CARD_RADIUS = 16;
576
+
577
+ /* ------------------------------------------------------------------ *
578
+ * reaching the settings screen
579
+ * ------------------------------------------------------------------ */
580
+
581
+ /**
582
+ * The setting that ships DSH's Plugins screen, as a stable `data-*` attribute.
583
+ *
584
+ * Measured on the shipped builds (0.2.0-rc.2): `ui-settings-plugins` registers
585
+ * its navigation entry with `id: "plugins"` and the settings shell renders each
586
+ * entry as `<button aria-current>`; the alpha line names the same entry
587
+ * `plugins.item` and opens it as a page. The attribute below is the one form a
588
+ * host actually SHIPS, and it is checked first because it is the only one that
589
+ * does not depend on a label's language.
590
+ */
591
+ var SETTINGS_NAV_ITEM_ATTR = "data-section";
592
+ var SETTINGS_NAV_ITEM_VALUE = "plugins";
593
+
594
+ /** The attribute the settings shell marks its navigation cells with. */
595
+ var SETTINGS_NAV_CURRENT_ATTR = "aria-current";
596
+
597
+ /** The attribute the sidebar's settings launcher gets: it opens a dialog. */
598
+ var SETTINGS_LAUNCHER_HASPOPUP = "dialog";
599
+
600
+ /** The attribute a plugin's own row ships with, and this plugin's id inside it. */
601
+ var PLUGIN_ITEM_ATTR = "data-plugin-item";
602
+ var PLUGIN_ITEM_VALUE = "fonttune";
603
+
604
+ /**
605
+ * Every spelling of this plugin's row, and of the tab that opens its settings.
606
+ *
607
+ * The row is found by its shipped id first (`data-plugin-item="fonttune"`); these
608
+ * labels are the fallback for a host that renders no such attribute, matched
609
+ * case-insensitively by substring so a decorated label still hits.
610
+ */
611
+ var PLUGIN_TEXTS = ["字体增强", "font tune", "fonttune"];
612
+ var PLUGIN_CONFIG_TEXTS = ["插件配置", "plugin configuration", "plugin config"];
613
+
614
+ /** What the plugin row and its tab can be, before the predicate narrows it. */
615
+ var PLUGIN_ITEM_CANDIDATES = "[data-plugin-item], button, a, li, [role=button], [role=tab], [role=treeitem], [role=option], [role=link]";
616
+ var PLUGIN_TAB_CANDIDATES = "button, a, [role=tab], [role=button]";
617
+
618
+ /**
619
+ * Every spelling of the Plugins navigation entry the supported languages use.
620
+ *
621
+ * A host that names its entry in the DOM ships it as translated text, so the
622
+ * text form is a genuine fallback and not the primary route (see
623
+ * `isSettingsNavItem`). Matched case-insensitively by substring, so a host that
624
+ * decorates the label ("Built-in plugins") still hits.
625
+ */
626
+ var SETTINGS_NAV_TEXTS = [
627
+ "插件",
628
+ "Plugins",
629
+ "Plugin",
630
+ "プラグイン",
631
+ "플러그인",
632
+ "Plugins", // de/es/fr/pt all ship "Plugins"; kept explicit for the table's sake
633
+ ];
634
+
635
+ /**
636
+ * The names the sidebar's settings launcher answers to, per language.
637
+ *
638
+ * The launcher is the LAST resort: it opens the settings dialog on whichever
639
+ * section was last active, so a caller that lands here still has to be told
640
+ * which cell to pick. It is the only route that cannot fail on a wrong name,
641
+ * because the attribute it is found by (`aria-haspopup="dialog"`) is structural.
642
+ */
643
+ var SETTINGS_LAUNCHER_TEXTS = ["设置", "Settings", "設定", "설정", "Einstellungen"];
644
+
645
+ /**
646
+ * Read an element's own text, tolerating a host without `textContent`.
647
+ * @param {object} node - the candidate element.
648
+ * @returns {string} its text, or "".
649
+ */
650
+ function readNodeText(node) {
651
+ if (node === null || node === undefined) return "";
652
+ var text = node.textContent;
653
+ return typeof text === "string" ? text : "";
654
+ }
655
+
656
+ /**
657
+ * Read one attribute, tolerating a host without `getAttribute`.
658
+ * @param {object} node - the candidate element.
659
+ * @param {string} name - the attribute name.
660
+ * @returns {string} its value, or "".
661
+ */
662
+ function readNodeAttr(node, name) {
663
+ if (node === null || node === undefined || typeof node.getAttribute !== "function") return "";
664
+ var value = node.getAttribute(name);
665
+ return value === null || value === undefined ? "" : String(value);
666
+ }
667
+
668
+ /**
669
+ * Read an element's classes as a space-separated string.
670
+ * @param {object} node - the candidate element.
671
+ * @returns {string} its classes, or "".
672
+ */
673
+ function readNodeClass(node) {
674
+ if (node === null || node === undefined) return "";
675
+ var name = node.className;
676
+ return typeof name === "string" ? name : "";
677
+ }
678
+
679
+ /**
680
+ * Whether one element is a settings navigation cell that opens the Plugins
681
+ * screen.
682
+ *
683
+ * Two host shapes are accepted, in the order they can be trusted:
684
+ * 1. the entry's OWN id, rendered as a `data-section="plugins"` attribute
685
+ * (the settings shell's list of `settings.section` entries);
686
+ * 2. the entry's translated label, read from a cell the shell marked with
687
+ * `aria-current` — the same attribute the shell puts on every cell it
688
+ * renders, so an unrelated button cannot match by accident.
689
+ * @param {object} node - the candidate element.
690
+ * @returns {boolean} whether it is the Plugins cell.
691
+ */
692
+ function isSettingsNavItem(node) {
693
+ if (node === null || node === undefined || typeof node !== "object") return false;
694
+ if (readNodeAttr(node, SETTINGS_NAV_ITEM_ATTR) === SETTINGS_NAV_ITEM_VALUE) return true;
695
+ if (readNodeAttr(node, SETTINGS_NAV_CURRENT_ATTR) === "") return false;
696
+ var text = readNodeText(node).trim().toLowerCase();
697
+ if (text === "") return false;
698
+ for (var index = 0; index < SETTINGS_NAV_TEXTS.length; index += 1) {
699
+ if (text.indexOf(SETTINGS_NAV_TEXTS[index].toLowerCase()) >= 0) return true;
700
+ }
701
+ return false;
702
+ }
703
+
704
+ /**
705
+ * Whether one element is THIS plugin's own row in the Plugins list.
706
+ *
707
+ * The shipped id is checked first — `data-plugin-item="fonttune"` is data the host
708
+ * publishes, so it survives a language change; the translated label is the
709
+ * fallback. Only clickable shapes are accepted, so a heading that merely contains
710
+ * the name cannot swallow the click.
711
+ * @param {object} node - the candidate element.
712
+ * @returns {boolean} whether it is the row to open.
713
+ */
714
+ function isPluginEntry(node) {
715
+ if (node === null || node === undefined || typeof node !== "object") return false;
716
+ if (readNodeAttr(node, PLUGIN_ITEM_ATTR) === PLUGIN_ITEM_VALUE) return true;
717
+ var tag = typeof node.tagName === "string" ? node.tagName.toLowerCase() : "";
718
+ var role = readNodeAttr(node, "role").toLowerCase();
719
+ // A bare `li` is NOT clickable, so the text fallback must never accept one:
720
+ // a conversation message that merely QUOTES the plugin's name would become
721
+ // "the entry" and swallow the click (seen on a real page, where the report
722
+ // naming this plugin was the only match on screen).
723
+ if (tag !== "button" && tag !== "a" && role !== "button" && role !== "tab" && role !== "treeitem" && role !== "option" && role !== "link") {
724
+ return false;
725
+ }
726
+ var text = readNodeText(node).trim().toLowerCase();
727
+ if (text === "") return false;
728
+ for (var index = 0; index < PLUGIN_TEXTS.length; index += 1) {
729
+ if (text.indexOf(PLUGIN_TEXTS[index].toLowerCase()) >= 0) return true;
730
+ }
731
+ return false;
732
+ }
733
+
734
+ /** The package name the host's own navigation service opens for this plugin. */
735
+ var PLUGIN_BUNDLE_NAME = "dsh-fonttune";
736
+
737
+ /** The main panel id the Plugins page lives in (what `layout.selectPanel` takes). */
738
+ var PLUGIN_PANEL_ID = "plugins";
739
+
740
+ /**
741
+ * Whether a node sits inside this plugin's own floating panel.
742
+ *
743
+ * The panel can carry the plugin's own name, and a lookup that mistakes it for
744
+ * a host entry would click ourselves.
745
+ * @param {object} node - the candidate element.
746
+ * @returns {boolean} true when the node is ours, not the host's.
747
+ */
748
+ function isOwnPanelNode(node) {
749
+ if (node === null || node === undefined) return true;
750
+ try {
751
+ return typeof node.closest === "function" && node.closest(".dfp-floatHost") !== null;
752
+ } catch (error) {
753
+ return false;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * THIS plugin's own card on the host's Plugins page, or null.
759
+ *
760
+ * The Plugins page publishes every entry as `data-plugin-item="<id>"` and this
761
+ * plugin's registration id is `fonttune`: the attribute is host-published data,
762
+ * so the lookup survives a language change and can never match a sentence that
763
+ * merely names the plugin.
764
+ * @param {object} doc - the document to search (a stub is fine).
765
+ * @returns {object|null} the card element, or null.
766
+ */
767
+ function pluginEntryCard(doc) {
768
+ if (doc === null || doc === undefined || typeof doc.querySelector !== "function") return null;
769
+ var card = null;
770
+ try {
771
+ card = doc.querySelector("[" + PLUGIN_ITEM_ATTR + '="' + PLUGIN_ITEM_VALUE + '"]');
772
+ } catch (error) {
773
+ return null;
774
+ }
775
+ if (card === null || card === undefined) return null;
776
+ return isOwnPanelNode(card) ? null : card;
777
+ }
778
+
779
+ /**
780
+ * The element that actually opens a plugin's page from its entry card.
781
+ *
782
+ * The card itself is a list item with no handler of its own; the host renders
783
+ * the title button inside it as the open gesture, and that button is the first
784
+ * one the card carries. A node that is already the clickable is returned as-is.
785
+ * @param {object} node - the card, or a clickable candidate.
786
+ * @returns {object|null} the element to click, or null.
787
+ */
788
+ function pluginClickTarget(node) {
789
+ if (node === null || node === undefined) return null;
790
+ var tag = typeof node.tagName === "string" ? node.tagName.toLowerCase() : "";
791
+ var role = readNodeAttr(node, "role").toLowerCase();
792
+ if (tag === "button" || tag === "a" || role === "button" || role === "tab" || role === "link") return node;
793
+ var buttons = typeof node.querySelectorAll === "function" ? node.querySelectorAll("button") : [];
794
+ return buttons.length > 0 ? buttons[0] : null;
795
+ }
796
+
797
+ /**
798
+ * Whether one element opens this plugin's own configuration page inside its row.
799
+ *
800
+ * Optional by design: a host that shows the configuration inline has no such tab,
801
+ * and the caller treats "not found" as success rather than as a failure.
802
+ * @param {object} node - the candidate element.
803
+ * @returns {boolean} whether it is the configuration tab.
804
+ */
805
+ function isPluginConfigTab(node) {
806
+ if (node === null || node === undefined || typeof node !== "object") return false;
807
+ var text = readNodeText(node).trim().toLowerCase();
808
+ if (text === "") return false;
809
+ for (var index = 0; index < PLUGIN_CONFIG_TEXTS.length; index += 1) {
810
+ if (text.indexOf(PLUGIN_CONFIG_TEXTS[index].toLowerCase()) >= 0) return true;
811
+ }
812
+ return false;
813
+ }
814
+
815
+ /**
816
+ * Whether one element is the sidebar's settings launcher.
817
+ *
818
+ * Structural, not textual: DSH's shell renders it as a button whose
819
+ * `aria-haspopup` is `dialog`. Its label only has to CONFIRM the reading, so a
820
+ * host that renames the section list still opens the dialog, and a random
821
+ * `aria-haspopup="dialog"` button (a menu elsewhere on the page) is rejected by
822
+ * the label rather than by position.
823
+ * @param {object} node - the candidate element.
824
+ * @returns {boolean} whether it is the settings launcher.
825
+ */
826
+ function isSettingsLauncher(node) {
827
+ if (node === null || node === undefined || typeof node !== "object") return false;
828
+ if (readNodeAttr(node, "aria-haspopup") !== SETTINGS_LAUNCHER_HASPOPUP) return false;
829
+ var label = (readNodeAttr(node, "aria-label") || readNodeAttr(node, "title") || readNodeText(node))
830
+ .trim()
831
+ .toLowerCase();
832
+ if (label === "") return false;
833
+ for (var index = 0; index < SETTINGS_LAUNCHER_TEXTS.length; index += 1) {
834
+ if (label.indexOf(SETTINGS_LAUNCHER_TEXTS[index].toLowerCase()) >= 0) return true;
835
+ }
836
+ return false;
837
+ }
838
+
839
+ /**
840
+ * Hand a synthetic event to a node, reporting whether the host took it.
841
+ *
842
+ * `cancelable` matters: a host may `preventDefault` the event to mark it as
843
+ * consumed, and that is the only acknowledgement available here.
844
+ * @param {object} node - the element to dispatch on.
845
+ * @param {string} type - the event type.
846
+ * @returns {boolean} whether the host ran without throwing.
847
+ */
848
+ function dispatchSynthetic(node, type) {
849
+ if (node === null || node === undefined || typeof node.dispatchEvent !== "function") return false;
850
+ var win = typeof globalThis !== "undefined" ? globalThis : null;
851
+ var View = win !== null && win.MouseEvent !== undefined ? win.MouseEvent : null;
852
+ var event = null;
853
+ try {
854
+ event = View !== null
855
+ ? new View(type, { bubbles: true, cancelable: true, view: win })
856
+ : { type: type, bubbles: true, cancelable: true, defaultPrevented: false, preventDefault: function () {} };
857
+ } catch (error) {
858
+ event = { type: type, bubbles: true, cancelable: true, defaultPrevented: false, preventDefault: function () {} };
859
+ }
860
+ try {
861
+ node.dispatchEvent(event);
862
+ return true;
863
+ } catch (error) {
864
+ return false;
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Resolve the shortest route to DSH's Plugins settings screen — or null.
870
+ *
871
+ * The panel's settings button must not guess at a fragile long selector, and it
872
+ * must not throw when the host is not there at all (the memory scope, a host
873
+ * line this package no longer supports, a page rendered before the shell
874
+ * mounted). So the lookup is a list of NARROWED plans, each with the evidence
875
+ * it rests on, and a `run` that performs the step:
876
+ *
877
+ * 1. `section-attr` — the Plugins cell, found by the entry's own id rendered as
878
+ * a `data-*` attribute. Stable by construction: it is data the host
879
+ * publishes, not text it translates.
880
+ * 2. `section-text` — the same cell found by its translated label among the
881
+ * cells the shell marked `aria-current="true"`. Same click, weaker key; it
882
+ * is what a host that renders no per-entry attribute needs.
883
+ * 3. `launcher` — no cell yet, so the sidebar's settings launcher is clicked
884
+ * first and the cell is looked for again on a later frame. The caller hands
885
+ * the retry in as `retry`, so this function stays pure about the DOM it can
886
+ * see right now.
887
+ *
888
+ * Every step is a plain `click()`, which is what a user does; nothing here pokes
889
+ * at a framework's internals, and nothing depends on element coordinates.
890
+ *
891
+ * @param {object} doc - the document to search (a stub is fine).
892
+ * @param {{retry?: (select: Function) => boolean}} [options] - retry runner.
893
+ * @returns {{kind: string, why: string, node: object, run: Function}|null} the plan, or null.
894
+ */
895
+ function findSettingsEntry(doc, options) {
896
+ if (doc === null || doc === undefined || typeof doc !== "object") return null;
897
+ var settings = options !== null && typeof options === "object" ? options : {};
898
+ var all = function () {
899
+ var found = typeof doc.querySelectorAll === "function" ? doc.querySelectorAll("button") : null;
900
+ return found === null || found === undefined ? [] : found;
901
+ };
902
+ var first = function (predicate) {
903
+ var nodes = all();
904
+ for (var index = 0; index < nodes.length; index += 1) {
905
+ if (predicate(nodes[index])) return nodes[index];
906
+ }
907
+ return null;
908
+ };
909
+ var cellWithAttr = function () {
910
+ return first(function (node) {
911
+ return readNodeAttr(node, SETTINGS_NAV_ITEM_ATTR) === SETTINGS_NAV_ITEM_VALUE;
912
+ });
913
+ };
914
+ var cellByText = function () {
915
+ return first(function (node) {
916
+ return readNodeAttr(node, SETTINGS_NAV_CURRENT_ATTR) !== "" && isSettingsNavItem(node);
917
+ });
918
+ };
919
+ var launcher = function () {
920
+ return first(isSettingsLauncher);
921
+ };
922
+ // The plugin row and its configuration tab are not necessarily `button`s, so the
923
+ // search is by shape instead of by tag alone.
924
+ var firstIn = function (selector, predicate) {
925
+ var nodes = typeof doc.querySelectorAll === "function" ? doc.querySelectorAll(selector) : [];
926
+ for (var index = 0; index < nodes.length; index += 1) {
927
+ if (predicate(nodes[index])) return nodes[index];
928
+ }
929
+ return null;
930
+ };
931
+ var pluginEntry = function () {
932
+ var found = firstIn(PLUGIN_ITEM_CANDIDATES, function (node) {
933
+ // Our own floating panel may carry the plugin's own name; never click ourselves.
934
+ try {
935
+ if (typeof node.closest === "function" && node.closest(".dfp-floatHost") !== null) return false;
936
+ } catch (error) {
937
+ // A node without closest cannot be ours anyway.
938
+ }
939
+ return isPluginEntry(node);
940
+ });
941
+ if (found === null) return null;
942
+ // The entry card is a list item with no handler of its own: the click lands
943
+ // on the title button the host renders inside it.
944
+ return pluginClickTarget(found) || found;
945
+ };
946
+ var pluginConfigTab = function () {
947
+ return firstIn(PLUGIN_TAB_CANDIDATES, isPluginConfigTab);
948
+ };
949
+ // A plan that just clicks the cell it names.
950
+ var sectionPlan = function (kind, why, node) {
951
+ return {
952
+ kind: kind,
953
+ why: why,
954
+ node: node,
955
+ run: function () {
956
+ return dispatchSynthetic(node, "click");
957
+ },
958
+ };
959
+ };
960
+ /**
961
+ * Give a plan the tail every route shares: open the Plugins cell, then THIS
962
+ * plugin's own row inside it, then its configuration tab when the host has one.
963
+ *
964
+ * The row mounts one commit after the cell, so the waiting belongs to the
965
+ * caller's `retry` runner and this function stays pure about what it can see.
966
+ * A host with no separate tab is normal, not a failure: the row click is the
967
+ * answer in that case.
968
+ * @param {object} plan - a plan that opens the Plugins screen.
969
+ * @returns {object} the same plan with the tail attached.
970
+ */
971
+ var withPluginEntry = function (plan) {
972
+ var opened = plan.run;
973
+ plan.run = function () {
974
+ var reached = opened();
975
+ if (typeof settings.retry !== "function") return reached;
976
+ var entry = settings.retry(pluginEntry);
977
+ if (entry === null) return reached;
978
+ return settings.retry(pluginConfigTab) || entry;
979
+ };
980
+ return plan;
981
+ };
982
+ // The plugin's own settings may be its own SIDEBAR entry — a page of its own,
983
+ // not a section of the settings dialog. When the shell publishes one, clicking
984
+ // it opens the configuration directly and there is no settings route to walk.
985
+ // The user's host lists the plugin in the sidebar, and this is that entry.
986
+ var sidebarEntry = pluginEntry();
987
+ if (sidebarEntry !== null) {
988
+ return {
989
+ kind: "sidebar-entry",
990
+ why: "the plugin ships its own entry; it opens the configuration directly",
991
+ node: sidebarEntry,
992
+ run: function () {
993
+ return dispatchSynthetic(sidebarEntry, "click");
994
+ },
995
+ };
996
+ }
997
+ var canonical = cellWithAttr();
998
+ if (canonical !== null) {
999
+ return withPluginEntry(
1000
+ sectionPlan("section-attr", "the Plugins entry ships its own id as a data attribute", canonical)
1001
+ );
1002
+ }
1003
+ var byText = cellByText();
1004
+ if (byText !== null) {
1005
+ return withPluginEntry(
1006
+ sectionPlan("section-text", "the Plugins entry was matched by its translated label", byText)
1007
+ );
1008
+ }
1009
+ var opener = launcher();
1010
+ if (opener === null) return null;
1011
+ return withPluginEntry({
1012
+ kind: "launcher",
1013
+ why: "only the sidebar's settings launcher is on screen; the entry is picked after it opens",
1014
+ node: opener,
1015
+ run: function () {
1016
+ var reached = dispatchSynthetic(opener, "click");
1017
+ if (typeof settings.retry !== "function") return reached;
1018
+ return settings.retry(cellWithAttr) || settings.retry(cellByText) || reached;
1019
+ },
1020
+ });
1021
+ }
1022
+
1023
+ /**
1024
+ * Normalize the floating panel's stored position.
1025
+ * @param {unknown} value - candidate `"x,y"` text.
1026
+ * @returns {string} the normalized `"x,y"` text, or "" when unusable.
1027
+ */
1028
+ function normalizePanelPos(value) {
1029
+ if (typeof value !== "string") return "";
1030
+ var parts = value.trim().split(",");
1031
+ if (parts.length !== 2) return "";
1032
+ var x = Math.round(Number(parts[0]));
1033
+ var y = Math.round(Number(parts[1]));
1034
+ if (!isFinite(x) || !isFinite(y)) return "";
1035
+ x = Math.min(PANEL_POS_MAX, Math.max(0, x));
1036
+ y = Math.min(PANEL_POS_MAX, Math.max(0, y));
1037
+ return x + "," + y;
1038
+ }
1039
+
1040
+ /**
1041
+ * Read the floating panel's stored position for placement.
1042
+ * @param {unknown} value - candidate `"x,y"` text.
1043
+ * @returns {{x: number, y: number}|null} the coordinates, or null when unset.
1044
+ */
1045
+ function parsePanelPos(value) {
1046
+ var text = normalizePanelPos(value);
1047
+ if (text === "") return null;
1048
+ var parts = text.split(",");
1049
+ return { x: Number(parts[0]), y: Number(parts[1]) };
1050
+ }
1051
+
1052
+ /**
1053
+ * Normalize the floating panel's remembered corner.
1054
+ * @param {unknown} value - candidate corner name.
1055
+ * @returns {string} one of `PANEL_CORNERS`, or "" when it names none.
1056
+ */
1057
+ function normalizePanelCorner(value) {
1058
+ if (typeof value !== "string") return "";
1059
+ var text = value.trim().toLowerCase();
1060
+ return PANEL_CORNERS.indexOf(text) >= 0 ? text : "";
1061
+ }
1062
+
1063
+ /**
1064
+ * Read the floating panel's remembered corner for placement.
1065
+ * @param {unknown} value - candidate corner name.
1066
+ * @returns {string|null} the corner, or null when the document names none (an
1067
+ * older document, or one nothing has settled yet).
1068
+ */
1069
+ function parsePanelCorner(value) {
1070
+ var text = normalizePanelCorner(value);
1071
+ return text === "" ? null : text;
1072
+ }
1073
+
1074
+ /**
1075
+ * Normalize the floating panel's stored size.
1076
+ *
1077
+ * A height of 0 means "auto" (the card sizes to its content); any other
1078
+ * height clamps into the resizable window, like the width always does.
1079
+ * @param {unknown} value - candidate `"w,h"` text.
1080
+ * @returns {string} the normalized `"w,h"` text, or "" when unusable.
1081
+ */
1082
+ function normalizePanelSize(value) {
1083
+ if (typeof value !== "string") return "";
1084
+ var parts = value.trim().split(",");
1085
+ if (parts.length !== 2) return "";
1086
+ var w = Math.round(Number(parts[0]));
1087
+ var h = Math.round(Number(parts[1]));
1088
+ if (!isFinite(w) || !isFinite(h)) return "";
1089
+ w = Math.min(PANEL_SIZE_MAX_W, Math.max(PANEL_SIZE_MIN_W, w));
1090
+ if (h !== 0) h = Math.min(PANEL_SIZE_MAX_H, Math.max(PANEL_SIZE_MIN_H, h));
1091
+ return w + "," + h;
1092
+ }
1093
+
1094
+ /**
1095
+ * Read the floating panel's stored size for layout.
1096
+ * @param {unknown} value - candidate `"w,h"` text.
1097
+ * @returns {{w: number, h: number}|null} the size, or null when unset.
1098
+ */
1099
+ function parsePanelSize(value) {
1100
+ var text = normalizePanelSize(value);
1101
+ if (text === "") return null;
1102
+ var parts = text.split(",");
1103
+ return { w: Number(parts[0]), h: Number(parts[1]) };
1104
+ }
1105
+
1106
+ /**
1107
+ * One rectangle as four finite edges, in viewport coordinates.
1108
+ *
1109
+ * A DOMRect, a plain box and anything in between is accepted; the width and
1110
+ * height are optional, because a DOMRect always carries `right`/`bottom` while a
1111
+ * hand-built box usually carries `width`/`height` instead.
1112
+ * @param {unknown} rect - the value to read.
1113
+ * @returns {{left: number, top: number, right: number, bottom: number}|null} the
1114
+ * box, or null when the value holds no usable rectangle.
1115
+ */
1116
+ function floatBoxOf(rect) {
1117
+ if (rect === null || typeof rect !== "object") return null;
1118
+ var left = Number(rect.left);
1119
+ var top = Number(rect.top);
1120
+ var right = rect.right === undefined ? left + Number(rect.width) : Number(rect.right);
1121
+ var bottom = rect.bottom === undefined ? top + Number(rect.height) : Number(rect.bottom);
1122
+ if (!isFinite(left) || !isFinite(top) || !isFinite(right) || !isFinite(bottom)) return null;
1123
+ return { left: left, top: top, right: right, bottom: bottom };
1124
+ }
1125
+
1126
+ /**
1127
+ * The rectangle the dot snaps to, drags within and reads its own quadrant from.
1128
+ *
1129
+ * The dot belongs INSIDE the conversation, not on the chrome around it: with a
1130
+ * sidebar on either side and a top bar, the window's four corners sit on bars the
1131
+ * user never wanted a control on. The caller therefore hands in its candidates in
1132
+ * priority order — the scroll container the conversation lives in first, the
1133
+ * prose element itself second — and the first one that is a usable region wins.
1134
+ *
1135
+ * A candidate is usable when, after being CLIPPED to the viewport, it is at least
1136
+ * `FLOAT_BOUNDS_MIN` on both axes: a container that hangs off the screen would
1137
+ * otherwise snap the dot to a corner nobody can reach, and one smaller than the
1138
+ * dot cannot hold it at all. Nothing usable (a settings page has no conversation
1139
+ * at all) falls back to the whole window, which is what the panel did before.
1140
+ *
1141
+ * The WINNING CANDIDATE is reported alongside the bounds, not only the numbers:
1142
+ * "which rectangle is the region" and "which element's size change means the
1143
+ * region changed" are one question, and the caller that watches for a region
1144
+ * change has to ask it the same way (see `regionElement` in the client half).
1145
+ * @param {Array<unknown>} rects - candidate rectangles, best first.
1146
+ * @param {{width: number, height: number}} viewport - the window size.
1147
+ * @returns {{index: number, bounds: {left: number, top: number, right: number,
1148
+ * bottom: number, width: number, height: number}}} the index of the rectangle
1149
+ * the region was taken from (-1 when the whole window stands in) and the
1150
+ * bounds, in viewport coordinates.
1151
+ */
1152
+ function floatBoundsPick(rects, viewport) {
1153
+ var vw = viewport === null || viewport === undefined ? NaN : Number(viewport.width);
1154
+ var vh = viewport === null || viewport === undefined ? NaN : Number(viewport.height);
1155
+ if (!isFinite(vw) || vw <= 0) vw = FLOAT_VIEWPORT_W;
1156
+ if (!isFinite(vh) || vh <= 0) vh = FLOAT_VIEWPORT_H;
1157
+ var full = { left: 0, top: 0, right: vw, bottom: vh, width: vw, height: vh };
1158
+ var list = Array.isArray(rects) ? rects : [];
1159
+ for (var index = 0; index < list.length; index += 1) {
1160
+ var box = floatBoxOf(list[index]);
1161
+ if (box === null) continue;
1162
+ var left = Math.max(0, Math.min(vw, box.left));
1163
+ var top = Math.max(0, Math.min(vh, box.top));
1164
+ var right = Math.max(0, Math.min(vw, box.right));
1165
+ var bottom = Math.max(0, Math.min(vh, box.bottom));
1166
+ var width = right - left;
1167
+ var height = bottom - top;
1168
+ if (width < FLOAT_BOUNDS_MIN || height < FLOAT_BOUNDS_MIN) continue;
1169
+ return {
1170
+ index: index,
1171
+ bounds: { left: left, top: top, right: right, bottom: bottom, width: width, height: height },
1172
+ };
1173
+ }
1174
+ return { index: -1, bounds: full };
1175
+ }
1176
+
1177
+ /**
1178
+ * The bounds alone, for every caller that only draws with them.
1179
+ * @param {Array<unknown>} rects - candidate rectangles, best first.
1180
+ * @param {{width: number, height: number}} viewport - the window size.
1181
+ * @returns {{left: number, top: number, right: number, bottom: number,
1182
+ * width: number, height: number}} the bounds, in viewport coordinates.
1183
+ */
1184
+ function floatBoundsFrom(rects, viewport) {
1185
+ return floatBoundsPick(rects, viewport).bounds;
1186
+ }
1187
+
1188
+ /**
1189
+ * Whether a second reading of the winning rectangle is the same shape as the last.
1190
+ *
1191
+ * The fallback watcher asks this every tick and nothing else, so it is stated on
1192
+ * FOUR numbers — the two edges — and never on anything derived from them. A caller
1193
+ * that re-read the region, resolved styles or compared a `width`/`height` pair as
1194
+ * well would be doing work the question does not need, twice a second, on a page
1195
+ * that is usually not moving at all.
1196
+ *
1197
+ * A rectangle that could not be read (null, or absent) is never the same shape as
1198
+ * anything: an unreadable region is a change, not a confirmation.
1199
+ * @param {object|null} a - the rectangle read last time.
1200
+ * @param {object|null} b - the rectangle read now.
1201
+ * @returns {boolean} true when both readings name one and the same box.
1202
+ */
1203
+ function sameWatchBounds(a, b) {
1204
+ if (a === null || a === undefined || b === null || b === undefined) return false;
1205
+ return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
1206
+ }
1207
+
1208
+ /**
1209
+ * Whether every box an observer is holding is still in the document.
1210
+ *
1211
+ * An observer holds a NODE, and a node the host replaced (a re-render swapping the
1212
+ * conversation element, a session switch) is detached: it keeps its size forever
1213
+ * and never reports again. So a target list may look unchanged and still be a list
1214
+ * that has to be resolved anew — which is the whole reason the body is watched
1215
+ * alongside it (see `watchRegion` in the client half).
1216
+ * @param {object} doc - the document to ask (`document`, in the client half).
1217
+ * @param {Array<object>} targets - the boxes currently observed.
1218
+ * @returns {boolean} true when nothing in the list was taken out of the tree.
1219
+ */
1220
+ function floatTargetsConnected(doc, targets) {
1221
+ if (doc === null || doc === undefined || typeof doc.contains !== "function") return true;
1222
+ if (Array.isArray(targets) === false) return true;
1223
+ try {
1224
+ for (var index = 0; index < targets.length; index += 1) {
1225
+ if (doc.contains(targets[index]) === false) return false;
1226
+ }
1227
+ } catch (error) {
1228
+ return true;
1229
+ }
1230
+ return true;
1231
+ }
1232
+
1233
+ /**
1234
+ * Which quadrant a point sits in, measured inside the bounds it lives in.
1235
+ *
1236
+ * The bounds matter: the expand direction is decided by the edges the dot is
1237
+ * NEAREST, and NEAREST is a question about the conversation — the region's own
1238
+ * middle, not the window's. Ties fall left/top.
1239
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1240
+ * @param {number} x - CSS pixels from the left.
1241
+ * @param {number} y - CSS pixels from the top.
1242
+ * @returns {string} one of "tl", "tr", "bl", "br".
1243
+ */
1244
+ function floatQuadrantIn(bounds, x, y) {
1245
+ var box = floatBoxOf(bounds);
1246
+ if (box === null) box = { left: 0, top: 0, right: FLOAT_VIEWPORT_W, bottom: FLOAT_VIEWPORT_H };
1247
+ var left = x < (box.left + box.right) / 2;
1248
+ var top = y < (box.top + box.bottom) / 2;
1249
+ if (left && top) return "tl";
1250
+ if (!left && top) return "tr";
1251
+ if (left && !top) return "bl";
1252
+ return "br";
1253
+ }
1254
+
1255
+ /**
1256
+ * Which viewport quadrant a point sits in (ties fall left/top).
1257
+ * @param {number} x - CSS pixels from the left.
1258
+ * @param {number} y - CSS pixels from the top.
1259
+ * @param {number} vw - viewport width.
1260
+ * @param {number} vh - viewport height.
1261
+ * @returns {string} one of "tl", "tr", "bl", "br".
1262
+ */
1263
+ function floatQuadrant(x, y, vw, vh) {
1264
+ return floatQuadrantIn({ left: 0, top: 0, right: vw, bottom: vh }, x, y);
1265
+ }
1266
+
1267
+ /**
1268
+ * Where the panel grows from, given the dot's quadrant: always away from
1269
+ * the nearest edges, so the card lands inside the viewport.
1270
+ * @param {string} quadrant - one of "tl", "tr", "bl", "br".
1271
+ * @returns {string} one of "right-down", "left-down", "right-up", "left-up".
1272
+ */
1273
+ function floatExpandDirection(quadrant) {
1274
+ if (quadrant === "tl") return "right-down";
1275
+ if (quadrant === "tr") return "left-down";
1276
+ if (quadrant === "bl") return "right-up";
1277
+ return "left-up";
1278
+ }
1279
+
1280
+ /**
1281
+ * The expand animation's origin corner, on the dot's side.
1282
+ * @param {string} quadrant - one of "tl", "tr", "bl", "br".
1283
+ * @returns {string} a CSS `transform-origin` value.
1284
+ */
1285
+ function floatTransformOrigin(quadrant) {
1286
+ if (quadrant === "tl") return "0 0";
1287
+ if (quadrant === "tr") return "100% 0";
1288
+ if (quadrant === "bl") return "0 100%";
1289
+ return "100% 100%";
1290
+ }
1291
+
1292
+ /**
1293
+ * Which corner of the card lands on the dot, and where the expansion starts.
1294
+ *
1295
+ * The card's nearest corner COINCIDES with the dot's own corner: the open card
1296
+ * is clipped, at the first frame, down to exactly the dot's rectangle, so the
1297
+ * panel reads as the dot unfolded rather than as a second object. The shared
1298
+ * corner is also the expand animation's origin, so the card grows toward the
1299
+ * opposite corner and shrinks back into the dot on the way out.
1300
+ * @param {string} quadrant - one of "tl", "tr", "bl", "br".
1301
+ * @returns {{h: string, v: string, origin: string}} the anchored edges (CSS
1302
+ * sides, always "0") and the `transform-origin` on that shared corner.
1303
+ */
1304
+ function floatCardAnchor(quadrant) {
1305
+ var left = quadrant === "tl" || quadrant === "bl";
1306
+ var top = quadrant === "tl" || quadrant === "tr";
1307
+ return {
1308
+ h: left ? "left" : "right",
1309
+ v: top ? "top" : "bottom",
1310
+ origin: floatTransformOrigin(quadrant),
1311
+ };
1312
+ }
1313
+
1314
+ /**
1315
+ * The clip-path the card's expand starts from — and its collapse ends on.
1316
+ *
1317
+ * The card is anchored on the dot's own corner, so the dot's rectangle inside
1318
+ * the card is `dot` by `dot` pixels flush with that corner. Clipping the card
1319
+ * down to it gives the "small rounded rectangle" the panel unfolds out of; the
1320
+ * animation then carries the same four lengths to zero, which is what makes the
1321
+ * panel LOOK like it grows instead of merely fading in. The radius is the dot's
1322
+ * own (half its diameter), so the first frame reads as the dot's circle.
1323
+ * @param {number} width - the card's border-box width, in CSS pixels.
1324
+ * @param {number} height - the card's border-box height, in CSS pixels.
1325
+ * @param {string} quadrant - one of "tl", "tr", "bl", "br".
1326
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1327
+ * @returns {string} a CSS `inset()` clip-path.
1328
+ */
1329
+ function floatClipStart(width, height, quadrant, dot) {
1330
+ var size = dot === undefined ? FLOAT_DOT : dot;
1331
+ var anchor = floatCardAnchor(quadrant);
1332
+ var w = Math.max(0, Math.round(width));
1333
+ var h = Math.max(0, Math.round(height));
1334
+ var far = Math.max(0, w - size);
1335
+ var low = Math.max(0, h - size);
1336
+ var top = anchor.v === "top" ? 0 : low;
1337
+ var right = anchor.h === "left" ? far : 0;
1338
+ var bottom = anchor.v === "top" ? low : 0;
1339
+ var left = anchor.h === "left" ? 0 : far;
1340
+ return (
1341
+ "inset(" + top + "px " + right + "px " + bottom + "px " + left +
1342
+ "px round " + Math.round(size / 2) + "px)"
1343
+ );
1344
+ }
1345
+
1346
+ /**
1347
+ * The clip-path the expanded card rests at: its own box, its own radius.
1348
+ *
1349
+ * The four lengths are zero, so nothing is cut away — but the value has to be a
1350
+ * real shape rather than `none`, because `none` does not interpolate and the
1351
+ * unfold would snap instead of growing. It is therefore only the shape the
1352
+ * animation lands ON: the card drops the clip for good once it has arrived
1353
+ * (`isRestClip` is how it knows), because clipping the card's own border box a
1354
+ * second time — `border-radius` already did it once — multiplies the
1355
+ * anti-aliasing along the rounded corners and cuts the card's own shadow away.
1356
+ * @returns {string} a CSS `inset()` clip-path.
1357
+ */
1358
+ function floatClipRest() {
1359
+ var round = FLOAT_CARD_RADIUS + "px";
1360
+ return "inset(0px 0px 0px 0px round " + round + ")";
1361
+ }
1362
+
1363
+ /**
1364
+ * Whether a computed `clip-path` has nothing left to cut away.
1365
+ *
1366
+ * `none` counts, and so does the resting `inset()` the card animates to: its
1367
+ * four lengths are zero, which is the element's own border box. Anything that
1368
+ * still hides part of the element — a clip in flight, a clip to the dot's
1369
+ * rectangle — does not.
1370
+ * @param {unknown} value - a computed `clip-path`.
1371
+ * @returns {boolean} true when the clip hides nothing.
1372
+ */
1373
+ function isRestClip(value) {
1374
+ if (typeof value !== "string") return false;
1375
+ var text = value.trim();
1376
+ if (text === "" || text === "none" || text === "auto") return true;
1377
+ var shape = /^inset\(([^)]*)\)$/.exec(text);
1378
+ if (shape === null) return false;
1379
+ var halves = shape[1].split("round");
1380
+ var found = halves[0].match(/-?\d+(?:\.\d+)?/g);
1381
+ if (found === null) return false;
1382
+ var values = found.map(Number);
1383
+ if (values.length > 4) return false;
1384
+ // CSS repeats the shorter lists: 1 -> all four, 2 -> vertical/horizontal,
1385
+ // 3 -> top/horizontal/bottom.
1386
+ var four;
1387
+ if (values.length === 1) four = [values[0], values[0], values[0], values[0]];
1388
+ else if (values.length === 2) four = [values[0], values[1], values[0], values[1]];
1389
+ else if (values.length === 3) four = [values[0], values[1], values[2], values[1]];
1390
+ else four = values;
1391
+ for (var index = 0; index < four.length; index += 1) {
1392
+ if (Math.abs(four[index]) > 0.5) return false;
1393
+ }
1394
+ if (halves.length < 2) return true;
1395
+ var round = halves[1].match(/-?\d+(?:\.\d+)?/g);
1396
+ if (round === null) return false;
1397
+ return Math.abs(Number(round[0]) - FLOAT_CARD_RADIUS) <= 0.5;
1398
+ }
1399
+
1400
+ /**
1401
+ * The four snapped corners of a region, as the one box they are laid out in.
1402
+ *
1403
+ * The margin is inward from the region's own edges and the dot's diameter is
1404
+ * held clear of the far ones, so a corner of this box is where a dot of that
1405
+ * size REST on that corner of the region. Everything about corners — which one
1406
+ * is nearest, and where a named one is — is read from these four numbers, so the
1407
+ * two questions can never disagree.
1408
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1409
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1410
+ * @param {number} [margin] - the edge margin (defaults to FLOAT_MARGIN).
1411
+ * @returns {{left: number, top: number, right: number, bottom: number}} the
1412
+ * four edges a snapped dot's top-left may take.
1413
+ */
1414
+ function floatCornersOf(bounds, dot, margin) {
1415
+ var size = dot === undefined ? FLOAT_DOT : dot;
1416
+ var gap = margin === undefined ? FLOAT_MARGIN : margin;
1417
+ var box = floatBoxOf(bounds);
1418
+ if (box === null) box = { left: 0, top: 0, right: FLOAT_VIEWPORT_W, bottom: FLOAT_VIEWPORT_H };
1419
+ var left = box.left + Math.max(0, gap);
1420
+ var top = box.top + Math.max(0, gap);
1421
+ return {
1422
+ left: left,
1423
+ top: top,
1424
+ right: Math.max(left, box.right - gap - size),
1425
+ bottom: Math.max(top, box.bottom - gap - size),
1426
+ };
1427
+ }
1428
+
1429
+ /**
1430
+ * Which of a region's four corners a point is nearest.
1431
+ *
1432
+ * Measured between the SNAPPED corners, which is the box `floatCornerPoint`
1433
+ * places a named corner in: "the nearest corner" and "that corner's position"
1434
+ * are then one question with one answer. Ties fall left/top.
1435
+ *
1436
+ * This is the ONLY way a corner is ever chosen: everywhere else the chosen one
1437
+ * is read back from the document (see `PANEL_CORNER_FIELD`), because a region
1438
+ * that changed shape must move the dot to its own corner rather than let the
1439
+ * old region's coordinates pick a different one.
1440
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1441
+ * @param {number} x - CSS pixels from the left.
1442
+ * @param {number} y - CSS pixels from the top.
1443
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1444
+ * @param {number} [margin] - the edge margin (defaults to FLOAT_MARGIN).
1445
+ * @returns {string} one of "tl", "tr", "bl", "br".
1446
+ */
1447
+ function floatNearestCorner(bounds, x, y, dot, margin) {
1448
+ var edges = floatCornersOf(bounds, dot, margin);
1449
+ var left = x < (edges.left + edges.right) / 2;
1450
+ var top = y < (edges.top + edges.bottom) / 2;
1451
+ if (left && top) return "tl";
1452
+ if (!left && top) return "tr";
1453
+ if (left && !top) return "bl";
1454
+ return "br";
1455
+ }
1456
+
1457
+ /**
1458
+ * The coordinates of one NAMED corner of a region, margin and all.
1459
+ *
1460
+ * This is where the dot's identity becomes a position, and the one place a
1461
+ * region change is answered from: whatever happened to the region, a dot that
1462
+ * lives in `br` is put back on the new `br`. A name that is not one of the four
1463
+ * falls back to `br`, the corner the panel is parked in before anything at all
1464
+ * is stored.
1465
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1466
+ * @param {string} corner - one of "tl", "tr", "bl", "br".
1467
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1468
+ * @param {number} [margin] - the edge margin (defaults to FLOAT_MARGIN).
1469
+ * @returns {{x: number, y: number}} the snapped top-left of the dot.
1470
+ */
1471
+ function floatCornerPoint(bounds, corner, dot, margin) {
1472
+ var edges = floatCornersOf(bounds, dot, margin);
1473
+ var left = corner === "tl" || corner === "bl";
1474
+ var top = corner === "tl" || corner === "tr";
1475
+ return {
1476
+ x: Math.round(left ? edges.left : edges.right),
1477
+ y: Math.round(top ? edges.top : edges.bottom),
1478
+ };
1479
+ }
1480
+
1481
+ /**
1482
+ * Snap a point to the nearest corner of a region, keeping the margin clear.
1483
+ *
1484
+ * The candidate corners are the BOUNDS' own, so a dot left on the chrome while
1485
+ * a sidebar grew is pulled onto the conversation's nearest corner — the same
1486
+ * corner the next expand grows away from.
1487
+ *
1488
+ * Asked when the USER picks a corner (a drag released, a document that names
1489
+ * none): everywhere else the corner is remembered rather than re-derived, or a
1490
+ * region change would silently re-pick it (`floatNearestCorner`).
1491
+ * @param {unknown} bounds - the region, as `floatBoundsFrom` returns it.
1492
+ * @param {number} x - CSS pixels from the left.
1493
+ * @param {number} y - CSS pixels from the top.
1494
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1495
+ * @param {number} [margin] - the edge margin (defaults to FLOAT_MARGIN).
1496
+ * @returns {{x: number, y: number}} the snapped top-left of the dot.
1497
+ */
1498
+ function floatSnapTo(bounds, x, y, dot, margin) {
1499
+ return floatCornerPoint(bounds, floatNearestCorner(bounds, x, y, dot, margin), dot, margin);
1500
+ }
1501
+
1502
+ /**
1503
+ * Snap a point to the nearest viewport corner, keeping the margin clear.
1504
+ * @param {number} x - CSS pixels from the left.
1505
+ * @param {number} y - CSS pixels from the top.
1506
+ * @param {number} vw - viewport width.
1507
+ * @param {number} vh - viewport height.
1508
+ * @param {number} [dot] - the dot's diameter (defaults to FLOAT_DOT).
1509
+ * @param {number} [margin] - the edge margin (defaults to FLOAT_MARGIN).
1510
+ * @returns {{x: number, y: number}} the snapped top-left of the dot.
1511
+ */
1512
+ function floatSnapCorner(x, y, vw, vh, dot, margin) {
1513
+ return floatSnapTo({ left: 0, top: 0, right: vw, bottom: vh }, x, y, dot, margin);
1514
+ }
1515
+
1516
+ /**
1517
+ * Whether a pointer travel counts as a drag (past the click threshold).
1518
+ * @param {number} dx - horizontal travel in CSS pixels.
1519
+ * @param {number} dy - vertical travel in CSS pixels.
1520
+ * @param {number} [threshold] - the limit (defaults to FLOAT_DRAG_THRESHOLD).
1521
+ * @returns {boolean} true when the travel is a drag, not a click.
1522
+ */
1523
+ function floatDragExceeded(dx, dy, threshold) {
1524
+ var limit = threshold === undefined ? FLOAT_DRAG_THRESHOLD : threshold;
1525
+ return Math.sqrt(dx * dx + dy * dy) > limit;
1526
+ }
1527
+
454
1528
  /* ------------------------------------------------------------------ *
455
1529
  * configuration normalization
456
1530
  * ------------------------------------------------------------------ */
@@ -488,6 +1562,17 @@ function normalizeField(field, value, relative) {
488
1562
  case UI_FOLLOWS_FIELD:
489
1563
  // Absent means the default: the interface follows the conversation.
490
1564
  return value === undefined || value === null || value === "" ? true : toBool(value);
1565
+ case PANEL_ENABLED_FIELD:
1566
+ // Absent means the default: the floating dot is shown.
1567
+ return value === undefined || value === null || value === "" ? true : toBool(value);
1568
+ case PANEL_POS_FIELD:
1569
+ return normalizePanelPos(value);
1570
+ case PANEL_CORNER_FIELD:
1571
+ return normalizePanelCorner(value);
1572
+ case PANEL_SIZE_FIELD:
1573
+ return normalizePanelSize(value);
1574
+ case SMOOTHING_FIELD:
1575
+ return clampSmoothing(value);
491
1576
  default:
492
1577
  return undefined;
493
1578
  }
@@ -607,6 +1692,36 @@ function clampLigatures(value) {
607
1692
  return mode >= LIGATURES_OFF ? LIGATURES_OFF : LIGATURES_ON;
608
1693
  }
609
1694
 
1695
+ /**
1696
+ * Clamp one text-rendering preference to the three known values.
1697
+ * @param {unknown} value - candidate mode.
1698
+ * @returns {string} "auto", "sharp" or "smooth".
1699
+ */
1700
+ function clampSmoothing(value) {
1701
+ return typeof value === "string" && SMOOTHING_VALUES.indexOf(value) >= 0 ? value : SMOOTHING_AUTO;
1702
+ }
1703
+
1704
+ /**
1705
+ * The stroke-rendering rule one preference asks for, or "".
1706
+ *
1707
+ * `sharp` keeps the system's crisp subpixel rendering explicit and `smooth`
1708
+ * asks for the soft grayscale kind: the same face reads firmer under one and
1709
+ * softer under the other. `auto` — and anything unusable — installs no rule at
1710
+ * all, so installing this plugin still changes nothing by default.
1711
+ * @param {unknown} value - the stored preference.
1712
+ * @returns {string} one rule, or "".
1713
+ */
1714
+ function smoothingRule(value) {
1715
+ var mode = clampSmoothing(value);
1716
+ if (mode === SMOOTHING_SHARP) {
1717
+ return "body{-webkit-font-smoothing:subpixel-antialiased !important;-moz-osx-font-smoothing:auto !important}";
1718
+ }
1719
+ if (mode === SMOOTHING_SMOOTH) {
1720
+ return "body{-webkit-font-smoothing:antialiased !important;-moz-osx-font-smoothing:grayscale !important}";
1721
+ }
1722
+ return "";
1723
+ }
1724
+
610
1725
  /**
611
1726
  * Validate and normalize one sparse axis-value map (dark overrides, presets).
612
1727
  * Only known axis fields survive, each clamped.
@@ -708,6 +1823,67 @@ function migratePresetWeights(presets) {
708
1823
  var PENDING_SET = "set";
709
1824
  var PENDING_UNSET = "unset";
710
1825
 
1826
+ /**
1827
+ * The preset snapshot op a single axis write implies, as one op — or null.
1828
+ *
1829
+ * The auto-save contract (an edit with a preset selected IS that preset from
1830
+ * now on) belongs to every writer, not just the card: the floating panel
1831
+ * confirms through this instead of reimplementing it. A write
1832
+ * that would only move `savedAt` builds nothing, for the same reason the
1833
+ * card's own path skips it — a whole-document rewrite for a timestamp is a
1834
+ * write nobody asked for.
1835
+ * @param {object[]} presetList - the stored preset entries.
1836
+ * @param {string} activeName - the selected preset's name.
1837
+ * @param {Record<string, unknown>} changes - field to value (`undefined`
1838
+ * unsets); only value-axis fields survive.
1839
+ * @returns {{op: string, path: string[], value: string}|null} the op, if due.
1840
+ */
1841
+ function mirrorPresetSnapshot(presetList, activeName, changes) {
1842
+ if (!Array.isArray(presetList) || typeof activeName !== "string" || activeName === "") return null;
1843
+ var entry = null;
1844
+ for (var index = 0; index < presetList.length; index += 1) {
1845
+ if (presetList[index] !== null && typeof presetList[index] === "object" && presetList[index].name === activeName) {
1846
+ entry = presetList[index];
1847
+ break;
1848
+ }
1849
+ }
1850
+ if (entry === null) return null;
1851
+ var base = entry.values !== null && typeof entry.values === "object" && !Array.isArray(entry.values)
1852
+ ? entry.values
1853
+ : {};
1854
+ var values = {};
1855
+ for (var key in base) {
1856
+ if (Object.prototype.hasOwnProperty.call(base, key)) values[key] = base[key];
1857
+ }
1858
+ var changed = false;
1859
+ for (var field in changes) {
1860
+ if (!Object.prototype.hasOwnProperty.call(changes, field)) continue;
1861
+ if (VALUE_FIELDS.indexOf(field) < 0) continue;
1862
+ var next = changes[field];
1863
+ if (next === undefined) {
1864
+ if (Object.prototype.hasOwnProperty.call(values, field)) {
1865
+ delete values[field];
1866
+ changed = true;
1867
+ }
1868
+ continue;
1869
+ }
1870
+ if (values[field] !== next) {
1871
+ values[field] = next;
1872
+ changed = true;
1873
+ }
1874
+ }
1875
+ if (!changed) return null;
1876
+ var list = [];
1877
+ for (var at = 0; at < presetList.length; at += 1) {
1878
+ if (presetList[at] !== null && typeof presetList[at] === "object" && presetList[at].name === activeName) {
1879
+ list.push({ name: activeName, values: values, savedAt: Date.now() });
1880
+ } else {
1881
+ list.push(presetList[at]);
1882
+ }
1883
+ }
1884
+ return { op: PENDING_SET, path: [PRESETS_FIELD], value: JSON.stringify(list) };
1885
+ }
1886
+
711
1887
  /**
712
1888
  * Merge the values the card painted locally over the document's own values.
713
1889
  *
@@ -2620,12 +3796,21 @@ function scaleCodeTokens(baseTokens, scale, lineOffset) {
2620
3796
  function buildFontCss(config, baseTokens, hints) {
2621
3797
  var tokens = baseTokens === undefined || baseTokens === null ? FALLBACK_TOKENS : baseTokens;
2622
3798
  var sets = resolveAxes(config);
2623
- if (isDormant(sets.light) && isDormant(sets.dark)) return "";
3799
+ // The rendering preference is NOT an axis: it must paint even when every
3800
+ // axis is dormant — "the strokes are drawn the other way" is exactly its own
3801
+ // use case with everything else untouched.
3802
+ var smoothing = smoothingRule(config !== null && typeof config === "object" ? config[SMOOTHING_FIELD] : "");
3803
+ if (isDormant(sets.light) && isDormant(sets.dark)) return smoothing;
2624
3804
  var light = buildAxisCss(sets.light, tokens, false, hints);
2625
- if (sameValueSet(sets.light, sets.dark)) return light;
3805
+ if (sameValueSet(sets.light, sets.dark)) {
3806
+ return smoothing === "" ? light : light === "" ? smoothing : light + "\n" + smoothing;
3807
+ }
2626
3808
  var dark = buildAxisCss(sets.dark, tokens, true, hints);
2627
- if (dark === "") return light;
2628
- return light + "\n" + dark;
3809
+ var parts = [];
3810
+ if (light !== "") parts.push(light);
3811
+ if (dark !== "") parts.push(dark);
3812
+ if (smoothing !== "") parts.push(smoothing);
3813
+ return parts.join("\n");
2629
3814
  }
2630
3815
 
2631
3816
  /**
@@ -2994,6 +4179,72 @@ var shared = {
2994
4179
  PRESETS_FIELD: PRESETS_FIELD,
2995
4180
  ACTIVE_PRESET_FIELD: ACTIVE_PRESET_FIELD,
2996
4181
  UI_FOLLOWS_FIELD: UI_FOLLOWS_FIELD,
4182
+ PANEL_ENABLED_FIELD: PANEL_ENABLED_FIELD,
4183
+ PANEL_POS_FIELD: PANEL_POS_FIELD,
4184
+ PANEL_CORNER_FIELD: PANEL_CORNER_FIELD,
4185
+ PANEL_SIZE_FIELD: PANEL_SIZE_FIELD,
4186
+ PANEL_SIZE_DEFAULT_W: PANEL_SIZE_DEFAULT_W,
4187
+ PANEL_SIZE_DEFAULT_H: PANEL_SIZE_DEFAULT_H,
4188
+ PANEL_SIZE_MIN_W: PANEL_SIZE_MIN_W,
4189
+ PANEL_SIZE_MAX_W: PANEL_SIZE_MAX_W,
4190
+ PANEL_SIZE_MIN_H: PANEL_SIZE_MIN_H,
4191
+ PANEL_SIZE_MAX_H: PANEL_SIZE_MAX_H,
4192
+ FLOAT_DOT: FLOAT_DOT,
4193
+ FLOAT_MARGIN: FLOAT_MARGIN,
4194
+ FLOAT_DRAG_THRESHOLD: FLOAT_DRAG_THRESHOLD,
4195
+ FLOAT_VIEWPORT_W: FLOAT_VIEWPORT_W,
4196
+ FLOAT_VIEWPORT_H: FLOAT_VIEWPORT_H,
4197
+ FLOAT_BOUNDS_MIN: FLOAT_BOUNDS_MIN,
4198
+ FLOAT_CLOSE_GAP: FLOAT_CLOSE_GAP,
4199
+ FLOAT_CLOSE: FLOAT_CLOSE,
4200
+ FLOAT_CLOSE_INSET: FLOAT_CLOSE_INSET,
4201
+ FLOAT_SETTINGS_GAP: FLOAT_SETTINGS_GAP,
4202
+ FLOAT_CARD_PAD: FLOAT_CARD_PAD,
4203
+ FLOAT_CARD_RADIUS: FLOAT_CARD_RADIUS,
4204
+ floatBoxOf: floatBoxOf,
4205
+ floatBoundsPick: floatBoundsPick,
4206
+ floatBoundsFrom: floatBoundsFrom,
4207
+ sameWatchBounds: sameWatchBounds,
4208
+ floatTargetsConnected: floatTargetsConnected,
4209
+ floatQuadrantIn: floatQuadrantIn,
4210
+ floatQuadrant: floatQuadrant,
4211
+ floatExpandDirection: floatExpandDirection,
4212
+ floatTransformOrigin: floatTransformOrigin,
4213
+ floatCardAnchor: floatCardAnchor,
4214
+ floatClipStart: floatClipStart,
4215
+ floatClipRest: floatClipRest,
4216
+ isRestClip: isRestClip,
4217
+ floatSnapTo: floatSnapTo,
4218
+ floatSnapCorner: floatSnapCorner,
4219
+ floatNearestCorner: floatNearestCorner,
4220
+ floatCornerPoint: floatCornerPoint,
4221
+ floatDragExceeded: floatDragExceeded,
4222
+ normalizePanelSize: normalizePanelSize,
4223
+ parsePanelSize: parsePanelSize,
4224
+ PANEL_POS_MAX: PANEL_POS_MAX,
4225
+ normalizePanelPos: normalizePanelPos,
4226
+ parsePanelPos: parsePanelPos,
4227
+ PANEL_CORNERS: PANEL_CORNERS,
4228
+ normalizePanelCorner: normalizePanelCorner,
4229
+ parsePanelCorner: parsePanelCorner,
4230
+ SETTINGS_NAV_ITEM_ATTR: SETTINGS_NAV_ITEM_ATTR,
4231
+ SETTINGS_NAV_ITEM_VALUE: SETTINGS_NAV_ITEM_VALUE,
4232
+ SETTINGS_NAV_CURRENT_ATTR: SETTINGS_NAV_CURRENT_ATTR,
4233
+ SETTINGS_NAV_TEXTS: SETTINGS_NAV_TEXTS,
4234
+ SETTINGS_LAUNCHER_TEXTS: SETTINGS_LAUNCHER_TEXTS,
4235
+ isSettingsNavItem: isSettingsNavItem,
4236
+ isSettingsLauncher: isSettingsLauncher,
4237
+ findSettingsEntry: findSettingsEntry,
4238
+ PLUGIN_ITEM_ATTR: PLUGIN_ITEM_ATTR,
4239
+ PLUGIN_ITEM_VALUE: PLUGIN_ITEM_VALUE,
4240
+ PLUGIN_BUNDLE_NAME: PLUGIN_BUNDLE_NAME,
4241
+ PLUGIN_PANEL_ID: PLUGIN_PANEL_ID,
4242
+ pluginEntryCard: pluginEntryCard,
4243
+ pluginClickTarget: pluginClickTarget,
4244
+ // The client's own click step needs this: the bundle keeps this module's scope
4245
+ // separate from the client's, so a bare name here is a ReferenceError there.
4246
+ dispatchSynthetic: dispatchSynthetic,
4247
+ mirrorPresetSnapshot: mirrorPresetSnapshot,
2997
4248
  VALUE_FIELDS: VALUE_FIELDS,
2998
4249
  RETIRED_FIELDS: RETIRED_FIELDS,
2999
4250
  DURABLE_FIELDS: DURABLE_FIELDS,
@@ -3007,6 +4258,12 @@ var shared = {
3007
4258
  CODE_LINE_HEIGHT_MIN: CODE_LINE_HEIGHT_MIN,
3008
4259
  CODE_LINE_HEIGHT_MAX: CODE_LINE_HEIGHT_MAX,
3009
4260
  LIGATURES_DEFAULT: LIGATURES_DEFAULT,
4261
+ SMOOTHING_FIELD: SMOOTHING_FIELD,
4262
+ SMOOTHING_AUTO: SMOOTHING_AUTO,
4263
+ SMOOTHING_SHARP: SMOOTHING_SHARP,
4264
+ SMOOTHING_SMOOTH: SMOOTHING_SMOOTH,
4265
+ SMOOTHING_VALUES: SMOOTHING_VALUES,
4266
+ clampSmoothing: clampSmoothing,
3010
4267
  LIGATURES_ON: LIGATURES_ON,
3011
4268
  LIGATURES_OFF: LIGATURES_OFF,
3012
4269
  STYLE_TAG: STYLE_TAG,