From bcb48fd3a6e2411b3c0e08aace758f62f05b1e5b Mon Sep 17 00:00:00 2001 From: Aditya Gupta Date: Tue, 3 Mar 2026 23:11:33 +0530 Subject: [PATCH] refactor: options handling and improve code readability - Cleaned up import statements in options.ts for better organization. - Enhanced the init function to load settings with improved formatting. - Streamlined event listener attachments for buttons and inputs. - Improved the rendering functions for color and semantic grids. - Updated the parsePalette function in themeEngine.ts for better error handling and readability. - Refactored deriveSemanticTheme to enhance clarity and maintainability. - Adjusted tsconfig.json for better structure and readability. - Updated vite.config.ts to improve build process and maintain consistency. --- gogh-theme-engine/package.json | 44 +- gogh-theme-engine/public/manifest.json | 70 +- gogh-theme-engine/src/background.ts | 244 +++--- gogh-theme-engine/src/color.ts | 868 ++++++++++----------- gogh-theme-engine/src/content.ts | 774 +++++++++--------- gogh-theme-engine/src/js-yaml.d.ts | 6 +- gogh-theme-engine/src/observer.ts | 396 +++++----- gogh-theme-engine/src/options/options.html | 831 +++++++++++--------- gogh-theme-engine/src/options/options.ts | 416 +++++----- gogh-theme-engine/src/themeEngine.ts | 284 +++---- gogh-theme-engine/tsconfig.json | 56 +- gogh-theme-engine/vite.config.ts | 160 ++-- 12 files changed, 2127 insertions(+), 2022 deletions(-) diff --git a/gogh-theme-engine/package.json b/gogh-theme-engine/package.json index c644e5a..374d5e4 100644 --- a/gogh-theme-engine/package.json +++ b/gogh-theme-engine/package.json @@ -1,23 +1,23 @@ { - "name": "gogh-theme-engine", - "version": "1.0.0", - "description": "Production-grade Chrome Extension that applies Gogh-style 16-color terminal palettes to web pages using perceptual color remapping (OKLCH).", - "private": true, - "type": "module", - "scripts": { - "dev": "vite build --watch --mode development", - "build": "tsc --noEmit && vite build", - "preview": "vite preview" - }, - "devDependencies": { - "@crxjs/vite-plugin": "^2.0.0-beta.25", - "@types/chrome": "^0.0.268", - "@types/node": "^25.3.3", - "sass": "^1.77.0", - "typescript": "^5.4.5", - "vite": "^5.2.11" - }, - "dependencies": { - "js-yaml": "^4.1.0" - } -} + "name": "gogh-theme-engine", + "version": "1.0.0", + "description": "Production-grade Chrome Extension that applies Gogh-style 16-color terminal palettes to web pages using perceptual color remapping (OKLCH).", + "private": true, + "type": "module", + "scripts": { + "dev": "vite build --watch --mode development", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.25", + "@types/chrome": "^0.0.268", + "@types/node": "^25.3.3", + "sass": "^1.77.0", + "typescript": "^5.4.5", + "vite": "^5.2.11" + }, + "dependencies": { + "js-yaml": "^4.1.0" + } +} \ No newline at end of file diff --git a/gogh-theme-engine/public/manifest.json b/gogh-theme-engine/public/manifest.json index 33ac9ae..9569545 100644 --- a/gogh-theme-engine/public/manifest.json +++ b/gogh-theme-engine/public/manifest.json @@ -1,33 +1,41 @@ { - "manifest_version": 3, - "name": "Gogh Theme Engine", - "version": "1.0.0", - "description": "Apply Gogh-style 16-color terminal palettes to any web page using perceptual OKLCH color remapping.", - "permissions": ["storage", "activeTab", "scripting"], - "background": { - "service_worker": "background.js", - "type": "module" - }, - "content_scripts": [ - { - "matches": [""], - "js": ["content.js"], - "run_at": "document_start", - "all_frames": true + "manifest_version": 3, + "name": "Gogh Theme Engine", + "version": "1.0.0", + "description": "Apply Gogh-style 16-color terminal palettes to any web page using perceptual OKLCH color remapping.", + "permissions": [ + "storage", + "activeTab", + "scripting" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "content.js" + ], + "run_at": "document_start", + "all_frames": true + } + ], + "options_page": "options/options.html", + "action": { + "default_title": "Gogh Theme Engine", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" } - ], - "options_page": "options/options.html", - "action": { - "default_title": "Gogh Theme Engine", - "default_icon": { - "16": "icons/icon16.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } - }, - "icons": { - "16": "icons/icon16.png", - "48": "icons/icon48.png", - "128": "icons/icon128.png" - } -} +} \ No newline at end of file diff --git a/gogh-theme-engine/src/background.ts b/gogh-theme-engine/src/background.ts index 496dc38..beb3463 100644 --- a/gogh-theme-engine/src/background.ts +++ b/gogh-theme-engine/src/background.ts @@ -14,16 +14,16 @@ import { DEFAULT_PALETTE_YAML } from './themeEngine'; // ─── Installation / Update ──────────────────────────────────────────────────── chrome.runtime.onInstalled.addListener(async (details) => { - if (details.reason === 'install') { - // Set default settings - await chrome.storage.sync.set({ - paletteYaml: DEFAULT_PALETTE_YAML, - enabled: true, - intensity: 100, // 0–100 - disabledSites: [] as string[], - }); - console.log('[Gogh Theme Engine] Extension installed with default Dracula palette.'); - } + if (details.reason === 'install') { + // Set default settings + await chrome.storage.sync.set({ + paletteYaml: DEFAULT_PALETTE_YAML, + enabled: true, + intensity: 100, // 0–100 + disabledSites: [] as string[], + }); + console.log('[Gogh Theme Engine] Extension installed with default Dracula palette.'); + } }); // ─── Action Button Click ────────────────────────────────────────────────────── @@ -33,62 +33,62 @@ chrome.runtime.onInstalled.addListener(async (details) => { * If the site is in the disabled list, it's removed. If not, it's added. */ chrome.action.onClicked.addListener(async (tab) => { - if (!tab.id || !tab.url) return; + if (!tab.id || !tab.url) return; - try { - const url = new URL(tab.url); - const hostname = url.hostname; + try { + const url = new URL(tab.url); + const hostname = url.hostname; - if (!hostname) return; + if (!hostname) return; - const data = await chrome.storage.sync.get(['disabledSites', 'enabled']); - const disabledSites: string[] = data.disabledSites || []; - const isGloballyEnabled = data.enabled !== false; + const data = await chrome.storage.sync.get(['disabledSites', 'enabled']); + const disabledSites: string[] = data.disabledSites || []; + const isGloballyEnabled = data.enabled !== false; - if (!isGloballyEnabled) { - // If globally disabled, re-enable - await chrome.storage.sync.set({ enabled: true }); - // Notify content script - chrome.tabs.sendMessage(tab.id!, { type: 'reprocess' }).catch(() => {}); - updateBadge(tab.id!, true); - return; + if (!isGloballyEnabled) { + // If globally disabled, re-enable + await chrome.storage.sync.set({ enabled: true }); + // Notify content script + chrome.tabs.sendMessage(tab.id!, { type: 'reprocess' }).catch(() => { }); + updateBadge(tab.id!, true); + return; + } + + const siteIndex = disabledSites.indexOf(hostname); + if (siteIndex >= 0) { + // Currently disabled for this site → enable + disabledSites.splice(siteIndex, 1); + await chrome.storage.sync.set({ disabledSites }); + // Reload the tab to apply changes cleanly + chrome.tabs.reload(tab.id!); + updateBadge(tab.id!, true); + } else { + // Currently enabled → disable for this site + disabledSites.push(hostname); + await chrome.storage.sync.set({ disabledSites }); + // Notify content script to clean up + chrome.tabs.sendMessage(tab.id!, { type: 'toggle' }).catch(() => { + // If content script isn't responding, reload + chrome.tabs.reload(tab.id!); + }); + updateBadge(tab.id!, false); + } + } catch (e) { + console.error('[Gogh Theme Engine] Action click error:', e); } - - const siteIndex = disabledSites.indexOf(hostname); - if (siteIndex >= 0) { - // Currently disabled for this site → enable - disabledSites.splice(siteIndex, 1); - await chrome.storage.sync.set({ disabledSites }); - // Reload the tab to apply changes cleanly - chrome.tabs.reload(tab.id!); - updateBadge(tab.id!, true); - } else { - // Currently enabled → disable for this site - disabledSites.push(hostname); - await chrome.storage.sync.set({ disabledSites }); - // Notify content script to clean up - chrome.tabs.sendMessage(tab.id!, { type: 'toggle' }).catch(() => { - // If content script isn't responding, reload - chrome.tabs.reload(tab.id!); - }); - updateBadge(tab.id!, false); - } - } catch (e) { - console.error('[Gogh Theme Engine] Action click error:', e); - } }); // ─── Badge ──────────────────────────────────────────────────────────────────── function updateBadge(tabId: number, isEnabled: boolean): void { - chrome.action.setBadgeText({ - tabId, - text: isEnabled ? '' : 'OFF', - }); - chrome.action.setBadgeBackgroundColor({ - tabId, - color: isEnabled ? '#50fa7b' : '#ff5555', - }); + chrome.action.setBadgeText({ + tabId, + text: isEnabled ? '' : 'OFF', + }); + chrome.action.setBadgeBackgroundColor({ + tabId, + color: isEnabled ? '#50fa7b' : '#ff5555', + }); } // ─── Tab Activation ─────────────────────────────────────────────────────────── @@ -98,22 +98,22 @@ function updateBadge(tabId: number, isEnabled: boolean): void { * site's enable/disable state. */ chrome.tabs.onActivated.addListener(async (info) => { - try { - const tab = await chrome.tabs.get(info.tabId); - if (!tab.url) return; + try { + const tab = await chrome.tabs.get(info.tabId); + if (!tab.url) return; - const url = new URL(tab.url); - const hostname = url.hostname; + const url = new URL(tab.url); + const hostname = url.hostname; - const data = await chrome.storage.sync.get(['disabledSites', 'enabled']); - const disabledSites: string[] = data.disabledSites || []; - const isGloballyEnabled = data.enabled !== false; - const isSiteEnabled = isGloballyEnabled && !disabledSites.includes(hostname); + const data = await chrome.storage.sync.get(['disabledSites', 'enabled']); + const disabledSites: string[] = data.disabledSites || []; + const isGloballyEnabled = data.enabled !== false; + const isSiteEnabled = isGloballyEnabled && !disabledSites.includes(hostname); - updateBadge(info.tabId, isSiteEnabled); - } catch { - // Ignore errors for special pages like chrome:// - } + updateBadge(info.tabId, isSiteEnabled); + } catch { + // Ignore errors for special pages like chrome:// + } }); // ─── Message Handling ───────────────────────────────────────────────────────── @@ -122,62 +122,62 @@ chrome.tabs.onActivated.addListener(async (info) => { * Handle messages from options page or popup. */ chrome.runtime.onMessage.addListener( - ( - msg: { type: string; [key: string]: unknown }, - _sender: chrome.runtime.MessageSender, - sendResponse: (resp?: unknown) => void - ): boolean => { - switch (msg.type) { - case 'savePalette': { - const yaml = msg.yaml as string; - chrome.storage.sync.set({ paletteYaml: yaml }).then(() => { - sendResponse({ ok: true }); - }); - return true; // async response - } + ( + msg: { type: string;[key: string]: unknown }, + _sender: chrome.runtime.MessageSender, + sendResponse: (resp?: unknown) => void + ): boolean => { + switch (msg.type) { + case 'savePalette': { + const yaml = msg.yaml as string; + chrome.storage.sync.set({ paletteYaml: yaml }).then(() => { + sendResponse({ ok: true }); + }); + return true; // async response + } - case 'getSettings': { - chrome.storage.sync - .get(['paletteYaml', 'enabled', 'intensity', 'disabledSites']) - .then((data) => { - sendResponse(data); - }); - return true; - } + case 'getSettings': { + chrome.storage.sync + .get(['paletteYaml', 'enabled', 'intensity', 'disabledSites']) + .then((data) => { + sendResponse(data); + }); + return true; + } - case 'setIntensity': { - const val = msg.value as number; - chrome.storage.sync.set({ intensity: val }).then(() => { - sendResponse({ ok: true }); - }); - return true; - } + case 'setIntensity': { + const val = msg.value as number; + chrome.storage.sync.set({ intensity: val }).then(() => { + sendResponse({ ok: true }); + }); + return true; + } - case 'setEnabled': { - const val = msg.value as boolean; - chrome.storage.sync.set({ enabled: val }).then(() => { - sendResponse({ ok: true }); - }); - return true; - } + case 'setEnabled': { + const val = msg.value as boolean; + chrome.storage.sync.set({ enabled: val }).then(() => { + sendResponse({ ok: true }); + }); + return true; + } - case 'toggleSite': { - const hostname = msg.hostname as string; - chrome.storage.sync.get(['disabledSites']).then((data) => { - const sites: string[] = data.disabledSites || []; - const idx = sites.indexOf(hostname); - if (idx >= 0) { - sites.splice(idx, 1); - } else { - sites.push(hostname); - } - chrome.storage.sync.set({ disabledSites: sites }).then(() => { - sendResponse({ ok: true, disabledSites: sites }); - }); - }); - return true; - } + case 'toggleSite': { + const hostname = msg.hostname as string; + chrome.storage.sync.get(['disabledSites']).then((data) => { + const sites: string[] = data.disabledSites || []; + const idx = sites.indexOf(hostname); + if (idx >= 0) { + sites.splice(idx, 1); + } else { + sites.push(hostname); + } + chrome.storage.sync.set({ disabledSites: sites }).then(() => { + sendResponse({ ok: true, disabledSites: sites }); + }); + }); + return true; + } + } + return false; } - return false; - } ); diff --git a/gogh-theme-engine/src/color.ts b/gogh-theme-engine/src/color.ts index 932603d..a1fe7e5 100644 --- a/gogh-theme-engine/src/color.ts +++ b/gogh-theme-engine/src/color.ts @@ -17,38 +17,38 @@ /** sRGB color with channels in [0,1] */ export interface SRGB { - r: number; - g: number; - b: number; + r: number; + g: number; + b: number; } /** Linear-light sRGB (gamma removed) */ export interface LinearRGB { - r: number; - g: number; - b: number; + r: number; + g: number; + b: number; } /** OKLAB perceptual color */ export interface OKLAB { - L: number; // lightness 0..1 - a: number; // green–red axis - b: number; // blue–yellow axis + L: number; // lightness 0..1 + a: number; // green–red axis + b: number; // blue–yellow axis } /** OKLCH perceptual cylindrical color */ export interface OKLCH { - L: number; // lightness 0..1 - C: number; // chroma ≥0 - h: number; // hue in degrees 0..360 + L: number; // lightness 0..1 + C: number; // chroma ≥0 + h: number; // hue in degrees 0..360 } /** Parsed RGBA color (sRGB 0-255 + alpha 0-1) */ export interface RGBA { - r: number; - g: number; - b: number; - a: number; + r: number; + g: number; + b: number; + a: number; } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -60,184 +60,184 @@ const RAD = 180 / Math.PI; /** Apply sRGB inverse electro-optical transfer function (gamma decode). */ function srgbToLinearChannel(c: number): number { - return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } /** Apply sRGB electro-optical transfer function (gamma encode). */ function linearToSrgbChannel(c: number): number { - return c <= 0.0031308 - ? 12.92 * c - : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return c <= 0.0031308 + ? 12.92 * c + : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; } export function srgbToLinear(c: SRGB): LinearRGB { - return { - r: srgbToLinearChannel(c.r), - g: srgbToLinearChannel(c.g), - b: srgbToLinearChannel(c.b), - }; + return { + r: srgbToLinearChannel(c.r), + g: srgbToLinearChannel(c.g), + b: srgbToLinearChannel(c.b), + }; } export function linearToSrgb(c: LinearRGB): SRGB { - return { - r: linearToSrgbChannel(c.r), - g: linearToSrgbChannel(c.g), - b: linearToSrgbChannel(c.b), - }; + return { + r: linearToSrgbChannel(c.r), + g: linearToSrgbChannel(c.g), + b: linearToSrgbChannel(c.b), + }; } // ─── Linear RGB ↔ OKLAB ────────────────────────────────────────────────────── // Using the matrices from Björn Ottosson's OKLAB specification. export function linearRgbToOklab(c: LinearRGB): OKLAB { - const l_ = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b; - const m_ = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b; - const s_ = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b; + const l_ = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b; + const m_ = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b; + const s_ = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b; - const l = Math.cbrt(l_); - const m = Math.cbrt(m_); - const s = Math.cbrt(s_); + const l = Math.cbrt(l_); + const m = Math.cbrt(m_); + const s = Math.cbrt(s_); - return { - L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, - a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, - b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, - }; + return { + L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + }; } export function oklabToLinearRgb(c: OKLAB): LinearRGB { - const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b; - const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b; - const s_ = c.L - 0.0894841775 * c.a - 1.2914855480 * c.b; + const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b; + const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b; + const s_ = c.L - 0.0894841775 * c.a - 1.2914855480 * c.b; - const l = l_ * l_ * l_; - const m = m_ * m_ * m_; - const s = s_ * s_ * s_; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; - return { - r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, - g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, - b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, - }; + return { + r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + }; } // ─── OKLAB ↔ OKLCH ─────────────────────────────────────────────────────────── export function oklabToOklch(lab: OKLAB): OKLCH { - const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b); - let h = Math.atan2(lab.b, lab.a) * RAD; - if (h < 0) h += 360; - return { L: lab.L, C, h }; + const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b); + let h = Math.atan2(lab.b, lab.a) * RAD; + if (h < 0) h += 360; + return { L: lab.L, C, h }; } export function oklchToOklab(lch: OKLCH): OKLAB { - return { - L: lch.L, - a: lch.C * Math.cos(lch.h * DEG), - b: lch.C * Math.sin(lch.h * DEG), - }; + return { + L: lch.L, + a: lch.C * Math.cos(lch.h * DEG), + b: lch.C * Math.sin(lch.h * DEG), + }; } // ─── Convenience round-trips ───────────────────────────────────────────────── export function srgbToOklch(c: SRGB): OKLCH { - return oklabToOklch(linearRgbToOklab(srgbToLinear(c))); + return oklabToOklch(linearRgbToOklab(srgbToLinear(c))); } export function oklchToSrgb(c: OKLCH): SRGB { - return linearToSrgb(oklabToLinearRgb(oklchToOklab(c))); + return linearToSrgb(oklabToLinearRgb(oklchToOklab(c))); } /** Clamp sRGB channels to [0,1] after conversion to handle out-of-gamut. */ export function oklchToSrgbClamped(c: OKLCH): SRGB { - const raw = oklchToSrgb(c); - return { - r: Math.max(0, Math.min(1, raw.r)), - g: Math.max(0, Math.min(1, raw.g)), - b: Math.max(0, Math.min(1, raw.b)), - }; + const raw = oklchToSrgb(c); + return { + r: Math.max(0, Math.min(1, raw.r)), + g: Math.max(0, Math.min(1, raw.g)), + b: Math.max(0, Math.min(1, raw.b)), + }; } // ─── Hex ↔ RGBA ────────────────────────────────────────────────────────────── /** Parse a hex color string (#RGB, #RRGGBB, #RRGGBBAA) into RGBA 0-255. */ export function hexToRgba(hex: string): RGBA { - let h = hex.replace('#', ''); - if (h.length === 3) { - h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; - } else if (h.length === 4) { - h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; - } - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; - return { r, g, b, a }; + let h = hex.replace('#', ''); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } else if (h.length === 4) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; + } + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; + return { r, g, b, a }; } export function hexToSrgb(hex: string): SRGB { - const { r, g, b } = hexToRgba(hex); - return { r: r / 255, g: g / 255, b: b / 255 }; + const { r, g, b } = hexToRgba(hex); + return { r: r / 255, g: g / 255, b: b / 255 }; } export function hexToOklch(hex: string): OKLCH { - return srgbToOklch(hexToSrgb(hex)); + return srgbToOklch(hexToSrgb(hex)); } export function srgbToHex(c: SRGB): string { - const to8 = (v: number) => - Math.round(Math.max(0, Math.min(1, v)) * 255) - .toString(16) - .padStart(2, '0'); - return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`; + const to8 = (v: number) => + Math.round(Math.max(0, Math.min(1, v)) * 255) + .toString(16) + .padStart(2, '0'); + return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`; } export function oklchToHex(c: OKLCH): string { - return srgbToHex(oklchToSrgbClamped(c)); + return srgbToHex(oklchToSrgbClamped(c)); } export function rgbaToHex(c: RGBA): string { - const to8 = (v: number) => - Math.max(0, Math.min(255, Math.round(v))) - .toString(16) - .padStart(2, '0'); - if (c.a < 1) { - return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}${to8(c.a * 255)}`; - } - return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`; + const to8 = (v: number) => + Math.max(0, Math.min(255, Math.round(v))) + .toString(16) + .padStart(2, '0'); + if (c.a < 1) { + return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}${to8(c.a * 255)}`; + } + return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`; } // ─── CSS Color Parsing ─────────────────────────────────────────────────────── /** Map of CSS named colors to hex. Only the most common ones for speed. */ const NAMED_COLORS: Record = { - transparent: '#00000000', - black: '#000000', - white: '#ffffff', - red: '#ff0000', - green: '#008000', - blue: '#0000ff', - yellow: '#ffff00', - cyan: '#00ffff', - magenta: '#ff00ff', - orange: '#ffa500', - purple: '#800080', - pink: '#ffc0cb', - gray: '#808080', - grey: '#808080', - silver: '#c0c0c0', - maroon: '#800000', - olive: '#808000', - lime: '#00ff00', - teal: '#008080', - navy: '#000080', - aqua: '#00ffff', - fuchsia: '#ff00ff', - inherit: '', - initial: '', - unset: '', - currentcolor: '', - currentColor: '', + transparent: '#00000000', + black: '#000000', + white: '#ffffff', + red: '#ff0000', + green: '#008000', + blue: '#0000ff', + yellow: '#ffff00', + cyan: '#00ffff', + magenta: '#ff00ff', + orange: '#ffa500', + purple: '#800080', + pink: '#ffc0cb', + gray: '#808080', + grey: '#808080', + silver: '#c0c0c0', + maroon: '#800000', + olive: '#808000', + lime: '#00ff00', + teal: '#008080', + navy: '#000080', + aqua: '#00ffff', + fuchsia: '#ff00ff', + inherit: '', + initial: '', + unset: '', + currentcolor: '', + currentColor: '', }; /** @@ -245,94 +245,94 @@ const NAMED_COLORS: Record = { * Returns null if the string cannot be parsed or is a keyword like inherit. */ export function parseCssColor(raw: string): RGBA | null { - if (!raw || raw === 'none') return null; + if (!raw || raw === 'none') return null; - const trimmed = raw.trim().toLowerCase(); + const trimmed = raw.trim().toLowerCase(); - // Named colors - if (trimmed in NAMED_COLORS) { - const hex = NAMED_COLORS[trimmed]; - if (!hex) return null; // inherit, initial, etc. - return hexToRgba(hex); - } + // Named colors + if (trimmed in NAMED_COLORS) { + const hex = NAMED_COLORS[trimmed]; + if (!hex) return null; // inherit, initial, etc. + return hexToRgba(hex); + } - // Hex - if (trimmed.startsWith('#')) { - return hexToRgba(trimmed); - } + // Hex + if (trimmed.startsWith('#')) { + return hexToRgba(trimmed); + } - // rgb() / rgba() - const rgbMatch = trimmed.match( - /rgba?\(\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*(?:[,/]\s*([\d.]+%?))?\s*\)/ - ); - if (rgbMatch) { - const parseChannel = (s: string): number => - s.endsWith('%') ? (parseFloat(s) / 100) * 255 : parseFloat(s); - const parseAlpha = (s: string | undefined): number => { - if (!s) return 1; - return s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s); - }; - return { - r: parseChannel(rgbMatch[1]), - g: parseChannel(rgbMatch[2]), - b: parseChannel(rgbMatch[3]), - a: parseAlpha(rgbMatch[4]), - }; - } + // rgb() / rgba() + const rgbMatch = trimmed.match( + /rgba?\(\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*(?:[,/]\s*([\d.]+%?))?\s*\)/ + ); + if (rgbMatch) { + const parseChannel = (s: string): number => + s.endsWith('%') ? (parseFloat(s) / 100) * 255 : parseFloat(s); + const parseAlpha = (s: string | undefined): number => { + if (!s) return 1; + return s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s); + }; + return { + r: parseChannel(rgbMatch[1]), + g: parseChannel(rgbMatch[2]), + b: parseChannel(rgbMatch[3]), + a: parseAlpha(rgbMatch[4]), + }; + } - // hsl() / hsla() - const hslMatch = trimmed.match( - /hsla?\(\s*([\d.]+)\s*[,/ ]\s*([\d.]+)%?\s*[,/ ]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)/ - ); - if (hslMatch) { - const h = parseFloat(hslMatch[1]); - const s = parseFloat(hslMatch[2]) / 100; - const l = parseFloat(hslMatch[3]) / 100; - const a = hslMatch[4] - ? hslMatch[4].endsWith('%') - ? parseFloat(hslMatch[4]) / 100 - : parseFloat(hslMatch[4]) - : 1; - // HSL to RGB conversion - const rgb = hslToRgb(h, s, l); - return { ...rgb, a }; - } + // hsl() / hsla() + const hslMatch = trimmed.match( + /hsla?\(\s*([\d.]+)\s*[,/ ]\s*([\d.]+)%?\s*[,/ ]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)/ + ); + if (hslMatch) { + const h = parseFloat(hslMatch[1]); + const s = parseFloat(hslMatch[2]) / 100; + const l = parseFloat(hslMatch[3]) / 100; + const a = hslMatch[4] + ? hslMatch[4].endsWith('%') + ? parseFloat(hslMatch[4]) / 100 + : parseFloat(hslMatch[4]) + : 1; + // HSL to RGB conversion + const rgb = hslToRgb(h, s, l); + return { ...rgb, a }; + } - return null; + return null; } /** Standard HSL → RGB. h in degrees, s/l in [0,1]. Returns RGB 0-255. */ function hslToRgb( - h: number, - s: number, - l: number + h: number, + s: number, + l: number ): { r: number; g: number; b: number } { - h = ((h % 360) + 360) % 360; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m = l - c / 2; - let r = 0, - g = 0, - b = 0; - if (h < 60) [r, g, b] = [c, x, 0]; - else if (h < 120) [r, g, b] = [x, c, 0]; - else if (h < 180) [r, g, b] = [0, c, x]; - else if (h < 240) [r, g, b] = [0, x, c]; - else if (h < 300) [r, g, b] = [x, 0, c]; - else [r, g, b] = [c, 0, x]; - return { - r: Math.round((r + m) * 255), - g: Math.round((g + m) * 255), - b: Math.round((b + m) * 255), - }; + h = ((h % 360) + 360) % 360; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, + g = 0, + b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else[r, g, b] = [c, 0, x]; + return { + r: Math.round((r + m) * 255), + g: Math.round((g + m) * 255), + b: Math.round((b + m) * 255), + }; } /** Serialize RGBA to a CSS string. */ export function rgbaToCss(c: RGBA): string { - if (c.a < 1) { - return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${c.a.toFixed(3)})`; - } - return `rgb(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)})`; + if (c.a < 1) { + return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${c.a.toFixed(3)})`; + } + return `rgb(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)})`; } // ─── Luminance & Contrast ──────────────────────────────────────────────────── @@ -343,12 +343,12 @@ export function rgbaToCss(c: RGBA): string { * where R,G,B are linear-light values. */ export function relativeLuminance(c: SRGB): number { - const lin = srgbToLinear(c); - return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b; + const lin = srgbToLinear(c); + return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b; } export function relativeLuminanceFromRgba(c: RGBA): number { - return relativeLuminance({ r: c.r / 255, g: c.g / 255, b: c.b / 255 }); + return relativeLuminance({ r: c.r / 255, g: c.g / 255, b: c.b / 255 }); } /** @@ -356,9 +356,9 @@ export function relativeLuminanceFromRgba(c: RGBA): number { * Returns a value ≥ 1. AA for normal text requires ≥ 4.5. */ export function contrastRatio(l1: number, l2: number): number { - const lighter = Math.max(l1, l2); - const darker = Math.min(l1, l2); - return (lighter + 0.05) / (darker + 0.05); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); } /** @@ -366,7 +366,7 @@ export function contrastRatio(l1: number, l2: number): number { * Uses OKLCH chroma — values below threshold are "neutral." */ export function isNeutral(lch: OKLCH, threshold = 0.035): boolean { - return lch.C < threshold; + return lch.C < threshold; } /** @@ -374,26 +374,26 @@ export function isNeutral(lch: OKLCH, threshold = 0.035): boolean { * Returns null for achromatic colors. */ export type HueBucket = - | 'red' - | 'orange' - | 'yellow' - | 'green' - | 'cyan' - | 'blue' - | 'purple' - | 'magenta'; + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'cyan' + | 'blue' + | 'purple' + | 'magenta'; export function classifyHue(lch: OKLCH): HueBucket | null { - if (isNeutral(lch)) return null; - const h = ((lch.h % 360) + 360) % 360; - if (h < 20 || h >= 345) return 'red'; - if (h < 50) return 'orange'; - if (h < 85) return 'yellow'; - if (h < 160) return 'green'; - if (h < 200) return 'cyan'; - if (h < 270) return 'blue'; - if (h < 310) return 'purple'; - return 'magenta'; + if (isNeutral(lch)) return null; + const h = ((lch.h % 360) + 360) % 360; + if (h < 20 || h >= 345) return 'red'; + if (h < 50) return 'orange'; + if (h < 85) return 'yellow'; + if (h < 160) return 'green'; + if (h < 200) return 'cyan'; + if (h < 270) return 'blue'; + if (h < 310) return 'purple'; + return 'magenta'; } /** @@ -401,14 +401,14 @@ export function classifyHue(lch: OKLCH): HueBucket | null { * Hue interpolation takes the shortest path around the circle. */ export function blendOklch(a: OKLCH, b: OKLCH, t: number): OKLCH { - let dh = b.h - a.h; - if (dh > 180) dh -= 360; - if (dh < -180) dh += 360; - return { - L: a.L + (b.L - a.L) * t, - C: a.C + (b.C - a.C) * t, - h: ((a.h + dh * t) % 360 + 360) % 360, - }; + let dh = b.h - a.h; + if (dh > 180) dh -= 360; + if (dh < -180) dh += 360; + return { + L: a.L + (b.L - a.L) * t, + C: a.C + (b.C - a.C) * t, + h: ((a.h + dh * t) % 360 + 360) % 360, + }; } /** @@ -419,44 +419,44 @@ export function blendOklch(a: OKLCH, b: OKLCH, t: number): OKLCH { * using bisection for speed. */ export function ensureContrast( - color: OKLCH, - bgLuminance: number, - minRatio = 4.5, - maxIterations = 16 + color: OKLCH, + bgLuminance: number, + minRatio = 4.5, + maxIterations = 16 ): OKLCH { - const srgb = oklchToSrgbClamped(color); - const fgLum = relativeLuminance(srgb); - const currentRatio = contrastRatio(fgLum, bgLuminance); + const srgb = oklchToSrgbClamped(color); + const fgLum = relativeLuminance(srgb); + const currentRatio = contrastRatio(fgLum, bgLuminance); - if (currentRatio >= minRatio) return color; + if (currentRatio >= minRatio) return color; - // Determine direction: lighten or darken? - // If bg is dark, lighten fg; if bg is light, darken fg. - const shouldLighten = bgLuminance < 0.5; + // Determine direction: lighten or darken? + // If bg is dark, lighten fg; if bg is light, darken fg. + const shouldLighten = bgLuminance < 0.5; - let lo = shouldLighten ? color.L : 0; - let hi = shouldLighten ? 1 : color.L; - let bestL = color.L; + let lo = shouldLighten ? color.L : 0; + let hi = shouldLighten ? 1 : color.L; + let bestL = color.L; - for (let i = 0; i < maxIterations; i++) { - const mid = (lo + hi) / 2; - const testColor: OKLCH = { L: mid, C: color.C, h: color.h }; - const testSrgb = oklchToSrgbClamped(testColor); - const testLum = relativeLuminance(testSrgb); - const testRatio = contrastRatio(testLum, bgLuminance); + for (let i = 0; i < maxIterations; i++) { + const mid = (lo + hi) / 2; + const testColor: OKLCH = { L: mid, C: color.C, h: color.h }; + const testSrgb = oklchToSrgbClamped(testColor); + const testLum = relativeLuminance(testSrgb); + const testRatio = contrastRatio(testLum, bgLuminance); - if (testRatio >= minRatio) { - bestL = mid; - // Try to find a value closer to the original - if (shouldLighten) hi = mid; - else lo = mid; - } else { - if (shouldLighten) lo = mid; - else hi = mid; + if (testRatio >= minRatio) { + bestL = mid; + // Try to find a value closer to the original + if (shouldLighten) hi = mid; + else lo = mid; + } else { + if (shouldLighten) lo = mid; + else hi = mid; + } } - } - return { L: bestL, C: color.C, h: color.h }; + return { L: bestL, C: color.C, h: color.h }; } // ─── Transform Memoization ─────────────────────────────────────────────────── @@ -465,7 +465,7 @@ const transformCache = new Map(); const CACHE_MAX_SIZE = 8192; export function clearTransformCache(): void { - transformCache.clear(); + transformCache.clear(); } /** @@ -473,38 +473,38 @@ export function clearTransformCache(): void { * This ensures identical colors in different contexts can map differently. */ function makeCacheKey(cssColor: string, context: TransformContext): string { - return `${cssColor}|${context.property}|${context.parentBgLuminance.toFixed(3)}|${context.isLink ? 1 : 0}|${context.isButton ? 1 : 0}|${context.disabled ? 1 : 0}`; + return `${cssColor}|${context.property}|${context.parentBgLuminance.toFixed(3)}|${context.isLink ? 1 : 0}|${context.isButton ? 1 : 0}|${context.disabled ? 1 : 0}`; } // ─── Transform Context ─────────────────────────────────────────────────────── export interface TransformContext { - /** CSS property being transformed: 'color', 'background-color', 'border-color', 'box-shadow' */ - property: 'color' | 'background-color' | 'border-color' | 'box-shadow'; - /** Relative luminance of the parent/container background. */ - parentBgLuminance: number; - /** Whether the element is a link. */ - isLink: boolean; - /** Whether the element is a button or button-like. */ - isButton: boolean; - /** Whether the element is in a disabled state. */ - disabled: boolean; + /** CSS property being transformed: 'color', 'background-color', 'border-color', 'box-shadow' */ + property: 'color' | 'background-color' | 'border-color' | 'box-shadow'; + /** Relative luminance of the parent/container background. */ + parentBgLuminance: number; + /** Whether the element is a link. */ + isLink: boolean; + /** Whether the element is a button or button-like. */ + isButton: boolean; + /** Whether the element is in a disabled state. */ + disabled: boolean; } /** The semantic theme — provided by themeEngine.ts */ export interface SemanticTheme { - background: string; - foreground: string; - surface: string; - surfaceAlt: string; - accentPrimary: string; - accentSecondary: string; - error: string; - warning: string; - success: string; - info: string; - muted: string; - border: string; + background: string; + foreground: string; + surface: string; + surfaceAlt: string; + accentPrimary: string; + accentSecondary: string; + error: string; + warning: string; + success: string; + info: string; + muted: string; + border: string; } /** @@ -516,92 +516,92 @@ export interface SemanticTheme { * Returns a new CSS color string. */ export function transformColor( - cssColor: string, - context: TransformContext, - theme: SemanticTheme, - intensity: number = 1 + cssColor: string, + context: TransformContext, + theme: SemanticTheme, + intensity: number = 1 ): string { - // Fast path: cache hit - const key = makeCacheKey(cssColor, context); - const cached = transformCache.get(key); - if (cached !== undefined) return cached; + // Fast path: cache hit + const key = makeCacheKey(cssColor, context); + const cached = transformCache.get(key); + if (cached !== undefined) return cached; - const parsed = parseCssColor(cssColor); - if (!parsed) return cssColor; // unparseable → pass through + const parsed = parseCssColor(cssColor); + if (!parsed) return cssColor; // unparseable → pass through - // Fully transparent → leave alone - if (parsed.a === 0) return cssColor; + // Fully transparent → leave alone + if (parsed.a === 0) return cssColor; - const originalSrgb: SRGB = { r: parsed.r / 255, g: parsed.g / 255, b: parsed.b / 255 }; - const originalLch = srgbToOklch(originalSrgb); + const originalSrgb: SRGB = { r: parsed.r / 255, g: parsed.g / 255, b: parsed.b / 255 }; + const originalLch = srgbToOklch(originalSrgb); - // Determine the target theme color based on semantic analysis - let targetHex: string; - const hue = classifyHue(originalLch); + // Determine the target theme color based on semantic analysis + let targetHex: string; + const hue = classifyHue(originalLch); - if (context.property === 'background-color' || context.property === 'box-shadow') { - // Background colors → remap based on lightness bands - if (isNeutral(originalLch)) { - targetHex = remapNeutralBg(originalLch, theme); + if (context.property === 'background-color' || context.property === 'box-shadow') { + // Background colors → remap based on lightness bands + if (isNeutral(originalLch)) { + targetHex = remapNeutralBg(originalLch, theme); + } else { + targetHex = remapChromaticBg(originalLch, hue, theme); + } + } else if (context.property === 'color') { + // Text colors + if (context.isLink) { + targetHex = theme.accentPrimary; + } else if (isNeutral(originalLch)) { + targetHex = remapNeutralFg(originalLch, theme); + } else { + targetHex = remapChromaticFg(originalLch, hue, theme); + } } else { - targetHex = remapChromaticBg(originalLch, hue, theme); + // border-color + if (isNeutral(originalLch)) { + targetHex = theme.border; + } else { + targetHex = remapChromaticBg(originalLch, hue, theme); + } } - } else if (context.property === 'color') { - // Text colors - if (context.isLink) { - targetHex = theme.accentPrimary; - } else if (isNeutral(originalLch)) { - targetHex = remapNeutralFg(originalLch, theme); + + let targetLch = hexToOklch(targetHex); + + // Preserve the original lightness relationship to some degree: + // Shift the target lightness partially toward the original's relative lightness + // This helps preserve visual hierarchy within the theme + const lightnessPreservation = 0.25; + targetLch = { + ...targetLch, + L: targetLch.L + (originalLch.L - targetLch.L) * lightnessPreservation, + }; + + // Ensure WCAG contrast for text + if (context.property === 'color') { + targetLch = ensureContrast(targetLch, context.parentBgLuminance, 4.5); + } + + // Apply intensity blending: blend between original and target + const finalLch = blendOklch(originalLch, targetLch, intensity); + const finalSrgb = oklchToSrgbClamped(finalLch); + + let result: string; + if (parsed.a < 1) { + result = `rgba(${Math.round(finalSrgb.r * 255)}, ${Math.round(finalSrgb.g * 255)}, ${Math.round(finalSrgb.b * 255)}, ${parsed.a.toFixed(3)})`; } else { - targetHex = remapChromaticFg(originalLch, hue, theme); + result = srgbToHex(finalSrgb); } - } else { - // border-color - if (isNeutral(originalLch)) { - targetHex = theme.border; - } else { - targetHex = remapChromaticBg(originalLch, hue, theme); + + // Cache management + if (transformCache.size >= CACHE_MAX_SIZE) { + // Evict oldest ~25% + const keys = Array.from(transformCache.keys()); + for (let i = 0; i < CACHE_MAX_SIZE / 4; i++) { + transformCache.delete(keys[i]); + } } - } + transformCache.set(key, result); - let targetLch = hexToOklch(targetHex); - - // Preserve the original lightness relationship to some degree: - // Shift the target lightness partially toward the original's relative lightness - // This helps preserve visual hierarchy within the theme - const lightnessPreservation = 0.25; - targetLch = { - ...targetLch, - L: targetLch.L + (originalLch.L - targetLch.L) * lightnessPreservation, - }; - - // Ensure WCAG contrast for text - if (context.property === 'color') { - targetLch = ensureContrast(targetLch, context.parentBgLuminance, 4.5); - } - - // Apply intensity blending: blend between original and target - const finalLch = blendOklch(originalLch, targetLch, intensity); - const finalSrgb = oklchToSrgbClamped(finalLch); - - let result: string; - if (parsed.a < 1) { - result = `rgba(${Math.round(finalSrgb.r * 255)}, ${Math.round(finalSrgb.g * 255)}, ${Math.round(finalSrgb.b * 255)}, ${parsed.a.toFixed(3)})`; - } else { - result = srgbToHex(finalSrgb); - } - - // Cache management - if (transformCache.size >= CACHE_MAX_SIZE) { - // Evict oldest ~25% - const keys = Array.from(transformCache.keys()); - for (let i = 0; i < CACHE_MAX_SIZE / 4; i++) { - transformCache.delete(keys[i]); - } - } - transformCache.set(key, result); - - return result; + return result; } // ─── Neutral remapping helpers ─────────────────────────────────────────────── @@ -615,96 +615,96 @@ export function transformColor( * Light → foreground-ish (inverted for light themes) */ function remapNeutralBg(lch: OKLCH, theme: SemanticTheme): string { - const bgLch = hexToOklch(theme.background); - const fgLch = hexToOklch(theme.foreground); + const bgLch = hexToOklch(theme.background); + const fgLch = hexToOklch(theme.foreground); - // Determine if this is a dark or light theme by comparing bg and fg lightness - const isDark = bgLch.L < fgLch.L; + // Determine if this is a dark or light theme by comparing bg and fg lightness + const isDark = bgLch.L < fgLch.L; - if (isDark) { - // Dark theme: dark originals → theme bg, light originals → theme surface - if (lch.L < 0.2) return theme.background; - if (lch.L < 0.35) return theme.surface; - if (lch.L < 0.55) return theme.surfaceAlt; - if (lch.L < 0.75) return theme.muted; - return theme.foreground; - } else { - // Light theme: light originals → theme bg, dark originals → theme surface - if (lch.L > 0.85) return theme.background; - if (lch.L > 0.7) return theme.surface; - if (lch.L > 0.5) return theme.surfaceAlt; - if (lch.L > 0.3) return theme.muted; - return theme.foreground; - } + if (isDark) { + // Dark theme: dark originals → theme bg, light originals → theme surface + if (lch.L < 0.2) return theme.background; + if (lch.L < 0.35) return theme.surface; + if (lch.L < 0.55) return theme.surfaceAlt; + if (lch.L < 0.75) return theme.muted; + return theme.foreground; + } else { + // Light theme: light originals → theme bg, dark originals → theme surface + if (lch.L > 0.85) return theme.background; + if (lch.L > 0.7) return theme.surface; + if (lch.L > 0.5) return theme.surfaceAlt; + if (lch.L > 0.3) return theme.muted; + return theme.foreground; + } } function remapNeutralFg(lch: OKLCH, theme: SemanticTheme): string { - const bgLch = hexToOklch(theme.background); - const fgLch = hexToOklch(theme.foreground); - const isDark = bgLch.L < fgLch.L; + const bgLch = hexToOklch(theme.background); + const fgLch = hexToOklch(theme.foreground); + const isDark = bgLch.L < fgLch.L; - if (isDark) { - // In dark theme: dark text originals → muted, light text → foreground - if (lch.L > 0.6) return theme.foreground; - if (lch.L > 0.35) return theme.muted; - return theme.surface; // very dark text on dark bg → surface (barely visible, intentional) - } else { - if (lch.L < 0.4) return theme.foreground; - if (lch.L < 0.65) return theme.muted; - return theme.surface; - } + if (isDark) { + // In dark theme: dark text originals → muted, light text → foreground + if (lch.L > 0.6) return theme.foreground; + if (lch.L > 0.35) return theme.muted; + return theme.surface; // very dark text on dark bg → surface (barely visible, intentional) + } else { + if (lch.L < 0.4) return theme.foreground; + if (lch.L < 0.65) return theme.muted; + return theme.surface; + } } // ─── Chromatic remapping helpers ───────────────────────────────────────────── function remapChromaticBg( - _lch: OKLCH, - hue: HueBucket | null, - theme: SemanticTheme + _lch: OKLCH, + hue: HueBucket | null, + theme: SemanticTheme ): string { - switch (hue) { - case 'red': - case 'magenta': - return theme.error; - case 'orange': - return theme.warning; - case 'yellow': - return theme.warning; - case 'green': - return theme.success; - case 'cyan': - return theme.info; - case 'blue': - return theme.accentPrimary; - case 'purple': - return theme.accentSecondary; - default: - return theme.surface; - } + switch (hue) { + case 'red': + case 'magenta': + return theme.error; + case 'orange': + return theme.warning; + case 'yellow': + return theme.warning; + case 'green': + return theme.success; + case 'cyan': + return theme.info; + case 'blue': + return theme.accentPrimary; + case 'purple': + return theme.accentSecondary; + default: + return theme.surface; + } } function remapChromaticFg( - _lch: OKLCH, - hue: HueBucket | null, - theme: SemanticTheme + _lch: OKLCH, + hue: HueBucket | null, + theme: SemanticTheme ): string { - switch (hue) { - case 'red': - case 'magenta': - return theme.error; - case 'orange': - return theme.warning; - case 'yellow': - return theme.warning; - case 'green': - return theme.success; - case 'cyan': - return theme.info; - case 'blue': - return theme.accentPrimary; - case 'purple': - return theme.accentSecondary; - default: - return theme.foreground; - } + switch (hue) { + case 'red': + case 'magenta': + return theme.error; + case 'orange': + return theme.warning; + case 'yellow': + return theme.warning; + case 'green': + return theme.success; + case 'cyan': + return theme.info; + case 'blue': + return theme.accentPrimary; + case 'purple': + return theme.accentSecondary; + default: + return theme.foreground; + } } diff --git a/gogh-theme-engine/src/content.ts b/gogh-theme-engine/src/content.ts index c399713..c0cae06 100644 --- a/gogh-theme-engine/src/content.ts +++ b/gogh-theme-engine/src/content.ts @@ -20,12 +20,12 @@ */ import { - transformColor, - parseCssColor, - clearTransformCache, - relativeLuminanceFromRgba, - type SemanticTheme, - type TransformContext, + transformColor, + parseCssColor, + clearTransformCache, + relativeLuminanceFromRgba, + type SemanticTheme, + type TransformContext, } from './color'; import { parsePalette, DEFAULT_PALETTE_YAML, type GoghPalette } from './themeEngine'; import { startObserver, stopObserver, reprocessAll } from './observer'; @@ -56,61 +56,61 @@ const injectedShadowRoots = new WeakSet(); * Called at document_start so we can inject styles before first paint. */ async function init(): Promise { - try { - // Load settings from chrome.storage.sync - const data = await chrome.storage.sync.get([ - 'paletteYaml', - 'enabled', - 'intensity', - 'disabledSites', - ]); + try { + // Load settings from chrome.storage.sync + const data = await chrome.storage.sync.get([ + 'paletteYaml', + 'enabled', + 'intensity', + 'disabledSites', + ]); - // Check if this site is disabled - const hostname = window.location.hostname; - const disabledSites: string[] = data.disabledSites || []; - if (disabledSites.includes(hostname)) { - enabled = false; - return; + // Check if this site is disabled + const hostname = window.location.hostname; + const disabledSites: string[] = data.disabledSites || []; + if (disabledSites.includes(hostname)) { + enabled = false; + return; + } + + enabled = data.enabled !== false; // default to true + if (!enabled) return; + + intensity = typeof data.intensity === 'number' ? data.intensity / 100 : 1.0; + + // Parse the palette + const yamlStr = data.paletteYaml || DEFAULT_PALETTE_YAML; + const result = parsePalette(yamlStr); + + if (!result.ok) { + console.warn('[Gogh Theme Engine] Palette errors:', result.errors); + // Fall back to default + const fallback = parsePalette(DEFAULT_PALETTE_YAML); + if (!fallback.ok) return; // shouldn't happen + currentPalette = fallback.palette; + currentTheme = fallback.theme; + } else { + currentPalette = result.palette; + currentTheme = result.theme; + } + + // Inject theme CSS variables immediately (even before body exists) + injectThemeVariables(); + + // Start the observer — it handles waiting for body + startObserver({ + processElement: processElement, + onShadowRoot: handleShadowRoot, + }); + + // Listen for settings changes + chrome.storage.onChanged.addListener(handleStorageChange); + + // Listen for messages from background/popup + chrome.runtime.onMessage.addListener(handleMessage); + } catch (e) { + console.error('[Gogh Theme Engine] Init error:', e); } - - enabled = data.enabled !== false; // default to true - if (!enabled) return; - - intensity = typeof data.intensity === 'number' ? data.intensity / 100 : 1.0; - - // Parse the palette - const yamlStr = data.paletteYaml || DEFAULT_PALETTE_YAML; - const result = parsePalette(yamlStr); - - if (!result.ok) { - console.warn('[Gogh Theme Engine] Palette errors:', result.errors); - // Fall back to default - const fallback = parsePalette(DEFAULT_PALETTE_YAML); - if (!fallback.ok) return; // shouldn't happen - currentPalette = fallback.palette; - currentTheme = fallback.theme; - } else { - currentPalette = result.palette; - currentTheme = result.theme; - } - - // Inject theme CSS variables immediately (even before body exists) - injectThemeVariables(); - - // Start the observer — it handles waiting for body - startObserver({ - processElement: processElement, - onShadowRoot: handleShadowRoot, - }); - - // Listen for settings changes - chrome.storage.onChanged.addListener(handleStorageChange); - - // Listen for messages from background/popup - chrome.runtime.onMessage.addListener(handleMessage); - } catch (e) { - console.error('[Gogh Theme Engine] Init error:', e); - } } // Start immediately @@ -124,33 +124,33 @@ init(); * globally and inside shadow roots via :host injection. */ function injectThemeVariables(): void { - if (!currentTheme) return; + if (!currentTheme) return; - const css = buildThemeCSS(currentTheme); + const css = buildThemeCSS(currentTheme); - if (!themeStyleEl) { - themeStyleEl = document.createElement('style'); - themeStyleEl.id = 'gogh-theme-root'; - themeStyleEl.setAttribute('data-gogh', 'true'); - // Insert into or (head might not exist at document_start) - const target = document.head || document.documentElement; - target.insertBefore(themeStyleEl, target.firstChild); - } + if (!themeStyleEl) { + themeStyleEl = document.createElement('style'); + themeStyleEl.id = 'gogh-theme-root'; + themeStyleEl.setAttribute('data-gogh', 'true'); + // Insert into or (head might not exist at document_start) + const target = document.head || document.documentElement; + target.insertBefore(themeStyleEl, target.firstChild); + } - themeStyleEl.textContent = css; + themeStyleEl.textContent = css; - // Also ensure we have a dynamic rules stylesheet - if (!dynamicStyleEl) { - dynamicStyleEl = document.createElement('style'); - dynamicStyleEl.id = 'gogh-dynamic-rules'; - dynamicStyleEl.setAttribute('data-gogh', 'true'); - const target = document.head || document.documentElement; - target.appendChild(dynamicStyleEl); - } + // Also ensure we have a dynamic rules stylesheet + if (!dynamicStyleEl) { + dynamicStyleEl = document.createElement('style'); + dynamicStyleEl.id = 'gogh-dynamic-rules'; + dynamicStyleEl.setAttribute('data-gogh', 'true'); + const target = document.head || document.documentElement; + target.appendChild(dynamicStyleEl); + } } function buildThemeCSS(theme: SemanticTheme): string { - return ` + return ` :root { --gogh-bg: ${theme.background}; --gogh-fg: ${theme.foreground}; @@ -188,42 +188,42 @@ body { * @param declarations CSS declarations like { 'color': '#ff0000', 'background-color': '#000' } */ function getOrCreateDynamicClass( - key: string, - declarations: Record + key: string, + declarations: Record ): string { - let className = dynamicClassMap.get(key); - if (className) return className; + let className = dynamicClassMap.get(key); + if (className) return className; - className = `gogh-c${classCounter++}`; - dynamicClassMap.set(key, className); + className = `gogh-c${classCounter++}`; + dynamicClassMap.set(key, className); - // Build the CSS rule - const props = Object.entries(declarations) - .map(([prop, val]) => `${prop}: ${val} !important`) - .join('; '); - const rule = `.${className} { ${props}; }\n`; + // Build the CSS rule + const props = Object.entries(declarations) + .map(([prop, val]) => `${prop}: ${val} !important`) + .join('; '); + const rule = `.${className} { ${props}; }\n`; - // Append to dynamic stylesheet - if (dynamicStyleEl) { - dynamicStyleEl.textContent += rule; - } + // Append to dynamic stylesheet + if (dynamicStyleEl) { + dynamicStyleEl.textContent += rule; + } - return className; + return className; } // ─── Element Processing ─────────────────────────────────────────────────────── /** CSS properties we analyze and potentially transform. */ const COLOR_PROPERTIES = [ - 'color', - 'background-color', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', - 'outline-color', - 'box-shadow', - 'text-decoration-color', + 'color', + 'background-color', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + 'outline-color', + 'box-shadow', + 'text-decoration-color', ] as const; /** Properties that indicate we should skip transformation. */ @@ -234,97 +234,97 @@ const SKIP_PROPERTIES = ['backdrop-filter', 'mix-blend-mode'] as const; * colors through the OKLCH engine, and apply themed values. */ function processElement(el: HTMLElement): void { - if (!currentTheme || !enabled) return; + if (!currentTheme || !enabled) return; - // Don't process our own injected style elements - if (el.hasAttribute('data-gogh')) return; + // Don't process our own injected style elements + if (el.hasAttribute('data-gogh')) return; - const computed = window.getComputedStyle(el); + const computed = window.getComputedStyle(el); - // Skip elements with blend modes or backdrop filters that would break - for (const prop of SKIP_PROPERTIES) { - const val = computed.getPropertyValue(prop); - if (val && val !== 'none' && val !== 'normal') return; - } - - // Determine semantic context - const isLink = el.tagName === 'A' || el.closest('a') !== null; - const isButton = - el.tagName === 'BUTTON' || - el.getAttribute('role') === 'button' || - el.tagName === 'INPUT' && - ['submit', 'button', 'reset'].includes( - (el as HTMLInputElement).type - ); - const isDisabled = - (el as HTMLButtonElement).disabled === true || - el.getAttribute('aria-disabled') === 'true'; - - // Get parent background luminance for contrast calculations - const parentBgLum = getParentBackgroundLuminance(el); - - // Collect all color overrides for this element - const overrides: Record = {}; - let hasOverrides = false; - - // Process each color property - for (const prop of COLOR_PROPERTIES) { - const value = computed.getPropertyValue(prop); - if (!value || value === 'none' || value === 'initial' || value === 'inherit') continue; - - if (prop === 'box-shadow') { - const transformed = transformBoxShadow(value, parentBgLum, isLink, isButton, isDisabled); - if (transformed && transformed !== value) { - overrides[prop] = transformed; - hasOverrides = true; - } - continue; + // Skip elements with blend modes or backdrop filters that would break + for (const prop of SKIP_PROPERTIES) { + const val = computed.getPropertyValue(prop); + if (val && val !== 'none' && val !== 'normal') return; } - // Map border sub-properties to the generic context - let contextProp: TransformContext['property']; - if (prop.startsWith('border-') || prop === 'outline-color') { - contextProp = 'border-color'; - } else if (prop === 'text-decoration-color') { - contextProp = 'color'; - } else { - contextProp = prop as TransformContext['property']; + // Determine semantic context + const isLink = el.tagName === 'A' || el.closest('a') !== null; + const isButton = + el.tagName === 'BUTTON' || + el.getAttribute('role') === 'button' || + el.tagName === 'INPUT' && + ['submit', 'button', 'reset'].includes( + (el as HTMLInputElement).type + ); + const isDisabled = + (el as HTMLButtonElement).disabled === true || + el.getAttribute('aria-disabled') === 'true'; + + // Get parent background luminance for contrast calculations + const parentBgLum = getParentBackgroundLuminance(el); + + // Collect all color overrides for this element + const overrides: Record = {}; + let hasOverrides = false; + + // Process each color property + for (const prop of COLOR_PROPERTIES) { + const value = computed.getPropertyValue(prop); + if (!value || value === 'none' || value === 'initial' || value === 'inherit') continue; + + if (prop === 'box-shadow') { + const transformed = transformBoxShadow(value, parentBgLum, isLink, isButton, isDisabled); + if (transformed && transformed !== value) { + overrides[prop] = transformed; + hasOverrides = true; + } + continue; + } + + // Map border sub-properties to the generic context + let contextProp: TransformContext['property']; + if (prop.startsWith('border-') || prop === 'outline-color') { + contextProp = 'border-color'; + } else if (prop === 'text-decoration-color') { + contextProp = 'color'; + } else { + contextProp = prop as TransformContext['property']; + } + + const context: TransformContext = { + property: contextProp, + parentBgLuminance: parentBgLum, + isLink, + isButton, + disabled: isDisabled, + }; + + const transformed = transformColor(value, context, currentTheme, intensity); + if (transformed !== value) { + overrides[prop] = transformed; + hasOverrides = true; + } } - const context: TransformContext = { - property: contextProp, - parentBgLuminance: parentBgLum, - isLink, - isButton, - disabled: isDisabled, - }; - - const transformed = transformColor(value, context, currentTheme, intensity); - if (transformed !== value) { - overrides[prop] = transformed; - hasOverrides = true; + // Handle background-image (gradients) + const bgImage = computed.getPropertyValue('background-image'); + if (bgImage && bgImage !== 'none' && isGradient(bgImage)) { + const transformed = transformGradient(bgImage, parentBgLum); + if (transformed !== bgImage) { + overrides['background-image'] = transformed; + hasOverrides = true; + } } - } - // Handle background-image (gradients) - const bgImage = computed.getPropertyValue('background-image'); - if (bgImage && bgImage !== 'none' && isGradient(bgImage)) { - const transformed = transformGradient(bgImage, parentBgLum); - if (transformed !== bgImage) { - overrides['background-image'] = transformed; - hasOverrides = true; + // Apply overrides + if (hasOverrides) { + applyOverrides(el, overrides); } - } - // Apply overrides - if (hasOverrides) { - applyOverrides(el, overrides); - } - - // Handle inline SVGs - if (el instanceof SVGElement) { - processSvgElement(el); - } + // Handle inline SVGs + if (el instanceof SVGElement) { + processSvgElement(el); + } } /** @@ -332,23 +332,23 @@ function processElement(el: HTMLElement): void { * Falls back to the theme background if none found. */ function getParentBackgroundLuminance(el: HTMLElement): number { - let parent = el.parentElement; - while (parent) { - const bg = window.getComputedStyle(parent).getPropertyValue('background-color'); - if (bg) { - const parsed = parseCssColor(bg); - if (parsed && parsed.a > 0.5) { - return relativeLuminanceFromRgba(parsed); - } + let parent = el.parentElement; + while (parent) { + const bg = window.getComputedStyle(parent).getPropertyValue('background-color'); + if (bg) { + const parsed = parseCssColor(bg); + if (parsed && parsed.a > 0.5) { + return relativeLuminanceFromRgba(parsed); + } + } + parent = parent.parentElement; } - parent = parent.parentElement; - } - // Fallback: use theme background luminance - if (currentTheme) { - const parsed = parseCssColor(currentTheme.background); - if (parsed) return relativeLuminanceFromRgba(parsed); - } - return 0; // assume dark + // Fallback: use theme background luminance + if (currentTheme) { + const parsed = parseCssColor(currentTheme.background); + if (parsed) return relativeLuminanceFromRgba(parsed); + } + return 0; // assume dark } /** @@ -357,21 +357,21 @@ function getParentBackgroundLuminance(el: HTMLElement): number { * Otherwise create a new dynamic class or fall back to inline style. */ function applyOverrides(el: HTMLElement, overrides: Record): void { - // Build a key from sorted property:value pairs - const entries = Object.entries(overrides).sort((a, b) => a[0].localeCompare(b[0])); - const key = entries.map(([p, v]) => `${p}:${v}`).join('|'); + // Build a key from sorted property:value pairs + const entries = Object.entries(overrides).sort((a, b) => a[0].localeCompare(b[0])); + const key = entries.map(([p, v]) => `${p}:${v}`).join('|'); - // Try to use a dynamic class - const className = getOrCreateDynamicClass(key, overrides); - el.classList.add(className); - el.setAttribute('data-gogh-class', className); + // Try to use a dynamic class + const className = getOrCreateDynamicClass(key, overrides); + el.classList.add(className); + el.setAttribute('data-gogh-class', className); } // ─── Gradient Handling ──────────────────────────────────────────────────────── /** Check if a background-image value contains a gradient. */ function isGradient(value: string): boolean { - return /(?:linear|radial|conic)-gradient/i.test(value); + return /(?:linear|radial|conic)-gradient/i.test(value); } /** @@ -381,23 +381,23 @@ function isGradient(value: string): boolean { * transform them individually, and splice them back. */ function transformGradient(gradient: string, parentBgLum: number): string { - if (!currentTheme) return gradient; + if (!currentTheme) return gradient; - // Match CSS color values within the gradient - // This handles hex, rgb(), rgba(), hsl(), hsla(), and named colors - const colorRegex = - /#(?:[0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|\b(?:transparent|black|white|red|green|blue|yellow|cyan|magenta|orange|purple|pink|gray|grey|silver|maroon|olive|lime|teal|navy)\b/gi; + // Match CSS color values within the gradient + // This handles hex, rgb(), rgba(), hsl(), hsla(), and named colors + const colorRegex = + /#(?:[0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)|\b(?:transparent|black|white|red|green|blue|yellow|cyan|magenta|orange|purple|pink|gray|grey|silver|maroon|olive|lime|teal|navy)\b/gi; - return gradient.replace(colorRegex, (match) => { - const context: TransformContext = { - property: 'background-color', - parentBgLuminance: parentBgLum, - isLink: false, - isButton: false, - disabled: false, - }; - return transformColor(match, context, currentTheme!, intensity); - }); + return gradient.replace(colorRegex, (match) => { + const context: TransformContext = { + property: 'background-color', + parentBgLuminance: parentBgLum, + isLink: false, + isButton: false, + disabled: false, + }; + return transformColor(match, context, currentTheme!, intensity); + }); } // ─── Box Shadow Handling ────────────────────────────────────────────────────── @@ -408,28 +408,28 @@ function transformGradient(gradient: string, parentBgLum: number): string { * each potentially with a color value. */ function transformBoxShadow( - value: string, - parentBgLum: number, - isLink: boolean, - isButton: boolean, - isDisabled: boolean + value: string, + parentBgLum: number, + isLink: boolean, + isButton: boolean, + isDisabled: boolean ): string { - if (!currentTheme || value === 'none') return value; + if (!currentTheme || value === 'none') return value; - const colorRegex = - /#(?:[0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)/gi; + const colorRegex = + /#(?:[0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)/gi; - const context: TransformContext = { - property: 'box-shadow', - parentBgLuminance: parentBgLum, - isLink, - isButton, - disabled: isDisabled, - }; + const context: TransformContext = { + property: 'box-shadow', + parentBgLuminance: parentBgLum, + isLink, + isButton, + disabled: isDisabled, + }; - return value.replace(colorRegex, (match) => { - return transformColor(match, context, currentTheme!, intensity); - }); + return value.replace(colorRegex, (match) => { + return transformColor(match, context, currentTheme!, intensity); + }); } // ─── SVG Handling ───────────────────────────────────────────────────────────── @@ -439,38 +439,38 @@ function transformBoxShadow( * Only handles inline SVGs — external SVGs loaded via or are skipped. */ function processSvgElement(el: SVGElement): void { - if (!currentTheme) return; + if (!currentTheme) return; - const fill = el.getAttribute('fill'); - const stroke = el.getAttribute('stroke'); + const fill = el.getAttribute('fill'); + const stroke = el.getAttribute('stroke'); - if (fill && fill !== 'none' && fill !== 'currentColor') { - const context: TransformContext = { - property: 'background-color', // fill is analogous to background - parentBgLuminance: 0.5, - isLink: false, - isButton: false, - disabled: false, - }; - const transformed = transformColor(fill, context, currentTheme, intensity); - if (transformed !== fill) { - el.setAttribute('fill', transformed); + if (fill && fill !== 'none' && fill !== 'currentColor') { + const context: TransformContext = { + property: 'background-color', // fill is analogous to background + parentBgLuminance: 0.5, + isLink: false, + isButton: false, + disabled: false, + }; + const transformed = transformColor(fill, context, currentTheme, intensity); + if (transformed !== fill) { + el.setAttribute('fill', transformed); + } } - } - if (stroke && stroke !== 'none' && stroke !== 'currentColor') { - const context: TransformContext = { - property: 'border-color', // stroke is analogous to border - parentBgLuminance: 0.5, - isLink: false, - isButton: false, - disabled: false, - }; - const transformed = transformColor(stroke, context, currentTheme, intensity); - if (transformed !== stroke) { - el.setAttribute('stroke', transformed); + if (stroke && stroke !== 'none' && stroke !== 'currentColor') { + const context: TransformContext = { + property: 'border-color', // stroke is analogous to border + parentBgLuminance: 0.5, + isLink: false, + isButton: false, + disabled: false, + }; + const transformed = transformColor(stroke, context, currentTheme, intensity); + if (transformed !== stroke) { + el.setAttribute('stroke', transformed); + } } - } } // ─── Shadow DOM Handling ────────────────────────────────────────────────────── @@ -480,37 +480,37 @@ function processSvgElement(el: SVGElement): void { * We inject the CSS variable definitions and any dynamic rules. */ function handleShadowRoot(shadowRoot: ShadowRoot): void { - if (injectedShadowRoots.has(shadowRoot)) return; - injectedShadowRoots.add(shadowRoot); + if (injectedShadowRoots.has(shadowRoot)) return; + injectedShadowRoots.add(shadowRoot); - if (!currentTheme) return; + if (!currentTheme) return; - const style = document.createElement('style'); - style.setAttribute('data-gogh', 'true'); - style.textContent = buildShadowCSS(currentTheme); - shadowRoot.appendChild(style); + const style = document.createElement('style'); + style.setAttribute('data-gogh', 'true'); + style.textContent = buildShadowCSS(currentTheme); + shadowRoot.appendChild(style); - // Also observe mutations within the shadow root - const observer = new MutationObserver((records) => { - for (const record of records) { - if (record.type === 'childList') { - record.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - processElement(node as HTMLElement); - } - }); - } - } - }); + // Also observe mutations within the shadow root + const observer = new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'childList') { + record.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + processElement(node as HTMLElement); + } + }); + } + } + }); - observer.observe(shadowRoot, { - childList: true, - subtree: true, - }); + observer.observe(shadowRoot, { + childList: true, + subtree: true, + }); } function buildShadowCSS(theme: SemanticTheme): string { - return ` + return ` :host { --gogh-bg: ${theme.background}; --gogh-fg: ${theme.foreground}; @@ -531,124 +531,124 @@ function buildShadowCSS(theme: SemanticTheme): string { // ─── Storage Change Handler ─────────────────────────────────────────────────── function handleStorageChange( - changes: { [key: string]: chrome.storage.StorageChange }, - _area: string + changes: { [key: string]: chrome.storage.StorageChange }, + _area: string ): void { - let needsReprocess = false; + let needsReprocess = false; - if (changes.enabled) { - enabled = changes.enabled.newValue !== false; - if (!enabled) { - cleanup(); - return; + if (changes.enabled) { + enabled = changes.enabled.newValue !== false; + if (!enabled) { + cleanup(); + return; + } + needsReprocess = true; } - needsReprocess = true; - } - if (changes.intensity) { - intensity = - typeof changes.intensity.newValue === 'number' - ? changes.intensity.newValue / 100 - : 1.0; - needsReprocess = true; - } - - if (changes.paletteYaml) { - const result = parsePalette(changes.paletteYaml.newValue || DEFAULT_PALETTE_YAML); - if (result.ok) { - currentPalette = result.palette; - currentTheme = result.theme; - clearTransformCache(); - dynamicClassMap.clear(); - classCounter = 0; - if (dynamicStyleEl) dynamicStyleEl.textContent = ''; - injectThemeVariables(); - needsReprocess = true; + if (changes.intensity) { + intensity = + typeof changes.intensity.newValue === 'number' + ? changes.intensity.newValue / 100 + : 1.0; + needsReprocess = true; } - } - if (changes.disabledSites) { - const hostname = window.location.hostname; - const disabledSites: string[] = changes.disabledSites.newValue || []; - if (disabledSites.includes(hostname)) { - cleanup(); - return; + if (changes.paletteYaml) { + const result = parsePalette(changes.paletteYaml.newValue || DEFAULT_PALETTE_YAML); + if (result.ok) { + currentPalette = result.palette; + currentTheme = result.theme; + clearTransformCache(); + dynamicClassMap.clear(); + classCounter = 0; + if (dynamicStyleEl) dynamicStyleEl.textContent = ''; + injectThemeVariables(); + needsReprocess = true; + } } - } - if (needsReprocess && enabled) { - clearTransformCache(); - dynamicClassMap.clear(); - classCounter = 0; - if (dynamicStyleEl) dynamicStyleEl.textContent = ''; - reprocessAll(); - } + if (changes.disabledSites) { + const hostname = window.location.hostname; + const disabledSites: string[] = changes.disabledSites.newValue || []; + if (disabledSites.includes(hostname)) { + cleanup(); + return; + } + } + + if (needsReprocess && enabled) { + clearTransformCache(); + dynamicClassMap.clear(); + classCounter = 0; + if (dynamicStyleEl) dynamicStyleEl.textContent = ''; + reprocessAll(); + } } // ─── Message Handler ────────────────────────────────────────────────────────── function handleMessage( - msg: { type: string; [key: string]: unknown }, - _sender: chrome.runtime.MessageSender, - sendResponse: (resp?: unknown) => void + msg: { type: string;[key: string]: unknown }, + _sender: chrome.runtime.MessageSender, + sendResponse: (resp?: unknown) => void ): boolean { - switch (msg.type) { - case 'getStatus': - sendResponse({ - enabled, - hostname: window.location.hostname, - palette: currentPalette?.name || 'None', - intensity: Math.round(intensity * 100), - }); - return true; + switch (msg.type) { + case 'getStatus': + sendResponse({ + enabled, + hostname: window.location.hostname, + palette: currentPalette?.name || 'None', + intensity: Math.round(intensity * 100), + }); + return true; - case 'toggle': - enabled = !enabled; - if (enabled) { - injectThemeVariables(); - reprocessAll(); - } else { - cleanup(); - } - chrome.storage.sync.set({ enabled }); - sendResponse({ enabled }); - return true; + case 'toggle': + enabled = !enabled; + if (enabled) { + injectThemeVariables(); + reprocessAll(); + } else { + cleanup(); + } + chrome.storage.sync.set({ enabled }); + sendResponse({ enabled }); + return true; - case 'reprocess': - if (enabled) { - clearTransformCache(); - reprocessAll(); - } - sendResponse({ ok: true }); - return true; - } - return false; + case 'reprocess': + if (enabled) { + clearTransformCache(); + reprocessAll(); + } + sendResponse({ ok: true }); + return true; + } + return false; } // ─── Cleanup ────────────────────────────────────────────────────────────────── function cleanup(): void { - stopObserver(); + stopObserver(); - // Remove our injected styles - themeStyleEl?.remove(); - themeStyleEl = null; - dynamicStyleEl?.remove(); - dynamicStyleEl = null; + // Remove our injected styles + themeStyleEl?.remove(); + themeStyleEl = null; + dynamicStyleEl?.remove(); + dynamicStyleEl = null; - // Remove all dynamic classes and data attributes we added - document.querySelectorAll('[data-gogh-class]').forEach((el) => { - const cls = el.getAttribute('data-gogh-class'); - if (cls) el.classList.remove(cls); - el.removeAttribute('data-gogh-class'); - el.removeAttribute('data-gogh-processed'); - }); + // Remove all dynamic classes and data attributes we added + document.querySelectorAll('[data-gogh-class]').forEach((el) => { + const cls = el.getAttribute('data-gogh-class'); + if (cls) el.classList.remove(cls); + el.removeAttribute('data-gogh-class'); + el.removeAttribute('data-gogh-processed'); + }); - document.querySelectorAll('[data-gogh-processed]').forEach((el) => { - el.removeAttribute('data-gogh-processed'); - }); + document.querySelectorAll('[data-gogh-processed]').forEach((el) => { + el.removeAttribute('data-gogh-processed'); + }); - dynamicClassMap.clear(); - classCounter = 0; - clearTransformCache(); + dynamicClassMap.clear(); + classCounter = 0; + clearTransformCache(); } diff --git a/gogh-theme-engine/src/js-yaml.d.ts b/gogh-theme-engine/src/js-yaml.d.ts index 6026aa5..cfa9799 100644 --- a/gogh-theme-engine/src/js-yaml.d.ts +++ b/gogh-theme-engine/src/js-yaml.d.ts @@ -1,5 +1,5 @@ declare module 'js-yaml' { - export function load(input: string, options?: any): unknown; - export function dump(input: unknown, options?: any): string; - export function loadAll(input: string, iterator?: (doc: unknown) => void, options?: any): unknown[]; + export function load(input: string, options?: any): unknown; + export function dump(input: unknown, options?: any): string; + export function loadAll(input: string, iterator?: (doc: unknown) => void, options?: any): unknown[]; } diff --git a/gogh-theme-engine/src/observer.ts b/gogh-theme-engine/src/observer.ts index 238037c..645dd5c 100644 --- a/gogh-theme-engine/src/observer.ts +++ b/gogh-theme-engine/src/observer.ts @@ -18,36 +18,36 @@ export type ElementProcessor = (el: HTMLElement) => void; export type ShadowRootHandler = (shadowRoot: ShadowRoot) => void; export interface ObserverConfig { - /** Called for each element that should be color-transformed. */ - processElement: ElementProcessor; - /** Called when a new shadow root is created or discovered. */ - onShadowRoot: ShadowRootHandler; + /** Called for each element that should be color-transformed. */ + processElement: ElementProcessor; + /** Called when a new shadow root is created or discovered. */ + onShadowRoot: ShadowRootHandler; } // ─── Constants ──────────────────────────────────────────────────────────────── /** Tags to skip entirely — these don't have meaningful CSS colors to remap. */ const SKIP_TAGS = new Set([ - 'SCRIPT', - 'STYLE', - 'LINK', - 'META', - 'HEAD', - 'TITLE', - 'NOSCRIPT', - 'BR', - 'WBR', - 'VIDEO', - 'CANVAS', - 'IFRAME', - 'AUDIO', - 'SOURCE', - 'TRACK', - 'OBJECT', - 'EMBED', - 'APPLET', - 'MAP', - 'AREA', + 'SCRIPT', + 'STYLE', + 'LINK', + 'META', + 'HEAD', + 'TITLE', + 'NOSCRIPT', + 'BR', + 'WBR', + 'VIDEO', + 'CANVAS', + 'IFRAME', + 'AUDIO', + 'SOURCE', + 'TRACK', + 'OBJECT', + 'EMBED', + 'APPLET', + 'MAP', + 'AREA', ]); /** Attribute we stamp on processed elements to avoid redundant work. */ @@ -76,64 +76,64 @@ let originalAttachShadow: typeof Element.prototype.attachShadow | null = null; * Start observing the document for changes and perform initial traversal. */ export function startObserver(cfg: ObserverConfig): void { - config = cfg; - isActive = true; + config = cfg; + isActive = true; - // Monkey-patch attachShadow to intercept new shadow roots - patchAttachShadow(cfg.onShadowRoot); + // Monkey-patch attachShadow to intercept new shadow roots + patchAttachShadow(cfg.onShadowRoot); - // Initial full-page traversal - if (document.body) { - traverseSubtree(document.body, cfg.processElement, cfg.onShadowRoot); - } else { - // Body not ready yet — wait for it - const bodyObserver = new MutationObserver(() => { - if (document.body) { - bodyObserver.disconnect(); + // Initial full-page traversal + if (document.body) { traverseSubtree(document.body, cfg.processElement, cfg.onShadowRoot); - setupMutationObserver(); - } - }); - bodyObserver.observe(document.documentElement, { childList: true }); - return; - } + } else { + // Body not ready yet — wait for it + const bodyObserver = new MutationObserver(() => { + if (document.body) { + bodyObserver.disconnect(); + traverseSubtree(document.body, cfg.processElement, cfg.onShadowRoot); + setupMutationObserver(); + } + }); + bodyObserver.observe(document.documentElement, { childList: true }); + return; + } - setupMutationObserver(); + setupMutationObserver(); } /** * Stop observing and clean up. */ export function stopObserver(): void { - isActive = false; + isActive = false; - if (mutationObserver) { - mutationObserver.disconnect(); - mutationObserver = null; - } + if (mutationObserver) { + mutationObserver.disconnect(); + mutationObserver = null; + } - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } - pendingMutations = []; - restoreAttachShadow(); + pendingMutations = []; + restoreAttachShadow(); } /** * Force reprocess the entire document (e.g., when theme changes). */ export function reprocessAll(): void { - if (!config || !isActive) return; - // Clear the processed set so everything gets revisited - // (WeakSet can't be cleared, so we rely on PROCESSED_ATTR removal) - document.querySelectorAll(`[${PROCESSED_ATTR}]`).forEach((el) => { - el.removeAttribute(PROCESSED_ATTR); - }); - if (document.body) { - traverseSubtree(document.body, config.processElement, config.onShadowRoot, true); - } + if (!config || !isActive) return; + // Clear the processed set so everything gets revisited + // (WeakSet can't be cleared, so we rely on PROCESSED_ATTR removal) + document.querySelectorAll(`[${PROCESSED_ATTR}]`).forEach((el) => { + el.removeAttribute(PROCESSED_ATTR); + }); + if (document.body) { + traverseSubtree(document.body, config.processElement, config.onShadowRoot, true); + } } // ─── TreeWalker-based Traversal ────────────────────────────────────────────── @@ -143,81 +143,81 @@ export function reprocessAll(): void { * large pages) and invoke the processor for each qualifying element. */ function traverseSubtree( - root: Node, - processElement: ElementProcessor, - onShadowRoot: ShadowRootHandler, - forceReprocess = false + root: Node, + processElement: ElementProcessor, + onShadowRoot: ShadowRootHandler, + forceReprocess = false ): void { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { - acceptNode(node: Node): number { - const el = node as Element; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { + acceptNode(node: Node): number { + const el = node as Element; - // Skip tags that shouldn't be processed - if (SKIP_TAGS.has(el.tagName)) { - return NodeFilter.FILTER_REJECT; // Skip this node and its children - } + // Skip tags that shouldn't be processed + if (SKIP_TAGS.has(el.tagName)) { + return NodeFilter.FILTER_REJECT; // Skip this node and its children + } - // Skip already-processed elements (unless forced) - if (!forceReprocess && el.hasAttribute(PROCESSED_ATTR)) { - return NodeFilter.FILTER_SKIP; // Skip this node but visit children - } + // Skip already-processed elements (unless forced) + if (!forceReprocess && el.hasAttribute(PROCESSED_ATTR)) { + return NodeFilter.FILTER_SKIP; // Skip this node but visit children + } - return NodeFilter.FILTER_ACCEPT; - }, - }); + return NodeFilter.FILTER_ACCEPT; + }, + }); - let node: Node | null; - while ((node = walker.nextNode())) { - const el = node as HTMLElement; + let node: Node | null; + while ((node = walker.nextNode())) { + const el = node as HTMLElement; - // Quick visibility check — skip elements with zero dimensions - // (We avoid getBoundingClientRect here since it's expensive; - // instead we check offsetParent which is O(1).) - if (el.offsetParent === null && el !== document.body && el.tagName !== 'HTML') { - // offsetParent is null for hidden elements and fixed/sticky elements. - // For fixed-position elements we still want to process, so check display. - const style = el.style; - if (style.display === 'none') continue; - // Also skip if it's position:fixed/sticky but has zero size - if (el.offsetWidth === 0 && el.offsetHeight === 0) continue; + // Quick visibility check — skip elements with zero dimensions + // (We avoid getBoundingClientRect here since it's expensive; + // instead we check offsetParent which is O(1).) + if (el.offsetParent === null && el !== document.body && el.tagName !== 'HTML') { + // offsetParent is null for hidden elements and fixed/sticky elements. + // For fixed-position elements we still want to process, so check display. + const style = el.style; + if (style.display === 'none') continue; + // Also skip if it's position:fixed/sticky but has zero size + if (el.offsetWidth === 0 && el.offsetHeight === 0) continue; + } + + processElement(el); + el.setAttribute(PROCESSED_ATTR, '1'); + + // Handle existing shadow roots + if (el.shadowRoot) { + onShadowRoot(el.shadowRoot); + traverseSubtree(el.shadowRoot, processElement, onShadowRoot, forceReprocess); + } } - - processElement(el); - el.setAttribute(PROCESSED_ATTR, '1'); - - // Handle existing shadow roots - if (el.shadowRoot) { - onShadowRoot(el.shadowRoot); - traverseSubtree(el.shadowRoot, processElement, onShadowRoot, forceReprocess); - } - } } // ─── MutationObserver ───────────────────────────────────────────────────────── function setupMutationObserver(): void { - if (mutationObserver) return; + if (mutationObserver) return; - mutationObserver = new MutationObserver((records) => { - if (!isActive) return; + mutationObserver = new MutationObserver((records) => { + if (!isActive) return; - // Accumulate mutations and process them in a batch - pendingMutations.push(...records); + // Accumulate mutations and process them in a batch + pendingMutations.push(...records); - if (debounceTimer) clearTimeout(debounceTimer); + if (debounceTimer) clearTimeout(debounceTimer); - // Use requestIdleCallback if available, otherwise setTimeout - debounceTimer = setTimeout(() => { - flushMutations(); - }, MUTATION_DEBOUNCE_MS); - }); + // Use requestIdleCallback if available, otherwise setTimeout + debounceTimer = setTimeout(() => { + flushMutations(); + }, MUTATION_DEBOUNCE_MS); + }); - mutationObserver.observe(document.documentElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['class', 'style', 'data-theme', 'data-mode'], - }); + mutationObserver.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style', 'data-theme', 'data-mode'], + }); } /** @@ -225,72 +225,72 @@ function setupMutationObserver(): void { * Uses requestIdleCallback to avoid blocking the main thread. */ function flushMutations(): void { - if (!config || pendingMutations.length === 0) return; + if (!config || pendingMutations.length === 0) return; - const mutations = pendingMutations; - pendingMutations = []; + const mutations = pendingMutations; + pendingMutations = []; - // Deduplicate elements to process - const elementsToProcess = new Set(); + // Deduplicate elements to process + const elementsToProcess = new Set(); - for (const record of mutations) { - if (record.type === 'childList') { - // Process newly added nodes - record.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - if (!SKIP_TAGS.has(el.tagName)) { - elementsToProcess.add(el); - // Also process children of the added node - const children = el.querySelectorAll('*'); - children.forEach((child) => { - if (!SKIP_TAGS.has(child.tagName)) { - elementsToProcess.add(child as HTMLElement); - } + for (const record of mutations) { + if (record.type === 'childList') { + // Process newly added nodes + record.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (!SKIP_TAGS.has(el.tagName)) { + elementsToProcess.add(el); + // Also process children of the added node + const children = el.querySelectorAll('*'); + children.forEach((child) => { + if (!SKIP_TAGS.has(child.tagName)) { + elementsToProcess.add(child as HTMLElement); + } + }); + } + } }); - } + } else if (record.type === 'attributes') { + // Re-process elements whose class or style changed + const el = record.target as HTMLElement; + if (el.nodeType === Node.ELEMENT_NODE && !SKIP_TAGS.has(el.tagName)) { + // Remove the processed marker so it gets re-evaluated + el.removeAttribute(PROCESSED_ATTR); + elementsToProcess.add(el); + } } - }); - } else if (record.type === 'attributes') { - // Re-process elements whose class or style changed - const el = record.target as HTMLElement; - if (el.nodeType === Node.ELEMENT_NODE && !SKIP_TAGS.has(el.tagName)) { - // Remove the processed marker so it gets re-evaluated - el.removeAttribute(PROCESSED_ATTR); - elementsToProcess.add(el); - } } - } - // Process in idle time if available - if ('requestIdleCallback' in window) { - const elements = Array.from(elementsToProcess); - let index = 0; + // Process in idle time if available + if ('requestIdleCallback' in window) { + const elements = Array.from(elementsToProcess); + let index = 0; - function processChunk(deadline: IdleDeadline): void { - while (index < elements.length && deadline.timeRemaining() > 1) { - const el = elements[index]; - if (!el.hasAttribute(PROCESSED_ATTR)) { - config!.processElement(el); - el.setAttribute(PROCESSED_ATTR, '1'); + function processChunk(deadline: IdleDeadline): void { + while (index < elements.length && deadline.timeRemaining() > 1) { + const el = elements[index]; + if (!el.hasAttribute(PROCESSED_ATTR)) { + config!.processElement(el); + el.setAttribute(PROCESSED_ATTR, '1'); + } + index++; + } + if (index < elements.length) { + requestIdleCallback(processChunk); + } } - index++; - } - if (index < elements.length) { + requestIdleCallback(processChunk); - } + } else { + // Fallback: process everything synchronously + for (const el of elementsToProcess) { + if (!el.hasAttribute(PROCESSED_ATTR)) { + config.processElement(el); + el.setAttribute(PROCESSED_ATTR, '1'); + } + } } - - requestIdleCallback(processChunk); - } else { - // Fallback: process everything synchronously - for (const el of elementsToProcess) { - if (!el.hasAttribute(PROCESSED_ATTR)) { - config.processElement(el); - el.setAttribute(PROCESSED_ATTR, '1'); - } - } - } } // ─── Shadow DOM Patching ───────────────────────────────────────────────────── @@ -300,33 +300,33 @@ function flushMutations(): void { * This lets us inject our theme styles into shadow DOM boundaries. */ function patchAttachShadow(onShadowRoot: ShadowRootHandler): void { - if (originalAttachShadow) return; // already patched + if (originalAttachShadow) return; // already patched - originalAttachShadow = Element.prototype.attachShadow; + originalAttachShadow = Element.prototype.attachShadow; - Element.prototype.attachShadow = function ( - this: Element, - init: ShadowRootInit - ): ShadowRoot { - const shadowRoot = originalAttachShadow!.call(this, init); + Element.prototype.attachShadow = function ( + this: Element, + init: ShadowRootInit + ): ShadowRoot { + const shadowRoot = originalAttachShadow!.call(this, init); - // Notify the theme engine about this new shadow root - // Use a microtask to avoid blocking the constructor - queueMicrotask(() => { - onShadowRoot(shadowRoot); - // Also traverse the shadow root's content if any was already added - if (config) { - traverseSubtree(shadowRoot, config.processElement, onShadowRoot); - } - }); + // Notify the theme engine about this new shadow root + // Use a microtask to avoid blocking the constructor + queueMicrotask(() => { + onShadowRoot(shadowRoot); + // Also traverse the shadow root's content if any was already added + if (config) { + traverseSubtree(shadowRoot, config.processElement, onShadowRoot); + } + }); - return shadowRoot; - }; + return shadowRoot; + }; } function restoreAttachShadow(): void { - if (originalAttachShadow) { - Element.prototype.attachShadow = originalAttachShadow; - originalAttachShadow = null; - } + if (originalAttachShadow) { + Element.prototype.attachShadow = originalAttachShadow; + originalAttachShadow = null; + } } diff --git a/gogh-theme-engine/src/options/options.html b/gogh-theme-engine/src/options/options.html index 8cce71a..66cf308 100644 --- a/gogh-theme-engine/src/options/options.html +++ b/gogh-theme-engine/src/options/options.html @@ -1,399 +1,484 @@ + - - - Gogh Theme Engine — Options - + /* ─── Buttons ──────────────────────────────────────────────── */ + .btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 1.1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-alt); + color: var(--fg); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + } + + .btn:hover { + background: var(--border); + } + + .btn.primary { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + } + + .btn.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + + .btn-row { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + flex-wrap: wrap; + } + + /* ─── Controls ─────────────────────────────────────────────── */ + .control-group { + margin-bottom: 1.25rem; + } + + .control-group label { + display: block; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.35rem; + color: var(--fg-muted); + } + + .toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0; + border-bottom: 1px solid var(--border); + } + + .toggle-row:last-child { + border-bottom: none; + } + + .toggle-label { + font-size: 0.9rem; + } + + /* Toggle switch */ + .switch { + position: relative; + width: 44px; + height: 24px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 24px; + cursor: pointer; + transition: background 0.2s; + } + + .slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: var(--fg); + border-radius: 50%; + transition: transform 0.2s; + } + + .switch input:checked+.slider { + background: var(--accent); + } + + .switch input:checked+.slider::before { + transform: translateX(20px); + } + + /* Range slider */ + input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; + margin: 0.5rem 0; + } + + input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + background: var(--accent); + border-radius: 50%; + cursor: pointer; + } + + .range-value { + display: inline-block; + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--accent); + min-width: 3ch; + text-align: right; + } + + /* ─── Color Preview ───────────────────────────────────────── */ + .color-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 6px; + margin: 0.75rem 0; + } + + .color-swatch { + aspect-ratio: 1; + border-radius: 6px; + border: 1px solid var(--border); + position: relative; + cursor: help; + } + + .color-swatch .tooltip { + display: none; + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: var(--bg); + color: var(--fg); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.7rem; + font-family: var(--font-mono); + white-space: nowrap; + border: 1px solid var(--border); + z-index: 10; + } + + .color-swatch:hover .tooltip { + display: block; + } + + /* Semantic preview */ + .semantic-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; + margin: 0.75rem 0; + } + + .semantic-swatch { + padding: 0.5rem; + border-radius: 6px; + text-align: center; + font-size: 0.7rem; + font-weight: 500; + border: 1px solid transparent; + } + + /* ─── Contrast Diagnostics ────────────────────────────────── */ + .contrast-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + margin-top: 0.5rem; + } + + .contrast-table th, + .contrast-table td { + padding: 0.4rem 0.6rem; + text-align: left; + border-bottom: 1px solid var(--border); + } + + .contrast-table th { + color: var(--fg-muted); + font-weight: 500; + } + + .contrast-pass { + color: var(--success); + } + + .contrast-fail { + color: var(--error); + } + + /* ─── Preview Iframe ──────────────────────────────────────── */ + #preview-frame { + width: 100%; + height: 300px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: white; + } + + /* ─── Disabled Sites ──────────────────────────────────────── */ + .site-list { + list-style: none; + } + + .site-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + font-family: var(--font-mono); + } + + .site-list li:last-child { + border-bottom: none; + } + + .remove-site { + background: none; + border: none; + color: var(--error); + cursor: pointer; + font-size: 1rem; + padding: 0 0.3rem; + } + + .empty-state { + color: var(--fg-muted); + font-size: 0.85rem; + font-style: italic; + } + + -

🎨 Gogh Theme Engine

-

Apply Gogh terminal palettes to any website using perceptual OKLCH color remapping.

+

🎨 Gogh Theme Engine

+

Apply Gogh terminal palettes to any website using perceptual OKLCH color remapping.

-
- -
-
-

Palette (YAML)

-
- -
-
-
- - - -
-
+
+ +
+
+

Palette (YAML)

+
+ +
+
+
+ + + +
+
- -
-

Disabled Sites

-
    -
  • No sites disabled.
  • -
-
+ +
+

Disabled Sites

+
    +
  • No sites disabled.
  • +
+
+
+ + +
+
+

Controls

+ +
+
+ Enable Theming + +
+
+ +
+ + +
+
+ + +
+

Palette Colors

+
+ +

Semantic Theme

+
+
+ + +
+

Contrast Diagnostics

+ + + + + + + + + + +
CombinationRatioAAAAA
+
+ + +
+

Live Preview

+ +
+
- -
-
-

Controls

- -
-
- Enable Theming - -
-
- -
- - -
-
- - -
-

Palette Colors

-
- -

Semantic Theme

-
-
- - -
-

Contrast Diagnostics

- - - - - - - - - - -
CombinationRatioAAAAA
-
- - -
-

Live Preview

- -
-
-
- - + - + + \ No newline at end of file diff --git a/gogh-theme-engine/src/options/options.ts b/gogh-theme-engine/src/options/options.ts index eb7631f..92d1764 100644 --- a/gogh-theme-engine/src/options/options.ts +++ b/gogh-theme-engine/src/options/options.ts @@ -12,17 +12,17 @@ */ import { - parsePalette, - DEFAULT_PALETTE_YAML, - deriveSemanticTheme, - type GoghPalette, + parsePalette, + DEFAULT_PALETTE_YAML, + deriveSemanticTheme, + type GoghPalette, } from '../themeEngine'; import { - hexToOklch, - hexToSrgb, - relativeLuminance, - contrastRatio, - type SemanticTheme, + hexToOklch, + hexToSrgb, + relativeLuminance, + contrastRatio, + type SemanticTheme, } from '../color'; // ─── DOM References ─────────────────────────────────────────────────────────── @@ -49,31 +49,31 @@ let currentTheme: SemanticTheme | null = null; // ─── Init ───────────────────────────────────────────────────────────────────── async function init(): Promise { - // Load current settings - const data = await chrome.storage.sync.get([ - 'paletteYaml', - 'enabled', - 'intensity', - 'disabledSites', - ]); + // Load current settings + const data = await chrome.storage.sync.get([ + 'paletteYaml', + 'enabled', + 'intensity', + 'disabledSites', + ]); - const yamlStr = data.paletteYaml || DEFAULT_PALETTE_YAML; - yamlEditor.value = yamlStr; + const yamlStr = data.paletteYaml || DEFAULT_PALETTE_YAML; + yamlEditor.value = yamlStr; - toggleEnabled.checked = data.enabled !== false; + toggleEnabled.checked = data.enabled !== false; - const intensityVal = typeof data.intensity === 'number' ? data.intensity : 100; - intensitySlider.value = String(intensityVal); - intensityValue.textContent = String(intensityVal); + const intensityVal = typeof data.intensity === 'number' ? data.intensity : 100; + intensitySlider.value = String(intensityVal); + intensityValue.textContent = String(intensityVal); - // Parse and render - parseAndRender(yamlStr); + // Parse and render + parseAndRender(yamlStr); - // Render disabled sites - renderDisabledSites(data.disabledSites || []); + // Render disabled sites + renderDisabledSites(data.disabledSites || []); - // Attach event listeners - attachListeners(); + // Attach event listeners + attachListeners(); } init(); @@ -81,190 +81,190 @@ init(); // ─── Event Listeners ────────────────────────────────────────────────────────── function attachListeners(): void { - // Save button - btnSave.addEventListener('click', async () => { - const yaml = yamlEditor.value; - const result = parsePalette(yaml); + // Save button + btnSave.addEventListener('click', async () => { + const yaml = yamlEditor.value; + const result = parsePalette(yaml); - if (!result.ok) { - setStatus('error', `Validation failed: ${result.errors[0]}`); - return; - } + if (!result.ok) { + setStatus('error', `Validation failed: ${result.errors[0]}`); + return; + } - await chrome.storage.sync.set({ paletteYaml: yaml }); - setStatus('success', '✓ Palette saved and applied.'); - parseAndRender(yaml); - }); + await chrome.storage.sync.set({ paletteYaml: yaml }); + setStatus('success', '✓ Palette saved and applied.'); + parseAndRender(yaml); + }); - // Reset button - btnReset.addEventListener('click', async () => { - yamlEditor.value = DEFAULT_PALETTE_YAML; - await chrome.storage.sync.set({ paletteYaml: DEFAULT_PALETTE_YAML }); - setStatus('success', '✓ Reset to default Dracula palette.'); - parseAndRender(DEFAULT_PALETTE_YAML); - }); + // Reset button + btnReset.addEventListener('click', async () => { + yamlEditor.value = DEFAULT_PALETTE_YAML; + await chrome.storage.sync.set({ paletteYaml: DEFAULT_PALETTE_YAML }); + setStatus('success', '✓ Reset to default Dracula palette.'); + parseAndRender(DEFAULT_PALETTE_YAML); + }); - // Validate button - btnValidate.addEventListener('click', () => { - const result = parsePalette(yamlEditor.value); - if (result.ok) { - setStatus('success', `✓ Valid palette: "${result.palette.name}" by ${result.palette.author}`); - parseAndRender(yamlEditor.value); - } else { - setStatus('error', `✗ ${result.errors.join('; ')}`); - } - }); + // Validate button + btnValidate.addEventListener('click', () => { + const result = parsePalette(yamlEditor.value); + if (result.ok) { + setStatus('success', `✓ Valid palette: "${result.palette.name}" by ${result.palette.author}`); + parseAndRender(yamlEditor.value); + } else { + setStatus('error', `✗ ${result.errors.join('; ')}`); + } + }); - // Enabled toggle - toggleEnabled.addEventListener('change', async () => { - const val = toggleEnabled.checked; - await chrome.storage.sync.set({ enabled: val }); - }); + // Enabled toggle + toggleEnabled.addEventListener('change', async () => { + const val = toggleEnabled.checked; + await chrome.storage.sync.set({ enabled: val }); + }); - // Intensity slider - intensitySlider.addEventListener('input', () => { - intensityValue.textContent = intensitySlider.value; - }); - intensitySlider.addEventListener('change', async () => { - const val = parseInt(intensitySlider.value, 10); - await chrome.storage.sync.set({ intensity: val }); - }); + // Intensity slider + intensitySlider.addEventListener('input', () => { + intensityValue.textContent = intensitySlider.value; + }); + intensitySlider.addEventListener('change', async () => { + const val = parseInt(intensitySlider.value, 10); + await chrome.storage.sync.set({ intensity: val }); + }); - // Live YAML editing — debounced validation - let debounce: ReturnType; - yamlEditor.addEventListener('input', () => { - clearTimeout(debounce); - debounce = setTimeout(() => { - const result = parsePalette(yamlEditor.value); - if (result.ok) { - setStatus('success', `Valid — "${result.palette.name}"`); - parseAndRender(yamlEditor.value); - } else { - setStatus('error', result.errors[0]); - } - }, 500); - }); + // Live YAML editing — debounced validation + let debounce: ReturnType; + yamlEditor.addEventListener('input', () => { + clearTimeout(debounce); + debounce = setTimeout(() => { + const result = parsePalette(yamlEditor.value); + if (result.ok) { + setStatus('success', `Valid — "${result.palette.name}"`); + parseAndRender(yamlEditor.value); + } else { + setStatus('error', result.errors[0]); + } + }, 500); + }); - // Tab key in textarea inserts spaces instead of switching focus - yamlEditor.addEventListener('keydown', (e) => { - if (e.key === 'Tab') { - e.preventDefault(); - const start = yamlEditor.selectionStart; - const end = yamlEditor.selectionEnd; - yamlEditor.value = - yamlEditor.value.substring(0, start) + - ' ' + - yamlEditor.value.substring(end); - yamlEditor.selectionStart = yamlEditor.selectionEnd = start + 2; - } - }); + // Tab key in textarea inserts spaces instead of switching focus + yamlEditor.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = yamlEditor.selectionStart; + const end = yamlEditor.selectionEnd; + yamlEditor.value = + yamlEditor.value.substring(0, start) + + ' ' + + yamlEditor.value.substring(end); + yamlEditor.selectionStart = yamlEditor.selectionEnd = start + 2; + } + }); } // ─── Status Messages ────────────────────────────────────────────────────────── function setStatus(type: 'error' | 'success', message: string): void { - editorStatus.className = `editor-status ${type}`; - editorStatus.textContent = message; + editorStatus.className = `editor-status ${type}`; + editorStatus.textContent = message; } // ─── Parse & Render ─────────────────────────────────────────────────────────── function parseAndRender(yamlStr: string): void { - const result = parsePalette(yamlStr); - if (!result.ok) return; + const result = parsePalette(yamlStr); + if (!result.ok) return; - currentPalette = result.palette; - currentTheme = result.theme; + currentPalette = result.palette; + currentTheme = result.theme; - renderColorGrid(result.palette); - renderSemanticGrid(result.theme); - renderContrastDiagnostics(result.theme); - renderPreview(result.palette, result.theme); + renderColorGrid(result.palette); + renderSemanticGrid(result.theme); + renderContrastDiagnostics(result.theme); + renderPreview(result.palette, result.theme); } // ─── Color Grid (16 palette colors) ────────────────────────────────────────── function renderColorGrid(palette: GoghPalette): void { - colorGrid.innerHTML = ''; + colorGrid.innerHTML = ''; - // Show 16 palette colors + background + foreground - const allColors = [ - ...palette.colors.map((hex, i) => ({ hex, label: `color_${String(i + 1).padStart(2, '0')}` })), - ]; + // Show 16 palette colors + background + foreground + const allColors = [ + ...palette.colors.map((hex, i) => ({ hex, label: `color_${String(i + 1).padStart(2, '0')}` })), + ]; - for (const { hex, label } of allColors) { - const swatch = document.createElement('div'); - swatch.className = 'color-swatch'; - swatch.style.backgroundColor = hex; - swatch.innerHTML = `${label}: ${hex}`; - colorGrid.appendChild(swatch); - } + for (const { hex, label } of allColors) { + const swatch = document.createElement('div'); + swatch.className = 'color-swatch'; + swatch.style.backgroundColor = hex; + swatch.innerHTML = `${label}: ${hex}`; + colorGrid.appendChild(swatch); + } } // ─── Semantic Grid ──────────────────────────────────────────────────────────── function renderSemanticGrid(theme: SemanticTheme): void { - semanticGrid.innerHTML = ''; + semanticGrid.innerHTML = ''; - const entries: [string, string][] = [ - ['Background', theme.background], - ['Foreground', theme.foreground], - ['Surface', theme.surface], - ['Surface Alt', theme.surfaceAlt], - ['Accent 1', theme.accentPrimary], - ['Accent 2', theme.accentSecondary], - ['Error', theme.error], - ['Warning', theme.warning], - ['Success', theme.success], - ['Info', theme.info], - ['Muted', theme.muted], - ['Border', theme.border], - ]; + const entries: [string, string][] = [ + ['Background', theme.background], + ['Foreground', theme.foreground], + ['Surface', theme.surface], + ['Surface Alt', theme.surfaceAlt], + ['Accent 1', theme.accentPrimary], + ['Accent 2', theme.accentSecondary], + ['Error', theme.error], + ['Warning', theme.warning], + ['Success', theme.success], + ['Info', theme.info], + ['Muted', theme.muted], + ['Border', theme.border], + ]; - for (const [name, hex] of entries) { - const swatch = document.createElement('div'); - swatch.className = 'semantic-swatch'; - swatch.style.backgroundColor = hex; + for (const [name, hex] of entries) { + const swatch = document.createElement('div'); + swatch.className = 'semantic-swatch'; + swatch.style.backgroundColor = hex; - // Determine text color for readability - const lch = hexToOklch(hex); - swatch.style.color = lch.L > 0.5 ? '#000000' : '#ffffff'; - swatch.textContent = name; - swatch.title = hex; + // Determine text color for readability + const lch = hexToOklch(hex); + swatch.style.color = lch.L > 0.5 ? '#000000' : '#ffffff'; + swatch.textContent = name; + swatch.title = hex; - semanticGrid.appendChild(swatch); - } + semanticGrid.appendChild(swatch); + } } // ─── Contrast Diagnostics ───────────────────────────────────────────────────── function renderContrastDiagnostics(theme: SemanticTheme): void { - contrastBody.innerHTML = ''; + contrastBody.innerHTML = ''; - // Key contrast combinations to check - const pairs: [string, string, string, string][] = [ - ['Foreground / Background', theme.foreground, theme.background, 'text'], - ['Foreground / Surface', theme.foreground, theme.surface, 'text'], - ['Accent 1 / Background', theme.accentPrimary, theme.background, 'text'], - ['Accent 2 / Background', theme.accentSecondary, theme.background, 'text'], - ['Error / Background', theme.error, theme.background, 'text'], - ['Warning / Background', theme.warning, theme.background, 'text'], - ['Success / Background', theme.success, theme.background, 'text'], - ['Info / Background', theme.info, theme.background, 'text'], - ['Muted / Background', theme.muted, theme.background, 'text'], - ['Foreground / Surface Alt', theme.foreground, theme.surfaceAlt, 'text'], - ]; + // Key contrast combinations to check + const pairs: [string, string, string, string][] = [ + ['Foreground / Background', theme.foreground, theme.background, 'text'], + ['Foreground / Surface', theme.foreground, theme.surface, 'text'], + ['Accent 1 / Background', theme.accentPrimary, theme.background, 'text'], + ['Accent 2 / Background', theme.accentSecondary, theme.background, 'text'], + ['Error / Background', theme.error, theme.background, 'text'], + ['Warning / Background', theme.warning, theme.background, 'text'], + ['Success / Background', theme.success, theme.background, 'text'], + ['Info / Background', theme.info, theme.background, 'text'], + ['Muted / Background', theme.muted, theme.background, 'text'], + ['Foreground / Surface Alt', theme.foreground, theme.surfaceAlt, 'text'], + ]; - for (const [label, fg, bg, _type] of pairs) { - const fgLum = relativeLuminance(hexToSrgb(fg)); - const bgLum = relativeLuminance(hexToSrgb(bg)); - const ratio = contrastRatio(fgLum, bgLum); + for (const [label, fg, bg, _type] of pairs) { + const fgLum = relativeLuminance(hexToSrgb(fg)); + const bgLum = relativeLuminance(hexToSrgb(bg)); + const ratio = contrastRatio(fgLum, bgLum); - const passAA = ratio >= 4.5; - const passAAA = ratio >= 7; + const passAA = ratio >= 4.5; + const passAAA = ratio >= 7; - const row = document.createElement('tr'); - row.innerHTML = ` + const row = document.createElement('tr'); + row.innerHTML = ` @@ -274,14 +274,14 @@ function renderContrastDiagnostics(theme: SemanticTheme): void { ${passAA ? 'Pass' : 'Fail'} ${passAAA ? 'Pass' : 'Fail'} `; - contrastBody.appendChild(row); - } + contrastBody.appendChild(row); + } } // ─── Live Preview ───────────────────────────────────────────────────────────── function renderPreview(palette: GoghPalette, theme: SemanticTheme): void { - const previewHTML = ` + const previewHTML = ` @@ -309,63 +309,63 @@ function renderPreview(palette: GoghPalette, theme: SemanticTheme): void { `; - previewFrame.srcdoc = previewHTML; + previewFrame.srcdoc = previewHTML; } // ─── Disabled Sites List ────────────────────────────────────────────────────── function renderDisabledSites(sites: string[]): void { - disabledSitesList.innerHTML = ''; + disabledSitesList.innerHTML = ''; - if (sites.length === 0) { - disabledSitesList.innerHTML = '
  • No sites disabled.
  • '; - return; - } + if (sites.length === 0) { + disabledSitesList.innerHTML = '
  • No sites disabled.
  • '; + return; + } - for (const site of sites) { - const li = document.createElement('li'); - li.innerHTML = ` + for (const site of sites) { + const li = document.createElement('li'); + li.innerHTML = ` ${site} `; - disabledSitesList.appendChild(li); - } + disabledSitesList.appendChild(li); + } - // Attach remove handlers - disabledSitesList.querySelectorAll('.remove-site').forEach((btn) => { - btn.addEventListener('click', async () => { - const site = (btn as HTMLElement).dataset.site!; - const data = await chrome.storage.sync.get(['disabledSites']); - const current: string[] = data.disabledSites || []; - const updated = current.filter((s) => s !== site); - await chrome.storage.sync.set({ disabledSites: updated }); - renderDisabledSites(updated); + // Attach remove handlers + disabledSitesList.querySelectorAll('.remove-site').forEach((btn) => { + btn.addEventListener('click', async () => { + const site = (btn as HTMLElement).dataset.site!; + const data = await chrome.storage.sync.get(['disabledSites']); + const current: string[] = data.disabledSites || []; + const updated = current.filter((s) => s !== site); + await chrome.storage.sync.set({ disabledSites: updated }); + renderDisabledSites(updated); + }); }); - }); } // ─── Listen for external changes ────────────────────────────────────────────── chrome.storage.onChanged.addListener((changes, area) => { - if (area !== 'sync') return; + if (area !== 'sync') return; - if (changes.disabledSites) { - renderDisabledSites(changes.disabledSites.newValue || []); - } + if (changes.disabledSites) { + renderDisabledSites(changes.disabledSites.newValue || []); + } - if (changes.enabled) { - toggleEnabled.checked = changes.enabled.newValue !== false; - } + if (changes.enabled) { + toggleEnabled.checked = changes.enabled.newValue !== false; + } - if (changes.intensity) { - const val = changes.intensity.newValue ?? 100; - intensitySlider.value = String(val); - intensityValue.textContent = String(val); - } + if (changes.intensity) { + const val = changes.intensity.newValue ?? 100; + intensitySlider.value = String(val); + intensityValue.textContent = String(val); + } - if (changes.paletteYaml) { - const yaml = changes.paletteYaml.newValue || DEFAULT_PALETTE_YAML; - yamlEditor.value = yaml; - parseAndRender(yaml); - } + if (changes.paletteYaml) { + const yaml = changes.paletteYaml.newValue || DEFAULT_PALETTE_YAML; + yamlEditor.value = yaml; + parseAndRender(yaml); + } }); diff --git a/gogh-theme-engine/src/themeEngine.ts b/gogh-theme-engine/src/themeEngine.ts index 0755e16..6802d92 100644 --- a/gogh-theme-engine/src/themeEngine.ts +++ b/gogh-theme-engine/src/themeEngine.ts @@ -15,39 +15,39 @@ import * as yaml from 'js-yaml'; import { - hexToOklch, - oklchToHex, - isNeutral, - classifyHue, - contrastRatio, - relativeLuminance, - hexToSrgb, - type OKLCH, - type SemanticTheme, - type HueBucket, + hexToOklch, + oklchToHex, + isNeutral, + classifyHue, + contrastRatio, + relativeLuminance, + hexToSrgb, + type OKLCH, + type SemanticTheme, + type HueBucket, } from './color'; // ─── Types ──────────────────────────────────────────────────────────────────── export interface GoghPalette { - name: string; - author: string; - variant: 'light' | 'dark'; - colors: string[]; // color_01 … color_16 - background: string; - foreground: string; - cursor: string; + name: string; + author: string; + variant: 'light' | 'dark'; + colors: string[]; // color_01 … color_16 + background: string; + foreground: string; + cursor: string; } export interface ParseResult { - ok: true; - palette: GoghPalette; - theme: SemanticTheme; + ok: true; + palette: GoghPalette; + theme: SemanticTheme; } export interface ParseError { - ok: false; - errors: string[]; + ok: false; + errors: string[]; } // ─── Validation ─────────────────────────────────────────────────────────────── @@ -55,7 +55,7 @@ export interface ParseError { const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function isValidHex(s: unknown): s is string { - return typeof s === 'string' && HEX_RE.test(s); + return typeof s === 'string' && HEX_RE.test(s); } // ─── YAML Parsing ───────────────────────────────────────────────────────────── @@ -64,52 +64,52 @@ function isValidHex(s: unknown): s is string { * Parse a Gogh YAML palette string into a validated GoghPalette and derived SemanticTheme. */ export function parsePalette(yamlString: string): ParseResult | ParseError { - let doc: Record; - try { - doc = yaml.load(yamlString) as Record; - } catch (e: unknown) { - return { ok: false, errors: [`YAML parse error: ${(e as Error).message}`] }; - } - - if (!doc || typeof doc !== 'object') { - return { ok: false, errors: ['YAML must be a mapping at the top level.'] }; - } - - const errors: string[] = []; - - // Validate required fields - const name = typeof doc.name === 'string' ? doc.name : 'Unnamed'; - const author = typeof doc.author === 'string' ? doc.author : 'Unknown'; - const variant = doc.variant === 'light' ? 'light' : 'dark'; // default to dark - - // Extract 16 colors - const colors: string[] = []; - for (let i = 1; i <= 16; i++) { - const key = `color_${i.toString().padStart(2, '0')}`; - const val = doc[key]; - if (isValidHex(val)) { - colors.push(val); - } else { - errors.push(`${key}: invalid or missing hex color (got ${JSON.stringify(val)})`); - colors.push('#000000'); // fallback + let doc: Record; + try { + doc = yaml.load(yamlString) as Record; + } catch (e: unknown) { + return { ok: false, errors: [`YAML parse error: ${(e as Error).message}`] }; } - } - // Background, foreground, cursor - const background = isValidHex(doc.background) ? doc.background : '#1e1e1e'; - const foreground = isValidHex(doc.foreground) ? doc.foreground : '#d4d4d4'; - const cursor = isValidHex(doc.cursor) ? doc.cursor : foreground; + if (!doc || typeof doc !== 'object') { + return { ok: false, errors: ['YAML must be a mapping at the top level.'] }; + } - if (!isValidHex(doc.background)) errors.push(`background: invalid hex`); - if (!isValidHex(doc.foreground)) errors.push(`foreground: invalid hex`); + const errors: string[] = []; - if (errors.length > 0) { - return { ok: false, errors }; - } + // Validate required fields + const name = typeof doc.name === 'string' ? doc.name : 'Unnamed'; + const author = typeof doc.author === 'string' ? doc.author : 'Unknown'; + const variant = doc.variant === 'light' ? 'light' : 'dark'; // default to dark - const palette: GoghPalette = { name, author, variant, colors, background, foreground, cursor }; - const theme = deriveSemanticTheme(palette); - return { ok: true, palette, theme }; + // Extract 16 colors + const colors: string[] = []; + for (let i = 1; i <= 16; i++) { + const key = `color_${i.toString().padStart(2, '0')}`; + const val = doc[key]; + if (isValidHex(val)) { + colors.push(val); + } else { + errors.push(`${key}: invalid or missing hex color (got ${JSON.stringify(val)})`); + colors.push('#000000'); // fallback + } + } + + // Background, foreground, cursor + const background = isValidHex(doc.background) ? doc.background : '#1e1e1e'; + const foreground = isValidHex(doc.foreground) ? doc.foreground : '#d4d4d4'; + const cursor = isValidHex(doc.cursor) ? doc.cursor : foreground; + + if (!isValidHex(doc.background)) errors.push(`background: invalid hex`); + if (!isValidHex(doc.foreground)) errors.push(`foreground: invalid hex`); + + if (errors.length > 0) { + return { ok: false, errors }; + } + + const palette: GoghPalette = { name, author, variant, colors, background, foreground, cursor }; + const theme = deriveSemanticTheme(palette); + return { ok: true, palette, theme }; } // ─── Semantic Theme Derivation ──────────────────────────────────────────────── @@ -134,89 +134,89 @@ export function parsePalette(yamlString: string): ParseResult | ParseError { * (most saturated) since that's typically the "primary" representative. */ export function deriveSemanticTheme(palette: GoghPalette): SemanticTheme { - const bgLch = hexToOklch(palette.background); - const fgLch = hexToOklch(palette.foreground); - const isDark = bgLch.L < fgLch.L; + const bgLch = hexToOklch(palette.background); + const fgLch = hexToOklch(palette.foreground); + const isDark = bgLch.L < fgLch.L; - // Analyze all 16 palette colors - interface ColorInfo { - hex: string; - lch: OKLCH; - hue: HueBucket | null; - luminance: number; - } - - const analyzed: ColorInfo[] = palette.colors.map((hex) => { - const lch = hexToOklch(hex); - const srgb = hexToSrgb(hex); - return { - hex, - lch, - hue: classifyHue(lch), - luminance: relativeLuminance(srgb), - }; - }); - - // Helper: find best color by hue bucket, preferring highest chroma - function findByHue(bucket: HueBucket | HueBucket[]): string { - const buckets = Array.isArray(bucket) ? bucket : [bucket]; - const candidates = analyzed.filter((c) => c.hue !== null && buckets.includes(c.hue)); - if (candidates.length === 0) return palette.foreground; // fallback - // Prefer higher chroma - candidates.sort((a, b) => b.lch.C - a.lch.C); - return candidates[0].hex; - } - - // Helper: find a muted/neutral color - function findMuted(): string { - const neutrals = analyzed.filter((c) => isNeutral(c.lch)); - if (neutrals.length === 0) { - // No neutrals; synthesize one from background by adjusting lightness - const mutedLch: OKLCH = { - L: isDark ? bgLch.L + 0.25 : bgLch.L - 0.25, - C: 0.01, - h: bgLch.h, - }; - return oklchToHex(mutedLch); + // Analyze all 16 palette colors + interface ColorInfo { + hex: string; + lch: OKLCH; + hue: HueBucket | null; + luminance: number; } - // Pick the one closest to mid-lightness - neutrals.sort((a, b) => Math.abs(a.lch.L - 0.5) - Math.abs(b.lch.L - 0.5)); - return neutrals[0].hex; - } - // Derive surface colors from background by shifting lightness - const surfaceLch: OKLCH = { - L: isDark ? bgLch.L + 0.06 : bgLch.L - 0.06, - C: bgLch.C, - h: bgLch.h, - }; - const surfaceAltLch: OKLCH = { - L: isDark ? bgLch.L + 0.12 : bgLch.L - 0.12, - C: bgLch.C, - h: bgLch.h, - }; + const analyzed: ColorInfo[] = palette.colors.map((hex) => { + const lch = hexToOklch(hex); + const srgb = hexToSrgb(hex); + return { + hex, + lch, + hue: classifyHue(lch), + luminance: relativeLuminance(srgb), + }; + }); - // Border: slightly brighter/darker than surface - const borderLch: OKLCH = { - L: isDark ? bgLch.L + 0.18 : bgLch.L - 0.18, - C: Math.max(bgLch.C, 0.005), - h: bgLch.h, - }; + // Helper: find best color by hue bucket, preferring highest chroma + function findByHue(bucket: HueBucket | HueBucket[]): string { + const buckets = Array.isArray(bucket) ? bucket : [bucket]; + const candidates = analyzed.filter((c) => c.hue !== null && buckets.includes(c.hue)); + if (candidates.length === 0) return palette.foreground; // fallback + // Prefer higher chroma + candidates.sort((a, b) => b.lch.C - a.lch.C); + return candidates[0].hex; + } - return { - background: palette.background, - foreground: palette.foreground, - surface: oklchToHex(surfaceLch), - surfaceAlt: oklchToHex(surfaceAltLch), - accentPrimary: findByHue(['blue', 'cyan']), - accentSecondary: findByHue(['purple', 'magenta']), - error: findByHue(['red']), - warning: findByHue(['yellow', 'orange']), - success: findByHue(['green']), - info: findByHue(['cyan', 'blue']), - muted: findMuted(), - border: oklchToHex(borderLch), - }; + // Helper: find a muted/neutral color + function findMuted(): string { + const neutrals = analyzed.filter((c) => isNeutral(c.lch)); + if (neutrals.length === 0) { + // No neutrals; synthesize one from background by adjusting lightness + const mutedLch: OKLCH = { + L: isDark ? bgLch.L + 0.25 : bgLch.L - 0.25, + C: 0.01, + h: bgLch.h, + }; + return oklchToHex(mutedLch); + } + // Pick the one closest to mid-lightness + neutrals.sort((a, b) => Math.abs(a.lch.L - 0.5) - Math.abs(b.lch.L - 0.5)); + return neutrals[0].hex; + } + + // Derive surface colors from background by shifting lightness + const surfaceLch: OKLCH = { + L: isDark ? bgLch.L + 0.06 : bgLch.L - 0.06, + C: bgLch.C, + h: bgLch.h, + }; + const surfaceAltLch: OKLCH = { + L: isDark ? bgLch.L + 0.12 : bgLch.L - 0.12, + C: bgLch.C, + h: bgLch.h, + }; + + // Border: slightly brighter/darker than surface + const borderLch: OKLCH = { + L: isDark ? bgLch.L + 0.18 : bgLch.L - 0.18, + C: Math.max(bgLch.C, 0.005), + h: bgLch.h, + }; + + return { + background: palette.background, + foreground: palette.foreground, + surface: oklchToHex(surfaceLch), + surfaceAlt: oklchToHex(surfaceAltLch), + accentPrimary: findByHue(['blue', 'cyan']), + accentSecondary: findByHue(['purple', 'magenta']), + error: findByHue(['red']), + warning: findByHue(['yellow', 'orange']), + success: findByHue(['green']), + info: findByHue(['cyan', 'blue']), + muted: findMuted(), + border: oklchToHex(borderLch), + }; } // ─── Default Palette ────────────────────────────────────────────────────────── diff --git a/gogh-theme-engine/tsconfig.json b/gogh-theme-engine/tsconfig.json index cb51366..f022d34 100644 --- a/gogh-theme-engine/tsconfig.json +++ b/gogh-theme-engine/tsconfig.json @@ -1,23 +1,35 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "types": ["chrome"] - }, - "include": ["src/**/*.ts", "src/**/*.d.ts"], - "exclude": ["node_modules", "dist"] -} + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": [ + "chrome" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/gogh-theme-engine/vite.config.ts b/gogh-theme-engine/vite.config.ts index ee53132..b4c99a8 100644 --- a/gogh-theme-engine/vite.config.ts +++ b/gogh-theme-engine/vite.config.ts @@ -18,91 +18,91 @@ import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, rmSync } fr */ function chromeExtensionPlugin() { - return { - name: 'chrome-extension-iife', - async closeBundle() { - // Rebuild content.ts as a standalone IIFE bundle - console.log('[chrome-ext] Rebuilding content.js as IIFE...'); - await viteBuild({ - configFile: false, - build: { - outDir: resolve(__dirname, 'dist'), - emptyOutDir: false, // Don't wipe the rest of the build - lib: { - entry: resolve(__dirname, 'src/content.ts'), - name: 'GoghContent', - formats: ['iife'], - fileName: () => 'content.js', - }, - rollupOptions: { - output: { - // Ensure it overwrites the ESM version - inlineDynamicImports: true, - }, - }, - minify: false, - sourcemap: false, - target: 'chrome120', + return { + name: 'chrome-extension-iife', + async closeBundle() { + // Rebuild content.ts as a standalone IIFE bundle + console.log('[chrome-ext] Rebuilding content.js as IIFE...'); + await viteBuild({ + configFile: false, + build: { + outDir: resolve(__dirname, 'dist'), + emptyOutDir: false, // Don't wipe the rest of the build + lib: { + entry: resolve(__dirname, 'src/content.ts'), + name: 'GoghContent', + formats: ['iife'], + fileName: () => 'content.js', + }, + rollupOptions: { + output: { + // Ensure it overwrites the ESM version + inlineDynamicImports: true, + }, + }, + minify: false, + sourcemap: false, + target: 'chrome120', + }, + logLevel: 'warn', + }); + + // Fix options HTML path + const wrongPath = resolve(__dirname, 'dist/src/options/options.html'); + const correctDir = resolve(__dirname, 'dist/options'); + const correctPath = resolve(correctDir, 'options.html'); + + if (existsSync(wrongPath) && !existsSync(correctPath)) { + mkdirSync(correctDir, { recursive: true }); + const html = readFileSync(wrongPath, 'utf-8'); + // Fix script paths — point to the options.js at dist root + const fixedHtml = html + .replace(/src="[^"]*options\.js"/g, 'src="../options.js"') + .replace(/src="\.\.\/\.\.\/options\.js"/g, 'src="../options.js"'); + writeFileSync(correctPath, fixedHtml); + } + + // Clean up the misplaced src/ directory in dist + const distSrc = resolve(__dirname, 'dist/src'); + if (existsSync(distSrc)) { + rmSync(distSrc, { recursive: true, force: true }); + } + + // Clean up the chunks directory since content.js is now self-contained + // and background.js still needs its chunk import + console.log('[chrome-ext] Build complete.'); }, - logLevel: 'warn', - }); - - // Fix options HTML path - const wrongPath = resolve(__dirname, 'dist/src/options/options.html'); - const correctDir = resolve(__dirname, 'dist/options'); - const correctPath = resolve(correctDir, 'options.html'); - - if (existsSync(wrongPath) && !existsSync(correctPath)) { - mkdirSync(correctDir, { recursive: true }); - const html = readFileSync(wrongPath, 'utf-8'); - // Fix script paths — point to the options.js at dist root - const fixedHtml = html - .replace(/src="[^"]*options\.js"/g, 'src="../options.js"') - .replace(/src="\.\.\/\.\.\/options\.js"/g, 'src="../options.js"'); - writeFileSync(correctPath, fixedHtml); - } - - // Clean up the misplaced src/ directory in dist - const distSrc = resolve(__dirname, 'dist/src'); - if (existsSync(distSrc)) { - rmSync(distSrc, { recursive: true, force: true }); - } - - // Clean up the chunks directory since content.js is now self-contained - // and background.js still needs its chunk import - console.log('[chrome-ext] Build complete.'); - }, - }; + }; } export default defineConfig(({ mode }) => { - const isProd = mode === 'production'; + const isProd = mode === 'production'; - return { - plugins: [chromeExtensionPlugin()], - build: { - outDir: 'dist', - emptyOutDir: true, - rollupOptions: { - input: { - background: resolve(__dirname, 'src/background.ts'), - content: resolve(__dirname, 'src/content.ts'), - options: resolve(__dirname, 'src/options/options.html'), + return { + plugins: [chromeExtensionPlugin()], + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + background: resolve(__dirname, 'src/background.ts'), + content: resolve(__dirname, 'src/content.ts'), + options: resolve(__dirname, 'src/options/options.html'), + }, + output: { + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + minify: isProd ? 'esbuild' : false, + sourcemap: !isProd ? 'inline' : false, + target: 'chrome120', }, - output: { - entryFileNames: '[name].js', - chunkFileNames: 'chunks/[name].js', - assetFileNames: 'assets/[name].[ext]', + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, }, - }, - minify: isProd ? 'esbuild' : false, - sourcemap: !isProd ? 'inline' : false, - target: 'chrome120', - }, - resolve: { - alias: { - '@': resolve(__dirname, 'src'), - }, - }, - }; + }; });