feat: implement complete PixelLens Chrome extension - #1
Conversation
Chrome Extension Manifest V3 for inspecting websites and extracting design systems. - Content scripts: element inspector (hover highlight, click select, distance measurer, grid overlay), page scanner (colors, typography, spacing, shadows extraction with clustering), floating toolbar + tooltip in Shadow DOM - Side panel: React 19 app with Inspector/Scan/DesignSystem/Export/History views, 8 reusable components (ColorSwatch, ColorPalette, TypeSpecimen, SpacingScale, BoxModelViz, CSSBlock, ShadowPreview, ExportButton) - Background: service worker for message routing, commands, badge management - Popup: compact dark UI with quick actions - Shared libs: color utils (Chroma.js), CSS parser, design tokens generator (CSS vars/Tailwind/JSON), DOM utils, export, typed messaging, Chrome storage - GSAP animations: tab sliding indicator, progress bar shimmer, confetti export, hover scale, toolbar slide-up - 68 unit tests (Vitest) for all core libraries - GitHub Actions CI: typecheck, lint, test, build, artifact upload - Full README with setup instructions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a full Manifest V3 Chrome extension (“PixelLens”) for inspecting page styles and scanning pages to extract a design system, with a Side Panel UI, Popup UI, content-script tooling, export formats, unit tests, and CI automation.
Changes:
- Adds Side Panel React app (Inspector/Scan/Design System/Export/History) and reusable UI components.
- Implements content-script inspector tools (highlight/select/measure/grid) plus a full-page scanner (colors/typography/spacing/shadows/radius) that reports progress back to the panel.
- Adds shared libraries (tokens export, CSS parsing, DOM utilities, storage/messaging) with Vitest coverage + GitHub Actions CI.
Reviewed changes
Copilot reviewed 52 out of 76 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Configures Vitest (jsdom, globals) and @ path alias for tests. |
| src/sidepanel/views/ScanView.tsx | Scan progress UI + tabbed results (colors/fonts/spacing/shadows). |
| src/sidepanel/views/InspectorView.tsx | Element inspection view (colors/typography/box model/effects/raw CSS). |
| src/sidepanel/views/HistoryView.tsx | Displays and manages scan history (store + storage merge). |
| src/sidepanel/views/ExportView.tsx | Exports tokens in CSS/Tailwind/JSON via copy/download actions. |
| src/sidepanel/views/DesignSystemView.tsx | Displays/edit tokens and provides export entry point. |
| src/sidepanel/styles/panel.css | Side Panel theme tokens, base styles, and shared animations. |
| src/sidepanel/store.ts | Zustand store for side panel modes, scan progress, and history. |
| src/sidepanel/main.tsx | Side Panel React entrypoint and stylesheet wiring. |
| src/sidepanel/index.html | Side Panel HTML host page. |
| src/sidepanel/components/TypeSpecimen.tsx | Typography specimen preview + copy-to-clipboard. |
| src/sidepanel/components/SpacingScale.tsx | Spacing scale visualization (frequency + base highlight). |
| src/sidepanel/components/ShadowPreview.tsx | Shadow preview tiles with copy-to-clipboard. |
| src/sidepanel/components/ExportButton.tsx | Dropdown export button with confetti feedback and PNG option. |
| src/sidepanel/components/CSSBlock.tsx | Code preview block with copy action and basic highlighting. |
| src/sidepanel/components/ColorSwatch.tsx | Color swatch with hover/copy interactions and format switching. |
| src/sidepanel/components/ColorPalette.tsx | Groups and displays extracted colors by category. |
| src/sidepanel/components/BoxModelViz.tsx | Renders margin/border/padding/content box-model visualization. |
| src/sidepanel/App.tsx | Main panel shell, routing between views, and runtime message handling. |
| src/popup/styles/popup.css | Popup styling aligned to the panel theme. |
| src/popup/Popup.tsx | Popup UI for toggling inspect, scanning, and opening side panel. |
| src/popup/main.tsx | Popup React entrypoint. |
| src/popup/index.html | Popup HTML host page. |
| src/lib/storage.ts | Chrome storage wrapper for preferences and saved design systems. |
| src/lib/messaging.ts | Typed wrapper around Chrome runtime/tab messaging. |
| src/lib/export.ts | Clipboard, file download, and palette PNG generation helpers. |
| src/lib/dom-utils.ts | DOM traversal/visibility helpers and computed-style extraction. |
| src/lib/design-tokens.ts | Converts extracted DS into CSS variables, Tailwind config, and JSON tokens. |
| src/lib/css-parser.ts | Computed-style parsing/filtering and CSS block generation utilities. |
| src/lib/colors.ts | Color conversion, clustering, categorization, and contrast helpers. |
| src/lib/tests/storage.test.ts | Unit tests for storage wrapper behavior. |
| src/lib/tests/dom-utils.test.ts | Unit tests for DOM utilities (visibility/path/PixelLens element checks). |
| src/lib/tests/design-tokens.test.ts | Unit tests for token export formats and dispatcher. |
| src/lib/tests/css-parser.test.ts | Unit tests for CSS formatting/filtering and shorthand expansion. |
| src/lib/tests/colors.test.ts | Unit tests for color utilities (conversion, clustering, classification). |
| src/content/ui/InspectorTooltip.tsx | Cursor-follow tooltip UI for inspect mode. |
| src/content/ui/FloatingToolbar.tsx | Draggable in-page toolbar for switching modes and starting scans. |
| src/content/ui/ContentApp.tsx | Shadow-DOM React wrapper that coordinates toolbar + tooltip. |
| src/content/scanner/TypographyExtractor.ts | Extracts font families/variants and detects a type scale ratio. |
| src/content/scanner/SpacingExtractor.ts | Extracts and normalizes spacing values and derives a spacing scale. |
| src/content/scanner/PageScanner.ts | Orchestrates full scan phases and progress reporting. |
| src/content/scanner/DesignSystemBuilder.ts | Assembles scan outputs and extracts shadows/border-radius tokens. |
| src/content/scanner/ColorExtractor.ts | Extracts, clusters, and categorizes colors from computed styles. |
| src/content/inspector/GridOverlay.ts | Canvas-based grid overlay with redraw scheduling. |
| src/content/inspector/ElementSelector.ts | Captures element selection and sends inspected computed styles. |
| src/content/inspector/ElementHighlighter.ts | Hover overlay for box model (content/padding/margin) + badge. |
| src/content/inspector/DistanceMeasurer.ts | Measures distance between two clicked elements with guides. |
| src/content/index.ts | Content script entry: mode management, message listeners, mounts Shadow UI. |
| src/background/service-worker.ts | Background SW routing, inspect state per tab, side panel open, shortcuts. |
| README.md | Project documentation (features, architecture, scripts, setup). |
| package.json | Adds test/typecheck scripts and new dependencies/devDependencies. |
| package-lock.json | Locks updated deps including Vitest/jsdom/coverage tooling. |
| .gitignore | Adds ignore for PRD_02_PixelLens.mddist/. |
| .github/workflows/ci.yml | CI pipeline: typecheck → lint → test → build → upload artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case MessageType.TOGGLE_INSPECT: { | ||
| const tabId = sender.tab?.id | ||
| if (tabId) { | ||
| toggleInspect(tabId) | ||
| } | ||
| sendResponse({ success: true }) |
There was a problem hiding this comment.
MessageType.TOGGLE_INSPECT payload includes { active: boolean }, but this handler ignores payload.active and always calls toggleInspect(tabId). That can invert the requested state (e.g., callers sending active: false may end up enabling inspect), and can desync popup/toolbar UI from the actual tab state. Consider honoring payload.active by setting activeTabState explicitly (e.g., setInspect(tabId, payload.active)) and reserving toggling behavior for the keyboard command path.
| chrome.storage.sync.set({ pixellens_preferences: prefs.preferences }) | ||
| sendResponse({ success: true }) | ||
| break |
There was a problem hiding this comment.
SET_PREFERENCES is typed as { preferences: Partial<Preferences> }, but this code overwrites pixellens_preferences with only the provided partial object. That can drop previously stored fields/defaults (e.g., setting colorFormat would erase gridSize). Merge with existing preferences before writing (or reuse the setPreferences helper in src/lib/storage.ts).
| chrome.storage.sync.set({ pixellens_preferences: prefs.preferences }) | |
| sendResponse({ success: true }) | |
| break | |
| chrome.storage.sync.get('pixellens_preferences', (result) => { | |
| const mergedPreferences = { | |
| colorFormat: 'hex', | |
| gridSize: 8, | |
| theme: 'dark', | |
| ...(result['pixellens_preferences'] || {}), | |
| ...prefs.preferences, | |
| } | |
| chrome.storage.sync.set({ pixellens_preferences: mergedPreferences }, () => { | |
| sendResponse({ success: true }) | |
| }) | |
| }) | |
| return true // async response |
| onMessage(MessageType.SCAN_PAGE, (_payload, _sender, sendResponse) => { | ||
| if (!scanner) { | ||
| scanner = new PageScanner() | ||
| } | ||
|
|
||
| scanner.scan((progress, phase) => { | ||
| sendMessage(MessageType.SCAN_PROGRESS, { progress, phase }) | ||
| }).then((designSystem) => { | ||
| sendMessage(MessageType.SCAN_COMPLETE, { designSystem }) | ||
| }) | ||
|
|
||
| // Return true to keep the message channel open for async response | ||
| return true | ||
| }) |
There was a problem hiding this comment.
This SCAN_PAGE listener returns true (keeps the message channel open) but never calls sendResponse. In MV3 this can lead to pending/failed sendMessage calls ("message port closed" warnings) and unnecessary open ports. Either respond immediately (and don’t return true), or return true and call sendResponse in the then/catch of the scan promise.
| // Drag handling | ||
| const onMouseDown = useCallback((e: React.MouseEvent) => { | ||
| if (!toolbarRef.current) return | ||
| const rect = toolbarRef.current.getBoundingClientRect() | ||
| dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top } | ||
| setDragging(true) | ||
| }, []) |
There was a problem hiding this comment.
Drag start is attached to the entire toolbar (onMouseDown on the wrapper), so pressing any button will also start dragging because stopPropagation() is only applied to the button click handler, not to mousedown. This makes button clicks feel “sticky”/draggable. Consider starting drag only from a dedicated handle area, or early-return in onMouseDown when e.target is a button (or call e.stopPropagation() on the buttons’ onMouseDown).
| <DSSection title="Palette" icon={<Palette size={14} />} count={designSystem.colors.length}> | ||
| <div className="flex flex-col gap-2"> | ||
| {designSystem.colors.map((color, i) => ( | ||
| <div key={i} className="flex items-center gap-2 group"> | ||
| <ColorSwatch color={color.hex} size={28} /> |
There was a problem hiding this comment.
Using the array index as the React key here can cause incorrect row/component state when colors are renamed/removed (indices shift). Since these items are editable/removable, it’s safer to key by a stable identifier (e.g., a token id, or a combination derived from color.hex/category plus a disambiguator for duplicates).
| function isPixelLensElement(el: Element): boolean { | ||
| let node: Node | null = el | ||
| while (node) { | ||
| if ((node as HTMLElement).id === 'pixellens-host') return true | ||
| node = node.parentNode | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
isPixelLensElement is duplicated here, but the same helper already exists in src/lib/dom-utils.ts. Duplicating this logic risks divergence (e.g., if the host id changes or other PixelLens-owned roots are added). Prefer importing and reusing the shared helper.
| // Message routing between content script <-> side panel | ||
| chrome.runtime.onMessage.addListener((message: IncomingMessage, sender, sendResponse) => { | ||
| const { type, payload } = message | ||
|
|
||
| switch (type) { | ||
| case MessageType.TOGGLE_INSPECT: { | ||
| const tabId = sender.tab?.id | ||
| if (tabId) { | ||
| toggleInspect(tabId) | ||
| } | ||
| sendResponse({ success: true }) | ||
| break |
There was a problem hiding this comment.
sender.tab?.id will be undefined for messages coming from extension pages like the popup/sidepanel. As a result, TOGGLE_INSPECT triggered from src/popup/Popup.tsx (which uses chrome.runtime.sendMessage) won’t toggle anything. Consider falling back to querying the active tab when sender.tab is missing (or include an explicit tabId in the message payload).
| // Message routing between content script <-> side panel | |
| chrome.runtime.onMessage.addListener((message: IncomingMessage, sender, sendResponse) => { | |
| const { type, payload } = message | |
| switch (type) { | |
| case MessageType.TOGGLE_INSPECT: { | |
| const tabId = sender.tab?.id | |
| if (tabId) { | |
| toggleInspect(tabId) | |
| } | |
| sendResponse({ success: true }) | |
| break | |
| async function resolveTabId(sender: chrome.runtime.MessageSender): Promise<number | undefined> { | |
| if (sender.tab?.id) { | |
| return sender.tab.id | |
| } | |
| const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) | |
| return tab?.id | |
| } | |
| // Message routing between content script <-> side panel | |
| chrome.runtime.onMessage.addListener((message: IncomingMessage, sender, sendResponse) => { | |
| const { type, payload } = message | |
| switch (type) { | |
| case MessageType.TOGGLE_INSPECT: { | |
| resolveTabId(sender) | |
| .then((tabId) => { | |
| if (tabId) { | |
| toggleInspect(tabId) | |
| } | |
| sendResponse({ success: true }) | |
| }) | |
| .catch(() => { | |
| sendResponse({ success: false }) | |
| }) | |
| return true |
| ## 📦 Installation | ||
|
|
||
| ### Prérequis | ||
| - Node.js 20+ |
There was a problem hiding this comment.
package-lock.json includes dependencies (e.g., jsdom@29) that require Node ^20.19.0 (not just any 20.x). The README currently says “Node.js 20+”, which may lead to install failures on older Node 20 minors. Consider updating the prerequisite to the minimum supported Node version (or adding an explicit engines field in package.json).
| - Node.js 20+ | |
| - Node.js 20.19.0+ |
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '20' |
There was a problem hiding this comment.
Several devDependencies in package-lock.json require Node ^20.19.0. Using node-version: '20' will usually pick the latest 20.x, but it’s safer/reproducible to pin to the minimum required minor (e.g., 20.19.0) or bump to Node 22+. Otherwise CI could break if it resolves to an older Node 20 minor in some environments.
| node-version: '20' | |
| node-version: '20.19.0' |
| "typecheck": "tsc --noEmit", | ||
| "test": "vitest", | ||
| "test:coverage": "vitest --coverage" | ||
| }, |
There was a problem hiding this comment.
Given some locked dependencies require Node ^20.19.0 (see package-lock.json), it would help to declare a matching engines.node constraint here to prevent confusing installs on unsupported Node minors.
| }, | |
| }, | |
| "engines": { | |
| "node": "^20.19.0" | |
| }, |
Summary
Changements
Tests
npx tsc --noEmit— 0 errorsnpx vitest --run— 68/68 pass🤖 Generated with Claude Code