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.
22
background/index.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
130
gogh-theme-engine/README.md
Normal 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 (0–100%)
|
||||
- **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
23
gogh-theme-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
gogh-theme-engine/postcss.config.cjs
Normal file
@@ -0,0 +1,2 @@
|
||||
// No PostCSS plugins needed for this project
|
||||
module.exports = {};
|
||||
6
gogh-theme-engine/public/icons/icon.svg
Normal 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 |
BIN
gogh-theme-engine/public/icons/icon128.png
Normal file
|
After Width: | Height: | Size: 306 B |
BIN
gogh-theme-engine/public/icons/icon16.png
Normal file
|
After Width: | Height: | Size: 79 B |
BIN
gogh-theme-engine/public/icons/icon48.png
Normal file
|
After Width: | Height: | Size: 123 B |
33
gogh-theme-engine/public/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
183
gogh-theme-engine/src/background.ts
Normal 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, // 0–100
|
||||
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;
|
||||
}
|
||||
);
|
||||
710
gogh-theme-engine/src/color.ts
Normal 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; // green–red axis
|
||||
b: number; // blue–yellow axis
|
||||
}
|
||||
|
||||
/** OKLCH perceptual cylindrical color */
|
||||
export interface OKLCH {
|
||||
L: number; // lightness 0..1
|
||||
C: number; // chroma ≥0
|
||||
h: number; // hue in degrees 0..360
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
654
gogh-theme-engine/src/content.ts
Normal 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
@@ -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[];
|
||||
}
|
||||
332
gogh-theme-engine/src/observer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
399
gogh-theme-engine/src/options/options.html
Normal 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>
|
||||
371
gogh-theme-engine/src/options/options.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
247
gogh-theme-engine/src/themeEngine.ts
Normal 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"
|
||||
`;
|
||||
23
gogh-theme-engine/tsconfig.json
Normal 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"]
|
||||
}
|
||||
108
gogh-theme-engine/vite.config.ts
Normal 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
|
After Width: | Height: | Size: 897 B |
BIN
icons/icon16.png
Normal file
|
After Width: | Height: | Size: 300 B |
BIN
icons/icon48.png
Normal file
|
After Width: | Height: | Size: 552 B |
56
manifest.json
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
@import "./variables";
|
||||
|
||||
body {
|
||||
background-color: $light-gray;
|
||||
color: $dark-gray;
|
||||
font: {
|
||||
family: $font-family;
|
||||
size: 16px;
|
||||
}
|
||||
}
|
||||
3
newtab/styles/_variables.scss
Normal 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;
|
||||
2
newtab/styles/newtab.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "./variables";
|
||||
@import "./page";
|
||||
31
options/options.html
Normal 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
@@ -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
@@ -0,0 +1,10 @@
|
||||
@import "./variables";
|
||||
|
||||
body {
|
||||
background-color: $light-gray;
|
||||
color: $dark-gray;
|
||||
font: {
|
||||
family: $font-family;
|
||||
size: 16px;
|
||||
}
|
||||
}
|
||||
3
options/styles/_variables.scss
Normal 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;
|
||||
2
options/styles/options.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "./variables";
|
||||
@import "./page";
|
||||
11
popup/popup.html
Normal 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
@@ -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
@@ -0,0 +1,10 @@
|
||||
@import "./variables";
|
||||
|
||||
body {
|
||||
background-color: $light-gray;
|
||||
color: $dark-gray;
|
||||
font: {
|
||||
family: $font-family;
|
||||
size: 16px;
|
||||
}
|
||||
}
|
||||
3
popup/styles/_variables.scss
Normal 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
@@ -0,0 +1,2 @@
|
||||
@import "./variables";
|
||||
@import "./page";
|
||||