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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,16 @@ import { DEFAULT_PALETTE_YAML } from './themeEngine';
|
||||
// ─── Installation / Update ────────────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === 'install') {
|
||||
// Set default settings
|
||||
await chrome.storage.sync.set({
|
||||
paletteYaml: DEFAULT_PALETTE_YAML,
|
||||
enabled: true,
|
||||
intensity: 100, // 0–100
|
||||
disabledSites: [] as string[],
|
||||
});
|
||||
console.log('[Gogh Theme Engine] Extension installed with default Dracula palette.');
|
||||
}
|
||||
if (details.reason === 'install') {
|
||||
// Set default settings
|
||||
await chrome.storage.sync.set({
|
||||
paletteYaml: DEFAULT_PALETTE_YAML,
|
||||
enabled: true,
|
||||
intensity: 100, // 0–100
|
||||
disabledSites: [] as string[],
|
||||
});
|
||||
console.log('[Gogh Theme Engine] Extension installed with default Dracula palette.');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Action Button Click ──────────────────────────────────────────────────────
|
||||
@@ -33,62 +33,62 @@ chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
* If the site is in the disabled list, it's removed. If not, it's added.
|
||||
*/
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (!tab.id || !tab.url) return;
|
||||
if (!tab.id || !tab.url) return;
|
||||
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
const hostname = url.hostname;
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
const hostname = url.hostname;
|
||||
|
||||
if (!hostname) return;
|
||||
if (!hostname) return;
|
||||
|
||||
const data = await chrome.storage.sync.get(['disabledSites', 'enabled']);
|
||||
const disabledSites: string[] = data.disabledSites || [];
|
||||
const isGloballyEnabled = data.enabled !== false;
|
||||
const data = await chrome.storage.sync.get(['disabledSites', 'enabled']);
|
||||
const disabledSites: string[] = data.disabledSites || [];
|
||||
const isGloballyEnabled = data.enabled !== false;
|
||||
|
||||
if (!isGloballyEnabled) {
|
||||
// If globally disabled, re-enable
|
||||
await chrome.storage.sync.set({ enabled: true });
|
||||
// Notify content script
|
||||
chrome.tabs.sendMessage(tab.id!, { type: 'reprocess' }).catch(() => {});
|
||||
updateBadge(tab.id!, true);
|
||||
return;
|
||||
if (!isGloballyEnabled) {
|
||||
// If globally disabled, re-enable
|
||||
await chrome.storage.sync.set({ enabled: true });
|
||||
// Notify content script
|
||||
chrome.tabs.sendMessage(tab.id!, { type: 'reprocess' }).catch(() => { });
|
||||
updateBadge(tab.id!, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const siteIndex = disabledSites.indexOf(hostname);
|
||||
if (siteIndex >= 0) {
|
||||
// Currently disabled for this site → enable
|
||||
disabledSites.splice(siteIndex, 1);
|
||||
await chrome.storage.sync.set({ disabledSites });
|
||||
// Reload the tab to apply changes cleanly
|
||||
chrome.tabs.reload(tab.id!);
|
||||
updateBadge(tab.id!, true);
|
||||
} else {
|
||||
// Currently enabled → disable for this site
|
||||
disabledSites.push(hostname);
|
||||
await chrome.storage.sync.set({ disabledSites });
|
||||
// Notify content script to clean up
|
||||
chrome.tabs.sendMessage(tab.id!, { type: 'toggle' }).catch(() => {
|
||||
// If content script isn't responding, reload
|
||||
chrome.tabs.reload(tab.id!);
|
||||
});
|
||||
updateBadge(tab.id!, false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Gogh Theme Engine] Action click error:', e);
|
||||
}
|
||||
|
||||
const siteIndex = disabledSites.indexOf(hostname);
|
||||
if (siteIndex >= 0) {
|
||||
// Currently disabled for this site → enable
|
||||
disabledSites.splice(siteIndex, 1);
|
||||
await chrome.storage.sync.set({ disabledSites });
|
||||
// Reload the tab to apply changes cleanly
|
||||
chrome.tabs.reload(tab.id!);
|
||||
updateBadge(tab.id!, true);
|
||||
} else {
|
||||
// Currently enabled → disable for this site
|
||||
disabledSites.push(hostname);
|
||||
await chrome.storage.sync.set({ disabledSites });
|
||||
// Notify content script to clean up
|
||||
chrome.tabs.sendMessage(tab.id!, { type: 'toggle' }).catch(() => {
|
||||
// If content script isn't responding, reload
|
||||
chrome.tabs.reload(tab.id!);
|
||||
});
|
||||
updateBadge(tab.id!, false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Gogh Theme Engine] Action click error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Badge ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function updateBadge(tabId: number, isEnabled: boolean): void {
|
||||
chrome.action.setBadgeText({
|
||||
tabId,
|
||||
text: isEnabled ? '' : 'OFF',
|
||||
});
|
||||
chrome.action.setBadgeBackgroundColor({
|
||||
tabId,
|
||||
color: isEnabled ? '#50fa7b' : '#ff5555',
|
||||
});
|
||||
chrome.action.setBadgeText({
|
||||
tabId,
|
||||
text: isEnabled ? '' : 'OFF',
|
||||
});
|
||||
chrome.action.setBadgeBackgroundColor({
|
||||
tabId,
|
||||
color: isEnabled ? '#50fa7b' : '#ff5555',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tab Activation ───────────────────────────────────────────────────────────
|
||||
@@ -98,22 +98,22 @@ function updateBadge(tabId: number, isEnabled: boolean): void {
|
||||
* site's enable/disable state.
|
||||
*/
|
||||
chrome.tabs.onActivated.addListener(async (info) => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(info.tabId);
|
||||
if (!tab.url) return;
|
||||
try {
|
||||
const tab = await chrome.tabs.get(info.tabId);
|
||||
if (!tab.url) return;
|
||||
|
||||
const url = new URL(tab.url);
|
||||
const hostname = url.hostname;
|
||||
const url = new URL(tab.url);
|
||||
const hostname = url.hostname;
|
||||
|
||||
const data = await chrome.storage.sync.get(['disabledSites', 'enabled']);
|
||||
const disabledSites: string[] = data.disabledSites || [];
|
||||
const isGloballyEnabled = data.enabled !== false;
|
||||
const isSiteEnabled = isGloballyEnabled && !disabledSites.includes(hostname);
|
||||
const data = await chrome.storage.sync.get(['disabledSites', 'enabled']);
|
||||
const disabledSites: string[] = data.disabledSites || [];
|
||||
const isGloballyEnabled = data.enabled !== false;
|
||||
const isSiteEnabled = isGloballyEnabled && !disabledSites.includes(hostname);
|
||||
|
||||
updateBadge(info.tabId, isSiteEnabled);
|
||||
} catch {
|
||||
// Ignore errors for special pages like chrome://
|
||||
}
|
||||
updateBadge(info.tabId, isSiteEnabled);
|
||||
} catch {
|
||||
// Ignore errors for special pages like chrome://
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Message Handling ─────────────────────────────────────────────────────────
|
||||
@@ -122,62 +122,62 @@ chrome.tabs.onActivated.addListener(async (info) => {
|
||||
* Handle messages from options page or popup.
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(
|
||||
msg: { type: string; [key: string]: unknown },
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (resp?: unknown) => void
|
||||
): boolean => {
|
||||
switch (msg.type) {
|
||||
case 'savePalette': {
|
||||
const yaml = msg.yaml as string;
|
||||
chrome.storage.sync.set({ paletteYaml: yaml }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true; // async response
|
||||
}
|
||||
(
|
||||
msg: { type: string;[key: string]: unknown },
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (resp?: unknown) => void
|
||||
): boolean => {
|
||||
switch (msg.type) {
|
||||
case 'savePalette': {
|
||||
const yaml = msg.yaml as string;
|
||||
chrome.storage.sync.set({ paletteYaml: yaml }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true; // async response
|
||||
}
|
||||
|
||||
case 'getSettings': {
|
||||
chrome.storage.sync
|
||||
.get(['paletteYaml', 'enabled', 'intensity', 'disabledSites'])
|
||||
.then((data) => {
|
||||
sendResponse(data);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'getSettings': {
|
||||
chrome.storage.sync
|
||||
.get(['paletteYaml', 'enabled', 'intensity', 'disabledSites'])
|
||||
.then((data) => {
|
||||
sendResponse(data);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'setIntensity': {
|
||||
const val = msg.value as number;
|
||||
chrome.storage.sync.set({ intensity: val }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'setIntensity': {
|
||||
const val = msg.value as number;
|
||||
chrome.storage.sync.set({ intensity: val }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'setEnabled': {
|
||||
const val = msg.value as boolean;
|
||||
chrome.storage.sync.set({ enabled: val }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'setEnabled': {
|
||||
const val = msg.value as boolean;
|
||||
chrome.storage.sync.set({ enabled: val }).then(() => {
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'toggleSite': {
|
||||
const hostname = msg.hostname as string;
|
||||
chrome.storage.sync.get(['disabledSites']).then((data) => {
|
||||
const sites: string[] = data.disabledSites || [];
|
||||
const idx = sites.indexOf(hostname);
|
||||
if (idx >= 0) {
|
||||
sites.splice(idx, 1);
|
||||
} else {
|
||||
sites.push(hostname);
|
||||
}
|
||||
chrome.storage.sync.set({ disabledSites: sites }).then(() => {
|
||||
sendResponse({ ok: true, disabledSites: sites });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'toggleSite': {
|
||||
const hostname = msg.hostname as string;
|
||||
chrome.storage.sync.get(['disabledSites']).then((data) => {
|
||||
const sites: string[] = data.disabledSites || [];
|
||||
const idx = sites.indexOf(hostname);
|
||||
if (idx >= 0) {
|
||||
sites.splice(idx, 1);
|
||||
} else {
|
||||
sites.push(hostname);
|
||||
}
|
||||
chrome.storage.sync.set({ disabledSites: sites }).then(() => {
|
||||
sendResponse({ ok: true, disabledSites: sites });
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
6
gogh-theme-engine/src/js-yaml.d.ts
vendored
6
gogh-theme-engine/src/js-yaml.d.ts
vendored
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user