nodality 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/dist/animator.cjs.js +1 -1
  2. package/dist/animator.cjs.js.LICENSE.txt +3 -1
  3. package/dist/animator.esm.js.LICENSE.txt +1 -1
  4. package/dist/audionew.cjs.js +1 -1
  5. package/dist/audionew.cjs.js.LICENSE.txt +3 -1
  6. package/dist/audionew.esm.js.LICENSE.txt +1 -1
  7. package/dist/base.cjs.js +1 -1
  8. package/dist/base.cjs.js.LICENSE.txt +3 -1
  9. package/dist/base.esm.js.LICENSE.txt +1 -1
  10. package/dist/beta-desktop-bar.cjs.js +1 -1
  11. package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +3 -1
  12. package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
  13. package/dist/beta-mobile-bar.cjs.js +1 -1
  14. package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +3 -1
  15. package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
  16. package/dist/bundle.umd.js +1 -1
  17. package/dist/bundle.umd.js.LICENSE.txt +1 -1
  18. package/dist/button.cjs.js +1 -1
  19. package/dist/button.cjs.js.LICENSE.txt +3 -1
  20. package/dist/button.esm.js.LICENSE.txt +1 -1
  21. package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
  22. package/dist/card-getter.esm.js.LICENSE.txt +1 -1
  23. package/dist/center.cjs.js +1 -1
  24. package/dist/center.cjs.js.LICENSE.txt +3 -1
  25. package/dist/center.esm.js.LICENSE.txt +1 -1
  26. package/dist/checkbox.cjs.js +1 -1
  27. package/dist/checkbox.cjs.js.LICENSE.txt +3 -1
  28. package/dist/checkbox.esm.js.LICENSE.txt +1 -1
  29. package/dist/code.cjs.js +1 -1
  30. package/dist/code.cjs.js.LICENSE.txt +3 -1
  31. package/dist/code.esm.js.LICENSE.txt +1 -1
  32. package/dist/container.cjs.js +1 -1
  33. package/dist/container.cjs.js.LICENSE.txt +3 -1
  34. package/dist/container.esm.js.LICENSE.txt +1 -1
  35. package/dist/data-list.cjs.js +1 -1
  36. package/dist/data-list.cjs.js.LICENSE.txt +1 -1
  37. package/dist/data-list.esm.js.LICENSE.txt +1 -1
  38. package/dist/designer.cjs.js +1 -1
  39. package/dist/designer.cjs.js.LICENSE.txt +1 -1
  40. package/dist/designer.esm.js.LICENSE.txt +1 -1
  41. package/dist/element-mapper.cjs.js +1 -1
  42. package/dist/element-mapper.cjs.js.LICENSE.txt +3 -1
  43. package/dist/element-mapper.esm.js.LICENSE.txt +1 -1
  44. package/dist/finalresult.esm.js +1 -1
  45. package/dist/finalresult.esm.js.LICENSE.txt +1 -1
  46. package/dist/flex-card.cjs.js +1 -1
  47. package/dist/flex-card.cjs.js.LICENSE.txt +3 -1
  48. package/dist/flex-card.esm.js.LICENSE.txt +1 -1
  49. package/dist/flex-grid.cjs.js +1 -1
  50. package/dist/flex-grid.cjs.js.LICENSE.txt +3 -1
  51. package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
  52. package/dist/flex-row.cjs.js +1 -1
  53. package/dist/flex-row.cjs.js.LICENSE.txt +3 -1
  54. package/dist/flex-row.esm.js.LICENSE.txt +1 -1
  55. package/dist/floating-input.cjs.js +1 -1
  56. package/dist/floating-input.cjs.js.LICENSE.txt +3 -1
  57. package/dist/floating-input.esm.js.LICENSE.txt +1 -1
  58. package/dist/free.cjs.js +1 -1
  59. package/dist/free.cjs.js.LICENSE.txt +3 -1
  60. package/dist/free.esm.js.LICENSE.txt +1 -1
  61. package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
  62. package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
  63. package/dist/image-picker.cjs.js +1 -1
  64. package/dist/image-picker.cjs.js.LICENSE.txt +3 -1
  65. package/dist/image-picker.esm.js.LICENSE.txt +1 -1
  66. package/dist/image.cjs.js +1 -1
  67. package/dist/image.cjs.js.LICENSE.txt +3 -1
  68. package/dist/image.esm.js.LICENSE.txt +1 -1
  69. package/dist/index.cjs.js +1 -1
  70. package/dist/index.cjs.js.LICENSE.txt +1 -1
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.esm.js.LICENSE.txt +1 -1
  73. package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
  74. package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
  75. package/dist/link-getter.cjs.js +1 -1
  76. package/dist/link-getter.cjs.js.LICENSE.txt +3 -1
  77. package/dist/link-getter.esm.js.LICENSE.txt +1 -1
  78. package/dist/link.cjs.js +1 -1
  79. package/dist/link.cjs.js.LICENSE.txt +3 -1
  80. package/dist/link.esm.js.LICENSE.txt +1 -1
  81. package/dist/meta-adder.cjs.js +1 -1
  82. package/dist/meta-adder.cjs.js.LICENSE.txt +3 -1
  83. package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
  84. package/dist/modal-2025.cjs.js +1 -1
  85. package/dist/modal-2025.cjs.js.LICENSE.txt +3 -1
  86. package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
  87. package/dist/multiswitcher.cjs.js +1 -1
  88. package/dist/multiswitcher.cjs.js.LICENSE.txt +3 -1
  89. package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
  90. package/dist/new-nav-bar.cjs.js +1 -1
  91. package/dist/new-nav-bar.cjs.js.LICENSE.txt +3 -1
  92. package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
  93. package/dist/picker.cjs.js +1 -1
  94. package/dist/picker.cjs.js.LICENSE.txt +3 -1
  95. package/dist/picker.esm.js.LICENSE.txt +1 -1
  96. package/dist/progress.cjs.js +1 -1
  97. package/dist/progress.cjs.js.LICENSE.txt +3 -1
  98. package/dist/progress.esm.js.LICENSE.txt +1 -1
  99. package/dist/radio.cjs.js +1 -1
  100. package/dist/radio.cjs.js.LICENSE.txt +1 -1
  101. package/dist/radio.esm.js.LICENSE.txt +1 -1
  102. package/dist/range.cjs.js +1 -1
  103. package/dist/range.cjs.js.LICENSE.txt +1 -1
  104. package/dist/range.esm.js.LICENSE.txt +1 -1
  105. package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
  106. package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
  107. package/dist/side-bar.cjs.js +1 -1
  108. package/dist/side-bar.cjs.js.LICENSE.txt +3 -1
  109. package/dist/side-bar.esm.js.LICENSE.txt +1 -1
  110. package/dist/side-nav-bar.cjs.js +1 -1
  111. package/dist/side-nav-bar.cjs.js.LICENSE.txt +3 -1
  112. package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
  113. package/dist/simple-bar.cjs.js +1 -1
  114. package/dist/simple-bar.cjs.js.LICENSE.txt +3 -1
  115. package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
  116. package/dist/slider-2025.cjs.js +1 -1
  117. package/dist/slider-2025.cjs.js.LICENSE.txt +3 -1
  118. package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
  119. package/dist/spacer.cjs.js +1 -1
  120. package/dist/spacer.cjs.js.LICENSE.txt +3 -1
  121. package/dist/spacer.esm.js.LICENSE.txt +1 -1
  122. package/dist/stack.cjs.js +1 -1
  123. package/dist/stack.cjs.js.LICENSE.txt +3 -1
  124. package/dist/stack.esm.js.LICENSE.txt +1 -1
  125. package/dist/stacker.cjs.js.LICENSE.txt +1 -1
  126. package/dist/stacker.esm.js.LICENSE.txt +1 -1
  127. package/dist/table.cjs.js +1 -1
  128. package/dist/table.cjs.js.LICENSE.txt +3 -1
  129. package/dist/table.esm.js.LICENSE.txt +1 -1
  130. package/dist/text-field.cjs.js +1 -1
  131. package/dist/text-field.cjs.js.LICENSE.txt +3 -1
  132. package/dist/text-field.esm.js.LICENSE.txt +1 -1
  133. package/dist/text.cjs.js +1 -1
  134. package/dist/text.cjs.js.LICENSE.txt +3 -1
  135. package/dist/text.esm.js.LICENSE.txt +1 -1
  136. package/dist/theme.cjs.js.LICENSE.txt +1 -1
  137. package/dist/theme.esm.js.LICENSE.txt +1 -1
  138. package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
  139. package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
  140. package/dist/ulist.cjs.js +1 -1
  141. package/dist/ulist.cjs.js.LICENSE.txt +3 -1
  142. package/dist/ulist.esm.js.LICENSE.txt +1 -1
  143. package/dist/video.cjs.js +1 -1
  144. package/dist/video.cjs.js.LICENSE.txt +3 -1
  145. package/dist/video.esm.js.LICENSE.txt +1 -1
  146. package/dist/wrap.cjs.js +1 -1
  147. package/dist/wrap.cjs.js.LICENSE.txt +3 -1
  148. package/dist/wrap.esm.js.LICENSE.txt +1 -1
  149. package/dist/zoom-card.cjs.js +1 -1
  150. package/dist/zoom-card.cjs.js.LICENSE.txt +3 -1
  151. package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
  152. package/layout/animator.js +1 -1
  153. package/layout/audio.js +1 -1
  154. package/layout/audionew.js +1 -1
  155. package/layout/base.js +1 -1
  156. package/layout/beta-desktop-bar.js +1 -1
  157. package/layout/beta-mobile-bar.js +1 -1
  158. package/layout/button.js +1 -1
  159. package/layout/center.js +1 -1
  160. package/layout/checkbox.js +1 -1
  161. package/layout/circle.js +1 -1
  162. package/layout/code.js +1 -1
  163. package/layout/container.js +1 -1
  164. package/layout/dropdown-2025.js +1 -1
  165. package/layout/flex-card.js +1 -1
  166. package/layout/flex-grid.js +1 -1
  167. package/layout/flex-row.js +1 -1
  168. package/layout/form-components/custom.js +1 -1
  169. package/layout/form-components/data-list.js +1 -1
  170. package/layout/form-components/floating-input.js +1 -1
  171. package/layout/form-components/form-all.js +1 -1
  172. package/layout/form-components/form.js +1 -1
  173. package/layout/form-components/image-picker.js +1 -1
  174. package/layout/form-components/picker.js +1 -1
  175. package/layout/form-components/radio.js +1 -1
  176. package/layout/form-components/radiogroup.js +1 -1
  177. package/layout/form-components/range.js +1 -1
  178. package/layout/free.js +1 -1
  179. package/layout/grid-switcher.js +1 -1
  180. package/layout/grid.js +1 -1
  181. package/layout/horizontal-scroller.js +1 -1
  182. package/layout/image.js +1 -1
  183. package/layout/index.js +1 -1
  184. package/layout/link.js +1 -1
  185. package/layout/list.js +1 -1
  186. package/layout/meta-adder.js +1 -1
  187. package/layout/modal-2025.js +1 -1
  188. package/layout/morph.js +1 -1
  189. package/layout/multiswitcher.js +1 -1
  190. package/layout/nav-bar.js +1 -1
  191. package/layout/nav-factor/custom-div.js +1 -1
  192. package/layout/new-nav-bar.js +1 -1
  193. package/layout/polygon.js +1 -1
  194. package/layout/prerender-site.js +1 -1
  195. package/layout/prerender.js +1 -1
  196. package/layout/progress.js +1 -1
  197. package/layout/row.js +1 -1
  198. package/layout/scroll-video.js +1 -1
  199. package/layout/side-bar.js +1 -1
  200. package/layout/side-nav-bar.js +1 -1
  201. package/layout/simple-bar.js +1 -1
  202. package/layout/slider-2025.js +1 -1
  203. package/layout/spacer.js +1 -1
  204. package/layout/stack.js +1 -1
  205. package/layout/svg.js +1 -1
  206. package/layout/switcher.js +1 -1
  207. package/layout/table.js +1 -1
  208. package/layout/text-field.js +1 -1
  209. package/layout/text.js +1 -1
  210. package/layout/ulist.js +1 -1
  211. package/layout/video.js +1 -1
  212. package/layout/wrap.js +1 -1
  213. package/layout/zoom-card.js +1 -1
  214. package/lib/agent-surface.js +1 -1
  215. package/lib/card-getter.js +1 -1
  216. package/lib/check-page.js +1 -1
  217. package/lib/codegen.js +1 -1
  218. package/lib/data.js +1 -1
  219. package/lib/designer.js +1 -1
  220. package/lib/element-mapper.js +1 -1
  221. package/lib/element-params.generated.js +1 -1
  222. package/lib/keyframe-animation.js +1 -1
  223. package/lib/link-getter.js +1 -1
  224. package/lib/morph-node.js +1 -1
  225. package/lib/normalize-spec.js +1 -1
  226. package/lib/parse-html.js +1 -1
  227. package/lib/raster-inspect.js +1 -1
  228. package/lib/raster-ops.js +256 -7
  229. package/lib/raster-presets.js +1 -1
  230. package/lib/resolve-refs.js +1 -1
  231. package/lib/scroll-video.js +1 -1
  232. package/lib/seo.js +1 -1
  233. package/lib/stacker.js +1 -1
  234. package/lib/suggest.js +1 -1
  235. package/lib/theme.js +1 -1
  236. package/lib/transform-anim.js +1 -1
  237. package/lib/transition.js +1 -1
  238. package/lib/validate-nodes.js +1 -1
  239. package/lib/webmcp-adapter.js +1 -1
  240. package/package.json +2 -2
  241. /package/bin/{nodality.js → nodality.mjs} +0 -0
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -2526,6 +2526,11 @@ ${defaultSeam}
2526
2526
  // computes, on a clone, so the snapshot matches the live layout.
