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.
- package/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- 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, """)}" 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, """)}" fill="rgb(0,0,0)">${label.replace(/[&<>]/g, (c) => ({"&":"&","<":"<",">":">"}[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) => ({"&":"&","<":"<",">":">"}[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) => ({ "&": "&", "<": "<", ">": ">" }[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) => ({ "&": "&", "<": "<", ">": ">" }[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
|
+
}
|