feat(FN-1583): merge fusion/fn-1583

This commit is contained in:
gsxdsm
2026-04-10 22:20:39 -07:00
parent 05229a5c0f
commit 3fdd04d6cb
4 changed files with 240 additions and 6 deletions

View File

@@ -96,7 +96,7 @@ if (cached) {
- There are **54 unique color themes** in `packages/dashboard/app/public/theme-data.css` (default, ocean, forest, sunset, zen, berry, high-contrast, industrial, monochrome, slate, ash, graphite, silver, solarized, factory, ayu, one-dark, nord, dracula, gruvbox, tokyo-night, catppuccin-mocha, github-dark, everforest, rose-pine, kanagawa, night-owl, palenight, monokai-pro, slime, brutalist, neon-city, parchment, terminal, glass, horizon, vitesse, outrun, snazzy, porple, espresso, mars, poimandres, ember, rust, copper, foundry, carbon, sandstone, lagoon, frost, lavender, neon-bloom, sepia). Each has a dark variant `[data-color-theme="<name>"]` and a light variant `[data-color-theme="<name>"][data-theme="light"]`. Theme blocks were extracted to a separate file in FN-1409 to enable lazy loading — theme-data.css is only loaded when a non-default color theme is active.
- When adding CSS custom properties that should be theme-aware (like `--accent`, `--status-*-bg`), add them to all 54 theme blocks plus `:root` and `[data-theme="light"]` base blocks. The test in `status-colors-theme.test.ts` iterates all blocks programmatically to prevent regressions.
- **Semantic tokens** (tokens describing purpose, not appearance) that maintain consistent meaning across all color themes (e.g., "autopilot active" is always green-tinted, "event error" is always red-tinted) only need dark/light adaptation via the base `[data-theme="light"]` block. They do NOT need per-color-theme overrides because the semantic meaning is consistent. Examples from FN-1357: `--autopilot-pulse`, `--event-*-text`, `--event-*-bg`, `--terminal-bg`, `--star-idle`, `--star-active`, `--badge-mission-*`, `--fab-*`.
- **Runtime-safe theme loading (FN-1526)**: The `theme-data.css` stylesheet URL is derived from `document.baseURI` rather than hardcoded paths. This ensures correct resolution in both HTTP/HTTPS contexts (uses `/theme-data.css`) and Electron `file://` contexts (derives path relative to HTML file directory). The same `getThemeDataUrl()` helper is used by both the pre-hydration inline script in `index.html` and the runtime `useTheme.ts` hook. **Bug fix (FN-1535)**: The initial implementation had a path joining bug where `new URL("theme-data.css", baseUrl)` was used incorrectly, producing malformed paths like `.../apptheme-data.css` instead of `.../app/theme-data.css`. The fix uses `url.resolve()` or explicit path joining with proper slash handling to ensure the URL always contains the correct slash separator between directory and filename. **Refinement (FN-1534)**: Fixed two additional issues: (1) URL resolution now correctly handles both trailing-slash directories (`/path/`) and filename paths (`/path/index.html`) by checking `base.endsWith('/')` and using appropriate slice/replace logic; (2) `loadThemeDataStylesheet()` now updates existing link href when stale instead of returning early, ensuring theme changes apply correctly even after page loads with different base URLs.
- **Runtime-safe theme loading (FN-1526)**: The `theme-data.css` stylesheet URL is derived from `document.baseURI` rather than hardcoded paths. This ensures correct resolution in both HTTP/HTTPS contexts (uses `/theme-data.css`) and Electron `file://` contexts (derives path relative to HTML file directory). The same `getThemeDataUrl()` helper is used by both the pre-hydration inline script in `index.html` and the runtime `useTheme.ts` hook. **Bug fix (FN-1535)**: The initial implementation had a path joining bug where `new URL("theme-data.css", baseUrl)` was used incorrectly, producing malformed paths like `.../apptheme-data.css` instead of `.../app/theme-data.css`. The fix uses `url.resolve()` or explicit path joining with proper slash handling to ensure the URL always contains the correct slash separator between directory and filename. **Refinement (FN-1534)**: Fixed two additional issues: (1) URL resolution now correctly handles both trailing-slash directories (`/path/`) and filename paths (`/path/index.html`) by checking `base.endsWith('/')` and using appropriate slice/replace logic; (2) `loadThemeDataStylesheet()` now updates existing link href when stale instead of returning early, ensuring theme changes apply correctly even after page loads with different base URLs. **Cascade ordering fix (FN-1583)**: Fixed the dark-mode cascade failure where color-theme CSS rules lost to base token redefinitions. The issue occurred when pre-hydration scripts injected `#theme-data` early in `<head>`, then `styles.css` loaded later and redefined `:root` tokens, overriding color-theme rules. The fix: after href reconciliation in `loadThemeDataStylesheet()`, the existing link is moved to the end of `<head>` via `appendChild()` so color-theme rules are evaluated after base token redefinitions. This ensures `[data-color-theme="..."]` selectors win the CSS cascade correctly.
## Plugin System (FN-1111 / FN-1400)

View File

@@ -890,8 +890,7 @@ describe("useTheme", () => {
// The correct implementation produces file:///app/theme-data.css
// These regexes catch the malformed pattern
expect(href).not.toMatch(/apptheme-data\.css$/);
expect(href).not.toMatch(/theme-data\.css$/ && !/\/theme-data\.css$/.test(href || ""));
// Verify it's actually a valid file URL
// Verify it's actually a valid file URL (no concatenation bug)
expect(href).toMatch(/^file:\/\/.*\/theme-data\.css$/);
// Clean up
@@ -900,6 +899,221 @@ describe("useTheme", () => {
configurable: true,
});
});
it("moves pre-existing theme-data link to end of head for correct cascade", () => {
// Regression test: When index.html pre-hydration script injects #theme-data early,
// the runtime hook must move it to the end of <head> so color-theme rules
// take precedence over base token redefinitions in later stylesheets.
//
// CSS cascade failure mode:
// 1. Pre-hydration script injects <link id="theme-data"> early in <head>
// 2. styles.css loads and re-defines :root tokens, overriding color-theme rules
// 3. Dark color themes appear broken because base tokens win the cascade
//
// The fix: after href reconciliation, append the existing link to end of head
// so its rules are evaluated after all other stylesheets.
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Set up base styles that would normally load after theme-data in real HTML
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root { --bg: #0d1117; --surface: #161b22; }
[data-theme="light"] { --bg: #ffffff; --surface: #f6f8fa; }
`;
document.head.appendChild(baseStyles);
// Inject theme-data link BEFORE base-styles (simulating pre-hydration injecting early)
const earlyLink = document.createElement("link");
earlyLink.id = "theme-data";
earlyLink.rel = "stylesheet";
earlyLink.href = "/theme-data.css";
document.head.insertBefore(earlyLink, baseStyles);
// Verify theme-data is early and base styles are after it
const headChildren = Array.from(document.head.children);
const themeDataIndex = headChildren.findIndex((el) => el.id === "theme-data");
const baseStylesIndex = headChildren.findIndex((el) => el.id === "base-styles");
expect(themeDataIndex).toBe(0);
expect(baseStylesIndex).toBe(1);
// Now switch to a non-default color theme via runtime hook
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("ocean");
});
// After runtime hook runs, theme-data link should be MOVED to end of head
const updatedHeadChildren = Array.from(document.head.children);
const newThemeDataIndex = updatedHeadChildren.findIndex((el) => el.id === "theme-data");
const newBaseStylesIndex = updatedHeadChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIndex).toBeGreaterThan(newBaseStylesIndex);
// Still only one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
it("dark mode color theme precedence is protected by link reordering", async () => {
// This test verifies the specific dark-mode cascade failure scenario:
// - Dark mode + non-default color theme
// - Pre-existing #theme-data link from pre-hydration
// - Base styles that redefine tokens after theme-data
//
// The color theme rules must win over base token redefinitions.
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "ocean";
// Mock fetch to resolve so isHydrating becomes false and useEffect runs
mockFetchGlobalSettings.mockResolvedValue({
themeMode: "dark",
colorTheme: "ocean",
});
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Simulate the HTML structure with theme-data injected early by pre-hydration
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root {
--bg: #0d1117;
--surface: #161b22;
--card: #21262d;
}
`;
document.head.appendChild(baseStyles);
// Pre-existing theme-data link (from pre-hydration)
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
// Insert before base-styles to simulate pre-hydration injecting early
document.head.insertBefore(existingLink, baseStyles);
// Verify pre-conditions: theme-data at index 0, base-styles at index 1
const children = Array.from(document.head.children);
const themeDataIdx = children.findIndex((el) => el.id === "theme-data");
const baseIdx = children.findIndex((el) => el.id === "base-styles");
expect(themeDataIdx).toBe(0);
expect(baseIdx).toBe(1);
// Run the hook
const { result } = renderHook(() => useTheme());
// Wait for hydration AND the theme-data loading useEffect to complete.
// The theme-data loading useEffect runs after colorTheme state updates,
// so we need to wait for the link to actually move.
await waitFor(() => {
expect(result.current.colorTheme).toBe("ocean");
});
// Wait for the link to be moved to end of head (separate useEffect)
await waitFor(() => {
const updatedChildren = Array.from(document.head.children);
const newThemeDataIdx = updatedChildren.findIndex((el) => el.id === "theme-data");
const newBaseIdx = updatedChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIdx).toBeGreaterThan(newBaseIdx);
});
// Only one #theme-data link should exist
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
// Dark mode attribute should be set
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("ocean");
});
it("light mode still works correctly with pre-existing theme-data link", async () => {
// Regression test: ensure the link-reordering fix doesn't break light mode
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
// Mock fetch to resolve so isHydrating becomes false and useEffect runs
mockFetchGlobalSettings.mockResolvedValue({
themeMode: "light",
colorTheme: "factory",
});
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Set up base styles that would load after theme-data
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root { --bg: #0d1117; }
[data-theme="light"] { --bg: #ffffff; }
`;
document.head.appendChild(baseStyles);
// Pre-existing theme-data link from pre-hydration (early in head)
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
document.head.insertBefore(existingLink, baseStyles);
const { result } = renderHook(() => useTheme());
// Wait for hydration AND the theme-data loading useEffect to complete.
// The theme-data loading useEffect runs after colorTheme state updates,
// so we need to wait for the link to actually move.
await waitFor(() => {
expect(result.current.colorTheme).toBe("factory");
});
// Wait for the link to be moved to end of head (separate useEffect)
await waitFor(() => {
const updatedChildren = Array.from(document.head.children);
const newThemeDataIdx = updatedChildren.findIndex((el) => el.id === "theme-data");
const newBaseIdx = updatedChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIdx).toBeGreaterThan(newBaseIdx);
});
// Verify light mode works
expect(result.current.themeMode).toBe("light");
expect(result.current.colorTheme).toBe("factory");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("factory");
// Only one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
it("only one theme-data link exists after multiple theme changes", () => {
// Ensure link reordering doesn't create duplicates
const { result } = renderHook(() => useTheme());
// Clear any existing elements
document.head.innerHTML = "";
// Pre-existing link
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
document.head.appendChild(existingLink);
// Multiple theme changes
act(() => result.current.setColorTheme("ocean"));
act(() => result.current.setColorTheme("forest"));
act(() => result.current.setColorTheme("nord"));
act(() => result.current.setColorTheme("default"));
act(() => result.current.setColorTheme("dracula"));
// Should still only have one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
});
});

View File

@@ -38,7 +38,7 @@ function getThemeDataUrl(): string {
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
} else {
// Filename path: replace last segment with "/theme-data.css"
return base.replace(/\/[^\/]+$/, `/${THEME_DATA_FILENAME}`);
return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`);
}
}
@@ -129,7 +129,14 @@ function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, syst
/**
* Load theme-data.css for non-default themes.
* Safely handles existing links by checking href and updating if stale.
* This ensures correct URL resolution when baseURI changes between renders.
* After href reconciliation, existing links are moved to the end of <head>
* to ensure color-theme CSS rules take precedence over base token
* redefinitions in subsequent stylesheets (CSS cascade correctness).
*
* This is critical for dark mode: if #theme-data is injected early by
* pre-hydration scripts but styles.css loads later and redefines base
* tokens, those redefinitions win the cascade unless theme-data is
* repositioned to come after them.
*/
function loadThemeDataStylesheet(): void {
if (!isBrowser) return;
@@ -142,7 +149,14 @@ function loadThemeDataStylesheet(): void {
if (existingLink.href !== expectedHref) {
existingLink.href = expectedHref;
}
// If href matches, link is already correct - nothing to do
// Move existing link to end of <head> for CSS cascade correctness.
// This ensures color-theme rules (which use [data-color-theme="..."] selectors)
// are evaluated AFTER any subsequent stylesheets that might redefine base tokens.
// Without this, dark color themes can appear broken because base token
// redefinitions win the cascade over color-theme rules.
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
document.head.appendChild(existingLink);
}
return;
}

View File

@@ -57,6 +57,12 @@
if (existingLink.href !== themeDataUrl) {
existingLink.href = themeDataUrl;
}
// Move existing link to end of <head> for CSS cascade correctness.
// This ensures color-theme rules take precedence over base token
// redefinitions in subsequent stylesheets.
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
document.head.appendChild(existingLink);
}
} else {
// No existing link - create one
var link = document.createElement('link');