FN-6748: add Air dashboard theme

Adds a minimal, borderless Air color theme across dashboard theme selection and bootstrap validation.

- Register Air in the core theme union, dashboard theme options, and browser/desktop bootstrap validators.
- Add dark and light Air token blocks with flattened chrome styling and matching swatch samples.
- Cover Air registration and CSS invariants with a focused dashboard test.
- Document the Air theme alongside the existing Shadcn theme in dashboard theming guidance.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 packages/core/src/types.ts                         |   2 +
 packages/dashboard/app/__tests__/air-theme.test.ts |  58 ++++++++++++
 .../dashboard/app/components/ThemeSelector.css     |  14 +++
 packages/dashboard/app/components/themeOptions.ts  |   1 +
 packages/dashboard/app/index.html                  |   2 +-
 packages/dashboard/app/public/theme-data.css       | 101 +++++++++++++++++++++
 packages/desktop/src/renderer/index.html           |   1 +
 8 files changed, 179 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-6748

Fusion-Task-Lineage: a0caf14f-673d-485c-8886-97e4b273b624
This commit is contained in:
gsxdsm
2026-06-19 16:17:27 -07:00
parent e19f7c2008
commit fc0636fe90
8 changed files with 179 additions and 2 deletions

View File

@@ -1266,7 +1266,7 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok
### Theme system
Dark/light modes via `data-theme`; 56 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme.
Dark/light modes via `data-theme`; 57 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme. Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening.
- **Base tokens** (`--bg`, `--surface`, etc.) — redefine in `:root`, `[data-theme="light"]`, and every theme block.
- **Semantic tokens** (`--autopilot-pulse`, `--event-error-text`, `--badge-mission-*`, `--fab-*`) — `:root` + `[data-theme="light"]` only; no per-color-theme overrides.

View File

