diff --git a/docker/frontend/scripts/wb-table-check.mjs b/docker/frontend/scripts/wb-table-check.mjs new file mode 100644 index 0000000..bde51f3 --- /dev/null +++ b/docker/frontend/scripts/wb-table-check.mjs @@ -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`); diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index b5e64e9..b603201 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -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 diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 4db7128..ac35834 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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 @@ -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: ( ({ 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. */} +
+ {t('notice.trial')} +
+ {/* 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. */} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8b0bedc..2465f80 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index bd44dd3..2dac607 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 8e5628e..b98aa93 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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 { diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index aae8c9e..105c659 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -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 ( -
+
{chips.map((chip) => (