FN-6867: fix roadmap CSS tokens

Roadmap dashboard styling now uses defined Fusion theme tokens and has regression coverage for undefined CSS custom properties.

- Retoken roadmap surfaces, form controls, and text from retired aliases to defined dashboard tokens.
- Document the roadmap styling requirement with an FNXC comment in the plugin CSS.
- Add a CSS-token validity test that rejects retired aliases and undefined roadmap custom-property references.

Files changed:
 .../src/dashboard/RoadmapsView.css                 |  63 ++++-----
 .../RoadmapsView.css-token-validity.test.ts        | 147 +++++++++++++++++++++
 2 files changed, 179 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-6867

Fusion-Task-Lineage: 0d5c71dc-b6ad-4b70-a792-2d8a05fe81f1
This commit is contained in:
gsxdsm
2026-06-21 14:17:45 -07:00
parent 32455363d0
commit 8124fa3761
2 changed files with 179 additions and 31 deletions

View File

@@ -1,4 +1,5 @@
/* Roadmaps view */
/* FNXC:RoadmapStyling 2026-06-21-00:00: Roadmap surfaces must use defined dashboard tokens (--card, --surface, --text). Retired elevated-surface, input-surface, and primary-text aliases rendered surfaces transparent and text uncolored (FN-6867). A CSS-token guard prevents reintroduction. */
.roadmaps-view {
display: flex;
height: 100%;
@@ -76,7 +77,7 @@
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
background: var(--surface-elevated);
background: var(--card);
}
.roadmaps-view__sidebar-header {
@@ -90,7 +91,7 @@
.roadmaps-view__sidebar-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text);
}
.roadmaps-view__add-btn {
@@ -150,7 +151,7 @@
.roadmaps-view__sidebar-item-title {
font-weight: 500;
color: var(--text-primary);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -244,7 +245,7 @@
.roadmaps-view__roadmap-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text);
flex: 1;
}
@@ -276,7 +277,7 @@
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--surface-elevated);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
@@ -316,7 +317,7 @@
}
.roadmaps-view__drag-handle:hover {
color: var(--text-primary);
color: var(--text);
background: var(--surface-hover);
}
@@ -335,7 +336,7 @@
.roadmaps-view__milestone-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text);
flex: 1;
}
@@ -371,7 +372,7 @@
.roadmaps-view__add-feature-btn:hover {
background: var(--surface-hover);
color: var(--text-primary);
color: var(--text);
border-color: var(--text-muted);
}
@@ -465,7 +466,7 @@
.roadmaps-view__feature-title {
font-weight: 500;
color: var(--text-primary);
color: var(--text);
font-size: 0.9rem;
}
@@ -504,8 +505,8 @@
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-input);
color: var(--text-primary);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
}
@@ -526,8 +527,8 @@
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-input);
color: var(--text-primary);
background: var(--surface);
color: var(--text);
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
@@ -542,7 +543,7 @@
.roadmaps-view__create-form {
padding: var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface-elevated);
background: var(--card);
}
.roadmaps-view__create-form-actions {
@@ -555,8 +556,8 @@
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-input);
color: var(--text-primary);
background: var(--surface);
color: var(--text);
font-size: 0.85rem;
cursor: pointer;
transition: background var(--transition-fast);
@@ -616,7 +617,7 @@
.roadmaps-view__add-milestone-btn:hover,
.roadmaps-view__add-milestone-fab:hover {
background: var(--surface-hover);
color: var(--text-primary);
color: var(--text);
border-color: var(--text-muted);
}
@@ -625,7 +626,7 @@
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
margin: var(--space-sm);
background: var(--surface-elevated);
background: var(--card);
}
.roadmaps-view__inline-form {
@@ -675,7 +676,7 @@
.roadmap-suggestion-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text);
margin: 0;
}
@@ -690,8 +691,8 @@
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-input);
color: var(--text-primary);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
@@ -797,7 +798,7 @@
align-items: flex-start;
justify-content: space-between;
padding: var(--space-md);
background: var(--surface-elevated);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
@@ -819,7 +820,7 @@
.roadmap-suggestion-card-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
color: var(--text);
}
.roadmap-suggestion-card-desc {
@@ -869,7 +870,7 @@
width: 28px;
height: 28px;
padding: 0;
background: var(--surface-elevated);
background: var(--card);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@@ -879,11 +880,11 @@
.roadmap-suggestion-edit-btn:hover {
background: var(--surface-hover);
color: var(--text-primary);
color: var(--text);
}
.roadmap-suggestion-card--editing {
background: var(--surface-elevated);
background: var(--card);
border-color: var(--accent);
}
@@ -899,7 +900,7 @@
width: 100%;
padding: var(--space-sm);
background: var(--bg);
color: var(--text-primary);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
@@ -954,7 +955,7 @@
width: 28px;
height: 28px;
padding: 0;
background: var(--surface-elevated);
background: var(--card);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@@ -964,7 +965,7 @@
.roadmap-suggestion-cancel-btn:hover {
background: var(--surface-hover);
color: var(--text-primary);
color: var(--text);
}
/* === Mobile Suggestion Panel Expand/Collapse === */
@@ -979,7 +980,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
color: var(--text-primary);
color: var(--text);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
@@ -1013,7 +1014,7 @@
}
.roadmap-suggestion-collapse-btn:hover {
color: var(--text-primary);
color: var(--text);
}
/* Mobile responsive */

View File

