The trial gets a date and WB's presets get a table

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>
This commit is contained in:
2026-09-29 20:21:08 +07:00
parent 899921ef8a
commit cc186c4d09
7 changed files with 213 additions and 25 deletions
@@ -0,0 +1,79 @@
// 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`);
@@ -137,6 +137,20 @@ export function whiteBalanceGain(temperature: number, tint: number): { r: number
]);
}
// Kelvin -> the CAST a WB swatch prints. Not the colour of a blackbody at that
// K: the number names the light the scene was shot under, so the engine
// compensates for it and WARMS the picture as K rises (kelvinToRGB). A swatch
// painted with the light's own colour would therefore run the other way from the
// picture — orange under a knob that blue-d the frame. Measuring the ruler means
// painting what the render does: the engine's own gains on a mid grey, which is
// the same matrix step the preview puts on every pixel, so this can never drift
// from it. Halved, so the swatch sits mid-grey in tone whatever the cast.
export function temperatureSwatch(kelvin: number): string {
const g = kelvinToRGB(kelvin);
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
}
// How hard that excursion is allowed to bite. The physical ratio is brutal (the
// blue channel carries 12x at 2500K) and it lands on sRGB-encoded, already
// tone-mapped pixels, where the raw value clips the ends flat. 0.5 is the
@@ -179,6 +193,25 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// The text colour a #rrggbb fill can carry. A white-balance swatch is a mid grey
// by construction — the engine's gains halved — so a label left at the theme's
// own text colour sits on it at whatever contrast the two happen to have, and
// the pale end of the ruler leaves it unreadable. The pair is chosen by the one
// rule that answers "which of the two reads better here": the WCAG relative
// luminance of the fill against black and against white, whichever is the larger
// ratio — the smaller of the two can fall under 4.5:1, the larger cannot.
// ponytail: two inks, not a per-theme palette; a third is a design decision, not
// a bug.
export function readableInk(bg: string): string {
const n = parseInt(bg.slice(1), 16);
const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const rel =
0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
const withBlack = (rel + 0.05) / 0.05;
const withWhite = 1.05 / (rel + 0.05);
return withBlack >= withWhite ? '#0b0e12' : '#ffffff';
}
// Sim-owned exposure bias: brightness a stock needs that is part of the stock
// itself, never a user knob. Kept beside the matrices it compensates so the
// values are one hop from the rows they lift. Only LC STREETLIFE VIVID carries
+52 -22
View File
@@ -43,7 +43,15 @@ import {
type MosaicSpot,
type Recipe,
} from '../shared/types';
import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
import {
HSL_BANDS,
isMonochromeBase,
kelvinToRGB,
nearestHslBand,
readableInk,
rgbToHsl,
temperatureSwatch,
} from '../shared/utils/colorUtils';
import { grainPerInch, grainStockFor } from '../shared/utils/grainShader';
import { curveIsActive } from '../shared/utils/toneCurve';
import { HEAL_DEFAULT_R } from '../shared/utils/heal';
@@ -146,17 +154,13 @@ const WB_PRESETS: { key: string; label: string; kelvin: number; tint: number }[]
{ key: 'fluorescent', label: 'FLUOR', kelvin: 4000, tint: 3 },
];
// Kelvin -> the CAST the ruler prints. Not the colour of a blackbody at that K:
// the number names the light the scene was shot under, so the engine compensates
// for it and WARMS the picture as K rises (kelvinToRGB). A swatch painted with
// the light's own colour would therefore run the other way from the picture —
// orange under a knob that blue-d the frame. Measuring the ruler means painting
// what the render does: the engine's own gains on a mid grey, which is the same
// matrix step the preview puts on every pixel, so this can never drift from it.
function temperatureSwatch(k: number): string {
const g = kelvinToRGB(k);
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
// The two fields a preset button needs — the ruler's own swatch as the button's
// fill, and the ink that fill can carry as a label (colorUtils.readableInk). The
// WB table and the TEMP strip both read a preset through this, so the seven
// presets cannot end up painted two ways.
function wbSwatch(kelvin: number): { bg: string; fg: string } {
const bg = temperatureSwatch(kelvin);
return { bg, fg: readableInk(bg) };
}
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
@@ -2176,7 +2180,7 @@ export function Workspace() {
label: string;
off: string;
value: string;
options: { v: string; d: string; pro?: boolean }[];
options: { v: string; d: string; pro?: boolean; bg?: string; fg?: string }[];
onPick: (v: string) => void;
}> = {
cx: {
@@ -2250,12 +2254,14 @@ export function Workspace() {
},
},
// TEMP: the presets plus COLOR TEMP, which opens the ruler above instead
// of picking a value.
// of picking a value. The presets carry the same two fields the WB table
// uses — the strip is laid out as a column, so there they stack rather than
// pair off, but a preset is painted the same either way.
temp: {
label: 'TEMP', off: 'auto', value: wbValue(),
options: [
{ v: 'color-temp', d: 'COLOR TEMP' },
...WB_PRESETS.map((p) => ({ v: p.key, d: p.label })),
...WB_PRESETS.map((p) => ({ v: p.key, d: p.label, ...wbSwatch(p.kelvin) })),
],
onPick: (v) => {
if (v === 'color-temp') return toggleParam('temperature');
@@ -2377,7 +2383,7 @@ export function Workspace() {
const choiceChips = (
prefix: string,
options: { v: string; d: string; pro?: boolean }[],
options: { v: string; d: string; pro?: boolean; bg?: string; fg?: string }[],
current: string,
onPick: (v: string) => void,
hover?: (v: string) => Partial<ChipDef>
@@ -2387,6 +2393,9 @@ export function Workspace() {
label: o.d,
active: current === o.v,
pro: o.pro,
// A fill rides through where the caller has one (TEMP's presets); a group
// that names no colour draws the plain chip, as before.
...(o.bg ? { bg: o.bg, fg: o.fg } : {}),
onClick: () => onPick(o.v),
...(hover?.(o.v) ?? {}),
}));
@@ -3029,14 +3038,28 @@ export function Workspace() {
),
// COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a
// chip that opened the same ruler would be a second way to the same knob.
//
// The presets are a TABLE, not a strip: they are one kind of value read
// against each other, so they are laid out as two columns of buttons rather
// than a wrapping row — each button filled with the cast it puts on the
// frame and labelled in the ink that fill carries, which is what makes a
// preset recognisable without reading its name. Keyed `wb-temp:` rather than
// `temp:`, because the strip's own TEMP row can be open beside it and two
// buttons may not answer to one key.
wb: (
<ChipRow
chips={choiceChips('temp', WB_PRESETS.map((p) => ({ v: p.key, d: p.label })), wbValue(), (v) => {
remember();
setWbChoice(v);
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
setAdjustment({ temperature: p.kelvin, tint: p.tint });
})}
grid
chips={WB_PRESETS.map((p) => ({
key: `wb-temp:${p.key}`,
label: `${p.label} · ${p.kelvin}K`,
active: wbValue() === p.key,
...wbSwatch(p.kelvin),
onClick: () => {
remember();
setWbChoice(p.key);
setAdjustment({ temperature: p.kelvin, tint: p.tint });
},
}))}
/>
),
effects: (
@@ -3107,6 +3130,13 @@ export function Workspace() {
}}
/>
{/* The trial notice: one band under the header, the same shape the verify
bar below takes. Copy only — nothing here is dismissible, because the
date IS the message. */}
<div className="notice-bar" role="status" data-key="trial-notice">
{t('notice.trial')}
</div>
{/* A signed-in account whose address is unproven is served as a guest, so
the studio says so once, at the top, instead of letting each locked
chip explain it on its own. */}
+4
View File
@@ -144,6 +144,10 @@ export const en: Dict = {
'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.',
'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in to unlock the PRO features.',
// The trial notice under the header: which features are being tried out, on
// whom, and until when.
'notice.trial': 'Some features are on trial for registered users until 1 December; after that they open only in the Android app.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'VERIFY EMAIL TO USE PRO',
+4
View File
@@ -157,6 +157,10 @@ export const vi = {
'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.',
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để bật các tính năng PRO.',
// The trial notice under the header: which features are being tried out, on
// whom, and until when.
'notice.trial': 'Một vài tính năng sẽ được sử dụng thử với người đã đăng ký đến ngày 1/12, sau đó chỉ mở các tính năng này trên ứng dụng android',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO',
+29
View File
@@ -118,6 +118,14 @@
/* In a column the chips stack: one chip per row, full width. */
.col .chip-row { flex-direction: column; align-items: stretch; }
.col .chip { justify-content: space-between; text-align: left; }
/* A row of one KIND of value read against each other, not a strip to scan: WB's
presets, laid out as a table of buttons instead of a wrapping row. */
.chip-row.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.chip-row.grid .chip { justify-content: center; text-align: center; }
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
/* WATERMARK's own column: its chips carry the longest labels in the app
@@ -158,6 +166,17 @@
.saved-row { display: flex; gap: 6px; align-items: stretch; }
.saved-row .chip:first-child { flex: 1; justify-content: space-between; text-align: left; }
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* A chip painted with the value it picks (a WB preset's cast, the same swatch
the ruler paints). The fill and the ink are inline — they ARE the value, not
the theme — so both are stated by the caller; the border is the chip's own
ink, which is visible against the fill it was chosen for, and the accent once
the preset is on, since the fill can no longer say so. */
.chip.tinted {
border: 2px solid currentColor;
border-radius: 8px;
font-weight: 600;
}
.chip.tinted.on { border-color: var(--accent); }
/* Edited value, strip closed: the text glows amber, the border does not. */
.chip.amber, .chip.amber .val { color: var(--accent); }
.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
@@ -238,6 +257,16 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
font-size: 12px;
}
.verify-bar .btn { padding: 3px 10px; font-size: 11px; }
/* The trial notice: the verify bar's shape in the plain surface rather than the
accent — it is news, and it offers nothing to click. */
.notice-bar {
padding: 6px 12px;
text-align: center;
font-size: 12px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
color: var(--text);
}
/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */
.create-form {
+12 -3
View File
@@ -17,6 +17,12 @@ export interface ChipDef {
// CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band
// chips name a colour, so they show it too.
color?: string;
// The chip's own FILL and the text colour that fill can carry (WB's preset
// table: the swatch the pick paints, printed on the button itself). `bg` wins
// over the class's own background, so an active button keeps its swatch and
// shows its state through the border alone.
bg?: string;
fg?: string;
// Present when the chip itself can be dragged somewhere: the payload the drop
// target reads back (the recipe's id, for the FAVORITED rail button).
drag?: string;
@@ -49,15 +55,18 @@ export interface SliderDef {
onBack?: () => void;
}
export function ChipRow({ chips }: { chips: ChipDef[] }) {
// `grid` lays the row out as the table a set of same-kind values is read as
// (WB's presets) instead of the wrapping strip the option rows use.
export function ChipRow({ chips, grid }: { chips: ChipDef[]; grid?: boolean }) {
return (
<div className="chip-row">
<div className={`chip-row${grid ? ' grid' : ''}`}>
{chips.map((chip) => (
<button
key={chip.key}
type="button"
data-key={chip.key}
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}`}
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}${chip.bg ? ' tinted' : ''}`}
style={chip.bg ? { background: chip.bg, color: chip.fg } : undefined}
aria-pressed={!!chip.active}
disabled={chip.disabled}
draggable={!!chip.drag}