feat(ui): refine gem-blue acrylic palette and focus borders

Derive the gem-blue palette with OKLCH tonal and complementary-color
rules while preserving acrylic opacity, blur and surface hierarchy.

Darker focus tokens exposed stacked field rings, fallback outlines and
persistent basemap selection rings. Keep fallback focus styles in the
base layer and use one themed field boundary or inset card stroke.
Preserve keyboard focus, invalid states and native input semantics.

Add browser regression coverage for pointer and keyboard focus, all
basemap presentations, narrow layouts and rendered contrast.

Validation in an isolated checkout: pnpm lint, pnpm typecheck,
pnpm test (393 tests), focused browser suites and pnpm build.
This commit is contained in:
2026-10-09 10:57:06 +08:00
parent d2d705ed4c
commit 617ba4d87c
69 changed files with 1239 additions and 765 deletions
+71
View File
@@ -0,0 +1,71 @@
import { expect, type Locator } from "@playwright/test";
export async function readThemeColor(locator: Locator, token: string) {
return locator.evaluate((element, name) => {
const probe = document.createElement("span");
probe.style.backgroundColor = getComputedStyle(element).getPropertyValue(name);
document.body.append(probe);
const color = getComputedStyle(probe).backgroundColor;
probe.remove();
return color;
}, token);
}
export function readColorAlpha(color: string) {
const rgba = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
const modern = color.match(/\/\s*([\d.]+)(%)?\s*\)$/);
return rgba ? Number(rgba[1]) : modern ? Number(modern[1]) / (modern[2] ? 100 : 1) : 1;
}
export async function expectThemeColor(locator: Locator, property: string, token: string) {
await expect(locator).toHaveCSS(property, await readThemeColor(locator, token));
}
// Canvas resolves RGB, OKLCH and color-mix() in the browser's sRGB output space.
// Compose ancestor backgrounds rather than assuming that the reading surface is white.
export async function readRenderedContrast(locator: Locator) {
return locator.evaluate((element) => {
const context = document.createElement("canvas").getContext("2d")!;
context.canvas.width = 1;
context.canvas.height = 1;
function rgba(color: string) {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r, g, b, alpha] = context.getImageData(0, 0, 1, 1).data;
return { r, g, b, a: alpha / 255 };
}
function over(top: ReturnType<typeof rgba>, bottom: ReturnType<typeof rgba>) {
const a = top.a + bottom.a * (1 - top.a);
return {
r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / a,
g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / a,
b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / a,
a
};
}
function luminance(color: ReturnType<typeof rgba>) {
const linear = [color.r, color.g, color.b].map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
const ancestors: Element[] = [];
for (let current: Element | null = element; current; current = current.parentElement) {
ancestors.unshift(current);
}
let background = rgba("white");
for (const ancestor of ancestors) {
background = over(rgba(getComputedStyle(ancestor).backgroundColor), background);
}
const foreground = over(rgba(getComputedStyle(element).color), background);
const light = Math.max(luminance(foreground), luminance(background));
const dark = Math.min(luminance(foreground), luminance(background));
return (light + 0.05) / (dark + 0.05);
});
}