diff --git a/manifest.json b/manifest.json index c9c1d35..2b6e215 100644 --- a/manifest.json +++ b/manifest.json @@ -12,6 +12,7 @@ "permissions": [ "activeTab", "scripting", + "tabs", "sidePanel", "storage", "clipboardWrite" @@ -39,10 +40,16 @@ } }, "commands": { - "_execute_action": {}, + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+P", + "mac": "Command+Shift+P" + } + }, "toggle-inspect": { "suggested_key": { - "default": "Ctrl+Shift+L" + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" }, "description": "Toggle inspect mode" } diff --git a/package-lock.json b/package-lock.json index 3dc230b..a54ea0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@joplin/turndown-plugin-gfm": "^1.0.67", "@phosphor-icons/react": "^2.1.10", "chroma-js": "^3.2.0", + "fflate": "^0.8.3", "gsap": "^3.15.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -3819,6 +3820,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/package.json b/package.json index 99a69d0..4d24c1e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@joplin/turndown-plugin-gfm": "^1.0.67", "@phosphor-icons/react": "^2.1.10", "chroma-js": "^3.2.0", + "fflate": "^0.8.3", "gsap": "^3.15.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 16ac668..bc92fb2 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -2,7 +2,9 @@ import { MessageType } from '@/types/messages' import type { MessagePayloadMap } from '@/types/messages' +import type { MarkdownResult } from '@/types/markdown' import { saveDesignSystem } from '@/lib/storage' +import { renderPageInTab, type TabRenderDeps } from '@/lib/tab-render' // Prevent side panel from opening on action click (we control it manually) chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }) @@ -145,6 +147,16 @@ chrome.runtime.onMessage.addListener((message: IncomingMessage, sender, sendResp return true // async response } + case MessageType.RENDER_PAGE: { + // Full-render mode: the content script (no chrome.tabs) delegates ONE page here. + // We open it in a background tab, let the JS run, capture the RENDERED DOM via + // EXTRACT_MARKDOWN, collect its links, then close the tab. renderPageInTab never + // rejects (returns a discriminated reason) and always closes the tab (no orphan). + const { url } = payload as MessagePayloadMap[MessageType.RENDER_PAGE] + renderPageInTab(url, realTabRenderDeps).then((outcome) => sendResponse(outcome)) + return true // async response + } + // CRAWL_PROGRESS / CRAWL_COMPLETE are intentionally NOT handled here: the content // script broadcasts them and the side panel listens directly. Relaying them // through the worker would double-deliver the (potentially multi-MB) document. @@ -159,24 +171,6 @@ chrome.runtime.onMessage.addListener((message: IncomingMessage, sender, sendResp }) return true // async response } - - case MessageType.GET_PREFERENCES: { - chrome.storage.sync.get('pixellens_preferences', (result) => { - sendResponse(result['pixellens_preferences'] || { - colorFormat: 'hex', - gridSize: 8, - theme: 'dark', - }) - }) - return true // async response - } - - case MessageType.SET_PREFERENCES: { - const prefs = payload as MessagePayloadMap[MessageType.SET_PREFERENCES] - chrome.storage.sync.set({ pixellens_preferences: prefs.preferences }) - sendResponse({ success: true }) - break - } } }) @@ -262,3 +256,85 @@ export async function ensureContentScriptAndForward( if (!tab?.id) return false return ensureContentScriptOnTab(tab.id, type, payload) } + +// --- Full-render tab orchestration (RENDER_PAGE) ------------------------------------ +// +// Real chrome.* wiring for lib/tab-render. Kept here (not in the message handler) so the +// orchestration in lib/tab-render stays pure/testable and this file just injects the APIs. + +// Resolves when `tabId` reaches status 'complete'. Also resolves if the tab is REMOVED +// (e.g. the per-page timeout in renderPageInTab already closed it): this lets the +// abandoned render step settle and, crucially, removes both listeners so nothing leaks. +// Guards against the load event having already fired before we subscribed (checks the +// current status once via tabs.get). +export function waitForTabComplete(tabId: number): Promise { + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) return + settled = true + chrome.tabs.onUpdated.removeListener(onUpdated) + chrome.tabs.onRemoved.removeListener(onRemoved) + resolve() + } + const onUpdated = (id: number, info: chrome.tabs.OnUpdatedInfo): void => { + if (id === tabId && info.status === 'complete') finish() + } + const onRemoved = (id: number): void => { + if (id === tabId) finish() + } + chrome.tabs.onUpdated.addListener(onUpdated) + chrome.tabs.onRemoved.addListener(onRemoved) + // If the tab was already 'complete' before we subscribed, the event won't fire again. + chrome.tabs + .get(tabId) + .then((tab) => { + if (tab.status === 'complete') finish() + }) + .catch(() => finish()) + }) +} + +// Real dependencies handed to renderPageInTab. active:false = never steals focus. +export const realTabRenderDeps: TabRenderDeps = { + createTab: async (url: string): Promise => { + const tab = await chrome.tabs.create({ url, active: false }) + if (tab.id === undefined) throw new Error('tab created without an id') + return tab.id + }, + waitForComplete: waitForTabComplete, + extractMarkdown: async (tabId: number): Promise => { + // The content script auto-injects on load; re-inject defensively if MV3 + // never ran it on this fresh tab, then ask it to convert the RENDERED DOM. + if (!(await pingContentScript(tabId))) { + if (!(await injectContentScript(tabId))) return undefined + } + try { + return (await chrome.tabs.sendMessage(tabId, { + type: MessageType.EXTRACT_MARKDOWN, + payload: undefined, + })) as MarkdownResult | undefined + } catch (err) { + console.debug('[PixelLens]', (err as Error).message) + return undefined + } + }, + collectLinks: async (tabId: number): Promise => { + // Absolute hrefs from the RENDERED DOM — feeds the BFS when the site has no sitemap. + try { + const [res] = await chrome.scripting.executeScript({ + target: { tabId }, + func: () => + Array.from(document.querySelectorAll('a[href]')) + .map((a) => (a as HTMLAnchorElement).href) + .filter((h) => h.startsWith('http')), + }) + return (res?.result as string[] | undefined) ?? [] + } catch (err) { + console.debug('[PixelLens]', (err as Error).message) + return [] + } + }, + removeTab: (tabId: number): Promise => chrome.tabs.remove(tabId).then(() => {}), +} + diff --git a/src/content/index.ts b/src/content/index.ts index 40adf8f..7b08418 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -2,6 +2,8 @@ import { onMessage } from '@/lib/messaging' import { MessageType } from '@/types/messages' +import type { CrawlDeps } from '@/lib/crawler' +import type { RenderPageResult } from '@/types/crawl' import { ElementHighlighter } from './inspector/ElementHighlighter' import { ElementSelector } from './inspector/ElementSelector' import { DistanceMeasurer } from './inspector/DistanceMeasurer' @@ -173,11 +175,13 @@ onMessage(MessageType.CRAWL_SITE, (options, _sender, sendResponse) => { // Le content script fait autorité sur l'URL de départ : si le panel n'a pas pu // fournir tab.url, on prend location.href de la page courante. const startUrl = options.startUrl || location.href - const result = await crawlSite({ ...options, startUrl }, { + + const deps: CrawlDeps = { // Chaque requête a un timeout dur ET écoute le signal d'annulation global : // Stop (STOP_CRAWL) abort() le fetch en vol, un timeout coupe un serveur muet. // Une page en échec (timeout/abort/erreur) renvoie null → comptée « skipped », - // le crawl continue. + // le crawl continue. En mode Full, fetchText ne sert QUE à la découverte + // (robots.txt / sitemap.xml, statiques et rendu-indépendants). fetchText: (url) => fetchTextWithTimeout(url, { signal: crawlStopController?.signal }), convert: (html, url) => { @@ -200,7 +204,37 @@ onMessage(MessageType.CRAWL_SITE, (options, _sender, sendResponse) => { sendMessage(MessageType.CRAWL_PROGRESS, progress).catch(() => {}) }, shouldStop: () => crawlAborted, - }) + } + + // Mode Full : le CONTENU de chaque page vient d'une navigation réelle. Le content + // script ne peut pas créer d'onglets → on délègue au service worker (RENDER_PAGE), + // qui rend la page dans un onglet arrière-plan et renvoie le Markdown du DOM rendu. + if (options.renderMode === 'full') { + deps.renderPage = async (url): Promise => { + try { + const res = await sendMessage(MessageType.RENDER_PAGE, { url }) + if (!res || !res.ok) { + return { ok: false, reason: res ? res.reason : 'network' } + } + const body = res.markdown.markdown.trim() + if (!body) return { ok: false, reason: 'empty' } + return { + ok: true, + page: { + url, + title: res.markdown.frontmatter.title, + markdown: body, + wordCount: res.markdown.frontmatter.wordCount, + }, + links: res.links, + } + } catch { + return { ok: false, reason: 'network' } + } + } + } + + const result = await crawlSite({ ...options, startUrl }, deps) sendMessage(MessageType.CRAWL_COMPLETE, { result }).catch(() => {}) }) .catch((err) => console.debug('[PixelLens]', (err as Error).message)) diff --git a/src/content/scanner/ColorExtractor.ts b/src/content/scanner/ColorExtractor.ts index 90fac81..132cfc1 100644 --- a/src/content/scanner/ColorExtractor.ts +++ b/src/content/scanner/ColorExtractor.ts @@ -1,6 +1,6 @@ // PixelLens — Color Extractor (extract and cluster colors from the page) -import { toRgb, toHsl, isTransparent, clusterColors, classifyColors, tryToHex } from '@/lib/colors' +import { isTransparent, clusterColors, classifyColors, tryToHex } from '@/lib/colors' import type { ColorToken } from '@/types/design-system' const COLOR_PROPS = ['color', 'background-color', 'border-color', 'outline-color'] as const @@ -44,8 +44,6 @@ export class ColorExtractor { const tokens: ColorToken[] = clustered.map((c) => ({ name: '', hex: c.hex, - rgb: toRgb(c.hex), - hsl: toHsl(c.hex), frequency: c.frequency, category: 'accent' as const, })) diff --git a/src/content/scanner/DesignSystemBuilder.ts b/src/content/scanner/DesignSystemBuilder.ts index 2daba9e..f2a6bcc 100644 --- a/src/content/scanner/DesignSystemBuilder.ts +++ b/src/content/scanner/DesignSystemBuilder.ts @@ -6,7 +6,6 @@ import type { TypographyToken, SpacingToken, ShadowToken, - ShadowParsed, BorderRadiusToken, } from '@/types/design-system' @@ -46,44 +45,15 @@ export class DesignSystemBuilder { // Deduplicate by raw value if (shadowSet.has(shadow)) continue - const parsed = this.parseShadow(shadow) - if (parsed) { - shadowSet.set(shadow, { value: shadow, parsed }) - } + // Store the raw computed value only. The previous parseShadow() output + // (ShadowToken.parsed) was never read by any consumer — ShadowPreview + // renders shadow.value directly — so the parse was dead + buggy work. + shadowSet.set(shadow, { value: shadow }) } return Array.from(shadowSet.values()) } - private parseShadow(shadow: string): ShadowParsed | null { - // Basic shadow parsing: - // Computed values always resolve to rgb() format - const rgbMatch = shadow.match(/(rgba?\([^)]+\))\s+(-?[\d.]+px)\s+(-?[\d.]+px)\s+([\d.]+px)\s*([\d.]+px)?/) - if (rgbMatch) { - return { - color: rgbMatch[1], - x: rgbMatch[2], - y: rgbMatch[3], - blur: rgbMatch[4], - spread: rgbMatch[5] || '0px', - } - } - - // Alternate order: offsets first then color - const altMatch = shadow.match(/(-?[\d.]+px)\s+(-?[\d.]+px)\s+([\d.]+px)\s*([\d.]+px)?\s+(rgba?\([^)]+\))/) - if (altMatch) { - return { - x: altMatch[1], - y: altMatch[2], - blur: altMatch[3], - spread: altMatch[4] || '0px', - color: altMatch[5], - } - } - - return null - } - private extractBorderRadius(elements: Element[]): BorderRadiusToken[] { const freqMap = new Map() diff --git a/src/content/scanner/TypographyExtractor.ts b/src/content/scanner/TypographyExtractor.ts index 4ae0ca2..2bc6ad0 100644 --- a/src/content/scanner/TypographyExtractor.ts +++ b/src/content/scanner/TypographyExtractor.ts @@ -8,8 +8,6 @@ const TEXT_TAGS = new Set([ 'td', 'th', 'caption', 'blockquote', ]) -const KNOWN_RATIOS = [1.067, 1.125, 1.2, 1.25, 1.333, 1.414, 1.5, 1.618] as const - export class TypographyExtractor { extract(elements: Element[]): TypographyToken[] { // Family → Map<"size|weight" → variant with count> @@ -70,41 +68,4 @@ export class TypographyExtractor { return tokens } - - detectTypeScaleRatio(tokens: TypographyToken[]): number | null { - // Collect all unique font sizes across all families - const sizes = new Set() - for (const token of tokens) { - for (const v of token.variants) { - const px = parseFloat(v.fontSize) - if (px > 0) sizes.add(px) - } - } - - const sorted = Array.from(sizes).sort((a, b) => a - b) - if (sorted.length < 3) return null - - // Compute ratios between consecutive sizes - const ratios: number[] = [] - for (let i = 1; i < sorted.length; i++) { - ratios.push(sorted[i] / sorted[i - 1]) - } - - // Find the median ratio - ratios.sort((a, b) => a - b) - const median = ratios[Math.floor(ratios.length / 2)] - - // Match to nearest known ratio - let closest: number = KNOWN_RATIOS[0] - let minDiff = Math.abs(median - closest) - for (const r of KNOWN_RATIOS) { - const diff = Math.abs(median - r) - if (diff < minDiff) { - minDiff = diff - closest = r - } - } - - return minDiff < 0.1 ? closest : null - } } diff --git a/src/content/ui/ContentApp.tsx b/src/content/ui/ContentApp.tsx index 1b187c8..77f1dbc 100644 --- a/src/content/ui/ContentApp.tsx +++ b/src/content/ui/ContentApp.tsx @@ -6,6 +6,7 @@ import { FloatingToolbar } from './FloatingToolbar' import { InspectorTooltip } from './InspectorTooltip' import { sendMessage } from '@/lib/messaging' import { MessageType } from '@/types/messages' +import { isPixelLensElement } from '@/lib/dom-utils' type ContentMode = 'off' | 'inspect' | 'measure' | 'grid' @@ -98,15 +99,6 @@ function ContentApp() { ) } -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 -} - export function mountContentApp(container: HTMLElement): void { const root = createRoot(container) root.render() diff --git a/src/content/ui/FloatingToolbar.tsx b/src/content/ui/FloatingToolbar.tsx index c5cee9a..4711a5c 100644 --- a/src/content/ui/FloatingToolbar.tsx +++ b/src/content/ui/FloatingToolbar.tsx @@ -8,6 +8,7 @@ import { Scan, } from '@phosphor-icons/react' import gsap from 'gsap' +import { prefersReducedMotion } from '@/sidepanel/reducedMotion' type ContentMode = 'off' | 'inspect' | 'measure' | 'grid' @@ -44,6 +45,12 @@ export function FloatingToolbar({ const el = toolbarRef.current if (!el) return + if (prefersReducedMotion()) { + // Appear in place, no slide-up. + gsap.set(el, { y: 0, opacity: 1 }) + return + } + gsap.fromTo( el, { y: 30, opacity: 0 }, @@ -159,6 +166,7 @@ export function FloatingToolbar({ {buttons.map((btn) => ( + + v1.0.0 diff --git a/src/sidepanel/App.tsx b/src/sidepanel/App.tsx index ba7ad0d..08f759e 100644 --- a/src/sidepanel/App.tsx +++ b/src/sidepanel/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { MagnifyingGlass, Scan, @@ -8,11 +8,13 @@ import { MarkdownLogo, GlobeHemisphereWest, GearSix, + CircleHalf, } from '@phosphor-icons/react' import gsap from 'gsap' import { usePanelStore, type PanelMode } from './store' import { MessageType } from '@/types/messages' import { getDesignSystems } from '@/lib/storage' +import { getHost } from '@/lib/url' import InspectorView from './views/InspectorView' import ScanView from './views/ScanView' import DesignSystemView from './views/DesignSystemView' @@ -20,7 +22,16 @@ import ExportView from './views/ExportView' import HistoryView from './views/HistoryView' import MarkdownView from './views/MarkdownView' import CrawlView from './views/CrawlView' +import ContrastCheckerView from './views/ContrastCheckerView' import SettingsView from './views/SettingsView' +import PixelLensLogo from './components/PixelLensLogo' +import CommandPalette, { + createNavigationCommands, + createContrastCommands, + createCrawlZipCommands, + isMacPlatform, +} from './components/CommandPalette' +import { prefersReducedMotion } from './reducedMotion' const MAIN_TABS: { mode: PanelMode; label: string; icon: typeof MagnifyingGlass }[] = [ { mode: 'inspect', label: 'Inspect', icon: MagnifyingGlass }, @@ -29,6 +40,7 @@ const MAIN_TABS: { mode: PanelMode; label: string; icon: typeof MagnifyingGlass ] const FOOTER_TABS: { mode: PanelMode; icon: typeof Export; label: string }[] = [ + { mode: 'contrast', icon: CircleHalf, label: 'Contrast checker' }, { mode: 'export', icon: Export, label: 'Export' }, { mode: 'markdown', icon: MarkdownLogo, label: 'Markdown' }, { mode: 'crawl', icon: GlobeHemisphereWest, label: 'Crawl site' }, @@ -48,11 +60,40 @@ function App() { const setCrawlProgress = usePanelStore((s) => s.setCrawlProgress) const setCrawlResult = usePanelStore((s) => s.setCrawlResult) const setCrawlRunning = usePanelStore((s) => s.setCrawlRunning) + const setCrawlExportMode = usePanelStore((s) => s.setCrawlExportMode) + const setActiveTabUrl = usePanelStore((s) => s.setActiveTabUrl) + const activeTabUrl = usePanelStore((s) => s.activeTabUrl) + const history = usePanelStore((s) => s.history) const tabsRef = useRef<(HTMLButtonElement | null)[]>([]) const indicatorRef = useRef(null) const isFirstRender = useRef(true) + // Command palette: transient, LOCAL state only — it is an accelerator, never + // part of the persisted panel envelope. + const [paletteOpen, setPaletteOpen] = useState(false) + const commands = useMemo( + () => [ + ...createNavigationCommands(setMode), + ...createContrastCommands(setMode), + ...createCrawlZipCommands(setMode, setCrawlExportMode), + ], + [setMode, setCrawlExportMode], + ) + const shortcutLabel = isMacPlatform() ? '⌘K' : 'Ctrl K' + + // Global ⌘K / Ctrl+K toggles the palette from anywhere in the panel. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault() + setPaletteOpen((v) => !v) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + // On mount (every time the side panel is opened — MV3 tears it down on close), // restore the durable state that lives outside the persisted panel envelope // and clear any transient flag so the UI never rehydrates stuck "in progress". @@ -84,6 +125,60 @@ function App() { } }, [hasHydrated]) + // Track the URL of the tab currently in front of the user so the views can + // tell whether a restored scan/markdown belongs to the page on screen (see + // StaleScanBanner). Reading tab.url relies on the activeTab grant from the + // action click that opened the panel; a tab the extension has no host access + // to reports url=undefined, in which case we store null (unknown) and the + // views won't flag a false mismatch. Runs on mount, then follows tab switches + // and in-tab navigations (incl. SPA route changes) while the panel stays open. + useEffect(() => { + let cancelled = false + const readActiveTab = async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!cancelled) setActiveTabUrl(tab?.url ?? null) + } catch { + if (!cancelled) setActiveTabUrl(null) + } + } + void readActiveTab() + const onActivated = () => void readActiveTab() + const onUpdated = ( + _tabId: number, + changeInfo: chrome.tabs.OnUpdatedInfo, + tab: chrome.tabs.Tab, + ) => { + // React only to a URL change on the *active* tab — a full navigation or an + // SPA route push both surface here. Ignore background-tab churn. + if (tab.active && changeInfo.url) void readActiveTab() + } + chrome.tabs.onActivated.addListener(onActivated) + chrome.tabs.onUpdated.addListener(onUpdated) + return () => { + cancelled = true + chrome.tabs.onActivated.removeListener(onActivated) + chrome.tabs.onUpdated.removeListener(onUpdated) + } + }, [setActiveTabUrl]) + + // Reconcile the shown design system with the active tab. When we know the host + // on screen, prefer the most recent scan of THAT host — so switching to (or + // reopening on) a previously scanned site restores its scan instead of + // whatever was scanned last globally. If nothing matches we leave the current + // scan in place and the stale-scan banner flags the host mismatch. Skipped + // while the active URL is unknown, so it can never wrongly override the scan. + useEffect(() => { + if (!hasHydrated) return + const activeHost = getHost(activeTabUrl) + if (!activeHost) return + const current = usePanelStore.getState().designSystem + // Already showing a scan of the active host → nothing to do. + if (current && getHost(current.metadata.url) === activeHost) return + const match = history.find((d) => getHost(d.metadata.url) === activeHost) + if (match) usePanelStore.getState().setDesignSystem(match) + }, [hasHydrated, activeTabUrl, history]) + // GSAP sliding indicator. `hasHydrated` is a dependency so the indicator is // (re)positioned on the very paint where the restored activeMode lands, not a // stale default — and never left stuck on the wrong tab after rehydration. @@ -107,6 +202,9 @@ function App() { if (isFirstRender.current) { gsap.set(indicator, { left: el.offsetLeft, width: el.offsetWidth, opacity: 1 }) isFirstRender.current = false + } else if (prefersReducedMotion()) { + // Reduced motion: snap the indicator to the new tab instead of sliding. + gsap.set(indicator, { left: el.offsetLeft, width: el.offsetWidth, opacity: 1 }) } else { gsap.set(indicator, { opacity: 1 }) gsap.to(indicator, { @@ -191,9 +289,7 @@ function App() { if (!hasHydrated) { return (
-
- P -
+
) } @@ -206,6 +302,8 @@ function App() { return case 'design-system': return + case 'contrast': + return case 'export': return case 'history': @@ -224,12 +322,19 @@ function App() { {/* Header */}
-
- P -
+

PixelLens

+
{/* Mode tabs with GSAP sliding indicator */} @@ -247,7 +352,8 @@ function App() { key={tab.mode} ref={(el) => { tabsRef.current[i] = el }} onClick={() => setMode(tab.mode)} - className={`flex items-center gap-1.5 px-3 pb-2 pt-1 text-[12px] font-medium transition-colors duration-200 ${ + aria-current={isActive ? 'page' : undefined} + className={`flex items-center gap-1.5 px-3 pb-2 pt-1 text-[12px] font-medium rounded-md transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-panel-accent focus-visible:ring-offset-2 focus-visible:ring-offset-panel-bg ${ isActive ? 'text-panel-text' : 'text-panel-text-dim hover:text-panel-text' }`} > @@ -274,7 +380,9 @@ function App() { + + ) +} diff --git a/src/sidepanel/reducedMotion.ts b/src/sidepanel/reducedMotion.ts new file mode 100644 index 0000000..d076bbf --- /dev/null +++ b/src/sidepanel/reducedMotion.ts @@ -0,0 +1,16 @@ +/** + * True when the OS asks to minimise non-essential motion. + * + * Read at call time (never cached) so a mid-session change to the system + * setting is honoured. Used to short-circuit GSAP tweens to their end state — + * GSAP animates inline values with rAF, so the global + * `@media (prefers-reduced-motion: reduce)` CSS block can't reach it; the JS + * guard does. Pure leaf util shared by the side panel and the in-page toolbar. + */ +export function prefersReducedMotion(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) +} diff --git a/src/sidepanel/store.ts b/src/sidepanel/store.ts index 13ea079..f2787d8 100644 --- a/src/sidepanel/store.ts +++ b/src/sidepanel/store.ts @@ -10,12 +10,16 @@ export type PanelMode = | 'inspect' | 'scan' | 'design-system' + | 'contrast' | 'export' | 'history' | 'markdown' | 'crawl' | 'settings' +/** How a finished crawl is exported: one concatenated .md, or a multi-file .zip. */ +export type CrawlExportMode = 'single' | 'zip' + export interface ScanProgress { percent: number phase: string @@ -38,11 +42,24 @@ interface PanelState { crawlProgress: CrawlProgress | null crawlResult: CrawlResult | null crawlError: string | null + // Chosen crawl export shape. Transient (never persisted): a fresh panel always + // defaults to the single-file .md; the ⌘K "Crawl → ZIP" command presets 'zip'. + crawlExportMode: CrawlExportMode + // Whether the next crawl respects robots.txt. Transient (never persisted): a + // fresh panel always defaults to ON (safe default). The user can turn it OFF + // from the crawl view when robots.txt is what's blocking every page. + crawlRespectRobots: boolean // True once `persist` has finished its async rehydration from // chrome.storage.local. The panel UI gates on this so it never paints the // default state first and then snaps to the restored one (see App.tsx). // Never persisted (excluded from `partialize`) — purely runtime state. hasHydrated: boolean + // URL of the tab currently in front of the user, tracked live from + // chrome.tabs (see App.tsx). Views compare it against the host their scan / + // markdown came from, so a restored result from another site is flagged as + // stale instead of shown as current. `null` = unknown (couldn't read the tab + // URL, e.g. no host permission for it). Never persisted — purely runtime. + activeTabUrl: string | null setMode: (mode: PanelMode) => void setInspectedElement: (el: InspectedElement | null) => void @@ -60,7 +77,10 @@ interface PanelState { setCrawlProgress: (progress: CrawlProgress | null) => void setCrawlResult: (result: CrawlResult | null) => void setCrawlError: (error: string | null) => void + setCrawlExportMode: (mode: CrawlExportMode) => void + setCrawlRespectRobots: (respect: boolean) => void setHasHydrated: (hydrated: boolean) => void + setActiveTabUrl: (url: string | null) => void } export const usePanelStore = create()( @@ -80,7 +100,10 @@ export const usePanelStore = create()( crawlProgress: null, crawlResult: null, crawlError: null, + crawlExportMode: 'single', + crawlRespectRobots: true, hasHydrated: false, + activeTabUrl: null, setMode: (mode) => set({ activeMode: mode }), setInspectedElement: (el) => set({ inspectedElement: el }), @@ -99,7 +122,10 @@ export const usePanelStore = create()( setCrawlProgress: (progress) => set({ crawlProgress: progress }), setCrawlResult: (result) => set({ crawlResult: result }), setCrawlError: (error) => set({ crawlError: error }), + setCrawlExportMode: (mode) => set({ crawlExportMode: mode }), + setCrawlRespectRobots: (respect) => set({ crawlRespectRobots: respect }), setHasHydrated: (hydrated) => set({ hasHydrated: hydrated }), + setActiveTabUrl: (url) => set({ activeTabUrl: url }), }), { name: PANEL_STATE_STORAGE_KEY, diff --git a/src/sidepanel/styles/panel.css b/src/sidepanel/styles/panel.css index c3ea39e..6a5fbb5 100644 --- a/src/sidepanel/styles/panel.css +++ b/src/sidepanel/styles/panel.css @@ -35,7 +35,7 @@ --color-panel-surface: #161618; --color-panel-border: #222225; --color-panel-text: #EDEDEF; - --color-panel-text-dim: #7E7E85; + --color-panel-text-dim: #83838C; --color-panel-accent: #6366F1; --color-panel-accent-hover: #818CF8; --color-success: #22C55E; diff --git a/src/sidepanel/views/ContrastCheckerView.tsx b/src/sidepanel/views/ContrastCheckerView.tsx new file mode 100644 index 0000000..82b972c --- /dev/null +++ b/src/sidepanel/views/ContrastCheckerView.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useRef } from 'react' +import { CircleHalf, Scan } from '@phosphor-icons/react' +import gsap from 'gsap' +import { usePanelStore } from '../store' +import { prefersReducedMotion } from '../reducedMotion' +import { buildContrastReport, formatRatio, type ContrastLevel } from '@/lib/contrast' + +// Pill palette. Each level carries its own label text, so the verdict never +// relies on color alone (WCAG SC 1.4.1). Green = AAA, amber = AA-only, red = fail. +const LEVEL_STYLE: Record = { + AAA: { label: 'AAA', className: 'border-[#22C55E]/30 bg-[#22C55E]/12 text-[#4ADE80]' }, + AA: { label: 'AA', className: 'border-[#F59E0B]/30 bg-[#F59E0B]/12 text-[#FBBF24]' }, + fail: { label: 'Fail', className: 'border-[#EF4444]/30 bg-[#EF4444]/12 text-[#F87171]' }, +} + +function VerdictPill({ scope, level, ratio }: { scope: 'Normal' | 'Large'; level: ContrastLevel; ratio: number }) { + const s = LEVEL_STYLE[level] + const verdict = level === 'fail' ? 'fails WCAG' : `passes ${level}` + return ( + + {scope} + + {s.label} + + ) +} + +function ContrastCheckerView() { + const designSystem = usePanelStore((s) => s.designSystem) + const setMode = usePanelStore((s) => s.setMode) + const listRef = useRef(null) + + const report = useMemo( + () => (designSystem ? buildContrastReport(designSystem.colors) : []), + [designSystem], + ) + + // Flatten every evaluated pair once for the summary strip. + const summary = useMemo(() => { + let total = 0 + let passAA = 0 + for (const pairing of report) { + for (const fg of pairing.foregrounds) { + total++ + if (fg.verdict.aaNormal) passAA++ + } + } + return { total, passAA } + }, [report]) + + // Staggered reveal of the background sections. Honors reduced motion by + // snapping to the end state (no tween) via the shared helper. + useEffect(() => { + const root = listRef.current + if (!root || report.length === 0) return + const sections = root.querySelectorAll('[data-contrast-section]') + if (sections.length === 0) return + if (prefersReducedMotion()) { + gsap.set(sections, { opacity: 1, y: 0 }) + return + } + const tween = gsap.fromTo( + sections, + { opacity: 0, y: 8 }, + { opacity: 1, y: 0, duration: 0.35, ease: 'power2.out', stagger: 0.06 }, + ) + return () => { + tween.kill() + } + }, [report]) + + // --- Empty — no scan yet ---------------------------------------------------------- + if (!designSystem) { + return ( +
+
+ +
+
+

Check color contrast

+

+ Scan a page first, then see which of its extracted colors meet WCAG AA / AAA + contrast on each background. +

+ +
+
+ ) + } + + // --- No usable colors ------------------------------------------------------------- + if (report.length === 0) { + return ( +
+
+ +
+
+

Not enough colors

+

+ This scan didn’t yield enough distinct colors to evaluate contrast. Try + scanning a richer page. +

+
+
+ ) + } + + // --- Results ---------------------------------------------------------------------- + return ( +
+ {/* Meta header + summary */} +
+
+ +

+ Contrast · {designSystem.metadata.title || designSystem.metadata.url} +

+
+

+ {summary.passAA}/ + {summary.total} pairs pass AA for normal text +

+
+ + {/* Pairings */} +
+ {report.map((pairing) => ( +
+ {/* Background surface header */} +
+
+ + {/* Foreground rows */} +
    + {pairing.foregrounds.map((fg) => ( +
  • +
    + +
    +

    {fg.token.hex}

    +

    + {fg.token.category} +

    +
    +
    + + {formatRatio(fg.verdict.ratio)} + + :1 +
    +
    +
    + + +
    +
  • + ))} +
+
+ ))} +
+
+ ) +} + +export default ContrastCheckerView diff --git a/src/sidepanel/views/CrawlView.tsx b/src/sidepanel/views/CrawlView.tsx index 9589818..596acb4 100644 --- a/src/sidepanel/views/CrawlView.tsx +++ b/src/sidepanel/views/CrawlView.tsx @@ -8,14 +8,18 @@ import { Stop, ArrowClockwise, Files, + FileZip, Prohibit, Database, TreeStructure, + Lightning, + Browser, } from '@phosphor-icons/react' import { usePanelStore } from '../store' import { sendMessage } from '@/lib/messaging' import { MessageType } from '@/types/messages' import { copyToClipboard, downloadMarkdown } from '@/lib/export' +import { downloadCrawlZip } from '@/lib/crawl-zip' import MarkdownPreview from '../components/MarkdownPreview' // Le download contient TOUT le document ; la preview est tronquée au-delà de ce seuil @@ -28,6 +32,71 @@ function formatBytes(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } +// Libellé lisible d'une raison de skip pour le breakdown (`http-403` → `HTTP 403`). +// C'est ce qui dit à l'utilisateur POURQUOI une page a été ignorée. +function formatSkipReason(reason: string): string { + if (reason.startsWith('http-')) return `HTTP ${reason.slice(5)}` + switch (reason) { + case 'robots': + return 'robots.txt' + case 'timeout': + return 'timeout' + case 'non-text': + return 'non-text' + case 'cross-origin': + return 'off-site' + case 'network': + return 'network/CORS' + case 'empty': + return 'empty' + default: + return reason + } +} + +// Fast (fetch server HTML) vs Full (navigate each page in a real background tab so the +// JS runs, then capture the RENDERED DOM). Full is the fix for JS-rendered SPAs whose +// initial HTML is an empty shell. Local UI state — passed in the CRAWL_SITE payload. +function RenderModeToggle({ + value, + onChange, +}: { + value: 'fast' | 'full' + onChange: (v: 'fast' | 'full') => void +}) { + return ( +
+ {( + [ + { value: 'fast', label: 'Fast', icon: Lightning }, + { value: 'full', label: 'Full', icon: Browser }, + ] as const + ).map(({ value: v, label, icon: Icon }) => { + const isActive = value === v + return ( + + ) + })} +
+ ) +} + function CrawlView() { const running = usePanelStore((s) => s.crawlRunning) const progress = usePanelStore((s) => s.crawlProgress) @@ -37,17 +106,32 @@ function CrawlView() { const setProgress = usePanelStore((s) => s.setCrawlProgress) const setResult = usePanelStore((s) => s.setCrawlResult) const setError = usePanelStore((s) => s.setCrawlError) + const exportMode = usePanelStore((s) => s.crawlExportMode) + const setExportMode = usePanelStore((s) => s.setCrawlExportMode) + const respectRobots = usePanelStore((s) => s.crawlRespectRobots) + const setRespectRobots = usePanelStore((s) => s.setCrawlRespectRobots) const [copied, setCopied] = useState(false) + // Render mode is LOCAL (not persisted in the store): Fast (fetch) vs Full (real tab). + const [renderMode, setRenderMode] = useState<'fast' | 'full'>('fast') + // Mode the currently-shown result was crawled with — drives the "empty → try Full" hint. + const [resultMode, setResultMode] = useState<'fast' | 'full'>('fast') const handleStart = useCallback(async () => { setRunning(true) setError(null) setResult(null) setProgress(null) + // Fige le mode utilisé pour CE crawl : le résultat qui reviendra sera jugé (hint + // empty→Full) selon ce mode, même si l'utilisateur bascule le toggle entre-temps. + setResultMode(renderMode) try { // startUrl vide : le content script fait autorité et utilise location.href de // l'onglet courant (le panel n'a pas forcément accès à tab.url). - const res = await sendMessage(MessageType.CRAWL_SITE, { startUrl: '' }) + const res = await sendMessage(MessageType.CRAWL_SITE, { + startUrl: '', + respectRobots, + renderMode, + }) if (!res?.success) { setRunning(false) setError('unsupported') @@ -57,7 +141,7 @@ function CrawlView() { setRunning(false) setError('failed') } - }, [setRunning, setError, setResult, setProgress]) + }, [setRunning, setError, setResult, setProgress, respectRobots, renderMode]) const handleStop = useCallback(() => { // Annulation coopérative : le crawl finit la page en cours puis renvoie le document @@ -161,8 +245,14 @@ function CrawlView() { > Crawl entire site +
+ Render + +

- {"Reads each page's initial HTML — client-rendered SPAs may come out partial."} + {renderMode === 'fast' + ? "Fast reads each page's initial HTML — client-rendered SPAs may come out partial." + : 'Full opens each page in a real background tab so the JS runs, then captures the rendered DOM. Slower, but handles SPAs.'}

@@ -171,6 +261,15 @@ function CrawlView() { // --- Done — document concaténé ---------------------------------------------------- const { stats } = result + // Breakdown lisible des raisons de skip (trié par nombre décroissant). RÉVÈLE + // pourquoi le crawl skippe — la donnée manquante de la version précédente. + const reasonEntries = (Object.entries(result.skippedReasons) as [string, number | undefined][]) + .map(([reason, n]) => [reason, n ?? 0] as const) + .filter(([, n]) => n > 0) + .sort((a, b) => b[1] - a[1]) + const robotsSkipped = result.skippedReasons.robots ?? 0 + // Empty conversions in FAST mode are the signal that the site is JS-rendered → suggest Full. + const emptySkipped = result.skippedReasons.empty ?? 0 const preview = result.document.length > PREVIEW_LIMIT ? result.document.slice(0, PREVIEW_LIMIT) + @@ -193,7 +292,12 @@ function CrawlView() { } const handleDownload = () => { - downloadMarkdown(`${result.host}-site.md`, result.document) + if (exportMode === 'zip') { + // Multi-file archive: one .md per page + a linking index.md. + downloadCrawlZip(result) + } else { + downloadMarkdown(`${result.host}-site.md`, result.document) + } } return ( @@ -214,6 +318,7 @@ function CrawlView() { - + + + {/* Export format — single concatenated .md vs a multi-file .zip. Additive: + Copy always yields the single document; only the download shape changes. */} +
- - + {([ + { value: 'single', label: '1 file', icon: Files }, + { value: 'zip', label: 'ZIP', icon: FileZip }, + ] as const).map(({ value, label, icon: Icon }) => { + const isActive = exportMode === value + return ( + + ) + })} +
+ +
+ + +
) diff --git a/src/sidepanel/views/DesignSystemView.tsx b/src/sidepanel/views/DesignSystemView.tsx index ae3a8a5..2f47559 100644 --- a/src/sidepanel/views/DesignSystemView.tsx +++ b/src/sidepanel/views/DesignSystemView.tsx @@ -4,12 +4,15 @@ import { usePanelStore } from '../store' import ExportButton from '../components/ExportButton' import ColorSwatch from '../components/ColorSwatch' import ShadowPreview from '../components/ShadowPreview' +import StaleScanBanner from '../components/StaleScanBanner' +import { isDifferentHost } from '@/lib/url' import type { DesignSystem } from '@/types/design-system' function DesignSystemView() { const designSystem = usePanelStore((s) => s.designSystem) const setDesignSystem = usePanelStore((s) => s.setDesignSystem) const setMode = usePanelStore((s) => s.setMode) + const activeTabUrl = usePanelStore((s) => s.activeTabUrl) if (!designSystem) { return ( @@ -49,8 +52,15 @@ function DesignSystemView() { setDesignSystem(updated) } + // Flag a design system restored from another site — the canonical scan CTA + // (with progress + error handling) lives in ScanView, so re-scan routes there. + const stale = isDifferentHost(designSystem.metadata.url, activeTabUrl) + return (
+ {stale && ( + setMode('scan')} /> + )} {/* Header with export */}
@@ -79,6 +89,7 @@ function DesignSystemView() {
{/* Actions */} -
+
+ {/* Hero action — the headline feature: one paste-ready document for an AI. */} - +
+ + +
) diff --git a/src/sidepanel/views/ScanView.tsx b/src/sidepanel/views/ScanView.tsx index 8497086..7b0f698 100644 --- a/src/sidepanel/views/ScanView.tsx +++ b/src/sidepanel/views/ScanView.tsx @@ -1,13 +1,16 @@ import { useState, useRef, useEffect } from 'react' import { Play, Palette, TextT, ArrowsOutSimple, Drop, WarningCircle } from '@phosphor-icons/react' import gsap from 'gsap' +import { prefersReducedMotion } from '../reducedMotion' import { usePanelStore } from '../store' import { sendMessage } from '@/lib/messaging' import { MessageType } from '@/types/messages' +import { isDifferentHost } from '@/lib/url' import ColorPalette from '../components/ColorPalette' import TypeSpecimen from '../components/TypeSpecimen' import SpacingScale from '../components/SpacingScale' import ShadowPreview from '../components/ShadowPreview' +import StaleScanBanner from '../components/StaleScanBanner' type ScanTab = 'colors' | 'fonts' | 'spacing' | 'shadows' @@ -24,6 +27,7 @@ function ScanView() { const designSystem = usePanelStore((s) => s.designSystem) const setMode = usePanelStore((s) => s.setMode) const setScanError = usePanelStore((s) => s.setScanError) + const activeTabUrl = usePanelStore((s) => s.activeTabUrl) const [activeTab, setActiveTab] = useState('colors') const tabsRef = useRef<(HTMLButtonElement | null)[]>([]) const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }) @@ -42,6 +46,12 @@ function ScanView() { const bar = progressBarRef.current if (!bar || !scanProgress) return + if (prefersReducedMotion()) { + // No width tween, no infinite shimmer: set the fill width directly. + gsap.set(bar, { width: `${scanProgress.percent}%` }) + return + } + gsap.to(bar, { width: `${scanProgress.percent}%`, duration: 0.3, @@ -193,8 +203,16 @@ function ScanView() { } } + // The shown design system is the last scan globally; if that came from a + // different host than the tab on screen, flag it instead of passing it off + // as the current page. + const stale = isDifferentHost(designSystem.metadata.url, activeTabUrl) + return (
+ {stale && ( + + )} {/* Scan result tabs */}