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