diff --git a/packages/dashboard/app/__tests__/styles-css-rgba-tokenization.test.ts b/packages/dashboard/app/__tests__/styles-css-rgba-tokenization.test.ts new file mode 100644 index 0000000000..e3a4c7a78b --- /dev/null +++ b/packages/dashboard/app/__tests__/styles-css-rgba-tokenization.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { loadStylesCss } from "../test/cssFixture"; + +interface SelectorExpectation { + selector: string; + expectedColorMix: string; +} + +const convertedSelectors: SelectorExpectation[] = [ + { + selector: ".modal-overlay", + expectedColorMix: "color-mix(in srgb, var(--text) 60%, transparent)", + }, + { + selector: ".modal-header", + expectedColorMix: "color-mix(in srgb, var(--text) 10%, transparent)", + }, + { + selector: ".modal-actions", + expectedColorMix: "color-mix(in srgb, var(--text) 5%, transparent)", + }, + { + selector: ".settings-sidebar", + expectedColorMix: "color-mix(in srgb, var(--text) 10%, transparent)", + }, + { + selector: ".step-progress-segment[data-tooltip]:hover::after", + expectedColorMix: "color-mix(in srgb, var(--text) 20%, transparent)", + }, + { + selector: ".dep-dropdown-item.selected", + expectedColorMix: "color-mix(in srgb, var(--todo) 15%, transparent)", + }, + { + selector: ".refine-menu", + expectedColorMix: "color-mix(in srgb, var(--text) 15%, transparent)", + }, +]; + +function braceDelta(line: string): number { + return (line.match(/\{/g) ?? []).length - (line.match(/}/g) ?? []).length; +} + +function isTokenDefinitionBlockStart(line: string): boolean { + return /^\s*:root(?:\[data-theme=[^\]]+\])?\s*\{/.test(line); +} + +function nonTokenRgbaLines(css: string): string[] { + const violations: string[] = []; + const lines = css.split(/\r?\n/); + let tokenBlockDepth = 0; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const entersTokenBlock = tokenBlockDepth === 0 && isTokenDefinitionBlockStart(line); + if (entersTokenBlock) { + tokenBlockDepth = braceDelta(line); + } + + const insideTokenBlock = tokenBlockDepth > 0 || entersTokenBlock; + if (!insideTokenBlock && line.includes("rgba(")) { + violations.push(`${index + 1}:${line.trim()}`); + } + + if (!entersTokenBlock && tokenBlockDepth > 0) { + tokenBlockDepth += braceDelta(line); + } + } + + return violations; +} + +function extractSelectorBlocks(css: string, selector: string): string[] { + const blocks: string[] = []; + let searchStart = 0; + + while (searchStart < css.length) { + const selectorStart = css.indexOf(`${selector} {`, searchStart); + if (selectorStart === -1) break; + + const blockStart = css.indexOf("{", selectorStart); + let depth = 0; + for (let index = blockStart; index < css.length; index += 1) { + if (css[index] === "{") { + depth += 1; + } else if (css[index] === "}") { + depth -= 1; + if (depth === 0) { + blocks.push(css.slice(selectorStart, index + 1)); + searchStart = index + 1; + break; + } + } + } + + if (blockStart === -1 || searchStart <= selectorStart) { + throw new Error(`Unterminated block for selector ${selector}`); + } + } + + return blocks; +} + +describe("styles.css rgba tokenization", () => { + it("does not use raw rgba() outside :root token definition blocks", () => { + expect(nonTokenRgbaLines(loadStylesCss())).toEqual([]); + }); + + it("keeps converted selectors on tokenized color-mix() values", () => { + const css = loadStylesCss(); + + for (const { selector, expectedColorMix } of convertedSelectors) { + const blocks = extractSelectorBlocks(css, selector); + expect(blocks, `Missing selector ${selector}`).not.toHaveLength(0); + + const matchingBlock = blocks.find((block) => block.includes(expectedColorMix)); + expect(matchingBlock, `Missing expected tokenized value for ${selector}`).toBeDefined(); + expect(matchingBlock).toContain("color-mix(in srgb, var(--"); + expect(matchingBlock).not.toContain("rgba("); + } + }); +}); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index babfd614a1..0f5f9beca3 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -1130,7 +1130,7 @@ body { display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: color-mix(in srgb, var(--text) 60%, transparent); backdrop-filter: blur(4px); /* Must remain above sticky top banners (e.g. onboarding/session resume cards). */ z-index: 100; @@ -1166,7 +1166,7 @@ body { align-items: center; padding: var(--modal-padding); border-bottom: 1px solid var(--border); - background: rgba(0, 0, 0, 0.1); + background: color-mix(in srgb, var(--text) 10%, transparent); } .modal-header h3 { font-size: 15px; @@ -1220,7 +1220,7 @@ body { gap: 10px; padding: var(--modal-padding); border-top: 1px solid var(--border); - background: rgba(0, 0, 0, 0.05); + background: color-mix(in srgb, var(--text) 5%, transparent); } .modal-actions-left { @@ -1511,7 +1511,7 @@ input[type="range"]:focus-visible { overflow-y: auto; padding: 10px 8px; gap: 2px; - background: rgba(0, 0, 0, 0.1); + background: color-mix(in srgb, var(--text) 10%, transparent); scrollbar-color: var(--border) transparent; scrollbar-width: thin; } @@ -2548,7 +2548,7 @@ input[type="range"]:focus-visible { z-index: 10; pointer-events: none; margin-bottom: var(--space-xs); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + box-shadow: 0 2px 8px color-mix(in srgb, var(--text) 20%, transparent); } .step-progress-label { @@ -2858,7 +2858,7 @@ input[type="range"]:focus-visible { background: var(--card-hover); } .dep-dropdown-item.selected { - background: rgba(88, 166, 255, 0.15); + background: color-mix(in srgb, var(--todo) 15%, transparent); } .dep-dropdown-id { @@ -2888,7 +2888,7 @@ input[type="range"]:focus-visible { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: 0 4px 12px color-mix(in srgb, var(--text) 15%, transparent); min-width: 200px; max-width: 280px; z-index: 100;