diff --git a/.changeset/fn-8455-calm-theme.md b/.changeset/fn-8455-calm-theme.md new file mode 100644 index 0000000000..937a43dd19 --- /dev/null +++ b/.changeset/fn-8455-calm-theme.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add the Calm dashboard theme with slate, sage, and misty light palettes. +category: feature +dev: Adds persisted calm theme tokens, first-paint validation, and shared selector support. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 47f3c5335f..ee7adf8bcb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1959,8 +1959,9 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok + -Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 82 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), and Aurora (navy/teal/violet dark surfaces with a misty blue-white light counterpart), the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink. +Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 83 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), Aurora (navy/teal/violet dark surfaces with a misty blue-white light counterpart), and Calm (low-stimulation slate/sage dark surfaces with a misty blue-white light counterpart), the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink. Choose color themes from **Settings → Appearance** or from the Command Center **Overview** theme card. Settings merges its selector into the current-theme row, which opens the full color-theme list; Command Center keeps the compact `ThemeDropdown` trigger. Both list every selectable color theme with the same labels and color-chip swatches. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 5546cbb1bb..e20597e5d1 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -52,7 +52,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | Setting | Type | Default | Description | |---|---|---:|---| | `themeMode` | `"dark" \| "light" \| "system"` | `"system"` | Dashboard theme mode. Fresh installs follow the operating system light/dark preference until the user chooses Light, Dark, or System. | -| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Aurora is a built-in navy/teal/violet palette with a misty light counterpart. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. | +| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Aurora is a built-in navy/teal/violet palette with a misty light counterpart; Calm is a low-stimulation slate/sage palette with a misty light counterpart. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. | | `shadcnCustomColors` | `Record` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. | | `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 45fba05d03..84e311d339 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -237,6 +237,14 @@ describe("GlobalSettingsStore", () => { await expect(new GlobalSettingsStore(dir).getSettings()).resolves.toMatchObject({ colorTheme: "aurora" }); }); + it("round-trips the Calm color theme through persisted settings", async () => { + await store.init(); + await store.updateSettings({ colorTheme: "calm" }); + + await expect(store.getSettings()).resolves.toMatchObject({ colorTheme: "calm" }); + await expect(new GlobalSettingsStore(dir).getSettings()).resolves.toMatchObject({ colorTheme: "calm" }); + }); + it("persists and clears the planner clarification preference", async () => { await store.init(); diff --git a/packages/core/src/types/execution-and-ui.ts b/packages/core/src/types/execution-and-ui.ts index 4c6347521b..1837d4382f 100644 --- a/packages/core/src/types/execution-and-ui.ts +++ b/packages/core/src/types/execution-and-ui.ts @@ -123,6 +123,8 @@ export const COLOR_THEMES = [ "moss", // FNXC:DashboardTheming 2026-07-20-00:00: Aurora is a persisted dashboard theme; keep this ID and order synchronized with selector metadata and web/desktop first-paint validators so saved selections survive validation before React hydrates. "aurora", + // FNXC:DashboardTheming 2026-07-20-00:00: Calm is persisted; keep its ID and order synchronized across core, selectors, and both pre-hydration validators so saved selections survive validation before React hydrates. + "calm", "shadcn", // FNXC:DashboardTheming 2026-06-30-00:00: Shadcn Ember is the default for unset installs; keep it adjacent to the shadcn base so dashboard options and bootstrap validators preserve published theme order while explicit legacy ids remain valid. "shadcn-ember", diff --git a/packages/dashboard/app/__tests__/calm-theme.test.ts b/packages/dashboard/app/__tests__/calm-theme.test.ts new file mode 100644 index 0000000000..941f310563 --- /dev/null +++ b/packages/dashboard/app/__tests__/calm-theme.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { COLOR_THEMES as CORE_COLOR_THEMES } from "@fusion/core"; +import { COLOR_THEMES as DASHBOARD_COLOR_THEMES } from "../components/themeOptions"; + +const themeDataPath = path.resolve(__dirname, "../public/theme-data.css"); +const themeSelectorPath = path.resolve(__dirname, "../components/ThemeSelector.css"); +const dashboardIndexPath = path.resolve(__dirname, "../index.html"); +const desktopIndexPath = path.resolve(__dirname, "../../../desktop/src/renderer/index.html"); + +/* +FNXC:DashboardTheming 2026-07-20-00:00: +Calm is valid only when persistence, first-paint validators, selector metadata, tokens, and globally resolvable previews agree. This source contract catches a partial registration before a saved operator preference can flash or fall back. +*/ +describe("Calm color theme", () => { + const themeData = readFileSync(themeDataPath, "utf-8"); + const themeSelector = readFileSync(themeSelectorPath, "utf-8"); + const dashboardIndexHtml = readFileSync(dashboardIndexPath, "utf-8"); + const desktopIndexHtml = readFileSync(desktopIndexPath, "utf-8"); + + it("keeps persisted, selector, and first-paint registries in exact order", () => { + const coreIds = [...CORE_COLOR_THEMES]; + const dashboardIds = DASHBOARD_COLOR_THEMES.map((theme) => theme.value); + const dashboardValidThemes = extractValidThemes(dashboardIndexHtml); + const desktopValidThemes = extractValidThemes(desktopIndexHtml); + + expect(CORE_COLOR_THEMES.filter((theme) => theme === "calm")).toHaveLength(1); + expect(DASHBOARD_COLOR_THEMES).toContainEqual({ + value: "calm", + label: "Calm", + className: "theme-swatch-calm", + }); + expect(dashboardIds).toEqual(coreIds); + expect(dashboardValidThemes).toEqual(coreIds); + expect(desktopValidThemes).toEqual(coreIds); + for (const ids of [coreIds, dashboardIds, dashboardValidThemes, desktopValidThemes]) { + expect(new Set(ids).size).toBe(ids.length); + } + expect(dashboardIndexHtml).toContain("colorTheme = 'shadcn-ember'"); + expect(desktopIndexHtml).toContain('colorTheme = "shadcn-ember"'); + }); + + it("defines complete readable dark and light Calm token blocks", () => { + const darkBlock = extractSelectorBlock(themeData, '[data-color-theme="calm"]'); + const lightBlock = extractSelectorBlock(themeData, '[data-color-theme="calm"][data-theme="light"]'); + const requiredTokens = [ + "--bg:", "--surface:", "--card:", "--border:", "--text:", "--color-success:", + "--color-warning:", "--color-error:", "--color-info:", "--cta-bg:", "--accent:", + "--accent-text:", "--focus-ring:", "--shadow-glow:", + ]; + + for (const block of [darkBlock, lightBlock]) { + for (const token of requiredTokens) expect(block).toContain(token); + } + expect(darkBlock).toContain("--bg: #111d2b;"); + expect(darkBlock).toContain("--accent: #8fc9b5;"); + expect(lightBlock).toContain("--bg: #f4f8fb;"); + expect(lightBlock).toContain("--accent: #2f7260;"); + }); + + it("uses mode-specific global Calm preview properties for an unselected swatch", () => { + const darkGlobals = extractSelectorBlock(themeData, ":root"); + const lightGlobals = extractSelectorBlock(themeData, '[data-theme="light"]'); + const darkSwatch = extractSelectorBlock(themeSelector, ".theme-swatch-calm"); + const lightSwatch = extractSelectorBlock(themeSelector, '[data-theme="light"] .theme-swatch-calm'); + + for (const block of [darkGlobals, lightGlobals]) { + for (const sample of [1, 2, 3, 4]) expect(block).toContain(`--calm-swatch-sample-${sample}:`); + } + for (const block of [darkSwatch, lightSwatch]) { + for (const sample of [1, 2, 3, 4]) { + expect(block).toContain(`--swatch-sample-${sample}: var(--calm-swatch-sample-${sample});`); + } + expect(block).not.toContain("var(--accent)"); + expect(block).not.toContain("var(--bg)"); + } + }); +}); + +function extractValidThemes(html: string): string[] { + const match = html.match(/var validThemes = \[([\s\S]*?)\];/); + if (!match) throw new Error("Could not find pre-hydration validThemes array"); + return [...match[1].matchAll(/["']([^"']+)["']/g)].map((themeMatch) => themeMatch[1]); +} + +function extractSelectorBlock(css: string, selector: string): string { + const startIdx = css.indexOf(`${selector} {`); + if (startIdx === -1) throw new Error(`Could not find selector block: ${selector}`); + const openBraceIdx = css.indexOf("{", startIdx); + let depth = 1; + for (let index = openBraceIdx + 1; index < css.length; index++) { + if (css[index] === "{") depth++; + if (css[index] === "}") depth--; + if (depth === 0) return css.slice(startIdx, index + 1); + } + throw new Error(`Could not find closing brace for selector block: ${selector}`); +} diff --git a/packages/dashboard/app/components/ThemeSelector.css b/packages/dashboard/app/components/ThemeSelector.css index 98102bb857..efa1952f53 100644 --- a/packages/dashboard/app/components/ThemeSelector.css +++ b/packages/dashboard/app/components/ThemeSelector.css @@ -319,6 +319,13 @@ --swatch-sample-4: var(--aurora-swatch-sample-4); } +.theme-swatch-calm { + --swatch-sample-1: var(--calm-swatch-sample-1); + --swatch-sample-2: var(--calm-swatch-sample-2); + --swatch-sample-3: var(--calm-swatch-sample-3); + --swatch-sample-4: var(--calm-swatch-sample-4); +} + .theme-swatch-silver { --swatch-sample-1: #27272a; --swatch-sample-2: #3f3f46; @@ -1153,6 +1160,13 @@ --swatch-sample-4: var(--aurora-swatch-sample-4); } +[data-theme="light"] .theme-swatch-calm { + --swatch-sample-1: var(--calm-swatch-sample-1); + --swatch-sample-2: var(--calm-swatch-sample-2); + --swatch-sample-3: var(--calm-swatch-sample-3); + --swatch-sample-4: var(--calm-swatch-sample-4); +} + [data-theme="light"] .theme-swatch-silver { --swatch-sample-1: #fafafa; --swatch-sample-2: #f4f4f5; diff --git a/packages/dashboard/app/components/__tests__/ThemeDropdown.test.tsx b/packages/dashboard/app/components/__tests__/ThemeDropdown.test.tsx index fdf51a300e..bdc6030704 100644 --- a/packages/dashboard/app/components/__tests__/ThemeDropdown.test.tsx +++ b/packages/dashboard/app/components/__tests__/ThemeDropdown.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { ThemeDropdown } from "../ThemeDropdown"; // FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly. -const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; +const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'calm', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; function renderedThemeIds(listbox: HTMLElement) { return within(listbox).getAllByRole("option").map((option) => { @@ -88,13 +88,13 @@ describe("ThemeDropdown", () => { it.each([ ["compact", "compact" as const, /ocean/i], ["current-row", "current-row" as const, /current theme dark \/ ocean/i], - ])("selects Aurora with a non-empty preview from the shared %s trigger", (_label, triggerVariant, triggerName) => { + ])("selects Calm with a non-empty preview from the shared %s trigger", (_label, triggerVariant, triggerName) => { document.documentElement.setAttribute("data-color-theme", "ocean"); document.documentElement.setAttribute("data-theme", "dark"); const previewTokens = document.createElement("style"); previewTokens.textContent = ` - :root { --aurora-swatch-sample-1: #0a1022; --aurora-swatch-sample-2: #162044; --aurora-swatch-sample-3: #65e6c7; --aurora-swatch-sample-4: #8d8cff; } - [data-theme="light"] { --aurora-swatch-sample-1: #f4f8ff; --aurora-swatch-sample-2: #dfeaf8; --aurora-swatch-sample-3: #197d72; --aurora-swatch-sample-4: #5652b8; } + :root { --calm-swatch-sample-1: #111d2b; --calm-swatch-sample-2: #1b2a3a; --calm-swatch-sample-3: #8fc9b5; --calm-swatch-sample-4: #7da9c4; } + [data-theme="light"] { --calm-swatch-sample-1: #f4f8fb; --calm-swatch-sample-2: #e1ebf0; --calm-swatch-sample-3: #2f7260; --calm-swatch-sample-4: #396f91; } `; document.head.appendChild(previewTokens); const onColorThemeChange = vi.fn(); @@ -109,14 +109,14 @@ describe("ThemeDropdown", () => { ); fireEvent.click(screen.getByRole("button", { name: triggerName })); - const auroraOption = screen.getByRole("option", { name: "Aurora" }); - expect(auroraOption.querySelector(".theme-swatch-aurora")).toBeTruthy(); - expect(auroraOption.querySelectorAll(".theme-option-swatch-sample")).toHaveLength(4); + const calmOption = screen.getByRole("option", { name: "Calm" }); + expect(calmOption.querySelector(".theme-swatch-calm")).toBeTruthy(); + expect(calmOption.querySelectorAll(".theme-option-swatch-sample")).toHaveLength(4); for (const sample of [1, 2, 3, 4]) { - expect(getComputedStyle(document.documentElement).getPropertyValue(`--aurora-swatch-sample-${sample}`).trim()).not.toBe(""); + expect(getComputedStyle(document.documentElement).getPropertyValue(`--calm-swatch-sample-${sample}`).trim()).not.toBe(""); } - fireEvent.click(auroraOption); - expect(onColorThemeChange).toHaveBeenCalledWith("aurora"); + fireEvent.click(calmOption); + expect(onColorThemeChange).toHaveBeenCalledWith("calm"); expect(screen.queryByRole("listbox")).toBeNull(); document.documentElement.setAttribute("data-theme", "light"); @@ -129,7 +129,7 @@ describe("ThemeDropdown", () => { />, ); for (const sample of [1, 2, 3, 4]) { - expect(getComputedStyle(document.documentElement).getPropertyValue(`--aurora-swatch-sample-${sample}`).trim()).not.toBe(""); + expect(getComputedStyle(document.documentElement).getPropertyValue(`--calm-swatch-sample-${sample}`).trim()).not.toBe(""); } previewTokens.remove(); }); diff --git a/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx b/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx index 1862df1c2c..bd908520b3 100644 --- a/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx +++ b/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx @@ -4,7 +4,7 @@ import type { ColorTheme } from "@fusion/core"; import { ThemeSelector } from "../ThemeSelector"; // FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly. -const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; +const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'calm', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; function renderedThemeIds(listbox: HTMLElement) { return within(listbox).getAllByRole("option").map((option) => { diff --git a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx index 5507f6494d..3da08d1528 100644 --- a/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/CommandCenterControls.test.tsx @@ -74,7 +74,7 @@ function expectCommandCenterUseOffset(testId: string, ratio: number) { } // FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly. -const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; +const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'calm', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const; function renderedThemeIds(listbox: HTMLElement) { return within(listbox).getAllByRole("option").map((option) => { diff --git a/packages/dashboard/app/components/themeOptions.ts b/packages/dashboard/app/components/themeOptions.ts index d48d127197..2240135fad 100644 --- a/packages/dashboard/app/components/themeOptions.ts +++ b/packages/dashboard/app/components/themeOptions.ts @@ -76,6 +76,7 @@ export const COLOR_THEMES: { value: ColorTheme; label: string; className: string { value: "clay", label: "Clay", className: "theme-swatch-clay" }, { value: "moss", label: "Moss", className: "theme-swatch-moss" }, { value: "aurora", label: "Aurora", className: "theme-swatch-aurora" }, + { value: "calm", label: "Calm", className: "theme-swatch-calm" }, { value: "shadcn", label: "Shadcn", className: "theme-swatch-shadcn" }, { value: "shadcn-ember", label: "Shadcn Ember (Default)", className: "theme-swatch-shadcn-ember" }, { value: "shadcn-custom", label: "Shadcn Custom", className: "theme-swatch-shadcn-custom" }, diff --git a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts index bde46693f2..d1312c7846 100644 --- a/packages/dashboard/app/hooks/__tests__/useTheme.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTheme.test.ts @@ -186,6 +186,18 @@ describe("useTheme", () => { expect(document.documentElement.getAttribute("data-color-theme")).toBe("aurora"); }); + it("hydrates, caches, and applies Calm from backend settings", async () => { + mockFetchGlobalSettings.mockResolvedValue({ colorTheme: "calm" }); + + const { result } = renderHook(() => useTheme()); + + await waitFor(() => { + expect(result.current.colorTheme).toBe("calm"); + }); + expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("calm"); + expect(document.documentElement.getAttribute("data-color-theme")).toBe("calm"); + }); + it("hydrates dashboard font scale from backend on mount", async () => { mockFetchGlobalSettings.mockResolvedValue({ dashboardFontScalePct: 110 }); diff --git a/packages/dashboard/app/index.html b/packages/dashboard/app/index.html index 21c9b972ef..f87969d9c0 100644 --- a/packages/dashboard/app/index.html +++ b/packages/dashboard/app/index.html @@ -164,7 +164,7 @@ mode = 'system'; } var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'shadcn-ember'; - var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue']; + var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', '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', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'aurora', 'calm', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue']; // FNXC:DashboardTheming 2026-06-30-00:00: Unset startup theme is Shadcn Ember; explicit stored legacy ids such as "default" and "ocean" remain valid and must not be migrated. // FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores Shadcn Mono as a distinct persisted choice, so bootstrap must retain its stored id instead of remapping it to Mono Red. if (!validThemes.includes(colorTheme)) { diff --git a/packages/dashboard/app/public/theme-data.css b/packages/dashboard/app/public/theme-data.css index 06f3311bbc..27c72628ea 100644 --- a/packages/dashboard/app/public/theme-data.css +++ b/packages/dashboard/app/public/theme-data.css @@ -8,12 +8,16 @@ Theme token translucency uses color-mix(in srgb, var(--token) N%, transparent) or exact literal color-mix values for glass surfaces. Raw RGB alpha color functions are banned outside var() fallbacks by FN-6489. ============================================================ */ -/* Aurora swatches stay globally resolvable while a different theme is active. */ +/* Aurora and Calm swatches stay globally resolvable while a different theme is active. */ :root { --aurora-swatch-sample-1: #0a1022; --aurora-swatch-sample-2: #162044; --aurora-swatch-sample-3: #65e6c7; --aurora-swatch-sample-4: #8d8cff; + --calm-swatch-sample-1: #111d2b; + --calm-swatch-sample-2: #1b2a3a; + --calm-swatch-sample-3: #8fc9b5; + --calm-swatch-sample-4: #7da9c4; } [data-theme="light"] { @@ -21,6 +25,10 @@ --aurora-swatch-sample-2: #dfeaf8; --aurora-swatch-sample-3: #197d72; --aurora-swatch-sample-4: #5652b8; + --calm-swatch-sample-1: #f4f8fb; + --calm-swatch-sample-2: #e1ebf0; + --calm-swatch-sample-3: #2f7260; + --calm-swatch-sample-4: #396f91; } /* OCEAN - Deep blues and cyans */ @@ -7459,3 +7467,79 @@ Aurora gives operators navy/teal/violet dark surfaces and misty light surfaces. --focus-ring: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); --focus-ring-strong: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent); } + +/* +FNXC:DashboardTheming 2026-07-20-00:00: +Calm provides low-stimulation slate-and-sage dark surfaces and misty light surfaces. Literal palette values stay in canonical theme tokens here, while global preview values keep the unselected option accurate in either mode. +*/ +[data-color-theme="calm"] { + --bg: #111d2b; + --surface: #172536; + --card: #1b2a3a; + --card-hover: #26394a; + --surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%); + --border: #34495a; + --text: #e8f0f2; + --text-muted: #b3c5c8; + --text-dim: #819aa3; + + --todo: #8aaed0; + --in-progress: #79b9bb; + --in-progress-rgb: 121, 185, 187; + --in-review: #a79bc9; + --triage: #d5af6c; + --done: #8b9daa; + --color-success: #8fc9b5; + --color-warning: #d5af6c; + --color-error: #d98798; + --color-info: #7da9c4; + + --cta-bg: #5b9984; + --cta-border: #8fc9b5; + --cta-text: #10241f; + --cta-bg-hover: #70ad98; + --cta-border-hover: #b1dfce; + --cta-glow: 0 0 8px color-mix(in srgb, var(--cta-border) 28%, transparent); + --accent: #8fc9b5; + --accent-text: #10241f; + --logo-accent: var(--accent); + --shadow-glow: 0 0 12px color-mix(in srgb, var(--accent) 24%, transparent); + --focus-ring: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent); + --focus-ring-strong: 0 0 0 2px color-mix(in srgb, var(--accent) 34%, transparent); +} + +[data-color-theme="calm"][data-theme="light"] { + --bg: #f4f8fb; + --surface: #e9f1f5; + --card: #ffffff; + --card-hover: #dce8ee; + --surface-hover: color-mix(in srgb, var(--surface) 92%, var(--text) 8%); + --border: #bfd0d8; + --text: #193242; + --text-muted: #4e6773; + --text-dim: #728994; + + --todo: #396f91; + --in-progress: #2f726f; + --in-progress-rgb: 47, 114, 111; + --in-review: #665b91; + --triage: #8e641d; + --done: #647c89; + --color-success: #2f7260; + --color-warning: #8e641d; + --color-error: #ad4357; + --color-info: #396f91; + + --cta-bg: #2f7260; + --cta-border: #245b4d; + --cta-text: #ffffff; + --cta-bg-hover: #265f51; + --cta-border-hover: #1d493e; + --cta-glow: 0 0 8px color-mix(in srgb, var(--cta-border) 24%, transparent); + --accent: #2f7260; + --accent-text: #ffffff; + --logo-accent: var(--accent); + --shadow-glow: 0 0 10px color-mix(in srgb, var(--accent) 18%, transparent); + --focus-ring: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); + --focus-ring-strong: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent); +} diff --git a/packages/desktop/src/renderer/index.html b/packages/desktop/src/renderer/index.html index 48d6116288..0d1f14b97c 100644 --- a/packages/desktop/src/renderer/index.html +++ b/packages/desktop/src/renderer/index.html @@ -76,6 +76,7 @@ "clay", "moss", "aurora", + "calm", "shadcn", "shadcn-ember", "shadcn-custom",