@miaskiewicz/turbo-dom 0.1.61 → 0.2.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/README.md CHANGED
@@ -15,10 +15,14 @@ npm install -D @miaskiewicz/turbo-dom
15
15
  - ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
16
16
  Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
17
17
  - ⚡ **Faster than both** — ~130× jsdom / ~45× happy-dom on a realistic suite (parse-memoized repeated shells), ~8–44× faster HTML parsing on real pages, and ~2.7× happy-dom on repeated queries while staying 99.7% spec-correct.
18
- - 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros.
19
- `getComputedStyle` runs a **partial cascade**: it resolves real injected `<style>` rules
20
- (emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source order — but only
21
- ever returns values a real rule set, never invented layout. Geometry tests belong in a real browser.
18
+ - 🎨 **Real computed style** — `getComputedStyle` runs a **partial cascade**: it resolves real
19
+ injected `<style>` rules (emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source
20
+ order, **inherits the standard inheritable properties down the tree** (a global `body{color}` reaches
21
+ descendants), and **canonicalizes colors to `rgb()/rgba()`** like a browser (so
22
+ `toHaveStyle({color:'#fff'})` matches a `#ffffff`/`white`/`rgb(255,255,255)` rule). It only ever
23
+ returns values a real rule set — never an invented one.
24
+ - 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and computed
25
+ style never invents layout. Geometry tests belong in a real browser.
22
26
 
23
27
  ## Quick start
24
28
 
@@ -124,7 +128,7 @@ to set up `document`/`window` on any global object.
124
128
  | @testing-library/dom + user-event | ✅ | ✅ | ✅ |
125
129
  | React + Radix / Headless UI / downshift | ✅ | partial | ✅ |
126
130
  | Real layout | ❌ (honest stub) | partial | partial |
127
- | `getComputedStyle` cascade | partial (real `<style>` + inline) | partial | partial |
131
+ | `getComputedStyle` cascade | partial (real `<style>` + inline + inheritance + `rgb()` colors) | partial | partial |
128
132
  | Shadow DOM (attach, slots, event retargeting, scoped/`:host` CSS) | ✅ | ✅ | ✅ |
129
133
 
130
134
  turbo-dom inherits Servo's tree constructor, so the "messy input" cases hand-rolled parsers
@@ -189,13 +193,23 @@ JS (chatty, fine-grained) but pays only for what a test touches. Full design not
189
193
  - **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
190
194
  - **`getComputedStyle` is a partial cascade** — it resolves REAL injected `<style>` rules
191
195
  (emotion/MUI `.css-HASH{…}`) plus inline `style`, applying specificity and source order
192
- (inline wins). It expands common shorthands to longhands (`margin`/`padding`/`border`/single-token
193
- `background`), serializes bare `0` as `0px` for length props, and normalizes `font-family`
194
- comma spacing to match browser output. Out of scope (returns `''`): `@media`/`@supports`/
195
- `@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, full inheritance, CSS custom
196
- properties, and length-unit conversion (`em`/`rem`→`px`). Only ever returns values from a
197
- matched rule or inline declaration — never an invented one. Style/geometry assertions belong
198
- in a real browser (Playwright/WebDriver).
196
+ (inline wins). It **inherits the standard inheritable properties** (`color`, `font*`,
197
+ `line-height`, `text-align`, `visibility`, …) down the flattened tree — a global rule on
198
+ `body`/`:root` reaches descendants, and inheritance crosses the shadow host boundary. It
199
+ **canonicalizes `<color>` values to `rgb()/rgba()`** (`#fff`/`white`/`hsl(...)` → `rgb(255, 255, 255)`)
200
+ exactly as browsers serialize computed style, so `@testing-library/jest-dom`'s `toHaveStyle`
201
+ color assertions compare equal regardless of how the rule authored the color. It expands common
202
+ shorthands to longhands (`margin`/`padding`/`border`/single-token `background`), serializes bare
203
+ `0` as `0px` for length props, and normalizes `font-family` comma spacing. Out of scope (returns
204
+ `''`): `@media`/`@supports`/`@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, the
205
+ `inherit`/`initial`/`unset` keywords, CSS custom properties, and length-unit conversion
206
+ (`em`/`rem`→`px`). Only ever returns values from a matched, inline, or inherited declaration —
207
+ never an invented initial value. Style/geometry assertions belong in a real browser
208
+ (Playwright/WebDriver).
209
+ - **`<style>.textContent` reflects rules injected via `sheet.insertRule()`** — CSS-in-JS engines in
210
+ "speedy" mode (emotion/styled-components) inject rules straight into the CSSOM without writing the
211
+ node's text; turbo-dom serializes them back into `textContent` (as browsers/jsdom do), so tests
212
+ that scrape `querySelectorAll('style')` text see the injected CSS.
199
213
  - Canvas, `<select>` rendering, and similar visual APIs are honest no-op stubs.
200
214
  - **Shadow DOM** is supported and pay-for-what-you-use — every event/query/cascade hot path
201
215
  is unchanged until the first `attachShadow` flips a per-document flag. Covered: `attachShadow`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.1.61",
3
+ "version": "0.2.0",
4
4
  "description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -7,12 +7,15 @@
7
7
  // added to any benchmarked path.
8
8
  //
9
9
  // HONEST: this only ever returns values that come from a REAL matched rule or inline
10
- // style. A property no stylesheet/inline set still reads '' — it never invents
11
- // layout/cascade numbers. Out of scope (return ''): @media/@supports/@keyframes,
12
- // :hover & other stateful pseudo-classes, pseudo-elements, full inheritance,
13
- // CSS custom-property resolution, length normalization.
10
+ // style (or inherited from one — see INHERITED). A property no stylesheet/inline set
11
+ // still reads '' — it never invents layout/cascade numbers or initial values. Out of
12
+ // scope (return ''): @media/@supports/@keyframes, :hover & other stateful
13
+ // pseudo-classes, pseudo-elements, the `inherit`/`initial`/`unset` keywords,
14
+ // CSS custom-property resolution. Colors ARE canonicalized to rgb()/rgba() (color.mjs)
15
+ // and bare-0 lengths to `0px`, matching what a browser serializes.
14
16
 
15
17
  import { matchesSelector } from './selectors.mjs';
18
+ import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
16
19
 
17
20
  const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
18
21
 
@@ -162,9 +165,10 @@ function collectMatched(el, rules, kind, into) {
162
165
  }
163
166
  }
164
167
 
165
- // Inheritable properties we propagate across the shadow boundary into shadow
166
- // content (curated; matches the common inherited set). Honest partial: only
167
- // these inherit, and only INTO shadow trees — light DOM stays inheritance-free.
168
+ // Inheritable properties we propagate down the flattened tree (curated; matches
169
+ // the common inherited set). Honest partial: only these inherit. Covers both
170
+ // light-DOM "CSS globals" (a rule on html/body cascades to descendants) and
171
+ // crossing the shadow host boundary into shadow content.
168
172
  const INHERITED = new Set([
169
173
  'color', 'cursor', 'direction', 'font', 'font-family', 'font-size', 'font-style',
170
174
  'font-variant', 'font-weight', 'letter-spacing', 'line-height', 'list-style',
@@ -199,6 +203,9 @@ function lookup(map, prop) {
199
203
  // (emotion minifies to "a",b,c). Scoped to font-family so we don't rewrite commas
200
204
  // inside rgb()/cubic-bezier() values, which browsers leave compact.
201
205
  if (prop === 'font-family') return v.replace(/\s*,\s*/g, ', ');
206
+ // <color> properties: browsers serialize computed colors to rgb()/rgba() —
207
+ // names, hex, hsl all canonicalize (includeNamed=true). Unrecognized → passthrough.
208
+ if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, true); if (c) return c; }
202
209
  return v;
203
210
  }
204
211
 
@@ -255,16 +262,21 @@ export function makeGetComputedStyle() {
255
262
  const inline = el.getAttribute && el.getAttribute('style');
256
263
  if (inline) parseDecls(inline, map);
257
264
 
258
- // Inheritance INTO shadow content only: an element inside a shadow tree
259
- // inherits unset inheritable props from its flattened parent (crossing the
260
- // host boundary). Light-DOM elements stay inheritance-free (honest).
261
- if (inShadow) {
262
- const parent = flattenedParent(el);
263
- if (parent) {
264
- const ps = gcs(parent); // recursive, memoized, terminates at the light-DOM host
265
- for (const prop of INHERITED) {
266
- if (!map.has(prop)) { const pv = ps.getPropertyValue(prop); if (pv) map.set(prop, pv); }
267
- }
265
+ // Inheritance: an element inherits unset inheritable props from its flattened
266
+ // parent (DOM parent, hopping shadow-root→host at the boundary). This carries
267
+ // "CSS globals" (a rule on html/body/:root) down the tree AND propagates into
268
+ // shadow content. Honest partial: only the curated INHERITED set inherits, only
269
+ // from values a REAL rule/inline actually set (pv truthy) — never an initial
270
+ // value. Recursion is memoized per element/version and terminates at the root
271
+ // (flattenedParent → null). Test-time only; hot paths never call gcs.
272
+ // `inShadow` is unused now that light-DOM inherits too, but the flattened-parent
273
+ // hop still crosses the host boundary correctly for shadow content.
274
+ void inShadow;
275
+ const parent = flattenedParent(el);
276
+ if (parent) {
277
+ const ps = gcs(parent);
278
+ for (const prop of INHERITED) {
279
+ if (!map.has(prop)) { const pv = ps.getPropertyValue(prop); if (pv) map.set(prop, pv); }
268
280
  }
269
281
  }
270
282
 
@@ -0,0 +1,160 @@
1
+ // Color canonicalization — browsers serialize computed/inline <color> values to
2
+ // rgb()/rgba(), never as the authored `#fff`/`white`. testing-library's
3
+ // `toHaveStyle` compares the element's computed value against the expected value
4
+ // AS STRINGS, after each is run through the DOM's own normalization — so to match
5
+ // a browser (and jsdom) both getComputedStyle (cascade.mjs) and inline style
6
+ // read-back (dom.mjs styleGet) must canonicalize. This module is the shared core.
7
+ //
8
+ // PERF: only ever called from getComputedStyle + el.style reads (test/app time),
9
+ // and only for properties in COLOR_PROPS. Parse/query/match/event hot paths never
10
+ // touch it. Returns null when the value isn't a recognized color → caller keeps
11
+ // the original string (honest passthrough for url()/gradients/var()/keywords).
12
+ //
13
+ // Two modes, matching real browsers:
14
+ // includeNamed=false (inline el.style): hex + rgb()/hsl() canonicalize; NAMED
15
+ // keywords (`red`, `transparent`) stay as authored — Chrome keeps them inline.
16
+ // includeNamed=true (computed getComputedStyle): names resolve to rgb() too.
17
+
18
+ // Properties whose value is a <color> (longhands + the single-token shorthands we
19
+ // already expand). `background` is included for the bare-color MUI case
20
+ // (`background:#fff`); a non-color background value just fails to parse → passthrough.
21
+ export const COLOR_PROPS = new Set([
22
+ 'color', 'background-color', 'background', 'border-color',
23
+ 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
24
+ 'outline-color', 'text-decoration-color', 'column-rule-color', 'caret-color',
25
+ 'fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color',
26
+ ]);
27
+
28
+ // CSS named colors → #rrggbb (reused through hexToRgb). `transparent` is special
29
+ // (→ rgba(0,0,0,0)); both gate behind includeNamed.
30
+ const NAMED = {
31
+ aliceblue: 'f0f8ff', antiquewhite: 'faebd7', aqua: '00ffff', aquamarine: '7fffd4',
32
+ azure: 'f0ffff', beige: 'f5f5dc', bisque: 'ffe4c4', black: '000000',
33
+ blanchedalmond: 'ffebcd', blue: '0000ff', blueviolet: '8a2be2', brown: 'a52a2a',
34
+ burlywood: 'deb887', cadetblue: '5f9ea0', chartreuse: '7fff00', chocolate: 'd2691e',
35
+ coral: 'ff7f50', cornflowerblue: '6495ed', cornsilk: 'fff8dc', crimson: 'dc143c',
36
+ cyan: '00ffff', darkblue: '00008b', darkcyan: '008b8b', darkgoldenrod: 'b8860b',
37
+ darkgray: 'a9a9a9', darkgreen: '006400', darkgrey: 'a9a9a9', darkkhaki: 'bdb76b',
38
+ darkmagenta: '8b008b', darkolivegreen: '556b2f', darkorange: 'ff8c00', darkorchid: '9932cc',
39
+ darkred: '8b0000', darksalmon: 'e9967a', darkseagreen: '8fbc8f', darkslateblue: '483d8b',
40
+ darkslategray: '2f4f4f', darkslategrey: '2f4f4f', darkturquoise: '00ced1', darkviolet: '9400d3',
41
+ deeppink: 'ff1493', deepskyblue: '00bfff', dimgray: '696969', dimgrey: '696969',
42
+ dodgerblue: '1e90ff', firebrick: 'b22222', floralwhite: 'fffaf0', forestgreen: '228b22',
43
+ fuchsia: 'ff00ff', gainsboro: 'dcdcdc', ghostwhite: 'f8f8ff', gold: 'ffd700',
44
+ goldenrod: 'daa520', gray: '808080', green: '008000', greenyellow: 'adff2f',
45
+ grey: '808080', honeydew: 'f0fff0', hotpink: 'ff69b4', indianred: 'cd5c5c',
46
+ indigo: '4b0082', ivory: 'fffff0', khaki: 'f0e68c', lavender: 'e6e6fa',
47
+ lavenderblush: 'fff0f5', lawngreen: '7cfc00', lemonchiffon: 'fffacd', lightblue: 'add8e6',
48
+ lightcoral: 'f08080', lightcyan: 'e0ffff', lightgoldenrodyellow: 'fafad2', lightgray: 'd3d3d3',
49
+ lightgreen: '90ee90', lightgrey: 'd3d3d3', lightpink: 'ffb6c1', lightsalmon: 'ffa07a',
50
+ lightseagreen: '20b2aa', lightskyblue: '87cefa', lightslategray: '778899', lightslategrey: '778899',
51
+ lightsteelblue: 'b0c4de', lightyellow: 'ffffe0', lime: '00ff00', limegreen: '32cd32',
52
+ linen: 'faf0e6', magenta: 'ff00ff', maroon: '800000', mediumaquamarine: '66cdaa',
53
+ mediumblue: '0000cd', mediumorchid: 'ba55d3', mediumpurple: '9370db', mediumseagreen: '3cb371',
54
+ mediumslateblue: '7b68ee', mediumspringgreen: '00fa9a', mediumturquoise: '48d1cc', mediumvioletred: 'c71585',
55
+ midnightblue: '191970', mintcream: 'f5fffa', mistyrose: 'ffe4e1', moccasin: 'ffe4b5',
56
+ navajowhite: 'ffdead', navy: '000080', oldlace: 'fdf5e6', olive: '808000',
57
+ olivedrab: '6b8e23', orange: 'ffa500', orangered: 'ff4500', orchid: 'da70d6',
58
+ palegoldenrod: 'eee8aa', palegreen: '98fb98', paleturquoise: 'afeeee', palevioletred: 'db7093',
59
+ papayawhip: 'ffefd5', peachpuff: 'ffdab9', peru: 'cd853f', pink: 'ffc0cb',
60
+ plum: 'dda0dd', powderblue: 'b0e0e6', purple: '800080', rebeccapurple: '663399',
61
+ red: 'ff0000', rosybrown: 'bc8f8f', royalblue: '4169e1', saddlebrown: '8b4513',
62
+ salmon: 'fa8072', sandybrown: 'f4a460', seagreen: '2e8b57', seashell: 'fff5ee',
63
+ sienna: 'a0522d', silver: 'c0c0c0', skyblue: '87ceeb', slateblue: '6a5acd',
64
+ slategray: '708090', slategrey: '708090', snow: 'fffafa', springgreen: '00ff7f',
65
+ steelblue: '4682b4', tan: 'd2b48c', teal: '008080', thistle: 'd8bfd8',
66
+ tomato: 'ff6347', turquoise: '40e0d0', violet: 'ee82ee', wheat: 'f5deb3',
67
+ white: 'ffffff', whitesmoke: 'f5f5f5', yellow: 'ffff00', yellowgreen: '9acd32',
68
+ };
69
+
70
+ const clamp255 = (n) => (n < 0 ? 0 : n > 255 ? 255 : n) | 0;
71
+
72
+ // Serialize {r,g,b,a}. Browsers emit `rgb(r, g, b)` when fully opaque, else
73
+ // `rgba(r, g, b, a)` with the alpha trimmed (0.5, not 0.50). Both sides of every
74
+ // comparison route through here, so the exact alpha rounding only has to be
75
+ // internally consistent.
76
+ function rgb(r, g, b, a) {
77
+ r = clamp255(r); g = clamp255(g); b = clamp255(b);
78
+ if (a === undefined || a >= 1) return `rgb(${r}, ${g}, ${b})`;
79
+ if (a < 0) a = 0;
80
+ const as = String(Math.round(a * 1000) / 1000);
81
+ return `rgba(${r}, ${g}, ${b}, ${as})`;
82
+ }
83
+
84
+ function hexToRgb(v) {
85
+ const h = v.slice(1);
86
+ const n = h.length;
87
+ if (!/^[0-9a-fA-F]+$/.test(h)) return null;
88
+ if (n === 3 || n === 4) {
89
+ const r = parseInt(h[0] + h[0], 16), g = parseInt(h[1] + h[1], 16), b = parseInt(h[2] + h[2], 16);
90
+ const a = n === 4 ? parseInt(h[3] + h[3], 16) / 255 : undefined;
91
+ return rgb(r, g, b, a);
92
+ }
93
+ if (n === 6 || n === 8) {
94
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
95
+ const a = n === 8 ? parseInt(h.slice(6, 8), 16) / 255 : undefined;
96
+ return rgb(r, g, b, a);
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // Re-serialize rgb()/rgba() to canonical spacing. Percentage components (rare in
102
+ // CSS-in-JS output) → null (passthrough) rather than guessing.
103
+ function normalizeRgb(v) {
104
+ const open = v.indexOf('(');
105
+ if (open === -1 || v[v.length - 1] !== ')') return null;
106
+ const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
107
+ if (parts.length < 3 || parts.length > 4) return null;
108
+ const nums = [];
109
+ for (let i = 0; i < parts.length; i++) {
110
+ if (parts[i].indexOf('%') !== -1) return null;
111
+ const f = parseFloat(parts[i]);
112
+ if (Number.isNaN(f)) return null;
113
+ nums.push(f);
114
+ }
115
+ return rgb(nums[0], nums[1], nums[2], nums[3]);
116
+ }
117
+
118
+ function hslToRgb(v) {
119
+ const open = v.indexOf('(');
120
+ if (open === -1 || v[v.length - 1] !== ')') return null;
121
+ const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
122
+ if (parts.length < 3 || parts.length > 4) return null;
123
+ let h = parseFloat(parts[0]);
124
+ const s = parseFloat(parts[1]) / 100;
125
+ const l = parseFloat(parts[2]) / 100;
126
+ if (Number.isNaN(h) || Number.isNaN(s) || Number.isNaN(l)) return null;
127
+ const a = parts[3] !== undefined ? parseFloat(parts[3]) : undefined;
128
+ h = ((h % 360) + 360) % 360 / 360;
129
+ const hue = (p, q, t) => {
130
+ if (t < 0) t += 1; if (t > 1) t -= 1;
131
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
132
+ if (t < 1 / 2) return q;
133
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
134
+ return p;
135
+ };
136
+ let r, g, b;
137
+ if (s === 0) { r = g = b = l; }
138
+ else {
139
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
140
+ const p = 2 * l - q;
141
+ r = hue(p, q, h + 1 / 3); g = hue(p, q, h); b = hue(p, q, h - 1 / 3);
142
+ }
143
+ return rgb(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a);
144
+ }
145
+
146
+ // Canonicalize one color value, or null if unrecognized (→ caller passthrough).
147
+ export function canonicalizeColor(value, includeNamed) {
148
+ if (!value) return null;
149
+ const v = value.trim();
150
+ if (v[0] === '#') return hexToRgb(v);
151
+ const lower = v.toLowerCase();
152
+ if (lower.startsWith('rgb')) return normalizeRgb(v);
153
+ if (lower.startsWith('hsl')) return hslToRgb(v);
154
+ if (includeNamed) {
155
+ if (lower === 'transparent') return 'rgba(0, 0, 0, 0)';
156
+ const hex = NAMED[lower];
157
+ if (hex) return hexToRgb('#' + hex);
158
+ }
159
+ return null;
160
+ }
@@ -15,6 +15,8 @@ import { liveNodeList, liveHTMLCollection } from './collections.mjs';
15
15
  import { matchesSelector, querySelector as qsel, querySelectorAll as qselAll } from './selectors.mjs';
16
16
  import { serializeInner, serializeOuter } from './html-serialize.mjs';
17
17
  import { CSSStyleSheet, styleSheetList } from './cssom.mjs';
18
+ import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
19
+ import { SVGAnimatedString, SVGAnimatedLength, SVGAnimatedRect, SVG_LENGTH_ATTRS } from './svg.mjs';
18
20
 
19
21
  // Per-node query-result cache keyed by (selector, document version). querySelectorAll
20
22
  // returns a STATIC list per spec, so caching is safe until the next mutation bumps
@@ -239,6 +241,20 @@ export class Node extends EventTarget {
239
241
  }
240
242
 
241
243
  get textContent() {
244
+ // <style> whose CSSOM sheet received insertRule() (emotion/styled-components
245
+ // "speedy" mode injects rules WITHOUT touching the node's text): reflect those
246
+ // rules in the serialized text, as browsers/jsdom do, so tests that scrape
247
+ // `querySelectorAll('style').map(s => s.textContent)` see the injected CSS.
248
+ // __sheet is only ever set on <style> elements (sheet getter gates on it), so
249
+ // this is one predicted-false read for every other element on the hot path.
250
+ const sheet = this.__sheet;
251
+ if (sheet && sheet.cssRules.length) {
252
+ const kids = this.__children();
253
+ let css = '';
254
+ for (let i = 0; i < kids.length; i++) if (kids[i].nodeType === TEXT_NODE) css += kids[i].data;
255
+ for (let i = 0; i < sheet.cssRules.length; i++) css += (css ? '\n' : '') + sheet.cssRules[i].cssText;
256
+ return css;
257
+ }
242
258
  const kids = this.__children();
243
259
  // hot leaf case (RTL getByText reads textContent on every element): a single
244
260
  // text child → return its data directly, skipping the iterator + concat.
@@ -724,7 +740,7 @@ export class Element extends Node {
724
740
  }
725
741
 
726
742
  cloneNode(deep = false) {
727
- const el = new Element(this.ownerDocument, this.localName, this.__ns);
743
+ const el = newElement(this.ownerDocument, this.localName, this.__ns);
728
744
  el.__attrs = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({ ...a }));
729
745
  if (deep) {
730
746
  // build the cloned child array directly — the clone is detached, so per-child
@@ -935,6 +951,46 @@ export class Element extends Node {
935
951
  }
936
952
 
937
953
 
954
+ // ------------------------------------------------------------- SVGElement ----
955
+ // SVG elements expose their geometry/identity attributes as SVGAnimated{String,Length,
956
+ // Rect} objects (el.className.baseVal, rect.width.baseVal.value, svg.viewBox.baseVal),
957
+ // not the plain strings/numbers HTML uses. A real subclass keeps these off every HTML
958
+ // element (zero regression, zero hot-path cost — the wrappers are built on access only).
959
+ // Instantiated by newElement() whenever the namespace is "svg".
960
+ export class SVGElement extends Element {
961
+ // className is a readonly SVGAnimatedString in the spec; we still allow assigning a
962
+ // string (common habit) by writing the class attribute, but reads return the wrapper.
963
+ get className() { return new SVGAnimatedString(this, 'class'); }
964
+ set className(v) { this.setAttribute('class', String(v)); }
965
+
966
+ get viewBox() { return new SVGAnimatedRect(this, 'viewBox'); }
967
+
968
+ // Honest geometry stubs (consistent with stubs.mjs — no layout engine, so zeros).
969
+ getBBox() { return { x: 0, y: 0, width: 0, height: 0 }; }
970
+ getCTM() { return null; }
971
+ getScreenCTM() { return null; }
972
+ get ownerSVGElement() {
973
+ let p = this.parentNode;
974
+ while (p && p.nodeType === ELEMENT_NODE) { if (p.localName === 'svg') return p; p = p.parentNode; }
975
+ return null;
976
+ }
977
+ }
978
+ // Length-valued geometry attributes → SVGAnimatedLength (built lazily per read).
979
+ for (const attr of SVG_LENGTH_ATTRS) {
980
+ Object.defineProperty(SVGElement.prototype, attr, {
981
+ configurable: true,
982
+ get() { return new SVGAnimatedLength(this, attr); },
983
+ set(v) { this.setAttribute(attr, v != null && v.baseVal ? String(v.baseVal.value) : String(v)); },
984
+ });
985
+ }
986
+
987
+ // Element factory: SVG namespace → SVGElement, everything else → Element. One place so
988
+ // every construction site (buffer inflation, innerHTML, cloneNode, createElementNS) agrees.
989
+ function newElement(doc, tag, ns) {
990
+ return ns === 'svg' ? new SVGElement(doc, tag, ns) : new Element(doc, tag, ns);
991
+ }
992
+
993
+
938
994
  // ---------------------------------------------------- DocumentFragment ----
939
995
  export class DocumentFragment extends Node {
940
996
  get nodeType() { return DOCUMENT_FRAGMENT_NODE; }
@@ -1300,11 +1356,19 @@ const SHORTHAND_OF = {
1300
1356
  'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
1301
1357
  };
1302
1358
  function styleGet(values, prop) {
1303
- const direct = values.get(prop);
1304
- if (direct !== undefined) return direct;
1305
- const sh = SHORTHAND_OF[prop];
1306
- if (sh) { const v = values.get(sh); if (v !== undefined && !/\s/.test(v.trim())) return v; }
1307
- return '';
1359
+ let v = values.get(prop);
1360
+ if (v === undefined) {
1361
+ const sh = SHORTHAND_OF[prop];
1362
+ if (sh) { const s = values.get(sh); if (s !== undefined && !/\s/.test(s.trim())) v = s; }
1363
+ }
1364
+ if (v === undefined) return '';
1365
+ // Inline CSSOM canonicalizes <color> values the way a browser does on read-back:
1366
+ // hex/rgb()/hsl() → rgb()/rgba(), but NAMED keywords stay as authored (includeNamed
1367
+ // =false) — `el.style.color='red'` reads back `'red'`, `'#fff'` reads back rgb().
1368
+ // This keeps el.style read-back consistent with getComputedStyle so jest-dom's
1369
+ // toHaveStyle (which normalizes the expected value via el.style) compares equal.
1370
+ if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, false); if (c) return c; }
1371
+ return v;
1308
1372
  }
1309
1373
 
1310
1374
  // WHATWG value-sanitization per input type. Returns the accepted value, or null
@@ -1473,7 +1537,7 @@ export class Document extends Node {
1473
1537
  switch (nt) {
1474
1538
  case ELEMENT_NODE: {
1475
1539
  const tag = buf.tagName(idx); // read once (was read twice — ctor + template check)
1476
- node = new Element(this, tag, buf.ns(idx));
1540
+ node = newElement(this, tag, buf.ns(idx));
1477
1541
  node.__idx = idx;
1478
1542
  node.__attrIdx = idx; // attrs lazy (constructor left __attrs undefined)
1479
1543
  // template content fragment: a child node typed 11 named "content"
@@ -1505,7 +1569,7 @@ export class Document extends Node {
1505
1569
  let node;
1506
1570
  switch (raw.nodeType) {
1507
1571
  case ELEMENT_NODE:
1508
- node = new Element(this, raw.name, raw.namespace || '');
1572
+ node = newElement(this, raw.name, raw.namespace || '');
1509
1573
  node.__attrs = raw.attrs.map((a) => ({ name: a.name, value: a.value, prefix: a.prefix || '' }));
1510
1574
  if (raw.name === 'template') {
1511
1575
  const contentRaw = raw.children.find((c) => c.nodeType === DOCUMENT_FRAGMENT_NODE && c.name === 'content');
@@ -1567,7 +1631,7 @@ export class Document extends Node {
1567
1631
  createElementNS(ns, qualified) {
1568
1632
  const short = ns === SVG_NS ? 'svg' : ns === MATHML_NS ? 'math' : '';
1569
1633
  const local = qualified.includes(':') ? qualified.split(':')[1] : qualified;
1570
- return new Element(this, local, short);
1634
+ return newElement(this, local, short);
1571
1635
  }
1572
1636
  createTextNode(data) { return new Text(this, String(data)); }
1573
1637
  createComment(data) { return new Comment(this, String(data)); }
@@ -0,0 +1,70 @@
1
+ // Minimal SVG DOM-property wrappers. SVG IDL attributes are not plain strings/numbers
2
+ // like HTML reflected attributes — they're SVGAnimated{String,Length,Rect} objects with
3
+ // .baseVal / .animVal. Component code (and the libraries that render SVG icons) reads
4
+ // `el.className.baseVal`, `rect.width.baseVal.value`, `svg.viewBox.baseVal`, etc.; without
5
+ // these wrappers those reads throw "Cannot read properties of undefined (reading 'baseVal')".
6
+ //
7
+ // HONEST + LIVE: every wrapper reads/writes the underlying attribute on demand (no cached
8
+ // snapshot), so it always reflects the current attribute and never invents a value. There's
9
+ // no animation engine — animVal === baseVal. Only attached to real SVGElement instances
10
+ // (see dom.mjs), so HTML elements are completely unaffected.
11
+
12
+ // SVGLength — the unit-bearing value behind an SVGAnimatedLength. valueOf() returns the
13
+ // number so `rect.width.baseVal` used in arithmetic/comparisons coerces correctly, while
14
+ // `rect.width.baseVal.value` reads the same number explicitly.
15
+ export class SVGLength {
16
+ constructor(host, attr) { this.__host = host; this.__attr = attr; }
17
+ get value() { const v = parseFloat(this.__host.getAttribute(this.__attr)); return Number.isNaN(v) ? 0 : v; }
18
+ set value(v) { this.__host.setAttribute(this.__attr, String(v)); }
19
+ get valueInSpecifiedUnits() { return this.value; }
20
+ get unitType() { return 1; } // SVG_LENGTHTYPE_NUMBER
21
+ get valueAsString() { return this.__host.getAttribute(this.__attr) || '0'; }
22
+ set valueAsString(s) { this.__host.setAttribute(this.__attr, String(s)); }
23
+ valueOf() { return this.value; }
24
+ toString() { return this.valueAsString; }
25
+ }
26
+
27
+ export class SVGAnimatedLength {
28
+ constructor(host, attr) {
29
+ const base = new SVGLength(host, attr);
30
+ this.baseVal = base;
31
+ this.animVal = base; // no animation engine — animated value tracks the base value
32
+ }
33
+ }
34
+
35
+ export class SVGAnimatedString {
36
+ constructor(host, attr) { this.__host = host; this.__attr = attr; }
37
+ get baseVal() { return this.__host.getAttribute(this.__attr) || ''; }
38
+ set baseVal(v) { this.__host.setAttribute(this.__attr, String(v)); }
39
+ get animVal() { return this.baseVal; }
40
+ }
41
+
42
+ // SVGRect / SVGAnimatedRect — backs `viewBox`. Parses the four-number list lazily.
43
+ export class SVGRect {
44
+ constructor(host, attr) { this.__host = host; this.__attr = attr; }
45
+ #nums() {
46
+ const raw = this.__host.getAttribute(this.__attr) || '';
47
+ const parts = raw.split(/[\s,]+/).map(parseFloat);
48
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] || 0];
49
+ }
50
+ get x() { return this.#nums()[0]; }
51
+ get y() { return this.#nums()[1]; }
52
+ get width() { return this.#nums()[2]; }
53
+ get height() { return this.#nums()[3]; }
54
+ }
55
+
56
+ export class SVGAnimatedRect {
57
+ constructor(host, attr) {
58
+ const base = new SVGRect(host, attr);
59
+ this.baseVal = base;
60
+ this.animVal = base;
61
+ }
62
+ }
63
+
64
+ // Attributes that surface as SVGAnimatedLength on SVG elements. Covers the common
65
+ // geometry set (shapes, gradients, markers); any not listed still works via getAttribute.
66
+ export const SVG_LENGTH_ATTRS = [
67
+ 'width', 'height', 'x', 'y', 'cx', 'cy', 'r', 'rx', 'ry',
68
+ 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'fx', 'fy', 'refX', 'refY',
69
+ 'markerWidth', 'markerHeight', 'startOffset', 'textLength',
70
+ ];
@@ -13,7 +13,7 @@ import {
13
13
  import { makeGetComputedStyle } from './cascade.mjs';
14
14
  import { CSSStyleSheet } from './cssom.mjs';
15
15
  import {
16
- Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
16
+ Node, Element, SVGElement, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
17
17
  MutationObserver, DOMParser, XMLSerializer, ShadowRoot,
18
18
  } from './dom.mjs';
19
19
  import {
@@ -282,7 +282,7 @@ const STATIC_BASE = {
282
282
  DataTransfer,
283
283
  // every element is a plain Element → `el instanceof HTMLElement` is true.
284
284
  // Tag-specific interfaces match by localName via Symbol.hasInstance.
285
- HTMLElement: Element, SVGElement: Element,
285
+ HTMLElement: Element, SVGElement,
286
286
  // SVG interface globals. Libs (and test setup mocks) do
287
287
  // `Object.defineProperty(SVGSVGElement.prototype, 'viewBox', …)` and
288
288
  // `el instanceof SVGSVGElement`; an undefined global is a hard ReferenceError.