52 | : never;
53 | };
54 |
55 | declare module "webext-bridge" {
56 | export interface ProtocolMap extends InferredProtocolMap {}
57 | }
58 |
--------------------------------------------------------------------------------
/entrypoints/devtools-pane/panda.css:
--------------------------------------------------------------------------------
1 | @layer reset, base, tokens, recipes, utilities;
2 |
3 | html,
4 | body,
5 | #root {
6 | height: 100%;
7 | }
8 |
9 | html {
10 | overflow: hidden;
11 | padding: 3px 0;
12 | }
13 |
14 | #root {
15 | position: relative;
16 | z-index: 0;
17 | --z-index: 10; /* z-index for tooltips */
18 | }
19 |
20 | body {
21 | background-color: var(--colors-devtools-cdt-base-container);
22 | color: var(--colors-devtools-on-surface);
23 | }
24 |
25 | .platform-mac ::-webkit-scrollbar {
26 | width: 8px;
27 | padding: 2px;
28 | }
29 |
30 | .platform-mac ::-webkit-scrollbar-thumb {
31 | background-color: #6b6b6b;
32 | border-radius: 999px;
33 | }
34 |
35 | .platform-mac ::-webkit-scrollbar-thumb:hover {
36 | background-color: #939393;
37 | }
38 |
39 | ::selection {
40 | background-color: var(--colors-devtools-tonal-container, rgb(0, 74, 119));
41 | }
42 |
--------------------------------------------------------------------------------
/entrypoints/devtools-pane/use-platform-class.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | /**
4 | * Add platform class to apply targeted styles
5 | */
6 | const usePlatformClass = () => {
7 | useEffect(() => {
8 | const listener = (themeName: string) => {
9 | if (themeName === "dark") {
10 | document.body.classList.add("-theme-with-dark-background");
11 | } else {
12 | document.body.classList.remove("-theme-with-dark-background");
13 | }
14 | };
15 |
16 | const hasOnThemeChanged =
17 | typeof browser.devtools.panels.onThemeChanged !== "undefined";
18 |
19 | hasOnThemeChanged &&
20 | browser.devtools.panels.onThemeChanged.addListener(listener);
21 |
22 | if (browser.runtime.getPlatformInfo) {
23 | browser.runtime.getPlatformInfo().then((info) => {
24 | document.body.classList.add("platform-" + info.os);
25 | });
26 | }
27 |
28 | return () => {
29 | hasOnThemeChanged &&
30 | browser.devtools.panels.onThemeChanged.removeListener(listener);
31 | };
32 | }, []);
33 | };
34 |
35 | export const WithPlatformClass = () => {
36 | usePlatformClass();
37 |
38 | return null;
39 | };
40 |
--------------------------------------------------------------------------------
/entrypoints/devtools/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/entrypoints/devtools/main.ts:
--------------------------------------------------------------------------------
1 | import { onMessage } from "webext-bridge/devtools";
2 |
3 | onMessage("resize", () => {
4 | // Dummy listener to prevent error:
5 | // Error: [webext-bridge] No handler registered in 'devtools' to accept messages with id 'resize'
6 | });
7 |
8 | onMessage("focus", () => {
9 | // Dummy listener to prevent error:
10 | // Error: [webext-bridge] No handler registered in 'devtools' to accept messages with id 'resize'
11 | });
12 |
13 | browser.devtools.panels.elements
14 | .createSidebarPane("Atomic CSS")
15 | .then((pane) => {
16 | pane.setPage("devtools-pane.html");
17 | pane.onShown.addListener(() => {
18 | browser.runtime.sendMessage({ type: "devtools-shown" });
19 | });
20 | pane.onHidden.addListener(() => {
21 | browser.runtime.sendMessage({ type: "devtools-hidden" });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Atomic CSS Devtools
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "atomic-css-devtools",
3 | "description": "A devtool panel for debugging Atomic CSS rules as if they were not atomic",
4 | "private": true,
5 | "version": "0.0.9-dev",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "wxt",
9 | "dev:firefox": "wxt -b firefox",
10 | "build": "wxt build",
11 | "build:firefox": "wxt build -b firefox",
12 | "zip": "wxt zip",
13 | "zip:firefox": "wxt zip -b firefox",
14 | "compile": "tsc --noEmit",
15 | "postinstall": "wxt prepare && panda",
16 | "test": "vitest",
17 | "typecheck": "tsc --noEmit",
18 | "play": "vite",
19 | "fmt": "prettier --write ."
20 | },
21 | "imports": {
22 | "#components/*": "./components/*",
23 | "#lib/*": "./lib/*",
24 | "#styled-system/*": "./styled-system/*"
25 | },
26 | "dependencies": {
27 | "@ark-ui/react": "^2.2.3",
28 | "@pandacss/shared": "^0.37.2",
29 | "@webext-core/messaging": "^1.4.0",
30 | "@webext-core/proxy-service": "^1.2.0",
31 | "@xstate/store": "^0.0.3",
32 | "@zag-js/color-utils": "^0.48.0",
33 | "@zag-js/interact-outside": "^0.45.0",
34 | "@zag-js/popper": "^0.47.0",
35 | "lucide-react": "^0.365.0",
36 | "media-query-fns": "^2.0.0",
37 | "react": "^18.3.1",
38 | "react-dom": "^18.3.1",
39 | "react-hotkeys-hook": "^4.5.1",
40 | "ts-pattern": "^5.5.0",
41 | "webext-bridge": "^6.0.1"
42 | },
43 | "devDependencies": {
44 | "@pandabox/prettier-plugin": "^0.1.3",
45 | "@pandacss/dev": "^0.37.2",
46 | "@types/react": "^18.3.11",
47 | "@types/react-dom": "^18.3.1",
48 | "@vitejs/plugin-react": "^4.3.2",
49 | "prettier": "^3.3.3",
50 | "type-fest": "^4.26.1",
51 | "typescript": "^5.6.3",
52 | "vite": "^5.4.9",
53 | "vite-plugin-inspect": "^0.8.7",
54 | "vitest": "^1.6.0",
55 | "wxt": "^0.17.12"
56 | },
57 | "author": "Alexandre Stahmer",
58 | "homepage": "https://twitter.com/astahmer_dev",
59 | "repository": {
60 | "type": "git",
61 | "url": "git+https://github.com/astahmer/atomic-css-devtools"
62 | },
63 | "packageManager": "pnpm@9.12.2+sha256.2ef6e547b0b07d841d605240dce4d635677831148cd30f6d564b8f4f928f73d2"
64 | }
65 |
--------------------------------------------------------------------------------
/panda.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "@pandacss/dev";
2 | import preset from "./panda.preset";
3 |
4 | export default defineConfig({
5 | presets: ["@pandacss/dev/presets", preset],
6 | // Whether to use css reset
7 | preflight: true,
8 |
9 | // Where to look for your css declarations
10 | include: [
11 | "./{components,entrypoints,lib,src,playground}/**/*.{js,jsx,ts,tsx}",
12 | ],
13 |
14 | // Files to exclude
15 | exclude: [],
16 |
17 | // Useful for theme customization
18 | theme: {
19 | extend: {},
20 | },
21 |
22 | // The output directory for your css system
23 | outdir: "styled-system",
24 | jsxFramework: "react",
25 | // importMap: "styled-system",
26 | hooks: {
27 | "parser:before": ({ configure }) => {
28 | configure({
29 | // ignore the entirely,
30 | // prevents: `🐼 error [sheet:process] > 1 | .content_Hide_\`\*\,_\:before\,_\:after\`_styles {content: Hide `*, :before, :after` styles;`
31 | matchTag: (tag) => tag !== "Tooltip",
32 | });
33 | },
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/panda.preset.ts:
--------------------------------------------------------------------------------
1 | import { definePreset } from "@pandacss/dev";
2 |
3 | export default definePreset({
4 | conditions: {
5 | extend: {
6 | dark: ".-theme-with-dark-background &, .dark &",
7 | },
8 | },
9 | theme: {
10 | extend: {
11 | tokens: {
12 | colors: {
13 | devtools: {
14 | // https://github.com/ChromeDevTools/devtools-frontend/blob/368d71862d3726025131629fc18a887954750531/front_end/ui/legacy/tokens.css#L157
15 | // https://github.com/szoxidy/Websites/blob/c96a6db64901830792678cd1c9a4c27c37f2be28/css/color.css#L65
16 | surface4: { value: "#eceff7ff" }, // color-mix(in sRGB,#d1e1ff 12%,var(--ref-palette-neutral10))
17 | neutral10: { value: "#1f1f1fff" },
18 | neutral15: { value: "#282828ff" },
19 | neutral25: { value: "#3c3c3cff" },
20 | neutral50: { value: "#757575ff" },
21 | neutral60: { value: "#8f8f8fff" },
22 | neutral80: { value: "#c7c7c7ff" },
23 | neutral90: { value: "#e3e3e3ff" },
24 | neutral95: { value: "#f2f2f2ff" },
25 | neutral98: { value: "#faf9f8ff" },
26 | neutral99: { value: "#fdfcfbff" },
27 | primary20: { value: "#062e6fff" },
28 | primary50: { value: "#1a73e8ff" },
29 | primary70: { value: "#7cacf8ff" },
30 | primary90: { value: "#d3e3fdff" },
31 | primary100: { value: "#ffffffff" },
32 | secondary25: { value: "#003f66ff" },
33 | secondary30: { value: "#004a77ff" },
34 | error50: { value: "#dc362eff" },
35 | cyan80: { value: "rgb(92 213 251 / 100%)" },
36 | },
37 | },
38 | },
39 | semanticTokens: {
40 | colors: {
41 | // https://github.com/ChromeDevTools/devtools-frontend/blob/368d71862d3726025131629fc18a887954750531/front_end/ui/legacy/themeColors.css#L302
42 | devtools: {
43 | "base-container": {
44 | value: {
45 | base: "{colors.devtools.surface4}",
46 | _dark: "{colors.devtools.neutral15}",
47 | },
48 | },
49 | "cdt-base-container": {
50 | value: {
51 | base: "{colors.devtools.neutral98}",
52 | _dark: "{colors.devtools.base-container}",
53 | },
54 | },
55 | "tonal-container": {
56 | value: {
57 | base: "{colors.devtools.primary90}",
58 | _dark: "{colors.devtools.secondary30}",
59 | },
60 | },
61 | "state-hover-on-subtle": {
62 | value: {
63 | base: "{colors.devtools.neutral10/6}",
64 | _dark: "{colors.devtools.neutral99/10}",
65 | },
66 | },
67 | "state-disabled": {
68 | value: {
69 | base: "rgb(31 31 31 / 38%)",
70 | _dark: "rgb(227 227 227 / 38%)",
71 | },
72 | },
73 | "primary-bright": {
74 | value: {
75 | base: "{colors.devtools.primary50}",
76 | _dark: "{colors.devtools.primary70}",
77 | },
78 | },
79 | "neutral-outline": {
80 | value: {
81 | base: "{colors.devtools.neutral80}",
82 | _dark: "{colors.devtools.neutral50}",
83 | },
84 | },
85 | "neutral-container": {
86 | value: {
87 | base: "{colors.devtools.neutral95}",
88 | _dark: "{colors.devtools.neutral25}",
89 | },
90 | },
91 | "on-primary": {
92 | value: {
93 | base: "{colors.devtools.primary20}",
94 | _dark: "{colors.devtools.primary100}",
95 | },
96 | },
97 | "on-surface": {
98 | value: {
99 | base: "{colors.devtools.neutral10}",
100 | _dark: "{colors.devtools.neutral90}",
101 | },
102 | },
103 | "token-property-special": {
104 | value: {
105 | base: "{colors.devtools.error50}",
106 | _dark: "{colors.devtools.cyan80}",
107 | },
108 | },
109 | "token-subtle": {
110 | value: {
111 | base: "{colors.devtools.neutral60}",
112 | _dark: "{colors.devtools.neutral60}",
113 | },
114 | },
115 | },
116 | },
117 | },
118 | },
119 | },
120 | });
121 |
--------------------------------------------------------------------------------
/playground/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | "eslint:recommended",
6 | "plugin:@typescript-eslint/recommended",
7 | "plugin:react-hooks/recommended",
8 | ],
9 | ignorePatterns: ["dist", ".eslintrc.cjs"],
10 | parser: "@typescript-eslint/parser",
11 | plugins: ["react-refresh"],
12 | rules: {
13 | "react-refresh/only-export-components": [
14 | "warn",
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/playground/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/playground/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/playground/src/browser-context.tsx:
--------------------------------------------------------------------------------
1 | import { DevtoolsContextValue, Evaluator } from "../../src/devtools-context";
2 | import { ContentScriptApi } from "../../src/devtools-messages";
3 | import { inspectApi } from "../../src/inspect-api";
4 | import {
5 | getInspectedElement,
6 | inspectedElementSelector,
7 | listeners,
8 | } from "./inspected";
9 |
10 | const noop = () => {};
11 |
12 | const evaluator: Evaluator = {
13 | fn: (fn, ...args) => {
14 | return new Promise((resolve, reject) => {
15 | try {
16 | const result = fn(...args);
17 | resolve(result);
18 | } catch (error) {
19 | reject(error);
20 | }
21 | });
22 | },
23 | el: (fn, ...args) => {
24 | return new Promise((resolve, reject) => {
25 | try {
26 | const element = getInspectedElement();
27 | const result = fn(element, ...args);
28 | resolve(result);
29 | } catch (error) {
30 | reject(error);
31 | }
32 | });
33 | },
34 | copy: async (valueToCopy: string) => {
35 | navigator.clipboard.writeText(valueToCopy);
36 | },
37 | inspect: async () => {
38 | return inspectApi.inspectElement([inspectedElementSelector]);
39 | },
40 | onSelectionChanged: (cb) => {
41 | const handleSelectionChanged = async () => {
42 | const result = await evaluator.inspect();
43 | cb(result ?? null);
44 | };
45 | listeners.set("selectionChanged", handleSelectionChanged);
46 |
47 | return noop;
48 | },
49 | };
50 |
51 | const contentScript: ContentScriptApi = {
52 | inspectElement: async () => {
53 | return inspectApi.inspectElement([inspectedElementSelector]);
54 | },
55 | computePropertyValue: async (message) => {
56 | return inspectApi.computePropertyValue(message.selectors, message.prop);
57 | },
58 | updateStyleRule: async (message) => {
59 | return inspectApi.updateStyleAction(message);
60 | },
61 | appendInlineStyle: async (message) => {
62 | return inspectApi.appendInlineStyleAction(message);
63 | },
64 | removeInlineStyle: async (message) => {
65 | return inspectApi.removeInlineStyleAction(message);
66 | },
67 | highlightSelector: async (message) => {
68 | return inspectApi.highlightSelector(message);
69 | },
70 | };
71 |
72 | export const browserContext: DevtoolsContextValue = {
73 | evaluator,
74 | onDevtoolEvent: (event, cb) => {
75 | listeners.set(event, cb);
76 | },
77 | contentScript,
78 | onContentScriptMessage: {
79 | resize: () => noop,
80 | focus: () => noop,
81 | },
82 | };
83 |
--------------------------------------------------------------------------------
/playground/src/element-details.tsx:
--------------------------------------------------------------------------------
1 | import { css } from "../../styled-system/css";
2 |
3 | export interface ElementDetailsData {
4 | tagName: string;
5 | classes: string;
6 | dimensions: string;
7 | color?: string;
8 | font?: string;
9 | background?: string;
10 | padding?: string;
11 | margin?: string;
12 | }
13 |
14 | export const ElementDetails = ({
15 | details,
16 | }: {
17 | details: ElementDetailsData;
18 | }) => {
19 | return (
20 | <>
21 |
22 | {details.tagName}
23 |
24 | .
25 | {details.classes.slice(0, 79) +
26 | (details.classes.length > 79 ? "..." : "")}
27 |
28 |
29 |
30 | Dimensions
31 | {details.dimensions}
32 |
33 | {details.color && (
34 |
35 |
Color
36 |
37 |
41 | {details.color}
42 |
43 |
44 | )}
45 | {details.font && (
46 |
47 | Font
48 | {details.font}
49 |
50 | )}
51 | {details.background && (
52 |
53 |
Background
54 |
55 |
59 | {details.background}
60 |
61 |
62 | )}
63 | {details.padding && (
64 |
65 | Padding
66 | {details.padding}
67 |
68 | )}
69 | {details.margin && (
70 |
71 | Margin
72 | {details.margin}
73 |
74 | )}
75 | >
76 | );
77 | };
78 |
79 | const tagStyle = css({
80 | color: "blue",
81 | fontWeight: "bold",
82 | });
83 |
84 | const classStyle = css({
85 | color: "green",
86 | textOverflow: "ellipsis",
87 | fontSize: "11px",
88 | fontWeight: "bold",
89 | overflow: "hidden",
90 | whiteSpace: "nowrap",
91 | });
92 |
93 | const itemStyle = css({
94 | display: "flex",
95 | gap: "4",
96 | justifyContent: "space-between",
97 | paddingY: "2px",
98 | });
99 |
100 | const colorPreviewStyle = css({
101 | display: "inline-block",
102 | border: "1px solid #ddd",
103 | width: "16px",
104 | height: "16px",
105 | marginRight: "5px",
106 | verticalAlign: "middle",
107 | });
108 |
--------------------------------------------------------------------------------
/playground/src/element-inspector.tsx:
--------------------------------------------------------------------------------
1 | import { Portal } from "@ark-ui/react";
2 | import { getPlacement, getPlacementStyles } from "@zag-js/popper";
3 | import React, { useEffect, useRef, useState } from "react";
4 | import { Declaration } from "../../src/declaration";
5 | import { InspectResult, inspectApi } from "../../src/inspect-api";
6 | import { getHighlightsStyles } from "../../src/lib/get-highlights-styles";
7 | import { computeStyles } from "../../src/lib/rules";
8 | import { css, cx } from "../../styled-system/css";
9 | import { ElementDetails, ElementDetailsData } from "./element-details";
10 | import { getInspectedElement } from "./inspected";
11 |
12 | export const ElementInspector = ({
13 | onInspect,
14 | view = "normal",
15 | }: {
16 | onInspect: (element: HTMLElement) => void;
17 | view?: "normal" | "atomic";
18 | }) => {
19 | const floatingRef = useRef(null);
20 |
21 | const [tooltipInfo, setTooltipInfo] = useState(
22 | null as { styles: React.CSSProperties; details: ElementDetailsData } | null,
23 | );
24 | const [highlightStyles, setHighlightStyles] = useState(
25 | [],
26 | );
27 |
28 | const update = (element: HTMLElement) => {
29 | const rect = element.getBoundingClientRect();
30 | const computedStyle = window.getComputedStyle(element);
31 |
32 | const tooltipData = {
33 | tagName: element.tagName.toLowerCase(),
34 | classes: Array.from(element.classList).join("."),
35 | dimensions: `${Math.round(rect.width)} x ${Math.round(rect.height)}`,
36 | color: computedStyle.color,
37 | font: `${computedStyle.fontSize}, ${computedStyle.fontFamily}`,
38 | background: computedStyle.backgroundColor,
39 | padding: `${computedStyle.paddingTop} ${computedStyle.paddingRight} ${computedStyle.paddingBottom} ${computedStyle.paddingLeft}`,
40 | margin: `${computedStyle.marginTop} ${computedStyle.marginRight} ${computedStyle.marginBottom} ${computedStyle.marginLeft}`,
41 | };
42 |
43 | const clean = getPlacement(element, () => floatingRef.current!, {
44 | sameWidth: false,
45 | overlap: true,
46 | onComplete: (data) => {
47 | const styles = getPlacementStyles(data).floating;
48 | setTooltipInfo({
49 | styles: { ...styles, minWidth: undefined },
50 | details: tooltipData,
51 | });
52 | },
53 | });
54 |
55 | setHighlightStyles(getHighlightsStyles(element) as React.CSSProperties[]);
56 |
57 | clean();
58 |
59 | const inspectResult = inspectApi.inspectElement([], element);
60 | setInspected(inspectResult ?? null);
61 | };
62 |
63 | // Inspect clicked element
64 | useEffect(() => {
65 | const handleClick = (event: MouseEvent) => {
66 | event.preventDefault();
67 | event.stopImmediatePropagation();
68 | event.stopPropagation();
69 |
70 | const element = event.target as HTMLElement;
71 | const currentInspectedElement = getInspectedElement();
72 |
73 | if (currentInspectedElement) {
74 | currentInspectedElement.removeAttribute("data-inspected-element");
75 | }
76 |
77 | element.dataset.inspectedElement = "";
78 | onInspect(element);
79 |
80 | return false;
81 | };
82 |
83 | document.addEventListener("click", handleClick, true);
84 |
85 | return () => {
86 | document.removeEventListener("click", handleClick, true);
87 | };
88 | }, []);
89 |
90 | // Add highlight styles when hovering over an element
91 | useEffect(() => {
92 | const handleMouseOver = (event: MouseEvent) => {
93 | const element = event.target as HTMLElement;
94 | update(element);
95 | };
96 |
97 | // Whenever we move the element that triggered the inspect state,
98 | // update the tooltip/highlight styles
99 | document.addEventListener(
100 | "mousemove",
101 | (e) => {
102 | const element = e.target as HTMLElement;
103 | update(element as HTMLElement);
104 | },
105 | { once: true },
106 | );
107 |
108 | document.addEventListener("mouseover", handleMouseOver);
109 | return () => {
110 | document.removeEventListener("mouseover", handleMouseOver);
111 | };
112 | }, []);
113 |
114 | const [inspected, setInspected] = useState(null);
115 | const computed = inspected && computeStyles(inspected.rules);
116 |
117 | return (
118 |
119 |
125 | {highlightStyles.map((style, index) => (
126 |
127 | ))}
128 |
175 |
176 |
177 | );
178 | };
179 |
180 | const tooltipStyles = css.raw({
181 | display: "flex",
182 | zIndex: "9999!",
183 | position: "absolute",
184 | gap: "2px",
185 | flexDirection: "column",
186 | border: "1px solid #aaa",
187 | borderRadius: "8px",
188 | maxHeight: "300px",
189 | padding: "10px",
190 | color: "#333",
191 | fontSize: "12px",
192 | backgroundColor: "#f9f9f9",
193 | boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
194 | overflow: "hidden",
195 | });
196 |
--------------------------------------------------------------------------------
/playground/src/inspected.ts:
--------------------------------------------------------------------------------
1 | export const inspectedElementSelector = "[data-inspected-element]";
2 | export const getInspectedElement = () =>
3 | document.querySelector(inspectedElementSelector) as HTMLElement;
4 |
5 | export const listeners = new Map void>();
6 |
--------------------------------------------------------------------------------
/playground/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import Playground from "./playground.tsx";
4 | import "./panda.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/playground/src/panda.css:
--------------------------------------------------------------------------------
1 | @layer reset, base, tokens, recipes, utilities;
2 |
3 | html,
4 | body,
5 | #root {
6 | height: 100%;
7 | }
8 |
9 | html {
10 | overflow: hidden;
11 | padding: 3px 0;
12 | }
13 |
14 | #root {
15 | position: relative;
16 | z-index: 0;
17 | --z-index: 10; /* z-index for tooltips */
18 | }
19 |
20 | body {
21 | background-color: var(--colors-devtools-cdt-base-container);
22 | color: var(--colors-devtools-on-surface);
23 | }
24 |
25 | .platform-mac ::-webkit-scrollbar {
26 | width: 8px;
27 | padding: 2px;
28 | }
29 |
30 | .platform-mac ::-webkit-scrollbar-thumb {
31 | background-color: #6b6b6b;
32 | border-radius: 999px;
33 | }
34 |
35 | .platform-mac ::-webkit-scrollbar-thumb:hover {
36 | background-color: #939393;
37 | }
38 |
39 | ::selection {
40 | background-color: var(--colors-devtools-tonal-container, rgb(0, 74, 119));
41 | }
42 |
--------------------------------------------------------------------------------
/playground/src/playground.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { DevtoolsProvider } from "../../src/devtools-context";
3 | import { SidebarPane } from "../../src/sidebar-pane";
4 | import { css } from "../../styled-system/css";
5 | import { Box, Flex, HStack, Stack } from "../../styled-system/jsx";
6 | import { browserContext } from "./browser-context";
7 | import { ElementInspector } from "./element-inspector";
8 | import { listeners } from "./inspected";
9 |
10 | function Playground() {
11 | const [isInspecting, setIsInspecting] = useState(false);
12 | const [isAtomic, setAtomic] = useState(false);
13 |
14 | return (
15 |
16 |
17 | {isInspecting && (
18 | {
20 | setIsInspecting(false);
21 | listeners.get("selectionChanged")?.();
22 | }}
23 | view={isAtomic ? "atomic" : "normal"}
24 | />
25 | )}
26 |
27 |
28 |
36 | Atomic CSS Devtools
37 |
38 | {
47 | setAtomic(!isAtomic);
48 | }}
49 | >
50 | view: {isAtomic ? "atomic" : "normal"}
51 |
52 |
53 |
54 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | export default Playground;
82 |
--------------------------------------------------------------------------------
/playground/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | "@pandacss/dev/postcss": {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/public/cross-circle-filled.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icon/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/icon/128.png
--------------------------------------------------------------------------------
/public/icon/16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/icon/16.png
--------------------------------------------------------------------------------
/public/icon/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/icon/32.png
--------------------------------------------------------------------------------
/public/icon/48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/icon/48.png
--------------------------------------------------------------------------------
/public/icon/96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/icon/96.png
--------------------------------------------------------------------------------
/public/screen1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/screen1.jpg
--------------------------------------------------------------------------------
/public/screen2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/screen2.jpg
--------------------------------------------------------------------------------
/public/screen3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/screen3.jpg
--------------------------------------------------------------------------------
/public/screen4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/public/screen4.jpg
--------------------------------------------------------------------------------
/public/wxt.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/asserts.ts:
--------------------------------------------------------------------------------
1 | // we have to check against the constructor.name because:
2 | // - `{rule}.type` is still available but deprecated
3 | // - `{rule} instanceof CSS{rule}` might not be the same in different JS contexts (e.g. iframe)
4 |
5 | const isCSSStyleRule = (rule: CSSRule): rule is CSSStyleRule => {
6 | return (
7 | rule.constructor.name === "CSSStyleRule" ||
8 | rule.type === rule.STYLE_RULE ||
9 | rule instanceof CSSStyleRule
10 | );
11 | };
12 |
13 | const isCSSMediaRule = (rule: CSSRule): rule is CSSMediaRule => {
14 | return (
15 | rule.constructor.name === "CSSMediaRule" ||
16 | rule.type === rule.MEDIA_RULE ||
17 | rule instanceof CSSMediaRule
18 | );
19 | };
20 |
21 | const isCSSLayerBlockRule = (rule: CSSRule): rule is CSSLayerBlockRule => {
22 | return (
23 | rule.constructor.name === "CSSLayerBlockRule" ||
24 | (rule.type === 0 &&
25 | rule.cssText.startsWith("@layer ") &&
26 | rule.cssText.includes("{")) ||
27 | rule instanceof CSSLayerBlockRule
28 | );
29 | };
30 |
31 | const isCSSLayerStatementRule = (
32 | rule: CSSRule,
33 | ): rule is CSSLayerStatementRule => {
34 | return (
35 | rule.constructor.name === "CSSLayerStatementRule" ||
36 | (rule.type === 0 &&
37 | rule.cssText.startsWith("@layer ") &&
38 | !rule.cssText.includes("{")) ||
39 | rule instanceof CSSLayerStatementRule
40 | );
41 | };
42 |
43 | const isElement = (obj: any): obj is Element => {
44 | return (
45 | obj != null && typeof obj === "object" && obj.nodeType === Node.ELEMENT_NODE
46 | );
47 | };
48 |
49 | const isHTMLIFrameElement = (obj: any): obj is HTMLIFrameElement => {
50 | return (
51 | obj.constructor.name === "HTMLIFrameElement" ||
52 | obj instanceof HTMLIFrameElement
53 | );
54 | };
55 |
56 | const isDocument = (obj: any): obj is Document => {
57 | return (
58 | obj != null &&
59 | typeof obj === "object" &&
60 | obj.nodeType === Node.DOCUMENT_NODE
61 | );
62 | };
63 |
64 | const isShadowRoot = (obj: any): obj is ShadowRoot => {
65 | return obj.constructor.name === "ShadowRoot";
66 | };
67 |
68 | const isHTMLStyleElement = (obj: any): obj is HTMLStyleElement => {
69 | return obj.constructor.name === "HTMLStyleElement";
70 | };
71 |
72 | export const asserts = {
73 | isCSSStyleRule,
74 | isCSSMediaRule,
75 | isCSSLayerBlockRule,
76 | isCSSLayerStatementRule,
77 | isElement,
78 | isHTMLIFrameElement,
79 | isDocument,
80 | isShadowRoot,
81 | isHTMLStyleElement,
82 | };
83 |
--------------------------------------------------------------------------------
/src/declaration-group.tsx:
--------------------------------------------------------------------------------
1 | import { Collapsible } from "@ark-ui/react";
2 | import { ReactNode } from "react";
3 | import { css, cx } from "#styled-system/css";
4 | import { styled } from "#styled-system/jsx";
5 | import { flex } from "#styled-system/patterns";
6 |
7 | interface DeclarationGroupProps {
8 | label: ReactNode;
9 | content: ReactNode;
10 | }
11 |
12 | export const DeclarationGroup = (props: DeclarationGroupProps) => {
13 | const { label, content } = props;
14 |
15 | return (
16 |
17 |
18 |
19 |
33 |
47 |
54 | {label}
55 |
56 |
57 |
58 | {content}
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/declaration-list.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction } from "react";
2 | import { Declaration } from "./declaration";
3 | import { InspectResult } from "./inspect-api";
4 | import { StyleRuleWithProp } from "./lib/rules";
5 | import { OverrideMap } from "./devtools-types";
6 | import { symbols } from "./lib/symbols";
7 |
8 | interface DeclarationListProps {
9 | rules: StyleRuleWithProp[];
10 | inspected: InspectResult;
11 | overrides: OverrideMap | null;
12 | setOverrides: Dispatch>;
13 | }
14 |
15 | export const DeclarationList = (props: DeclarationListProps) => {
16 | const { rules, inspected, overrides, setOverrides } = props;
17 | return rules.map((rule, index) => {
18 | const prop = rule.prop;
19 | return (
20 |
30 | setOverrides((overrides) => ({
31 | ...overrides,
32 | [symbols.overrideKey]: prop,
33 | [prop]: value != null ? { value, computed } : null,
34 | })),
35 | }}
36 | />
37 | );
38 | });
39 | };
40 |
--------------------------------------------------------------------------------
/src/declaration.tsx:
--------------------------------------------------------------------------------
1 | import { parseColor } from "@zag-js/color-utils";
2 | import * as TooltipPrimitive from "#components/tooltip";
3 | import { Portal } from "@ark-ui/react";
4 | import { useSelector } from "@xstate/store/react";
5 | import { useId, useState } from "react";
6 | import { css } from "#styled-system/css";
7 | import { styled } from "#styled-system/jsx";
8 | import { Tooltip } from "#components/tooltip";
9 | import { EditableValue, EditableValueProps } from "./editable-value";
10 | import { HighlightMatch } from "./highlight-match";
11 | import type { InspectResult } from "./inspect-api";
12 | import type { MatchedStyleRule } from "./devtools-types";
13 | import { hypenateProperty } from "./lib/hyphenate-proprety";
14 | import { isColor } from "./lib/is-color";
15 | import { symbols } from "./lib/symbols";
16 | import { unescapeString } from "./lib/unescape-string";
17 | import { store } from "./store";
18 | import { useDevtoolsContext } from "./devtools-context";
19 |
20 | interface DeclarationProps
21 | extends Pick<
22 | EditableValueProps,
23 | "prop" | "override" | "setOverride" | "isRemovable" | "refresh"
24 | > {
25 | index: number;
26 | matchValue: string;
27 | rule: MatchedStyleRule;
28 | inspected: InspectResult;
29 | hasLineThrough?: boolean;
30 | }
31 |
32 | export const checkboxStyles = css.raw({
33 | width: "13px",
34 | height: "13px",
35 | px: "4px",
36 | color: "devtools.on-primary",
37 | accentColor: "devtools.primary-bright",
38 | fontSize: "10px",
39 | });
40 |
41 | export const Declaration = (props: DeclarationProps) => {
42 | const {
43 | prop,
44 | index,
45 | matchValue,
46 | rule,
47 | inspected,
48 | override,
49 | setOverride,
50 | hasLineThrough,
51 | isRemovable,
52 | refresh,
53 | } = props;
54 |
55 | let computedValue = override?.computed;
56 |
57 | if (matchValue.includes("var(--") && inspected.cssVars[matchValue]) {
58 | computedValue = inspected.cssVars[matchValue];
59 | }
60 |
61 | if (computedValue == null) {
62 | if (rule.selector === symbols.inlineStyleSelector) {
63 | computedValue = matchValue;
64 | } else {
65 | computedValue = inspected.computedStyle[prop];
66 | }
67 | }
68 |
69 | const prettySelector = unescapeString(rule.selector);
70 | const isTogglable =
71 | rule.selector === symbols.inlineStyleSelector ||
72 | (prettySelector.startsWith(".") && !prettySelector.includes(" "));
73 |
74 | const [enabled, setEnabled] = useState(true);
75 | const id = useId();
76 | const filter = useSelector(store, (s) => s.context.filter);
77 | const showSelector = useSelector(store, (s) => s.context.showSelector);
78 |
79 | const { evaluator, contentScript } = useDevtoolsContext();
80 | const colorPickerId = useId();
81 |
82 | return (
83 |
93 | {
108 | if (rule.selector === symbols.inlineStyleSelector) {
109 | const enabled = e.target.checked;
110 | const result = await contentScript.updateStyleRule({
111 | selectors: inspected.elementSelectors,
112 | prop: prop,
113 | value: matchValue,
114 | kind: "inlineStyle",
115 | atIndex: index,
116 | isCommented: !enabled,
117 | });
118 |
119 | if (result.hasUpdated) {
120 | setEnabled(enabled);
121 | }
122 |
123 | return;
124 | }
125 |
126 | // We can only toggle atomic classes
127 | if (!isTogglable) {
128 | return;
129 | }
130 |
131 | const isEnabled = await evaluator.el((el, className) => {
132 | try {
133 | return el.classList.toggle(className);
134 | } catch (err) {
135 | console.log(err);
136 | }
137 | }, prettySelector.slice(1));
138 |
139 | if (typeof isEnabled === "boolean") {
140 | setEnabled(isEnabled);
141 | }
142 | }}
143 | />
144 | {/* TODO editable property */}
145 |
146 |
155 |
156 | {hypenateProperty(prop)}
157 |
158 |
159 | :
160 | {isColor(computedValue) && (
161 |
199 | )}
200 |
211 | {matchValue.startsWith("var(--") &&
212 | computedValue &&
213 | computedValue !== (override?.value ?? matchValue) && (
214 | {
221 | const tooltipTrigger = document.querySelector(
222 | `[data-tooltipid="trigger${prop + index}" ]`,
223 | ) as HTMLElement;
224 | if (!tooltipTrigger) return;
225 |
226 | if (details.open) {
227 | const tooltipContent = document.querySelector(
228 | `[data-tooltipid="content${prop + index}" ]`,
229 | )?.parentElement as HTMLElement;
230 | if (!tooltipContent) return;
231 |
232 | if (!tooltipContent.dataset.overflow) return;
233 |
234 | tooltipTrigger.style.textDecoration = "underline";
235 | return;
236 | }
237 |
238 | tooltipTrigger.style.textDecoration = "";
239 | return;
240 | }}
241 | >
242 |
243 |
254 | {computedValue}
255 |
256 |
257 |
258 |
259 | {
262 | const tooltipTrigger = document.querySelector(
263 | `[data-tooltipid="trigger${prop + index}" ]`,
264 | ) as HTMLElement;
265 | if (!tooltipTrigger) return;
266 |
267 | const tooltipContent = node as HTMLElement;
268 | if (!tooltipContent) return;
269 |
270 | if (
271 | tooltipTrigger.offsetWidth < tooltipTrigger.scrollWidth
272 | ) {
273 | // Text is overflowing, add tooltip
274 | tooltipContent.style.display = "";
275 | tooltipContent.dataset.overflow = "true";
276 | } else {
277 | tooltipContent.style.display = "none";
278 | }
279 | }}
280 | >
281 |
286 | {computedValue}
287 |
288 |
289 |
290 |
291 |
292 | )}
293 | {showSelector && (
294 |
299 |
303 | {rule.layer && (
304 |
305 | {`@layer ${rule.layer} \n\n `}
306 |
307 | )}
308 | {rule.media && (
309 |
310 | {`@media ${rule.media} \n\n `}
311 |
312 | )}
313 |
317 | {prettySelector}
318 |
319 | {rule.media && {"}"}}
320 | {rule.layer && {"}"}}
321 | {rule.source}
322 | >
323 | }
324 | >
325 | {
327 | await evaluator.copy(prettySelector);
328 | }}
329 | onMouseOver={() => {
330 | contentScript.highlightSelector({
331 | selectors:
332 | rule.selector === symbols.inlineStyleSelector
333 | ? inspected.elementSelectors
334 | : [rule.selector],
335 | });
336 | }}
337 | onMouseOut={(e) => {
338 | // Skip if the mouse is hovering same selector
339 | if (
340 | e.target instanceof HTMLElement &&
341 | e.relatedTarget instanceof HTMLElement &&
342 | e.target.innerText === e.relatedTarget.innerText
343 | ) {
344 | return;
345 | }
346 |
347 | // Clear highlights
348 | contentScript.highlightSelector({ selectors: [] });
349 | }}
350 | maxWidth={{
351 | base: "150px",
352 | sm: "200px",
353 | md: "300px",
354 | }}
355 | // cursor="pointer"
356 | textDecoration={{
357 | _hover: "underline",
358 | }}
359 | textOverflow="ellipsis"
360 | opacity="0.7"
361 | overflow="hidden"
362 | whiteSpace="nowrap"
363 | >
364 |
365 | {prettySelector}
366 |
367 |
368 |
369 |
370 | )}
371 |
372 | );
373 | };
374 |
--------------------------------------------------------------------------------
/src/devtools-context.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from "react";
2 | import { ContentScriptApi, DevtoolsListeners } from "./devtools-messages";
3 | import { InspectResult } from "./inspect-api";
4 | import { AnyElementFunction, AnyFunction, WithoutFirst } from "./lib/types";
5 |
6 | const DevtoolsContext = createContext({} as any);
7 | export const DevtoolsProvider = DevtoolsContext.Provider;
8 | export const useDevtoolsContext = () => useContext(DevtoolsContext);
9 |
10 | export interface Evaluator {
11 | fn: (
12 | fn: T,
13 | ...args: Parameters
14 | ) => Promise>;
15 | el: (
16 | fn: T,
17 | ...args: WithoutFirst
18 | ) => Promise>;
19 | copy: (valueToCopy: string) => Promise;
20 | inspect: () => Promise;
21 | onSelectionChanged: (
22 | cb: (element: InspectResult | null) => void,
23 | ) => () => void;
24 | }
25 |
26 | export interface DevtoolsContextValue {
27 | evaluator: Evaluator;
28 | onDevtoolEvent: (
29 | event: "devtools-shown" | "devtools-hidden",
30 | cb: () => void,
31 | ) => void;
32 | contentScript: ContentScriptApi;
33 | onContentScriptMessage: DevtoolsListeners;
34 | }
35 |
--------------------------------------------------------------------------------
/src/devtools-messages.ts:
--------------------------------------------------------------------------------
1 | import type { WindowEnv } from "./devtools-types";
2 | import type {
3 | InspectAPI,
4 | RemoveInlineStyle,
5 | UpdateStyleRuleMessage,
6 | } from "./inspect-api";
7 |
8 | interface InlineStyleReturn {
9 | hasUpdated: boolean;
10 | computedValue: string | null;
11 | }
12 |
13 | export type DevtoolsMessage = { data: Data; return: Return };
14 | export interface ContentScriptEvents {
15 | // devtools to contentScript
16 | inspectElement: DevtoolsMessage<
17 | { selectors: string[] },
18 | ReturnType
19 | >;
20 | computePropertyValue: DevtoolsMessage<
21 | { selectors: string[]; prop: string },
22 | ReturnType
23 | >;
24 | updateStyleRule: DevtoolsMessage;
25 | appendInlineStyle: DevtoolsMessage<
26 | Omit,
27 | InlineStyleReturn
28 | >;
29 | removeInlineStyle: DevtoolsMessage<
30 | RemoveInlineStyle & Pick,
31 | InlineStyleReturn
32 | >;
33 | highlightSelector: DevtoolsMessage<
34 | { selectors: string[] },
35 | ReturnType
36 | >;
37 | }
38 |
39 | export type ContentScriptApi = {
40 | [T in keyof ContentScriptEvents]: ContentScriptEvents[T] extends DevtoolsMessage<
41 | infer Data,
42 | infer Return
43 | >
44 | ? (args: Data) => Promise
45 | : (args: ContentScriptEvents[T]) => Promise;
46 | };
47 |
48 | /**
49 | * contentScript to devtools
50 | */
51 | export interface DevtoolsApiEvents {
52 | resize: DevtoolsMessage;
53 | focus: DevtoolsMessage;
54 | }
55 |
56 | export type DevtoolsApi = {
57 | [T in keyof DevtoolsApiEvents]: DevtoolsApiEvents[T] extends DevtoolsMessage<
58 | infer Data,
59 | infer Return
60 | >
61 | ? (args: Data) => Promise
62 | : (args: DevtoolsApiEvents[T]) => Promise;
63 | };
64 |
65 | type MessageCallback = (message: {
66 | data: Data;
67 | }) => Return | Promise;
68 |
69 | export type DevtoolsListeners = {
70 | [T in keyof DevtoolsApiEvents]: DevtoolsApiEvents[T] extends DevtoolsMessage<
71 | infer Data,
72 | infer Return
73 | >
74 | ? (cb: MessageCallback) => void
75 | : never;
76 | };
77 |
--------------------------------------------------------------------------------
/src/devtools-types.ts:
--------------------------------------------------------------------------------
1 | export type Override = { value: string; computed: string | null };
2 | export type OverrideMap = Record;
3 | export type HistoryState = {
4 | overrides: OverrideMap | null;
5 | };
6 |
7 | export interface MatchedStyleRule {
8 | type: "style";
9 | source: string;
10 | selector: string;
11 | parentRule: MatchedMediaRule | MatchedLayerBlockRule | null;
12 | style: Record;
13 | /**
14 | * Computed layer name from traversing `parentRule`
15 | */
16 | layer?: string;
17 | /**
18 | * Computed media query from traversing `parentRule`
19 | */
20 | media?: string;
21 | }
22 |
23 | export interface MatchedMediaRule {
24 | type: "media";
25 | source: string;
26 | parentRule: MatchedLayerBlockRule | null;
27 | media: string;
28 | }
29 |
30 | export interface MatchedLayerBlockRule {
31 | type: "layer";
32 | source: string;
33 | parentRule: MatchedLayerBlockRule | null;
34 | layer: string;
35 | }
36 |
37 | export type MatchedRule =
38 | | MatchedStyleRule
39 | | MatchedMediaRule
40 | | MatchedLayerBlockRule;
41 |
42 | export interface WindowEnv {
43 | location: string;
44 | widthPx: number;
45 | heightPx: number;
46 | deviceWidthPx: number;
47 | deviceHeightPx: number;
48 | dppx: number;
49 | }
50 |
--------------------------------------------------------------------------------
/src/editable-value.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from "#components/tooltip";
2 | import { css, cx } from "#styled-system/css";
3 | import { styled } from "#styled-system/jsx";
4 | import { Editable, Portal, useEditableContext } from "@ark-ui/react";
5 | import { useSelector } from "@xstate/store/react";
6 | import { TrashIcon, Undo2 } from "lucide-react";
7 | import { useRef, useState } from "react";
8 | import { useDevtoolsContext } from "./devtools-context";
9 | import { HighlightMatch } from "./highlight-match";
10 | import { hypenateProperty } from "./lib/hyphenate-proprety";
11 | import { symbols } from "./lib/symbols";
12 | import { store } from "./store";
13 |
14 | export interface EditableValueProps {
15 | index: number;
16 | /**
17 | * Selectors computed from the inspected element (window.$0 in content script)
18 | * By traversing the DOM tree until reaching HTML so we can uniquely identify the element
19 | * This may have multiple selectors when the inspected element is nested in iframe/shadow roots
20 | */
21 | elementSelector: string[];
22 | /**
23 | * One of the key of the MatchedStyleRule.style (basically an atomic CSS declaration)
24 | */
25 | prop: string;
26 | /**
27 | * Selector from the MatchedStyleRule
28 | */
29 | selector: string;
30 | /**
31 | * Value that was matched with this MatchedStyleRule for this property
32 | */
33 | matchValue: string;
34 | override: { value: string; computed: string | null } | null;
35 | /**
36 | * When user overrides the value, we need the computed value (from window.getComputedStyle.getPropertyValue)
37 | * This is mostly useful when the override is a CSS variable
38 | * so we can show the underlying value as inlay hint and show the appropriate color preview
39 | */
40 | setOverride: (value: string | null, computed: string | null) => void;
41 | isRemovable?: boolean;
42 | refresh?: () => Promise;
43 | }
44 |
45 | export const EditableValue = (props: EditableValueProps) => {
46 | const {
47 | index,
48 | elementSelector,
49 | prop,
50 | selector,
51 | matchValue,
52 | override,
53 | setOverride,
54 | isRemovable,
55 | refresh,
56 | } = props;
57 |
58 | const { contentScript } = useDevtoolsContext();
59 |
60 | const ref = useRef(null as HTMLDivElement | null);
61 | const [key, setKey] = useState(0);
62 |
63 | const propValue = override?.value || matchValue;
64 | const kind =
65 | selector === symbols.inlineStyleSelector ? "inlineStyle" : "cssRule";
66 |
67 | const updateValue = (update: string) => {
68 | return contentScript.updateStyleRule({
69 | selectors: kind === "inlineStyle" ? elementSelector : [selector],
70 | prop: hypenateProperty(prop),
71 | value: update,
72 | kind,
73 | atIndex: index + 1,
74 | isCommented: false,
75 | });
76 | };
77 |
78 | const removeDeclaration = async () => {
79 | const { hasUpdated, computedValue } = await contentScript.removeInlineStyle(
80 | {
81 | selectors: elementSelector,
82 | prop,
83 | atIndex: index,
84 | },
85 | );
86 |
87 | if (!hasUpdated) return;
88 | setOverride(null, computedValue);
89 | refresh?.();
90 | };
91 |
92 | const overrideValue = async (update: string) => {
93 | if (update === "") {
94 | if (!isRemovable) return;
95 | return removeDeclaration();
96 | }
97 | if (update === propValue) return;
98 |
99 | const { hasUpdated, computedValue } = await updateValue(update);
100 | if (hasUpdated) {
101 | setOverride(update, computedValue);
102 | }
103 | };
104 |
105 | const revert = async () => {
106 | const hasUpdated = await updateValue(matchValue);
107 | if (hasUpdated) {
108 | setOverride(null, null);
109 | }
110 | };
111 |
112 | const parentRef = useRef(null);
113 |
114 | return (
115 | {
132 | overrideValue(update.value);
133 | }}
134 | >
135 |
136 | setKey((key) => key + 1)}
152 | aria-label="Property value"
153 | />
154 |
155 |
156 | {isRemovable && (
157 |
160 | Remove
161 |
162 | }
163 | >
164 | {
173 | return removeDeclaration();
174 | }}
175 | />
176 |
177 | )}
178 | {override !== null && (
179 |
182 | Revert to default
183 | ({matchValue})
184 |
185 | }
186 | >
187 | {
196 | revert();
197 | }}
198 | />
199 |
200 | )}
201 |
202 | );
203 | };
204 |
205 | const EditablePreview = ({
206 | parentRef,
207 | }: {
208 | parentRef: React.RefObject;
209 | }) => {
210 | const ctx = useEditableContext();
211 | const filter = useSelector(store, (s) => s.context.filter);
212 |
213 | return (
214 |
215 |
216 | {ctx.previewProps.children}
217 |
218 |
219 |
220 | ;
221 |
222 |
223 | {ctx.isEditing ? null : ;}
224 |
225 | );
226 | };
227 |
--------------------------------------------------------------------------------
/src/highlight-match.tsx:
--------------------------------------------------------------------------------
1 | import { camelCaseProperty, esc } from "@pandacss/shared";
2 | import { styled } from "#styled-system/jsx";
3 | import { SystemStyleObject } from "#styled-system/types";
4 |
5 | export const HighlightMatch = ({
6 | children,
7 | highlight,
8 | variant,
9 | css,
10 | }: {
11 | children: string;
12 | highlight: string | null;
13 | variant?: "initial" | "blue";
14 | css?: SystemStyleObject;
15 | }) => {
16 | if (!highlight?.trim()) {
17 | return {children};
18 | }
19 |
20 | const regex = new RegExp(`(${esc(highlight)})`, "gi");
21 | const parts = children.split(regex);
22 |
23 | return (
24 |
25 | {parts.map((part, index) => {
26 | let isMatching = regex.test(part);
27 | if (!isMatching && children.includes("-")) {
28 | isMatching = regex.test(camelCaseProperty(part));
29 | }
30 |
31 | return isMatching ? (
32 |
40 | {part}
41 |
42 | ) : (
43 | part
44 | );
45 | })}
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/src/insert-inline-row.tsx:
--------------------------------------------------------------------------------
1 | import { trackInteractOutside } from "@zag-js/interact-outside";
2 | import {
3 | Dispatch,
4 | Fragment,
5 | MouseEvent,
6 | SetStateAction,
7 | useEffect,
8 | useState,
9 | } from "react";
10 | import { flushSync } from "react-dom";
11 | import { css } from "#styled-system/css";
12 | import { Flex, styled } from "#styled-system/jsx";
13 | import { InspectResult } from "./inspect-api";
14 | import { symbols } from "./lib/symbols";
15 | import { OverrideMap } from "./devtools-types";
16 | import { Declaration } from "./declaration";
17 | import { useDevtoolsContext } from "./devtools-context";
18 | import { camelCaseProperty, dashCase } from "@pandacss/shared";
19 | import { compactCSS } from "./lib/compact-css";
20 | import { pick } from "./lib/pick";
21 |
22 | interface InsertInlineRowProps {
23 | inspected: InspectResult;
24 | refresh: () => Promise;
25 | overrides: OverrideMap | null;
26 | setOverrides: Dispatch>;
27 | }
28 |
29 | type EditingState = "idle" | "key" | "value";
30 |
31 | const getState = () =>
32 | (dom.getInlineContainer().dataset.editing || "idle") as EditingState;
33 |
34 | const setState = (state: EditingState) => {
35 | // console.log(
36 | // `setState ${dom.getInlineContainer().dataset.editing} => ${state}`
37 | // );
38 | if (state === "idle") {
39 | delete dom.getInlineContainer().dataset.editing;
40 | return;
41 | }
42 |
43 | dom.getInlineContainer().dataset.editing = state;
44 | };
45 |
46 | export const InsertInlineRow = (props: InsertInlineRowProps) => {
47 | const { inspected, refresh, overrides, setOverrides } = props;
48 | const { contentScript, onContentScriptMessage } = useDevtoolsContext();
49 |
50 | const startEditing = (e: MouseEvent, from: "first" | "last") => {
51 | // console.log("start-editing", from);
52 | const state = getState();
53 |
54 | if (state === "key") {
55 | return cancelEditing("already editing key");
56 | }
57 |
58 | if (state === "idle") {
59 | const target = e.target as HTMLElement;
60 | const declaration = dom.getClosestDeclaration(target);
61 | if (declaration && target !== declaration) return;
62 |
63 | const index = declaration?.dataset.declaration
64 | ? parseInt(declaration.dataset.declaration)
65 | : from === "first"
66 | ? -1
67 | : inspected.styleDeclarationEntries.length - 1;
68 |
69 | // Needed so that the element is rendered before we can focus it
70 | flushSync(() => {
71 | setClickedRowIndex(index);
72 |
73 | setState("key");
74 | });
75 | dom.getEditableKey().focus();
76 | }
77 | };
78 |
79 | const cancelEditing = (reason: string) => {
80 | // console.log("cancel-editing", reason);
81 | const editableKey = dom.getEditableKey();
82 | const editableValue = dom.getEditableValue();
83 | const inlineContainer = dom.getInlineContainer();
84 |
85 | if (editableKey) editableKey.innerText = "";
86 | if (editableValue) editableValue.innerText = "";
87 | if (inlineContainer) delete inlineContainer.dataset.editing;
88 |
89 | setState("idle");
90 | };
91 |
92 | const commit = () => {
93 | const editableValue = dom.getEditableValue();
94 | const editableKey = dom.getEditableKey();
95 | // console.log("commit", editableValue.innerText);
96 |
97 | const declaration = {
98 | prop: dashCase(editableKey.innerText),
99 | value: editableValue.innerText,
100 | };
101 |
102 | return contentScript
103 | .appendInlineStyle({
104 | selectors: inspected.elementSelectors,
105 | prop: declaration.prop,
106 | value: declaration.value,
107 | atIndex: clickedRowIndex === null ? null : clickedRowIndex + 1,
108 | isCommented: false,
109 | })
110 | .then(({ hasUpdated, computedValue }) => {
111 | if (!hasUpdated) return cancelEditing("no update");
112 |
113 | const { prop, value } = declaration;
114 | const key = `style:${prop}`;
115 | setOverrides((overrides) => ({
116 | ...overrides,
117 | [symbols.overrideKey]: key,
118 | [key]: value != null ? { value, computed: computedValue } : null,
119 | }));
120 |
121 | editableValue.innerText = "";
122 | editableKey.innerText = "";
123 |
124 | setState("key");
125 | refresh().then(() => {
126 | setClickedRowIndex((clickedRowIndex ?? -1) + 1);
127 | });
128 | });
129 | };
130 |
131 | // When focusing the host website window, cancel editing
132 | useEffect(() => {
133 | return onContentScriptMessage.focus(() => {
134 | cancelEditing("focusing host website");
135 | });
136 | }, []);
137 |
138 | const [clickedRowIndex, setClickedRowIndex] = useState(-1);
139 |
140 | // When clicking outside the editable key while editing it, cancel editing
141 | useEffect(() => {
142 | return trackInteractOutside(() => dom.getEditableKey(), {
143 | exclude: (target) => {
144 | return dom.getInlineContainer().contains(target);
145 | },
146 | onInteractOutside: () => {
147 | const state = dom.getInlineContainer().dataset.editing;
148 | if (state === "key") {
149 | cancelEditing("clicking outside key");
150 | }
151 | },
152 | });
153 | }, [clickedRowIndex]);
154 |
155 | // When clicking outside the editable value while editing it, cancel editing if empty, otherwise commit
156 | useEffect(() => {
157 | return trackInteractOutside(() => dom.getEditableValue(), {
158 | onInteractOutside: (e) => {
159 | const state = dom.getInlineContainer().dataset.editing;
160 | if (state === "value") {
161 | const editable = e.target as HTMLElement;
162 | if (editable.innerText == null || editable.innerText.trim() === "") {
163 | cancelEditing("clicking outside value");
164 | return;
165 | }
166 |
167 | commit();
168 | }
169 | },
170 | });
171 | }, [clickedRowIndex]);
172 |
173 | const EditableRow = (
174 |
186 | {
199 | // Auto focus the editable key when the row is clicked
200 | if (node && getState() === "key") {
201 | node.focus();
202 | }
203 | }}
204 | onKeyDown={(e) => {
205 | const state = getState();
206 | if (state !== "key") return;
207 |
208 | const editable = e.target as HTMLElement;
209 |
210 | if (e.key === "Escape") {
211 | return cancelEditing("escaping key");
212 | }
213 |
214 | if (e.key === "Backspace" && !editable.innerText) {
215 | return cancelEditing("backspace on empty key");
216 | }
217 |
218 | if (!["Enter", "Tab"].includes(e.key)) return;
219 |
220 | e.preventDefault();
221 |
222 | // Empty string, exit editing
223 | if (editable.innerText == null || editable.innerText.trim() === "") {
224 | return cancelEditing("submitting empty key");
225 | }
226 |
227 | // Otherwise, commit the key & move to value editing
228 | setState("value");
229 |
230 | const editableValue = dom.getEditableValue();
231 | // console.log("commit-key", editableValue);
232 | editableValue.focus();
233 | }}
234 | />
235 |
243 | {":"}
244 |
245 | {
256 | const editable = e.target as HTMLElement;
257 |
258 | if (e.key === "Escape") {
259 | cancelEditing("escaping value");
260 | return;
261 | }
262 |
263 | // Return to key editing when backspace is pressed on an empty value
264 | if (e.key === "Backspace" && !editable.innerText) {
265 | e.preventDefault();
266 | setState("key");
267 |
268 | const editableKey = dom.getEditableKey();
269 | editableKey.focus();
270 |
271 | const element = editableKey;
272 | const range = document.createRange();
273 | range.selectNodeContents(element);
274 |
275 | const selection = window.getSelection();
276 | if (selection) {
277 | selection.removeAllRanges();
278 | selection.addRange(range);
279 | }
280 |
281 | return;
282 | }
283 |
284 | if (!["Enter", "Tab"].includes(e.key)) return;
285 |
286 | e.preventDefault();
287 |
288 | // Empty string, exit editing
289 | if (editable.innerText == null || editable.innerText.trim() === "") {
290 | cancelEditing("submitting empty value");
291 | return;
292 | }
293 |
294 | // Otherwise, commit the value & reset the editing state
295 | commit();
296 | }}
297 | />
298 | ;
299 |
300 | );
301 |
302 | const styles = Object.fromEntries(
303 | inspected.styleEntries.map(([prop, value]) => [
304 | camelCaseProperty(prop),
305 | value,
306 | ]),
307 | );
308 | const keys = compactCSS(styles);
309 | const applied = pick(styles, keys.pick);
310 |
311 | return (
312 | {
316 | startEditing(e, "first");
317 | }}
318 | gap="2px"
319 | direction="column"
320 | px="4px"
321 | >
322 |
323 |
324 | element.style
325 |
326 |
327 | {"{"}
328 |
329 |
330 | {clickedRowIndex === -1 ? EditableRow : null}
331 | {inspected.styleDeclarationEntries.length ? (
332 |
333 | {inspected.styleDeclarationEntries.map(
334 | ([prop, value], index, arr) => {
335 | const key = `style:${prop}:${value}`;
336 | const isAppliedLater = arr
337 | .slice(index + 1)
338 | .some(([prop2, value2]) => prop2 === prop && value2 === value);
339 |
340 | return (
341 |
342 |
363 | setOverrides((overrides) => ({
364 | ...overrides,
365 | [symbols.overrideKey]: key,
366 | [key]: value != null ? { value, computed } : null,
367 | })),
368 | }}
369 | />
370 | {index === clickedRowIndex ? EditableRow : null}
371 |
372 | );
373 | },
374 | )}
375 |
376 | ) : null}
377 | {
379 | e.stopPropagation();
380 | startEditing(e, "last");
381 | }}
382 | color="devtools.on-surface"
383 | fontWeight="600"
384 | >
385 | {"}"}
386 |
387 |
388 |
389 | );
390 | };
391 |
392 | const dom = {
393 | getInlineContainer: () =>
394 | document.getElementById("inline-styles") as HTMLElement,
395 | getEditableKey: () => document.getElementById("editable-key") as HTMLElement,
396 | getEditableValue: () =>
397 | document.getElementById("editable-value") as HTMLElement,
398 | getClosestDeclaration: (element: HTMLElement) =>
399 | element.closest("[data-declaration]") as HTMLElement,
400 | };
401 |
402 | const contentEditableStyles = css.raw({
403 | margin: "0 -2px -1px",
404 | padding: "0 2px 1px",
405 | //
406 | color: "devtools.on-surface",
407 | textDecoration: "inherit",
408 | textOverflow: "clip!important",
409 | opacity: "100%!important",
410 | whiteSpace: "pre",
411 | overflowWrap: "break-word",
412 |
413 | _focusVisible: {
414 | outline: "none",
415 | },
416 | });
417 |
--------------------------------------------------------------------------------
/src/inspect-api.ts:
--------------------------------------------------------------------------------
1 | import { asserts } from "./asserts";
2 | import { cssTextToEntries } from "./lib/css-text-to-entries";
3 | import {
4 | getMatchedLayerFullName,
5 | getLayer,
6 | getLayerBlockFullName,
7 | getMedia,
8 | } from "./lib/rules";
9 | import { reorderNestedLayers } from "./lib/reorder-nested-layers";
10 | import {
11 | MatchedStyleRule,
12 | WindowEnv,
13 | MatchedRule,
14 | MatchedMediaRule,
15 | MatchedLayerBlockRule,
16 | } from "./devtools-types";
17 | import { getHighlightsStyles } from "./lib/get-highlights-styles";
18 | import { dashCase } from "@pandacss/shared";
19 |
20 | export class InspectAPI {
21 | traverseSelectors(selectors: string[]): HTMLElement | null {
22 | let currentContext: Document | Element | ShadowRoot = document; // Start at the main document
23 |
24 | for (let i = 0; i < selectors.length; i++) {
25 | let selector = selectors[i];
26 |
27 | if (selector === "::shadow-root") {
28 | // Assume the next selector targets inside the shadow DOM
29 | if (
30 | i + 1 < selectors.length &&
31 | asserts.isElement(currentContext) &&
32 | currentContext.shadowRoot
33 | ) {
34 | i++; // Move to the next selector which is inside the shadow DOM
35 | const shadowRoot = currentContext.shadowRoot as ShadowRoot;
36 | if (shadowRoot) {
37 | const el = shadowRoot.querySelector(selectors[i]);
38 | if (el) {
39 | currentContext = el;
40 | }
41 | }
42 | } else {
43 | console.error(
44 | "No shadow root available for selector:",
45 | selector,
46 | currentContext,
47 | );
48 | return null;
49 | }
50 | } else if (asserts.isHTMLIFrameElement(currentContext)) {
51 | // If the current context is an iframe, switch to its content document
52 | currentContext = currentContext.contentDocument as Document;
53 | if (currentContext) {
54 | currentContext = currentContext.querySelector(
55 | selector,
56 | ) as HTMLElement;
57 | } else {
58 | console.error(
59 | "Content document not accessible in iframe for selector:",
60 | selector,
61 | currentContext,
62 | );
63 | return null;
64 | }
65 | } else if (
66 | asserts.isDocument(currentContext) ||
67 | asserts.isElement(currentContext) ||
68 | asserts.isShadowRoot(currentContext)
69 | ) {
70 | if (asserts.isElement(currentContext) && currentContext.shadowRoot) {
71 | currentContext = currentContext.shadowRoot;
72 | }
73 |
74 | // Regular DOM traversal
75 | const found = currentContext.querySelector(selector) as HTMLElement;
76 | if (found) {
77 | currentContext = found;
78 | } else {
79 | console.error(
80 | "Element not found at selector:",
81 | selector,
82 | currentContext,
83 | );
84 | return null; // Element not found at this selector, exit early
85 | }
86 | } else {
87 | console.error(
88 | "Current context is neither Document, Element, nor ShadowRoot:",
89 | currentContext,
90 | );
91 | return null;
92 | }
93 | }
94 |
95 | return currentContext as HTMLElement; // Return the final element, cast to Element since it's not null
96 | }
97 |
98 | /**
99 | * Inspects an element and returns all matching CSS rules
100 | * This needs to contain every functions as it will be stringified/evaluated in the browser
101 | */
102 | inspectElement(elementSelectors: string[], el?: HTMLElement) {
103 | const element = el ?? this.traverseSelectors(elementSelectors);
104 | // console.log({ elementSelectors, element });
105 | if (!element) return;
106 |
107 | const matches = this.getMatchingRules(element);
108 | if (!matches) return;
109 |
110 | const computed = getComputedStyle(element);
111 | const cssVars = this.getCssVars(matches.rules, element);
112 | const layersOrder = matches.layerOrders.flat();
113 | const styleEntries = this.getAppliedStyleEntries(element);
114 |
115 | const serialized = {
116 | elementSelectors,
117 | rules: matches.rules,
118 | layersOrder,
119 | cssVars,
120 | classes: [...element.classList].filter(Boolean),
121 | displayName: element.nodeName.toLowerCase(),
122 | /**
123 | * This contains the final style object with all the CSS rules applied on the element
124 | * including stuff we don't care about
125 | */
126 | computedStyle: Object.fromEntries(
127 | Array.from(computed).map((key) => [
128 | key,
129 | computed.getPropertyValue(key),
130 | ]),
131 | ),
132 | /**
133 | * This contains only the applied `style` attributes as an array of [property, value] pairs
134 | */
135 | styleEntries,
136 | /**
137 | * This contains all declared `style` attributes as an array of [property, value] pairs
138 | */
139 | styleDeclarationEntries: this.getStyleAttributeEntries(element),
140 | /**
141 | * This contains the `style` attribute resulting object applied on the element
142 | */
143 | // style: Object.fromEntries(styleEntries),
144 | /**
145 | * This is needed to match rules that are nested in media queries
146 | * and filter them out if they are not applied with this environment
147 | */
148 | env: this.getWindowEnv(),
149 | };
150 |
151 | const layers = new Map();
152 | serialized.rules.forEach((_rule) => {
153 | const rule = _rule as MatchedStyleRule;
154 | const parentMedia = getMedia(rule);
155 | const parentLayer = getLayer(rule);
156 |
157 | if (parentLayer) {
158 | rule.layer = getMatchedLayerFullName(parentLayer);
159 |
160 | if (!layers.has(rule.layer)) {
161 | layers.set(rule.layer, []);
162 | }
163 | layers.get(rule.layer)!.push(rule);
164 | }
165 |
166 | if (parentMedia) {
167 | rule.media = parentMedia.media;
168 | }
169 | });
170 |
171 | if (layersOrder.length > 0) {
172 | serialized.rules = serialized.rules.sort((a, b) => {
173 | if (!a.layer && !b.layer) return 0;
174 | if (!a.layer) return -1;
175 | if (!b.layer) return 1;
176 |
177 | const aIndex = layersOrder.indexOf(a.layer);
178 | const bIndex = layersOrder.indexOf(b.layer);
179 | return aIndex - bIndex;
180 | });
181 | }
182 |
183 | return serialized;
184 | }
185 |
186 | getWindowEnv(): WindowEnv {
187 | return {
188 | location: window.location.href,
189 | widthPx: window.innerWidth,
190 | heightPx: window.innerHeight,
191 | deviceWidthPx: window.screen.width,
192 | deviceHeightPx: window.screen.height,
193 | dppx: window.devicePixelRatio,
194 | } as WindowEnv;
195 | }
196 |
197 | /**
198 | * Returns all style entries applied to an element
199 | * @example
200 | * `color: red; color: blue;` -> `["color", "blue"]`
201 | */
202 | getAppliedStyleEntries(element: HTMLElement) {
203 | if (!element.style.cssText) return [];
204 | // console.log(element.style);
205 | return Array.from(element.style).map((key) => {
206 | const important = element.style.getPropertyPriority(key);
207 | return [
208 | key,
209 | element.style[key as keyof typeof element.style] +
210 | (important ? " !" + important : ""),
211 | ];
212 | });
213 | }
214 |
215 | /**
216 | * Returns all style entries applied to an element
217 | * @example
218 | * `color: red; color: blue;` -> `[["color", "red"], ["color", "blue"]]`
219 | */
220 | getStyleAttributeEntries(element: HTMLElement) {
221 | if (!element.style.cssText) return [];
222 | // console.log(element.style);
223 | return cssTextToEntries(element.getAttribute("style") ?? "");
224 | }
225 |
226 | /**
227 | * Traverses the document stylesheets and returns all matching CSS rules
228 | */
229 | getMatchingRules(element: Element) {
230 | const seenLayers = new Set();
231 |
232 | const matchedRules: Array<
233 | CSSStyleRule | CSSMediaRule | CSSLayerBlockRule
234 | >[] = [];
235 |
236 | const doc = element.getRootNode() as Document;
237 | if (!doc) return;
238 |
239 | for (const sheet of Array.from(doc.styleSheets)) {
240 | try {
241 | if (sheet.cssRules) {
242 | const rules = Array.from(sheet.cssRules);
243 | const matchingRules = this.findMatchingRules(
244 | rules,
245 | element,
246 | (rule) => {
247 | if (asserts.isCSSLayerStatementRule(rule)) {
248 | rule.nameList.forEach((layer) => seenLayers.add(layer));
249 | } else if (asserts.isCSSLayerBlockRule(rule)) {
250 | seenLayers.add(getLayerBlockFullName(rule));
251 | }
252 | },
253 | );
254 |
255 | if (matchingRules.length > 0) {
256 | matchedRules.push(matchingRules);
257 | }
258 | }
259 | } catch (e) {
260 | // Handle cross-origin stylesheets
261 | }
262 | }
263 |
264 | const serialize = this.createSerializer();
265 |
266 | const serialized = matchedRules
267 | .flat()
268 | .map((v) => {
269 | return serialize(v);
270 | })
271 | .filter(Boolean) as MatchedStyleRule[];
272 |
273 | return {
274 | rules: serialized,
275 | layerOrders: reorderNestedLayers(Array.from(seenLayers)),
276 | };
277 | }
278 |
279 | /**
280 | * Returns the computed value of a CSS variable
281 | */
282 | getComputedCSSVariableValue(element: HTMLElement, variable: string): string {
283 | const stack: string[] = [variable];
284 | const seen = new Set();
285 | let currentValue: string = "";
286 |
287 | while (stack.length > 0) {
288 | const currentVar = stack.pop()!;
289 | const [name, fallback] = extractVariableName(currentVar);
290 |
291 | const computed = getComputedStyle(element);
292 | currentValue = computed.getPropertyValue(name).trim();
293 |
294 | if (!currentValue && fallback) {
295 | if (!fallback.startsWith("var(--")) return fallback;
296 | if (!seen.has(fallback)) return fallback;
297 |
298 | seen.add(fallback);
299 | stack.push(fallback);
300 | }
301 | }
302 |
303 | return currentValue;
304 | }
305 |
306 | findStyleRule(doc: Document, selector: string) {
307 | const sheets = Array.from(doc.styleSheets);
308 | for (const sheet of sheets) {
309 | if (!sheet.cssRules) return;
310 |
311 | const rule = this.findStyleRuleBySelector(
312 | Array.from(sheet.cssRules),
313 | selector,
314 | );
315 |
316 | if (rule) {
317 | return rule;
318 | }
319 | }
320 | }
321 |
322 | computePropertyValue(selectors: string[], prop: string) {
323 | const element = this.traverseSelectors(selectors);
324 | if (!element) return;
325 |
326 | const computed = getComputedStyle(element);
327 | return computed.getPropertyValue(prop);
328 | }
329 |
330 | updateStyleAction(params: UpdateStyleRuleMessage) {
331 | let hasUpdated, computedValue;
332 | if (params.kind === "inlineStyle") {
333 | const element = inspectApi.traverseSelectors(params.selectors);
334 | if (!element) return { hasUpdated: false, computedValue: null };
335 |
336 | hasUpdated = inspectApi.updateInlineStyle({
337 | element,
338 | prop: params.prop,
339 | value: params.value,
340 | atIndex: params.atIndex,
341 | isCommented: params.isCommented,
342 | mode: "edit",
343 | });
344 | } else {
345 | let doc = document;
346 | if (params.selectors.length > 1) {
347 | const element = inspectApi.traverseSelectors(params.selectors);
348 | if (!element) return { hasUpdated: false, computedValue: null };
349 |
350 | doc = element.getRootNode() as Document;
351 | }
352 |
353 | hasUpdated = inspectApi.updateCssStyleRule({
354 | doc,
355 | selector: params.selectors[0],
356 | prop: params.prop,
357 | value: params.value,
358 | });
359 | }
360 |
361 | if (hasUpdated) {
362 | computedValue = inspectApi.computePropertyValue(
363 | params.selectors,
364 | params.prop,
365 | );
366 | }
367 |
368 | return {
369 | hasUpdated: Boolean(hasUpdated),
370 | computedValue: computedValue ?? null,
371 | };
372 | }
373 |
374 | appendInlineStyleAction(params: Omit) {
375 | const element = inspectApi.traverseSelectors(params.selectors);
376 | if (!element) return { hasUpdated: false, computedValue: null };
377 |
378 | const hasUpdated = inspectApi.updateInlineStyle({
379 | element,
380 | prop: params.prop,
381 | value: params.value,
382 | atIndex: params.atIndex,
383 | isCommented: params.isCommented,
384 | mode: "insert",
385 | });
386 | if (!hasUpdated) return { hasUpdated: false, computedValue: null };
387 |
388 | const computedValue = inspectApi.computePropertyValue(
389 | params.selectors,
390 | params.prop,
391 | );
392 |
393 | return {
394 | hasUpdated: Boolean(hasUpdated),
395 | computedValue: computedValue ?? null,
396 | };
397 | }
398 |
399 | updateCssStyleRule({
400 | doc,
401 | selector,
402 | prop,
403 | value,
404 | }: {
405 | doc: Document;
406 | selector: string;
407 | prop: string;
408 | value: string;
409 | }) {
410 | const styleRule = this.findStyleRule(doc, selector);
411 | if (styleRule) {
412 | styleRule.style.setProperty(prop, value);
413 | return true;
414 | }
415 | }
416 |
417 | updateInlineStyle(params: InlineStyleUpdate & { element: HTMLElement }) {
418 | const { element, prop, value, atIndex, mode, isCommented } = params;
419 | if (element) {
420 | // element.style.cssText += `${prop}: ${value};`;
421 | // will not work, it will only the last property+value declaration for a given property
422 |
423 | const cssText = element.getAttribute("style") || "";
424 |
425 | const updated = this.getUpdatedCssText({
426 | cssText,
427 | prop,
428 | value,
429 | atIndex,
430 | mode,
431 | isCommented,
432 | });
433 | // but this is fine for some reason
434 | element.setAttribute("style", updated);
435 | return true;
436 | }
437 | }
438 |
439 | removeInlineStyleAction(
440 | params: RemoveInlineStyle &
441 | Pick,
442 | ) {
443 | const element = inspectApi.traverseSelectors(params.selectors);
444 | if (!element) return { hasUpdated: false, computedValue: null };
445 |
446 | const hasUpdated = inspectApi.removeInlineStyleDeclaration({
447 | element,
448 | atIndex: params.atIndex,
449 | });
450 | if (!hasUpdated) return { hasUpdated: false, computedValue: null };
451 |
452 | const computedValue = inspectApi.computePropertyValue(
453 | params.selectors,
454 | params.prop,
455 | );
456 |
457 | return {
458 | hasUpdated: Boolean(hasUpdated),
459 | computedValue: computedValue ?? null,
460 | };
461 | }
462 |
463 | removeInlineStyleDeclaration(
464 | params: RemoveInlineStyle & { element: HTMLElement },
465 | ) {
466 | const { element, atIndex } = params;
467 | if (element) {
468 | const cssText = element.getAttribute("style") || "";
469 | const declarations = cssTextToEntries(cssText);
470 | const split = declarations.filter(Boolean);
471 |
472 | // Removes the declaration at the given index
473 | const updated =
474 | split
475 | .slice(0, atIndex)
476 | .concat(split.slice(atIndex + 1))
477 | .map((entry) => {
478 | const [prop, value, isCommented] = entry;
479 | const declaration = `${prop}: ${value}`;
480 | if (isCommented) {
481 | return `;/* ${declaration} */;`;
482 | }
483 | return declaration;
484 | })
485 | .join(";") + ";";
486 |
487 | element.setAttribute("style", updated);
488 | return true;
489 | }
490 | }
491 |
492 | /**
493 | * getUpdatedCssText("color: red; color: blue;", "color", "green", 0)
494 | * => "color: red; color: green; color: blue;"
495 | */
496 | getUpdatedCssText(params: InlineStyleUpdate & { cssText: string }) {
497 | const { cssText, prop, value, atIndex, isCommented, mode } = params;
498 | let declaration = ` ${prop}: ${value}`;
499 | if (isCommented) {
500 | declaration = `;/* ${declaration} */;`;
501 | }
502 |
503 | if (atIndex === null) {
504 | return cssText + declaration + ";";
505 | }
506 |
507 | const split = cssText.split(";").filter(Boolean);
508 |
509 | if (mode === "insert") {
510 | return split
511 | .slice(0, atIndex)
512 | .concat(declaration)
513 | .concat(split.slice(atIndex).concat(""))
514 | .join(";");
515 | }
516 |
517 | split[atIndex] = declaration;
518 | return split.filter(Boolean).join(";") + ";";
519 | }
520 |
521 | private prevHighlightedSelector: string | null = null;
522 |
523 | highlightSelector(params: { selectors: string[] }) {
524 | const { selectors } = params;
525 |
526 | // Remove any existing highlight
527 | document.querySelectorAll("[data-selector-highlighted]").forEach((el) => {
528 | if (el instanceof HTMLElement) {
529 | delete el.dataset.selectorHighlighted;
530 | }
531 | });
532 |
533 | let container = document.querySelector(
534 | "[data-selector-highlighted-container]",
535 | );
536 | if (!container) {
537 | container = document.createElement("div") as HTMLElement;
538 | container.setAttribute("data-selector-highlighted-container", "");
539 | container.setAttribute("style", "pointer-events: none;");
540 | document.body.appendChild(container);
541 | }
542 |
543 | // Explicitly clear container if no selectors were passed
544 | if (!selectors.length) {
545 | container.innerHTML = "";
546 | this.prevHighlightedSelector = null;
547 | return;
548 | }
549 |
550 | // Add highlight to the elements matching the selectors
551 | // Ignore selectors that contain `*` because they are too broad
552 | const selector = selectors.filter((s) => !s.includes("*")).join(",");
553 | if (!selector) return;
554 | if (this.prevHighlightedSelector === selector) return;
555 |
556 | // Clear container children if no selectors are left
557 | container.innerHTML = "";
558 |
559 | this.prevHighlightedSelector = selector;
560 |
561 | document.querySelectorAll(selector).forEach((el) => {
562 | if (el instanceof HTMLElement) {
563 | el.dataset.selectorHighlighted = "";
564 |
565 | const highlights = getHighlightsStyles(el);
566 | highlights.forEach((styles) => {
567 | const highlight = document.createElement("div") as HTMLElement;
568 | highlight.style.cssText = Object.entries(styles)
569 | .map(([prop, value]) => `${dashCase(prop)}: ${value}`)
570 | .join(";");
571 | container.appendChild(highlight);
572 | });
573 | }
574 | });
575 | }
576 |
577 | private createSerializer() {
578 | // https://developer.mozilla.org/en-US/docs/Web/API/CSSRule/type
579 | const cache = new WeakMap();
580 |
581 | /**
582 | * Serializes a CSSRule into a MatchedRule
583 | * This is needed because we're sending this data to the devtools panel
584 | */
585 | const serialize = (rule: CSSRule): MatchedRule | null => {
586 | const cached = cache.get(rule);
587 | if (cached) {
588 | return cached;
589 | }
590 |
591 | if (asserts.isCSSStyleRule(rule)) {
592 | const matched: MatchedStyleRule = {
593 | type: "style",
594 | source: this.getRuleSource(rule),
595 | selector: (rule as CSSStyleRule).selectorText,
596 | parentRule: rule.parentRule
597 | ? (serialize(rule.parentRule) as any)
598 | : null,
599 | style: this.filterStyleDeclarations(rule as CSSStyleRule),
600 | };
601 | cache.set(rule, matched);
602 | return matched;
603 | }
604 |
605 | if (asserts.isCSSMediaRule(rule)) {
606 | const matched: MatchedMediaRule = {
607 | type: "media",
608 | source: this.getRuleSource(rule),
609 | parentRule: rule.parentRule
610 | ? (serialize(rule.parentRule) as any)
611 | : null,
612 | media: rule.media.mediaText,
613 | // query: compileQuery(rule.media.mediaText),
614 | };
615 | cache.set(rule, matched);
616 | return matched;
617 | }
618 |
619 | if (asserts.isCSSLayerBlockRule(rule)) {
620 | const matched: MatchedLayerBlockRule = {
621 | type: "layer",
622 | source: this.getRuleSource(rule),
623 | parentRule: rule.parentRule
624 | ? (serialize(rule.parentRule) as any)
625 | : null,
626 | layer: rule.name,
627 | };
628 | cache.set(rule, matched);
629 | return matched;
630 | }
631 |
632 | console.warn("Unknown rule type", rule, typeof rule);
633 | return null;
634 | };
635 |
636 | return serialize;
637 | }
638 |
639 | private getCssVars(rules: MatchedStyleRule[], element: HTMLElement) {
640 | const cssVars = {} as Record;
641 |
642 | // Store every CSS variable (and their computed values) from matched rules
643 | for (const rule of rules) {
644 | if (rule.type === "style") {
645 | for (const property in rule.style) {
646 | const value = rule.style[property];
647 | if (value.startsWith("var(--")) {
648 | cssVars[value] = this.getComputedCSSVariableValue(element, value);
649 | }
650 | }
651 | }
652 | }
653 | return cssVars;
654 | }
655 |
656 | /**
657 | * Recursively finds all matching CSS rules, traversing `@media` queries and `@layer` blocks
658 | */
659 | private findMatchingRules(
660 | rules: CSSRule[],
661 | element: Element,
662 | cb: (rule: CSSRule) => void,
663 | ) {
664 | let matchingRules: Array =
665 | [];
666 |
667 | for (const rule of rules) {
668 | cb(rule);
669 |
670 | if (asserts.isCSSStyleRule(rule) && element.matches(rule.selectorText)) {
671 | matchingRules.push(rule);
672 | } else if (
673 | asserts.isCSSMediaRule(rule) ||
674 | asserts.isCSSLayerBlockRule(rule)
675 | ) {
676 | matchingRules = matchingRules.concat(
677 | this.findMatchingRules(Array.from(rule.cssRules), element, cb),
678 | );
679 | }
680 | }
681 |
682 | return matchingRules;
683 | }
684 |
685 | /**
686 | * Recursively finds all matching CSS rules, traversing `@media` queries and `@layer` blocks
687 | */
688 | private findStyleRuleBySelector(
689 | rules: CSSRule[],
690 | selector: string,
691 | ): CSSStyleRule | undefined {
692 | for (const cssRule of rules) {
693 | if (asserts.isCSSStyleRule(cssRule)) {
694 | if (cssRule.selectorText === selector) {
695 | return cssRule;
696 | }
697 | }
698 |
699 | if (
700 | asserts.isCSSMediaRule(cssRule) ||
701 | asserts.isCSSLayerBlockRule(cssRule)
702 | ) {
703 | const styleRule = this.findStyleRuleBySelector(
704 | Array.from(cssRule.cssRules),
705 | selector,
706 | );
707 | if (styleRule) {
708 | return styleRule;
709 | }
710 | }
711 | }
712 | }
713 |
714 | private getRuleSource(rule: CSSRule): string {
715 | if (rule.parentStyleSheet?.href) {
716 | return rule.parentStyleSheet.href;
717 | } else if (asserts.isHTMLStyleElement(rule.parentStyleSheet?.ownerNode)) {
718 | const data = rule.parentStyleSheet?.ownerNode.dataset;
719 | if (data.viteDevId) {
720 | return data.viteDevId;
721 | }
722 |
723 | return "