FN-6489: replace dashboard rgba tokens with color-mix

Replace raw dashboard RGB alpha colors with color-mix token expressions and guard the global CSS surface.

- Converted global theme shadow, state, mission, event, and theme-data alpha colors from raw rgba() to color-mix() expressions.
- Added CSS fixture loading for theme-data.css and a regression test banning raw rgb/rgba outside var() fallbacks across global app CSS.
- Documented the stricter dashboard styling rule for global and theme token CSS.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 .../__tests__/global-theme-css-no-raw-rgba.test.ts |  68 +++
 packages/dashboard/app/public/theme-data.css       | 573 +++++++++++----------
 packages/dashboard/app/styles.css                  |  49 +-
 packages/dashboard/app/test/cssFixture.ts          |   7 +
 5 files changed, 389 insertions(+), 310 deletions(-)

Fusion-Task-Id: FN-6489

Fusion-Task-Lineage: 7c466971-d15c-4382-a24b-1b32eea71974
This commit is contained in:
gsxdsm
2026-06-15 11:15:20 -07:00
parent d08ec053a6
commit 222b5cedf3
5 changed files with 389 additions and 310 deletions

View File

@@ -1187,7 +1187,7 @@ The `index.html` shell is templated server-side: the server injects a per-user `
`styles.css` is the source of truth for tokens (`--space-*`, `--radius-*`, `--shadow-*`, `--duration-*`, `--transition-*`, `--font-*`, `--header-height`, `--mobile-nav-height`, `--standalone-bottom-gap`, `--overlay-padding-top`) and color variables (`--bg`, `--surface`, `--card`, `--text`, `--text-muted`, status colors `--triage`/`--todo`/`--in-progress`/`--in-review`/`--done`, semantic `--color-success`/`--color-error`/`--color-warning`/`--color-info`, status backgrounds `--status-*-bg`).
**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — the only exception is inside `:root`/theme blocks where tokens are *defined*. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`.
**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — global/theme token CSS is also covered by `global-theme-css-no-raw-rgba.test.ts`, so raw `rgba()` belongs only in explicit `var(--token, rgba(...))` fallbacks. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`.
### Theme system

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss, loadAllAppCssBaseOnly, loadThemeDataCss } from "../test/cssFixture";
const ALLOWED_EXCEPTIONS: string[] = [];
function stripVarFallbackRgba(content: string): string {
return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, "");
}
function findRawRgbViolations(source: string, fileName: string): string[] {
const withoutFallbacks = stripVarFallbackRgba(source);
const lines = withoutFallbacks.split(/\r?\n/);
return lines
.flatMap((line, index) =>
/rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : []
)
.filter((violation) => !ALLOWED_EXCEPTIONS.includes(violation));
}
function buildRawRgbFailureMessage(violations: string[]): string {
return [
"Raw rgb/rgba() found in global dashboard CSS.",
"Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead.",
"Allowed exceptions must be documented in ALLOWED_EXCEPTIONS:",
...violations,
].join("\n");
}
describe("global and theme CSS color token hygiene", () => {
it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => {
const source = [
".clean { color: var(--color-text); }",
".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }",
".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
].join("\n");
const violations = findRawRgbViolations(source, "fixture.css");
expect(violations).toEqual([
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }",
]);
expect(buildRawRgbFailureMessage(violations)).toContain(
"fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }"
);
expect(buildRawRgbFailureMessage(violations)).toContain(
"color-mix(in srgb, var(--color-X) N%, transparent)"
);
});
it("keeps base global CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadAllAppCssBaseOnly(), "loadAllAppCssBaseOnly()");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
it("keeps all app CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadAllAppCss(), "loadAllAppCss()");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
it("keeps theme-data CSS free of raw rgb/rgba calls outside var() fallbacks", () => {
const violations = findRawRgbViolations(loadThemeDataCss(), "public/theme-data.css");
expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -158,15 +158,16 @@ html {
--standalone-bottom-gap: 0px;
/* Shadow tokens */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 8px rgba(88, 166, 255, 0.3);
--glow-success: 0 0 8px rgba(46, 160, 67, 0.3);
--glow-warning: 0 0 8px rgba(227, 179, 65, 0.3);
--glow-danger: 0 0 8px rgba(248, 81, 73, 0.3);
--focus-ring: 0 0 0 2px rgba(88, 166, 255, 0.15);
--focus-ring-strong: 0 0 0 2px rgba(88, 166, 255, 0.3);
/* FNXC:DashboardTheming 2026-06-15-00:00: Global token translucency uses color-mix(in srgb, var(--token) N%, transparent); raw RGB alpha color calls are banned outside var() fallbacks for FN-6489. */
--shadow-sm: 0 1px 2px color-mix(in srgb, #000000 10%, transparent);
--shadow-md: 0 4px 6px color-mix(in srgb, #000000 10%, transparent);
--shadow-lg: 0 4px 24px color-mix(in srgb, #000000 40%, transparent);
--shadow-glow: 0 0 8px color-mix(in srgb, var(--todo) 30%, transparent);
--glow-success: 0 0 8px color-mix(in srgb, var(--cta-border) 30%, transparent);
--glow-warning: 0 0 8px color-mix(in srgb, #e3b541 30%, transparent);
--glow-danger: 0 0 8px color-mix(in srgb, var(--color-error) 30%, transparent);
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--todo) 15%, transparent);
--focus-ring-strong: 0 0 0 2px color-mix(in srgb, var(--todo) 30%, transparent);
/* Animation tokens.
--duration-* are bare durations, safe anywhere (animation shorthands,
@@ -317,19 +318,19 @@ svg.spin {
--cta-text: #fff;
--cta-bg-hover: #2ea043;
--cta-border-hover: #3fb950;
--cta-glow: 0 0 8px rgba(46, 160, 67, 0.3);
--cta-glow: 0 0 8px color-mix(in srgb, var(--cta-border) 30%, transparent);
/* === Agent State Colors === */
--state-idle-bg: rgba(139, 148, 158, 0.15);
--state-idle-bg: color-mix(in srgb, var(--state-idle-text) 15%, transparent);
--state-idle-text: #8b949e;
--state-idle-border: #8b949e;
--state-active-bg: rgba(46, 160, 67, 0.15);
--state-active-bg: color-mix(in srgb, var(--cta-border) 15%, transparent);
--state-active-text: #3fb950;
--state-active-border: #3fb950;
--state-paused-bg: rgba(227, 179, 65, 0.15);
--state-paused-bg: color-mix(in srgb, var(--state-paused-text) 15%, transparent);
--state-paused-text: #e3b541;
--state-paused-border: #e3b541;
--state-error-bg: rgba(248, 81, 73, 0.15);
--state-error-bg: color-mix(in srgb, var(--state-error-text) 15%, transparent);
--state-error-text: #f85149;
--state-error-border: #f85149;
@@ -425,11 +426,11 @@ svg.spin {
/* === Mission autopilot indicators === */
--autopilot-pulse: var(--color-success);
--autopilot-icon: #eab308;
--autopilot-shadow: rgba(34, 197, 94, 0.4);
--autopilot-shadow: color-mix(in srgb, var(--color-success) 40%, transparent);
/* === Mission toggle & badge backgrounds === */
--toggle-checked-bg: rgba(34, 197, 94, 0.2);
--meta-badge-bg: rgba(63, 185, 80, 0.1);
--toggle-checked-bg: color-mix(in srgb, var(--color-success) 20%, transparent);
--meta-badge-bg: color-mix(in srgb, var(--color-success) 10%, transparent);
/* === Mission event type colors (semantic — adapted for light in [data-theme="light"]) === */
--event-error-text: #fca5a5;
@@ -437,17 +438,17 @@ svg.spin {
--event-task-text: #6ee7b7;
--event-slice-text: #fcd34d;
--event-autopilot-text: #d8b4fe;
--event-error-bg: rgba(239, 68, 68, 0.15);
--event-state-bg: rgba(59, 130, 246, 0.15);
--event-task-bg: rgba(16, 185, 129, 0.15);
--event-slice-bg: rgba(245, 158, 11, 0.15);
--event-autopilot-bg: rgba(168, 85, 247, 0.15);
--event-error-bg: color-mix(in srgb, #ef4444 15%, transparent);
--event-state-bg: color-mix(in srgb, #3b82f6 15%, transparent);
--event-task-bg: color-mix(in srgb, #10b981 15%, transparent);
--event-slice-bg: color-mix(in srgb, #f59e0b 15%, transparent);
--event-autopilot-bg: color-mix(in srgb, #a855f7 15%, transparent);
/* === Card mission badge === */
--badge-mission-text: #a78bfa;
--badge-mission-text-hover: #c4b5fd;
--badge-mission-bg: rgba(167, 139, 250, 0.12);
--badge-mission-bg-hover: rgba(167, 139, 250, 0.22);
--badge-mission-bg: color-mix(in srgb, var(--badge-mission-text) 12%, transparent);
--badge-mission-bg-hover: color-mix(in srgb, var(--badge-mission-text-hover) 22%, transparent);
/* === Terminal background === */
--terminal-bg: #1e1e1e;

View File

@@ -6,6 +6,7 @@ const COMPONENTS_DIR = join(APP_DIR, "components");
let cached: string | null = null;
let stylesCached: string | null = null;
let themeDataCached: string | null = null;
let baseOnlyCached: string | null = null;
export function loadStylesCss(): string {
@@ -14,6 +15,12 @@ export function loadStylesCss(): string {
return stylesCached;
}
export function loadThemeDataCss(): string {
if (themeDataCached !== null) return themeDataCached;
themeDataCached = readFileSync(join(APP_DIR, "public", "theme-data.css"), "utf-8");
return themeDataCached;
}
export function loadAllAppCss(): string {
if (cached !== null) return cached;
// styles.css first (preserves all section-marker positions for legacy slice