{t("No visible ontology relations for this focus. Open a Keyman or affiliated organization next.")}
diff --git a/frontend/src/ontologyExplorerCss.test.ts b/frontend/src/ontologyExplorerCss.test.ts index ad35b8868..612df09a3 100644 --- a/frontend/src/ontologyExplorerCss.test.ts +++ b/frontend/src/ontologyExplorerCss.test.ts @@ -11,4 +11,8 @@ describe("Ontology Explorer CSS contracts", () => { it("defines the ontology node focus-visible rule exactly once", () => { expect(appCss.match(/\.ontology-node:focus-visible\s*\{/g)).toHaveLength(1); }); + + it("lets the node-type label override the shared node text color", () => { + expect(appCss).toContain(".ontology-node text.ontology-node-type {"); + }); }); diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 400c686da..ef152eb51 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -104,6 +104,13 @@ --color-dashboard-positive: #426b45; --color-dashboard-surface: #f4f6fa; + /* Ontology node surfaces (ADR 0184: shape and text remain primary) */ + --ontology-node-post-fill: #e3f2fd; + --ontology-node-person-fill: #ede7f6; + --ontology-node-organization-fill: #fff3e0; + --ontology-node-team-fill: #e0f2f1; + --ontology-node-generic-fill: var(--color-background); + /* Layout & Breakpoint Tokens (§2.1 – 화면 해상도 / 반응형) */ --breakpoint-phone: 768px; --breakpoint-tablet: 1024px; @@ -209,6 +216,12 @@ --badge-status-prediction-bg: rgba(251, 146, 60, 0.2); --badge-status-prediction-text: #fdba74; + --ontology-node-post-fill: #173653; + --ontology-node-person-fill: #30234d; + --ontology-node-organization-fill: #4b2e1d; + --ontology-node-team-fill: #123f3b; + --ontology-node-generic-fill: var(--color-background); + --color-drawer-bg: #16171d; --color-drawer-border: #2e303a; --color-drawer-overlay: rgba(0, 0, 0, 0.65); diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 74d987ccb..435c973ad 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -36,6 +36,14 @@ const BADGE_AND_ACCENT_TOKENS = [ "--badge-status-prediction-text", ]; +const ONTOLOGY_NODE_TOKENS = [ + "--ontology-node-post-fill", + "--ontology-node-person-fill", + "--ontology-node-organization-fill", + "--ontology-node-team-fill", + "--ontology-node-generic-fill", +]; + // Colors this file's dark-mode block replaced -- a regression here would // silently make dark mode as bright as light mode again (ADR 0099). const RETIRED_LIGHT_ONLY_HEX = [ @@ -55,7 +63,10 @@ const RETIRED_LIGHT_ONLY_HEX = [ // WCAG 2.x relative luminance / contrast ratio (SC 1.4.3). function relativeLuminance(hex: string): number { - const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const normalized = hex.length === 4 + ? `#${[...hex.slice(1)].map((digit) => `${digit}${digit}`).join("")}` + : hex; + const rgb = [1, 3, 5].map((i) => parseInt(normalized.slice(i, i + 2), 16) / 255); const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } @@ -76,7 +87,7 @@ function readToken(block: string, token: string): string { // literal passed by the call sites below (e.g. "--color-footer-bg"), and // escapeRegExp() neutralizes any regex metacharacters before // interpolation, so no caller-controlled input reaches RegExp unescaped. - const match = block.match(new RegExp(`${escapeRegExp(token)}:\\s*(#[0-9a-fA-F]{6})`)); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp + const match = block.match(new RegExp(`${escapeRegExp(token)}:\\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})(?![0-9a-fA-F])`)); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp if (!match) throw new Error(`${token} not found as a hex value`); return match[1]; } @@ -125,6 +136,26 @@ describe("design tokens", () => { } }); + it("defines and consumes ontology node fills in both color schemes", () => { + for (const token of ONTOLOGY_NODE_TOKENS) { + expect(lightBlock, `${token} missing from the light :root block`).toContain(`${token}:`); + expect(darkBlock, `${token} missing from the dark prefers-color-scheme block`).toContain( + `${token}:`, + ); + expect(appCss, `App.css never references var(${token})`).toContain(`var(${token})`); + } + }); + + it("keeps ontology labels and their halo at WCAG AA contrast in both color schemes", () => { + for (const block of [lightBlock, darkBlock]) { + const background = readToken(block, "--color-background"); + for (const token of ["--color-text-heading", "--color-text"]) { + expect(contrastRatio(readToken(block, token), background), `${token} label contrast`) + .toBeGreaterThanOrEqual(4.5); + } + } + }); + it("does not dim .post-meta with opacity -- it fails WCAG AA in both themes and carries role=\"status\" next-action text", () => { const match = appCss.match(/\.post-meta\s*\{([^}]*)\}/); expect(match, ".post-meta rule not found in App.css").not.toBeNull();