cc186c4d09
Two asks, one row of the studio each. The first is copy: a band under the header, the same shape the verify bar below it takes, saying which features are on trial, on whom, and until when. It is a notice and not a task, so it wears the plain surface rather than the accent, and it carries no dismiss: the date is the message. The string is in the dictionary like every other, Vietnamese as written and English beside it — the band is one line at both lengths in a 1440-wide window, and it sets no nowrap, so a narrow window wraps it rather than cutting it. The second is the WB panel's presets. They were a wrapping strip of seven identical pills, which is the wrong shape for one kind of value read against the others: nothing about "SHADE" said 7500K, and nothing about the row said which end of the scale anything was on. They are a TABLE now — two columns of buttons, laid out as a grid — and each button is painted with the cast it puts on the frame: the same swatch the TEMPERATURE ruler already prints under its track (temperatureSwatch, the engine's own gains on a mid grey, halved), so the button and the ruler cannot disagree, and the kelvin is printed on the button beside the name. A button that is a swatch cannot use the theme's text colour for its label: the fill is a mid grey by construction, and mid grey is exactly where white and the light theme's near-black both sit near the 4.5:1 line. So the label is given the ink that fill can carry, chosen by WCAG's relative luminance against black and against white — whichever of the two ratios is larger, which is the one that cannot fall under 4.5:1. The border is that same ink, which is what makes a button stand out from the page it sits on and from the six beside it; the accent takes the border over while the preset is the one in force, since the fill can no longer say so. Both the fill and the ink ride in as inline styles — they are the value, not the theme — so the chip needed two fields and a tinted class, and the row needed a grid mode. The TEMP strip reads the same two fields through choiceChips, because the same seven presets are painted in both places and two opinions about 3200K is the bug this would have been. The chip's own value slot is deliberately unused here: `.chip .val` sets its own dim colour, and a dim grey on a mid grey button is the failure this commit is about. Verified: scripts/wb-table-check.mjs — readableInk picks the better of its two inks for black, white and four mid greys, all 151 swatches across the ruler (2500..10000K, every 50) and all seven presets clear 4.5:1 under the ink they are given, the swatch runs the way the ruler does (red up, blue down, #517eff at 2500K to #947d61 at 10000K), and the seven presets collapse to the five fills their kelvins do. In the running app through a probe: the notice prints in both languages (Vietnamese one line, unclipped), 7 buttons in a 147px + 147px grid, TUNGSTEN filled rgb(98,130,187) with ink rgb(11,14,18) and a 2px border of the same, AUTO the accent border rgb(206,117,9) while it is the preset in force, and the panel holds at 420px wide without overflowing. The panel was viewed in both themes. npx tsc --noEmit clean, npm run build clean. Co-authored-by: PenguinHarness <noreply@penguin.local>
80 lines
4.1 KiB
JavaScript
80 lines
4.1 KiB
JavaScript
// The WB preset table's two colour decisions, checked where they are made.
|
|
//
|
|
// A preset button is filled with temperatureSwatch(kelvin) and labelled in
|
|
// readableInk(fill). Both are one formula over the whole range, and both fail
|
|
// quietly in the direction nobody notices: a swatch that stops moving with K
|
|
// still looks like a swatch, and an ink that is merely the theme's text colour
|
|
// on a mid-grey fill is legible to whoever picked the theme and unreadable to
|
|
// everyone on the other one. So this walks the ruler and holds every swatch to
|
|
// WCAG's 4.5:1 against the ink it was given — plus the direction of the cast,
|
|
// which is the whole reason the fill is a colour rather than a name.
|
|
//
|
|
// node scripts/wb-table-check.mjs
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import ts from 'typescript';
|
|
|
|
const transpile = (path) =>
|
|
ts.transpileModule(readFileSync(new URL(path, import.meta.url), 'utf8'), {
|
|
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
|
}).outputText;
|
|
|
|
// colorUtils imports types only, so the compiler drops that line itself.
|
|
const dir = mkdtempSync(join(tmpdir(), 'wb-table-check-'));
|
|
writeFileSync(join(dir, 'colorUtils.mjs'), transpile('../shared/utils/colorUtils.ts'));
|
|
const { readableInk, temperatureSwatch } = await import(pathToFileURL(join(dir, 'colorUtils.mjs')).href);
|
|
|
|
// WCAG contrast ratio between two #rrggbb colours.
|
|
const channel = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
const rel = (hex) => {
|
|
const n = parseInt(hex.slice(1), 16);
|
|
return 0.2126 * channel(((n >> 16) & 255) / 255) + 0.7152 * channel(((n >> 8) & 255) / 255) + 0.0722 * channel((n & 255) / 255);
|
|
};
|
|
const ratio = (a, b) => {
|
|
const [hi, lo] = [rel(a), rel(b)].sort((x, y) => y - x);
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
};
|
|
|
|
const PRESETS = [5500, 5500, 5500, 6500, 7500, 3200, 4000]; // AUTO..FLUOR, WB_PRESETS' kelvins
|
|
const RULER = [...Array(151).keys()].map((i) => 2500 + i * 50);
|
|
|
|
// 1. The ink is always one of the two, and always the one that reads better.
|
|
const inkOf = (k) => readableInk(temperatureSwatch(k));
|
|
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#808080')));
|
|
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#000000')));
|
|
assert.ok(['#0b0e12', '#ffffff'].includes(readableInk('#ffffff')));
|
|
assert.equal(readableInk('#ffffff'), '#0b0e12', 'a white fill carries dark ink');
|
|
assert.equal(readableInk('#000000'), '#ffffff', 'a black fill carries light ink');
|
|
for (const fill of ['#7f7f7f', '#808080', '#517eff', '#947d61', '#e8e8e8', '#101010']) {
|
|
const ink = readableInk(fill);
|
|
const other = ink === '#ffffff' ? '#0b0e12' : '#ffffff';
|
|
assert.ok(ratio(fill, ink) >= ratio(fill, other), `${fill}: ${ink} is not the better of the two`);
|
|
}
|
|
|
|
// 2. Every swatch on the ruler, and every preset's, clears 4.5:1 under its ink.
|
|
for (const k of [...RULER, ...PRESETS]) {
|
|
const bg = temperatureSwatch(k);
|
|
assert.match(bg, /^#[0-9a-f]{6}$/, `${k}K paints ${bg}`);
|
|
const ink = readableInk(bg);
|
|
const r = ratio(bg, ink);
|
|
assert.ok(r >= 4.5, `${k}K: ${ink} on ${bg} is ${r.toFixed(2)}:1`);
|
|
}
|
|
|
|
// 3. The fill runs the way the ruler does: warmer as K rises, i.e. red up and
|
|
// blue down, so the table reads as a scale rather than seven greys.
|
|
const low = temperatureSwatch(2500);
|
|
const high = temperatureSwatch(10000);
|
|
const chan = (hex, shift) => (parseInt(hex.slice(1), 16) >> shift) & 255;
|
|
assert.ok(chan(low, 16) < chan(high, 16), `red does not rise: ${low} -> ${high}`);
|
|
assert.ok(chan(low, 0) > chan(high, 0), `blue does not fall: ${low} -> ${high}`);
|
|
|
|
// 4. And the fills collapse the way the kelvins do — five over the seven, since
|
|
// the three 5500K presets differ by TINT and the swatch is the temperature's.
|
|
const fills = new Set(PRESETS.map(temperatureSwatch));
|
|
assert.equal(fills.size, 5, `the presets paint ${fills.size} different fills`);
|
|
|
|
console.log(`wb table: ${RULER.length} swatches >= 4.5:1 under readableInk, ${low} -> ${high} across the ruler, ${fills.size} fills over 7 presets`);
|