feat: Implement Gogh Theme Engine with options page, theme parsing, and semantic theme derivation

We're cooked. <skull emoji>

- Added options page for YAML palette editing, live color preview, and diagnostics.
- Implemented theme engine to parse Gogh YAML palettes and derive semantic themes.
- Configured TypeScript and Vite for building the extension.
- Created new tab and popup HTML pages with corresponding scripts and styles.
- Established storage management for user configurations in Chrome storage.
- Added icons for the extension and updated manifest for MV3 compatibility.
This commit is contained in:
2026-03-03 23:11:08 +05:30
commit 6cdc79e345
45 changed files with 5817 additions and 0 deletions

22
background/index.js Normal file
View File

@@ -0,0 +1,22 @@
chrome.runtime.onInstalled.addListener(function (object) {
if (object.reason === chrome.runtime.OnInstalledReason.INSTALL) {
chrome.tabs.create({ url: "https://clydedsouza.net" });
}
});
chrome.commands.onCommand.addListener((command) => {
if(command === 'turn-on') {
chrome.action.setBadgeText({ text: 'ON' });
chrome.action.setBadgeBackgroundColor({ color: 'green' });
}
if(command === 'turn-off') {
chrome.action.setBadgeText({ text: 'OFF' });
chrome.action.setBadgeBackgroundColor({ color: 'red' });
}
});
chrome.omnibox.onInputEntered.addListener((text) => {
chrome.tabs.create({ url: 'https://facebook.com/' + encodeURIComponent(text) });
chrome.tabs.create({ url: 'https://twitter.com/' + encodeURIComponent(text) });
chrome.tabs.create({ url: 'https://instagram.com/' + encodeURIComponent(text) });
});

5
common/settings.js Normal file
View File

@@ -0,0 +1,5 @@
export const CHROME_SYNC_STORAGE_KEY = "chrome-extension-template";
export const PRESET_CONFIGURATION = {
"storageValue": "https://clydedsouza.net",
};

10
common/settings.test.js Normal file
View File

@@ -0,0 +1,10 @@
import * as settingsModule from "./settings";
describe("settings", () => {
test("has a preset configuration key", () => {
const presetConfig = settingsModule.PRESET_CONFIGURATION;
const keys = Object.keys(presetConfig);
expect(keys.length).toBe(1);
expect(presetConfig["storageValue"]).toBe("https://clydedsouza.net");
});
});

9
common/storage.js Normal file
View File

@@ -0,0 +1,9 @@
export const getStorage = (componentKey, callback) => {
chrome.storage.sync.get([componentKey], function(result) {
callback(result[componentKey]);
});
};
export const setStorage = (componentKey, value) => {
chrome.storage.sync.set({[componentKey]: value});
};

42
common/storage.test.js Normal file
View File

@@ -0,0 +1,42 @@
import * as storageModule from "./storage";
const get = jest.fn();
const set = jest.fn();
global.chrome = {
storage: {
sync: {
set,
get
}
}
};
const chromeGetStorageSpy = jest.spyOn(chrome.storage.sync, 'get');
const chromeSetStorageSpy = jest.spyOn(chrome.storage.sync, 'set');
describe("storage → getStorage", () => {
beforeEach(() => {
jest.resetAllMocks();
});
test("chrome get storage api method called", () => {
const getCallback = jest.fn();
storageModule.getStorage("Test", getCallback);
expect(chromeGetStorageSpy).toHaveBeenCalledWith(["Test"], expect.any(Function));
});
});
describe("storage → setStorage", () => {
beforeEach(() => {
jest.resetAllMocks();
});
test("chrome set storage api method called", () => {
storageModule.setStorage("Test", "Value");
expect(chromeSetStorageSpy).toHaveBeenCalledWith({["Test"]: "Value"});
});
});

13
content_scripts/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { getStorage } from "../common/storage";
import { CHROME_SYNC_STORAGE_KEY, PRESET_CONFIGURATION } from "../common/settings";
function loadAndDisplayStorageValue(result) {
const savedConfiguration = result || PRESET_CONFIGURATION;
const storageValue = savedConfiguration["storageValue"];
console.info("Storage value is", storageValue);
}
window.onload = function() {
console.info("Content script loaded");
getStorage(CHROME_SYNC_STORAGE_KEY, loadAndDisplayStorageValue);
}

3
gogh-theme-engine/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

130
gogh-theme-engine/README.md Normal file
View File

@@ -0,0 +1,130 @@
# Gogh Theme Engine
A production-grade Chrome Extension (Manifest V3) that applies Gogh-style 16-color terminal palettes to all web pages by intelligently computing and rewriting styles at runtime using perceptual OKLCH color remapping.
## Features
- **OKLCH Perceptual Color Model** — All color math uses OKLCH for perceptually uniform transformations
- **Semantic Theme Derivation** — Automatically maps 16 terminal colors to semantic roles (error, success, accent, etc.) using hue + brightness analysis
- **Smart DOM Processing** — TreeWalker-based traversal, MutationObserver with batched/debounced updates, Shadow DOM support
- **WCAG Contrast Enforcement** — Ensures minimum 4.5:1 contrast ratio for text
- **Gradient & SVG Support** — Parses and transforms CSS gradients and inline SVG fill/stroke
- **Dynamic CSS Class Generation** — Reuses CSS classes instead of inline styles for performance
- **Per-site Toggle** — Enable/disable theming on a per-domain basis
- **Intensity Slider** — Blend between original and themed colors (0100%)
- **Options Page** — YAML editor, live preview, contrast diagnostics, site management
## Build Instructions
### Prerequisites
- Node.js ≥ 18
- npm ≥ 9
### Setup
```bash
cd gogh-theme-engine
npm install
```
### Development Build (with watch)
```bash
npm run dev
```
### Production Build
```bash
npm run build
```
### Load in Chrome
1. Run `npm run build`
2. Open `chrome://extensions/`
3. Enable **Developer mode**
4. Click **Load unpacked**
5. Select the `dist/` folder
## Project Structure
```
src/
├── background.ts # Service worker — storage, per-domain toggle, messaging
├── content.ts # Content script — DOM processing, style injection, coordination
├── color.ts # OKLCH color engine — all color math and transforms
├── themeEngine.ts # YAML parsing, validation, semantic theme derivation
├── observer.ts # DOM observation — TreeWalker, MutationObserver, Shadow DOM
├── js-yaml.d.ts # Type declarations for js-yaml
└── options/
├── options.html # Options page UI
└── options.ts # Options page controller
public/
├── manifest.json # Manifest V3 configuration
└── icons/ # Extension icons
```
## Palette Format
Provide a YAML palette in Gogh format:
```yaml
name: My Theme
author: Your Name
variant: dark
color_01: "#282a36"
color_02: "#ff5555"
color_03: "#50fa7b"
color_04: "#f1fa8c"
color_05: "#bd93f9"
color_06: "#ff79c6"
color_07: "#8be9fd"
color_08: "#f8f8f2"
color_09: "#44475a"
color_10: "#ff6e6e"
color_11: "#69ff94"
color_12: "#ffffa5"
color_13: "#d6acff"
color_14: "#ff92df"
color_15: "#a4ffff"
color_16: "#ffffff"
background: "#282a36"
foreground: "#f8f8f2"
cursor: "#f8f8f2"
```
## How It Works
### Color Pipeline
1. **Parse** — CSS color strings are parsed into RGBA
2. **Convert** — RGBA → sRGB → Linear RGB → OKLAB → OKLCH
3. **Classify** — Colors are classified as neutral (achromatic) or chromatic with a hue bucket
4. **Remap** — Neutral colors map to surface/background bands; chromatic colors map to semantic roles
5. **Contrast** — WCAG contrast is enforced by adjusting the L (lightness) channel
6. **Blend** — Final color is blended with original based on intensity setting
7. **Cache** — Results are memoized by color + context key
### Theme Derivation
Instead of fixed index mapping, semantic roles are derived by analyzing each palette color's OKLCH properties:
- Red hue range → `error`
- Green → `success`
- Blue/Cyan → `accentPrimary` / `info`
- Yellow/Orange → `warning`
- Purple → `accentSecondary`
- Low chroma → `muted`
- Surface/border colors are synthesized from background by shifting lightness
## Architecture Notes
- No naive CSS inversion or global filters
- `TreeWalker` for initial traversal (faster than `querySelectorAll`)
- `MutationObserver` with `requestIdleCallback` batching
- `WeakSet` for tracking processed nodes
- `Element.prototype.attachShadow` monkey-patching for shadow DOM
- Dynamic CSS class generation with reuse to minimize inline styles
- Transform results cached with LRU-like eviction (max 8192 entries)

