domotion-svg 0.2.2 → 0.3.2

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 (115) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +29 -0
  3. package/dist/animation/animator.js +25 -14
  4. package/dist/animation/animator.test.js +54 -21
  5. package/dist/animation/cursor-overlay.js +0 -2
  6. package/dist/capture/emoji.js +29 -18
  7. package/dist/capture/index.js +5 -4
  8. package/dist/capture/script/color-norm.d.ts +1 -0
  9. package/dist/capture/script/color-norm.js +43 -1
  10. package/dist/capture/script/emoji-detect.js +14 -0
  11. package/dist/capture/script/index.js +593 -65
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +123 -7
  14. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  15. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  16. package/dist/capture/script/walker/input-value.js +14 -1
  17. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  18. package/dist/capture/script/walker/lists-counters.js +22 -2
  19. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  20. package/dist/capture/script/walker/masks-clips.js +41 -1
  21. package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
  22. package/dist/capture/script/walker/pseudo-content.js +301 -61
  23. package/dist/capture/script/walker/pseudo-inject.js +20 -0
  24. package/dist/capture/script/walker/text-segments.js +98 -4
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +16 -0
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +213 -2
  29. package/dist/cli/animate.js +151 -15
  30. package/dist/mask.test.js +12 -7
  31. package/dist/render/borders.d.ts +9 -13
  32. package/dist/render/borders.js +379 -14
  33. package/dist/render/element-tree-to-svg.d.ts +11 -12
  34. package/dist/render/element-tree-to-svg.js +2046 -241
  35. package/dist/render/embedded-font-builder.d.ts +49 -0
  36. package/dist/render/embedded-font-builder.js +149 -0
  37. package/dist/render/form-controls.js +45 -24
  38. package/dist/render/gradients.d.ts +15 -0
  39. package/dist/render/gradients.js +103 -2
  40. package/dist/render/gradients.test.js +34 -0
  41. package/dist/render/text-to-path.d.ts +38 -1
  42. package/dist/render/text-to-path.js +654 -29
  43. package/dist/render/text-to-path.test.js +230 -9
  44. package/dist/render/text.d.ts +14 -0
  45. package/dist/render/text.js +344 -40
  46. package/dist/scroll/composer.d.ts +26 -0
  47. package/dist/scroll/composer.js +199 -11
  48. package/dist/scroll/composer.test.js +293 -16
  49. package/dist/scroll/executor.d.ts +3 -1
  50. package/dist/scroll/executor.js +15 -6
  51. package/dist/scroll/executor.test.js +25 -0
  52. package/dist/scroll/hoist-fixed.d.ts +48 -0
  53. package/dist/scroll/hoist-fixed.js +85 -0
  54. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  55. package/dist/scroll/hoist-fixed.test.js +103 -0
  56. package/dist/scroll/hoist-sticky.d.ts +45 -0
  57. package/dist/scroll/hoist-sticky.js +157 -0
  58. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  59. package/dist/scroll/hoist-sticky.test.js +154 -0
  60. package/dist/scroll/pattern.d.ts +22 -5
  61. package/dist/scroll/pattern.js +55 -7
  62. package/dist/scroll/pattern.test.js +48 -1
  63. package/dist/tree-ops/frame-merge.d.ts +10 -0
  64. package/dist/tree-ops/frame-merge.js +23 -5
  65. package/dist/tree-ops/frame-merge.test.js +45 -0
  66. package/dist/tree-ops/tree-diff.js +1 -1
  67. package/dist/tree-ops/viewbox-culling.js +32 -18
  68. package/dist/tree-ops/viewbox-culling.test.js +40 -6
  69. package/package.json +8 -2
  70. package/src/animation/animator.test.ts +56 -21
  71. package/src/animation/animator.ts +25 -14
  72. package/src/animation/cursor-overlay.ts +0 -2
  73. package/src/capture/emoji.ts +28 -18
  74. package/src/capture/index.ts +15 -14
  75. package/src/capture/script/color-norm.ts +38 -1
  76. package/src/capture/script/emoji-detect.ts +14 -0
  77. package/src/capture/script/index.ts +555 -48
  78. package/src/capture/script/walker/borders-backgrounds.ts +114 -7
  79. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  80. package/src/capture/script/walker/input-value.ts +14 -1
  81. package/src/capture/script/walker/lists-counters.ts +24 -2
  82. package/src/capture/script/walker/masks-clips.ts +40 -1
  83. package/src/capture/script/walker/pseudo-content.ts +297 -55
  84. package/src/capture/script/walker/pseudo-inject.ts +20 -0
  85. package/src/capture/script/walker/text-segments.ts +93 -4
  86. package/src/capture/script/walker/transforms.ts +14 -0
  87. package/src/capture/script.generated.ts +1 -1
  88. package/src/capture/types.ts +202 -2
  89. package/src/cli/animate.ts +135 -15
  90. package/src/mask.test.ts +12 -7
  91. package/src/render/borders.ts +383 -17
  92. package/src/render/element-tree-to-svg.ts +2051 -238
  93. package/src/render/embedded-font-builder.ts +221 -0
  94. package/src/render/form-controls.ts +45 -24
  95. package/src/render/gradients.test.ts +46 -0
  96. package/src/render/gradients.ts +94 -2
  97. package/src/render/opentype.js.d.ts +7 -0
  98. package/src/render/text-to-path.test.ts +246 -9
  99. package/src/render/text-to-path.ts +702 -31
  100. package/src/render/text.ts +344 -40
  101. package/src/scroll/composer.test.ts +322 -16
  102. package/src/scroll/composer.ts +246 -13
  103. package/src/scroll/executor.test.ts +27 -0
  104. package/src/scroll/executor.ts +19 -10
  105. package/src/scroll/hoist-fixed.test.ts +117 -0
  106. package/src/scroll/hoist-fixed.ts +95 -0
  107. package/src/scroll/hoist-sticky.test.ts +173 -0
  108. package/src/scroll/hoist-sticky.ts +193 -0
  109. package/src/scroll/pattern.test.ts +58 -1
  110. package/src/scroll/pattern.ts +71 -8
  111. package/src/tree-ops/frame-merge.test.ts +51 -0
  112. package/src/tree-ops/frame-merge.ts +24 -6
  113. package/src/tree-ops/tree-diff.ts +3 -1
  114. package/src/tree-ops/viewbox-culling.test.ts +42 -6
  115. package/src/tree-ops/viewbox-culling.ts +32 -18
