fix(theme): text on glass holds 4.5:1 — light --muted kept at 6:1 on the page, tonal fills opaque
Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8% fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
This commit is contained in:
+5
-1
@@ -130,7 +130,10 @@ style guide.
|
||||
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
|
||||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode;
|
||||
`--muted` keeps ≥ 6:1 on the bare `--bg`, because it mostly sits on glass, which costs up to ~1.5 of the ratio.
|
||||
A tonal fill under text (a badge, the item count, the current rail row) is an opaque `bg-tint*` (its colour
|
||||
mixed into `--surface`), never a translucent `bg-fg/8`: the glass and what scrolls under it show through.
|
||||
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
|
||||
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
|
||||
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
|
||||
@@ -788,3 +791,4 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
||||
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
||||
|
||||
@@ -63,7 +63,7 @@ function Entry({
|
||||
onClick={() => setView(view)}
|
||||
className={clsx(
|
||||
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
|
||||
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
|
||||
active ? "bg-tint-current font-medium text-accent" : "text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { ReactNode } from "react";
|
||||
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
|
||||
// `tone` says which kind of label it is (a role, a state to notice): its own colour at 6%, the most
|
||||
// that keeps its text at 4.5:1 on light (theme.contract.test.ts measures all three in every scheme).
|
||||
// Each tint is mixed into the surface (`bg-tint*`), opaque: a translucent one lets the glass and what
|
||||
// scrolls under it through, and the text's contrast goes with them.
|
||||
const TONES = {
|
||||
neutral: "bg-fg/8 text-muted",
|
||||
accent: "bg-accent/6 text-accent",
|
||||
warn: "bg-warn/6 text-warn",
|
||||
neutral: "bg-tint text-muted",
|
||||
accent: "bg-tint-accent text-accent",
|
||||
warn: "bg-tint-warn text-warn",
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
|
||||
@@ -8,7 +8,8 @@ import { formatNumber } from "../../lib/format";
|
||||
const OVERFLOW_AT = 1_000_000;
|
||||
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
|
||||
// and tonal like a `Badge` — no edge, because it does nothing (edged = pressable, flat = just
|
||||
// and tonal like a `Badge` (the same opaque `bg-tint`: the toolbar is glass over scrolling thumbnails)
|
||||
// — no edge, because it does nothing (edged = pressable, flat = just
|
||||
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
|
||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
@@ -46,7 +47,7 @@ export default function ItemCount({
|
||||
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
|
||||
}
|
||||
aria-busy={stale || undefined}
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-tint px-3 text-sm whitespace-nowrap text-muted"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
||||
|
||||
+12
-5
@@ -51,6 +51,13 @@
|
||||
--color-live-fg: var(--live-fg);
|
||||
--color-media-chrome: var(--media-chrome);
|
||||
--color-media-mark: var(--media-mark);
|
||||
/* A tonal fill under text (a Badge, the item count, the current rail row): its colour mixed into the
|
||||
surface, OPAQUE — a `bg-fg/8` lets the glass and whatever scrolls under it through, which pixel-measured
|
||||
the item count down to 3.8:1. theme.contract.test.ts measures each one's text on it. */
|
||||
--color-tint: color-mix(in srgb, var(--fg) 8%, var(--surface));
|
||||
--color-tint-accent: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||
--color-tint-warn: color-mix(in srgb, var(--warn) 6%, var(--surface));
|
||||
--color-tint-current: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
|
||||
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
|
||||
@@ -1032,7 +1039,7 @@ html[data-scheme="midnight"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #dde3f0;
|
||||
--fg: #1a2238;
|
||||
--muted: #5b6685;
|
||||
--muted: #515c79;
|
||||
--accent: #3b5bdb;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1054,7 +1061,7 @@ html[data-scheme="forest"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #d6e7e0;
|
||||
--fg: #102a22;
|
||||
--muted: #4d6b60;
|
||||
--muted: #446157;
|
||||
--accent: #0e7490;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1076,7 +1083,7 @@ html[data-scheme="slate"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #e3e6eb;
|
||||
--fg: #1c1f26;
|
||||
--muted: #5d636e;
|
||||
--muted: #535963;
|
||||
--accent: #a21caf;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1100,7 +1107,7 @@ html[data-scheme="matrix"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #dfe3d8;
|
||||
--fg: #161a14;
|
||||
--muted: #5a6352;
|
||||
--muted: #505849;
|
||||
--accent: #556b2f;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1126,7 +1133,7 @@ html[data-scheme="fjord"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #c9d6e3;
|
||||
--fg: #152232;
|
||||
--muted: #4f6278;
|
||||
--muted: #46586e;
|
||||
--accent: #256b8f;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { hexContrast, hueGap, mixHex } from "./colorMath";
|
||||
import { SCHEMES } from "./theme";
|
||||
|
||||
// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
|
||||
// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
|
||||
// an accent fill [WCAG 1.4.3];
|
||||
// - body text and accent text ≥ 4.5:1 on the page, muted text ≥ 6:1 there (the headroom glass costs),
|
||||
// and the accent's own text ≥ 4.5:1 on an accent fill [WCAG 1.4.3];
|
||||
// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
|
||||
// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
|
||||
// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
|
||||
@@ -177,6 +177,22 @@ it("text on a solid accent fill uses --accent-fg", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The tints measured below are only what renders while the tonal fills USE them: a translucent
|
||||
// `bg-fg/8` there passes every token check and still lets the glass under a toolbar or a rail through.
|
||||
it("a tonal fill under text is an opaque tint", () => {
|
||||
const sites = [
|
||||
"components/ui/Badge.tsx",
|
||||
"components/ui/ItemCount.tsx",
|
||||
"components/FilesRail.tsx",
|
||||
];
|
||||
const offenders = sites.flatMap((f) => {
|
||||
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
|
||||
const bad = [...src.matchAll(/\bbg-(?:fg|accent|warn)\/\d+/g)].map((m) => `${f}: ${m[0]}`);
|
||||
return /\bbg-tint/.test(src) ? bad : [...bad, `${f}: no bg-tint`];
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
||||
it.each(MODES)("%s: backdrop rule and file", (mode) => {
|
||||
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
|
||||
@@ -200,15 +216,23 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
||||
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
|
||||
rating: hexContrast(s.rating!, bg),
|
||||
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
|
||||
// A Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
|
||||
// one's text on its own colour at 6% (ui/Badge).
|
||||
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
|
||||
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
|
||||
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
|
||||
// A tonal fill under text (the `tint` tokens: a Badge, the item count, the current rail row) is
|
||||
// OPAQUE — its colour mixed into --surface, so the glass and what scrolls under it never show
|
||||
// through: the neutral one's muted text on the fg at 8%, a toned one's text on its own colour at
|
||||
// 6%, the current row's accent on the accent at 12%.
|
||||
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, t.surface!, 0.08)),
|
||||
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.06)),
|
||||
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, t.surface!, 0.06)),
|
||||
accentOnCurrent: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.12)),
|
||||
live: hexContrast(media().live!, bg),
|
||||
};
|
||||
const nonText = new Set(["edge", "live"]);
|
||||
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
|
||||
// Muted text mostly sits on GLASS (a toolbar, a tab row, a panel): the scrim, the blur and the
|
||||
// backdrop under it cost up to ~1.5 of the ratio a bare --bg gives (pixel-measured over every
|
||||
// scheme × mode × backdrop), so on the bare page it keeps that headroom over 4.5.
|
||||
const onGlass = new Set(["muted"]);
|
||||
const need = (k: string) => (nonText.has(k) ? 3 : onGlass.has(k) ? 6 : 4.5);
|
||||
const failing = Object.entries(measured).filter(([k, v]) => v < need(k));
|
||||
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user