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:
@@ -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 */
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user