@@ -1,8 +1,10 @@
1
- export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMetrics, textNeedsRaster }: {
1
+ export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMetrics, textNeedsRaster, resolveCounterValue, isCustomCounterStyle }: {
2
2
  vp: any;
3
3
  normColor: any;
4
4
  measureFontMetrics: any;
5
5
  textNeedsRaster: any;
6
+ resolveCounterValue: any;
7
+ isCustomCounterStyle: any;
6
8
  }) => {
7
9
  capturePseudoContent: (el: any, cs: any, rect: any, counterSnapshot: any) => {
8
10
  pseudoSegments: ({
@@ -38,6 +40,7 @@ export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMe
38
40
  fontSize: number;
39
41
  fontWeight: string;
40
42
  fontFamily: string;
43
+ fontStyle: string;
41
44
  fontAscent: any;
42
45
  };
43
46
  color: string;
@@ -54,9 +57,16 @@ export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMe
54
57
  lineH: number;
55
58
  fontSize: number;
56
59
  backgroundColor: any;
60
+ backgroundImage: string | undefined;
57
61
  borderRadius: number | undefined;
58
62
  borderWidth: number | undefined;
59
63
  borderColor: any;
64
+ transform: string | undefined;
65
+ transformOrigin: string | undefined;
66
+ borderTopColor: any;
67
+ borderRightColor: any;
68
+ borderBottomColor: any;
69
+ borderLeftColor: any;
60
70
  } | null;
61
71
  imageUrl?: undefined;
62
72
  renderWidth?: undefined;
@@ -74,6 +84,7 @@ export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMe
74
84
  width: number;
75
85
  height: number;
76
86
  backgroundColor: any;
87
+ backgroundImage: string | undefined;
77
88
  borderTopWidth: number;
78
89
  borderTopColor: any;
79
90
  borderTopStyle: string;
@@ -87,6 +98,8 @@ export declare const createPseudoContentHandler: ({ vp, normColor, measureFontMe
87
98
  borderLeftColor: any;
88
99
  borderLeftStyle: string;
89
100
  borderRadius: number;
101
+ transform: string | undefined;
102
+ transformOrigin: string | undefined;
90
103
  }[];
91
104
  };
92
105
  };
@@ -52,7 +52,125 @@
52
52
  // entries), and everything that mixes pseudo positioning with the
53
53
  // element's own textSegments. Those depend on text shaping state that
54
54
  // hasn't been pulled out of captureInner yet — follow-up.
55
- export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster }) => {
55
+ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster, resolveCounterValue, isCustomCounterStyle }) => {
56
+ // DM-785: Chrome's HarfBuzz-shaped layout width differs from
57
+ // `canvas.measureText` by ~1-3px on bold uppercase short strings (the
58
+ // gradient-pill / MOST POPULAR / NEW badge pattern). Measuring via an
59
+ // off-screen <span> with the pseudo's resolved font properties and reading
60
+ // `getBoundingClientRect().width` matches the painted width exactly because
61
+ // it goes through the same shaping pipeline Chrome uses for layout. Only
62
+ // matters for `width: auto` absolute pseudos — the DM-507 numeric `pcs.width`
63
+ // path is still authoritative when present.
64
+ const probePseudoTextWidth = (text, pcs) => {
65
+ const span = document.createElement('span');
66
+ span.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;left:-99999px;top:-99999px;white-space:pre;line-height:normal;margin:0;padding:0;border:0;text-indent:0';
67
+ span.style.fontFamily = pcs.fontFamily || '';
68
+ span.style.fontSize = pcs.fontSize || '';
69
+ span.style.fontWeight = pcs.fontWeight || '';
70
+ span.style.fontStyle = pcs.fontStyle || '';
71
+ span.style.fontStretch = pcs.fontStretch || '';
72
+ span.style.fontVariant = pcs.fontVariant || '';
73
+ span.style.fontFeatureSettings = pcs.fontFeatureSettings || '';
74
+ span.style.fontVariationSettings = pcs.fontVariationSettings || '';
75
+ span.style.letterSpacing = pcs.letterSpacing || '';
76
+ span.style.wordSpacing = pcs.wordSpacing || '';
77
+ span.textContent = text;
78
+ document.body.appendChild(span);
79
+ const w = span.getBoundingClientRect().width;
80
+ document.body.removeChild(span);
81
+ return w;
82
+ };
83
+ // DM-768: when a static-flow pseudo declares `display: inline-block` (or
84
+ // inline-flex / inline-grid / inline-table) the box participates in Chrome's
85
+ // inline vertical-align math — `vertical-align: middle` aligns the pseudo's
86
+ // mid-point with the parent's baseline + 0.5 × x-height, `baseline` aligns
87
+ // the pseudo's bottom to the parent baseline, etc. The earlier formula
88
+ // (`rect.top + hostBorT + hostPadT + pMarT`) ignores that and places the
89
+ // pseudo at the host's content-area top — i.e. the line-box top — so an
90
+ // inline-block down-caret with `border-top: 5px solid` paints 6-7 px too
91
+ // high inside its parent button. Probe instead: insert a real sentinel
92
+ // mirroring the pseudo's box (display / size / borders / padding / margin /
93
+ // vertical-align) at the pseudo's logical position in the host and read its
94
+ // `getBoundingClientRect()`. Chrome lays out the sentinel exactly where the
95
+ // pseudo would have gone, so we get the correct x/y without re-deriving
96
+ // font metrics + vertical-align semantics ourselves.
97
+ const probePseudoStaticBoxRect = (el, pseudo, pcs) => {
98
+ const probe = document.createElement('span');
99
+ probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box';
100
+ probe.style.display = pcs.display;
101
+ probe.style.width = pcs.width;
102
+ probe.style.height = pcs.height;
103
+ probe.style.paddingTop = pcs.paddingTop;
104
+ probe.style.paddingRight = pcs.paddingRight;
105
+ probe.style.paddingBottom = pcs.paddingBottom;
106
+ probe.style.paddingLeft = pcs.paddingLeft;
107
+ probe.style.borderTopWidth = pcs.borderTopWidth;
108
+ probe.style.borderRightWidth = pcs.borderRightWidth;
109
+ probe.style.borderBottomWidth = pcs.borderBottomWidth;
110
+ probe.style.borderLeftWidth = pcs.borderLeftWidth;
111
+ probe.style.borderStyle = 'solid';
112
+ probe.style.borderColor = 'transparent';
113
+ probe.style.marginTop = pcs.marginTop;
114
+ probe.style.marginRight = pcs.marginRight;
115
+ probe.style.marginBottom = pcs.marginBottom;
116
+ probe.style.marginLeft = pcs.marginLeft;
117
+ probe.style.verticalAlign = pcs.verticalAlign;
118
+ probe.style.font = ''; // inherit so line-box metrics match the pseudo's parent
119
+ if (pseudo === '::before')
120
+ el.insertBefore(probe, el.firstChild);
121
+ else
122
+ el.appendChild(probe);
123
+ const r = probe.getBoundingClientRect();
124
+ probe.remove();
125
+ return r;
126
+ };
127
+ // For `position: absolute` / `position: fixed` pseudos, the containing block
128
+ // is the nearest positioned ancestor of the host (NOT the host itself when
129
+ // the host is `position: static`). NYT's mobile nav `.css-sdhjrl::after`
130
+ // fade-out is `position: absolute; right: 0; top:0; width: 24px; height: 40px`
131
+ // on a `position: static; display: flex; overflow: scroll` NAV — the pseudo's
132
+ // computed `top` / `left` resolve against a far-up ancestor, so naïvely adding
133
+ // them to the host's padding-box origin places the gradient ~3088px below the
134
+ // NAV (where there's no NAV to fade over). Instead, inject a real absolutely-
135
+ // positioned sentinel as a child of the host: it inherits the same containing
136
+ // block the pseudo would have, and Chrome lays it out at the exact rect the
137
+ // pseudo paints to. Read its `getBoundingClientRect` directly.
138
+ const probePseudoAbsoluteBoxRect = (el, pseudo, pcs) => {
139
+ const probe = document.createElement('div');
140
+ probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box;margin:0';
141
+ probe.style.position = pcs.position;
142
+ probe.style.top = pcs.top;
143
+ probe.style.right = pcs.right;
144
+ probe.style.bottom = pcs.bottom;
145
+ probe.style.left = pcs.left;
146
+ probe.style.width = pcs.width;
147
+ probe.style.height = pcs.height;
148
+ probe.style.paddingTop = pcs.paddingTop;
149
+ probe.style.paddingRight = pcs.paddingRight;
150
+ probe.style.paddingBottom = pcs.paddingBottom;
151
+ probe.style.paddingLeft = pcs.paddingLeft;
152
+ probe.style.borderTopWidth = pcs.borderTopWidth;
153
+ probe.style.borderRightWidth = pcs.borderRightWidth;
154
+ probe.style.borderBottomWidth = pcs.borderBottomWidth;
155
+ probe.style.borderLeftWidth = pcs.borderLeftWidth;
156
+ probe.style.borderStyle = 'solid';
157
+ probe.style.borderColor = 'transparent';
158
+ probe.style.marginTop = pcs.marginTop;
159
+ probe.style.marginRight = pcs.marginRight;
160
+ probe.style.marginBottom = pcs.marginBottom;
161
+ probe.style.marginLeft = pcs.marginLeft;
162
+ probe.style.transform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : '';
163
+ probe.style.transformOrigin = pcs.transformOrigin || '';
164
+ // Pseudo lives logically inside the host; an absolute child of the host
165
+ // inherits the same containing-block lookup.
166
+ if (pseudo === '::before')
167
+ el.insertBefore(probe, el.firstChild);
168
+ else
169
+ el.appendChild(probe);
170
+ const r = probe.getBoundingClientRect();
171
+ probe.remove();
172
+ return r;
173
+ };
56
174
  const pickQuoteChar = (forEl, isOpen) => {
57
175
  // Count q-element ancestors above this element (depth=0 = the first q
58
176
  // not inside another q). The pseudo lives ON forEl so when forEl IS a
@@ -113,6 +231,17 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
113
231
  const content = pcs.content;
114
232
  if (content == null || content === 'none' || content === 'normal' || content === '')
115
233
  continue;
234
+ // DM-665 / DM-677: pseudos with computed `opacity: 0` paint nothing in
235
+ // Chrome (Material-style ripple / hover overlays use this — Google's
236
+ // `a.gb_C::before` is the empty-content variant we already skipped;
237
+ // `a.gb_A::before` on the mobile "Sign in" pill is the same idea but
238
+ // with `content: " "` (a single space, non-empty) so it slipped past
239
+ // the previous gate). Skip ALL opacity-zero pseudos before doing any
240
+ // measurement / box-rect work; capturing them anyway would paint an
241
+ // opaque box over the host's actual content.
242
+ const opacityNum = parseFloat(pcs.opacity);
243
+ if (Number.isFinite(opacityNum) && opacityNum === 0)
244
+ continue;
116
245
  let text = '';
117
246
  let imageUrl = '';
118
247
  let i = 0;
@@ -161,16 +290,28 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
161
290
  });
