| 1 | import { THEME_COLOR_ROLES, getThemeColorVariable, type ThemeColorRole } from "../../themePalette"; |
| 2 | |
| 3 | export type ThemePaintKind = "background" | "border" | "foreground"; |
| 4 | |
| 5 | export type ThemePaintSnapshot = Readonly<Record<ThemePaintKind, string>>; |
| 6 | export type ThemeElementInspection = Readonly<{ |
| 7 | element: Element; |
| 8 | role: ThemeColorRole; |
| 9 | }>; |
| 10 | |
| 11 | const THEME_PAINT_KIND_ORDER: ReadonlyArray<ThemePaintKind> = [ |
| 12 | "background", |
| 13 | "border", |
| 14 | "foreground", |
| 15 | ]; |
| 16 | |
| 17 | const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; |
| 18 | |
| 19 | const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; |
| 20 | const THEME_TOKEN_PROBE_COLOR = "#01fea7"; |
| 21 | const THEME_TOKEN_ALTERNATE_PROBE_COLOR = "#fe01a7"; |
| 22 | const THEME_SPOTLIGHT_ID = "theme-inspector-spotlight"; |
| 23 | const THEME_SPOTLIGHT_MASK_ID = "theme-inspector-spotlight-mask"; |
| 24 | const THEME_SPOTLIGHT_GLOW_ID = "theme-inspector-spotlight-glow"; |
| 25 | const THEME_HOVER_ID = "theme-inspector-hover"; |
| 26 | const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; |
| 27 | |
| 28 | const THEME_UTILITY_ROLES: Readonly<Partial<Record<string, ThemeColorRole>>> = { |
| 29 | background: "canvas", |
| 30 | foreground: "text", |
| 31 | card: "surface", |
| 32 | "card-foreground": "text", |
| 33 | popover: "surfaceOverlay", |
| 34 | "popover-foreground": "text", |
| 35 | "surface-raised": "surfaceRaised", |
| 36 | primary: "messageAction", |
| 37 | "primary-foreground": "messageActionForeground", |
| 38 | secondary: "secondary", |
| 39 | "secondary-foreground": "secondaryForeground", |
| 40 | muted: "muted", |
| 41 | "muted-foreground": "mutedForeground", |
| 42 | placeholder: "placeholder", |
| 43 | "secondary-label": "secondaryLabel", |
| 44 | "icon-muted": "iconMuted", |
| 45 | accent: "accentSurface", |
| 46 | "accent-foreground": "accentSurfaceForeground", |
| 47 | destructive: "error", |
| 48 | "destructive-foreground": "errorForeground", |
| 49 | error: "error", |
| 50 | "error-foreground": "errorForeground", |
| 51 | "error-surface": "errorSurface", |
| 52 | warning: "warning", |
| 53 | "warning-foreground": "warningForeground", |
| 54 | "warning-surface": "warningSurface", |
| 55 | update: "update", |
| 56 | "update-foreground": "updateForeground", |
| 57 | "update-surface": "updateSurface", |
| 58 | message: "messageSurface", |
| 59 | "message-foreground": "messageForeground", |
| 60 | "message-action": "messageAction", |
| 61 | "message-action-foreground": "messageActionForeground", |
| 62 | "message-action-hover": "messageActionHover", |
| 63 | sidebar: "sidebar", |
| 64 | "sidebar-foreground": "sidebarForeground", |
| 65 | "sidebar-muted-foreground": "sidebarMutedForeground", |
| 66 | "sidebar-control-surface": "sidebarControlSurface", |
| 67 | "sidebar-row-hover": "sidebarRowHover", |
| 68 | "sidebar-row-active": "sidebarRowActive", |
| 69 | "sidebar-row-selected": "sidebarRowSelected", |
| 70 | "sidebar-border": "sidebarBorder", |
| 71 | border: "border", |
| 72 | input: "input", |
| 73 | ring: "focus", |
| 74 | }; |
| 75 | |
| 76 | const THEME_UTILITY_PREFIXES: Readonly<Record<ThemePaintKind, ReadonlyArray<string>>> = { |
| 77 | background: ["bg-"], |
| 78 | border: ["border-", "outline-", "ring-"], |
| 79 | foreground: ["text-", "caret-", "fill-", "stroke-"], |
| 80 | }; |
| 81 | |
| 82 | function clearThemeInspectorAttribute(attribute: string): void { |
| 83 | document |
| 84 | .querySelectorAll(`[${attribute}]`) |
| 85 | .forEach((element) => element.removeAttribute(attribute)); |
| 86 | } |
| 87 | |
| 88 | export function clearThemeInspectorHighlights(): void { |
| 89 | clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE); |
| 90 | document.getElementById(THEME_SPOTLIGHT_ID)?.remove(); |
| 91 | } |
| 92 | |
| 93 | export function clearThemeInspectorHover(): void { |
| 94 | document.getElementById(THEME_HOVER_ID)?.remove(); |
| 95 | } |
| 96 | |
| 97 | function svgElement<Name extends keyof SVGElementTagNameMap>( |
| 98 | name: Name, |
| 99 | ): SVGElementTagNameMap[Name] { |
| 100 | return document.createElementNS(SVG_NAMESPACE, name); |
| 101 | } |
| 102 | |
| 103 | function spotlightRect(element: Element): { |
| 104 | x: number; |
| 105 | y: number; |
| 106 | width: number; |
| 107 | height: number; |
| 108 | radius: number; |
| 109 | } | null { |
| 110 | const bounds = element.getBoundingClientRect(); |
| 111 | if ( |
| 112 | bounds.width <= 0 || |
| 113 | bounds.height <= 0 || |
| 114 | bounds.right < 0 || |
| 115 | bounds.bottom < 0 || |
| 116 | bounds.left > window.innerWidth || |
| 117 | bounds.top > window.innerHeight |
| 118 | ) { |
| 119 | return null; |
| 120 | } |
| 121 | |
| 122 | const padding = 5; |
| 123 | // Keep the true bounds when an element is partially offscreen. Clamping its |
| 124 | // rectangle to the viewport would draw a fake glow edge along the crop. |
| 125 | const x = bounds.left - padding; |
| 126 | const y = bounds.top - padding; |
| 127 | const elementRadius = |
| 128 | Number.parseFloat(window.getComputedStyle(element).borderTopLeftRadius) || 0; |
| 129 | return { |
| 130 | x, |
| 131 | y, |
| 132 | width: bounds.width + padding * 2, |
| 133 | height: bounds.height + padding * 2, |
| 134 | radius: Math.min(18, Math.max(7, elementRadius + padding)), |
| 135 | }; |
| 136 | } |
| 137 | |
| 138 | export function showThemeInspectorHover(inspection: ThemeElementInspection, label: string): void { |
| 139 | const rectangle = spotlightRect(inspection.element); |
| 140 | if (!rectangle) { |
| 141 | clearThemeInspectorHover(); |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | let hover = document.getElementById(THEME_HOVER_ID); |
| 146 | if (!hover) { |
| 147 | hover = document.createElement("div"); |
| 148 | hover.id = THEME_HOVER_ID; |
| 149 | hover.setAttribute("aria-hidden", "true"); |
| 150 | const tokenLabel = document.createElement("span"); |
| 151 | tokenLabel.dataset.themeInspectorHoverLabel = ""; |
| 152 | hover.append(tokenLabel); |
| 153 | document.body.append(hover); |
| 154 | } |
| 155 | |
| 156 | hover.style.left = `${rectangle.x}px`; |
| 157 | hover.style.top = `${rectangle.y}px`; |
| 158 | hover.style.width = `${rectangle.width}px`; |
| 159 | hover.style.height = `${rectangle.height}px`; |
| 160 | hover.style.borderRadius = `${rectangle.radius}px`; |
| 161 | hover.dataset.placement = rectangle.y < 32 ? "below" : "above"; |
| 162 | const tokenLabel = hover.querySelector<HTMLElement>("[data-theme-inspector-hover-label]"); |
| 163 | if (tokenLabel) tokenLabel.textContent = label; |
| 164 | } |
| 165 | |
| 166 | function renderThemeInspectorSpotlight(elements: ReadonlyArray<Element>): void { |
| 167 | const rectangles = new Map<string, NonNullable<ReturnType<typeof spotlightRect>>>(); |
| 168 | for (const element of elements) { |
| 169 | const rectangle = spotlightRect(element); |
| 170 | if (!rectangle) continue; |
| 171 | const key = [rectangle.x, rectangle.y, rectangle.width, rectangle.height] |
| 172 | .map((value) => Math.round(value)) |
| 173 | .join(":"); |
| 174 | rectangles.set(key, rectangle); |
| 175 | } |
| 176 | |
| 177 | if (rectangles.size === 0) { |
| 178 | document.getElementById(THEME_SPOTLIGHT_ID)?.remove(); |
| 179 | return; |
| 180 | } |
| 181 | |
| 182 | let spotlight = document.getElementById(THEME_SPOTLIGHT_ID) as SVGSVGElement | null; |
| 183 | if (!spotlight) { |
| 184 | spotlight = svgElement("svg"); |
| 185 | spotlight.id = THEME_SPOTLIGHT_ID; |
| 186 | spotlight.setAttribute("aria-hidden", "true"); |
| 187 | spotlight.setAttribute("focusable", "false"); |
| 188 | document.body.append(spotlight); |
| 189 | } |
| 190 | spotlight.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); |
| 191 | |
| 192 | const definitions = svgElement("defs"); |
| 193 | const mask = svgElement("mask"); |
| 194 | mask.id = THEME_SPOTLIGHT_MASK_ID; |
| 195 | mask.setAttribute("maskUnits", "userSpaceOnUse"); |
| 196 | const maskSurface = svgElement("rect"); |
| 197 | maskSurface.setAttribute("width", String(window.innerWidth)); |
| 198 | maskSurface.setAttribute("height", String(window.innerHeight)); |
| 199 | maskSurface.setAttribute("fill", "white"); |
| 200 | mask.append(maskSurface); |
| 201 | |
| 202 | const glowFilter = svgElement("filter"); |
| 203 | glowFilter.id = THEME_SPOTLIGHT_GLOW_ID; |
| 204 | glowFilter.setAttribute("x", "-50%"); |
| 205 | glowFilter.setAttribute("y", "-50%"); |
| 206 | glowFilter.setAttribute("width", "200%"); |
| 207 | glowFilter.setAttribute("height", "200%"); |
| 208 | const blur = svgElement("feGaussianBlur"); |
| 209 | blur.setAttribute("stdDeviation", "5"); |
| 210 | blur.setAttribute("result", "blur"); |
| 211 | const merge = svgElement("feMerge"); |
| 212 | const blurredGlow = svgElement("feMergeNode"); |
| 213 | blurredGlow.setAttribute("in", "blur"); |
| 214 | const crispGlow = svgElement("feMergeNode"); |
| 215 | crispGlow.setAttribute("in", "SourceGraphic"); |
| 216 | merge.append(blurredGlow, crispGlow); |
| 217 | glowFilter.append(blur, merge); |
| 218 | definitions.append(mask, glowFilter); |
| 219 | |
| 220 | const glowGroup = svgElement("g"); |
| 221 | for (const rectangle of rectangles.values()) { |
| 222 | const hole = svgElement("rect"); |
| 223 | hole.setAttribute("x", String(rectangle.x)); |
| 224 | hole.setAttribute("y", String(rectangle.y)); |
| 225 | hole.setAttribute("width", String(rectangle.width)); |
| 226 | hole.setAttribute("height", String(rectangle.height)); |
| 227 | hole.setAttribute("rx", String(rectangle.radius)); |
| 228 | hole.setAttribute("fill", "black"); |
| 229 | mask.append(hole); |
| 230 | |
| 231 | const glow = hole.cloneNode(false) as SVGRectElement; |
| 232 | glow.removeAttribute("fill"); |
| 233 | glow.setAttribute("class", "theme-inspector-spotlight-glow"); |
| 234 | glow.setAttribute("filter", `url(#${THEME_SPOTLIGHT_GLOW_ID})`); |
| 235 | glowGroup.append(glow); |
| 236 | } |
| 237 | |
| 238 | const dimmer = svgElement("rect"); |
| 239 | dimmer.setAttribute("class", "theme-inspector-spotlight-dimmer"); |
| 240 | dimmer.setAttribute("width", String(window.innerWidth)); |
| 241 | dimmer.setAttribute("height", String(window.innerHeight)); |
| 242 | dimmer.setAttribute("mask", `url(#${THEME_SPOTLIGHT_MASK_ID})`); |
| 243 | spotlight.replaceChildren(definitions, dimmer, glowGroup); |
| 244 | } |
| 245 | |
| 246 | export function refreshThemeInspectorSpotlight(): void { |
| 247 | renderThemeInspectorSpotlight([ |
| 248 | ...document.querySelectorAll(`[${THEME_INSPECTOR_MATCH_ATTRIBUTE}]`), |
| 249 | ]); |
| 250 | } |
| 251 | |
| 252 | function elementHasVisibleText(element: Element): boolean { |
| 253 | if (element.matches("input, textarea, select, option")) return true; |
| 254 | return Array.from(element.childNodes).some( |
| 255 | (node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()), |
| 256 | ); |
| 257 | } |
| 258 | |
| 259 | function getThemePaintSnapshot( |
| 260 | element: Element, |
| 261 | { forHitTest = false }: { forHitTest?: boolean } = {}, |
| 262 | ): ThemePaintSnapshot | null { |
| 263 | const style = window.getComputedStyle(element); |
| 264 | if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { |
| 265 | return null; |
| 266 | } |
| 267 | |
| 268 | const borderPaints: Array<string> = []; |
| 269 | for (const [color, borderStyle, width] of [ |
| 270 | [style.borderTopColor, style.borderTopStyle, style.borderTopWidth], |
| 271 | [style.borderRightColor, style.borderRightStyle, style.borderRightWidth], |
| 272 | [style.borderBottomColor, style.borderBottomStyle, style.borderBottomWidth], |
| 273 | [style.borderLeftColor, style.borderLeftStyle, style.borderLeftWidth], |
| 274 | ] as const) { |
| 275 | if (borderStyle !== "none" && Number.parseFloat(width) > 0) borderPaints.push(color); |
| 276 | } |
| 277 | if (style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) > 0) { |
| 278 | borderPaints.push(style.outlineColor); |
| 279 | } |
| 280 | if (style.borderImageSource !== "none") borderPaints.push(style.borderImageSource); |
| 281 | if (style.boxShadow !== "none") borderPaints.push(style.boxShadow); |
| 282 | |
| 283 | const foregroundPaints: Array<string> = []; |
| 284 | if (forHitTest || elementHasVisibleText(element)) foregroundPaints.push(style.color); |
| 285 | if (element instanceof SVGElement) foregroundPaints.push(style.fill, style.stroke); |
| 286 | if (element.matches("input, textarea")) foregroundPaints.push(style.caretColor); |
| 287 | if (style.textDecorationLine !== "none") foregroundPaints.push(style.textDecorationColor); |
| 288 | if (style.textShadow !== "none") foregroundPaints.push(style.textShadow); |
| 289 | |
| 290 | return { |
| 291 | background: [style.backgroundColor, style.backgroundImage].join("\n"), |
| 292 | border: borderPaints.join("\n"), |
| 293 | foreground: foregroundPaints.join("\n"), |
| 294 | }; |
| 295 | } |
| 296 | |
| 297 | export function changedThemePaintKinds( |
| 298 | before: ThemePaintSnapshot, |
| 299 | after: ThemePaintSnapshot, |
| 300 | ): ReadonlyArray<ThemePaintKind> { |
| 301 | return THEME_PAINT_KIND_ORDER.filter((kind) => before[kind] !== after[kind]); |
| 302 | } |
| 303 | |
| 304 | function withThemeTokenProbeSession<Result>(run: () => Result): Result { |
| 305 | const root = document.documentElement; |
| 306 | const wasAlreadyProbing = root.hasAttribute(THEME_TOKEN_PROBE_ATTRIBUTE); |
| 307 | if (!wasAlreadyProbing) root.setAttribute(THEME_TOKEN_PROBE_ATTRIBUTE, ""); |
| 308 | try { |
| 309 | return run(); |
| 310 | } finally { |
| 311 | // Flush every restored token while transitions are still suppressed. The |
| 312 | // browser never gets a paint opportunity between the probe and restore. |
| 313 | void window.getComputedStyle(root).color; |
| 314 | if (!wasAlreadyProbing) root.removeAttribute(THEME_TOKEN_PROBE_ATTRIBUTE); |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | function applyThemeTokenProbe(role: ThemeColorRole): () => void { |
| 319 | const root = document.documentElement; |
| 320 | const variable = getThemeColorVariable(role); |
| 321 | const originalValue = root.style.getPropertyValue(variable); |
| 322 | const originalPriority = root.style.getPropertyPriority(variable); |
| 323 | const resolvedValue = originalValue.trim().toLowerCase(); |
| 324 | const probeColor = |
| 325 | resolvedValue === THEME_TOKEN_PROBE_COLOR |
| 326 | ? THEME_TOKEN_ALTERNATE_PROBE_COLOR |
| 327 | : THEME_TOKEN_PROBE_COLOR; |
| 328 | root.style.setProperty(variable, probeColor, "important"); |
| 329 | |
| 330 | return () => { |
| 331 | if (originalValue) { |
| 332 | root.style.setProperty(variable, originalValue, originalPriority); |
| 333 | } else { |
| 334 | root.style.removeProperty(variable); |
| 335 | } |
| 336 | }; |
| 337 | } |
| 338 | |
| 339 | function applyThemeTokenProbes(roles: ReadonlyArray<ThemeColorRole>): () => void { |
| 340 | const restores = roles.map(applyThemeTokenProbe); |
| 341 | return () => { |
| 342 | for (const restore of restores.toReversed()) restore(); |
| 343 | }; |
| 344 | } |
| 345 | |
| 346 | function themeInspectorCandidates(): ReadonlyArray<Element> { |
| 347 | return [document.body, ...document.body.querySelectorAll("*")].filter( |
| 348 | (element) => |
| 349 | !element.closest("[data-theme-editor-panel]") && |
| 350 | !element.closest(`#${THEME_SPOTLIGHT_ID}, #${THEME_HOVER_ID}`), |
| 351 | ); |
| 352 | } |
| 353 | |
| 354 | export function themeRoleFromUtilityClass( |
| 355 | className: string, |
| 356 | kind: ThemePaintKind, |
| 357 | ): ThemeColorRole | null { |
| 358 | // Stateful variants such as hover: and disabled: may not be contributing to |
| 359 | // the current paint. The computed-style probe below handles those exactly. |
| 360 | if (className.includes(":")) return null; |
| 361 | for (const prefix of THEME_UTILITY_PREFIXES[kind]) { |
| 362 | if (!className.startsWith(prefix)) continue; |
| 363 | const colorName = className.slice(prefix.length).split("/", 1)[0] ?? ""; |
| 364 | return THEME_UTILITY_ROLES[colorName] ?? null; |
| 365 | } |
| 366 | return null; |
| 367 | } |
| 368 | |
| 369 | function themeRoleFromUtilities(element: Element): ThemeColorRole | null { |
| 370 | for (const kind of THEME_PAINT_KIND_ORDER) { |
| 371 | for (const className of element.classList) { |
| 372 | const role = themeRoleFromUtilityClass(className, kind); |
| 373 | if (role) return role; |
| 374 | } |
| 375 | } |
| 376 | return null; |
| 377 | } |
| 378 | |
| 379 | function themeInspectionCandidates(initialElement: Element): ReadonlyArray<Element> { |
| 380 | const candidates: Array<Element> = []; |
| 381 | let element: Element | null = initialElement; |
| 382 | while (element && element !== document.documentElement) { |
| 383 | if (!element.closest("[data-theme-editor-panel]")) candidates.push(element); |
| 384 | element = element.parentElement; |
| 385 | } |
| 386 | return candidates; |
| 387 | } |
| 388 | |
| 389 | export function inspectThemeRoleFromUtilitiesAtElement( |
| 390 | initialElement: Element, |
| 391 | ): ThemeElementInspection | null { |
| 392 | if (initialElement.closest("[data-theme-editor-panel]")) return null; |
| 393 | for (const candidate of themeInspectionCandidates(initialElement)) { |
| 394 | const role = themeRoleFromUtilities(candidate); |
| 395 | if (role) return { element: candidate, role }; |
| 396 | } |
| 397 | return null; |
| 398 | } |
| 399 | |
| 400 | /** |
| 401 | * Finds actual dependency on a theme token instead of comparing final colors. |
| 402 | * A token is synchronously replaced with a sentinel, computed paint is read, |
| 403 | * and the original value is restored before the browser can render a frame. |
| 404 | */ |
| 405 | export function highlightThemeRoleUsage(roles: ReadonlyArray<ThemeColorRole>): number { |
| 406 | const startedAt = performance.now(); |
| 407 | clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE); |
| 408 | const candidates = themeInspectorCandidates(); |
| 409 | const probeStartedAt = performance.now(); |
| 410 | const matches = withThemeTokenProbeSession(() => { |
| 411 | const baseline = new Map<Element, ThemePaintSnapshot>(); |
| 412 | for (const element of candidates) { |
| 413 | const snapshot = getThemePaintSnapshot(element); |
| 414 | if (snapshot) baseline.set(element, snapshot); |
| 415 | } |
| 416 | |
| 417 | const restore = applyThemeTokenProbes(roles); |
| 418 | const changed = new Set<Element>(); |
| 419 | try { |
| 420 | for (const [element, before] of baseline) { |
| 421 | const after = getThemePaintSnapshot(element); |
| 422 | if (after && changedThemePaintKinds(before, after).length > 0) changed.add(element); |
| 423 | } |
| 424 | } finally { |
| 425 | restore(); |
| 426 | } |
| 427 | return changed; |
| 428 | }); |
| 429 | const probeDuration = performance.now() - probeStartedAt; |
| 430 | |
| 431 | const highlightedElements = new Set<Element>(); |
| 432 | for (const element of matches) { |
| 433 | highlightedElements.add( |
| 434 | element instanceof SVGElement ? (element.closest("svg") ?? element) : element, |
| 435 | ); |
| 436 | } |
| 437 | for (const element of highlightedElements) { |
| 438 | element.setAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE, ""); |
| 439 | } |
| 440 | renderThemeInspectorSpotlight([...highlightedElements]); |
| 441 | if (import.meta.env.DEV) { |
| 442 | const spotlight = document.getElementById(THEME_SPOTLIGHT_ID); |
| 443 | if (spotlight) { |
| 444 | spotlight.dataset.themeInspectorCandidates = String(candidates.length); |
| 445 | spotlight.dataset.themeInspectorProbeMs = probeDuration.toFixed(2); |
| 446 | spotlight.dataset.themeInspectorTotalMs = (performance.now() - startedAt).toFixed(2); |
| 447 | } |
| 448 | } |
| 449 | return highlightedElements.size; |
| 450 | } |
| 451 | |
| 452 | /** Resolves the nearest painted token at a touched element. */ |
| 453 | export function inspectThemeRoleAtElement(initialElement: Element): ThemeElementInspection | null { |
| 454 | if (initialElement.closest("[data-theme-editor-panel]")) return null; |
| 455 | |
| 456 | const candidates = themeInspectionCandidates(initialElement); |
| 457 | |
| 458 | // Most app paint comes from Tailwind's semantic color utilities. Those class |
| 459 | // names express the token dependency directly and avoid dozens of forced |
| 460 | // style recalculations on every pointer-down. Custom CSS still falls through |
| 461 | // to the exact computed-style probe. |
| 462 | const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(initialElement); |
| 463 | if (utilityInspection) return utilityInspection; |
| 464 | |
| 465 | const rolesByElement = withThemeTokenProbeSession(() => { |
| 466 | const baseline = new Map<Element, ThemePaintSnapshot>(); |
| 467 | const roles = new Map<Element, Partial<Record<ThemePaintKind, ThemeColorRole>>>(); |
| 468 | for (const candidate of candidates) { |
| 469 | const snapshot = getThemePaintSnapshot(candidate, { forHitTest: true }); |
| 470 | if (snapshot) baseline.set(candidate, snapshot); |
| 471 | } |
| 472 | |
| 473 | for (const role of THEME_COLOR_ROLES) { |
| 474 | const restore = applyThemeTokenProbe(role); |
| 475 | try { |
| 476 | for (const [candidate, before] of baseline) { |
| 477 | const after = getThemePaintSnapshot(candidate, { forHitTest: true }); |
| 478 | if (!after) continue; |
| 479 | const candidateRoles = roles.get(candidate) ?? {}; |
| 480 | for (const kind of changedThemePaintKinds(before, after)) { |
| 481 | candidateRoles[kind] ??= role; |
| 482 | } |
| 483 | roles.set(candidate, candidateRoles); |
| 484 | } |
| 485 | } finally { |
| 486 | restore(); |
| 487 | } |
| 488 | } |
| 489 | return roles; |
| 490 | }); |
| 491 | |
| 492 | for (const candidate of candidates) { |
| 493 | const roles = rolesByElement.get(candidate); |
| 494 | if (!roles) continue; |
| 495 | for (const kind of THEME_PAINT_KIND_ORDER) { |
| 496 | const role = roles[kind]; |
| 497 | if (role) return { element: candidate, role }; |
| 498 | } |
| 499 | } |
| 500 | return null; |
| 501 | } |