2286
gogh-theme-engine/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +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"
}
}

View File

@@ -0,0 +1,2 @@
// No PostCSS plugins needed for this project
module.exports = {};

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#282a36"/>
<text x="64" y="80" font-family="Arial, sans-serif" font-size="64" text-anchor="middle" fill="#f8f8f2">G</text>
<circle cx="96" cy="32" r="12" fill="#ff79c6"/>
<circle cx="96" cy="32" r="6" fill="#bd93f9"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

View File

@@ -0,0 +1,33 @@
{
"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"
}
}

View File

@@ -0,0 +1,183 @@
/**
* background.ts — Manifest V3 service worker.
*
* Responsibilities:
* • Store palette in chrome.storage.sync
* • Toggle enable/disable per domain
* • Handle action button clicks (quick toggle)
* • Manage default settings on install
* • Relay messages between popup/options and content scripts
*/
import { DEFAULT_PALETTE_YAML } from './themeEngine';
// ─── Installation / Update ────────────────────────────────────────────────────
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'install') {
// Set default settings
await chrome.storage.sync.set({
paletteYaml: DEFAULT_PALETTE_YAML,
enabled: true,
intensity: 100, // 0100
disabledSites: [] as string[],
});
console.log('[Gogh Theme Engine] Extension installed with default Dracula palette.');
}
});
// ─── Action Button Click ──────────────────────────────────────────────────────
/**
* Clicking the extension icon toggles theming on the current tab.
* 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;
try {
const url = new URL(tab.url);
const hostname = url.hostname;
if (!hostname) return;
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;
}
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',
});
}
// ─── Tab Activation ───────────────────────────────────────────────────────────
/**
* When the user switches tabs, update the badge to reflect the current
* site's enable/disable state.
*/
chrome.tabs.onActivated.addListener(async (info) => {
try {
const tab = await chrome.tabs.get(info.tabId);
if (!tab.url) return;
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);
updateBadge(info.tabId, isSiteEnabled);
} catch {
// Ignore errors for special pages like chrome://
}
});
// ─── Message Handling ─────────────────────────────────────────────────────────
/**
* 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
}
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 '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;
}
}
return false;
}
);

View File

@@ -0,0 +1,710 @@
/**
* color.ts — Perceptual color engine built on OKLCH.
*
* This module provides all color math the extension needs:
* • Hex ↔ sRGB ↔ Linear RGB ↔ OKLAB ↔ OKLCH conversions
* • Relative luminance (WCAG definition, from linear sRGB)
* • WCAG contrast ratio
* • Color parsing (hex, rgb(), rgba(), named colors, hsl())
* • Color serialization
* • A memoized transformColor() entry point
*
* All intermediate math uses the OKLCH perceptual model so that
* lightness adjustments look uniform to the human eye.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/** sRGB color with channels in [0,1] */
export interface SRGB {
r: number;
g: number;
b: number;
}
/** Linear-light sRGB (gamma removed) */
export interface LinearRGB {
r: number;
g: number;
b: number;
}
/** OKLAB perceptual color */
export interface OKLAB {
L: number; // lightness 0..1
a: number; // greenred axis
b: number; // blueyellow axis
}
/** OKLCH perceptual cylindrical color */
export interface OKLCH {
L: number; // lightness 0..1
C: number; // chroma ≥0
h: number; // hue in degrees 0..360
}
/** Parsed RGBA color (sRGB 0-255 + alpha 0-1) */
export interface RGBA {
r: number;
g: number;
b: number;
a: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const DEG = Math.PI / 180;
const RAD = 180 / Math.PI;
// ─── sRGB ↔ Linear RGB ───────────────────────────────────────────────────────
/** Apply sRGB inverse electro-optical transfer function (gamma decode). */
function srgbToLinearChannel(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/** Apply sRGB electro-optical transfer function (gamma encode). */
function linearToSrgbChannel(c: number): number {
return c <= 0.0031308
? 12.92 * c
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
}
export function srgbToLinear(c: SRGB): LinearRGB {
return {
r: srgbToLinearChannel(c.r),
g: srgbToLinearChannel(c.g),
b: srgbToLinearChannel(c.b),
};
}
export function linearToSrgb(c: LinearRGB): SRGB {
return {
r: linearToSrgbChannel(c.r),
g: linearToSrgbChannel(c.g),
b: linearToSrgbChannel(c.b),
};
}
// ─── Linear RGB ↔ OKLAB ──────────────────────────────────────────────────────
// Using the matrices from Björn Ottosson's OKLAB specification.
export function linearRgbToOklab(c: LinearRGB): OKLAB {
const l_ = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
const m_ = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
const s_ = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
const l = Math.cbrt(l_);
const m = Math.cbrt(m_);
const s = Math.cbrt(s_);
return {
L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
};
}
export function oklabToLinearRgb(c: OKLAB): LinearRGB {
const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b;
const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b;
const s_ = c.L - 0.0894841775 * c.a - 1.2914855480 * c.b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
};
}
// ─── OKLAB ↔ OKLCH ───────────────────────────────────────────────────────────
export function oklabToOklch(lab: OKLAB): OKLCH {
const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
let h = Math.atan2(lab.b, lab.a) * RAD;
if (h < 0) h += 360;
return { L: lab.L, C, h };
}
export function oklchToOklab(lch: OKLCH): OKLAB {
return {
L: lch.L,
a: lch.C * Math.cos(lch.h * DEG),
b: lch.C * Math.sin(lch.h * DEG),
};
}
// ─── Convenience round-trips ─────────────────────────────────────────────────
export function srgbToOklch(c: SRGB): OKLCH {
return oklabToOklch(linearRgbToOklab(srgbToLinear(c)));
}
export function oklchToSrgb(c: OKLCH): SRGB {
return linearToSrgb(oklabToLinearRgb(oklchToOklab(c)));
}
/** Clamp sRGB channels to [0,1] after conversion to handle out-of-gamut. */
export function oklchToSrgbClamped(c: OKLCH): SRGB {
const raw = oklchToSrgb(c);
return {
r: Math.max(0, Math.min(1, raw.r)),
g: Math.max(0, Math.min(1, raw.g)),
b: Math.max(0, Math.min(1, raw.b)),
};
}
// ─── Hex ↔ RGBA ──────────────────────────────────────────────────────────────
/** Parse a hex color string (#RGB, #RRGGBB, #RRGGBBAA) into RGBA 0-255. */
export function hexToRgba(hex: string): RGBA {
let h = hex.replace('#', '');
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
} else if (h.length === 4) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
}
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
export function hexToSrgb(hex: string): SRGB {
const { r, g, b } = hexToRgba(hex);
return { r: r / 255, g: g / 255, b: b / 255 };
}
export function hexToOklch(hex: string): OKLCH {
return srgbToOklch(hexToSrgb(hex));
}
export function srgbToHex(c: SRGB): string {
const to8 = (v: number) =>
Math.round(Math.max(0, Math.min(1, v)) * 255)
.toString(16)
.padStart(2, '0');
return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`;
}
export function oklchToHex(c: OKLCH): string {
return srgbToHex(oklchToSrgbClamped(c));
}
export function rgbaToHex(c: RGBA): string {
const to8 = (v: number) =>
Math.max(0, Math.min(255, Math.round(v)))
.toString(16)
.padStart(2, '0');
if (c.a < 1) {
return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}${to8(c.a * 255)}`;
}
return `#${to8(c.r)}${to8(c.g)}${to8(c.b)}`;
}
// ─── CSS Color Parsing ───────────────────────────────────────────────────────
/** Map of CSS named colors to hex. Only the most common ones for speed. */
const NAMED_COLORS: Record<string, string> = {
transparent: '#00000000',
black: '#000000',
white: '#ffffff',
red: '#ff0000',
green: '#008000',
blue: '#0000ff',
yellow: '#ffff00',
cyan: '#00ffff',
magenta: '#ff00ff',
orange: '#ffa500',
purple: '#800080',
pink: '#ffc0cb',
gray: '#808080',
grey: '#808080',
silver: '#c0c0c0',
maroon: '#800000',
olive: '#808000',
lime: '#00ff00',
teal: '#008080',
navy: '#000080',
aqua: '#00ffff',
fuchsia: '#ff00ff',
inherit: '',
initial: '',
unset: '',
currentcolor: '',
currentColor: '',
};
/**
* Parse any CSS color string into RGBA (0-255 for RGB, 0-1 for alpha).
* Returns null if the string cannot be parsed or is a keyword like inherit.
*/
export function parseCssColor(raw: string): RGBA | null {
if (!raw || raw === 'none') return null;
const trimmed = raw.trim().toLowerCase();
// Named colors
if (trimmed in NAMED_COLORS) {
const hex = NAMED_COLORS[trimmed];
if (!hex) return null; // inherit, initial, etc.
return hexToRgba(hex);
}
// Hex
if (trimmed.startsWith('#')) {
return hexToRgba(trimmed);
}
// rgb() / rgba()
const rgbMatch = trimmed.match(
/rgba?\(\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*[,/ ]\s*([\d.]+%?)\s*(?:[,/]\s*([\d.]+%?))?\s*\)/
);
if (rgbMatch) {
const parseChannel = (s: string): number =>
s.endsWith('%') ? (parseFloat(s) / 100) * 255 : parseFloat(s);
const parseAlpha = (s: string | undefined): number => {
if (!s) return 1;
return s.endsWith('%') ? parseFloat(s) / 100 : parseFloat(s);
};
return {
r: parseChannel(rgbMatch[1]),
g: parseChannel(rgbMatch[2]),
b: parseChannel(rgbMatch[3]),
a: parseAlpha(rgbMatch[4]),
};
}
// hsl() / hsla()
const hslMatch = trimmed.match(
/hsla?\(\s*([\d.]+)\s*[,/ ]\s*([\d.]+)%?\s*[,/ ]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)/
);
if (hslMatch) {
const h = parseFloat(hslMatch[1]);
const s = parseFloat(hslMatch[2]) / 100;
const l = parseFloat(hslMatch[3]) / 100;
const a = hslMatch[4]
? hslMatch[4].endsWith('%')
? parseFloat(hslMatch[4]) / 100
: parseFloat(hslMatch[4])
: 1;
// HSL to RGB conversion
const rgb = hslToRgb(h, s, l);
return { ...rgb, a };
}
return null;
}
/** Standard HSL → RGB. h in degrees, s/l in [0,1]. Returns RGB 0-255. */
function hslToRgb(
h: number,
s: number,
l: number
): { r: number; g: number; b: number } {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
}
/** Serialize RGBA to a CSS string. */
export function rgbaToCss(c: RGBA): string {
if (c.a < 1) {
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${c.a.toFixed(3)})`;
}
return `rgb(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)})`;
}
// ─── Luminance & Contrast ────────────────────────────────────────────────────
/**
* WCAG 2.x relative luminance from sRGB channels in [0,1].
* Uses the standard formula: Y = 0.2126*R + 0.7152*G + 0.0722*B
* where R,G,B are linear-light values.
*/
export function relativeLuminance(c: SRGB): number {
const lin = srgbToLinear(c);
return 0.2126 * lin.r + 0.7152 * lin.g + 0.0722 * lin.b;
}
export function relativeLuminanceFromRgba(c: RGBA): number {
return relativeLuminance({ r: c.r / 255, g: c.g / 255, b: c.b / 255 });
}
/**
* WCAG contrast ratio between two luminance values.
* Returns a value ≥ 1. AA for normal text requires ≥ 4.5.
*/
export function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check whether a color is roughly achromatic (near-neutral).
* Uses OKLCH chroma — values below threshold are "neutral."
*/
export function isNeutral(lch: OKLCH, threshold = 0.035): boolean {
return lch.C < threshold;
}
/**
* Classify the hue of an OKLCH color into a named bucket.
* Returns null for achromatic colors.
*/
export type HueBucket =
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'cyan'
| 'blue'
| 'purple'
| 'magenta';
export function classifyHue(lch: OKLCH): HueBucket | null {
if (isNeutral(lch)) return null;
const h = ((lch.h % 360) + 360) % 360;
if (h < 20 || h >= 345) return 'red';
if (h < 50) return 'orange';
if (h < 85) return 'yellow';
if (h < 160) return 'green';
if (h < 200) return 'cyan';
if (h < 270) return 'blue';
if (h < 310) return 'purple';
return 'magenta';
}
/**
* Blend two OKLCH colors. t=0 → a, t=1 → b.
* Hue interpolation takes the shortest path around the circle.
*/
export function blendOklch(a: OKLCH, b: OKLCH, t: number): OKLCH {
let dh = b.h - a.h;
if (dh > 180) dh -= 360;
if (dh < -180) dh += 360;
return {
L: a.L + (b.L - a.L) * t,
C: a.C + (b.C - a.C) * t,
h: ((a.h + dh * t) % 360 + 360) % 360,
};
}
/**
* Adjust the L channel of an OKLCH color to meet a minimum contrast ratio
* against a given background luminance.
*
* We walk the L channel in the direction that increases contrast,
* using bisection for speed.
*/
export function ensureContrast(
color: OKLCH,
bgLuminance: number,
minRatio = 4.5,
maxIterations = 16
): OKLCH {
const srgb = oklchToSrgbClamped(color);
const fgLum = relativeLuminance(srgb);
const currentRatio = contrastRatio(fgLum, bgLuminance);
if (currentRatio >= minRatio) return color;
// Determine direction: lighten or darken?
// If bg is dark, lighten fg; if bg is light, darken fg.
const shouldLighten = bgLuminance < 0.5;
let lo = shouldLighten ? color.L : 0;
let hi = shouldLighten ? 1 : color.L;
let bestL = color.L;
for (let i = 0; i < maxIterations; i++) {
const mid = (lo + hi) / 2;
const testColor: OKLCH = { L: mid, C: color.C, h: color.h };
const testSrgb = oklchToSrgbClamped(testColor);
const testLum = relativeLuminance(testSrgb);
const testRatio = contrastRatio(testLum, bgLuminance);
if (testRatio >= minRatio) {
bestL = mid;
// Try to find a value closer to the original
if (shouldLighten) hi = mid;
else lo = mid;
} else {
if (shouldLighten) lo = mid;
else hi = mid;
}
}
return { L: bestL, C: color.C, h: color.h };
}
// ─── Transform Memoization ───────────────────────────────────────────────────
const transformCache = new Map<string, string>();
const CACHE_MAX_SIZE = 8192;
export function clearTransformCache(): void {
transformCache.clear();
}
/**
* Build a cache key from the original color string and its context.
* This ensures identical colors in different contexts can map differently.
*/
function makeCacheKey(cssColor: string, context: TransformContext): string {
return `${cssColor}|${context.property}|${context.parentBgLuminance.toFixed(3)}|${context.isLink ? 1 : 0}|${context.isButton ? 1 : 0}|${context.disabled ? 1 : 0}`;
}
// ─── Transform Context ───────────────────────────────────────────────────────
export interface TransformContext {
/** CSS property being transformed: 'color', 'background-color', 'border-color', 'box-shadow' */
property: 'color' | 'background-color' | 'border-color' | 'box-shadow';
/** Relative luminance of the parent/container background. */
parentBgLuminance: number;
/** Whether the element is a link. */
isLink: boolean;
/** Whether the element is a button or button-like. */
isButton: boolean;
/** Whether the element is in a disabled state. */
disabled: boolean;
}
/** The semantic theme — provided by themeEngine.ts */
export interface SemanticTheme {
background: string;
foreground: string;
surface: string;
surfaceAlt: string;
accentPrimary: string;
accentSecondary: string;
error: string;
warning: string;
success: string;
info: string;
muted: string;
border: string;
}
/**
* Main entry point for color transformation.
*
* Takes an original CSS color string, the transform context, the semantic theme,
* and an intensity factor (0 = no change, 1 = full theme).
*
* Returns a new CSS color string.
*/
export function transformColor(
cssColor: string,
context: TransformContext,
theme: SemanticTheme,
intensity: number = 1
): string {
// Fast path: cache hit
const key = makeCacheKey(cssColor, context);
const cached = transformCache.get(key);
if (cached !== undefined) return cached;
const parsed = parseCssColor(cssColor);
if (!parsed) return cssColor; // unparseable → pass through
// Fully transparent → leave alone
if (parsed.a === 0) return cssColor;
const originalSrgb: SRGB = { r: parsed.r / 255, g: parsed.g / 255, b: parsed.b / 255 };
const originalLch = srgbToOklch(originalSrgb);
// Determine the target theme color based on semantic analysis
let targetHex: string;
const hue = classifyHue(originalLch);
if (context.property === 'background-color' || context.property === 'box-shadow') {
// Background colors → remap based on lightness bands
if (isNeutral(originalLch)) {
targetHex = remapNeutralBg(originalLch, theme);
} else {
targetHex = remapChromaticBg(originalLch, hue, theme);
}
} else if (context.property === 'color') {
// Text colors
if (context.isLink) {
targetHex = theme.accentPrimary;
} else if (isNeutral(originalLch)) {
targetHex = remapNeutralFg(originalLch, theme);
} else {
targetHex = remapChromaticFg(originalLch, hue, theme);
}
} else {
// border-color
if (isNeutral(originalLch)) {
targetHex = theme.border;
} else {
targetHex = remapChromaticBg(originalLch, hue, theme);
}
}
let targetLch = hexToOklch(targetHex);
// Preserve the original lightness relationship to some degree:
// Shift the target lightness partially toward the original's relative lightness
// This helps preserve visual hierarchy within the theme
const lightnessPreservation = 0.25;
targetLch = {
...targetLch,
L: targetLch.L + (originalLch.L - targetLch.L) * lightnessPreservation,
};
// Ensure WCAG contrast for text
if (context.property === 'color') {
targetLch = ensureContrast(targetLch, context.parentBgLuminance, 4.5);
}
// Apply intensity blending: blend between original and target
const finalLch = blendOklch(originalLch, targetLch, intensity);
const finalSrgb = oklchToSrgbClamped(finalLch);
let result: string;
if (parsed.a < 1) {
result = `rgba(${Math.round(finalSrgb.r * 255)}, ${Math.round(finalSrgb.g * 255)}, ${Math.round(finalSrgb.b * 255)}, ${parsed.a.toFixed(3)})`;
} else {
result = srgbToHex(finalSrgb);
}
// Cache management
if (transformCache.size >= CACHE_MAX_SIZE) {
// Evict oldest ~25%
const keys = Array.from(transformCache.keys());
for (let i = 0; i < CACHE_MAX_SIZE / 4; i++) {
transformCache.delete(keys[i]);
}
}
transformCache.set(key, result);
return result;
}
// ─── Neutral remapping helpers ───────────────────────────────────────────────
/**
* Remap a neutral (achromatic) background color to the theme's surface bands.
* Uses lightness to decide which surface level to use:
* Very dark → background
* Dark → surface
* Medium → surfaceAlt
* Light → foreground-ish (inverted for light themes)
*/
function remapNeutralBg(lch: OKLCH, theme: SemanticTheme): string {
const bgLch = hexToOklch(theme.background);
const fgLch = hexToOklch(theme.foreground);
// Determine if this is a dark or light theme by comparing bg and fg lightness
const isDark = bgLch.L < fgLch.L;
if (isDark) {
// Dark theme: dark originals → theme bg, light originals → theme surface
if (lch.L < 0.2) return theme.background;
if (lch.L < 0.35) return theme.surface;
if (lch.L < 0.55) return theme.surfaceAlt;
if (lch.L < 0.75) return theme.muted;
return theme.foreground;
} else {
// Light theme: light originals → theme bg, dark originals → theme surface
if (lch.L > 0.85) return theme.background;
if (lch.L > 0.7) return theme.surface;
if (lch.L > 0.5) return theme.surfaceAlt;
if (lch.L > 0.3) return theme.muted;
return theme.foreground;
}
}
function remapNeutralFg(lch: OKLCH, theme: SemanticTheme): string {
const bgLch = hexToOklch(theme.background);
const fgLch = hexToOklch(theme.foreground);
const isDark = bgLch.L < fgLch.L;
if (isDark) {
// In dark theme: dark text originals → muted, light text → foreground
if (lch.L > 0.6) return theme.foreground;
if (lch.L > 0.35) return theme.muted;
return theme.surface; // very dark text on dark bg → surface (barely visible, intentional)
} else {
if (lch.L < 0.4) return theme.foreground;
if (lch.L < 0.65) return theme.muted;
return theme.surface;
}
}
// ─── Chromatic remapping helpers ─────────────────────────────────────────────
function remapChromaticBg(
_lch: OKLCH,
hue: HueBucket | null,
theme: SemanticTheme
): string {
switch (hue) {
case 'red':
case 'magenta':
return theme.error;
case 'orange':
return theme.warning;
case 'yellow':
return theme.warning;
case 'green':
return theme.success;
case 'cyan':
return theme.info;
case 'blue':
return theme.accentPrimary;
case 'purple':
return theme.accentSecondary;
default:
return theme.surface;
}
}
function remapChromaticFg(
_lch: OKLCH,
hue: HueBucket | null,
theme: SemanticTheme
): string {
switch (hue) {
case 'red':
case 'magenta':
return theme.error;
case 'orange':
return theme.warning;
case 'yellow':
return theme.warning;
case 'green':
return theme.success;
case 'cyan':
return theme.info;
case 'blue':
return theme.accentPrimary;
case 'purple':
return theme.accentSecondary;
default:
return theme.foreground;
}
}

View File

@@ -0,0 +1,654 @@
/**
* content.ts — Content script injected into every page.
*
* This is the main orchestrator that:
* 1. Loads the user's theme from chrome.storage.sync
* 2. Injects CSS variables into a <style> tag
* 3. Processes each visible element's computed styles
* 4. Generates dynamic CSS classes for transformed colors
* 5. Handles gradients, SVG fill/stroke, and inline styles
* 6. Coordinates with the observer for mutation handling
* 7. Injects theme styles into shadow DOM roots
*
* Architecture notes:
* - We inject ONE <style id="gogh-theme-root"> into <head> for CSS variables.
* - We inject ONE <style id="gogh-dynamic-rules"> for generated class rules.
* - We prefer CSS class rules over inline styles for performance.
* - For truly unique per-element colors, we fall back to inline style.
* - We use data-gogh-class="<hash>" to apply dynamic rules without
* conflicting with the site's own classes.
*/
import {
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';
// ─── State ────────────────────────────────────────────────────────────────────
let currentTheme: SemanticTheme | null = null;
let currentPalette: GoghPalette | null = null;
let intensity = 1.0; // 0 = no effect, 1 = full theme
let enabled = true;
let themeStyleEl: HTMLStyleElement | null = null;
let dynamicStyleEl: HTMLStyleElement | null = null;
/**
* Map of "transformed color CSS string" → generated class name.
* Allows us to reuse a class instead of inlining the same transform many times.
*/
const dynamicClassMap = new Map<string, string>();
let classCounter = 0;
/** Track all shadow roots we've injected styles into. */
const injectedShadowRoots = new WeakSet<ShadowRoot>();
// ─── Initialization ──────────────────────────────────────────────────────────
/**
* Bootstrap the content script.
* 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',
]);
// 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);
}
}
// Start immediately
init();
// ─── Theme CSS Variable Injection ─────────────────────────────────────────────
/**
* Inject (or update) the <style> tag containing CSS custom properties
* derived from the semantic theme. These variables are available
* globally and inside shadow roots via :host injection.
*/
function injectThemeVariables(): void {
if (!currentTheme) return;
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);
}
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);
}
}
function buildThemeCSS(theme: SemanticTheme): string {
return `
:root {
--gogh-bg: ${theme.background};
--gogh-fg: ${theme.foreground};
--gogh-surface: ${theme.surface};
--gogh-surface-alt: ${theme.surfaceAlt};
--gogh-accent-primary: ${theme.accentPrimary};
--gogh-accent-secondary: ${theme.accentSecondary};
--gogh-error: ${theme.error};
--gogh-warning: ${theme.warning};
--gogh-success: ${theme.success};
--gogh-info: ${theme.info};
--gogh-muted: ${theme.muted};
--gogh-border: ${theme.border};
}
/* Base overrides — applied broadly via variables */
html {
background-color: var(--gogh-bg) !important;
color: var(--gogh-fg) !important;
}
body {
background-color: var(--gogh-bg) !important;
color: var(--gogh-fg) !important;
}
`;
}
// ─── Dynamic Class Generation ─────────────────────────────────────────────────
/**
* Get or create a CSS class for a given set of style overrides.
* Returns the class name (without dot prefix).
*
* @param key A unique key for this combination of overrides
* @param declarations CSS declarations like { 'color': '#ff0000', 'background-color': '#000' }
*/
function getOrCreateDynamicClass(
key: string,
declarations: Record<string, string>
): string {
let className = dynamicClassMap.get(key);
if (className) return 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`;
// Append to dynamic stylesheet
if (dynamicStyleEl) {
dynamicStyleEl.textContent += rule;
}
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',
] as const;
/** Properties that indicate we should skip transformation. */
const SKIP_PROPERTIES = ['backdrop-filter', 'mix-blend-mode'] as const;
/**
* Process a single HTML element: read its computed styles, transform
* colors through the OKLCH engine, and apply themed values.
*/
function processElement(el: HTMLElement): void {
if (!currentTheme || !enabled) return;
// Don't process our own injected style elements
if (el.hasAttribute('data-gogh')) return;
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;
}
// 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;
}
}
// 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);
}
// Handle inline SVGs
if (el instanceof SVGElement) {
processSvgElement(el);
}
}
/**
* Get the background luminance of the nearest ancestor with an opaque background.
* 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);
}
}
parent = parent.parentElement;
}
// Fallback: use theme background luminance
if (currentTheme) {
const parsed = parseCssColor(currentTheme.background);
if (parsed) return relativeLuminanceFromRgba(parsed);
}
return 0; // assume dark
}
/**
* Apply color overrides to an element.
* Strategy: if there's a reusable class (same overrides seen before), use that.
* 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('|');
// 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);
}
/**
* Parse a gradient string, transform each color stop, and reconstruct.
*
* We use a regex-based approach to find color tokens within gradient functions,
* transform them individually, and splice them back.
*/
function transformGradient(gradient: string, parentBgLum: number): string {
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;
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 ──────────────────────────────────────────────────────
/**
* Transform colors within box-shadow values.
* Box shadows can contain multiple shadows separated by commas,
* each potentially with a color value.
*/
function transformBoxShadow(
value: string,
parentBgLum: number,
isLink: boolean,
isButton: boolean,
isDisabled: boolean
): string {
if (!currentTheme || value === 'none') return value;
const colorRegex =
/#(?:[0-9a-fA-F]{3,8})|rgba?\([^)]+\)|hsla?\([^)]+\)/gi;
const context: TransformContext = {
property: 'box-shadow',
parentBgLuminance: parentBgLum,
isLink,
isButton,
disabled: isDisabled,
};
return value.replace(colorRegex, (match) => {
return transformColor(match, context, currentTheme!, intensity);
});
}
// ─── SVG Handling ─────────────────────────────────────────────────────────────
/**
* Process SVG-specific color attributes (fill, stroke).
* Only handles inline SVGs — external SVGs loaded via <img> or <object> are skipped.
*/
function processSvgElement(el: SVGElement): void {
if (!currentTheme) return;
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 (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 ──────────────────────────────────────────────────────
/**
* Inject theme styles into a shadow root.
* We inject the CSS variable definitions and any dynamic rules.
*/
function handleShadowRoot(shadowRoot: ShadowRoot): void {
if (injectedShadowRoots.has(shadowRoot)) return;
injectedShadowRoots.add(shadowRoot);
if (!currentTheme) return;
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);
}
});
}
}
});
observer.observe(shadowRoot, {
childList: true,
subtree: true,
});
}
function buildShadowCSS(theme: SemanticTheme): string {
return `
:host {
--gogh-bg: ${theme.background};
--gogh-fg: ${theme.foreground};
--gogh-surface: ${theme.surface};
--gogh-surface-alt: ${theme.surfaceAlt};
--gogh-accent-primary: ${theme.accentPrimary};
--gogh-accent-secondary: ${theme.accentSecondary};
--gogh-error: ${theme.error};
--gogh-warning: ${theme.warning};
--gogh-success: ${theme.success};
--gogh-info: ${theme.info};
--gogh-muted: ${theme.muted};
--gogh-border: ${theme.border};
}
`;
}
// ─── Storage Change Handler ───────────────────────────────────────────────────
function handleStorageChange(
changes: { [key: string]: chrome.storage.StorageChange },
_area: string
): void {
let needsReprocess = false;
if (changes.enabled) {
enabled = changes.enabled.newValue !== false;
if (!enabled) {
cleanup();
return;
}
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.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
): boolean {
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 'reprocess':
if (enabled) {
clearTransformCache();
reprocessAll();
}
sendResponse({ ok: true });
return true;
}
return false;
}
// ─── Cleanup ──────────────────────────────────────────────────────────────────
function cleanup(): void {
stopObserver();
// 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');
});
document.querySelectorAll('[data-gogh-processed]').forEach((el) => {
el.removeAttribute('data-gogh-processed');
});
dynamicClassMap.clear();
classCounter = 0;
clearTransformCache();
}