162
291
  const cname = args[0];
163
292
  const sep = isCounters ? (args[1] ?? '') : '';
164
- // counter-style argument (third arg of counters, second of
165
- // counter) is currently ignored decimal-only. Most fixtures
166
- // use the default style; non-decimal can be added later.
293
+ // DM-788: third arg of counters() / second arg of counter() is a
294
+ // `<counter-style>` name. When that name matches a custom
295
+ // `@counter-style` rule captured in the pre-walk, run each value
296
+ // through the resolver so prefix / suffix / pad / negative / range
297
+ // / fallback descriptors apply — e.g. `counter(step, prefixed)`
298
+ // produces "Step 01: " instead of plain decimal "1".
299
+ const styleArg = isCounters ? args[2] : args[1];
300
+ const useCustomStyle = styleArg != null && styleArg !== ''
301
+ && isCustomCounterStyle != null && isCustomCounterStyle(styleArg);
302
+ const format = (v) => {
303
+ if (!useCustomStyle)
304
+ return String(v);
305
+ const out = resolveCounterValue(styleArg, v);
306
+ return out != null ? out : String(v);
307
+ };
167
308
  const snapshot = counterSnapshot.get(el) || [];
168
- const matches = snapshot.filter((s) => s.name === cname).map((s) => String(s.value));
309
+ const matches = snapshot.filter((s) => s.name === cname).map((s) => format(s.value));
169
310
  if (isCounters) {
170
- text += matches.length > 0 ? matches.join(sep) : '0';
311
+ text += matches.length > 0 ? matches.join(sep) : format(0);
171
312
  }
