@miaskiewicz/turbo-dom 0.1.62 → 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.62",
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,7 @@ 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';
18
19
  import { SVGAnimatedString, SVGAnimatedLength, SVGAnimatedRect, SVG_LENGTH_ATTRS } from './svg.mjs';
19
20
 
20
21
  // Per-node query-result cache keyed by (selector, document version). querySelectorAll
@@ -240,6 +241,20 @@ export class Node extends EventTarget {
240
241
  }
241
242
 
242
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
+ }
243
258
  const kids = this.__children();
244
259
  // hot leaf case (RTL getByText reads textContent on every element): a single
245
260
  // text child → return its data directly, skipping the iterator + concat.
@@ -1341,11 +1356,19 @@ const SHORTHAND_OF = {
1341
1356
  'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
1342
1357
  };
1343
1358
  function styleGet(values, prop) {
1344
- const direct = values.get(prop);
1345
- if (direct !== undefined) return direct;
1346
- const sh = SHORTHAND_OF[prop];
1347
- if (sh) { const v = values.get(sh); if (v !== undefined && !/\s/.test(v.trim())) return v; }
1348
- 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;
1349
1372
  }
1350
1373
 
1351
1374
  // WHATWG value-sanitization per input type. Returns the accepted value, or null