5
gogh-theme-engine/src/js-yaml.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,332 @@
/**
* observer.ts — Efficient DOM observation and processing.
*
* Responsibilities:
* 1. Initial page traversal using TreeWalker (faster than querySelectorAll)
* 2. MutationObserver with batched, debounced updates
* 3. Shadow DOM interception via monkey-patched attachShadow
* 4. Visibility filtering (skip invisible / offscreen elements)
* 5. WeakSet tracking to avoid reprocessing stable nodes
*
* The observer calls back into a provided `processElement` function
* for each element that needs color transformation.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
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;
}
// ─── 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',
]);
/** Attribute we stamp on processed elements to avoid redundant work. */
const PROCESSED_ATTR = 'data-gogh-processed';
/** Batch debounce time in ms for mutation processing. */
const MUTATION_DEBOUNCE_MS = 50;
// ─── State ────────────────────────────────────────────────────────────────────
/** WeakSet of elements we've already processed. Faster than checking attributes. */
const processedNodes = new WeakSet<Element>();
let mutationObserver: MutationObserver | null = null;
let isActive = false;
let pendingMutations: MutationRecord[] = [];
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let config: ObserverConfig | null = null;
// Store original attachShadow so we can restore it
let originalAttachShadow: typeof Element.prototype.attachShadow | null = null;
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Start observing the document for changes and perform initial traversal.
*/
export function startObserver(cfg: ObserverConfig): void {
config = cfg;
isActive = true;
// 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();
traverseSubtree(document.body, cfg.processElement, cfg.onShadowRoot);
setupMutationObserver();
}
});
bodyObserver.observe(document.documentElement, { childList: true });
return;
}
setupMutationObserver();
}
/**
* Stop observing and clean up.
*/
export function stopObserver(): void {
isActive = false;
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
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);
}
}
// ─── TreeWalker-based Traversal ──────────────────────────────────────────────
/**
* Walk the DOM tree using TreeWalker (much faster than querySelectorAll for
* large pages) and invoke the processor for each qualifying element.
*/
function traverseSubtree(
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;
// 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
}
return NodeFilter.FILTER_ACCEPT;
},
});
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;
}
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;
mutationObserver = new MutationObserver((records) => {
if (!isActive) return;
// Accumulate mutations and process them in a batch
pendingMutations.push(...records);
if (debounceTimer) clearTimeout(debounceTimer);
// 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'],
});
}
/**
* Process accumulated mutations in a single batch.
* Uses requestIdleCallback to avoid blocking the main thread.
*/
function flushMutations(): void {
if (!config || pendingMutations.length === 0) return;
const mutations = pendingMutations;
pendingMutations = [];
// 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);
}
});
}
}
});
} 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;
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);
}
}
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 ─────────────────────────────────────────────────────
/**
* Monkey-patch Element.prototype.attachShadow to intercept all new shadow roots.
* This lets us inject our theme styles into shadow DOM boundaries.
*/
function patchAttachShadow(onShadowRoot: ShadowRootHandler): void {
if (originalAttachShadow) return; // already patched
originalAttachShadow = Element.prototype.attachShadow;
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);
}
});
return shadowRoot;
};
}
function restoreAttachShadow(): void {
if (originalAttachShadow) {
Element.prototype.attachShadow = originalAttachShadow;
originalAttachShadow = null;
}
}