172
313
  else {
173
- text += matches.length > 0 ? matches[matches.length - 1] : '0';
314
+ text += matches.length > 0 ? matches[matches.length - 1] : format(0);
174
315
  }
175
316
  i = closeIdx + 1;
176
317
  }
@@ -202,13 +343,29 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
202
343
  // side or a visible background.
203
344
  const bgRaw = pcs.backgroundColor;
204
345
  const hasBg = bgRaw && bgRaw !== '' && bgRaw !== 'rgba(0, 0, 0, 0)' && bgRaw !== 'transparent';
346
+ // DM-767: capture background-image (linear-gradient / radial-gradient /
347
+ // url) on empty-content pseudos too. The `.corner::after` accent stripe
348
+ // pattern in `24-deep-pseudo-shapes` is an absolutely-positioned 4 px
349
+ // strip with a `linear-gradient` background and no color / border —
350
+ // without this check the pseudoBox emit was skipped entirely.
351
+ const bgImgRaw = pcs.backgroundImage;
352
+ const hasBgImg = bgImgRaw != null && bgImgRaw !== '' && bgImgRaw !== 'none';
205
353
  const bwT = parseFloat(pcs.borderTopWidth) || 0;
206
354
  const bwR = parseFloat(pcs.borderRightWidth) || 0;
