Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions client-web/app/design-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

const stylesheet = readFileSync(resolve(process.cwd(), "app/globals.css"), "utf8");

const p4RegressionPairs = [
{ legacyForeground: "--ow-muted-2", foreground: "--ow-muted-2", background: "--bg" },
{ legacyForeground: "--ow-muted-2", foreground: "--ow-muted-2", background: "--panel" },
{ legacyForeground: "--ow-muted-2", foreground: "--ow-muted-2", background: "--panel-2" },
{ legacyForeground: "--danger", foreground: "--danger-text", background: "--panel-2" },
{ legacyForeground: "--sev-low", foreground: "--sev-low", background: "--panel-2" },
{
legacyForeground: "--sev-critical",
foreground: "--sev-critical",
background: "--panel-2",
},
{ legacyForeground: "--st-open", foreground: "--st-open", background: "--panel-2" },
{ legacyForeground: "--st-ack", foreground: "--st-ack", background: "--panel-2" },
] as const;

function cssToken(name: string) {
const match = stylesheet.match(new RegExp(`${name}:\\s*(#[0-9a-f]{3,8})`, "i"));
if (!match) throw new Error(`Missing opaque token ${name}`);
return match[1];
}

function rgb(hex: string) {
const value = hex.slice(1);
const expanded = value.length === 3 ? [...value].map((digit) => digit.repeat(2)).join("") : value;
return expanded
.match(/.{2}/g)!
.slice(0, 3)
.map((channel) => Number.parseInt(channel, 16) / 255);
}

function luminance(hex: string) {
const [red, green, blue] = rgb(hex).map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}

function contrast(foreground: string, background: string) {
const light = Math.max(luminance(foreground), luminance(background));
const dark = Math.min(luminance(foreground), luminance(background));
return (light + 0.05) / (dark + 0.05);
}

function composite(foreground: string, background: string, opacity: number) {
const foregroundChannels = rgb(foreground);
const backgroundChannels = rgb(background);
const channels = foregroundChannels.map(
(channel, index) => channel * opacity + backgroundChannels[index] * (1 - opacity),
);
return `#${channels
.map((channel) =>
Math.round(channel * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}

describe("design token contrast contract", () => {
it.each(p4RegressionPairs)(
"$legacyForeground on $background passes as $foreground",
({ foreground, background }) => {
expect(contrast(cssToken(foreground), cssToken(background))).toBeGreaterThanOrEqual(4.5);
},
);

it.each(["--danger", "--danger-hover"])("keeps danger ink readable on %s", (background) => {
expect(contrast(cssToken("--danger-ink"), cssToken(background))).toBeGreaterThanOrEqual(4.5);
});

it.each(["--sev-low", "--sev-critical", "--st-open", "--st-ack"])(
"keeps %s readable over its 10% status surface",
(foreground) => {
const text = cssToken(foreground);
const statusSurface = composite(text, cssToken("--panel"), 0.1);
expect(contrast(text, statusSurface)).toBeGreaterThanOrEqual(4.5);
},
);
});

describe("platform preference contract", () => {
it("defines shared reduced-motion and forced-colors policies", () => {
expect(stylesheet).toMatch(/@media\s*\(prefers-reduced-motion:\s*reduce\)/);
expect(stylesheet).toMatch(/@media\s*\(forced-colors:\s*active\)/);
expect(stylesheet).toContain(".ow-progress-spinner");
expect(stylesheet).toContain(".ow-action-menu-item");
});
});
78 changes: 70 additions & 8 deletions client-web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,25 @@
--ow-border-2: rgb(255 255 255 / 12%);
--text: #e7e7ea;
--ow-muted: #989ba1;
--ow-muted-2: #6f737a;
--ow-muted-2: #878b93;
--gold: #fbc02d;
--gold-hover: #f9a825;
--gold-dim: #fbc02d;
--gold-ink: #1a1405;
--danger: #ff2d2d;
--danger-hover: #e91919;
--danger: #c62828;
--danger-hover: #b71c1c;
--danger-ink: #fff;
--danger-text: #f55;

/* severities */
--sev-low: #3b82f6;
--sev-low: #5798f5;
--sev-medium: #f59e0b;
--sev-high: #fb7d3c;
--sev-critical: #ef4444;
--sev-critical: #f55;

/* states */
--st-open: #ef4444;
--st-ack: #3b82f6;
--st-open: #f55;
--st-ack: #5798f5;
--st-esc: #c084fc;
--st-res: #22c55e;
}
Expand Down Expand Up @@ -110,11 +111,72 @@

.ow-input:focus-visible {
border-color: color-mix(in srgb, var(--gold) 70%, transparent);
outline: none;
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--gold) 35%, transparent);
}
}

@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}

*:not(.ow-progress-spinner),
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}

@media (forced-colors: active) {
body {
background-image: none;
}

.glass,
.surface,
.ow-action-menu {
border-color: CanvasText;
box-shadow: none;
}

.ow-input {
color: FieldText;
background: Field;
border-color: FieldText;
forced-color-adjust: auto;
}

select.ow-input {
background-image: none;
}

.ow-button {
border: 1px solid ButtonText;
forced-color-adjust: auto;
}

.ow-input:focus-visible,
.ow-button:focus-visible {
outline: 2px solid Highlight !important;
outline-offset: 2px;
box-shadow: none;
}

.ow-action-menu-item[data-highlighted],
[aria-current="page"],
[aria-selected="true"],
[aria-pressed="true"] {
outline: 2px solid Highlight;
outline-offset: -2px;
}
}

/* Autofill override to preserve background and text color */
input:-webkit-autofill,
input:-webkit-autofill:hover,
Expand Down
Loading