diff --git a/element-inspector/README.md b/element-inspector/README.md index a283527..5aa6594 100644 --- a/element-inspector/README.md +++ b/element-inspector/README.md @@ -47,8 +47,14 @@ pnpm dev # build + watch into dist/ pnpm build # type-check + production build into dist/ pnpm test # unit tests (color + geometry utils) pnpm typecheck # tsc --noEmit +pnpm icons # regenerate the extension icons (PNG sizes + SVG source) ``` +The toolbar icon sizes are generated from the source artwork `src/assets/logo.png` by +[`scripts/generate-icons.mjs`](scripts/generate-icons.mjs) (pure Node — decodes the PNG and +area-downsamples it). They are written to `public/icons/`, which Vite copies into `dist/` where +the manifest references them. To change the logo, replace `src/assets/logo.png` and run `pnpm icons`. + Then load it in Chrome: 1. Open `chrome://extensions`, enable **Developer mode**. diff --git a/element-inspector/manifest.json b/element-inspector/manifest.json index fff6f4d..4df8c21 100644 --- a/element-inspector/manifest.json +++ b/element-inspector/manifest.json @@ -1,10 +1,22 @@ { "manifest_version": 3, "name": "Element Inspector", - "version": "0.0.0", - "description": "Isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.", + "version": "1.0.0", + "description": "Isolate any page element on a clean canvas: box model, colors, inherited styles, grid/flex layout and responsive testing.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, "action": { - "default_title": "Inspect element (Alt+Shift+E)" + "default_title": "Inspect element (Alt+Shift+E)", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } }, "background": { "service_worker": "src/background.ts", diff --git a/element-inspector/package.json b/element-inspector/package.json index 21a8af2..e2ba50e 100644 --- a/element-inspector/package.json +++ b/element-inspector/package.json @@ -1,15 +1,16 @@ { "name": "element-inspector", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", - "description": "Browser extension to isolate any page element on a clean canvas and inspect its sizes, colors, spacing and responsive behavior.", + "description": "Browser extension to isolate any page element on a clean canvas and inspect its box model, colors, inherited styles, grid/flex layout and responsive behavior.", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "icons": "node scripts/generate-icons.mjs" }, "dependencies": { "vue": "3.6.0-beta.13" @@ -21,7 +22,7 @@ "@vue/tsconfig": "^0.9.1", "tailwindcss": "^4.3.0", "typescript": "~6.0.3", - "vite": "^8.0.13", + "vite": "^8.0.16", "vite-plugin-web-extension": "^4.5.1", "vitest": "^4.1.8", "vue-jsx-vapor": "^3.2.14" diff --git a/element-inspector/pnpm-lock.yaml b/element-inspector/pnpm-lock.yaml index 6f6aece..8cc4592 100644 --- a/element-inspector/pnpm-lock.yaml +++ b/element-inspector/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: specifier: ~6.0.3 version: 6.0.3 vite: - specifier: ^8.0.13 + specifier: ^8.0.16 version: 8.0.16(@types/node@25.9.1)(jiti@2.7.0)(yaml@2.9.0) vite-plugin-web-extension: specifier: ^4.5.1 @@ -164,36 +164,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -262,24 +268,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -407,21 +417,25 @@ packages: resolution: {integrity: sha512-nQ5+lm7sCscWlh7FpAZyatHChfhyUjPtqlpXrUq/zGilHq57Ls4OLxoI6krPPTUpxMR6GaqhJjozgoniy1CzZQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@vue-jsx-vapor/compiler-rs-linux-arm64-musl@3.2.14': resolution: {integrity: sha512-SCjl5dlnH7HKuaCdwvJZ/Z1L7AYw+R6Bheruvf2HDfyzabNMn4ovW2LLZ3rSBrzciTlkfH34RDJBqHr/GV8Q2A==} cpu: [arm64] os: [linux] + libc: [musl] '@vue-jsx-vapor/compiler-rs-linux-x64-gnu@3.2.14': resolution: {integrity: sha512-mD8Ooy11xGucEf/RClUNBP9iQyQKw5kLCCQZ3+dX/Q+V12OSYkrt/4Bz1quCeoink0uIz7FxpQay1/o2qbFo4Q==} cpu: [x64] os: [linux] + libc: [glibc] '@vue-jsx-vapor/compiler-rs-linux-x64-musl@3.2.14': resolution: {integrity: sha512-hoeRrCtJTMHAQCMYn3NZbiV69efaX/0PHPg54SmWgCXxkXvl05AKHli9QpOERWmG4DYRo8APLCs9FvLe7Lyp9g==} cpu: [x64] os: [linux] + libc: [musl] '@vue-jsx-vapor/compiler-rs-wasm32-wasi@3.2.14': resolution: {integrity: sha512-m17CyZXe0t72xc3EwSMIRavxeW9ZnOIOjMzRvWLxnUHZr4cH5nrrG/QhO4wul1gqpMWQrQkQMdUKclALNPFt4A==} @@ -981,24 +995,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1072,9 +1090,8 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - obug@2.1.2: - resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} - engines: {node: '>=12.20.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} @@ -1178,8 +1195,8 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} - semver@7.8.2: - resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} engines: {node: '>=10'} hasBin: true @@ -2442,7 +2459,7 @@ snapshots: dependencies: growly: 1.3.0 is-wsl: 2.2.0 - semver: 7.8.2 + semver: 7.8.1 shellwords: 0.1.1 uuid: 8.3.2 which: 2.0.2 @@ -2451,7 +2468,7 @@ snapshots: dependencies: boolbase: 1.0.0 - obug@2.1.2: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -2462,7 +2479,7 @@ snapshots: ky: 1.14.3 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.8.2 + semver: 7.8.1 pako@1.0.11: {} @@ -2578,7 +2595,7 @@ snapshots: sax@1.6.0: {} - semver@7.8.2: {} + semver@7.8.1: {} set-value@4.1.0: dependencies: @@ -2722,7 +2739,7 @@ snapshots: is-npm: 6.1.0 latest-version: 9.0.0 pupa: 3.3.0 - semver: 7.8.2 + semver: 7.8.1 xdg-basedir: 5.1.0 util-deprecate@1.0.2: {} @@ -2783,7 +2800,7 @@ snapshots: es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.2 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 std-env: 4.1.0 diff --git a/element-inspector/pnpm-workspace.yaml b/element-inspector/pnpm-workspace.yaml new file mode 100644 index 0000000..d20de61 --- /dev/null +++ b/element-inspector/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + spawn-sync: true diff --git a/element-inspector/public/icons/icon-128.png b/element-inspector/public/icons/icon-128.png new file mode 100644 index 0000000..dcdbfdd Binary files /dev/null and b/element-inspector/public/icons/icon-128.png differ diff --git a/element-inspector/public/icons/icon-16.png b/element-inspector/public/icons/icon-16.png new file mode 100644 index 0000000..5ccdb1a Binary files /dev/null and b/element-inspector/public/icons/icon-16.png differ diff --git a/element-inspector/public/icons/icon-32.png b/element-inspector/public/icons/icon-32.png new file mode 100644 index 0000000..6dfeb9c Binary files /dev/null and b/element-inspector/public/icons/icon-32.png differ diff --git a/element-inspector/public/icons/icon-48.png b/element-inspector/public/icons/icon-48.png new file mode 100644 index 0000000..ccc7a07 Binary files /dev/null and b/element-inspector/public/icons/icon-48.png differ diff --git a/element-inspector/src/assets/logo.png b/element-inspector/src/assets/logo.png new file mode 100644 index 0000000..c6b141e Binary files /dev/null and b/element-inspector/src/assets/logo.png differ diff --git a/element-inspector/src/content/main.ts b/element-inspector/src/content/main.ts index 3a3634d..c2a8f4f 100644 --- a/element-inspector/src/content/main.ts +++ b/element-inspector/src/content/main.ts @@ -64,14 +64,25 @@ function ensureHost(): ShadowRoot { function deactivate(): void { if (mode === 'idle') return; mode = 'idle'; + // Each teardown step is isolated so a throw in one (e.g. during Vapor unmount) can't + // strand the overlay on the page — the host is always removed. if (picker) { const active = picker; picker = null; - active.cancel(); + try { + active.cancel(); + } catch { + /* picker already torn down */ + } } if (canvas) { - canvas.unmount(); + const active = canvas; canvas = null; + try { + active.unmount(); + } catch { + /* app already unmounted */ + } } if (host) { host.remove(); diff --git a/element-inspector/src/ui/App.tsx b/element-inspector/src/ui/App.tsx index a07d657..cb7e352 100644 --- a/element-inspector/src/ui/App.tsx +++ b/element-inspector/src/ui/App.tsx @@ -16,7 +16,7 @@ export default function App() { onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true)); return ( -
+
diff --git a/element-inspector/src/ui/components/ColorSwatch.tsx b/element-inspector/src/ui/components/ColorSwatch.tsx index cc8539e..a77f7d8 100644 --- a/element-inspector/src/ui/components/ColorSwatch.tsx +++ b/element-inspector/src/ui/components/ColorSwatch.tsx @@ -1,30 +1,38 @@ import type { ColorSwatch as Swatch } from '../store'; +import { useClipboard } from '../composables'; // A single color row: swatch + label + value (CSS variable name when resolved, else hex). // Clicking copies `var(--name)` or the hex to the clipboard. export default function ColorSwatch(props: { swatch: Swatch }) { - const copy = (): void => { - const text = props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex; - navigator.clipboard?.writeText(text).catch(() => { - /* clipboard may be blocked on some pages */ - }); + const { copy, copied } = useClipboard(); + const onCopy = (): void => { + void copy(props.swatch.varName ? `var(${props.swatch.varName})` : props.swatch.hex); }; return ( ); } diff --git a/element-inspector/src/ui/components/GuidesLayer.tsx b/element-inspector/src/ui/components/GuidesLayer.tsx new file mode 100644 index 0000000..e04a2ab --- /dev/null +++ b/element-inspector/src/ui/components/GuidesLayer.tsx @@ -0,0 +1,73 @@ +import { shallowRef } from 'vue'; +import { removeGuide, state, updateGuide } from '../store'; + +// The ruler band size (must match Rulers' SIZE). Dropping a guide back over its ruler removes it. +const RULER = 20; + +// Interactive guides drawn over the canvas. Each guide is a thin draggable strip: drag to +// reposition, drag back onto its ruler (or double-click) to remove. New guides are pulled out +// of the rulers (see Rulers.tsx). Positions are stored in iframe-content pixels and mapped to +// the viewport via the current pan/zoom. +export default function GuidesLayer() { + const layer = shallowRef(); + + const startMove = (axis: 'x' | 'y', index: number): void => { + const rect = layer.value?.getBoundingClientRect(); + if (!rect) return; + const move = (ev: PointerEvent): void => { + const pos = + axis === 'x' + ? (ev.clientX - rect.left - state.panX) / state.zoom + : (ev.clientY - rect.top - state.panY) / state.zoom; + updateGuide(axis, index, pos); + }; + const up = (ev: PointerEvent): void => { + window.removeEventListener('pointermove', move, true); + window.removeEventListener('pointerup', up, true); + // Released over the originating ruler band → remove the guide. + const overRuler = axis === 'x' ? ev.clientY - rect.top < RULER : ev.clientX - rect.left < RULER; + if (overRuler) removeGuide(axis, index); + }; + window.addEventListener('pointermove', move, true); + window.addEventListener('pointerup', up, true); + }; + + const begin = (e: { preventDefault(): void; stopPropagation(): void }, axis: 'x' | 'y', index: number): void => { + e.preventDefault(); + e.stopPropagation(); + startMove(axis, index); + }; + + return ( +
+ {state.guides.x.map((gx, i) => ( +
begin(e, 'x', i)} + onDblclick={() => removeGuide('x', i)} + > +
+ + {Math.round(gx)} + +
+ ))} + {state.guides.y.map((gy, i) => ( +
begin(e, 'y', i)} + onDblclick={() => removeGuide('y', i)} + > +
+ + {Math.round(gy)} + +
+ ))} +
+ ); +} diff --git a/element-inspector/src/ui/components/InspectorPanel.tsx b/element-inspector/src/ui/components/InspectorPanel.tsx index db611f1..0ab0825 100644 --- a/element-inspector/src/ui/components/InspectorPanel.tsx +++ b/element-inspector/src/ui/components/InspectorPanel.tsx @@ -1,14 +1,19 @@ import { computed } from 'vue'; import { state } from '../store'; +import type { LayoutHighlight, StyleItem } from '../store'; import ColorSwatch from './ColorSwatch'; // Right sidebar. Shows the hovered element's metrics (falling back to the selected one): -// dimensions, spacing/radius, typography and colors (resolved to CSS variables when possible). +// dimensions, layout (with hover-to-highlight on the canvas), spacing, typography, effects and +// colors — inherited values are flagged, and colors resolve to CSS variables when possible. export default function InspectorPanel() { const info = computed(() => state.hover ?? state.selected); + const highlightFor = (label: string): LayoutHighlight => + label === 'Gap' ? 'gap' : label === 'Columns' || label === 'Rows' ? 'tracks' : 'none'; + return ( -