207
355
  const bwB = parseFloat(pcs.borderBottomWidth) || 0;
208
356
  const bwL = parseFloat(pcs.borderLeftWidth) || 0;
209
357
  const hasBorder = bwT > 0 || bwR > 0 || bwB > 0 || bwL > 0;
210
358
  const isBlockLike = pcs.display === 'block' || pcs.display === 'inline-block' || pcs.display === 'flex';
211
- if (isBlockLike && (hasBg || hasBorder)) {
359
+ // DM-665: `opacity: 0` pseudos paint nothing (Material-style ripple
360
+ // / hover overlays use this pattern — Google's `a.gb_C::before` is
361
+ // a 40×40 dark-grey absolute box that's invisible at rest). Capturing
362
+ // them anyway would paint an opaque box over the host's content
363
+ // (the apps-grid SVG underneath the anchor). Skip empty-content
364
+ // pseudos whose `opacity: 0` makes them visually a no-op.
365
+ const opacityNum = parseFloat(pcs.opacity);
366
+ if (Number.isFinite(opacityNum) && opacityNum === 0)
367
+ continue;
368
+ if (isBlockLike && (hasBg || hasBgImg || hasBorder)) {
212
369
  const hostPadL = parseFloat(cs.paddingLeft) || 0;
213
370
  const hostPadT = parseFloat(cs.paddingTop) || 0;
214
371
  const hostBorL = parseFloat(cs.borderLeftWidth) || 0;
@@ -235,44 +392,100 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
235
392
  let borderBoxX;
236
393
  let borderBoxY;
237
394
  if (pcs.position === 'absolute' || pcs.position === 'fixed') {
238
- const pcsLeft = parseFloat(pcs.left);
239
- const pcsTop = parseFloat(pcs.top);
240
- const pcsRight = parseFloat(pcs.right);
241
- const pcsBottom = parseFloat(pcs.bottom);
242
- const paddingBoxL = rect.left - vp.x + hostBorL;
243
- const paddingBoxT = rect.top - vp.y + hostBorT;
244
- const paddingBoxR = rect.right - vp.x - hostBorR;
245
- const paddingBoxB = rect.bottom - vp.y - (parseFloat(cs.borderBottomWidth) || 0);
246
- if (!isNaN(pcsLeft))
247
- borderBoxX = paddingBoxL + pcsLeft;
248
- else if (!isNaN(pcsRight))
249
- borderBoxX = paddingBoxR - pcsRight - borderBoxW;
250
- else
251
- borderBoxX = paddingBoxL;
252
- if (!isNaN(pcsTop))
253
- borderBoxY = paddingBoxT + pcsTop;
254
- else if (!isNaN(pcsBottom))
255
- borderBoxY = paddingBoxB - pcsBottom - borderBoxH;
256
- else
257
- borderBoxY = paddingBoxT;
395
+ // Use a real positioned sentinel to find the pseudo's true painted
396
+ // rect. Chrome's containing-block lookup walks up from the host
397
+ // looking for a positioned ancestor (or a transformed / filtered
398
+ // / contained ancestor); the same lookup applies to a real child
399
+ // of the host. Probing avoids re-implementing that walk + all the
400
+ // containing-block-establishing properties. NYT mobile's nav fade-
401
+ // out `.css-sdhjrl::after` (`position: absolute; right: 0`) on a
402
+ // `position: static` NAV is the trigger case the pseudo's
403
+ // resolved `top` / `left` are relative to a far-up positioned
404
+ // ancestor, not the NAV, so the prior additive math placed the
405
+ // gradient thousands of pixels off the NAV.
406
+ const pr = probePseudoAbsoluteBoxRect(el, pseudo, pcs);
407
+ borderBoxX = pr.left - vp.x;
408
+ borderBoxY = pr.top - vp.y;
258
409
  }
259
410
  else {
260
411
  const pMarT = parseFloat(pcs.marginTop) || 0;
261
412
  borderBoxX = rect.left - vp.x + hostBorL + hostPadL + pMarL;
262
413
  borderBoxY = rect.top - vp.y + hostBorT + hostPadT + pMarT;
414
+ // DM-768: static `display: inline-block` (and inline-flex / inline-grid /
415
+ // inline-table) pseudos participate in Chrome's inline vertical-align
416
+ // math — the formula above ignores `vertical-align` and pins the box to
417
+ // the host's content-area top, which is 6-7 px too high for a typical
418
+ // `vertical-align: middle` down-caret. Probe with a real sentinel that
419
+ // mirrors the pseudo's box properties.
420
+ //
421
+ // CSS render order on the line is: ::before → real children → ::after.
422
+ // The sentinel is a real child:
423
+ // - For ::before, the sentinel renders AFTER the pseudo. The
424
+ // pseudo's own position is unchanged; the sentinel just shifts
425
+ // subsequent content. So the pseudo's border-box left =
426
+ // probe.left − pMarR − borderBoxW − pMarL (back out the sentinel
427
+ // gap), and the pseudo's top equals probe.top (both lay out
428
+ // on the same line with matching `vertical-align`).
429
+ // - For ::after, the sentinel renders BEFORE the pseudo. Without
430
+ // the sentinel, the pseudo would take the slot the sentinel
431
+ // now occupies, so the pseudo's border-box left = probe.left
432
+ // and top = probe.top.
433
+ const dispIsInline = pcs.display === 'inline-block' || pcs.display === 'inline-flex' || pcs.display === 'inline-grid' || pcs.display === 'inline-table';
434
+ if (dispIsInline) {
435
+ const pr = probePseudoStaticBoxRect(el, pseudo, pcs);
436
+ borderBoxY = pr.top - vp.y;
437
+ if (pseudo === '::after') {
438
+ borderBoxX = pr.left - vp.x;
439
+ }
440
+ else {
441
+ const pMarR = parseFloat(pcs.marginRight) || 0;
442
+ borderBoxX = pr.left - vp.x - pMarR - borderBoxW - pMarL;
443
+ }
444
+ }
445
+ }
446
+ // DM-710: if the host has a CSS transform whose 2D submatrix is
447
+ // singular (zero determinant), the host's painted area collapses
448
+ // to a point / line and the pseudo paints nothing visible —
449
+ // Apple's `.globalnav-bag-badge` carries `transform: matrix(0, 0,
450
+ // 0, 0, 0, 0)` as the "no items in cart" state, and the empty
451
+ // ::before with `width: 13px; background: black; border-radius:
452
+ // 13px` would otherwise emit as a visible dot. Skip the pseudoBox
453
+ // in that case; the live-rect model already drops the host itself.
454
+ let degenerateHostTransform = false;
455
+ if (cs.transform && cs.transform !== 'none') {
456
+ const m2 = /^matrix\(\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)/.exec(cs.transform);
457
+ if (m2) {
458
+ const a = parseFloat(m2[1]);
459
+ const b = parseFloat(m2[2]);
460
+ const c = parseFloat(m2[3]);
461
+ const d = parseFloat(m2[4]);
462
+ if (Math.abs(a * d - b * c) < 1e-9)
463
+ degenerateHostTransform = true;
464
+ }
263
465
  }
264
- if (borderBoxW > 0 && borderBoxH > 0) {
466
+ if (borderBoxW > 0 && borderBoxH > 0 && !degenerateHostTransform) {
467
+ // DM-783: the pseudo's own `transform` (rotate/scale/translate/
468
+ // matrix) wraps the pseudoBox at render time. getComputedStyle
469
+ // returns the resolved matrix() form, and transformOrigin returns
470
+ // resolved px values relative to the pseudo's box top-left — both
471
+ // can be pasted directly into an SVG `<g>` wrapper. Captured only
472
+ // when non-`none` to keep the captured tree compact.
473
+ const pcsTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
474
+ const pcsTransformOrigin = pcsTransform != null ? (pcs.transformOrigin || undefined) : undefined;
265
475
  pseudoBoxes.push({
266
476
  x: borderBoxX,
267
477
  y: borderBoxY,
268
478
  width: borderBoxW,
269
479
  height: borderBoxH,
270
480
  backgroundColor: hasBg ? normColor(bgRaw) : undefined,
481
+ backgroundImage: hasBgImg ? bgImgRaw : undefined,
271
482
  borderTopWidth: bwT, borderTopColor: bwT > 0 ? normColor(pcs.borderTopColor) : undefined, borderTopStyle: pcs.borderTopStyle,
272
483
  borderRightWidth: bwR, borderRightColor: bwR > 0 ? normColor(pcs.borderRightColor) : undefined, borderRightStyle: pcs.borderRightStyle,
273
484
  borderBottomWidth: bwB, borderBottomColor: bwB > 0 ? normColor(pcs.borderBottomColor) : undefined, borderBottomStyle: pcs.borderBottomStyle,
274
485
  borderLeftWidth: bwL, borderLeftColor: bwL > 0 ? normColor(pcs.borderLeftColor) : undefined, borderLeftStyle: pcs.borderLeftStyle,
275
486
  borderRadius: parseFloat(pcs.borderRadius) || 0,
487
+ transform: pcsTransform,
488
+ transformOrigin: pcsTransformOrigin,
276
489
  });
277
490
  }
278
491
  }
@@ -332,22 +545,12 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
332
545
  }
333
546
  if (text === '')
334
547
  continue;
335
- // Measure via canvas using the pseudo's computed font.
336
- const fontSpec = pcs.font || (pcs.fontWeight + ' ' + pcs.fontSize + ' ' + pcs.fontFamily);
337
- const measureCanvas = document.createElement('canvas');
338
- const mctx = measureCanvas.getContext('2d');
339
- mctx.font = fontSpec;
340
- // DM-507: prefer Chrome's resolved layout width over
341
- // canvas.measureText when available. For position:absolute pseudos
342
- // with auto-width Chrome shrink-to-fits the box and
343
- // getComputedStyle returns the resolved content-box width.
344
- // canvas.measureText drifts ~1-2px from Chrome's actual layout in
345
- // common bold / symbol-mix fixtures because the canvas font-
346
- // shaping path differs slightly from Chrome's HarfBuzz paint
347
- // pipeline. Falling back to canvas measurement when pcs.width is
348
- // unavailable (typical for non-positioned inline pseudos) keeps
349
- // the existing path.
350
- let pseudoWidth = mctx.measureText(text).width;
548
+ // DM-785: probe-span measurement matches Chrome's HarfBuzz-shaped
549
+ // layout width canvas.measureText drifted ~1-3px on bold uppercase
550
+ // short strings (visible on rotated gradient pills as the text
551
+ // overflowing the badge). DM-507 numeric-pcs.width override still
552
+ // wins when the pseudo's box has an authored fixed width.
553
+ let pseudoWidth = probePseudoTextWidth(text, pcs);
351
554
  if (pcs.position === 'absolute' || pcs.position === 'fixed') {
352
555
  const pcsW = parseFloat(pcs.width);
353
556
  if (!isNaN(pcsW) && pcsW > 0)
@@ -449,12 +652,15 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
449
652
  width: pseudoWidth,
450
653
  height: elFontSize,
451
654
  // Carry pseudo-specific typography so the renderer can respect
452
- // per-pseudo color, font-size, font-weight, font-family (CSS
453
- // lets pseudos style independently of their parent).
655
+ // per-pseudo color, font-size, font-weight, font-family, font-style
656
+ // (CSS lets pseudos style independently of their parent — Slashdot's
657
+ // "Most Discussed" carousel heading is a ::after that's italic+bordered
658
+ // on a non-italic host div).
454
659
  color: pcs.color,
455
660
  fontSize: elFontSize,
456
661
  fontWeight: pcs.fontWeight,
457
662
  fontFamily: pcs.fontFamily,
663
+ fontStyle: pcs.fontStyle,
458
664
  fontAscent: pseudoMetrics.ascent,
459
665
  };
460
666
  // DM-497: stash pseudo's own background / border-radius on the
@@ -466,26 +672,49 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
466
672
  const pseudoBgColor = pseudoBgRaw && pseudoBgRaw !== '' && pseudoBgRaw !== 'rgba(0, 0, 0, 0)' && pseudoBgRaw !== 'transparent'
467
673
  ? normColor(pseudoBgRaw) : '';
468
674
  const pseudoBR = parseFloat(pcs.borderRadius) || 0;
469
- // Capture a uniform border when all four sides match. Mixed-side
470
- // styling is rare on pseudos in real-world fixtures and falls
471
- // through.
472
- const bw = parseFloat(pcs.borderTopWidth) || 0;
473
- const bwUniform = bw > 0
474
- && (parseFloat(pcs.borderRightWidth) || 0) === bw
475
- && (parseFloat(pcs.borderBottomWidth) || 0) === bw
476
- && (parseFloat(pcs.borderLeftWidth) || 0) === bw;
675
+ // Capture a uniform border when all four sides match (renders as
676
+ // `<rect stroke=…>`). When a single side carries a border (e.g.
677
+ // `border-bottom: 1px solid rgba(255,255,255,0.5)` on Slashdot's
678
+ // `.carouselHeading::after`) we still capture per-side widths +
679
+ // colors so the renderer can emit a `<line>` for the visible side.
680
+ const bwTop = parseFloat(pcs.borderTopWidth) || 0;
681
+ const bwRight = parseFloat(pcs.borderRightWidth) || 0;
682
+ const bwBottom = parseFloat(pcs.borderBottomWidth) || 0;
683
+ const bwLeft = parseFloat(pcs.borderLeftWidth) || 0;
684
+ const bwUniform = bwTop > 0 && bwRight === bwTop && bwBottom === bwTop && bwLeft === bwTop;
477
685
  const pseudoBC = bwUniform ? normColor(pcs.borderTopColor) : '';
686
+ const colorIsPaintable = (raw) => raw !== '' && raw !== 'rgba(0, 0, 0, 0)' && raw !== 'transparent';
687
+ const sideBorderTopColor = bwTop > 0 ? normColor(pcs.borderTopColor) : '';
688
+ const sideBorderRightColor = bwRight > 0 ? normColor(pcs.borderRightColor) : '';
689
+ const sideBorderBottomColor = bwBottom > 0 ? normColor(pcs.borderBottomColor) : '';
690
+ const sideBorderLeftColor = bwLeft > 0 ? normColor(pcs.borderLeftColor) : '';
691
+ const hasPerSideBorder = !bwUniform && ((bwTop > 0 && colorIsPaintable(sideBorderTopColor))
692
+ || (bwRight > 0 && colorIsPaintable(sideBorderRightColor))
693
+ || (bwBottom > 0 && colorIsPaintable(sideBorderBottomColor))
694
+ || (bwLeft > 0 && colorIsPaintable(sideBorderLeftColor)));
695
+ // DM-782: background-image (linear-gradient / radial-gradient / url())
696
+ // on text-content pseudos. The empty-content path already plumbs this
697
+ // (DM-767); the text-content path was dropping it, so "gradient badge"
698
+ // patterns (`.tier.popular::before { content: "MOST POPULAR"; background:
699
+ // linear-gradient(135deg, ...) }`) lost the pill bg behind the white
700
+ // glyphs.
701
+ const pseudoBgImgRaw = pcs.backgroundImage;
702
+ const hasPseudoBgImg = pseudoBgImgRaw != null && pseudoBgImgRaw !== '' && pseudoBgImgRaw !== 'none';
703
+ // DM-783: pseudo's own `transform` (rotate/scale/translate/matrix)
704
+ // wraps both the paint box AND the glyph emit at render time.
705
+ const pseudoTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
706
+ const pseudoTransformOrigin = pseudoTransform != null ? (pcs.transformOrigin || undefined) : undefined;
478
707
  let pseudoBoxStyles = null;
479
- if (pseudoBgColor !== '' || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)')) {
708
+ if (pseudoBgColor !== '' || hasPseudoBgImg || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)') || hasPerSideBorder || pseudoTransform != null) {
480
709
  pseudoBoxStyles = {
481
710
  padL: parseFloat(pcs.paddingLeft) || 0,
482
711
  padR: parseFloat(pcs.paddingRight) || 0,
483
712
  padT: parseFloat(pcs.paddingTop) || 0,
484
713
  padB: parseFloat(pcs.paddingBottom) || 0,
485
- borL: parseFloat(pcs.borderLeftWidth) || 0,
486
- borR: parseFloat(pcs.borderRightWidth) || 0,
487
- borT: parseFloat(pcs.borderTopWidth) || 0,
488
- borB: parseFloat(pcs.borderBottomWidth) || 0,
714
+ borL: bwLeft,
715
+ borR: bwRight,
716
+ borT: bwTop,
717
+ borB: bwBottom,
489
718
  // Inline-box bg paints at line-height, not at font-size — so
490
719
  // the box's vertical extent is lineH + padding + border (not
491
720
  // fontSize). Capture lineH alongside the metrics; the post-
@@ -494,9 +723,20 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
494
723
  lineH,
495
724
  fontSize: elFontSize,
496
725
  backgroundColor: pseudoBgColor !== '' ? pseudoBgColor : undefined,
726
+ backgroundImage: hasPseudoBgImg ? pseudoBgImgRaw : undefined,
497
727
  borderRadius: pseudoBR > 0 ? pseudoBR : undefined,
498
- borderWidth: bwUniform ? bw : undefined,
728
+ borderWidth: bwUniform ? bwTop : undefined,
499
729
  borderColor: bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)' ? pseudoBC : undefined,
730
+ transform: pseudoTransform,
731
+ transformOrigin: pseudoTransformOrigin,
732
+ // Per-side colors. Renderer reads these when no uniform border
733
+ // is set and emits a `<line>` for each side whose width > 0 and
734
+ // color is paintable. Undefined when the side has no visible
735
+ // border, keeping the captured tree compact in the common case.
736
+ borderTopColor: hasPerSideBorder && bwTop > 0 && colorIsPaintable(sideBorderTopColor) ? sideBorderTopColor : undefined,
737
+ borderRightColor: hasPerSideBorder && bwRight > 0 && colorIsPaintable(sideBorderRightColor) ? sideBorderRightColor : undefined,
738
+ borderBottomColor: hasPerSideBorder && bwBottom > 0 && colorIsPaintable(sideBorderBottomColor) ? sideBorderBottomColor : undefined,
739
+ borderLeftColor: hasPerSideBorder && bwLeft > 0 && colorIsPaintable(sideBorderLeftColor) ? sideBorderLeftColor : undefined,
500
740
  };
