1import { THEME_COLOR_ROLES, getThemeColorVariable, type ThemeColorRole } from "../../themePalette";
2
3export type ThemePaintKind = "background" | "border" | "foreground";
4
5export type ThemePaintSnapshot = Readonly<Record<ThemePaintKind, string>>;
6export type ThemeElementInspection = Readonly<{
7 element: Element;
8 role: ThemeColorRole;
9}>;
10
11const THEME_PAINT_KIND_ORDER: ReadonlyArray<ThemePaintKind> = [
12 "background",
13 "border",
14 "foreground",
15];
16
17const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match";
18
19const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe";
20const THEME_TOKEN_PROBE_COLOR = "#01fea7";
21const THEME_TOKEN_ALTERNATE_PROBE_COLOR = "#fe01a7";
22const THEME_SPOTLIGHT_ID = "theme-inspector-spotlight";
23const THEME_SPOTLIGHT_MASK_ID = "theme-inspector-spotlight-mask";
24const THEME_SPOTLIGHT_GLOW_ID = "theme-inspector-spotlight-glow";
25const THEME_HOVER_ID = "theme-inspector-hover";
26const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
27
28const 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
76const THEME_UTILITY_PREFIXES: Readonly<Record<ThemePaintKind, ReadonlyArray<string>>> = {
77 background: ["bg-"],
78 border: ["border-", "outline-", "ring-"],
79 foreground: ["text-", "caret-", "fill-", "stroke-"],
80};
81
82function clearThemeInspectorAttribute(attribute: string): void {
83 document
84 .querySelectorAll(`[${attribute}]`)
85 .forEach((element) => element.removeAttribute(attribute));
86}
87
88export function clearThemeInspectorHighlights(): void {
89 clearThemeInspectorAttribute(THEME_INSPECTOR_MATCH_ATTRIBUTE);
90 document.getElementById(THEME_SPOTLIGHT_ID)?.remove();
91}
92
93export function clearThemeInspectorHover(): void {
94 document.getElementById(THEME_HOVER_ID)?.remove();
95}
96
97function svgElement<Name extends keyof SVGElementTagNameMap>(
98 name: Name,
99): SVGElementTagNameMap[Name] {
100 return document.createElementNS(SVG_NAMESPACE, name);
101}
102
103function 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
138export 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
166function 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
246export function refreshThemeInspectorSpotlight(): void {
247 renderThemeInspectorSpotlight([
248 ...document.querySelectorAll(`[${THEME_INSPECTOR_MATCH_ATTRIBUTE}]`),
249 ]);
250}
251
252function 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
259function 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
297export 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
304function 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
318function 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
339function applyThemeTokenProbes(roles: ReadonlyArray<ThemeColorRole>): () => void {
340 const restores = roles.map(applyThemeTokenProbe);
341 return () => {
342 for (const restore of restores.toReversed()) restore();
343 };
344}
345
346function 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
354export 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
369function 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
379function 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
389export 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 */
405export 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. */
453export 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}