Files
NetMesh/application/state/useActiveChromeTheme.ts

289 lines
9.0 KiB
TypeScript
Raw Normal View History

import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
applyTopTabsChromeThemeVars,
clearTopTabsChromeThemeVars,
} from "../app/topTabsChromeTheme";
import { runThemeTransition } from "./themeTransition";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { resolveReadableForegroundForHsl } from "../../domain/colorContrast";
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const nextLightness = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(nextLightness * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const nextSaturation = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(nextSaturation * 10) / 10}% ${parts[2]}`;
}
const CSS_VARS = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"border",
"input",
"ring",
] as const;
function buildChromeCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === "dark";
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const primaryFg = resolveReadableForegroundForHsl(cursor);
const values = [
bg, fg, card, fg,
card, fg,
cursor, primaryFg,
secondary, fg,
muted, mutedFg,
cursor, primaryFg,
"0 70% 50%", "0 0% 100%",
border, border, cursor,
];
const rules = CSS_VARS.map((name, index) => `--${name}: ${values[index]} !important`).join("; ");
return [
`:root { ${rules}; }`,
`:root[data-active-chrome-theme] [data-agent-badge] { border-color: hsl(var(--primary) / 0.2) !important; background-color: hsl(var(--primary) / 0.1) !important; }`,
].join("\n");
}
const cssCache = new Map<string, string>();
export const CHROME_THEME_CSS_CACHE_MAX_ENTRIES = 256;
export function themeFingerprint(theme: TerminalTheme): string {
return `${theme.id}\0${theme.type}\0${theme.colors.background}\0${theme.colors.foreground}\0${theme.colors.cursor}`;
}
function getAppliedChromeFingerprint(): string | null {
if (typeof document === "undefined") return null;
return document.documentElement.dataset.activeChromeTheme ?? null;
}
function retainChromeCss(fingerprint: string, css: string): string {
cssCache.delete(fingerprint);
cssCache.set(fingerprint, css);
while (cssCache.size > CHROME_THEME_CSS_CACHE_MAX_ENTRIES) {
const oldestFingerprint = cssCache.keys().next().value as string | undefined;
if (oldestFingerprint === undefined) break;
cssCache.delete(oldestFingerprint);
}
return css;
}
for (const theme of TERMINAL_THEMES) {
retainChromeCss(themeFingerprint(theme), buildChromeCss(theme));
}
function getChromeCss(theme: TerminalTheme): string {
const fingerprint = themeFingerprint(theme);
let css = cssCache.get(fingerprint);
if (!css) {
css = buildChromeCss(theme);
} else {
cssCache.delete(fingerprint);
}
return retainChromeCss(fingerprint, css);
}
export function _cacheChromeThemeCssForTests(theme: TerminalTheme): void {
getChromeCss(theme);
}
export function _getChromeThemeCssCacheStatsForTests(): { size: number; keys: string[] } {
return { size: cssCache.size, keys: [...cssCache.keys()] };
}
const STYLE_ID = "netcatty-active-chrome-theme";
/** Double-rAF window used to let layout settle after a paint. */
export const INSTANT_THEME_SWITCH_SETTLE_FRAMES = 2;
function getAnimationView(root: HTMLElement) {
return root.ownerDocument?.defaultView ?? globalThis.window;
}
/** Run after instant theme switch finishes suppressing CSS transitions. */
export function scheduleAfterInstantThemeSwitch(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
const frameIds: number[] = [];
const scheduleFrames = (remaining: number) => {
const frameId = requestFrame(() => {
const index = frameIds.indexOf(frameId);
if (index >= 0) frameIds.splice(index, 1);
if (remaining <= 1) {
callback();
return;
}
scheduleFrames(remaining - 1);
});
frameIds.push(frameId);
};
scheduleFrames(INSTANT_THEME_SWITCH_SETTLE_FRAMES);
return () => {
for (const frameId of frameIds) cancelFrame(frameId);
};
}
/**
* Run one frame after instant theme switch settles so layout transitions can
* start from the pre-animation state without `transition: none` on :root.
*/
export function scheduleChromeLayoutAnimation(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
let layoutFrameId = 0;
const cancelSettle = scheduleAfterInstantThemeSwitch(() => {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
layoutFrameId = requestFrame(() => callback());
}, root);
return () => {
cancelSettle();
const view = getAnimationView(root);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
if (layoutFrameId) cancelFrame(layoutFrameId);
};
}
function removeActiveChromeTheme() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.activeChromeTheme;
}
function applyActiveChromeTheme(theme: TerminalTheme) {
runThemeTransition(() => {
const root = document.documentElement;
const targetClass = theme.type === "dark" ? "dark" : "light";
root.classList.remove("light", "dark");
root.classList.add(targetClass);
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = getChromeCss(theme);
root.dataset.activeChromeTheme = themeFingerprint(theme);
refreshActiveChromeThemeSurfaces(theme);
}, { mode: "instant" });
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
}
applyTopTabsChromeThemeVars(theme);
}
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) {
return;
}
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
return;
}
clearTopTabsChromeThemeVars();
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
}, { mode: "instant" });
}
export function useActiveChromeTheme({
activeTheme,
applyAppTheme,
}: {
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
useLayoutEffect(() => {
return () => {
removeActiveChromeTheme();
clearTopTabsChromeThemeVars();
applyAppThemeRef.current();
};
}, []);
}