- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
116 lines
4.4 KiB
TypeScript
116 lines
4.4 KiB
TypeScript
import { useEffect } from "react";
|
|
import type { Theme, FontSize } from "../lib/types";
|
|
|
|
const LIGHT_COLOR_SCHEME_QUERY = "(prefers-color-scheme: light)";
|
|
|
|
/**
|
|
* Resolves a Theme setting to the concrete color scheme it maps to (pure).
|
|
* For "system" this reads the webview's prefers-color-scheme, which correctly
|
|
* mirrors the OS only while the native window is NOT forced to a specific
|
|
* theme (see applyTheme's system handling).
|
|
*/
|
|
export function resolveTheme(theme: Theme): "light" | "dark" {
|
|
if (theme === "dark") return "dark";
|
|
if (theme === "light") return "light";
|
|
// "system" — follow the OS preference. Guard for environments without matchMedia.
|
|
if (typeof window.matchMedia !== "function") return "dark";
|
|
return window.matchMedia(LIGHT_COLOR_SCHEME_QUERY).matches ? "light" : "dark";
|
|
}
|
|
|
|
/**
|
|
* Syncs the native window chrome. Pass null to reset the window to follow the
|
|
* OS theme — required for the "system" setting, because forcing the window
|
|
* theme changes the webview's prefers-color-scheme (WKWebView follows the
|
|
* window appearance), which would otherwise pollute matchMedia.
|
|
*/
|
|
async function syncWindowTheme(effective: "light" | "dark" | null): Promise<void> {
|
|
try {
|
|
// Dynamic import keeps the Tauri API out of the hot path for
|
|
// non-Tauri bundles and non-Tauri test environments.
|
|
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
|
const win = getCurrentWindow();
|
|
if (effective === null) {
|
|
await win.setTheme(null);
|
|
return;
|
|
}
|
|
await win.setTheme(effective);
|
|
// macOS "Overlay" title bar paints the WINDOW background color in the
|
|
// title bar strip; keep it in sync with the theme.
|
|
await win.setBackgroundColor(effective === "light" ? "#FAFAFA" : "#0A0A0B");
|
|
} catch {
|
|
// Outside Tauri — nothing to sync.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the theme to the document root and returns a cleanup that
|
|
* stops tracking the system preference while the theme is "system".
|
|
* Also syncs the native window chrome (fire-and-forget; noop outside Tauri).
|
|
*/
|
|
export function applyTheme(theme: Theme): () => void {
|
|
const root = document.documentElement;
|
|
|
|
if (theme === "dark") {
|
|
root.classList.remove("light");
|
|
void syncWindowTheme("dark");
|
|
return () => {};
|
|
}
|
|
if (theme === "light") {
|
|
root.classList.add("light");
|
|
void syncWindowTheme("light");
|
|
return () => {};
|
|
}
|
|
|
|
// "system" — follow the OS preference and keep it in sync.
|
|
// Guard for environments without matchMedia (e.g. jsdom).
|
|
if (typeof window.matchMedia !== "function") {
|
|
root.classList.remove("light");
|
|
void syncWindowTheme(null);
|
|
return () => {};
|
|
}
|
|
const mq = window.matchMedia(LIGHT_COLOR_SCHEME_QUERY);
|
|
const applySystem = () => {
|
|
root.classList.toggle("light", mq.matches);
|
|
void syncWindowTheme(null);
|
|
};
|
|
applySystem();
|
|
// Reset the native window to follow the OS first, then re-read matchMedia
|
|
// once it actually mirrors the OS — the immediate applySystem above may be
|
|
// stale if the window was previously forced to the other theme.
|
|
void syncWindowTheme(null).then(applySystem);
|
|
mq.addEventListener("change", applySystem);
|
|
return () => mq.removeEventListener("change", applySystem);
|
|
}
|
|
|
|
/** Applies the font-size scale by toggling a data attribute on the root. */
|
|
export function applyFontSize(fontSize: FontSize): void {
|
|
const root = document.documentElement;
|
|
if (fontSize === "medium") {
|
|
delete root.dataset.fontSize;
|
|
} else {
|
|
root.dataset.fontSize = fontSize;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the accent color as a CSS custom property on the root. Invalid or
|
|
* non-hex values fall back to the theme default (via removal).
|
|
*/
|
|
export function applyAccentColor(accent: string): void {
|
|
const root = document.documentElement;
|
|
if (/^#[0-9a-fA-F]{6}$/.test(accent)) {
|
|
root.style.setProperty("--color-accent", accent);
|
|
} else {
|
|
root.style.removeProperty("--color-accent");
|
|
}
|
|
}
|
|
|
|
/** Keeps the document appearance in sync with the theme/font-size settings. */
|
|
export function useAppearance(theme: Theme, fontSize: FontSize, accentColor: string): void {
|
|
useEffect(() => {
|
|
const cleanupTheme = applyTheme(theme);
|
|
applyFontSize(fontSize);
|
|
applyAccentColor(accentColor);
|
|
return cleanupTheme;
|
|
}, [theme, fontSize, accentColor]);
|
|
} |