[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
288
application/state/useActiveChromeTheme.ts
Normal file
288
application/state/useActiveChromeTheme.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user