2527
2527
  const VIEWPORT_UNIT = /\d(?:vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh)\b/i;
2528
2528
 
2529
+ // Text metrics that must reach the snapshot as pixels. Anything relative here
2530
+ // gives the foreignObject a chance to resolve it against its own box instead of
2531
+ // the page — see part 1b of freezeStyles.
2532
+ const FROZEN_LENGTHS = ["font-size", "line-height", "letter-spacing", "word-spacing"];
2533
+
2529
2534
  // Inheritable text properties. A foreignObject rendered from a data: URI
2530
2535
  // carries NO page stylesheet, and a clone carries only inline styles — so
2531
2536
  // anything a rule or an ancestor supplied is gone. Nodality's own
@@ -2570,8 +2575,8 @@ function freezeStyles(original, clone) {
2570
2575
  // 5vw)` renders at a different size than it does on the page.
2571
2576
  // The glyphs then sit somewhere the DOM text does not, which
2572
2577
  // is what makes a selection highlight look offset.
2578
+ let computed = null;
2573
2579
  if (inline.length > 0) {
2574
- let computed = null;
2575
2580
  // Iterate a snapshot of the names: writing to style mutates the list.
2576
2581
  for (const prop of Array.from(inline)) {
2577
2582
  if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
@@ -2581,6 +2586,28 @@ function freezeStyles(original, clone) {
2581
2586
  }
2582
2587
  }
2583
2588
 
2589
+ // 1b. Text lengths are frozen to pixels ALWAYS, whatever unit they were
2590
+ // written in and whether or not they were set inline.
2591
+ //
2592
+ // The sweep above only rewrites a declaration whose own inline text
2593
+ // matches VIEWPORT_UNIT. That misses every other way a length can
2594
+ // resolve differently inside the foreignObject: `rem` against a root
2595
+ // that is not the page's root, `em` down an ancestor chain the clone
2596
+ // no longer has, and — the case that prompted this — a `clamp()` whose
2597
+ // chosen branch depends on a viewport the SVG does not share. A
2598
+ // headline written `clamp(2.4rem, 4.6vw, 3.4rem)` came out at a
2599
+ // fraction of its size on a phone while looking correct in desktop
2600
+ // device emulation, because the two resolve that clamp differently.
2601
+ //
2602
+ // Computed values are already absolute pixels, so this cannot change
2603
+ // what the page shows; it only removes the chance for the SVG to
2604
+ // recompute something the page had already decided.
2605
+ for (const prop of FROZEN_LENGTHS) {
2606
+ computed = computed || window.getComputedStyle(origs[i]);
2607
+ const px = computed.getPropertyValue(prop);
2608
+ if (px && px !== "normal") inline.setProperty(prop, px);
2609
+ }
2610
+
2584
2611
  // 2. Inherited text styling, which the clone would otherwise lose.
2585
2612
  const cs = window.getComputedStyle(origs[i]);
2586
2613
  const parent = origs[i].parentElement;
@@ -2721,27 +2748,249 @@ function fitImageToBox(img, w, h, dpr) {
2721
2748
  return c;
2722
2749
  }
2723
2750
 
2751
+ // ── Web fonts inside the snapshot ────────────────────────────────────
2752
+ //
2753
+ // A foreignObject rendered from a data: URI fetches NO subresources. An
2754
+ // @font-face whose src is a URL therefore never loads inside the capture, and
2755
+ // the text draws in whatever family the SVG can resolve locally — the page's
2756
+ // own web font is simply not among them. Recapturing on document.fonts.ready
2757
+ // does not help: the font is ready on the PAGE, never inside the snapshot.
2758
+ //
2759
+ // The only way the real typeface reaches the capture is to carry the bytes in
2760
+ // with it, so the faces the subtree actually uses are fetched once, base64'd,
2761
+ // and emitted as an inline <style> ahead of the serialized markup.
2762
+ //
2763
+ // Everything here degrades to "no embedded font" rather than failing the
2764
+ // capture: a cross-origin stylesheet that refuses CORS, a font host that does
2765
+ // the same, an offline reload — each ends with the previous behaviour, which is
2766
+ // a fallback typeface, not a broken image.
2767
+
2768
+ /** Absolute font URL -> `data:` URI, or null when it could not be fetched. */
2769
+ const FONT_DATA_CACHE = new Map();
2770
+
2771
+ /** Parsed @font-face rules for the document, resolved once. */
2772
+ let FACE_RULES = null;
2773
+
2774
+ const FACE_BLOCK = /@font-face\s*{[^}]*}/gi;
2775
+ const FACE_FAMILY = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
2776
+ const FACE_WEIGHT = /font-weight\s*:\s*([^;]+)/i;
2777
+ const FACE_STYLE = /font-style\s*:\s*([^;]+)/i;
2778
+ const FACE_SRC_URL = /url\((['"]?)([^)'"]+)\1\)/gi;
2779
+ const FACE_RANGE = /unicode-range\s*:\s*([^;}]+)/i;
2780
+
2781
+ /**
2782
+ * Does this face cover any character the capture actually contains?
2783
+ *
2784
+ * A provider splits one family into a subset per script — Google ships seven for
2785
+ * Inter — so embedding every subset for every weight in use put well over half a
2786
+ * megabyte of base64 into a single snapshot. Only the subsets the text actually
2787
+ * lands in are worth carrying. A face that declares no range covers everything
2788
+ * and is always kept.
2789
+ */
2790
+ function faceCoversText(range, text) {
2791
+ if (!range) return true;
2792
+ const spans = [];
2793
+ for (const part of range.split(",")) {
2794
+ const m = /U\+([0-9a-fA-F?]+)(?:-([0-9a-fA-F]+))?/.exec(part.trim());
2795
+ if (!m) continue;
2796
+ if (m[1].indexOf("?") !== -1) {
2797
+ spans.push([parseInt(m[1].replace(/\?/g, "0"), 16),
2798
+ parseInt(m[1].replace(/\?/g, "F"), 16)]);
2799
+ } else {
2800
+ const lo = parseInt(m[1], 16);
2801
+ spans.push([lo, m[2] ? parseInt(m[2], 16) : lo]);
2802
+ }
2803
+ }
2804
+ if (!spans.length) return true;
2805
+ for (const ch of text) {
2806
+ const cp = ch.codePointAt(0);
2807
+ for (let i = 0; i < spans.length; i++) {
2808
+ if (cp >= spans[i][0] && cp <= spans[i][1]) return true;
2809
+ }
2810
+ }
2811
+ return false;
2812
+ }
2813
+
2814
+ function parseFaces(cssText, baseUrl) {
2815
+ const out = [];
2816
+ for (const block of cssText.match(FACE_BLOCK) || []) {
2817
+ const fam = FACE_FAMILY.exec(block);
2818
+ if (!fam) continue;
2819
+ const urls = [];
2820
+ let m;
2821
+ FACE_SRC_URL.lastIndex = 0;
2822
+ while ((m = FACE_SRC_URL.exec(block))) {
2823
+ try { urls.push(new URL(m[2], baseUrl).href); } catch (e) { /* skip */ }
2824
+ }
2825
+ if (!urls.length) continue;
2826
+ out.push({
2827
+ family: fam[2].trim().toLowerCase(),
2828
+ weight: (FACE_WEIGHT.exec(block) || [, "400"])[1].trim(),
2829
+ style: (FACE_STYLE.exec(block) || [, "normal"])[1].trim(),
2830
+ range: (FACE_RANGE.exec(block) || [, ""])[1].trim(),
2831
+ block,
2832
+ urls,
2833
+ });
2834
+ }
2835
+ return out;
2836
+ }
2837
+
2838
+ async function collectFaceRules() {
2839
+ if (FACE_RULES) return FACE_RULES;
2840
+ const faces = [];
2841
+ for (const sheet of Array.from(document.styleSheets || [])) {
2842
+ // Same-origin sheets can be read directly. A cross-origin one throws on
2843
+ // cssRules, so it is fetched instead — Google Fonts and the like serve
2844
+ // their CSS with permissive CORS, and anything that does not simply
2845
+ // contributes no faces.
2846
+ let rules = null;
2847
+ try { rules = sheet.cssRules; } catch (e) { rules = null; }
2848
+ if (rules) {
2849
+ for (const rule of Array.from(rules)) {
2850
+ if (rule.constructor && rule.constructor.name === "CSSFontFaceRule") {
2851
+ faces.push(...parseFaces(rule.cssText, sheet.href || location.href));
2852
+ }
2853
+ }
2854
+ } else if (sheet.href) {
2855
+ try {
2856
+ const res = await fetch(sheet.href, { mode: "cors" });
2857
+ if (res.ok) faces.push(...parseFaces(await res.text(), sheet.href));
2858
+ } catch (e) { /* no faces from this sheet */ }
2859
+ }
2860
+ }
2861
+ FACE_RULES = faces;
2862
+ return faces;
2863
+ }
2864
+
2865
+ async function asDataUri(url) {
2866
+ if (FONT_DATA_CACHE.has(url)) return FONT_DATA_CACHE.get(url);
2867
+ let uri = null;
2868
+ try {
2869
+ const res = await fetch(url, { mode: "cors" });
2870
+ if (res.ok) {
2871
+ const buf = new Uint8Array(await res.arrayBuffer());
2872
+ let bin = "";
2873
+ for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
2874
+ const type = url.endsWith(".woff2") ? "font/woff2"
2875
+ : url.endsWith(".woff") ? "font/woff" : "font/ttf";
2876
+ uri = `data:${type};base64,${btoa(bin)}`;
2877
+ }
2878
+ } catch (e) { uri = null; }
2879
+ FONT_DATA_CACHE.set(url, uri);
2880
+ return uri;
2881
+ }
2882
+
2883
+ /** The (family, weight, style) triples this subtree actually renders in. */
2884
+ function usedFaces(el) {
2885
+ const seen = new Map();
2886
+ for (const node of [el, ...el.querySelectorAll("*")]) {
2887
+ const cs = window.getComputedStyle(node);
2888
+ const first = (cs.fontFamily || "").split(",")[0].trim().replace(/^['"]|['"]$/g, "");
2889
+ if (!first) continue;
2890
+ const key = `${first.toLowerCase()}|${cs.fontWeight}|${cs.fontStyle}`;
2891
+ if (!seen.has(key)) {
2892
+ seen.set(key, { family: first.toLowerCase(), weight: cs.fontWeight, style: cs.fontStyle });
2893
+ }
2894
+ }
2895
+ return Array.from(seen.values());
2896
+ }
2897
+
2898
+ async function embeddedFontCss(el) {
2899
+ let faces;
2900
+ try { faces = await collectFaceRules(); } catch (e) { return ""; }
2901
+ if (!faces.length) return "";
2902
+
2903
+ const wanted = usedFaces(el);
2904
+ const text = el.textContent || "";
2905
+ const chosen = faces.filter((f) => faceCoversText(f.range, text) && wanted.some((u) =>
2906
+ u.family === f.family &&
2907
+ (f.style === u.style || f.style === "normal") &&
2908
+ // Google emits one face per weight; a variable face states a range and
2909
+ // is kept for every weight that asks for the family.
2910
+ (f.weight === String(u.weight) || /\s/.test(f.weight))));
2911
+ if (!chosen.length) return "";
2912
+
2913
+ const parts = [];
2914
+ for (const face of chosen) {
2915
+ // First url that actually comes back — the list is a preference order.
2916
+ let uri = null;
2917
+ for (const u of face.urls) {
2918
+ uri = await asDataUri(u);
2919
+ if (uri) break;
2920
+ }
2921
+ if (!uri) continue;
2922
+ parts.push(
2923
+ `@font-face{font-family:'${face.family}';font-style:${face.style};` +
2924
+ `font-weight:${face.weight};src:url(${uri});}`);
2925
+ }
2926
+ return parts.join("");
2927
+ }
2928
+
2724
2929
  function snapshotToImage(el, w, h, dpr) {
2725
2930
  const direct = soleImageOf(el);
2726
2931
  if (direct) return Promise.resolve(fitImageToBox(direct, w, h, dpr));
2727
2932
 
2728
- return new Promise((resolve, reject) => {
2933
+ // The font CSS is resolved before serialising. It is cached after the first
2934
+ // capture, so only the very first snapshot on a page pays for the fetch, and
2935
+ // a failure returns "" and leaves the previous fallback behaviour intact.
2936
+ return embeddedFontCss(el).catch(() => "").then((fontCss) => new Promise((resolve, reject) => {
2729
2937
  const serialized = new XMLSerializer()
2730
2938
  .serializeToString(freezeStyles(el, el.cloneNode(true)));
2939
+ // The SVG carries NO scaling at all — it is emitted at CSS size, 1:1.
2940
+ //
2941
+ // Engines do not agree about scaling foreignObject content: mobile Chrome and
2942
+ // Safari apply neither a viewBox ratio nor an ancestor transform to the HTML
2943
+ // subtree, so content laid out at `w x h` stayed `w x h` in the top-left of a
2944
+ // `w*dpr` canvas. Reading the uploaded texture back off the GPU on a dpr-3
2945
+ // phone showed exactly that: a 1101x369 texture with its ink confined to the
2946
+ // first 367x123. The same SVG looked correct in an <img> sized to 367 CSS px,
2947
+ // because there the unscaled content happened to fill the box — which is what
2948
+ // made this look like a compositing bug for so long.
2949
+ //
2950
+ // So the SVG is rasterised at the size its contents are laid out for, and the
2951
+ // dpr upscale is done by drawImage below, where the destination rectangle is
2952
+ // stated outright and no engine has an opinion. High-dpr output is a touch
2953
+ // softer than a native-resolution rasterisation would be; correct everywhere
2954
+ // beats crisp on the machines that happened to agree with us.
2731
2955
  const svg =
2732
- `<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
2956
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
2733
2957
  `<foreignObject width="100%" height="100%">` +
2734
- `<div xmlns="http://www.w3.org/1999/xhtml">${serialized}</div>` +
2958
+ `<div xmlns="http://www.w3.org/1999/xhtml">` +
2959
+ (fontCss ? `<style>${fontCss}</style>` : "") +
2960
+ `${serialized}</div>` +
2735
2961
  `</foreignObject></svg>`;
2736
2962
  // createElement, NOT `new Image()` — this library exports a
2737
2963
  // component named `Image`, and consumers publish the exports onto
2738
2964
  // globalThis. Where they do, the DOM constructor is gone and this
2739
2965
  // promise never settles: the snapshot backend hangs with no error.
2740
2966
  const img = document.createElement("img");
2741
- img.onload = () => resolve(img);
2967
+ img.onload = () => {
2968
+ // Rasterise into a canvas of exactly the size we asked for, and hand
2969
+ // THAT to the caller.
2970
+ //
2971
+ // texImage2D takes an image's INTRINSIC size, and engines disagree about
2972
+ // what that is for an SVG: desktop Chrome honours the width/height
2973
+ // attributes (w*dpr), while mobile Chrome and Safari take it from the
2974
+ // viewBox (w). On a phone the texture therefore arrived at 1/dpr scale
2975
+ // and the effect painted its content into the top-left corner of its own
2976
+ // canvas — the headline came out about a third of its size, while every
2977
+ // measurement of the box, the SVG and the CSS looked perfectly correct.
2978
+ //
2979
+ // Drawing with an explicit destination rectangle makes the size ours
2980
+ // instead of theirs, so the texture matches the backing store on every
2981
+ // engine. This mirrors what fitImageToBox already does for the
2982
+ // sole-image fast path.
2983
+ const c = document.createElement("canvas");
2984
+ c.width = Math.max(1, Math.round(w * dpr));
2985
+ c.height = Math.max(1, Math.round(h * dpr));
2986
+ const ctx = c.getContext("2d");
2987
+ if (!ctx) { resolve(img); return; }
2988
+ ctx.drawImage(img, 0, 0, c.width, c.height);
2989
+ resolve(c);
2990
+ };
2742
2991
  img.onerror = reject;
2743
2992
  img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
2744
- });
2993
+ }));
2745
2994
  }
2746
2995
 
2747
2996
  // ── switch: pick a sub-chain by condition ────────────────────────────
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/seo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/stacker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/suggest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/theme.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/lib/transition.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.3.6
2
+ * nodality v1.3.8
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "nodality",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
4
4
  "description": "A lightweight library for declarative UI elements.",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.esm.js",
7
7
  "bin": {
8
- "nodality": "bin/nodality.js"
8
+ "nodality": "bin/nodality.mjs"
9
9
  },
10
10
  "files": [
11
11
  "lib/",
File without changes