Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"permissions": [
"activeTab",
"scripting",
"tabs",
"sidePanel",
"storage",
"clipboardWrite"
Expand Down Expand Up @@ -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"
}
Expand Down
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 94 additions & 18 deletions src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
}
})

Expand Down Expand Up @@ -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<void> {
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<number> => {
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<MarkdownResult | undefined> => {
// The <all_urls> 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<string[]> => {
// 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<void> => chrome.tabs.remove(tabId).then(() => {}),
}

40 changes: 37 additions & 3 deletions src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand All @@ -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<RenderPageResult> => {
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))
Expand Down
4 changes: 1 addition & 3 deletions src/content/scanner/ColorExtractor.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
}))
Expand Down
38 changes: 4 additions & 34 deletions src/content/scanner/DesignSystemBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {
TypographyToken,
SpacingToken,
ShadowToken,
ShadowParsed,
BorderRadiusToken,
} from '@/types/design-system'

Expand Down Expand Up @@ -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: <x> <y> <blur> <spread> <color>
// 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<string, number>()

Expand Down
39 changes: 0 additions & 39 deletions src/content/scanner/TypographyExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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<number>()
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
}
}
Loading
Loading