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.
This commit is contained in:
2026-03-03 23:11:33 +05:30
parent 6cdc79e345
commit bcb48fd3a6
12 changed files with 2127 additions and 2022 deletions

View File

@@ -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"
}
}

View File

@@ -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": ["<all_urls>"],
"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": [
"<all_urls>"
],
"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"
}
}
}

View File

@@ -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, // 0100
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, // 0100
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;
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -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<ShadowRoot>();
* Called at document_start so we can inject styles before first paint.
*/
async function init(): Promise<void> {
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 <head> or <html> (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 <head> or <html> (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<string, string>
key: string,
declarations: Record<string, string>
): 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<string, string> = {};
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<string, string> = {};
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<string, string>): 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 <img> or <object> 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();
}

View File

@@ -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[];
}

View File

@@ -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<HTMLElement>();
// Deduplicate elements to process
const elementsToProcess = new Set<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);
}
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;
}
}

View File

@@ -1,399 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gogh Theme Engine — Options</title>
<style>
/* ─── Reset & Base ──────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gogh Theme Engine — Options</title>
<style>
/* ─── Reset & Base ──────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #1e1e2e;
--surface: #262637;
--surface-alt: #2e2e42;
--fg: #cdd6f4;
--fg-muted: #7f849c;
--accent: #89b4fa;
--accent-hover: #74c7ec;
--error: #f38ba8;
--success: #a6e3a1;
--warning: #f9e2af;
--border: #45475a;
--radius: 8px;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
:root {
--bg: #1e1e2e;
--surface: #262637;
--surface-alt: #2e2e42;
--fg: #cdd6f4;
--fg-muted: #7f849c;
--accent: #89b4fa;
--accent-hover: #74c7ec;
--error: #f38ba8;
--success: #a6e3a1;
--warning: #f9e2af;
--border: #45475a;
--radius: 8px;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html { background: var(--bg); color: var(--fg); font-family: var(--font-sans); }
body { min-height: 100vh; padding: 2rem; max-width: 1200px; margin: 0 auto; }
html {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--accent);
}
h2 {
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--fg);
}
p.subtitle {
color: var(--fg-muted);
margin-bottom: 2rem;
font-size: 0.95rem;
}
body {
min-height: 100vh;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
/* ─── Layout ──────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--accent);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
h2 {
font-size: 1.15rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--fg);
}
/* ─── YAML Editor ─────────────────────────────────────────── */
.editor-wrap {
position: relative;
}
#yaml-editor {
width: 100%;
min-height: 420px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.5;
resize: vertical;
tab-size: 2;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
#yaml-editor:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
p.subtitle {
color: var(--fg-muted);
margin-bottom: 2rem;
font-size: 0.95rem;
}
.editor-status {
margin-top: 0.5rem;
font-size: 0.8rem;
min-height: 1.4em;
}
.editor-status.error { color: var(--error); }
.editor-status.success { color: var(--success); }
/* ─── Layout ──────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
/* ─── 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; }
@media (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
}
}
/* ─── 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; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
/* 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); }
/* ─── YAML Editor ─────────────────────────────────────────── */
.editor-wrap {
position: relative;
}
/* 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;
}
#yaml-editor {
width: 100%;
min-height: 420px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.5;
resize: vertical;
tab-size: 2;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
/* ─── 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; }
#yaml-editor:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
/* 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;
}
.editor-status {
margin-top: 0.5rem;
font-size: 0.8rem;
min-height: 1.4em;
}
/* ─── 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); }
.editor-status.error {
color: var(--error);
}
/* ─── Preview Iframe ──────────────────────────────────────── */
#preview-frame {
width: 100%;
height: 300px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: white;
}
.editor-status.success {
color: var(--success);
}
/* ─── 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;
}
</style>
/* ─── 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;
}
</style>
</head>
<body>
<h1>🎨 Gogh Theme Engine</h1>
<p class="subtitle">Apply Gogh terminal palettes to any website using perceptual OKLCH color remapping.</p>
<h1>🎨 Gogh Theme Engine</h1>
<p class="subtitle">Apply Gogh terminal palettes to any website using perceptual OKLCH color remapping.</p>
<div class="grid">
<!-- Left column: YAML Editor -->
<div>
<div class="card">
<h2>Palette (YAML)</h2>
<div class="editor-wrap">
<textarea id="yaml-editor" spellcheck="false"></textarea>
<div id="editor-status" class="editor-status"></div>
</div>
<div class="btn-row">
<button id="btn-save" class="btn primary">💾 Save & Apply</button>
<button id="btn-reset" class="btn">↺ Reset to Default</button>
<button id="btn-validate" class="btn">✓ Validate</button>
</div>
</div>
<div class="grid">
<!-- Left column: YAML Editor -->
<div>
<div class="card">
<h2>Palette (YAML)</h2>
<div class="editor-wrap">
<textarea id="yaml-editor" spellcheck="false"></textarea>
<div id="editor-status" class="editor-status"></div>
</div>
<div class="btn-row">
<button id="btn-save" class="btn primary">💾 Save & Apply</button>
<button id="btn-reset" class="btn">↺ Reset to Default</button>
<button id="btn-validate" class="btn">✓ Validate</button>
</div>
</div>
<!-- Disabled Sites -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Disabled Sites</h2>
<ul id="disabled-sites" class="site-list">
<li class="empty-state">No sites disabled.</li>
</ul>
</div>
<!-- Disabled Sites -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Disabled Sites</h2>
<ul id="disabled-sites" class="site-list">
<li class="empty-state">No sites disabled.</li>
</ul>
</div>
</div>
<!-- Right column: Controls & Preview -->
<div>
<div class="card">
<h2>Controls</h2>
<div class="control-group">
<div class="toggle-row">
<span class="toggle-label">Enable Theming</span>
<label class="switch">
<input type="checkbox" id="toggle-enabled" checked>
<span class="slider"></span>
</label>
</div>
</div>
<div class="control-group">
<label>
Intensity: <span class="range-value" id="intensity-value">100</span>%
</label>
<input type="range" id="intensity-slider" min="0" max="100" value="100">
</div>
</div>
<!-- Color Preview -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Palette Colors</h2>
<div id="color-grid" class="color-grid"></div>
<h2 style="margin-top: 1rem;">Semantic Theme</h2>
<div id="semantic-grid" class="semantic-grid"></div>
</div>
<!-- Contrast Diagnostics -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Contrast Diagnostics</h2>
<table class="contrast-table">
<thead>
<tr>
<th>Combination</th>
<th>Ratio</th>
<th>AA</th>
<th>AAA</th>
</tr>
</thead>
<tbody id="contrast-body"></tbody>
</table>
</div>
<!-- Live Preview -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Live Preview</h2>
<iframe id="preview-frame" sandbox="allow-same-origin" srcdoc=""></iframe>
</div>
</div>
</div>
<!-- Right column: Controls & Preview -->
<div>
<div class="card">
<h2>Controls</h2>
<div class="control-group">
<div class="toggle-row">
<span class="toggle-label">Enable Theming</span>
<label class="switch">
<input type="checkbox" id="toggle-enabled" checked>
<span class="slider"></span>
</label>
</div>
</div>
<div class="control-group">
<label>
Intensity: <span class="range-value" id="intensity-value">100</span>%
</label>
<input type="range" id="intensity-slider" min="0" max="100" value="100">
</div>
</div>
<!-- Color Preview -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Palette Colors</h2>
<div id="color-grid" class="color-grid"></div>
<h2 style="margin-top: 1rem;">Semantic Theme</h2>
<div id="semantic-grid" class="semantic-grid"></div>
</div>
<!-- Contrast Diagnostics -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Contrast Diagnostics</h2>
<table class="contrast-table">
<thead>
<tr>
<th>Combination</th>
<th>Ratio</th>
<th>AA</th>
<th>AAA</th>
</tr>
</thead>
<tbody id="contrast-body"></tbody>
</table>
</div>
<!-- Live Preview -->
<div class="card" style="margin-top: 1.5rem;">
<h2>Live Preview</h2>
<iframe id="preview-frame" sandbox="allow-same-origin" srcdoc=""></iframe>
</div>
</div>
</div>
<script type="module" src="./options.ts"></script>
<script type="module" src="./options.ts"></script>
</body>
</html>
</html>

View File

@@ -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<void> {
// 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<typeof setTimeout>;
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<typeof setTimeout>;
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 = `<span class="tooltip">${label}: ${hex}</span>`;
colorGrid.appendChild(swatch);
}
for (const { hex, label } of allColors) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.backgroundColor = hex;
swatch.innerHTML = `<span class="tooltip">${label}: ${hex}</span>`;
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 = `
<td>
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:${fg};border:1px solid #555;vertical-align:middle;margin-right:4px;"></span>
<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:${bg};border:1px solid #555;vertical-align:middle;margin-right:6px;"></span>
@@ -274,14 +274,14 @@ function renderContrastDiagnostics(theme: SemanticTheme): void {
<td class="${passAA ? 'contrast-pass' : 'contrast-fail'}">${passAA ? 'Pass' : 'Fail'}</td>
<td class="${passAAA ? 'contrast-pass' : 'contrast-fail'}">${passAAA ? 'Pass' : 'Fail'}</td>
`;
contrastBody.appendChild(row);
}
contrastBody.appendChild(row);
}
}
// ─── Live Preview ─────────────────────────────────────────────────────────────
function renderPreview(palette: GoghPalette, theme: SemanticTheme): void {
const previewHTML = `
const previewHTML = `
<!DOCTYPE html>
<html style="background:${theme.background};color:${theme.foreground};font-family:-apple-system,sans-serif;">
<body style="padding:1.5rem;margin:0;">
@@ -309,63 +309,63 @@ function renderPreview(palette: GoghPalette, theme: SemanticTheme): void {
</html>
`;
previewFrame.srcdoc = previewHTML;
previewFrame.srcdoc = previewHTML;
}
// ─── Disabled Sites List ──────────────────────────────────────────────────────
function renderDisabledSites(sites: string[]): void {
disabledSitesList.innerHTML = '';
disabledSitesList.innerHTML = '';
if (sites.length === 0) {
disabledSitesList.innerHTML = '<li class="empty-state">No sites disabled.</li>';
return;
}
if (sites.length === 0) {
disabledSitesList.innerHTML = '<li class="empty-state">No sites disabled.</li>';
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 = `
<span>${site}</span>
<button class="remove-site" data-site="${site}" title="Re-enable this site">✕</button>
`;
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);
}
});

View File

@@ -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<string, unknown>;
try {
doc = yaml.load(yamlString) as Record<string, unknown>;
} 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<string, unknown>;
try {
doc = yaml.load(yamlString) as Record<string, unknown>;
} 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 ──────────────────────────────────────────────────────────

View File

@@ -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"
]
}

View File

@@ -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'),
},
},
};
};
});