@@ -283,6 +283,8 @@ export const COLOR_THEMES = [
"monochrome",
"slate",
"ash",
// FNXC:DashboardTheming 2026-06-19-15:36: "air" is the source-of-truth id for the minimal, borderless, paper-like dashboard theme; keep bootstrap validators and theme options in sync with this union.
"air",
"graphite",
"silver",
"solarized",

View File

@@ -0,0 +1,58 @@
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";
import fs from "fs";
import path from "path";
const themeDataPath = path.resolve(__dirname, "../public/theme-data.css");
const dashboardIndexPath = path.resolve(__dirname, "../index.html");
const desktopIndexPath = path.resolve(__dirname, "../../../desktop/src/renderer/index.html");
describe("Air color theme", () => {
const themeData = fs.readFileSync(themeDataPath, "utf-8");
it("defines dark and light Air theme blocks with clean font and faint borders", () => {
const darkBlock = extractSelectorBlock(themeData, '[data-color-theme="air"]');
const lightBlock = extractSelectorBlock(themeData, '[data-color-theme="air"][data-theme="light"]');
expect(darkBlock).toContain('--font-primary: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif');
expect(lightBlock).toContain('--font-primary: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif');
expect(darkBlock).toContain("--border: color-mix(in srgb, var(--text) 8%, transparent)");
expect(lightBlock).toContain("--border: color-mix(in srgb, var(--text) 8%, transparent)");
expect(`${darkBlock}\n${lightBlock}`).not.toMatch(/rgba\(/);
expect(`${darkBlock}\n${lightBlock}`).not.toMatch(/#[0-9a-fA-F]{8}\b/);
});
it("registers Air in core, dashboard options, and both bootstrap validators", () => {
expect(CORE_COLOR_THEMES).toContain("air");
expect(DASHBOARD_COLOR_THEMES).toContainEqual({
value: "air",
label: "Air",
className: "theme-swatch-air",
});
expect(fs.readFileSync(dashboardIndexPath, "utf-8")).toContain("'air'");
expect(fs.readFileSync(desktopIndexPath, "utf-8")).toContain('"air"');
});
});
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;
let end = openBraceIdx;
for (let i = openBraceIdx + 1; i < css.length; i++) {
if (css[i] === "{") depth++;
if (css[i] === "}") depth--;
if (depth === 0) {
end = i;
break;
}
}
return css.slice(startIdx, end + 1);
}

View File

@@ -248,6 +248,13 @@
--swatch-sample-4: #d6d3d1;
}
.theme-swatch-air {
--swatch-sample-1: #0f1115;
--swatch-sample-2: #151820;
--swatch-sample-3: #a8bfd8;
--swatch-sample-4: #eef1f4;
}
.theme-swatch-graphite {
--swatch-sample-1: #18181b;
--swatch-sample-2: #27272a;
@@ -760,6 +767,13 @@
--swatch-sample-4: #768694;
}
[data-theme="light"] .theme-swatch-air {
--swatch-sample-1: #fbfbfa;
--swatch-sample-2: #ffffff;
--swatch-sample-3: #536b86;
--swatch-sample-4: #26313d;
}
[data-theme="light"] .theme-swatch-graphite {
--swatch-sample-1: #fafafa;
--swatch-sample-2: #f4f4f5;

View File

@@ -23,6 +23,7 @@ export const COLOR_THEMES: { value: ColorTheme; label: string; className: string
{ value: "monochrome", label: "Mono", className: "theme-swatch-monochrome" },
{ value: "slate", label: "Slate", className: "theme-swatch-slate" },
{ value: "ash", label: "Ash", className: "theme-swatch-ash" },
{ value: "air", label: "Air", className: "theme-swatch-air" },
{ value: "graphite", label: "Graphite", className: "theme-swatch-graphite" },
{ value: "silver", label: "Silver", className: "theme-swatch-silver" },
{ value: "solarized", label: "Solarized", className: "theme-swatch-solarized" },

View File

@@ -160,7 +160,7 @@
try {
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', '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', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn'];
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', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn'];
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}

View File

@@ -333,6 +333,107 @@
--accent-text: #fff;
}
/*
FNXC:DashboardTheming 2026-06-19-15:39:
Air must be the calmest, flattest theme: near-monochrome tokens, a restrained blue-gray accent, a clean system sans stack, and faint color-mix borders.
The "remove unnecessary UI" requirement is intentionally CSS-only and scoped to [data-color-theme="air"] so shared DOM affordances remain present while borders, shadows, and decorative chrome flatten visually.
*/
[data-color-theme="air"] {
--bg: #0f1115;
--surface: #151820;
--card: #171a22;
--card-hover: color-mix(in srgb, var(--card) 92%, var(--text) 8%);
--text: #eef1f4;
--text-muted: #9aa3ad;
--border: color-mix(in srgb, var(--text) 8%, transparent);
--surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%);
--font-primary: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--todo: #a5adba;
--in-progress: #b8c0cc;
--in-progress-rgb: 184, 192, 204;
--in-review: #c7ced8;
--triage: #8d96a3;
--done: #737d8a;
--color-success: #c7ced8;
--color-error: #d8a7a7;
--color-warning: #b7ad9a;
--color-info: #a8bfd8;
--cta-bg: #dbe5ef;
--cta-border: color-mix(in srgb, var(--cta-bg) 72%, transparent);
--cta-text: #111318;
--cta-bg-hover: #eef3f8;
--cta-border-hover: color-mix(in srgb, var(--cta-bg-hover) 82%, transparent);
--cta-glow: 0 0 0 transparent;
--logo-accent: var(--accent);
--accent: #a8bfd8;
--accent-text: #111318;
}
[data-color-theme="air"][data-theme="light"] {
--bg: #fbfbfa;
--surface: #ffffff;
--card: #ffffff;
--card-hover: color-mix(in srgb, var(--card) 96%, var(--text) 4%);
--text: #1f242b;
--text-muted: #69717c;
--border: color-mix(in srgb, var(--text) 8%, transparent);
--surface-hover: color-mix(in srgb, var(--surface) 92%, var(--text) 8%);
--font-primary: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--todo: #667085;
--in-progress: #4f5f73;
--in-progress-rgb: 79, 95, 115;
--in-review: #5f6f83;
--triage: #7a7368;
--done: #8a929f;
--color-success: #526273;
--color-error: #b45f5f;
--color-warning: #8a7760;
--color-info: #536b86;
--cta-bg: #26313d;
--cta-border: color-mix(in srgb, var(--cta-bg) 76%, transparent);
--cta-text: #ffffff;
--cta-bg-hover: #354253;
--cta-border-hover: color-mix(in srgb, var(--cta-bg-hover) 84%, transparent);
--cta-glow: 0 0 0 transparent;
--logo-accent: var(--accent);
--accent: #536b86;
--accent-text: #ffffff;
}
[data-color-theme="air"] .card,
[data-color-theme="air"] .column,
[data-color-theme="air"] .panel,
[data-color-theme="air"] .modal,
[data-color-theme="air"] .task-card {
border-color: var(--border);
box-shadow: 0 0 0 transparent;
background-image: none;
}
[data-color-theme="air"] .btn,
[data-color-theme="air"] .btn-icon,
[data-color-theme="air"] .input,
[data-color-theme="air"] input,
[data-color-theme="air"] textarea,
[data-color-theme="air"] select {
border-color: var(--border);
box-shadow: 0 0 0 transparent;
}
[data-color-theme="air"] .btn:hover,
[data-color-theme="air"] .btn-icon:hover,
[data-color-theme="air"] .card:hover,
[data-color-theme="air"] .task-card:hover {
box-shadow: 0 0 0 transparent;
}
[data-color-theme="air"] .badge,
[data-color-theme="air"] .pill,
[data-color-theme="air"] .tag {
border-color: var(--border);
box-shadow: 0 0 0 transparent;
}
/* GRAPHITE - Dark graphite blacks */
[data-color-theme="graphite"] {
--surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%);

View File

@@ -22,6 +22,7 @@
"monochrome",
"slate",
"ash",
"air",
"graphite",
"silver",
"solarized",