domotion-svg 0.1.1

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 (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,1151 @@
1
+ /**
2
+ * Native form-control chrome synthesis.
3
+ *
4
+ * Emulates the default appearance of Chromium on macOS for controls that
5
+ * don't round-trip through pure DOM capture — radios, checkboxes, progress
6
+ * bars, meters, select chevrons, and details disclosure triangles.
7
+ *
8
+ * These are the visible details that a bare <rect> + text capture misses.
9
+ * For controls that authors have styled (background/border set to non-default
10
+ * via CSS), the capture path already handles them; we only paint on top when
11
+ * the captured element's appearance is essentially the UA default.
12
+ */
13
+ import { buildLinearGradientDef, buildRadialGradientDef, gradientCacheKey, parseGradient } from "./gradients.js";
14
+ /** Helper: parse a captured gradient text and register a gradient def, returning fill="url(#id)" or null. */
15
+ function gradientFillFor(bgImage, rect, ctx) {
16
+ if (ctx == null || bgImage == null || bgImage === "" || bgImage === "none")
17
+ return null;
18
+ const grad = parseGradient(bgImage);
19
+ if (grad == null)
20
+ return null;
21
+ // Conic on a form-control pseudo (slider thumb / track / etc.) is rare and
22
+ // the pseudo doesn't flow through the captured-tree raster pre-pass that
23
+ // handles full-element conic backgrounds (DM-547). Fall back to the flat
24
+ // first-color path by returning null here.
25
+ if (grad.kind === "conic")
26
+ return null;
27
+ const key = gradientCacheKey(grad, rect);
28
+ let id = ctx.gradientCache.get(key);
29
+ if (id == null) {
30
+ id = ctx.nextGradId();
31
+ const def = grad.kind === "linear"
32
+ ? buildLinearGradientDef(grad, id, rect)
33
+ : buildRadialGradientDef(grad, id, rect);
34
+ ctx.defsParts.push(def);
35
+ ctx.gradientCache.set(key, id);
36
+ }
37
+ return `url(#${id})`;
38
+ }
39
+ const STOCK_LIGHT = {
40
+ border: "rgb(118,118,118)",
41
+ fill: "rgb(255,255,255)",
42
+ accent: "rgb(0,117,255)",
43
+ trackBg: "rgb(239,239,239)",
44
+ trackFg: "rgb(118,118,118)",
45
+ meterGreen: "rgb(16,124,16)",
46
+ meterYellow: "rgb(255,185,0)",
47
+ meterRed: "rgb(216,59,1)",
48
+ disabledBorder: "rgba(118,118,118,0.5)",
49
+ };
50
+ // DM-553: dark-mode UA defaults sampled from headless Chromium on macOS with
51
+ // `colorScheme: 'dark'` AND `:root { color-scheme: dark }` on the page (just
52
+ // the Playwright option isn't enough — it sets prefers-color-scheme but not
53
+ // the effective UA scheme). Border/fill/track all collapse to a single dark
54
+ // gray (rgb(59,59,59)), the accent shifts to a lighter blue
55
+ // (rgb(153,200,255)) for visibility against the dark canvas, and the meter
56
+ // states are desaturated. Disabled-border alpha increases to 0.7 to stay
57
+ // visible against the darker fill.
58
+ const STOCK_DARK = {
59
+ border: "rgb(59,59,59)",
60
+ fill: "rgb(59,59,59)",
61
+ accent: "rgb(153,200,255)",
62
+ trackBg: "rgb(59,59,59)",
63
+ trackFg: "rgb(178,178,178)",
64
+ meterGreen: "rgb(116,179,116)",
65
+ meterYellow: "rgb(242,200,18)",
66
+ meterRed: "rgb(232,107,86)",
67
+ disabledBorder: "rgba(178,178,178,0.5)",
68
+ };
69
+ /**
70
+ * DM-553: dispatch the per-scheme stock palette. `"light"` (default), missing,
71
+ * or any value other than `"dark"` returns the light palette so today's
72
+ * output stays byte-identical at default settings. `"dark"` returns the
73
+ * dark palette for the form-control synthesizers to consume when rendering
74
+ * an unstyled control on a page captured under `color-scheme: dark`.
75
+ */
76
+ export function stockPalette(scheme) {
77
+ return scheme === "dark" ? STOCK_DARK : STOCK_LIGHT;
78
+ }
79
+ // Back-compat aliases — the synthesizers below still reference the old
80
+ // constant names. These resolve to the LIGHT palette so any code that
81
+ // hasn't been routed through `stockPalette(defCtx?.colorScheme)` yet
82
+ // continues to behave as today. New synthesizer code should call
83
+ // `stockPalette(defCtx?.colorScheme)` directly and read from the returned
84
+ // object instead of importing these aliases.
85
+ const UA_BORDER = STOCK_LIGHT.border;
86
+ const UA_FILL = STOCK_LIGHT.fill;
87
+ const ACCENT_BLUE = STOCK_LIGHT.accent;
88
+ const TRACK_BG = STOCK_LIGHT.trackBg;
89
+ const TRACK_FG = STOCK_LIGHT.trackFg;
90
+ const METER_GREEN = STOCK_LIGHT.meterGreen;
91
+ const METER_YELLOW = STOCK_LIGHT.meterYellow;
92
+ const METER_RED = STOCK_LIGHT.meterRed;
93
+ const DISABLED_BORDER = STOCK_LIGHT.disabledBorder;
94
+ void UA_BORDER;
95
+ void UA_FILL;
96
+ void ACCENT_BLUE;
97
+ void TRACK_BG;
98
+ void TRACK_FG;
99
+ void METER_GREEN;
100
+ void METER_YELLOW;
101
+ void METER_RED;
102
+ void DISABLED_BORDER;
103
+ function r(n) { return Number(n.toFixed(1)).toString(); }
104
+ /** Resolve CSS accent-color to a concrete fill. 'auto' (or missing) falls back
105
+ * to the Chromium macOS default blue (DM-553: scheme-aware via defCtx).
106
+ * Author-set values pass through. */
107
+ function resolveAccent(el, defCtx) {
108
+ const ac = el.styles.accentColor;
109
+ if (ac == null || ac === "" || ac === "auto" || ac === "currentcolor")
110
+ return stockPalette(defCtx?.colorScheme).accent;
111
+ return ac;
112
+ }
113
+ /**
114
+ * Pick the native UA color for the *unfilled* portion of a `<input type=range>`
115
+ * track based on `accent-color`. Chrome ensures the unfilled track stays
116
+ * visible against the accent: when the accent has relative luminance above
117
+ * ~0.26 (CIE Y, sRGB → linear), Chrome darkens the unfilled track to
118
+ * `rgb(59, 59, 59)` instead of the default `rgb(239, 239, 239)`. Empirical
119
+ * probe (DM-320) of accents at 24 luminance points confirmed the threshold:
120
+ * #888888 (Y=0.246) → light, #16a34a (Y=0.269) → dark; switch line ≈ Y=0.26.
121
+ *
122
+ * Returns `TRACK_BG` (light) when accent is unset / 'auto' / dark, the dark
123
+ * variant when the accent is bright enough, and the original `TRACK_BG`
124
+ * fallback when the color string can't be parsed.
125
+ */
126
+ function unfilledTrackColor(accentCss, defCtx) {
127
+ // DM-553: scheme-aware default — under dark mode the track is already
128
+ // dark (rgb(59,59,59)), so the contrast-flip path collapses.
129
+ const palette = stockPalette(defCtx?.colorScheme);
130
+ if (accentCss == null || accentCss === "" || accentCss === "auto" || accentCss === "currentcolor")
131
+ return palette.trackBg;
132
+ // Extract sRGB triplet from rgb()/rgba() (Chrome canonicalises hex etc.).
133
+ const m = /rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(accentCss);
134
+ if (m == null)
135
+ return palette.trackBg;
136
+ const lin = (c) => {
137
+ const s = c / 255;
138
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
139
+ };
140
+ const Y = 0.2126 * lin(parseFloat(m[1])) + 0.7152 * lin(parseFloat(m[2])) + 0.0722 * lin(parseFloat(m[3]));
141
+ return Y > 0.26 ? "rgb(59,59,59)" : palette.trackBg;
142
+ }
143
+ export function renderFormControl(el, indent, defCtx) {
144
+ const tag = el.tag;
145
+ if (tag === "input")
146
+ return renderInputControl(el, indent, defCtx);
147
+ if (tag === "progress")
148
+ return renderProgress(el, indent, defCtx);
149
+ if (tag === "meter")
150
+ return renderMeter(el, indent, defCtx);
151
+ // Closed-dropdown selects: emit the selected-option text always, but the
152
+ // native chevron only when the page kept UA chrome (selectChevron). Pages
153
+ // using `appearance: none` + a CSS background-image arrow get just the
154
+ // text — the page's CSS chevron paints separately via the background-image
155
+ // pipeline. (DM-308)
156
+ if (tag === "select" && el.styles.selectDisplayText != null)
157
+ return renderSelectChevron(el, indent, defCtx);
158
+ if (tag === "select" && el.styles.selectListboxOptions != null)
159
+ return renderListbox(el, indent);
160
+ if (tag === "details")
161
+ return renderDetailsMarker(el, indent);
162
+ return "";
163
+ }
164
+ function renderInputControl(el, indent, defCtx) {
165
+ const t = el.styles.inputType ?? "text";
166
+ if (t === "checkbox")
167
+ return renderCheckbox(el, indent, defCtx);
168
+ if (t === "radio")
169
+ return renderRadio(el, indent, defCtx);
170
+ if (t === "range")
171
+ return renderRange(el, indent, defCtx);
172
+ if (t === "color")
173
+ return renderColorSwatch(el, indent, defCtx);
174
+ if (t === "file")
175
+ return renderFileInput(el, indent, defCtx);
176
+ if (t === "number")
177
+ return renderNumberInput(el, indent, defCtx);
178
+ if (t === "search")
179
+ return renderSearchInput(el, indent, defCtx);
180
+ if (t === "date" || t === "time" || t === "datetime-local" || t === "month" || t === "week") {
181
+ return renderDatePicker(el, indent);
182
+ }
183
+ // text-like inputs already render via the normal border+bg path
184
+ return "";
185
+ }
186
+ function renderCheckbox(el, indent, defCtx) {
187
+ // appearance: none → author has opted out of UA chrome. The host's normal
188
+ // element-rendering path already painted its bg + border with the captured
189
+ // styles; we just overlay the :checked indicator. Switch-shape (wide,
190
+ // pill-radius) renders as a toggle thumb instead of a checkmark. DM-285.
191
+ if (el.styles.inputAppearance === "none")
192
+ return renderCustomCheckboxOrSwitch(el, indent);
193
+ // 13x13 square with 2px radius, blue+check when checked, dash when indeterminate.
194
+ const palette = stockPalette(defCtx?.colorScheme);
195
+ const size = Math.min(el.width, el.height);
196
+ const cx = el.x + el.width / 2;
197
+ const cy = el.y + el.height / 2;
198
+ const x = cx - size / 2;
199
+ const y = cy - size / 2;
200
+ const parts = [];
201
+ const stroke = el.styles.disabled ? palette.disabledBorder : palette.border;
202
+ const accent = resolveAccent(el, defCtx);
203
+ if (el.styles.indeterminate === true) {
204
+ parts.push(`${indent}<rect x="${r(x)}" y="${r(y)}" width="${r(size)}" height="${r(size)}" rx="2" fill="${accent}" />`);
205
+ parts.push(`${indent}<rect x="${r(x + size * 0.2)}" y="${r(cy - size * 0.08)}" width="${r(size * 0.6)}" height="${r(size * 0.16)}" fill="#fff" />`);
206
+ }
207
+ else if (el.styles.checked === true) {
208
+ parts.push(`${indent}<rect x="${r(x)}" y="${r(y)}" width="${r(size)}" height="${r(size)}" rx="2" fill="${accent}" />`);
209
+ // Check mark path (two-segment tick).
210
+ const p = (dx, dy) => `${r(x + dx * size)},${r(y + dy * size)}`;
211
+ parts.push(`${indent}<polyline points="${p(0.22, 0.55)} ${p(0.42, 0.74)} ${p(0.78, 0.3)}" fill="none" stroke="#fff" stroke-width="${r(size * 0.14)}" stroke-linecap="round" stroke-linejoin="round" />`);
212
+ }
213
+ else {
214
+ parts.push(`${indent}<rect x="${r(x)}" y="${r(y)}" width="${r(size)}" height="${r(size)}" rx="2" fill="${palette.fill}" stroke="${stroke}" stroke-width="1" />`);
215
+ }
216
+ return parts.join("\n");
217
+ }
218
+ /**
219
+ * Render an `appearance: none` custom checkbox or switch. Distinguishes the
220
+ * switch shape (wide pill: aspect ratio > 1.5 + border-radius >= half-height)
221
+ * from the rectangular checkbox shape (square-ish + border-radius < half).
222
+ *
223
+ * The host rect (background / border) is already painted by the normal
224
+ * element-rendering path, so we only overlay the :checked indicator.
225
+ */
226
+ function renderCustomCheckboxOrSwitch(el, indent) {
227
+ const w = el.width;
228
+ const h = el.height;
229
+ const aspect = h > 0 ? w / h : 1;
230
+ const radiusStr = el.styles.borderRadius ?? "0";
231
+ const radius = parseFloat(radiusStr) || 0;
232
+ const isSwitch = aspect > 1.5 && radius >= h / 2 - 1;
233
+ if (isSwitch) {
234
+ // Pill switch: thumb circle 2px inset from each edge, anchored left when
235
+ // unchecked, right when checked. Thumb is white per common authoring
236
+ // (the .sw fixture's ::before { background: white }).
237
+ const inset = 2;
238
+ const thumbR = (h - inset * 2) / 2;
239
+ const cx = el.styles.checked === true
240
+ ? el.x + el.width - inset - thumbR
241
+ : el.x + inset + thumbR;
242
+ const cy = el.y + el.height / 2;
243
+ return `${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(thumbR)}" fill="#fff" />`;
244
+ }
245
+ // Custom checkbox. Indicator is a checkmark in the host's border color
246
+ // (the :checked rule typically swaps both bg and border to the same
247
+ // accent). Falls back to UA accent when the captured border is missing.
248
+ if (el.styles.checked !== true)
249
+ return "";
250
+ const indicatorColor = el.styles.borderTopColor ?? resolveAccent(el);
251
+ const size = Math.min(w, h);
252
+ const cx = el.x + w / 2;
253
+ const cy = el.y + h / 2;
254
+ const x = cx - size / 2;
255
+ const y = cy - size / 2;
256
+ const p = (dx, dy) => `${r(x + dx * size)},${r(y + dy * size)}`;
257
+ return `${indent}<polyline points="${p(0.22, 0.55)} ${p(0.42, 0.74)} ${p(0.78, 0.3)}" fill="none" stroke="${indicatorColor}" stroke-width="${r(Math.max(1.5, size * 0.14))}" stroke-linecap="round" stroke-linejoin="round" />`;
258
+ }
259
+ function renderRadio(el, indent, defCtx) {
260
+ // appearance: none → host rect already painted with author bg/border;
261
+ // overlay only the :checked dot in the captured border color. DM-285.
262
+ if (el.styles.inputAppearance === "none") {
263
+ if (el.styles.checked !== true)
264
+ return "";
265
+ const size = Math.min(el.width, el.height);
266
+ const cx = el.x + el.width / 2;
267
+ const cy = el.y + el.height / 2;
268
+ const dotColor = el.styles.borderTopColor ?? resolveAccent(el, defCtx);
269
+ return `${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(size * 0.25)}" fill="${dotColor}" />`;
270
+ }
271
+ const palette = stockPalette(defCtx?.colorScheme);
272
+ const size = Math.min(el.width, el.height);
273
+ const cx = el.x + el.width / 2;
274
+ const cy = el.y + el.height / 2;
275
+ const rr = size / 2;
276
+ const parts = [];
277
+ const stroke = el.styles.disabled ? palette.disabledBorder : palette.border;
278
+ const accent = resolveAccent(el, defCtx);
279
+ if (el.styles.checked === true) {
280
+ // Chrome's checked native radio is a donut: thin accent-colored outer
281
+ // ring (~1px at 13px diameter), white middle, accent-colored center dot
282
+ // (~0.5 of the radius). Three concentric circles. (DM-292)
283
+ parts.push(`${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(rr)}" fill="${accent}" />`);
284
+ parts.push(`${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(rr - 1)}" fill="#fff" />`);
285
+ parts.push(`${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(rr * 0.5)}" fill="${accent}" />`);
286
+ }
287
+ else {
288
+ parts.push(`${indent}<circle cx="${r(cx)}" cy="${r(cy)}" r="${r(rr - 0.5)}" fill="${palette.fill}" stroke="${stroke}" stroke-width="1" />`);
289
+ }
290
+ return parts.join("\n");
291
+ }
292
+ function renderRange(el, indent, defCtx) {
293
+ // DM-553: scheme-aware UA defaults — rangeUA fill / track inherit dark
294
+ // palette when the page was captured under color-scheme: dark.
295
+ const palette = stockPalette(defCtx?.colorScheme);
296
+ // Horizontal track + circular thumb (UA default), or author-styled track
297
+ // and thumb when CAPTURE_SCRIPT detected ::-webkit-slider-runnable-track
298
+ // / ::-webkit-slider-thumb diverging from the unstyled-range reference
299
+ // baseline (SK-1131 + SK-1137). The detection lives in CAPTURE_SCRIPT —
300
+ // here we only react to which of rangeTrack* / rangeThumb* are populated.
301
+ // Gradient backgrounds (SK-1224) on track/thumb resolve through defCtx
302
+ // into a top-level <linearGradient> def referenced via fill="url(#...)".
303
+ //
304
+ // Vertical writing-mode (DM-276) flips the layout axis: the track runs
305
+ // top-to-bottom inside `el`, and `direction: rtl` puts low value at the
306
+ // bottom (matching the test fixture and Chrome's painted behavior for
307
+ // `<input type=range>` with `writing-mode: vertical-*`).
308
+ const s = el.styles;
309
+ const styledTrack = s.rangeTrackBg != null;
310
+ const styledThumb = s.rangeThumbWidth != null;
311
+ // Native UA range slider — empirically Chrome paints a ~6px solid track
312
+ // (8px painted total with AA) and a 16px-diameter thumb regardless of bbox
313
+ // height (verified via probe-range-native-track.mjs against bbox h=16/20).
314
+ // Earlier values trackThickness=4 / thumbW=14 left a ~2-3px diff visible on
315
+ // the accent-color re-hue sliders. DM-338.
316
+ const trackThickness = styledTrack ? (parseFloat(s.rangeTrackHeight ?? "") || 4) : 6;
317
+ const trackR = styledTrack ? (parseFloat(s.rangeTrackRadius ?? "") || 0) : 2;
318
+ // Unfilled-track color: author-set when the slider is `appearance: none` +
319
+ // styled track, otherwise the UA default which depends on `accent-color`
320
+ // (Chrome darkens the unfilled track when the accent is bright — DM-320).
321
+ const trackBgColor = styledTrack && s.rangeTrackBg !== "rgba(0, 0, 0, 0)" ? s.rangeTrackBg : unfilledTrackColor(s.accentColor, defCtx);
322
+ const thumbW = styledThumb ? (parseFloat(s.rangeThumbWidth ?? "") || 14) : 16;
323
+ const thumbH = styledThumb ? (parseFloat(s.rangeThumbHeight ?? "") || thumbW) : 16;
324
+ const thumbRadius = styledThumb ? (parseFloat(s.rangeThumbRadius ?? "") || thumbW / 2) : thumbW / 2;
325
+ const accent = resolveAccent(el, defCtx);
326
+ const valStr = s.inputValue;
327
+ const minStr = s.inputMin;
328
+ const maxStr = s.inputMax;
329
+ const val = valStr != null && valStr !== "" ? parseFloat(valStr) : NaN;
330
+ const min = minStr != null && minStr !== "" ? parseFloat(minStr) : 0;
331
+ const max = maxStr != null && maxStr !== "" ? parseFloat(maxStr) : 100;
332
+ const ratio = !isNaN(val) && max > min ? Math.max(0, Math.min(1, (val - min) / (max - min))) : 0.5;
333
+ const isVertical = s.writingMode != null && s.writingMode !== "" && s.writingMode !== "horizontal-tb";
334
+ const parts = [];
335
+ // Geometry: in horizontal mode the track is along x (length = el.width),
336
+ // thumb moves in x. In vertical mode track is along y (length = el.height),
337
+ // thumb moves in y. `direction: rtl` flips the value axis so low is at
338
+ // the visual end of the track (right for horizontal, bottom for vertical).
339
+ // Round box edges to integer device pixels (DM-433) so the track / fill
340
+ // rects land on Chrome's pixel grid — same alignment Chrome's UA paint
341
+ // applies. `getBoundingClientRect()` returns fractional coords for inputs
342
+ // laid out in flex / inline contexts; without rounding, even-thickness
343
+ // tracks render across 3 antialiased rows instead of 2 solid rows.
344
+ const elL = Math.round(el.x);
345
+ const elT = Math.round(el.y);
346
+ const elR = Math.round(el.x + el.width);
347
+ const elB = Math.round(el.y + el.height);
348
+ const elW = elR - elL;
349
+ const elH = elB - elT;
350
+ let trackRect;
351
+ let fillRect;
352
+ let thumbCx;
353
+ let thumbCy;
354
+ // Track spans the FULL element width / height in Chrome (verified via
355
+ // probe-range-track.mjs against painted output for both UA and appearance:
356
+ // none + custom track). Earlier we shortened the track by ±halfThumb on each
357
+ // end, leaving it ~22 px narrower than Chrome's painted track for the gradient
358
+ // sliders in DM-409. The thumb still travels within an inset range so its
359
+ // center stays inside the track bounds at value=min/max.
360
+ if (isVertical) {
361
+ const halfThumb = thumbH / 2;
362
+ const trackX = elL + elW / 2 - trackThickness / 2;
363
+ const thumbTravelTop = elT + halfThumb;
364
+ const thumbTravelBottom = elT + elH - halfThumb;
365
+ const lowAtBottom = s.direction === "rtl";
366
+ const fromTop = lowAtBottom ? (1 - ratio) : ratio;
367
+ thumbCx = elL + elW / 2;
368
+ thumbCy = thumbTravelTop + (thumbTravelBottom - thumbTravelTop) * fromTop;
369
+ trackRect = { x: trackX, y: elT, w: trackThickness, h: elH };
370
+ if (lowAtBottom) {
371
+ fillRect = { x: trackX, y: thumbCy, w: trackThickness, h: Math.max(0, elB - thumbCy) };
372
+ }
373
+ else {
374
+ fillRect = { x: trackX, y: elT, w: trackThickness, h: Math.max(0, thumbCy - elT) };
375
+ }
376
+ }
377
+ else {
378
+ const halfThumb = thumbW / 2;
379
+ const cy = elT + elH / 2;
380
+ const trackY = cy - trackThickness / 2;
381
+ const thumbTravelLeft = elL + halfThumb;
382
+ const thumbTravelRight = elR - halfThumb;
383
+ thumbCy = cy;
384
+ thumbCx = thumbTravelLeft + (thumbTravelRight - thumbTravelLeft) * ratio;
385
+ trackRect = { x: elL, y: trackY, w: elW, h: trackThickness };
386
+ fillRect = { x: elL, y: trackY, w: Math.max(0, thumbCx - elL), h: trackThickness };
387
+ }
388
+ const trackGradFill = gradientFillFor(s.rangeTrackBgImage, trackRect, defCtx);
389
+ // CSS background layering: when both a gradient image and a background
390
+ // color are declared, the color paints first and the gradient overlays.
391
+ // For opaque non-repeating gradients this is invisible, but a repeating
392
+ // gradient with transparent stops (e.g. tick-marks track) reveals the
393
+ // color between stripes (DM-275).
394
+ if (trackGradFill != null && styledTrack && s.rangeTrackBg !== "rgba(0, 0, 0, 0)" && s.rangeTrackBg != null && s.rangeTrackBg !== "") {
395
+ parts.push(`${indent}<rect x="${r(trackRect.x)}" y="${r(trackRect.y)}" width="${r(trackRect.w)}" height="${r(trackRect.h)}" rx="${r(trackR)}" fill="${s.rangeTrackBg}" />`);
396
+ }
397
+ const trackFill = trackGradFill ?? trackBgColor;
398
+ // Native UA range slider paints a 1px gray border around the track.
399
+ // Empirical Chrome paint at 18px sans-serif: rgb(204,204,204) 1px stroke.
400
+ // Author-styled tracks (rangeTrackBg set) skip this — the author owns
401
+ // the visual chrome. DM-409.
402
+ const trackStroke = !styledTrack ? ` stroke="rgb(204,204,204)" stroke-width="1"` : "";
403
+ parts.push(`${indent}<rect x="${r(trackRect.x)}" y="${r(trackRect.y)}" width="${r(trackRect.w)}" height="${r(trackRect.h)}" rx="${r(trackR)}" fill="${trackFill}"${trackStroke} />`);
404
+ // UA default paints an accent-colored fill from the track origin to the
405
+ // thumb. Author-styled tracks usually replace this with their own
406
+ // background, so skip the accent fill when the track was overridden.
407
+ if (!styledTrack) {
408
+ parts.push(`${indent}<rect x="${r(fillRect.x)}" y="${r(fillRect.y)}" width="${r(fillRect.w)}" height="${r(fillRect.h)}" rx="${r(trackR)}" fill="${accent}" />`);
409
+ }
410
+ // Parse author thumb border (e.g. "2px solid white") for styled sliders. When
411
+ // the pseudo doesn't declare a border, fall through to the UA stroke for
412
+ // unstyled UA thumbs and to no stroke for styled thumbs (Chrome paints the
413
+ // styled thumb borderless unless the author asks for one). DM-273.
414
+ const thumbBorder = parseBorderShorthand(s.rangeThumbBorder);
415
+ // Author-styled non-square thumb: render as a rect (matches rectangular
416
+ // and pill-shaped thumbs). Default UA thumb is a circle.
417
+ if (styledThumb && (thumbH !== thumbW || thumbRadius < Math.min(thumbW, thumbH) / 2)) {
418
+ const thumbBgColor = s.rangeThumbBg != null && s.rangeThumbBg !== "" && s.rangeThumbBg !== "rgba(0, 0, 0, 0)" ? s.rangeThumbBg : palette.fill;
419
+ const thumbRect = { x: thumbCx - thumbW / 2, y: thumbCy - thumbH / 2, w: thumbW, h: thumbH };
420
+ const thumbGradFill = gradientFillFor(s.rangeThumbBgImage, thumbRect, defCtx);
421
+ const thumbFill = thumbGradFill ?? thumbBgColor;
422
+ const strokeAttrs = thumbBorder != null ? ` stroke="${thumbBorder.color}" stroke-width="${thumbBorder.width}"` : "";
423
+ parts.push(`${indent}<rect x="${r(thumbRect.x)}" y="${r(thumbRect.y)}" width="${r(thumbW)}" height="${r(thumbH)}" rx="${r(thumbRadius)}" fill="${thumbFill}"${strokeAttrs} />`);
424
+ }
425
+ else if (styledThumb) {
426
+ const halfThumb = thumbW / 2;
427
+ const thumbBgColor = s.rangeThumbBg != null && s.rangeThumbBg !== "" && s.rangeThumbBg !== "rgba(0, 0, 0, 0)" ? s.rangeThumbBg : palette.fill;
428
+ const thumbRect = { x: thumbCx - halfThumb, y: thumbCy - halfThumb, w: thumbW, h: thumbW };
429
+ const thumbGradFill = gradientFillFor(s.rangeThumbBgImage, thumbRect, defCtx);
430
+ const thumbFill = thumbGradFill ?? thumbBgColor;
431
+ // Donut-effect outer ring (DM-319). When the thumb pseudo has a
432
+ // `box-shadow: 0 0 0 Npx <color>` (spread-only inset-less shadow), Chrome
433
+ // paints an extra ring of width N around the thumb's outer edge — the
434
+ // visible "green outer ring" on the tick-marks slider. Layering matches
435
+ // CSS paint order: shadow (bottom) → background-fill → border (top).
436
+ // SVG's `stroke` is path-centered, so to keep the border drawn ENTIRELY
437
+ // inside the thumb's box (Chrome's behavior), we shrink the inner-fill
438
+ // circle's path radius by `borderWidth/2` and add the same back to the
439
+ // stroke width — that puts the stroke band between r=halfThumb-border and
440
+ // r=halfThumb, leaving the outer-ring band (halfThumb..halfThumb+spread)
441
+ // free for the box-shadow.
442
+ const ringShadow = parseSpreadOnlyShadow(s.rangeThumbBoxShadow);
443
+ if (ringShadow != null) {
444
+ const ringR = halfThumb + ringShadow.spread;
445
+ parts.push(`${indent}<circle cx="${r(thumbCx)}" cy="${r(thumbCy)}" r="${r(ringR)}" fill="${ringShadow.color}" />`);
446
+ }
447
+ if (thumbBorder != null) {
448
+ const innerR = Math.max(0, halfThumb - thumbBorder.width / 2);
449
+ parts.push(`${indent}<circle cx="${r(thumbCx)}" cy="${r(thumbCy)}" r="${r(innerR)}" fill="${thumbFill}" stroke="${thumbBorder.color}" stroke-width="${thumbBorder.width}" />`);
450
+ }
451
+ else {
452
+ parts.push(`${indent}<circle cx="${r(thumbCx)}" cy="${r(thumbCy)}" r="${r(halfThumb)}" fill="${thumbFill}" />`);
453
+ }
454
+ }
455
+ else {
456
+ // Native (UA-default) range thumb. Chrome paints a filled accent-colored
457
+ // circle, not a hollow white-with-gray-border one. Disabled state mutes
458
+ // it via the host opacity Chrome already applies. DM-273.
459
+ const halfThumb = thumbW / 2;
460
+ parts.push(`${indent}<circle cx="${r(thumbCx)}" cy="${r(thumbCy)}" r="${r(halfThumb)}" fill="${accent}" />`);
461
+ }
462
+ return parts.join("\n");
463
+ }
464
+ /**
465
+ * Parse a CSS `box-shadow` value of the spread-only form
466
+ * `<color> 0px 0px 0px <Npx>` or `0px 0px 0px <Npx> <color>`
467
+ * (Chrome canonicalizes either author syntax to the color-first form). Used
468
+ * by `renderRange` to detect the donut-ring author pattern
469
+ * `box-shadow: 0 0 0 1px <color>`
470
+ * on `::-webkit-slider-thumb` (DM-319). Returns `{ spread, color }` for the
471
+ * single-shadow spread-only case; null for missing / `none` / multi-shadow /
472
+ * any-non-zero-offset / any-non-zero-blur.
473
+ */
474
+ function parseSpreadOnlyShadow(value) {
475
+ if (value == null || value === "" || value === "none")
476
+ return null;
477
+ // Multi-shadow lists are comma-separated; we only handle single-shadow so
478
+ // bail when more than one comma at the top level (parens nesting in `rgb(...)`
479
+ // is fine because we tokenize with a depth counter via the regex below).
480
+ // Pattern: optional color prefix, then four <length> tokens, then optional
481
+ // color suffix. The four lengths are x / y / blur / spread.
482
+ const m = /^\s*(?:(rgba?\([^)]+\)|#[0-9a-fA-F]+|\w+)\s+)?(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px(?:\s+(rgba?\([^)]+\)|#[0-9a-fA-F]+|\w+))?\s*$/.exec(value.trim());
483
+ if (m == null)
484
+ return null;
485
+ const colorPrefix = m[1];
486
+ const x = parseFloat(m[2]);
487
+ const y = parseFloat(m[3]);
488
+ const blur = parseFloat(m[4]);
489
+ const spread = parseFloat(m[5]);
490
+ const colorSuffix = m[6];
491
+ const color = colorPrefix ?? colorSuffix;
492
+ if (color == null || color === "" || /^(?:inset|none)$/i.test(color))
493
+ return null;
494
+ if (x !== 0 || y !== 0 || blur !== 0)
495
+ return null;
496
+ if (!isFinite(spread) || spread <= 0)
497
+ return null;
498
+ return { spread, color };
499
+ }
500
+ /** Parse a CSS `border` shorthand like `"2px solid white"` into a width/color
501
+ * pair. Returns null when the input is missing, "none", or unparseable. */
502
+ function parseBorderShorthand(border) {
503
+ if (border == null || border === "" || /\bnone\b/.test(border))
504
+ return null;
505
+ const m = /^([\d.]+)px\s+(\w+)\s+(.+)$/.exec(border.trim());
506
+ if (m == null || m[2] === "none")
507
+ return null;
508
+ const w = parseFloat(m[1]);
509
+ if (!isFinite(w) || w <= 0)
510
+ return null;
511
+ return { width: w, color: m[3] };
512
+ }
513
+ function renderColorSwatch(el, indent, defCtx) {
514
+ // Button-like rounded rect with a colored inner swatch. el.value is a
515
+ // '#rrggbb' string (default #000000). Author CSS on the host or
516
+ // ::-webkit-color-swatch / ::-webkit-color-swatch-wrapper pseudos
517
+ // (captured via the SK-1223 stylesheet walker) overrides the default
518
+ // wrapper border/radius and inner swatch styling when present.
519
+ // DM-553: scheme-aware UA defaults.
520
+ const palette = stockPalette(defCtx?.colorScheme);
521
+ const parts = [];
522
+ const s = el.styles;
523
+ const value = s.inputValue && /^#[0-9a-f]{6}$/i.test(s.inputValue) ? s.inputValue : "#000000";
524
+ // Outer wrapper: host bg/border (already painted by the normal element
525
+ // path) provides the chrome, plus ::-webkit-color-swatch-wrapper padding
526
+ // adjusts the inset of the inner swatch.
527
+ let pad = 4;
528
+ if (s.colorSwatchWrapperPadding != null && s.colorSwatchWrapperPadding !== "") {
529
+ const tok = s.colorSwatchWrapperPadding.trim().split(/\s+/).map((p) => parseFloat(p) || 0);
530
+ if (tok.length >= 1)
531
+ pad = tok[0];
532
+ }
533
+ // Author hasn't styled the wrapper via host CSS — paint UA defaults.
534
+ const hostHasBg = (s.backgroundColor != null && s.backgroundColor !== "" && s.backgroundColor !== "transparent" && !/^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(s.backgroundColor));
535
+ if (!hostHasBg) {
536
+ parts.push(`${indent}<rect x="${r(el.x)}" y="${r(el.y)}" width="${r(el.width)}" height="${r(el.height)}" rx="3" fill="${palette.fill}" stroke="${palette.border}" stroke-width="1" />`);
537
+ }
538
+ // Inner swatch: prefer ::-webkit-color-swatch background-color/image when
539
+ // authored, otherwise paint the input's value color.
540
+ const swatchRect = { x: el.x + pad, y: el.y + pad, w: el.width - pad * 2, h: el.height - pad * 2 };
541
+ const swatchGrad = gradientFillFor(s.colorSwatchBgImage, swatchRect, defCtx);
542
+ const swatchFill = swatchGrad ?? (s.colorSwatchBg != null && s.colorSwatchBg !== "" ? s.colorSwatchBg : value);
543
+ const radius = s.colorSwatchRadius != null && s.colorSwatchRadius !== "" ? parseFloat(s.colorSwatchRadius) || 0 : 0;
544
+ // Border on the swatch (e.g. authors set ::-webkit-color-swatch { border: 2px solid gray }).
545
+ let borderAttrs = "";
546
+ if (s.colorSwatchBorder != null && s.colorSwatchBorder !== "") {
547
+ const m = /^([\d.]+)px\s+(\w+)\s+(.+)$/.exec(s.colorSwatchBorder);
548
+ if (m != null && m[2] !== "none") {
549
+ borderAttrs = ` stroke="${m[3]}" stroke-width="${m[1]}"`;
550
+ }
551
+ }
552
+ parts.push(`${indent}<rect x="${r(swatchRect.x)}" y="${r(swatchRect.y)}" width="${r(swatchRect.w)}" height="${r(swatchRect.h)}" rx="${r(radius)}" fill="${swatchFill}"${borderAttrs} />`);
553
+ return parts.join("\n");
554
+ }
555
+ /**
556
+ * <input type=number>: paint the ::-webkit-inner-spin-button chrome on the
557
+ * right edge — a small box with up/down arrow chevrons. Author rules on
558
+ * ::-webkit-inner-spin-button (captured via the SK-1223 stylesheet walker)
559
+ * override the UA defaults for background / border / radius.
560
+ */
561
+ function renderNumberInput(el, indent, defCtx) {
562
+ const s = el.styles;
563
+ // Like the search-cancel button, Chrome only paints the spin buttons when
564
+ // the input is hovered or focused. Static-screenshot captures see no
565
+ // hover/focus, so by default the spin chrome must be invisible. Only emit
566
+ // when the author put explicit rules on `::-webkit-inner-spin-button` —
567
+ // those force the visibility on. (DM-289)
568
+ const hasAuthorPseudo = (s.numberSpinButtonBg != null && s.numberSpinButtonBg !== "")
569
+ || (s.numberSpinButtonBorder != null && s.numberSpinButtonBorder !== "")
570
+ || (s.numberSpinButtonRadius != null && s.numberSpinButtonRadius !== "");
571
+ if (!hasAuthorPseudo)
572
+ return "";
573
+ const parts = [];
574
+ // Spin button geometry: ~14px wide, full input height minus 1px inset on
575
+ // each edge so the box sits inside the input's border.
576
+ const w = 14;
577
+ const x = el.x + el.width - w - 1;
578
+ const y = el.y + 1;
579
+ const h = Math.max(0, el.height - 2);
580
+ if (h <= 0)
581
+ return "";
582
+ const radius = s.numberSpinButtonRadius != null && s.numberSpinButtonRadius !== ""
583
+ ? parseFloat(s.numberSpinButtonRadius) || 0 : 0;
584
+ const bgColor = s.numberSpinButtonBg != null && s.numberSpinButtonBg !== ""
585
+ ? s.numberSpinButtonBg : "rgb(244, 244, 244)";
586
+ // Background: gradient (rare but supported via the shared gradient pipeline).
587
+ const bgGrad = gradientFillFor(undefined, { x, y, w, h }, defCtx);
588
+ const fill = bgGrad ?? bgColor;
589
+ // Border parsing: "Wpx <style> <color>". 'none' suppresses the stroke.
590
+ let strokeAttrs = "";
591
+ if (s.numberSpinButtonBorder != null && s.numberSpinButtonBorder !== "") {
592
+ const m = /^([\d.]+)px\s+(\w+)\s+(.+)$/.exec(s.numberSpinButtonBorder);
593
+ if (m != null && m[2] !== "none") {
594
+ strokeAttrs = ` stroke="${m[3]}" stroke-width="${m[1]}"`;
595
+ }
596
+ }
597
+ parts.push(`${indent}<rect x="${r(x)}" y="${r(y)}" width="${r(w)}" height="${r(h)}" rx="${r(radius)}" fill="${fill}"${strokeAttrs} />`);
598
+ // Up + down arrow chevrons centered horizontally within the box, at the
599
+ // 1/4 and 3/4 vertical positions. Subtle gray strokes.
600
+ const cx = x + w / 2;
601
+ const upY = y + h / 4;
602
+ const downY = y + (h * 3) / 4;
603
+ const arm = Math.min(3, h / 6);
604
+ parts.push(`${indent}<polyline points="${r(cx - arm)},${r(upY + arm * 0.5)} ${r(cx)},${r(upY - arm * 0.5)} ${r(cx + arm)},${r(upY + arm * 0.5)}" fill="none" stroke="rgb(110,110,110)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />`);
605
+ parts.push(`${indent}<polyline points="${r(cx - arm)},${r(downY - arm * 0.5)} ${r(cx)},${r(downY + arm * 0.5)} ${r(cx + arm)},${r(downY - arm * 0.5)}" fill="none" stroke="rgb(110,110,110)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" />`);
606
+ return parts.join("\n");
607
+ }
608
+ /**
609
+ * <input type=search>: paint the ::-webkit-search-cancel-button chrome
610
+ * (the "X" reset button) on the right edge. Chrome only shows the cancel
611
+ * button when the input is hovered or focused — for a static-screenshot
612
+ * capture neither state is in effect, so we only emit the chrome when the
613
+ * page has explicit author rules on the pseudo (those override Chrome's
614
+ * default visibility). Without this guard our default cancel button stamps
615
+ * an "X" on every search input that has a value, while Chrome paints
616
+ * nothing. (DM-289)
617
+ */
618
+ function renderSearchInput(el, indent, defCtx) {
619
+ const s = el.styles;
620
+ if (s.inputValue == null || s.inputValue === "")
621
+ return "";
622
+ const hasAuthorPseudo = (s.searchCancelButtonBg != null && s.searchCancelButtonBg !== "")
623
+ || (s.searchCancelButtonBorder != null && s.searchCancelButtonBorder !== "")
624
+ || (s.searchCancelButtonRadius != null && s.searchCancelButtonRadius !== "");
625
+ if (!hasAuthorPseudo)
626
+ return "";
627
+ const parts = [];
628
+ const size = Math.min(14, el.height - 4);
629
+ if (size <= 0)
630
+ return "";
631
+ const x = el.x + el.width - size - 4;
632
+ const y = el.y + (el.height - size) / 2;
633
+ const radius = s.searchCancelButtonRadius != null && s.searchCancelButtonRadius !== ""
634
+ ? parseFloat(s.searchCancelButtonRadius) || size / 2 : size / 2;
635
+ const bgColor = s.searchCancelButtonBg != null && s.searchCancelButtonBg !== ""
636
+ ? s.searchCancelButtonBg : "rgb(180, 180, 180)";
637
+ const bgGrad = gradientFillFor(undefined, { x, y, w: size, h: size }, defCtx);
638
+ const fill = bgGrad ?? bgColor;
639
+ let strokeAttrs = "";
640
+ if (s.searchCancelButtonBorder != null && s.searchCancelButtonBorder !== "") {
641
+ const m = /^([\d.]+)px\s+(\w+)\s+(.+)$/.exec(s.searchCancelButtonBorder);
642
+ if (m != null && m[2] !== "none") {
643
+ strokeAttrs = ` stroke="${m[3]}" stroke-width="${m[1]}"`;
644
+ }
645
+ }
646
+ parts.push(`${indent}<rect x="${r(x)}" y="${r(y)}" width="${r(size)}" height="${r(size)}" rx="${r(radius)}" fill="${fill}"${strokeAttrs} />`);
647
+ // White "X" centered.
648
+ const cx = x + size / 2;
649
+ const cy = y + size / 2;
650
+ const arm = size * 0.25;
651
+ parts.push(`${indent}<line x1="${r(cx - arm)}" y1="${r(cy - arm)}" x2="${r(cx + arm)}" y2="${r(cy + arm)}" stroke="white" stroke-width="1.5" stroke-linecap="round" />`);
652
+ parts.push(`${indent}<line x1="${r(cx + arm)}" y1="${r(cy - arm)}" x2="${r(cx - arm)}" y2="${r(cy + arm)}" stroke="white" stroke-width="1.5" stroke-linecap="round" />`);
653
+ return parts.join("\n");
654
+ }
655
+ /**
656
+ * <input type=file>: emit a 'Choose File' button + filename label. Styling
657
+ * comes from the captured ::file-selector-button pseudo-element so author
658
+ * CSS (background, color, border, padding, border-radius) carries through.
659
+ * Falls back to the Chromium UA defaults when the pseudo isn't customized.
660
+ */
661
+ function renderFileInput(el, indent, defCtx) {
662
+ // DM-553: scheme-aware UA border default for the 'Choose File' chrome.
663
+ const palette = stockPalette(defCtx?.colorScheme);
664
+ const parts = [];
665
+ const s = el.styles;
666
+ // Visually-hidden file inputs (label-wrapped pattern: opacity:0 or
667
+ // width/height clipped to 1px) shouldn't render the synthesized 'Choose
668
+ // File' chrome — the label is the visible UI and our chrome would stamp
669
+ // ugly overlapping text on top. DM-271.
670
+ const isHidden = el.width <= 2 || el.height <= 2 || s.opacity === "0";
671
+ if (isHidden)
672
+ return "";
673
+ // Resolve styles from the captured pseudo, with UA defaults as fallback.
674
+ const bg = s.fileButtonBg != null && s.fileButtonBg !== "" ? s.fileButtonBg : "rgb(239,239,239)";
675
+ const color = s.fileButtonColor != null && s.fileButtonColor !== "" ? s.fileButtonColor : "rgb(0,0,0)";
676
+ const rawRadius = s.fileButtonBorderRadius != null ? (parseFloat(s.fileButtonBorderRadius) || 3) : 3;
677
+ // Border: parse "Wpx <style> <color>" — only the width matters for our paint.
678
+ let borderW = 1;
679
+ let borderColor = palette.border;
680
+ if (s.fileButtonBorder != null) {
681
+ const m = /^([\d.]+)px\s+(\w+)\s+(.+)$/.exec(s.fileButtonBorder);
682
+ if (m != null) {
683
+ borderW = parseFloat(m[1]) || 0;
684
+ if (m[2] === "none")
685
+ borderW = 0;
686
+ else
687
+ borderColor = m[3];
688
+ }
689
+ }
690
+ // Padding: parse "Tpx Rpx Bpx Lpx" (or shorthand) for vertical/horizontal.
691
+ let padV = 4, padH = 8;
692
+ if (s.fileButtonPadding != null) {
693
+ const tok = s.fileButtonPadding.trim().split(/\s+/).map((p) => parseFloat(p) || 0);
694
+ if (tok.length >= 1) {
695
+ padV = tok[0];
696
+ padH = tok[0];
697
+ }
698
+ if (tok.length >= 2) {
699
+ padH = tok[1];
700
+ }
701
+ if (tok.length >= 4) {
702
+ padH = (tok[1] + tok[3]) / 2;
703
+ }
704
+ }
705
+ const fontWeight = s.fileButtonFontWeight != null && s.fileButtonFontWeight !== "" ? s.fileButtonFontWeight : "400";
706
+ // Chrome's UA default font-size for ::file-selector-button is 13.333px (it
707
+ // inherits from the input chrome, not from the page). When the author sets
708
+ // `font: inherit` on the pseudo (the f-primary / f-outline patterns in
709
+ // 06-forms-style-file), this becomes the body's font-size — typically 16px.
710
+ // Reading the captured pseudo font-size makes us match either case.
711
+ const fontSize = s.fileButtonFontSize != null && s.fileButtonFontSize !== ""
712
+ ? (parseFloat(s.fileButtonFontSize) || 13)
713
+ : 13;
714
+ const fontFamily = s.fileButtonFontFamily != null && s.fileButtonFontFamily !== ""
715
+ ? s.fileButtonFontFamily
716
+ : "-apple-system, system-ui, sans-serif";
717
+ // Chrome's UA ::file-selector-button has `margin-inline-end: 4px` (4px gap
718
+ // before the trailing "No file chosen" placeholder), but the test fixture
719
+ // overrides this to `margin-right: 12px`. Read the captured pseudo
720
+ // marginRight and use it as the gap; default to 4 when unset. DM-288.
721
+ const marginRight = s.fileButtonMarginRight != null && s.fileButtonMarginRight !== ""
722
+ ? (parseFloat(s.fileButtonMarginRight) || 4)
723
+ : 4;
724
+ // <input type=file multiple> labels as "Choose Files" (Chrome).
725
+ const labelText = s.inputMultiple === true ? "Choose Files" : "Choose File";
726
+ // Use the canvas-measureText'\''d label width when the capture provided one
727
+ // (it'\''s painted at sub-pixel exact width from Chrome'\''s actual font);
728
+ // fall back to the cheap per-char ratio otherwise (e.g. animated frames
729
+ // captured before measureText was available).
730
+ const textW = s.fileButtonLabelWidth != null && s.fileButtonLabelWidth > 0
731
+ ? s.fileButtonLabelWidth
732
+ : labelText.length * fontSize * 0.6;
733
+ const btnW = textW + padH * 2;
734
+ const btnH = Math.min(fontSize + padV * 2, el.height);
735
+ const bx = el.x + 2;
736
+ const by = el.y + (el.height - btnH) / 2;
737
+ // Clamp the captured border-radius to half-extents so a `border-radius: 999px`
738
+ // pill doesn't become an ellipse via SVG's per-axis rx/ry default-equality
739
+ // rule (rx=999 with ry unset → ry=999 → ry clamps to btnH/2 independent of
740
+ // rx clamping to btnW/2 → ellipse ends, not semicircles). DM-271.
741
+ const radius = Math.min(rawRadius, btnW / 2, btnH / 2);
742
+ const strokeAttrs = borderW > 0 ? ` stroke="${borderColor}" stroke-width="${borderW}"` : "";
743
+ parts.push(`${indent}<rect x="${r(bx)}" y="${r(by)}" width="${r(btnW)}" height="${r(btnH)}" rx="${r(radius)}" fill="${bg}"${strokeAttrs} />`);
744
+ // Baseline offset inside the button: ~0.35*fontSize below the vertical center
745
+ // matches Helvetica/sans-serif baseline placement at small sizes.
746
+ const baselineOffset = fontSize * 0.35;
747
+ parts.push(`${indent}<text x="${r(bx + btnW / 2)}" y="${r(by + btnH / 2 + baselineOffset)}" text-anchor="middle" font-size="${fontSize}" font-weight="${fontWeight}" font-family="${fontFamily.replace(/"/g, "&quot;")}" fill="${color}">${labelText}</text>`);
748
+ const label = el.styles.inputFileName != null && el.styles.inputFileName !== "" ? el.styles.inputFileName : "No file chosen";
749
+ parts.push(`${indent}<text x="${r(bx + btnW + marginRight)}" y="${r(by + btnH / 2 + baselineOffset)}" font-size="${fontSize}" font-family="${fontFamily.replace(/"/g, "&quot;")}" fill="rgb(0,0,0)">${label.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))}</text>`);
750
+ return parts.join("\n");
751
+ }
752
+ /**
753
+ * Date/time/datetime-local/month/week/color picker chrome: show the captured
754
+ * value as text plus a small picker indicator on the right.
755
+ */
756
+ function renderDatePicker(el, indent) {
757
+ const parts = [];
758
+ const t = el.styles.inputType ?? "date";
759
+ const val = el.styles.inputValue ?? "";
760
+ const tx = el.x + 6;
761
+ const ty = el.y + el.height / 2 + 4;
762
+ // Chrome renders date inputs with an en-US-formatted display value: dates
763
+ // as MM/DD/YYYY, times as hh:mm AM/PM, etc. The captured `inputValue` is
764
+ // the canonical ISO form (`2026-04-21`). DM-263.
765
+ const display = formatDateInputDisplay(t, val);
766
+ if (display !== "") {
767
+ // Chrome paints date input values in a tabular monospaced face; we route
768
+ // through the system mono fallback so the segments don't kern.
769
+ parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="11" font-family="ui-monospace, Menlo, monospace" fill="rgb(0,0,0)">${display.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))}</text>`);
770
+ }
771
+ // Picker icon on the right edge: calendar for date / month / week / datetime-local,
772
+ // clock for time. Chrome paints these monochrome at ~14px in the input's
773
+ // line-height. DM-263.
774
+ const cx = el.x + el.width - 12;
775
+ const cy = el.y + el.height / 2;
776
+ const iconSize = Math.min(11, el.height - 6);
777
+ if (t === "time") {
778
+ parts.push(renderClockIcon(indent, cx, cy, iconSize));
779
+ }
780
+ else {
781
+ parts.push(renderCalendarIcon(indent, cx, cy, iconSize));
782
+ }
783
+ return parts.join("\n");
784
+ }
785
+ /** Format an ISO date-input value into Chrome's en-US display string.
786
+ * Falls back to the raw value when parsing fails (preserves the original
787
+ * rendering for unrecognized inputs). DM-263. */
788
+ function formatDateInputDisplay(type, val) {
789
+ if (val === "")
790
+ return "";
791
+ if (type === "date") {
792
+ // YYYY-MM-DD → MM/DD/YYYY
793
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(val);
794
+ if (m == null)
795
+ return val;
796
+ return `${m[2]}/${m[3]}/${m[1]}`;
797
+ }
798
+ if (type === "time") {
799
+ // HH:MM[:SS] (24h) → hh:mm AM/PM (12h)
800
+ const m = /^(\d{2}):(\d{2})/.exec(val);
801
+ if (m == null)
802
+ return val;
803
+ const h24 = parseInt(m[1], 10);
804
+ const mm = m[2];
805
+ const ampm = h24 >= 12 ? "PM" : "AM";
806
+ let h12 = h24 % 12;
807
+ if (h12 === 0)
808
+ h12 = 12;
809
+ return `${h12.toString().padStart(2, "0")}:${mm} ${ampm}`;
810
+ }
811
+ if (type === "datetime-local") {
812
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(val);
813
+ if (m == null)
814
+ return val;
815
+ const h24 = parseInt(m[4], 10);
816
+ const ampm = h24 >= 12 ? "PM" : "AM";
817
+ let h12 = h24 % 12;
818
+ if (h12 === 0)
819
+ h12 = 12;
820
+ return `${m[2]}/${m[3]}/${m[1]}, ${h12.toString().padStart(2, "0")}:${m[5]} ${ampm}`;
821
+ }
822
+ if (type === "month") {
823
+ const m = /^(\d{4})-(\d{2})$/.exec(val);
824
+ if (m == null)
825
+ return val;
826
+ const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
827
+ const idx = parseInt(m[2], 10) - 1;
828
+ if (idx < 0 || idx > 11)
829
+ return val;
830
+ return `${months[idx]} ${m[1]}`;
831
+ }
832
+ if (type === "week") {
833
+ const m = /^(\d{4})-W(\d{2})$/.exec(val);
834
+ if (m == null)
835
+ return val;
836
+ return `Week ${m[2]}, ${m[1]}`;
837
+ }
838
+ return val;
839
+ }
840
+ function renderCalendarIcon(indent, cx, cy, size) {
841
+ // Simple calendar glyph: rounded rect with two top "binders" and a grid line.
842
+ const w = size;
843
+ const h = size;
844
+ const x = cx - w / 2;
845
+ const y = cy - h / 2;
846
+ const stroke = TRACK_FG;
847
+ return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><rect x="${r(x + 0.5)}" y="${r(y + 1.5)}" width="${r(w - 1)}" height="${r(h - 2)}" rx="1" /><line x1="${r(x + 0.5)}" y1="${r(y + 4)}" x2="${r(x + w - 0.5)}" y2="${r(y + 4)}" /><line x1="${r(x + 3)}" y1="${r(y + 0.5)}" x2="${r(x + 3)}" y2="${r(y + 2.5)}" /><line x1="${r(x + w - 3)}" y1="${r(y + 0.5)}" x2="${r(x + w - 3)}" y2="${r(y + 2.5)}" /></g>`;
848
+ }
849
+ function renderClockIcon(indent, cx, cy, size) {
850
+ // Simple clock glyph: circle with two hands.
851
+ const r1 = size / 2;
852
+ const stroke = TRACK_FG;
853
+ return `${indent}<g fill="none" stroke="${stroke}" stroke-width="1" stroke-linecap="round"><circle cx="${r(cx)}" cy="${r(cy)}" r="${r(r1 - 0.5)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx)}" y2="${r(cy - r1 * 0.55)}" /><line x1="${r(cx)}" y1="${r(cy)}" x2="${r(cx + r1 * 0.4)}" y2="${r(cy)}" /></g>`;
854
+ }
855
+ /**
856
+ * If the captured pseudo-element background is something other than the
857
+ * UA default (transparent / Chrome's default fill), return it as a paint
858
+ * value. Solid colors return the rgb string. Gradients return the first
859
+ * gradient color stop as a fallback (full gradient rendering would require
860
+ * emitting an SVG <linearGradient> def, which we approximate here).
861
+ *
862
+ * NOTE: in headless Chromium getComputedStyle on ::-webkit-progress-value
863
+ * etc. often returns transparent regardless of the CSS rule (because the
864
+ * pseudo styles are not always exposed via the API even when they paint
865
+ * correctly). So in practice this returns null for almost everything and
866
+ * the renderer falls back to UA defaults.
867
+ */
868
+ function customPseudoFill(bg, bgImage) {
869
+ if (bgImage != null && bgImage !== "none" && bgImage !== "") {
870
+ // Try to extract the dominant color from a gradient string. Best-effort:
871
+ // pull the first color literal that appears.
872
+ const m = /(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\))/i.exec(bgImage);
873
+ if (m != null)
874
+ return m[1];
875
+ }
876
+ if (bg == null || bg === "" || bg === "transparent")
877
+ return null;
878
+ // Detect transparent rgba(...) regardless of inner spacing.
879
+ const transparentRgba = /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/i;
880
+ if (transparentRgba.test(bg))
881
+ return null;
882
+ return bg;
883
+ }
884
+ function renderProgress(el, indent, defCtx) {
885
+ // DM-553: scheme-aware UA default track + accent fill.
886
+ const palette = stockPalette(defCtx?.colorScheme);
887
+ const value = el.styles.progressValue;
888
+ const max = el.styles.progressMax ?? 1;
889
+ const isIndeterminate = value == null;
890
+ const ratio = !isIndeterminate && max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
891
+ const parts = [];
892
+ const accent = resolveAccent(el, defCtx);
893
+ // Custom pseudo-element fills override the UA defaults when present
894
+ // (SK-1222: capture now flows through the stylesheet walker rather than
895
+ // the broken getComputedStyle-on-pseudo path, so author rules round-trip).
896
+ const customTrackFill = customPseudoFill(el.styles.progressBarBg, el.styles.progressBarBgImage);
897
+ const customValueFill = customPseudoFill(el.styles.progressValueBg, el.styles.progressValueBgImage);
898
+ const trackFill = customTrackFill ?? palette.trackBg;
899
+ const valueFill = customValueFill ?? accent;
900
+ // UA-default <progress>: empirical Chrome-on-macOS paint is a centered bar
901
+ // inset by floor(h/4) top and bottom, with a small pill radius that only
902
+ // appears once barH exceeds ~8px. Sampled blue value pseudo (DM-354):
903
+ // h=8 → barH=4 inset=2 rx=0 (square, AA only)
904
+ // h=14 → barH=8 inset=3 rx=0 (square, AA only)
905
+ // h=16 → barH=8 inset=4 rx=0 (square, AA only)
906
+ // h=40 → barH=20 inset=10 rx≈6 (partial pill, NOT full half-circle)
907
+ // The previous DM-337 formula (barH=h/2, rx=barH/2) over-rounded at h≤16
908
+ // and over-rounded at h=40 (full pill instead of partial).
909
+ //
910
+ // Author-styled <progress> (appearance:none with custom pseudo bg or
911
+ // pseudo border-radius) — empirically Chrome does NOT propagate the host's
912
+ // border-radius to the pseudos; only the pseudo's own border-radius rounds
913
+ // them. So when no pseudo border-radius is set, default to 0 (square),
914
+ // not el.height/2 (full pill).
915
+ const isAuthorStyled = customTrackFill != null || customValueFill != null
916
+ || (el.styles.progressBarRadius != null && el.styles.progressBarRadius !== "0px")
917
+ || (el.styles.progressValueRadius != null && el.styles.progressValueRadius !== "0px");
918
+ const inset = Math.floor(el.height / 4);
919
+ const barH = isAuthorStyled ? el.height : el.height - 2 * inset;
920
+ const barY = isAuthorStyled ? el.y : el.y + inset;
921
+ const trackRadius = isAuthorStyled
922
+ ? (el.styles.progressBarRadius != null && el.styles.progressBarRadius !== "0px"
923
+ ? parseFloat(el.styles.progressBarRadius) || 0 : 0)
924
+ : Math.max(0, (barH - 8) / 2);
925
+ const valueRadius = isAuthorStyled
926
+ ? (el.styles.progressValueRadius != null && el.styles.progressValueRadius !== "0px"
927
+ ? parseFloat(el.styles.progressValueRadius) || 0 : 0)
928
+ : Math.max(0, (barH - 8) / 2);
929
+ // Gradient fills (SK-1224 / SK-1225) for progress pseudos: when the
930
+ // captured ::-webkit-progress-bar / -value bg-image parses as a gradient,
931
+ // emit a <linearGradient> / <radialGradient> def and reference it via
932
+ // fill="url(#...)". Fall back to the flat trackFill / valueFill above.
933
+ const trackRect = { x: el.x, y: barY, w: el.width, h: barH };
934
+ const trackGrad = gradientFillFor(el.styles.progressBarBgImage, trackRect, defCtx);
935
+ parts.push(`${indent}<rect x="${r(el.x)}" y="${r(barY)}" width="${r(el.width)}" height="${r(barH)}" rx="${r(trackRadius)}" fill="${trackGrad ?? trackFill}" />`);
936
+ if (isIndeterminate) {
937
+ // Chromium indeterminate progress shows a short moving bar. For a static
938
+ // frame, approximate with a ~25% bar near the left (matches a mid-cycle).
939
+ const barW = Math.min(el.width * 0.25, 60);
940
+ const barX = el.x + el.width * 0.1;
941
+ const valueRect = { x: barX, y: barY, w: barW, h: barH };
942
+ const valueGrad = gradientFillFor(el.styles.progressValueBgImage, valueRect, defCtx);
943
+ parts.push(`${indent}<rect x="${r(barX)}" y="${r(barY)}" width="${r(barW)}" height="${r(barH)}" rx="${r(valueRadius)}" fill="${valueGrad ?? valueFill}" />`);
944
+ }
945
+ else if (ratio > 0) {
946
+ const valueW = el.width * ratio;
947
+ const valueRect = { x: el.x, y: barY, w: valueW, h: barH };
948
+ const valueGrad = gradientFillFor(el.styles.progressValueBgImage, valueRect, defCtx);
949
+ parts.push(`${indent}<rect x="${r(el.x)}" y="${r(barY)}" width="${r(valueW)}" height="${r(barH)}" rx="${r(valueRadius)}" fill="${valueGrad ?? valueFill}" />`);
950
+ }
951
+ return parts.join("\n");
952
+ }
953
+ function renderMeter(el, indent, defCtx) {
954
+ // DM-553: scheme-aware UA defaults — meter green/yellow/red palette + track.
955
+ const palette = stockPalette(defCtx?.colorScheme);
956
+ const value = el.styles.meterValue ?? 0;
957
+ const min = el.styles.meterMin ?? 0;
958
+ const max = el.styles.meterMax ?? 1;
959
+ // CSS spec: low defaults to min, high defaults to max, optimum defaults to (min+max)/2.
960
+ const low = el.styles.meterLow ?? min;
961
+ const high = el.styles.meterHigh ?? max;
962
+ const optimum = el.styles.meterOptimum ?? (min + max) / 2;
963
+ const ratio = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0;
964
+ // Classify each point into low/medium/high region based on the bar's low/high
965
+ // bounds. Then: same-region = optimal (green), adjacent = suboptimal (yellow),
966
+ // opposite ends = worst (red). This matches the HTML <meter> color rules.
967
+ const region = (v) => v < low ? 0 : v > high ? 2 : 1;
968
+ const valR = region(value);
969
+ const optR = region(optimum);
970
+ const dist = Math.abs(valR - optR);
971
+ // Pick the matching pseudo (optimum/suboptimum/even-less-good) for the
972
+ // value's region. Author CSS on those pseudos overrides the UA-default
973
+ // green/yellow/red palette. SK-1222 fixed the upstream capture so author
974
+ // rules now round-trip via the stylesheet walker.
975
+ const customTrackFill = customPseudoFill(el.styles.meterBarBg, el.styles.meterBarBgImage);
976
+ let valueBgImage;
977
+ let customValueFill;
978
+ if (dist === 0) {
979
+ customValueFill = customPseudoFill(el.styles.meterOptimumBg, el.styles.meterOptimumBgImage);
980
+ valueBgImage = el.styles.meterOptimumBgImage;
981
+ }
982
+ else if (dist === 1) {
983
+ customValueFill = customPseudoFill(el.styles.meterSuboptimumBg, el.styles.meterSuboptimumBgImage);
984
+ valueBgImage = el.styles.meterSuboptimumBgImage;
985
+ }
986
+ else {
987
+ customValueFill = customPseudoFill(el.styles.meterEvenLessGoodBg, el.styles.meterEvenLessGoodBgImage);
988
+ valueBgImage = el.styles.meterEvenLessGoodBgImage;
989
+ }
990
+ const defaultFill = dist === 0 ? palette.meterGreen : dist === 1 ? palette.meterYellow : palette.meterRed;
991
+ const fill = customValueFill ?? defaultFill;
992
+ const trackFill = customTrackFill ?? palette.trackBg;
993
+ // Same UA-default formula as <progress> (DM-354): inset=floor(h/4) top
994
+ // and bottom, with a partial pill radius that only emerges past barH≈8.
995
+ // Author-styled <meter> (appearance:none with custom pseudo) uses the
996
+ // pseudo's own border-radius, defaulting to 0 (Chrome doesn't propagate
997
+ // the host's border-radius to the pseudos in styled mode).
998
+ const isAuthorStyled = customTrackFill != null || customValueFill != null
999
+ || (el.styles.meterBarRadius != null && el.styles.meterBarRadius !== "0px");
1000
+ const inset = Math.floor(el.height / 4);
1001
+ const barH = isAuthorStyled ? el.height : el.height - 2 * inset;
1002
+ const barY = isAuthorStyled ? el.y : el.y + inset;
1003
+ const trackRadius = isAuthorStyled
1004
+ ? (el.styles.meterBarRadius != null && el.styles.meterBarRadius !== "0px"
1005
+ ? parseFloat(el.styles.meterBarRadius) || 0 : 0)
1006
+ : Math.max(0, (barH - 8) / 2);
1007
+ const parts = [];
1008
+ // Gradient fills (SK-1222 + SK-1224 / SK-1225) for meter pseudos.
1009
+ const trackRect = { x: el.x, y: barY, w: el.width, h: barH };
1010
+ const trackGrad = gradientFillFor(el.styles.meterBarBgImage, trackRect, defCtx);
1011
+ parts.push(`${indent}<rect x="${r(el.x)}" y="${r(barY)}" width="${r(el.width)}" height="${r(barH)}" rx="${r(trackRadius)}" fill="${trackGrad ?? trackFill}" />`);
1012
+ if (ratio > 0) {
1013
+ const valueW = el.width * ratio;
1014
+ const valueRect = { x: el.x, y: barY, w: valueW, h: barH };
1015
+ const valueGrad = gradientFillFor(valueBgImage, valueRect, defCtx);
1016
+ parts.push(`${indent}<rect x="${r(el.x)}" y="${r(barY)}" width="${r(valueW)}" height="${r(barH)}" rx="${r(trackRadius)}" fill="${valueGrad ?? fill}" />`);
1017
+ }
1018
+ return parts.join("\n");
1019
+ }
1020
+ /**
1021
+ * Render a listbox-mode `<select>` (size > 1 or multiple). The host rect
1022
+ * (border + bg) is already painted by the normal element-rendering path;
1023
+ * this overlays one text row per option, with `:checked` rows highlighted
1024
+ * in the Chrome-on-macOS selection-blue band. Optgroup labels render in
1025
+ * italic + bold and are not selectable. DM-282.
1026
+ */
1027
+ function renderListbox(el, indent) {
1028
+ const opts = el.styles.selectListboxOptions;
1029
+ if (opts == null || opts.length === 0)
1030
+ return "";
1031
+ const fontSize = parseFloat(el.styles.fontSize ?? "13") || 13;
1032
+ const fontFamily = el.styles.fontFamily ?? "-apple-system, system-ui, sans-serif";
1033
+ const color = el.styles.color ?? "rgb(0,0,0)";
1034
+ // Chrome's listbox option row is line-height ≈ fontSize × 1.16. The first
1035
+ // row is offset by 1px (border inset) plus a thin top padding.
1036
+ const rowH = fontSize * 1.16;
1037
+ const innerX = el.x + 5;
1038
+ const innerY = el.y + 1;
1039
+ const innerW = el.width - 6;
1040
+ const parts = [];
1041
+ // Selection-row highlight (Chrome-on-macOS). We overlay an opaque rect
1042
+ // BEHIND the text. Disabled rows aren't highlighted even when selected.
1043
+ const SELECTION_BG = "rgb(180, 215, 255)";
1044
+ for (let i = 0; i < opts.length; i++) {
1045
+ const o = opts[i];
1046
+ const ry = innerY + i * rowH;
1047
+ if (ry + rowH > el.y + el.height - 1)
1048
+ break;
1049
+ if (o.selected && !o.disabled && !o.isOptgroupLabel) {
1050
+ parts.push(`${indent}<rect x="${r(innerX - 4)}" y="${r(ry)}" width="${r(innerW + 4)}" height="${r(rowH)}" fill="${SELECTION_BG}" />`);
1051
+ }
1052
+ const tx = innerX + (o.isOptgroupChild ? 8 : 0);
1053
+ const ty = ry + rowH * 0.78;
1054
+ const fontStyleAttr = o.isOptgroupLabel ? ` font-style="italic"` : "";
1055
+ const fontWeightAttr = o.isOptgroupLabel ? ` font-weight="bold"` : "";
1056
+ const opacityAttr = o.disabled ? ` opacity="0.5"` : "";
1057
+ const escaped = o.text.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
1058
+ parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="${r(fontSize)}" font-family="${fontFamily}" fill="${color}"${fontStyleAttr}${fontWeightAttr}${opacityAttr}>${escaped}</text>`);
1059
+ }
1060
+ return parts.join("\n");
1061
+ }
1062
+ function renderSelectChevron(el, indent, defCtx) {
1063
+ // DM-553: scheme-aware chevron stroke.
1064
+ const palette = stockPalette(defCtx?.colorScheme);
1065
+ const parts = [];
1066
+ // Selected-option text inside the closed dropdown's content rect (DM-246).
1067
+ // Chrome paints `selectedOptions[0]?.textContent` here; option/optgroup
1068
+ // children are otherwise textIsHiddenFallback and don't reach the renderer.
1069
+ const display = el.styles.selectDisplayText;
1070
+ if (display != null && display !== "") {
1071
+ const fontSize = parseFloat(el.styles.fontSize ?? "13") || 13;
1072
+ const fontFamily = el.styles.fontFamily ?? "-apple-system, system-ui, sans-serif";
1073
+ const fontWeight = el.styles.fontWeight ?? "400";
1074
+ const color = el.styles.color ?? "rgb(0,0,0)";
1075
+ // Anchor the display text at the element's content-box left edge.
1076
+ // Pages style selects with `appearance: none; padding: 8px 34px 8px 12px`
1077
+ // and similar — the previous hardcoded `el.x + 6` ignored the captured
1078
+ // padding, so styled selects rendered the text 6-12px too far left
1079
+ // (DM-341). UA-default selects (where padding is empty) still resolve to
1080
+ // a reasonable position via Chrome's small computed padding.
1081
+ const padL = parseFloat(el.styles.paddingLeft ?? "0") || 0;
1082
+ const bwL = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
1083
+ const tx = el.x + bwL + padL;
1084
+ const ty = el.y + el.height / 2 + fontSize * 0.35;
1085
+ const escaped = display.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
1086
+ parts.push(`${indent}<text x="${r(tx)}" y="${r(ty)}" font-size="${r(fontSize)}" font-family="${fontFamily}" font-weight="${fontWeight}" fill="${color}">${escaped}</text>`);
1087
+ }
1088
+ // Chromium macOS default: small down-chevron near the right edge. Skip
1089
+ // when the page set appearance: none — the chevron is the page's
1090
+ // responsibility (drawn via background-image) and stacking ours produces
1091
+ // a double-arrow. DM-308.
1092
+ if (el.styles.selectChevron === true) {
1093
+ const size = Math.min(10, el.height * 0.5);
1094
+ const cx = el.x + el.width - 10;
1095
+ const cy = el.y + el.height / 2;
1096
+ const p = (dx, dy) => `${r(cx + dx * size)},${r(cy + dy * size)}`;
1097
+ parts.push(`${indent}<polyline points="${p(-0.35, -0.18)} ${p(0, 0.18)} ${p(0.35, -0.18)}" fill="none" stroke="${palette.trackFg}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />`);
1098
+ }
1099
+ return parts.join("\n");
1100
+ }
1101
+ function renderDetailsMarker(el, indent) {
1102
+ // When author CSS hides the UA disclosure marker (::marker { color:
1103
+ // transparent } or ::-webkit-details-marker { color: transparent }),
1104
+ // skip painting — the author has supplied a replacement (typically a
1105
+ // ::before pseudo) and stacking our triangle on top double-paints. DM-448.
1106
+ if (el.styles.summaryMarkerSuppressed === true)
1107
+ return "";
1108
+ // Chrome's UA stylesheet for `<details><summary>` paints a disclosure
1109
+ // triangle to the LEFT of the summary's first text line. Empirical
1110
+ // probe (DM-370): triangle size scales roughly with font-size at
1111
+ // ~0.6em per side, painted in the summary's text color (NOT the
1112
+ // greyed-out TRACK_FG we used previously — that left a barely-visible
1113
+ // ghost where Chrome paints a clean black caret). Closed state uses
1114
+ // the right-pointing triangle ▶ (U+25B6); open state uses the
1115
+ // down-pointing triangle ▼ (U+25BC).
1116
+ const fontSizePx = parseFloat(el.styles.fontSize ?? "") || 14;
1117
+ // Chrome's UA disclosure triangle is rendered via the ▶ / ▼ glyphs in the
1118
+ // summary's font at the summary's font-size. Glyph advance is ~0.7em for
1119
+ // U+25B6 / U+25BC in the system sans fonts (DM-448 follow-up). The
1120
+ // previous 0.6em multiplier produced a triangle that read visibly small
1121
+ // vs Chrome's painted output.
1122
+ const size = Math.max(8, fontSizePx * 0.7);
1123
+ const lineH = parseFloat(el.styles.lineHeight ?? "") || fontSizePx * 1.5;
1124
+ // Position: marker sits inside the summary at its content-start, which
1125
+ // is el.x + paddingL + borderL. Offset by half the marker size so the
1126
+ // glyph's center sits ~half-marker-width past the summary's left edge,
1127
+ // matching Chrome's painted offset (DM-448).
1128
+ const padL = parseFloat(el.styles.paddingLeft ?? "") || 0;
1129
+ const brL = parseFloat(el.styles.borderLeftWidth ?? "") || 0;
1130
+ const padT = parseFloat(el.styles.paddingTop ?? "") || 0;
1131
+ const brT = parseFloat(el.styles.borderTopWidth ?? "") || 0;
1132
+ const cx = el.x + padL + brL + size / 2;
1133
+ // Vertical center: the summary is the first child of <details>; its
1134
+ // first line-box top sits at el.y + paddingTop + borderTop, and its
1135
+ // center is half a line-height below that. The previous `cy = el.y +
1136
+ // lineH/2` ignored the details element's own padding/border, leaving
1137
+ // the triangle painted ~paddingTop pixels above where Chrome paints
1138
+ // it (DM-448 user feedback: 'disclosure arrow positions still
1139
+ // incorrect').
1140
+ const cy = el.y + padT + brT + lineH / 2;
1141
+ const open = el.styles.detailsOpen === true;
1142
+ // Use the summary's text color when captured, else dark grey.
1143
+ const fill = (el.styles.color != null && el.styles.color !== "")
1144
+ ? el.styles.color : "rgb(0,0,0)";
1145
+ const half = size / 2;
1146
+ // Right-pointing (closed): ▶ — apex at right. Down-pointing (open): ▼ — apex at bottom.
1147
+ const p = open
1148
+ ? `${r(cx - half)},${r(cy - half * 0.6)} ${r(cx + half)},${r(cy - half * 0.6)} ${r(cx)},${r(cy + half * 0.7)}`
1149
+ : `${r(cx - half * 0.7)},${r(cy - half)} ${r(cx + half * 0.6)},${r(cy)} ${r(cx - half * 0.7)},${r(cy + half)}`;
1150
+ return `${indent}<polygon points="${p}" fill="${fill}" />`;
1151
+ }