@@ -0,0 +1,147 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const TEST_DIR = __dirname;
const REPO_ROOT = path.resolve(TEST_DIR, "../../../../../");
const DASHBOARD_APP_ROOT = path.join(REPO_ROOT, "packages/dashboard/app");
const DASHBOARD_COMPONENTS_ROOT = path.join(DASHBOARD_APP_ROOT, "components");
const ROADMAP_CSS = path.resolve(TEST_DIR, "../RoadmapsView.css");
const RETIRED_TOKEN_REFERENCES = ["--surface-elevated", "--surface-input", "--text-primary"];
/**
* FNXC:RoadmapStyling 2026-06-21-00:00:
* FN-6867 guards roadmap plugin CSS with a raw-text token scan because jsdom does not resolve custom properties.
* Roadmap surfaces and text must reference dashboard-defined tokens so sidebar, lane, form, feature-card, and suggestion surfaces remain opaque in every theme.
*/
function stripCssComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "");
}
function collectFiles(dir: string, predicate: (fileName: string) => boolean): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "dist" || entry === "__tests__" || entry.startsWith(".")) continue;
const fullPath = path.join(dir, entry);
const info = statSync(fullPath);
if (info.isDirectory()) {
out.push(...collectFiles(fullPath, predicate));
continue;
}
if (info.isFile() && predicate(entry)) out.push(fullPath);
}
return out.sort((left, right) => formatRepoPath(left).localeCompare(formatRepoPath(right)));
}
function collectDashboardVocabularyCssFiles(): string[] {
const appLevelCss = readdirSync(DASHBOARD_APP_ROOT)
.filter((entry) => entry.endsWith(".css"))
.map((entry) => path.join(DASHBOARD_APP_ROOT, entry));
const componentCss = collectFiles(DASHBOARD_COMPONENTS_ROOT, (fileName) => fileName.endsWith(".css"));
const themeDataCss = [path.join(DASHBOARD_APP_ROOT, "public/theme-data.css")];
return [...appLevelCss, ...themeDataCss, ...componentCss].sort((left, right) =>
formatRepoPath(left).localeCompare(formatRepoPath(right)),
);
}
function collectDefinedProperties(cssFiles: string[]): Set<string> {
const properties = new Set<string>();
for (const filePath of cssFiles) {
const source = stripCssComments(readFileSync(filePath, "utf8"));
for (const match of source.matchAll(/(^|[\s{;])(--[A-Za-z0-9_-]+)\s*:/g)) {
properties.add(match[2]);
}
}
return properties;
}
function collectReferencedProperties(source: string): Map<string, number[]> {
const references = new Map<string, number[]>();
const uncommented = stripCssComments(source);
uncommented.split("\n").forEach((line, index) => {
for (const match of line.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
const property = match[1];
const lines = references.get(property) ?? [];
lines.push(index + 1);
references.set(property, lines);
}
});
return references;
}
function findUndefinedReferences(args: {
cssFilesToScan: string[];
definedProperties: Set<string>;
sourceByFile?: Map<string, string>;
}): string[] {
const { cssFilesToScan, definedProperties, sourceByFile = new Map() } = args;
const violations: string[] = [];
for (const filePath of cssFilesToScan) {
const source = sourceByFile.get(filePath) ?? readFileSync(filePath, "utf8");
for (const [property, lines] of collectReferencedProperties(source)) {
if (definedProperties.has(property)) continue;
violations.push(`${formatRepoPath(filePath)} references ${property} at line(s) ${lines.join(", ")}`);
}
}
return violations.sort();
}
function formatRepoPath(filePath: string): string {
return path.relative(REPO_ROOT, filePath).split(path.sep).join("/");
}
describe("RoadmapsView CSS token validity (FN-6867)", () => {
it("flags a synthetic undefined custom-property reference", () => {
const fixturePath = path.join(REPO_ROOT, "fixture.css");
const fixtureSource = "/* var(--commented-out) */ .x { color: var(--does-not-exist); background: var(--defined-token); }";
const violations = findUndefinedReferences({
cssFilesToScan: [fixturePath],
definedProperties: new Set(["--defined-token"]),
sourceByFile: new Map([[fixturePath, fixtureSource]]),
});
expect(collectReferencedProperties(fixtureSource)).toEqual(
new Map([
["--does-not-exist", [1]],
["--defined-token", [1]],
]),
);
expect(violations).toEqual(["fixture.css references --does-not-exist at line(s) 1"]);
});
it("does not reintroduce retired roadmap surface or text token aliases", () => {
const css = readFileSync(ROADMAP_CSS, "utf8");
const offenders = RETIRED_TOKEN_REFERENCES.filter((token) => css.includes(token));
expect(
offenders,
`RoadmapsView.css must use --card, --surface, and --text instead of retired aliases: ${offenders.join(", ")}`,
).toEqual([]);
});
it("references only dashboard-defined or RoadmapsView-local custom properties", () => {
const dashboardVocabularyCssFiles = collectDashboardVocabularyCssFiles();
const definedProperties = collectDefinedProperties([...dashboardVocabularyCssFiles, ROADMAP_CSS]);
const violations = findUndefinedReferences({
cssFilesToScan: [ROADMAP_CSS],
definedProperties,
});
expect(dashboardVocabularyCssFiles).toContain(path.join(DASHBOARD_APP_ROOT, "styles.css"));
expect(dashboardVocabularyCssFiles).toContain(path.join(DASHBOARD_APP_ROOT, "public/theme-data.css"));
expect(Array.from(definedProperties)).toEqual(expect.arrayContaining(["--card", "--surface", "--text"]));
expect(violations, [`Undefined CSS custom-property references found in RoadmapsView.css:`, ...violations].join("\n")).toEqual([]);
});
});