View File

@@ -0,0 +1,399 @@
<!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; }
: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; }
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;
}
/* ─── Layout ──────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 800px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
/* ─── 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;
}
.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); }
/* ─── 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>
<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>
</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>
</body>
</html>

View File

@@ -0,0 +1,371 @@
/**
* options.ts — Options page controller.
*
* Handles:
* • YAML palette editing with validation feedback
* • Live color preview (palette grid + semantic swatches)
* • Contrast diagnostics table
* • Enable/disable toggle
* • Intensity slider
* • Per-site disable list management
* • Live preview iframe
*/
import {
parsePalette,
DEFAULT_PALETTE_YAML,
deriveSemanticTheme,
type GoghPalette,
} from '../themeEngine';
import {
hexToOklch,
hexToSrgb,
relativeLuminance,
contrastRatio,
type SemanticTheme,
} from '../color';
// ─── DOM References ───────────────────────────────────────────────────────────
const yamlEditor = document.getElementById('yaml-editor') as HTMLTextAreaElement;
const editorStatus = document.getElementById('editor-status') as HTMLDivElement;
const btnSave = document.getElementById('btn-save') as HTMLButtonElement;
const btnReset = document.getElementById('btn-reset') as HTMLButtonElement;
const btnValidate = document.getElementById('btn-validate') as HTMLButtonElement;
const toggleEnabled = document.getElementById('toggle-enabled') as HTMLInputElement;
const intensitySlider = document.getElementById('intensity-slider') as HTMLInputElement;
const intensityValue = document.getElementById('intensity-value') as HTMLSpanElement;
const colorGrid = document.getElementById('color-grid') as HTMLDivElement;
const semanticGrid = document.getElementById('semantic-grid') as HTMLDivElement;
const contrastBody = document.getElementById('contrast-body') as HTMLTableSectionElement;
const disabledSitesList = document.getElementById('disabled-sites') as HTMLUListElement;
const previewFrame = document.getElementById('preview-frame') as HTMLIFrameElement;
// ─── State ────────────────────────────────────────────────────────────────────
let currentPalette: GoghPalette | null = null;
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',
]);
const yamlStr = data.paletteYaml || DEFAULT_PALETTE_YAML;
yamlEditor.value = yamlStr;
toggleEnabled.checked = data.enabled !== false;
const intensityVal = typeof data.intensity === 'number' ? data.intensity : 100;
intensitySlider.value = String(intensityVal);
intensityValue.textContent = String(intensityVal);
// Parse and render
parseAndRender(yamlStr);
// Render disabled sites
renderDisabledSites(data.disabledSites || []);
// Attach event listeners
attachListeners();
}
init();
// ─── Event Listeners ──────────────────────────────────────────────────────────
function attachListeners(): void {
// 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;
}
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);
});
// 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 });
});
// 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);
});
// 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;
}
// ─── Parse & Render ───────────────────────────────────────────────────────────
function parseAndRender(yamlStr: string): void {
const result = parsePalette(yamlStr);
if (!result.ok) return;
currentPalette = result.palette;
currentTheme = 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 = '';
// 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);
}
}
// ─── Semantic Grid ────────────────────────────────────────────────────────────
function renderSemanticGrid(theme: SemanticTheme): void {
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],
];
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;
semanticGrid.appendChild(swatch);
}
}
// ─── Contrast Diagnostics ─────────────────────────────────────────────────────
function renderContrastDiagnostics(theme: SemanticTheme): void {
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'],
];
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 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>
${label}
</td>
<td>${ratio.toFixed(2)}:1</td>
<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);
}
}
// ─── Live Preview ─────────────────────────────────────────────────────────────
function renderPreview(palette: GoghPalette, theme: SemanticTheme): void {
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;">
<h1 style="color:${theme.foreground};font-size:1.3rem;margin-bottom:0.5rem;">${palette.name}</h1>
<p style="color:${theme.muted};font-size:0.85rem;margin-bottom:1rem;">By ${palette.author} · ${palette.variant} variant</p>
<p style="color:${theme.foreground};margin-bottom:0.8rem;">
This is body text on the <span style="background:${theme.background};padding:0 4px;border-radius:3px;">background</span>.
Here is a <a href="#" style="color:${theme.accentPrimary};text-decoration:underline;">primary link</a>
and a <a href="#" style="color:${theme.accentSecondary};text-decoration:underline;">secondary link</a>.
</p>
<div style="background:${theme.surface};padding:1rem;border-radius:8px;margin-bottom:0.8rem;border:1px solid ${theme.border};">
<p style="color:${theme.foreground};margin:0;">Surface card with <code style="background:${theme.surfaceAlt};padding:2px 6px;border-radius:4px;font-size:0.85em;">inline code</code>.</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<span style="background:${theme.error};color:#fff;padding:4px 10px;border-radius:4px;font-size:0.8rem;">Error</span>
<span style="background:${theme.warning};color:#000;padding:4px 10px;border-radius:4px;font-size:0.8rem;">Warning</span>
<span style="background:${theme.success};color:#000;padding:4px 10px;border-radius:4px;font-size:0.8rem;">Success</span>
<span style="background:${theme.info};color:#000;padding:4px 10px;border-radius:4px;font-size:0.8rem;">Info</span>
</div>
<hr style="border:none;border-top:1px solid ${theme.border};margin:1rem 0;">
<div style="display:flex;gap:6px;">
${palette.colors.map((c) => `<div style="width:20px;height:20px;border-radius:4px;background:${c};"></div>`).join('')}
</div>
</body>
</html>
`;
previewFrame.srcdoc = previewHTML;
}
// ─── Disabled Sites List ──────────────────────────────────────────────────────
function renderDisabledSites(sites: string[]): void {
disabledSitesList.innerHTML = '';
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 = `
<span>${site}</span>
<button class="remove-site" data-site="${site}" title="Re-enable this site">✕</button>
`;
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);
});
});
}
// ─── Listen for external changes ──────────────────────────────────────────────
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') return;
if (changes.disabledSites) {
renderDisabledSites(changes.disabledSites.newValue || []);
}
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.paletteYaml) {
const yaml = changes.paletteYaml.newValue || DEFAULT_PALETTE_YAML;
yamlEditor.value = yaml;
parseAndRender(yaml);
}
});

View File

@@ -0,0 +1,247 @@
/**
* themeEngine.ts — Parses Gogh YAML palettes and derives a semantic theme model.
*
* A Gogh palette provides:
* name, author, variant (light|dark)
* color_01 … color_16 (16 terminal colors)
* background, foreground, cursor
*
* This module:
* 1. Parses YAML using js-yaml
* 2. Validates all hex colors
* 3. Derives a SemanticTheme using hue + brightness analysis rather than
* fixed index mapping — so any arbitrary palette produces meaningful semantics.
*/
import * as yaml from 'js-yaml';
import {
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;
}
export interface ParseResult {
ok: true;
palette: GoghPalette;
theme: SemanticTheme;
}
export interface ParseError {
ok: false;
errors: string[];
}
// ─── Validation ───────────────────────────────────────────────────────────────
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);
}
// ─── YAML Parsing ─────────────────────────────────────────────────────────────
/**
* 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
}
}
// 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 ────────────────────────────────────────────────
/**
* Derive a semantic theme model from a 16-color Gogh palette.
*
* Instead of fixed index mapping (which is fragile), we analyze each color's
* hue and lightness in OKLCH space and assign roles based on perceptual properties:
*
* • Red-ish colors → error
* • Green-ish → success
* • Blue-ish → accentPrimary
* • Yellow/orange-ish → warning
* • Cyan-ish → info
* • Purple-ish → accentSecondary
* • Low chroma, mid lightness → muted
* • Darkest → used for surface derivation
* • Highest contrast to background → foreground verification
*
* When multiple colors match a role, we pick the one with the highest chroma
* (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;
// 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);
}
// 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 ──────────────────────────────────────────────────────────
/** A sensible default Dracula-ish palette in Gogh YAML format for out-of-box experience. */
export const DEFAULT_PALETTE_YAML = `name: Dracula
author: Zeno Rocha
variant: dark
color_01: "#282a36"
color_02: "#ff5555"
color_03: "#50fa7b"
color_04: "#f1fa8c"
color_05: "#bd93f9"
color_06: "#ff79c6"
color_07: "#8be9fd"
color_08: "#f8f8f2"
color_09: "#44475a"
color_10: "#ff6e6e"
color_11: "#69ff94"
color_12: "#ffffa5"
color_13: "#d6acff"
color_14: "#ff92df"
color_15: "#a4ffff"
color_16: "#ffffff"
background: "#282a36"
foreground: "#f8f8f2"
cursor: "#f8f8f2"
`;

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"types": ["chrome"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,108 @@
import { defineConfig, build as viteBuild, type UserConfig } from 'vite';
import { resolve } from 'path';
import { readFileSync, writeFileSync, mkdirSync, cpSync, existsSync, rmSync } from 'fs';
/**
* Vite build config for the Gogh Theme Engine Chrome Extension.
*
* Chrome MV3 content scripts CANNOT use ES module imports.
* Service workers CAN if the manifest specifies "type": "module".
*
* Strategy:
* • content.ts → IIFE bundle (no imports, self-contained)
* • background.ts → ESM (manifest has "type": "module")
* • options/ → Standard Vite HTML entry
*
* We solve the code-splitting problem by using a custom plugin that
* rebuilds content.ts as a separate IIFE after the main build.
*/
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',
},
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';
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',
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
};
});

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

56
manifest.json Normal file
View File

@@ -0,0 +1,56 @@
{
"manifest_version": 3,
"name": "Chrome Extension Template",
"description": "A Chrome Extension created using a template",
"version": "0.0.1",
"author": "Clyde D'Souza",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"omnibox": {
"keyword" : "@@"
},
"permissions": [
"storage"
],
"chrome_url_overrides" : {
"newtab": "newtab.html"
},
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [{
"matches": ["*://github.com/*"],
"js": ["content_scripts.js"]
}],
"action": {
"default_popup": "popup.html",
"default_title": "Open template popup"
},
"commands": {
"turn-on": {
"suggested_key": {
"default": "Alt + Shift + M"
},
"description": "Adds an ON badge to the action icon."
},
"turn-off": {
"suggested_key": {
"default": "Alt + Shift + N"
},
"description": "Adds an OFF badge to the action icon."
},
"_execute_action": {
"suggested_key": {
"default": "Alt + Shift + L"
}
}
}
}

11
newtab/newtab.html Normal file
View File

@@ -0,0 +1,11 @@
<html>
<head>
<meta charset="utf-8">
<title>Chrome Extension Template New Tab</title>
<meta name="viewport" content="initial-scale=1, maximum-scale=1" />
<meta name="description" content="Chrome Extension Template" />
<script src="newtab.js" type="text/javascript"></script>
<link href="newtab.css" rel="stylesheet" />
</head>
<body>This is the customized new tab page inserted by Chrome Extension Template</body>
</html>

13
newtab/scripts/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { getStorage } from "../../common/storage";
import { PRESET_CONFIGURATION, CHROME_SYNC_STORAGE_KEY } from "../../common/settings";
function loadConfiguration(result) {
const savedConfiguration = result || PRESET_CONFIGURATION;
const storageValue = savedConfiguration["storageValue"];
console.info("Storage value", storageValue);
}
window.onload = function() {
console.info("New Tab script loaded");
getStorage(CHROME_SYNC_STORAGE_KEY, loadConfiguration);
};

10
newtab/styles/_page.scss Normal file
View File

@@ -0,0 +1,10 @@
@import "./variables";
body {
background-color: $light-gray;
color: $dark-gray;
font: {
family: $font-family;
size: 16px;
}
}

View File

@@ -0,0 +1,3 @@
$dark-gray: #5600eb;
$light-gray: #b7d6ff;
$font-family: -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,'Fira Sans','Droid Sans','Helvetica Neue',sans-serif;

View File

@@ -0,0 +1,2 @@
@import "./variables";
@import "./page";

31
options/options.html Normal file
View File

@@ -0,0 +1,31 @@
<!-- (c) 2022 Clyde D'Souza -->
<html>
<head>
<meta charset="utf-8">
<title>Chrome Extension Template</title>
<meta name="viewport" content="initial-scale=1, maximum-scale=1" />
<meta name="description" content="Chrome Extension Template" />
<script src="options.js" type="text/javascript"></script>
<link href="options.css" rel="stylesheet" />
</head>
<body>
<main>
<form>
<label>
Chrome storage value
<input type="text" id="storageValue" aria-label="Chrome storage value" />
</label>
<br/>
<button id="SaveConfiguration" type="button" class="btn-primary">Save new Chrome Storage value</button>
</form>
</main>
<footer>
<a href="https://github.com/ClydeDz/google-meet-exit-page-chrome-extension/issues/new/choose" target="_blank" rel="noreferrer">
Visit this on GitHub</a><span class="link-separator"></span>
<a href="https://sponsor.clydedsouza.net/" target="_blank" rel="noreferrer">
Buy me a coffee</a><span class="link-separator"></span>
<a href="https://clydedsouza.net" target="_blank" rel="noreferrer">
Developed by Clyde D'Souza</a>
</footer>
</body>
</html>

21
options/scripts/index.js Normal file
View File

@@ -0,0 +1,21 @@
import { getStorage,setStorage } from "../../common/storage";
import { PRESET_CONFIGURATION, CHROME_SYNC_STORAGE_KEY } from "../../common/settings";
function saveConfiguration() {
const updatedConfiguration = {
storageValue: document.getElementById("storageValue").value
};
setStorage(CHROME_SYNC_STORAGE_KEY, updatedConfiguration);
}
function loadConfiguration(result) {
const savedConfiguration = result || PRESET_CONFIGURATION;
const storageValue = savedConfiguration["storageValue"];
document.getElementById("storageValue").value = storageValue;
}
window.onload = function() {
console.info("Options script loaded");
document.getElementById("SaveConfiguration").addEventListener("click", saveConfiguration);
getStorage(CHROME_SYNC_STORAGE_KEY, loadConfiguration);
};

10
options/styles/_page.scss Normal file
View File

@@ -0,0 +1,10 @@
@import "./variables";
body {
background-color: $light-gray;
color: $dark-gray;
font: {
family: $font-family;
size: 16px;
}
}

View File

@@ -0,0 +1,3 @@
$dark-gray: #6B6B6B;
$light-gray: #F4F5F7;
$font-family: -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,'Fira Sans','Droid Sans','Helvetica Neue',sans-serif;

View File

@@ -0,0 +1,2 @@
@import "./variables";
@import "./page";

11
popup/popup.html Normal file
View File

@@ -0,0 +1,11 @@
<html>
<head>
<meta charset="utf-8">
<title>Chrome Extension Template Popup</title>
<meta name="viewport" content="initial-scale=1, maximum-scale=1" />
<meta name="description" content="Chrome Extension Template" />
<script src="popup.js" type="text/javascript"></script>
<link href="popup.css" rel="stylesheet" />
</head>
<body>This is a Chrome Extension popup window</body>
</html>

13
popup/scripts/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { getStorage } from "../../common/storage";
import { PRESET_CONFIGURATION, CHROME_SYNC_STORAGE_KEY } from "../../common/settings";
function loadConfiguration(result) {
const savedConfiguration = result || PRESET_CONFIGURATION;
const storageValue = savedConfiguration["storageValue"];
console.info("Storage value", storageValue);
}
window.onload = function() {
console.info("Popup script loaded");
getStorage(CHROME_SYNC_STORAGE_KEY, loadConfiguration);
};

10
popup/styles/_page.scss Normal file
View File

@@ -0,0 +1,10 @@
@import "./variables";
body {
background-color: $light-gray;
color: $dark-gray;
font: {
family: $font-family;
size: 16px;
}
}

View File

@@ -0,0 +1,3 @@
$dark-gray: #830000;
$light-gray: #fdffd0;
$font-family: -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,'Fira Sans','Droid Sans','Helvetica Neue',sans-serif;

2
popup/styles/popup.scss Normal file
View File

@@ -0,0 +1,2 @@
@import "./variables";
@import "./page";