501
741
  }
502
742
  // If the pseudo contains any codepoint Chrome paints via a color-
@@ -168,9 +168,29 @@ export const createPseudoInjectHandler = () => {
168
168
  p.seg.pseudoBox = {
169
169
  x: bx, y: boxTop, width: bw, height: bh,
170
170
  backgroundColor: bs.backgroundColor,
171
+ // DM-782: gradient/url() bg-image plumbing — renderer threads
172
+ // each comma-separated layer through `buildBackgroundLayerDef`
173
+ // and paints rect(s) behind the glyphs (mirrors the empty-
174
+ // content pseudoBox path in `element-tree-to-svg.ts`).
175
+ backgroundImage: bs.backgroundImage,
171
176
  borderRadius: bs.borderRadius,
172
177
  borderWidth: bs.borderWidth,
173
178
  borderColor: bs.borderColor,
179
+ // Per-side widths + colors for non-uniform borders (e.g. a
180
+ // bare `border-bottom` on a pseudo). Width fields are always
181
+ // emitted so the renderer doesn't have to fall back to zero
182
+ // when a `borderWidth` (uniform) shorthand is absent.
183
+ borL: bs.borL, borR: bs.borR, borT: bs.borT, borB: bs.borB,
184
+ borderTopColor: bs.borderTopColor,
185
+ borderRightColor: bs.borderRightColor,
186
+ borderBottomColor: bs.borderBottomColor,
187
+ borderLeftColor: bs.borderLeftColor,
188
+ // DM-783: pseudo's `transform` + `transformOrigin`. Renderer
189
+ // wraps the box + glyphs in a pre-baked
190
+ // translate-(transform)-translate matrix so the rotation/scale
191
+ // pivots around the box-relative origin instead of (0,0).
192
+ transform: bs.transform,
193
+ transformOrigin: bs.transformOrigin,
174
194
  };
175
195
  }
176
196
  }