├── .github └── FUNDING.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── README.md ├── TODO.md ├── __tests__ ├── compact-css.test.ts ├── css-text-to-entries.test.ts └── get-updated-css-text.test.ts ├── components ├── create-style-context.tsx └── tooltip.tsx ├── demo.gif ├── entrypoints ├── background.ts ├── content.ts ├── devtools-pane │ ├── api.ts │ ├── context.ts │ ├── eval.ts │ ├── index.html │ ├── main.tsx │ ├── message-typings.ts │ ├── panda.css │ └── use-platform-class.ts └── devtools │ ├── index.html │ └── main.ts ├── index.html ├── package.json ├── panda.config.ts ├── panda.preset.ts ├── playground ├── .eslintrc.cjs ├── .gitignore ├── public │ └── vite.svg └── src │ ├── browser-context.tsx │ ├── element-details.tsx │ ├── element-inspector.tsx │ ├── inspected.ts │ ├── main.tsx │ ├── panda.css │ ├── playground.tsx │ └── vite-env.d.ts ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public ├── cross-circle-filled.svg ├── icon │ ├── 128.png │ ├── 16.png │ ├── 32.png │ ├── 48.png │ └── 96.png ├── screen1.jpg ├── screen2.jpg ├── screen3.jpg ├── screen4.jpg └── wxt.svg ├── src ├── asserts.ts ├── declaration-group.tsx ├── declaration-list.tsx ├── declaration.tsx ├── devtools-context.ts ├── devtools-messages.ts ├── devtools-types.ts ├── editable-value.tsx ├── highlight-match.tsx ├── insert-inline-row.tsx ├── inspect-api.ts ├── lib │ ├── compact-css.ts │ ├── css-text-to-entries.ts │ ├── get-highlights-styles.tsx │ ├── hyphenate-proprety.ts │ ├── is-color.ts │ ├── pick.ts │ ├── reorder-nested-layers.ts │ ├── rules.ts │ ├── shorthands.ts │ ├── sort-at-rules.ts │ ├── symbols.ts │ ├── types.ts │ ├── unescape-string.ts │ ├── use-inspect-result.ts │ ├── use-undo-redo.ts │ └── use-window-size.ts ├── sidebar-pane.tsx ├── store.ts └── toolbar.tsx ├── tsconfig.json ├── vite.config.ts ├── web-ext.config.ts └── wxt.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [astahmer] 2 | -------------------------------------------------------------------------------- /.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 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | # web-ext.config.ts 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | ## Panda 29 | styled-system 30 | styled-system-studio 31 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .output 3 | .wxt 4 | styled-system 5 | pnpm-lock.yaml 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "printWidth": 80, 4 | "bracketSpacing": true, 5 | "jsxSingleQuote": false, 6 | "proseWrap": "always", 7 | "semi": true, 8 | "tabWidth": 2, 9 | "plugins": ["@pandabox/prettier-plugin"] 10 | } 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | - `pnpm i` 2 | - `pnpm dev` or `pnpm dev:firefox` 3 | 4 | ## Testing 5 | 6 | - `pnpm test` 7 | 8 | ## Building 9 | 10 | - `pnpm build` or `pnpm build:firefox` 11 | 12 | ## Publishing 13 | 14 | - `pnpm zip` or `pnpm zip:firefox` 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Atomic CSS Devtools 2 | A devtool panel for debugging Atomic CSS rules as if they were not atomic 3 | 4 | ![](/demo.gif) 5 | 6 | Discover a better way to debug CSS with Atomic CSS Devtools. This powerful extension provides a unique approach to handling Atomic CSS rules by 7 | presenting them in a non-atomic format, making them easier to interpret and 8 | adjust. 9 | 10 | It's a must-have for developers aiming to streamline their CSS 11 | troubleshooting and enhance site performance. 12 | 13 | ## Installation 14 | [link-chrome]: https://chromewebstore.google.com/detail/atomic-css-devtools/cbjhfeooiomphlikkblgdageenemhpgc 'Version published on Chrome Web Store' 15 | [link-firefox]: https://addons.mozilla.org/en-US/firefox/addon/atomic-css-devtools/ 'Version published on Mozilla Add-ons' 16 | 17 | [Chrome][link-chrome] [][link-chrome] and other Chromium browsers 18 | 19 | [Firefox][link-firefox] [][link-firefox] including Firefox Android 20 | 21 | If you find this extension useful, please consider supporting it by giving it a 22 | star on GitHub or sharing it with your friends and colleagues. 23 | 24 | And if you're feeling generous, here's my 25 | [GitHub Sponsors page](https://github.com/sponsors/astahmer) where you can 26 | support me directly. 27 | 28 | ## Features 29 | 30 | - https://twitter.com/astahmer_dev/status/1776919737999425629 31 | - https://twitter.com/astahmer_dev/status/1777768741041750226 32 | - https://twitter.com/astahmer_dev/status/1780207908195582010 33 | - https://twitter.com/astahmer_dev/status/1785256449892880819 34 | - https://twitter.com/astahmer_dev/status/1786371593070871022 35 | 36 | ## Made with 37 | 38 | This extension is built using: 39 | 40 | - [WXT](https://wxt.dev/) (it's really awesome) 41 | - [Panda CSS](https://panda-css.com/) 42 | - [Ark-ui](https://ark-ui.com/) 43 | - [Zag JS](https://zagjs.com/) 44 | 45 | ## Inspired by 46 | 47 | - [this tweet from @wesbos tbh](https://twitter.com/wesbos/status/1776269438850892182) 48 | (had a mvp 49 | [the next day](https://twitter.com/astahmer_dev/status/1776685925029892270)) 50 | - [Tailwind CSS devtools](https://github.com/Tailscan/tails-devtools) and 51 | [Griffel devtools](https://chromewebstore.google.com/detail/griffel-devtools/bejhagjehnpgagkaaeehdpdadmffbigb) 52 | - [Hoverify](https://tryhoverify.com/) / [Tailscan](https://tailscan.com/) for 53 | [the custom element inspector feature](https://twitter.com/astahmer_dev/status/1786371593070871022) 54 | 55 | ## Contributing 56 | 57 | Contributions are welcome! There's even a bunch of ideas in the 58 | [TODO.md](./TODO.md) file. 59 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | note for anyone curious: I had those notes when the project was still private 😅 2 | hence the little mess 3 | 4 | --- 5 | 6 | - (ElementInspector) -> add atomic classes Declarations (like in the devtools 7 | panel) https://www.tryhoverify.com/ 8 | 9 | - when (next) inline style is disabled (line-through), remove disabled state 10 | from previous ones (which are now applied) 11 | - line-through on atomic class row declaration when there's an inline style 12 | declaration for the same prop (unless atomic has important, unless style has 13 | important) 14 | 15 | - on selector click, show a new panel with the selector and the matching 16 | elements -> allow editing the selector from there, allow adding styles in that 17 | selector 18 | 19 | - add "link effect" on `var(--here)` with tooltip showing computed value 20 | - add title attribute when possible (and there is not tooltip already) 21 | - copy raw value on click sur computed value hint 22 | - light mode 23 | - (firefox) red filter input on no results 24 | - (firefox) green highlight (like git diff) on overrides (added inline 25 | styles/updated values) 26 | 27 | - exclude list (of selectors), save in idb 28 | - highlight part of the selector matching current element 29 | (`.dark xxx, xxx .dark`) + parseSelectors from panda 30 | - right click (context menu) + mimic the one from `Styles` devtools panel (Copy 31 | all declarations as CSS/JS, Copy all changes, Revert to default, etc) 32 | - right click copy computed styles (of a given DeclarationGroup, ex: every 33 | styles in @layer utilities using the computed values as a JS object) 34 | - edit component styles (match all elements with the same classes as the current 35 | element, allow updating class names that are part of the class list) 36 | - EditableValue for property name 37 | - allow toggling any declaration (not just atomic) (just use an override) 38 | - auto-completions for property names 39 | - auto-completions for CSS vars 40 | - toggle show source (next to layer/media) 41 | - toggle btn to remove selectors with `*` 42 | - CSS vars 43 | - only atomic (filter out rules with more than 1 declaration) 44 | - revert all to default (by group = only in X layer/media/or all if not 45 | grouped?) 46 | - collapse/expand all 47 | - save preferences in idb ? 48 | - when adding inlien styles, add warning icon + line-though if property name is 49 | invalid ? 50 | - when property value is using a known number/amount unit, use NumberInput + 51 | Scrubber 52 | - when property value is using a known number/amount unit, allow shortcuts to 53 | change the step (0.1, 1, 10, 100) 54 | - (firefox) IF we want to show the property rules stack (like in Computed 55 | devtools panel), use firefox styling 56 | - toggle to sort alphabetically based on property names 57 | -------------------------------------------------------------------------------- /__tests__/compact-css.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { compactCSS } from "../src/lib/compact-css"; 3 | 4 | test("removes longhands when same value as shorthands", () => { 5 | expect( 6 | compactCSS({ 7 | padding: "0px", 8 | paddingTop: "0px", 9 | paddingRight: "0px", 10 | paddingBottom: "0px", 11 | paddingLeft: "0px", 12 | }), 13 | ).toMatchInlineSnapshot(` 14 | { 15 | "omit": [ 16 | "paddingTop", 17 | "paddingRight", 18 | "paddingBottom", 19 | "paddingLeft", 20 | ], 21 | "pick": [ 22 | "padding", 23 | ], 24 | } 25 | `); 26 | }); 27 | 28 | test("removes longhands when same value as shorthands - multiple", () => { 29 | expect( 30 | compactCSS({ 31 | padding: "0px", 32 | paddingTop: "0px", 33 | paddingRight: "0px", 34 | paddingBottom: "0px", 35 | paddingLeft: "0px", 36 | // 37 | margin: "0px", 38 | marginTop: "0px", 39 | marginRight: "0px", 40 | marginBottom: "0px", 41 | marginLeft: "0px", 42 | }), 43 | ).toMatchInlineSnapshot(` 44 | { 45 | "omit": [ 46 | "paddingTop", 47 | "paddingRight", 48 | "paddingBottom", 49 | "paddingLeft", 50 | "marginTop", 51 | "marginRight", 52 | "marginBottom", 53 | "marginLeft", 54 | ], 55 | "pick": [ 56 | "padding", 57 | "margin", 58 | ], 59 | } 60 | `); 61 | }); 62 | 63 | test("removes shorthands when different value in one of the longhands", () => { 64 | expect( 65 | compactCSS({ 66 | padding: "0px", 67 | paddingTop: "1px", 68 | paddingRight: "0px", 69 | paddingBottom: "0px", 70 | paddingLeft: "0px", 71 | // 72 | margin: "0px", 73 | marginTop: "1px", 74 | marginRight: "0px", 75 | marginBottom: "0px", 76 | marginLeft: "0px", 77 | }), 78 | ).toMatchInlineSnapshot(` 79 | { 80 | "omit": [ 81 | "padding", 82 | "margin", 83 | ], 84 | "pick": [ 85 | "paddingTop", 86 | "paddingRight", 87 | "paddingBottom", 88 | "paddingLeft", 89 | "marginTop", 90 | "marginRight", 91 | "marginBottom", 92 | "marginLeft", 93 | ], 94 | } 95 | `); 96 | }); 97 | 98 | test("keeps other keys", () => { 99 | expect( 100 | compactCSS({ 101 | display: "flex", 102 | padding: "0px", 103 | paddingTop: "0px", 104 | paddingRight: "0px", 105 | paddingBottom: "0px", 106 | paddingLeft: "0px", 107 | // 108 | color: "red", 109 | margin: "0px", 110 | marginTop: "0px", 111 | marginRight: "0px", 112 | marginBottom: "0px", 113 | marginLeft: "0px", 114 | }), 115 | ).toMatchInlineSnapshot(` 116 | { 117 | "omit": [ 118 | "paddingTop", 119 | "paddingRight", 120 | "paddingBottom", 121 | "paddingLeft", 122 | "marginTop", 123 | "marginRight", 124 | "marginBottom", 125 | "marginLeft", 126 | ], 127 | "pick": [ 128 | "display", 129 | "padding", 130 | "color", 131 | "margin", 132 | ], 133 | } 134 | `); 135 | }); 136 | 137 | test("works with partial longhands", () => { 138 | expect( 139 | compactCSS({ 140 | display: "flex", 141 | padding: "0px", 142 | paddingBottom: "0px", 143 | paddingLeft: "0px", 144 | }), 145 | ).toMatchInlineSnapshot(` 146 | { 147 | "omit": [ 148 | "paddingTop", 149 | "paddingRight", 150 | "paddingBottom", 151 | "paddingLeft", 152 | ], 153 | "pick": [ 154 | "display", 155 | "padding", 156 | ], 157 | } 158 | `); 159 | }); 160 | 161 | test("add both longhands and shorthands if not all longhands are in the styles and one differs from the shorthand", () => { 162 | expect( 163 | compactCSS({ 164 | overflowX: "hidden", 165 | overflow: "auto", 166 | }), 167 | ).toMatchInlineSnapshot(` 168 | { 169 | "omit": [], 170 | "pick": [ 171 | "overflow", 172 | "overflowX", 173 | ], 174 | } 175 | `); 176 | 177 | expect( 178 | compactCSS({ 179 | overflow: "auto", 180 | overflowY: "hidden", 181 | }), 182 | ).toMatchInlineSnapshot(` 183 | { 184 | "omit": [], 185 | "pick": [ 186 | "overflow", 187 | "overflowY", 188 | ], 189 | } 190 | `); 191 | 192 | expect( 193 | compactCSS({ 194 | overflow: "auto", 195 | overflowY: "auto", 196 | }), 197 | ).toMatchInlineSnapshot(` 198 | { 199 | "omit": [ 200 | "overflowX", 201 | "overflowY", 202 | ], 203 | "pick": [ 204 | "overflow", 205 | ], 206 | } 207 | `); 208 | }); 209 | 210 | test("works in any order", () => { 211 | expect( 212 | compactCSS({ 213 | paddingBottom: "0px", 214 | overflowX: "hidden", 215 | display: "flex", 216 | padding: "0px", 217 | overflow: "auto", 218 | paddingLeft: "0px", 219 | }), 220 | ).toMatchInlineSnapshot(` 221 | { 222 | "omit": [ 223 | "paddingTop", 224 | "paddingRight", 225 | "paddingBottom", 226 | "paddingLeft", 227 | ], 228 | "pick": [ 229 | "padding", 230 | "overflow", 231 | "overflowX", 232 | "display", 233 | ], 234 | } 235 | `); 236 | }); 237 | 238 | test("add shorthand if all longhands are in the styles and none differs from the shorthand", () => { 239 | expect( 240 | compactCSS({ 241 | paddingLeft: "0px", 242 | paddingRight: "0px", 243 | paddingTop: "0px", 244 | paddingBottom: "0px", 245 | }), 246 | ).toMatchInlineSnapshot(` 247 | { 248 | "omit": [ 249 | "paddingTop", 250 | "paddingRight", 251 | "paddingBottom", 252 | "paddingLeft", 253 | ], 254 | "pick": [ 255 | "padding", 256 | ], 257 | } 258 | `); 259 | }); 260 | -------------------------------------------------------------------------------- /__tests__/css-text-to-entries.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { cssTextToEntries } from "../src/lib/css-text-to-entries"; 3 | 4 | test("return all declarations", () => { 5 | expect(cssTextToEntries("color: red; color: blue !important; color: green;")) 6 | .toMatchInlineSnapshot(` 7 | [ 8 | [ 9 | "color", 10 | "red", 11 | ], 12 | [ 13 | "color", 14 | "blue !important", 15 | ], 16 | [ 17 | "color", 18 | "green", 19 | ], 20 | ] 21 | `); 22 | }); 23 | 24 | test("works without space", () => { 25 | expect(cssTextToEntries("color: red;color: blue;color: green;")) 26 | .toMatchInlineSnapshot(` 27 | [ 28 | [ 29 | "color", 30 | "red", 31 | ], 32 | [ 33 | "color", 34 | "blue", 35 | ], 36 | [ 37 | "color", 38 | "green", 39 | ], 40 | ] 41 | `); 42 | }); 43 | 44 | test("works with multiple comma", () => { 45 | expect( 46 | cssTextToEntries(" color: green;; color: blue; color: red; color: yellow;"), 47 | ).toMatchInlineSnapshot(` 48 | [ 49 | [ 50 | "color", 51 | "green", 52 | ], 53 | [ 54 | "color", 55 | "blue", 56 | ], 57 | [ 58 | "color", 59 | "red", 60 | ], 61 | [ 62 | "color", 63 | "yellow", 64 | ], 65 | ] 66 | `); 67 | }); 68 | 69 | test("extracts commented declarations", () => { 70 | expect( 71 | cssTextToEntries( 72 | " color: green;; color: blue;/* color: orange; */ color: red; /* color: amber; */color: yellow;/* color: pink; */", // 73 | ), 74 | ).toMatchInlineSnapshot(` 75 | [ 76 | [ 77 | "color", 78 | "green", 79 | ], 80 | [ 81 | "color", 82 | "blue", 83 | ], 84 | [ 85 | "color", 86 | "orange", 87 | true, 88 | ], 89 | [ 90 | "color", 91 | "red", 92 | ], 93 | [ 94 | "color", 95 | "amber", 96 | true, 97 | ], 98 | [ 99 | "color", 100 | "yellow", 101 | ], 102 | [ 103 | "color", 104 | "pink", 105 | true, 106 | ], 107 | ] 108 | `); 109 | }); 110 | -------------------------------------------------------------------------------- /__tests__/get-updated-css-text.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { inspectApi } from "../src/inspect-api"; 3 | 4 | const first = inspectApi.getUpdatedCssText({ 5 | cssText: "", 6 | prop: "color", 7 | value: "red", 8 | atIndex: null, 9 | isCommented: false, 10 | mode: "insert", 11 | }); 12 | 13 | const second = inspectApi.getUpdatedCssText({ 14 | cssText: first, 15 | prop: "margin", 16 | value: "10px", 17 | atIndex: null, 18 | isCommented: false, 19 | mode: "insert", 20 | }); 21 | 22 | test("add inline style declaration", () => { 23 | expect(first).toMatchInlineSnapshot(`" color: red;"`); 24 | }); 25 | 26 | test("add multiple inline style declaration", () => { 27 | expect(second).toMatchInlineSnapshot(`" color: red; margin: 10px;"`); 28 | }); 29 | 30 | test("insert at index 0", () => { 31 | expect( 32 | inspectApi.getUpdatedCssText({ 33 | cssText: second, 34 | prop: "display", 35 | value: "flex", 36 | atIndex: 0, 37 | isCommented: false, 38 | mode: "insert", 39 | }), 40 | ).toMatchInlineSnapshot(`" display: flex; color: red; margin: 10px;"`); 41 | }); 42 | 43 | test("insert at index 1", () => { 44 | expect( 45 | inspectApi.getUpdatedCssText({ 46 | cssText: second, 47 | prop: "display", 48 | value: "flex", 49 | atIndex: 1, 50 | isCommented: false, 51 | mode: "insert", 52 | }), 53 | ).toMatchInlineSnapshot(`" color: red; display: flex; margin: 10px;"`); 54 | }); 55 | 56 | test("insert at index 2", () => { 57 | expect( 58 | inspectApi.getUpdatedCssText({ 59 | cssText: second, 60 | prop: "display", 61 | value: "flex", 62 | atIndex: 2, 63 | isCommented: false, 64 | mode: "insert", 65 | }), 66 | ).toMatchInlineSnapshot(`" color: red; margin: 10px; display: flex;"`); 67 | }); 68 | 69 | test("insert at index 3", () => { 70 | expect( 71 | inspectApi.getUpdatedCssText({ 72 | cssText: second, 73 | prop: "display", 74 | value: "flex", 75 | atIndex: 2, 76 | isCommented: false, 77 | mode: "insert", 78 | }), 79 | ).toMatchInlineSnapshot(`" color: red; margin: 10px; display: flex;"`); 80 | }); 81 | 82 | test("edit at index 0", () => { 83 | expect( 84 | inspectApi.getUpdatedCssText({ 85 | cssText: second, 86 | prop: "display", 87 | value: "flex", 88 | atIndex: 0, 89 | isCommented: false, 90 | mode: "edit", 91 | }), 92 | ).toMatchInlineSnapshot(`" display: flex; margin: 10px;"`); 93 | }); 94 | 95 | test("edit at index 1", () => { 96 | expect( 97 | inspectApi.getUpdatedCssText({ 98 | cssText: second, 99 | prop: "display", 100 | value: "flex", 101 | atIndex: 1, 102 | isCommented: false, 103 | mode: "edit", 104 | }), 105 | ).toMatchInlineSnapshot(`" color: red; display: flex;"`); 106 | }); 107 | 108 | test("edit at index 2", () => { 109 | expect( 110 | inspectApi.getUpdatedCssText({ 111 | cssText: second, 112 | prop: "display", 113 | value: "flex", 114 | atIndex: 2, 115 | isCommented: false, 116 | mode: "edit", 117 | }), 118 | ).toMatchInlineSnapshot(`" color: red; margin: 10px; display: flex;"`); 119 | }); 120 | 121 | test("edit at index 3", () => { 122 | expect( 123 | inspectApi.getUpdatedCssText({ 124 | cssText: second, 125 | prop: "display", 126 | value: "flex", 127 | atIndex: 2, 128 | isCommented: false, 129 | mode: "edit", 130 | }), 131 | ).toMatchInlineSnapshot(`" color: red; margin: 10px; display: flex;"`); 132 | }); 133 | -------------------------------------------------------------------------------- /components/create-style-context.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | createElement, 4 | forwardRef, 5 | useContext, 6 | type ComponentProps, 7 | type ElementType, 8 | type JSX, 9 | } from "react"; 10 | 11 | type GenericProps = Record; 12 | type StyleRecipe = { 13 | (props?: GenericProps): Record; 14 | // biome-ignore lint/suspicious/noExplicitAny: this is a generic type 15 | splitVariantProps: (props: GenericProps) => any; 16 | }; 17 | type StyleSlot = keyof ReturnType; 18 | type StyleSlotRecipe = Record, string>; 19 | type StyleVariantProps = Parameters[0]; 20 | type CombineProps = Omit & U; 21 | 22 | const cx = (...args: (string | undefined)[]) => args.filter(Boolean).join(" "); 23 | 24 | export interface ComponentVariants< 25 | T extends ElementType, 26 | R extends StyleRecipe, 27 | > { 28 | (props: CombineProps, StyleVariantProps>): JSX.Element; 29 | } 30 | 31 | export const createStyleContext = (recipe: R) => { 32 | const StyleContext = createContext | null>(null); 33 | 34 | const withProvider = ( 35 | Component: T, 36 | slot?: StyleSlot, 37 | ): ComponentVariants => { 38 | const StyledComponent = forwardRef>((props, ref) => { 39 | const [variantProps, otherProps] = recipe.splitVariantProps(props); 40 | const slotStyles = recipe(variantProps) as StyleSlotRecipe; 41 | return ( 42 | 43 | 48 | 49 | ); 50 | }); 51 | return StyledComponent as unknown as ComponentVariants; 52 | }; 53 | 54 | const withContext = ( 55 | Component: T, 56 | slot?: StyleSlot, 57 | ): T => { 58 | if (!slot) return Component; 59 | const StyledComponent = forwardRef>((props, ref) => { 60 | const slotStyles = useContext(StyleContext); 61 | return createElement(Component, { 62 | ...props, 63 | className: cx(slotStyles?.[slot ?? ""], props.className), 64 | ref, 65 | }); 66 | }); 67 | return StyledComponent as unknown as T; 68 | }; 69 | 70 | return { 71 | withProvider, 72 | withContext, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /components/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as ArkTooltip } from "@ark-ui/react/tooltip"; 2 | 3 | import { Portal } from "@ark-ui/react"; 4 | import { ComponentProps, Fragment, PropsWithChildren, ReactNode } from "react"; 5 | 6 | import { styled } from "#styled-system/jsx"; 7 | import { createStyleContext } from "#components/create-style-context.js"; 8 | import { sva } from "../styled-system/css"; 9 | 10 | const styles = sva({ 11 | slots: ["positioner", "content", "trigger", "arrow", "arrowTip"], 12 | base: { 13 | positioner: { 14 | display: "flex", 15 | borderRadius: "2px", 16 | color: "devtools.on-surface", 17 | fontSize: "12px", 18 | lineHeight: "11px", 19 | backgroundColor: "var(--arrow-background)", 20 | userSelect: "text", 21 | "--arrow-background": "colors.devtools.cdt-base-container", 22 | "--drop-shadow": 23 | "0 0 0 1px rgb(255 255 255/20%),0 2px 4px 2px rgb(0 0 0/20%),0 2px 6px 2px rgb(0 0 0/10%)", 24 | "&:has([data-state=open])": { 25 | boxShadow: "var(--drop-shadow)", 26 | }, 27 | }, 28 | content: { 29 | padding: "11px 7px", 30 | }, 31 | arrow: { 32 | "--arrow-size": "8px", 33 | }, 34 | }, 35 | }); 36 | 37 | const { withProvider, withContext } = createStyleContext(styles); 38 | 39 | export const Root = withProvider(ArkTooltip.Root); 40 | export const Content = withContext(styled(ArkTooltip.Content), "content"); 41 | export const Positioner = withContext( 42 | styled(ArkTooltip.Positioner), 43 | "positioner", 44 | ); 45 | export const Trigger = withContext(styled(ArkTooltip.Trigger), "trigger"); 46 | export const Arrow = withContext(styled(ArkTooltip.Arrow), "arrow"); 47 | export const ArrowTip = withContext(styled(ArkTooltip.ArrowTip), "arrowTip"); 48 | 49 | interface RootProps extends ComponentProps {} 50 | 51 | export interface TooltipProps extends PropsWithChildren { 52 | content: ReactNode; 53 | portalled?: boolean; 54 | withArrow?: boolean; 55 | } 56 | 57 | export const Tooltip = (props: TooltipProps) => { 58 | const { 59 | children, 60 | content, 61 | portalled = true, 62 | withArrow = true, 63 | ...rest 64 | } = props; 65 | const Portallish = portalled ? Portal : Fragment; 66 | 67 | return ( 68 | 75 | {children} 76 | 77 | 78 | 84 | {content} 85 | {withArrow && ( 86 | 87 | 88 | 89 | )} 90 | 91 | 92 | 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astahmer/atomic-css-devtools/83b004f7eba9cf2f4aa9e45b1d8ac2d762bd93ae/demo.gif -------------------------------------------------------------------------------- /entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | import "webext-bridge/background"; 2 | 3 | export default defineBackground(() => { 4 | console.log("Started background.ts"); 5 | }); 6 | -------------------------------------------------------------------------------- /entrypoints/content.ts: -------------------------------------------------------------------------------- 1 | import { onMessage, sendMessage } from "webext-bridge/content-script"; 2 | import type { 3 | DevtoolsMessage, 4 | ContentScriptEvents, 5 | DevtoolsApi, 6 | } from "../src/devtools-messages"; 7 | import { inspectApi } from "../src/inspect-api"; 8 | import { WindowEnv } from "../src/devtools-types"; 9 | import type { ContentScriptExtensionApi } from "./devtools-pane/message-typings"; 10 | 11 | export default defineContentScript({ 12 | matches: [""], 13 | main(_ctx) { 14 | import.meta.env.DEV && console.log("Started content.ts"); 15 | 16 | window.addEventListener("resize", function () { 17 | const env: WindowEnv = { 18 | location: window.location.href, 19 | widthPx: window.innerWidth, 20 | heightPx: window.innerHeight, 21 | deviceWidthPx: window.screen.width, 22 | deviceHeightPx: window.screen.height, 23 | dppx: window.devicePixelRatio, 24 | }; 25 | devtools.resize(env); 26 | }); 27 | 28 | window.addEventListener("focus", () => { 29 | devtools.focus(null); 30 | }); 31 | 32 | onDevtoolsMessage.inspectElement((message) => { 33 | const rule = inspectApi.inspectElement(message.data.selectors); 34 | return rule; 35 | }); 36 | onDevtoolsMessage.computePropertyValue((message) => { 37 | return inspectApi.computePropertyValue( 38 | message.data.selectors, 39 | message.data.prop, 40 | ); 41 | }); 42 | onDevtoolsMessage.updateStyleRule((message) => { 43 | return inspectApi.updateStyleAction(message.data); 44 | }); 45 | 46 | onDevtoolsMessage.appendInlineStyle((message) => { 47 | return inspectApi.appendInlineStyleAction(message.data); 48 | }); 49 | 50 | onDevtoolsMessage.removeInlineStyle((message) => { 51 | return inspectApi.removeInlineStyleAction(message.data); 52 | }); 53 | 54 | onDevtoolsMessage.highlightSelector((message) => { 55 | return inspectApi.highlightSelector(message.data); 56 | }); 57 | }, 58 | }); 59 | 60 | const devtools = new Proxy({} as any, { 61 | get(_target: any, propKey: T) { 62 | const context = "devtools"; 63 | const tabId = null as any; 64 | 65 | return async function (arg?: any) { 66 | // console.log(`Calling ${propKey} with payload`, arg); 67 | return sendMessage(propKey, arg, { context, tabId }); 68 | } as ContentScriptEvents[T] extends DevtoolsMessage< 69 | infer Data, 70 | infer Return 71 | > 72 | ? (args: Data) => Promise 73 | : (args: ContentScriptEvents[T]) => Promise; 74 | }, 75 | }); 76 | 77 | const onDevtoolsMessage = new Proxy({} as any, { 78 | get(_target: any, propKey: T) { 79 | return function (cb: (message: any) => any) { 80 | return onMessage(propKey, (message) => { 81 | // console.log(`Received ${propKey} with message`, message.data); 82 | return cb(message); 83 | }); 84 | }; 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/api.ts: -------------------------------------------------------------------------------- 1 | import { onMessage, sendMessage } from "webext-bridge/devtools"; 2 | import { DevtoolsContextValue } from "../../src/devtools-context"; 3 | import type { 4 | ContentScriptApi, 5 | ContentScriptEvents, 6 | } from "../../src/devtools-messages"; 7 | import type { DevtoolsExtensionApi } from "./message-typings"; 8 | 9 | /** 10 | * From devtools to content script 11 | */ 12 | export const contentScript = new Proxy({} as any, { 13 | get(_target: any, propKey: T) { 14 | const context = "content-script"; 15 | const tabId = null as any; 16 | 17 | return async function (arg?: any) { 18 | // console.log(`Calling ${propKey} with payload`, arg); 19 | return sendMessage(propKey, arg, { context, tabId }); 20 | }; 21 | }, 22 | }); 23 | 24 | /** 25 | * From (currently) content script to devtools 26 | */ 27 | export const onContentScriptMessage = new Proxy( 28 | {} as any, 29 | { 30 | get(_target: any, propKey: T) { 31 | return function (cb: (message: any) => any) { 32 | return onMessage(propKey, (message) => { 33 | // console.log(`Received ${propKey} with message`, message.data); 34 | return cb(message); 35 | }); 36 | }; 37 | }, 38 | }, 39 | ); 40 | 41 | const listeners = new Map void>(); 42 | export const onDevtoolEvent: DevtoolsContextValue["onDevtoolEvent"] = ( 43 | event, 44 | cb, 45 | ) => { 46 | listeners.set(event, cb); 47 | }; 48 | 49 | onMessage("devtools-shown", () => { 50 | const cb = listeners.get("devtools-shown"); 51 | cb?.(); 52 | }); 53 | 54 | onMessage("devtools-hidden", () => { 55 | const cb = listeners.get("devtools-hidden"); 56 | cb?.(); 57 | }); 58 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/context.ts: -------------------------------------------------------------------------------- 1 | import type { DevtoolsContextValue } from "../../src/devtools-context"; 2 | import { contentScript, onDevtoolEvent, onContentScriptMessage } from "./api"; 3 | import { evaluator } from "./eval"; 4 | 5 | export const extensionContext: DevtoolsContextValue = { 6 | evaluator, 7 | contentScript, 8 | onDevtoolEvent, 9 | onContentScriptMessage, 10 | }; 11 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/eval.ts: -------------------------------------------------------------------------------- 1 | import { Evaluator } from "../../src/devtools-context"; 2 | import type { InspectResult } from "../../src/inspect-api"; 3 | import { 4 | AnyElementFunction, 5 | WithoutFirst, 6 | AnyFunction, 7 | } from "../../src/lib/types"; 8 | import { contentScript } from "./api"; 9 | 10 | const evalEl = ( 11 | fn: T, 12 | ...args: WithoutFirst 13 | ) => { 14 | return new Promise>(async (resolve, reject) => { 15 | const stringified = 16 | "(" + 17 | fn.toString() + 18 | ")(" + 19 | ["$0"] 20 | .concat(args as any) 21 | .map((arg, index) => (index === 0 ? arg : JSON.stringify(arg))) 22 | .join() + 23 | ")"; 24 | const [result, error] = 25 | await browser.devtools.inspectedWindow.eval(stringified); 26 | if (error) { 27 | // console.error("{evalEl} error"); 28 | console.log({ stringified }); 29 | return reject(error.value); 30 | } 31 | 32 | return resolve(result); 33 | }); 34 | }; 35 | 36 | const evalFn = (fn: T, ...args: Parameters) => { 37 | return new Promise>(async (resolve, reject) => { 38 | const stringified = 39 | "(" + 40 | fn.toString() + 41 | ")(" + 42 | args.map((arg) => JSON.stringify(arg)).join() + 43 | ")"; 44 | const [result, error] = 45 | await browser.devtools.inspectedWindow.eval(stringified); 46 | if (error) { 47 | // console.error("{eval} error"); 48 | console.log({ stringified }); 49 | return reject(error.value); 50 | } 51 | 52 | return resolve(result); 53 | }); 54 | }; 55 | 56 | const inspect = async () => { 57 | const selectors = await evalEl((el) => { 58 | if (!el) return null; 59 | 60 | function getElementSelectors(el: Element) { 61 | const rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|^-|[^\x80-\uFFFF\w-]/g; 62 | const fcssescape = function (ch: string, asCodePoint: string) { 63 | if (!asCodePoint) return "\\" + ch; 64 | if (ch === "\0") return "\uFFFD"; 65 | if (ch === "-" && ch.length === 1) return "\\-"; 66 | return ( 67 | ch.slice(0, -1) + 68 | "\\" + 69 | ch.charCodeAt(ch.length - 1).toString(16) + 70 | "" 71 | ); 72 | }; 73 | const esc = (sel: string) => { 74 | return (sel + "").replace(rcssescape, fcssescape); 75 | }; 76 | 77 | // Use nth-of-type as a more reliable alternative to nth-child 78 | const getNthSelector = (el: Element) => { 79 | const parent = el.parentNode; 80 | if (!parent) return; 81 | 82 | const tag = el.tagName; 83 | const siblings = parent.children; 84 | 85 | let count = 0; 86 | for (let i = 0; i < siblings.length; i++) { 87 | if (siblings[i].tagName === tag) { 88 | count++; 89 | if (siblings[i] === el && count > 1) { 90 | return `:nth-of-type(${count})`; 91 | } 92 | } 93 | } 94 | }; 95 | 96 | function getUniqueSelector(element: Element) { 97 | const path = []; 98 | let currentElement = element; 99 | 100 | while (currentElement.nodeType === Node.ELEMENT_NODE) { 101 | let selector = currentElement.nodeName.toLowerCase(); 102 | if (currentElement.id) { 103 | selector = "#" + esc(currentElement.id); 104 | path.unshift(selector); 105 | break; // ID is unique enough 106 | } 107 | 108 | const nth = getNthSelector(currentElement); 109 | if (nth) selector += nth; 110 | path.unshift(selector); 111 | 112 | if (currentElement.parentElement) { 113 | currentElement = currentElement.parentElement; 114 | } else if ((currentElement as any as ShadowRoot).host) { 115 | // Move up through shadow DOM 116 | path.unshift("::shadow-root"); 117 | currentElement = (currentElement as any as ShadowRoot).host; 118 | } else { 119 | break; // No parent or host means we're at the top 120 | } 121 | } 122 | return path.join(" > "); 123 | } 124 | 125 | const selectors = []; 126 | let currentContext = el; 127 | while (currentContext) { 128 | selectors.unshift(getUniqueSelector(currentContext)); 129 | 130 | const rootNode = currentContext.getRootNode() as ShadowRoot; 131 | if (rootNode && rootNode.host) { 132 | currentContext = rootNode.host; 133 | } else { 134 | break; 135 | } 136 | } 137 | 138 | // Check for being inside an iframe by checking defaultView.frameElement 139 | let contextWindow = el.ownerDocument.defaultView; 140 | while (contextWindow && contextWindow.frameElement) { 141 | selectors.unshift(getUniqueSelector(contextWindow.frameElement)); 142 | contextWindow = contextWindow.parent as Window & typeof globalThis; 143 | } 144 | 145 | const filtered = selectors.filter(Boolean); 146 | if (filtered.length === 0) return null; 147 | 148 | return filtered; 149 | } 150 | 151 | return getElementSelectors(el); 152 | }); 153 | 154 | if (!selectors) return null; 155 | 156 | return contentScript.inspectElement({ selectors }); 157 | }; 158 | 159 | export const evaluator: Evaluator = { 160 | fn: evalFn, 161 | el: evalEl, 162 | copy: (valueToCopy: string) => { 163 | return evalFn( 164 | // @ts-expect-error https://developer.chrome.com/docs/devtools/console/utilities/#copy-function 165 | (value: string) => window.copy(value), 166 | valueToCopy, 167 | ); 168 | }, 169 | inspect: inspect, 170 | onSelectionChanged: (cb: (element: InspectResult | null) => void) => { 171 | const handleSelectionChanged = async () => { 172 | const result = await inspect(); 173 | cb(result ?? null); 174 | }; 175 | browser.devtools.panels.elements.onSelectionChanged.addListener( 176 | handleSelectionChanged, 177 | ); 178 | 179 | if (browser.devtools.panels.themeName === "dark") { 180 | document.body.classList.add("-theme-with-dark-background"); 181 | } 182 | 183 | handleSelectionChanged(); 184 | 185 | return () => { 186 | browser.devtools.panels.elements.onSelectionChanged.removeListener( 187 | handleSelectionChanged, 188 | ); 189 | }; 190 | }, 191 | }; 192 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
18 | Select an element in the element panel 21 |
22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { SidebarPane } from "../../src/sidebar-pane.tsx"; 4 | import { sendMessage } from "webext-bridge/devtools"; 5 | import { WithPlatformClass } from "./use-platform-class.ts"; 6 | import { DevtoolsProvider } from "../../src/devtools-context.ts"; 7 | import { extensionContext } from "./context.ts"; 8 | 9 | browser.runtime.onMessage.addListener( 10 | function (_request, _sender, _sendResponse) { 11 | // Dummy listener to prevent error: 12 | // Could not establish connection. Receiving end does not exist. 13 | // the actual listener will be in the component 14 | sendMessage("devtools-shown", null, { 15 | context: "devtools", 16 | tabId: null as any, 17 | }); 18 | }, 19 | ); 20 | 21 | ReactDOM.createRoot(document.getElementById("root")!).render( 22 | 23 | 24 | 25 | 26 | 27 | , 28 | ); 29 | -------------------------------------------------------------------------------- /entrypoints/devtools-pane/message-typings.ts: -------------------------------------------------------------------------------- 1 | import type { Endpoint, ProtocolWithReturn } from "webext-bridge"; 2 | import { 3 | ContentScriptEvents, 4 | DevtoolsApiEvents, 5 | DevtoolsMessage, 6 | } from "../../src/devtools-messages"; 7 | 8 | export type ContentScriptExtensionApi = { 9 | [T in keyof ContentScriptEvents]: ContentScriptEvents[T] extends DevtoolsMessage< 10 | infer Data, 11 | infer Return 12 | > 13 | ? (cb: OnMessageCallback) => void 14 | : never; 15 | }; 16 | 17 | export type DevtoolsExtensionApi = { 18 | [T in keyof DevtoolsApiEvents]: DevtoolsApiEvents[T] extends DevtoolsMessage< 19 | infer Data, 20 | infer Return 21 | > 22 | ? (cb: OnMessageCallback) => void 23 | : never; 24 | }; 25 | 26 | // 27 | 28 | interface BridgeMessage { 29 | sender: Endpoint; 30 | id: string; 31 | data: T; 32 | timestamp: number; 33 | } 34 | type OnMessageCallback = ( 35 | message: BridgeMessage, 36 | ) => R | Promise; 37 | 38 | // from type-fest 2.19 (version of type-fest that is used in webext-bridge) 39 | type JsonObject = { [Key in string]?: JsonValue }; 40 | type JsonArray = JsonValue[]; 41 | type JsonPrimitive = string | number | boolean | null; 42 | type JsonValue = JsonPrimitive | JsonObject | JsonArray; 43 | 44 | // 45 | 46 | type InferredProtocolMap = { 47 | [T in keyof ContentScriptEvents]: ContentScriptEvents[T] extends DevtoolsMessage< 48 | infer Data, 49 | infer Return 50 | > 51 | ? ProtocolWithReturn 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 |
143 | {view === "normal" && tooltipInfo && ( 144 | 145 | )} 146 | {view === "atomic" && inspected && computed && ( 147 |
158 | {Array.from(computed.order).map((key, index) => ( 159 | {}, 169 | }} 170 | /> 171 | ))} 172 |
173 | )} 174 |
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 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 "