Commit Graph

92 Commits

Author SHA1 Message Date
3dtours 3d759cc71b fix(studio): let a session an older build filled heal on the next open
The previous frame stops a session being written, not read: the browser that
reported this still holds the olive develop under the ORF's name, and a reload
would paint it once more. A session photo named like a RAW is that develop and
nothing else — the RAW branch writes none — so the restore path now reads the
parked RAW first and only falls back to such a copy when there is nothing
parked to develop. An ordinary photo's name is left alone.

Measured in Chromium on the built bundle, with the session poisoned and the ORF
parked: a copy named POISON.ORF opens as the re-developed 1600x1197 frame
(mean 167.3,164.2,138) and a copy named POISON.jpg opens as itself.
2026-10-06 07:28:13 +07:00
3dtours c346b657eb fix(studio): stop the session from serving a frame an older build developed
A RAW was parked in OPFS whole and its develop kept in the session too, so a
reload painted the stored JPEG and never went back to the sensor. That makes a
session outlive the engine that filled it: 2e36acd fixed the Olympus ORF's
colour — measured against the file's own preview, mean 167.0,163.9,137.7
against the preview's 167.0,164.8,138.6 — and a browser that had opened the ORF
before the fix kept painting the olive develop, which is what "the fix is
deployed and it still opens olive" was.

The RAW is in OPFS either way, so the RAW branch now clears the session slot
instead of filling it (forgetPhoto) and the reload develops the parked sensor
data again (App.tsx:767). Measured in Chromium against the deployed bundle:
after a drop the session holds no photo, and a reload re-develops the parked
RAW to the same 1600x1197 frame, mean 167.3,164.2,138. The cost is one develop
per reload; the comment names the build stamp that would lift it.
2026-10-06 07:23:21 +07:00
3dtours 77cf872b16 fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading 2026-10-05 06:34:37 +07:00
3dtours 84b8e64652 feat(frame): add OLD FILM LANDSCAPE and OLD FILM PORTRAIT from old_film.png 2026-10-02 19:46:06 +07:00
3dtours 9b24ce7bbc web: the RECIPES chip hands the phone's bar to the list, not a second row over it — IMPORT .RECIPE stays the tab's own chip, and the path says TABS > PRESETS > RECIPES
On a phone the bar holds one level: the tab's chips, or the strip a chip of
theirs opened. The RECIPES list was the exception — it stood over the bar
with the bar still under it, so the level the finger had just asked for was
drawn twice, once as the list and once as its own row of chips.

The list now displaces the bar, beside the option groups and the watermark,
and wears the bar's slot and skin: that row IS the bar now.

IMPORT .RECIPE is not one of the looks the list holds, so it does not go
into the list — the way in belongs to the chip that opens it, not to the
content it fills. It stands in the tab's own row, one chip over from RECIPES
and at the same level, which is where it already stood on a wide screen. It
stands down with the bar while the list is open, as every chip of that row
does.

With the bar gone the tab's chip is gone too, so the path under the strip
grows the level: TABS > PRESETS > RECIPES, and PRESETS in it returns to the
tab's own chips. A wide screen is untouched — the bar never stands down
there; it simply gains the chip in its own row.

Checked on the running bundle (390x844 and 1280x900): the bar holds the
three chips in one row, the list takes the bar's slot under the ceiling
holding looks and nothing else, IMPORT is not among them, the path reads and
walks back, and a wide screen keeps its bar.
2026-10-01 14:53:09 +07:00
3dtours 7b6a1a805f web: a knob's ruler stands in the strip's slot, and a sub-chip is its name again
The strip a chip opened kept a row of its own while a knob's ruler opened over
it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip
and the panel under them — and charged the photo a row for the saying. The path
under the bar (.crumb) already names the panel the ruler came from and is one tap
from its strip, so the ruler now stands in the strip's slot and the strip stands
down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px,
COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB >
COLOR TEMP walking back to the strip and then to the bar.

A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP
as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled
twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the
name alone. The ruler's own "<" goes with them: the path names the strip it would
reopen, and a wide screen draws neither the path nor a ruler in the bar.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css
  71.42 kB.
- Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB
  open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's
  ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB >
  COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at
  641px. No page error and no sideways scroll (390px of 390px) in any state; on
  1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
2026-10-01 07:25:37 +07:00
3dtours c47ce212eb web: LIGHT is four strips on a phone, not four folded rows — the tab's column measured 215px of a 390x844 screen with its panels stacked, and the photo 498px; the strip is 40px and the photo 641px
LIGHT is the one tab whose picks are panels, and on a phone those panels were
being drawn the way a wide screen draws them: four heads stacked down the bottom
bar, each a disclosure for rows that were already there. The bar is a strip on
every other tab, so it is a strip here: .dev-chips now carries the four panels
as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under
860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip
without scrolling.

WB, TONE, PRESENCE and DETAIL & EFFECTS become strip keys of their own
(DEV_PANELS holds the rows each owns; DEV_DEF is one lookup for the defs, so a
row the phone has and the web has not draws nothing). A panel's strip is its
knobs first, then the picks that close it where they belong: WB's presets and
its two Color Chromes, TONE's curve, DETAIL & EFFECTS' four D.RANGE stops.

A knob in that strip opens its ruler in the row above, and the strip stays under
it, so the panel is still on screen while the number moves — the first level of
the cascade the other tabs already walk, one turn deeper.

PARAM_GROUP is what that costs: every knob LIGHT owns names its panel
(temperature -> wb, exposure -> tone, dehaze -> presence, grain -> effects), so
the ruler's own "<" reopens the panel it came from instead of dropping the strip
off the screen. A slider row opened from a strip returns to it.

The way back up is a path under the strip: TABS > LIGHT > WB > COLOR TEMP, one
name per level, each name returning to the strip it names and the last one drawn
as where the visitor stands. It replaces the single ☀TABS chip: that chip said
the same thing with one name where the path says how far down the visitor is,
which is the half of it the glyph could not. It is the last line of the stack,
so it, not the strip, carries the home-indicator inset now. A wide screen draws
no path: every column is in sight there already.

RESET leaves the bar. The topbar's RESET and this row both call the same reset(),
so the row was a second button for one action, and on a phone it sat at the end
of a strip that already scrolls sideways. CREATE keeps its own: that row also
bumps createReset, which the topbar's button does not do.

Measured on 390x844, LIGHT open: the bar 215px -> 40px, the photo 498px ->
641px, the path 32px; the four chips 390px of scroll width in a 390px strip, and
the page itself does not scroll sideways. On 1280x900 .dev-panels is painted,
.dev-chips and .crumb are not.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-BAbl_ECT.css
  71.65 kB, index-RU9cTwG7.js 735.50 kB.
- node scripts/{tone-base,highlight-knee,half,white-level,auto-tone,preview-match,
  raw-develop,roll-walk,wb-table,mask-wb}-check.mjs: all pass.
- Chromium 390x844 light and dark: rail -> LIGHT gives one 40px row of
  dev-wb/dev-tone/dev-presence/dev-effects and the path "TABS > LIGHT";
  dev-wb gives the panel's strip over the bar (13 chips, 824px of scroll);
  COLOR TEMP opens its ruler above that strip, strip still up, path
  "TABS > LIGHT > WB > COLOR TEMP"; the ruler's "<" lands back on the WB strip;
  crumb-strip drops the ruler, crumb-tab drops the strip, crumb-tabs stands the
  rail back up; TONE closes on the curve, DETAIL & EFFECTS on the four D.RANGE
  stops; no page error, no sideways scroll.
- Chromium 1280x900: .dev-panels painted, .dev-chips/.crumb none, WB panel opens
  with its presets, rows, chromes; no ruler column, as before.

Skipped:
- The row RESET on CREATE is kept, so that one bar still ends on a chip, where
  the topbar's button would leave the form's own reset unrun.
- The path is not drawn over 860px, where the columns are already in sight.
2026-10-01 06:49:36 +07:00
3dtours 6bbf18dc44 web: the phone's bar wears icons, stays at the top and ends on EXPORT — the four words wrapped the header to 135px over a 390px screen, 88px now, and the tab pills were 28px tall under a thumb, 44px now
LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The
recipe is the one the file already used for the stage's toolbar: font-size 0
takes the word out of the paint, never out of the button, so every accessible
name is exactly the word that is no longer drawn, and a ::before carries the
glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content
measured 990px wide with the words and 716px with the icons, and the bar it sat
in measured 135px tall on a 390px screen before this and 88px after: three rows
to two.

RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it.

EXPORT moves to the end of the <header>, after the three menus. It is the end of
the job and now the end of the row, which is the button the eye should land on
once the edit is done.

SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is
a word in a box too small for it, and the count that shares the label has no room
either. The button keeps its title, and the count is the one thing the phone
loses here — the wide screen still counts.

The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page
never scrolls (the stage does), so nothing moved before this and nothing moves
now — but that is a promise the layout can keep rather than a coincidence of who
scrolls. `.adm-bar` already carried the same three lines.

The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and
28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes
6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px.

This commit also lands the strip work from the session before it, which was
written and probed but never committed: under 860px the rail of tabs and the
tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip
stands the rail back up, the open chip's sub-chips stand over the bar as 60px
tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with
the bar above, so the phone's block lands in one piece.

Checks:
- npm run build (tsc --noEmit + vite) clean.
- The ten browser-free image checks (tone-base, highlight-knee, half,
  white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table,
  mask-wb) all pass.
- Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header
  sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key
  export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before;
  the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside
  the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0;
  strip-open keeps headerY 0 with the rail display:none; no page errors.
- Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row
  50px, the same four buttons at 14px with no ::before, EXPORT last.

Skipped: no new aria-label — the words the icons replace are still the buttons'
own text, so nothing needs one. Add one only if a label leaves the DOM.
2026-10-01 06:21:52 +07:00
3dtours 91aeacbe46 web: the session's photo waits for the account before it opens — a PRO clicking a RAW in the LIBRARY was read as a guest, asked to sign in, and the RAW never opened
Both halves of the boot ran in one effect: `api.me()` and the photo handover,
with the handover not waiting for the answer. Which file may open is a tier
question — `loadFile` turns a RAW away unless the account is PRO — and the tier
came from the render that effect closed over, which was the FIRST one, where
`user` is still null. So a PRO's own RAW was read as a guest's, `promptPro`
raised the sign-in modal, and because the handover had already cleared `?lib=`
out of the URL with `history.replaceState`, there was nothing left to retry:
signing in landed on an empty workspace, with the frame the visitor clicked
sitting in the library behind it.

The account and a new `authReady` flag now land in ONE batch, and the handover
is an effect of its own that returns until the flag is set. Both the batch and
the wait matter: one setState per render is what lets the handover effect see a
render that already carries `user`, and the flag is what says so.

Checked against a stubbed `api.me()` answering after 800ms (scratchpad
bug1-probe.mjs, a PRO opening `/app?lib=probe-frame`): on the old bundle the
`?lib=` is gone at the first sample, 765ms BEFORE the answer, and the session
photo opens on the wrong tier; here it survives until 193ms AFTER the answer.
The catalogue itself cannot be stood up in a check — its frames are real
FileSystemFileHandles in IndexedDB — so the RAW actually opening is a manual
step on a real catalogue.

Skipped: a GUEST who clicks a RAW still loses it across the sign-in — the
handover has run by then and the `?lib=` is gone. Add when someone reports it;
the tier that can open a RAW is the operator's own account, which is the case
this fixes.
2026-09-30 21:24:01 +07:00
3dtours 61b1210a47 TOOLS: AUTO and MONOCHROME come with any account, FIX and GRADIENT MASK stay PRO and answer with the reason 2026-09-30 12:20:03 +07:00
3dtours a3175210f9 studio: read an allowlisted admin as PRO on its own, so a rollout that pairs this bundle with an older API cannot show the operator the basic-tier bar 2026-09-30 12:13:57 +07:00
3dtours 74c0b2747f Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column 2026-09-30 11:50:28 +07:00
3dtours 35d8d57984 HSL: click a colour chip to open its panel, the way PICK does
The colour band chips only aimed the mixer before; now they open the same
panel the eyedropper opens, seeded with the colour the chip is named after
(hexToRgb turns that readout back into the RGB bytes the picker stores).
A pick already on the photo keeps its spot — only a mixer with nowhere to
hang takes the middle of the frame.

The HSL column itself is untouched: 8 chips, the rule, IMAGE,
HUE/SAT/LUM, the readout and RESET all stay where they were.
2026-09-30 07:34:07 +07:00
3dtours f7031c1809 LIGHT drops its PROFILE panel, TONE CURVE folds into TONE, and IMPORT .RECIPE moves under the RECIPES chip
Three small moves in the column, plus the rule they needed.

LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers
what it did — and the tone curve chip leaves the column's chip row. It becomes
the last line of the TONE panel's body, at the foot TONE always had room for.

PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column,
directly under the RECIPES chip, which is the set it imports into. The strip now
carries the recipes and nothing else, so its empty hint fires at length zero.

HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule
is a chip — divider: true in ChipDef — that renders a separator instead of a
button, so a row can say that what follows is a different kind of thing.

Verified by probe against the built app (exit 0, no console errors):
- LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE;
  the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27].
- PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under
  RECIPES at y 124), RESET; the strip no longer carries the import chip.
- HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule
  [94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852,
  where the rules measure [8,560,377,1] and [8,639,377,1].
- The rule is a 1px line: width 100% with height 1px, which keeps it a line in
  both the desktop column and the phone's wrapped strip.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:56:29 +07:00
3dtours 4476372b9e The two Color Chromes close the WB panel and D.RANGE gets a box of its own
Two picks that were drawn as rows of the column rather than as picks of a panel,
both of them because of where a slot happened to be rather than what the control
is. The pair of Color Chromes was LIGHT's own `chip-row grid` at the foot of the
column, under TONE CURVE, where it read as a third strip of the tab and sat far
from the two tracks it is the other axis of. D.RANGE was the effects slot, four
chips poured into the foot of DETAIL & EFFECTS where they were four more rows of
the panel and not the one setting they are.

WB now owns the pair. `DevelopPanels` gains a `foot` on a panel — a slot drawn
under the rows instead of above them — and the `wb` entry names `wbFoot`, so
COLOR CHROME and CHROME BLUE are rendered under COLOR TEMP and TINT, on the tab
whose colour they are and against the pair of tracks they are read with. They
keep everything else: the `chip-row grid` table layout the WB presets wear, their
keys, their OFF/ON labels and their pointer behaviour. The `wb` panel had no
slot but its presets and its two rows, so the foot is the whole of what changed
there; the four panels that name no foot are untouched, and the slot is looked up
per panel (`slots?.[panel.foot]`) so a panel that never asked for one draws
nothing.

D.RANGE closes DETAIL & EFFECTS inside a box that carries its name. `.dev-box` is
a frame in `--border-soft` with a `.dev-box-title` in the small-caps the panel
heads use, and the four stops sit in it as one set — which is what they are: a
hold on the whole frame, read as a set of stops, not as four more knobs of the
effects. The box is the slot's own wrapper, so it holds whichever chips the slot
is given and the chips keep their own keys and their amber AUTO.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-v5g7p7bS.js`, `dist/assets/index-RYLacibB.css` at 66.29 kB).
Against the built page (`127.0.0.1:8090`, 1440x900 desktop and 393x852 phone),
LIGHT tab, panels open:

- LIGHT's own row is now `∿ TONE CURVE` alone — the pair is out of the column.
- WB's children read `[chip-row grid (presets), mini-slider COLOR TEMP,
  mini-slider TINT, chip-row grid COLOR CHROME OFF / CHROME BLUE OFF]`, and both
  chromes report `inWb: true` — `COLOR CHROME [94,424,147,37]`,
  `CHROME BLUE [247,424,147,37]` at 1440 wide, `[8,599,186,37]` and
  `[200,599,186,37]` at 393 — in the WB body, under the two tracks.
- The D.RANGE box reports `data-key="dev-box-dr"`, title `DYNAMIC RANGE`,
  `[94,754,300,162]` under `effBox [94,569,300,347]` at 1440 wide, border
  `1px rgb(236,238,241)` (`--border-soft`); the four chips are full-width rows
  (282px) at y=781/814/847/880, AUTO `aria-pressed=true` in amber
  `rgb(206,117,9)` on `rgba(206,117,9,0.14)` and DR100/200/400 on white. On the
  phone the box is `[8,929,377,63]` and the four chips come back to one line at
  `.chip-row`'s own `@media (max-width:860px)` override, `[17,956,58,27]`,
  `[81,956,62,27]`, `[149,956,64,27]`, `[219,956,65,27]` — no clipping, nothing
  over the caption — and `errors: []` on both.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:37:15 +07:00
3dtours 6996d1bdb0 The marks get a tab of their own and the rulers read on one line
Four asks, one reading of the LIGHT column. EV's ruler sat under PROFILE, which
is the film simulation and nothing else — the range a picture is on is a knob of
the tonal range, not of the stock it was shot on. D.RANGE sat with the tone
controls for the same wrong reason: it is a hold on the whole frame, so it
belongs at the foot of the effects. The two Color Chromes had gone missing from
the eye, because the only place they were drawn was the effects slot and
DETAIL & EFFECTS now opens folded. And every ruler was two lines: a name with
its number on one, the track alone under them, where Lightroom reads a ruler as
one line. The four feature buttons answered a fifth ask that arrived with them:
AUTO, FIX, GRADIENT MASK and MONOCHROME do not name a look, they make a move on
the frame, and they now share a tab of their own.

TOOLS is the rail's ninth tab, between LIGHT and HSL, marked with the hammer and
pick (`⚒`) because the four features work ON the photo rather than pick a look
for it. The chips keep their keys, their amber-value logic and their pointer
behaviour — FIX and GRADIENT MASK still put down whatever tool was up before
they open their strip — and each carries its own mark at the head of its label
so the four read as one set: `✦ AUTO`, `✚ FIX`, `▚ GRADIENT MASK`,
`◑ MONOCHROME`. The marks were probed in the page's own stack before they were
spent (Inter, then the system sans): four-pointed star, thick cross, two
diagonal quadrants, half-filled circle — 26px, 47px, 77px and 15px of ink at
11px against the 36px a tofu box draws, so none of them is a missing glyph. The
strips still open in the last column, the same `stripChips` any other tab uses.

LIGHT keeps the one chip that is not a knob — TONE CURVE, `∿ TONE CURVE`, which
opens the graph on the photo rather than a ruler in the column, and stands over
the TONE panel whose shape it is — and gains the pair under it. COLOR CHROME and
CHROME BLUE are the same switch over two axes of one colour, so they are read
against each other rather than scanned in turn: one row, two chips, the table
layout the WB presets already wear (`.chip-row.grid`), 147px each side by side
at top 123 with nothing overflowing. Each names the strength it is on, and names
`OFF` when it is on `none` — the pair is compared, so the neutral value is one
of the two being read (`groupChip(g, nameAlways)`). Either one still opens its
own strip in the last column; picking STRONG lands on the chip and, at rest,
goes amber.

Every ruler is one line now: the knob's name at the head of the row, the track
taking the width they leave, the number it is on at the foot. The track moved
inside `MiniSlider`'s `.mini-head` and inside `SliderRow`'s `.head`, and both
rules became a flex row (`span` and `b` `flex: 0 0 auto`, the input
`flex: 1 1 40px; min-width: 0`). The eight TONE rows, the six of DETAIL &
EFFECTS and the mask column's eleven all read as one line, track included. The
HSL mixer's three knobs are the exception and keep the stacked form: three to a
narrow panel, a track between two labels is a sliver, so `.hsl-card-knobs`
wraps the readout onto its own line and lets the track take the second. The mask
column takes the ruler column's own width (`col-slider`, 232px) rather than a
strip's 168px, which is what the one-line form costs: eleven rows, and in a
strip's width the labels leave the tracks nothing.

PROFILE is the simulation strip alone, TONE leads with EV and then the finer
move on the same axis, and DETAIL & EFFECTS closes with the D.RANGE strip.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-CJSnLD0u.js`). Off the running page, through a probe: the
rail reads `◉ PRESETS ★ FAVORITED ☀ LIGHT ⚒ TOOLS ◍ HSL ▣ FRAME ⤓ SAVE RECENT
✎ CREATE RECIPES`; LIGHT's non-grid row is `[{key: curve, label: "∿ TONE
CURVE"}]` and the grid row under it is `["grp-cx", "grp-cxb"]` — 147px + 147px
in a 300px column, both boxes at top 123, nothing overflowing, `COLOR CHROME
OFF` and `CHROME BLUE OFF` each on its chip; its strip is
`[hint-cx, cx:none, cx:weak, cx:strong]` and picking STRONG lands
`{value: "STRONG", amber: false, on: true}` and then `{value: "STRONG", amber:
true}` once the strip closes. The TONE rows come back `EV 15+229+36
one-line=true`, `EXPOSURE 58+198+24`, `CONTRAST 60+214+6`, `HIGHLIGHT
60+214+6`, `SHADOW 50+224+6`, `WHITE 36+238+6`, `BLACK 38+236+6` (label, track,
readout; one line on every one), and the mask column's eleven the same at 232px.
PROFILE holds `{ev: false, dr: false, sim: […11 sims]}` and DETAIL & EFFECTS
holds `{dr: [dr:auto, dr:100, dr:200, dr:400], chrome: false, last: "AUTO DR100
DR200 DR400"}`. The TOOLS row is `[✦ AUTO, ✚ FIX, ▚ GRADIENT MASK, ◑
MONOCHROME]` at 148px each, the marks drawn 26/47/77/15px against the 36px tofu
control, and FIX's and GRADIENT MASK's strips still open
(`[hint-fix, heal, mosaic]`, `[hint-gradient, linear, radial]`) with MONOCHROME
still switching `false → true`. No page errors. `studio-open-check` (updated
for the pair and the single LIGHT chip), `library-check` and `scan-nav-check`
all pass, and the column was viewed with TONE and DETAIL & EFFECTS open.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 22:09:53 +07:00
3dtours 2f34c36131 The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left
Four asks, one shape: the LIGHT column had a chip that opened the same two
knobs DETAIL & EFFECTS already shows, the WB table was reading as one long
line per preset, the disclosure carets were too small to be the affordance
they are, and both folds — the studio's panels and the catalogue's folder tree
— opened fully every time. What the fold is for is the same in both places: a
column of a dozen controls is a wall to scroll past, and the control being
looked for is named by the head it sits under. So both open folded, and both
remember what was opened.

GRAIN's chip is gone from the LIGHT row. Its strip held a knob `grain` and the
"size" behind it, and both are rows of DETAIL & EFFECTS now — MONOCHROME GRAIN
and GRAIN SIZE — so the chip was a second way to one pair. The strip machinery
stays (`GroupKey 'grain'`, `groupDefs.grain`, `grainInch()`): it is reachable
from nothing today, and taking it out is a diff of its own. What went with the
chip is the `/INCH` readout, which only the strip drew.

The WB presets read as a name on one line and its kelvin under it: the label is
the chip's own label and the kelvin moved from the label into the value slot,
which the grid's rule stacks (`flex-direction: column`, `font-size: 11px`,
`white-space: nowrap`). Seven buttons, 147x39 each, name
box 14px — one line at 11px/1.25 — kelvin at 10px, `scrollWidth` equal to
`clientWidth` on every one of them. The kelvin rides a swatch, so it wears the
fill's own ink rather than the panel's dim grey (`.chip.tinted .val { color:
inherit }`): readableInk already picks #0b0e12 or #ffffff off that fill for
4.5:1, and dimming it a step costs contrast on a mid grey fill. Measured off
the running page: 4.88:1 (CLOUDY) to 5.00:1 (TUNGSTEN), name and kelvin alike.

The panels: `recipescam.develop.open` holds the slugs that are open, comma-
joined, and a slug no panel answers to is dropped on the way in, so a renamed
or removed panel leaves no hole. A visit that touches nothing writes nothing —
the column opens folded from an empty store — and a click on a head writes the
list as it stands. The caret is drawn a size up from the label it sits beside
(17px against an 11px head) and takes full opacity under the pointer, because
a line of uppercase does not read as a control on its own.

The tree: a row's click still opens it and folds it again, but the set it moves
in is inverted — what is *drawn open* rather than what is folded away — which
is what makes the state storable at all (a folded set is the whole tree minus
what is open, and the whole tree is not in that state to be stored). The
column opens with every folder shut, and the remembered set comes back over
it. A first visit under the new rule has no stored set and can still have a
folder remembered from before, so that one case seeds its ancestors: a screen
that opens on a node shrunk away behind a shut row is worse than a
remembered fold. COLLAPSE ALL folds the top-level row too now, which is what
makes it the way back to the column's rest — it was written when a top-level
row could not be folded, and the item is disabled when there is nothing open.
The caret is a leaf on a row with nothing under it, as it was.

`remember.ts` is the two functions both screens now share; Library had its own
copy of them.

The two checks that read these screens were wrong about the new fold and are
right about it now, not the other way round. `library-check` gains the rest
state (`the tree opens with the folders folded away — 1 rows`), walks the
tree a level per click, and reads the visit it left behind off a reload:
`lib-node-CheckRoll:true lib-node-CheckRoll/2026:true .../04:- .../Empty:-`,
with the screen marked on the subfolder. Two of its steps were stale
expectations rather than a fault: the fold-the-tree claim is one click over
four rows once the rows are opened for it, and the folder the screen reopens
on is the last row clicked, not the one clicked before it. `scan-nav-check`
asks the same tree for its deepest folder, so it opens the two rows above it
and folds them back — the reading it is watching does not move with rows.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-T721P-Nv.js` 711.33 kB, `dist/assets/index-Co_dQD6t.css`
65.69 kB). `scripts/library-check.mjs` — "all checks passed", 55 steps, none
failing — including `the tree opens with the folders folded away — 1 rows,
aria-expanded=false`, `a row draws its own children, not the whole branch — 3
rows`, `and one click folds every row, leaving the frames where they were — 4
rows → 1, lib-node-CheckRoll:false, 2 tiles kept`, `the tree reopens with the
rows that were left open`, `the screen reopens on the folder it was left on —
lib-node-CheckRoll/2026`, `the screen reopens on the frame that was raised`,
and the column `198px, then 198px`. `scripts/scan-nav-check.mjs` — all steps
passed, its tree walk reading
`["lib-node-SlowRoll","lib-node-SlowRoll/2026","lib-node-SlowRoll/2026/04"]`.
In the running page: 5 panel heads and 0 panel bodies at rest with
`recipescam.develop.open` unset, caret `17px` `▸`; the WB table's seven rows
at 147x39 with name 14px one line, kelvin below at 10px, `nowrap`, no
overflow, ink 4.88–5.00:1; the LIGHT chip row `["auto","curve","fix",
"gradient-mask","mono","wb-temp:*","reset-all"]` — no `grp-grain` — with
MONOCHROME GRAIN and GRAIN SIZE still the two rows of DETAIL & EFFECTS; two
heads opened, `wb,effects` stored, and the same two open after a reload with
the other three shut. The deployed bundle was read too: `docker compose build
frontend && docker compose up -d frontend`, and `index-DpYWAhJ3.js` off
`127.0.0.1:8090` carries `recipescam.develop.open` and
`recipescam.library.open` and no `grp-grain`, with the same probe readings
taken against it.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 21:25:18 +07:00
3dtours cc186c4d09 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>
2026-09-29 20:21:08 +07:00
3dtours 3b92e4e2d2 A gradient mask can carry the LIGHT column's white balance now: COLOR TEMP and
TINT, the same two rulers, read on the mask's own pixels.

The pair was asked for as the two rows the develop column already has, so it is
the same pair and not a second opinion about what a kelvin means: the gain comes
from one function, colorUtils.whiteBalanceGain(temperature, tint), which the
frame-wide colour matrix now calls too — the RGB kelvin fit of kelvinToRGB, the
symmetric ±0.08 magenta/green tint, and the division by the product's own Rec.709
luma that keeps a cast from being a brightness move. A mask that never touched
the pair reads 5500K / 0 (readMasks' own defaults, clamped to the ruler's ends),
whose gain is exactly (1,1,1), so nothing moves and every recipe stored before
this reads the same. The measured rule holds inside a mask exactly as it does on
the whole frame: at 10000K the mask's pixels came out R/B 1.25 -> 1.72 against
0.994 -> 0.994 outside it.

The Kelvin fit is a curve, so it is resolved on the JS side and handed over as a
gain: maskUniforms grows one more float4 array (wb[i], three gains and a zero
pad) between the spatial pair and the frame size, in declaration order like every
other array in that buffer, and the shader multiplies the mask's colour by it
right after EXPOSURE — one clamp, three multiplies by one for a mask that leaves
the pair alone, and no second copy of the fit in SkSL. The spatial build is a
second shader text with a second signature and reads the same array.

App.tsx draws the rows where the panel's other rows already are, and TINT rides
the existing maskKnobRow helper: same store unit (±10), same ±100 slider the
frame-wide row wears since the develop knobs were deepened. COLOR TEMP keeps its
own scale (2500..10000, step 100, "10000K"), because a temperature is not a
percentage, and it carries the same swatch the frame-wide row paints.

Verified: npx tsc --noEmit; npm run build; the repo's check set (highlight-knee,
auto-tone, half, white-level, preview-match, library, scan-nav, roll-walk) all
pass; and scripts/mask-wb-check.mjs, which is new here — it transpiles the
gradientMask graph into a temp dir and checks the gain (neutral at 5500K/0, warm
at 10000K, cool at 2500K, luma-preserving, ±TINT symmetric), the clamps, the
uniform offsets (the gain lands where the shader reads wb[i], the frame size one
array further on), and then compiles the real shader through CanvasKit and pushes
a mid-grey through it: neutral leaves 128, 10000K warms it, +TINT magenta-ises it,
and both the plain and the spatial builds and a two-mask list read it. The UI was
driven on the built bundle too (a linear mask dragged on the preview, then the
two rulers moved through their own range inputs): the mask column reports 11 rows,
opens at 5500K / 0, takes 10000K and +40 ±100-scale TINT, the swatch follows, and
the preview's own pixels warm inside the mask and nowhere else.

ponytail: the mask's WB is not skipped for monochrome stocks the way the
frame-wide matrix skips it — a local kelvin on a B&W frame is a tint someone
asked for by hand, not the colour leak that rule exists to stop. Add the same
isMonochromeBase guard (and pass the base filter into maskUniforms) only if that
turn out to read wrong on a live B&W recipe.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:35:19 +07:00
3dtours c0aaa67361 Studio: fold WB and FX into LIGHT, and put every develop slider on -100..+100
WB and FX were separate tabs whose only content was the same Lightroom-style
develop column LIGHT already renders, so the rail carried three doors into one
room. LIGHT now owns the whole column: white balance, tone, presence, the
effects group (grain, dehaze, vignette) and the filters, in that order. The
`wb` and `fx` TabIds, their rail entries, their `wbLabel()` helper and the now
dead `tab.wb` / `tab.fx` i18n keys are gone; `ToolRail` documents eight tabs.

Every continuous develop value that the UI exposes as a symmetric knob now
runs -100..+100 instead of -10..+10. `paramDefs` gains the `HUNDRED` key set
and the `deepen` helper, which widens a def's range and scales its accessors by
ten, so the store keeps its -10..+10 internal scale and every stored look,
DEFAULT_RECIPES entry and URL round-trip is byte-identical. Params with a
meaningful physical scale (temperature in kelvin, exposure in EV-ish units,
grain, grain size, hdf, vignette, rotate, crop) keep their own units.

Highlight recovery no longer bends hue. The old knee clamped the per-channel
gain into 0.55..1.35, which is a per-channel operation and therefore a hue
rotation: on the flat skin patch it walked hue from 24 deg to 48 deg at
HIGHLIGHT +100, and on a saturated 30:1 chroma ramp it desaturated toward black
instead of toward white. The shader now caps the pixel's distance from its
undersaturated knee point while preserving the direction of that offset, i.e.
it scales chroma and keeps hue, then clamps into gamut.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- `node scripts/highlight-knee-check.mjs` (pins the new shader source and
  twin-tests 135 knob combinations) ok.
- Existing checks re-run green: `auto-tone-check`, `half-check`,
  `white-level-check`, `preview-match-check`, `library-check`,
  `scan-nav-check`, `roll-walk-check`.
- Live browser pass: rail shows exactly the seven expected tabs with no WB or
  FX; LIGHT renders 5 panels / 23 `data-key` knobs; 12 knobs report
  `min=-100 max=100`; everything else keeps its own range.
- Highlight hue measured on ten flat colour patches: HIGHLIGHT -100 gives a
  hue delta of 0.00 deg on every chromatic patch; HIGHLIGHT +100 stays within
  0.22 deg (sky) and 0.28 deg (magenta) wherever chroma survives, and the
  patches the ramp intentionally drives to white arrive fully neutral. Greys
  stay neutral (channel spread <= 2/255) at every knob setting. Skin patch
  before/after: 23.94 deg -> 0.00 deg.

ponytail: temperature (2500-10000 K), exposure (+-10 units at 0.25 EV each),
grain, grain size, hdf, vignette, rotate and crop deliberately keep their own
scales rather than the blanket -100..+100; widen them the day a user asks for
more range, not before. Hue assertions live in the flat-patch check because
real-photo measurements pick up 2-4 deg of resample drift from the snapshot
pipeline that has nothing to do with the shader.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:04:08 +07:00
3dtours 34f8601c91 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom
spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become
shapes of the tone curve, the way the mapping spec measures them.

The column. The left rail used to hand LIGHT a row of chips and nothing else;
the four tone knobs were chips that opened a curve, and the rest of the develop
state lived in the chip row's own vocabulary. `DevelopPanels` renders the five
sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS —
as an accordion, all open, and every parameter the recipe holds has a row in it
with a `data-key` off the parameter name: slider, value readout, double-click to
default. Sliders are always visible, so a knob is one drag away instead of two
taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber,
green through pink) so the direction is on the control. The PRO looks the spec
marks stay in the list but locked, tagged PRO, and tapping one asks for PRO —
they are shown, not hidden, and not silently dropped.

WHITE and BLACK move out of the WB group. They were the temperature group's
extremes, which is what a white balance control does — the toe and the shoulder
of the same ramp — but the spec puts them with the tone knobs and gives them the
two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE
and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights`
and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives.
A recipe written before this commit still reads: the keys are unchanged.

The four knobs. The first pass of the mapping spec added a mask per zone onto the
channel: `luma += knob * mask * intensity`, and the shader followed it. It is the
wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the
four masks are not a partition of the ramp. They sum to one at the ends and to
zero at the midpoint, so an adjustment in the middle of a zone is applied where
the mask is half and not at all where the mask has fallen to nothing, and the
ramp inverts: with every knob at its stop the curve folds over itself, slope −5
at t=0.87, and the twin catches it as a non-monotone ramp.

So each knob moves a knot on the curve instead, which is the reading the spec's
own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50,
HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve
through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a
quarter of the range at that knot, so the range is 0.75..1.00 at the top and
0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`)
by clamping each against its neighbour. Between knots the curve is a straight
line, and 0.5 is untouched by every knob, so a knob at zero is the identity
exactly rather than nearly, and any combination of the four is monotone. The mask
sum survives where the spec is right about it: it hints the split between the two
dark zones and the two light ones, nothing else.

The hue is kept the way the spec keeps it: work in luma, then scale the chroma
offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a
saturated red stays the same red and only its brightness moves. The ratio is
clamped to 0.55..1.35 because at luma near zero the division is the whole
highlight of the picture on one code value.

Verified:

- `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader —
  four masks, four anchors, the four `mix` lines — and asserts the constructions
  it replaced are gone, then drives a twin of the ramp in JS: the masks do not
  overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162
  combinations of the four knobs, every combination is monotone, the amplitude at
  each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded
  case from the additive build is in the harness as a regression.
- `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and
  `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`,
  `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check`
  and `white-level-check` all pass against the bundle — the catalogue, the RAW
  path, auto tone and the white level are untouched by the panel move.
- Driven in a real browser (`tone-live-check.mjs`, Chromium against
  `vite preview`, a P1010256.JPG in the source control, mean luma of the preview
  canvas read before and after each knob): neutral 184.25, WHITE +1 187.35,
  BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80.
  Every knob moves the picture the way the spec says it should and none of them
  moves it much — a stop of a knob is a quarter of a zone, not a level.
- The same run asserts the built DOM: five panels, the 23 `data-key` rows,
  `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and
  `dev-shadow` together in TONE, the gradient classes on the two white balance
  sliders, and the chip slots the panel is handed. The only failed request is
  `/api/events`, which is the backend this preview does not run.

ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a
per-channel rolloff in linear light — is gone, deleted rather than ported. The
additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask
could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends,
and the per-channel rolloff is a second knob for the same picture. Bring it back
as its own parameter if a frame ever clips badly enough to need it.

Also dropped: DR used to ride along as two additive terms. That is where the fold
at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the
ramp past its own end. It shifts the knots now, which is what the film sims
always meant by it, and the numbers in the sims were kept and their meaning
recommented (classic-chrome toe 0.22, head 0.7375, etc.).

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 16:38:56 +07:00
3dtours 81637b8502 web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library
now takes a folder through Chromium's directory picker, keeps the handle
in IndexedDB so the folder is there on the next visit, and walks it into
a grid: one thumbnail per frame, the frame's own date, and the recipe it
was last graded with. Nothing is uploaded and nothing is read twice —
the RAW itself is opened only when a tile is clicked, at which point the
studio develops it and the recipe comes back on top. The studio files
every change back against the frame, debounced, so reopening a RAW is
not doing the grade again.

Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and
a copy where a develop is a full decode of every pixel, and off
createImageBitmap for anything else. A RAW with no preview inside it
gets a placeholder tile rather than a minute of decoding per file.

  node scripts/library-check.mjs
  ok  both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2
  ok  thumbnail for P1010256.JPG — 40400 bytes, jpeg=true
  ok  thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true      (LibRaw preview)
  ok  studio developed the frame from its handle
  ok  the address was handed back — url=/app (no ?lib= left behind)
  ok  the look was filed back against the frame — baseFilter=none, 19 knobs
  ok  the tile says the frame is edited
2026-09-28 17:46:12 +07:00
3dtours e24ea9e71d web: open the stage on the neutral stock, not the CLASSIC NEGIPES preset
The boot recipe was DEFAULT_RECIPES[0], so every file that landed — NEF
or JPEG — came in with exposure +2 (+0.5 EV), contrast +1, saturation -1,
6300K / tint +2, highlight -2, clarity +2, grain +4 and the classic-neg
filter already on. On APACHAI-20160215-357.NEF that read as pink skin and
blown highlights, and it made a correct decode look like a decode bug.
Develop itself was fine: 179.0/168.2/171.2 against the embedded preview's
178.2/167.1/170.7.

Boot is now BASE_RECIPE — no filter, all knobs default, the same look
COMPARE splits against. A session left by the old boot holds that preset
verbatim, which was never the visitor's pick, so it is dropped; anything
the visitor actually touched moves the stored object off the exact
fingerprint and is kept.

Render bytes, same 1600x1068 view of the JPEG (R/G/B means):
  source file          170.7 / 159.1 / 163.1
  before               194.7 / 178.0 / 177.2
  after                169.5 / 158.5 / 162.0
NEF render stage: 202.4 / 186.3 / 185.4 -> 177.9 / 167.3 / 169.9
Output is byte-identical to the old neutral render (md5 a3932746...),
while a clicked chip (classic-neg-default) and a tweaked session both
still restore their own bytes.
2026-09-28 16:29:24 +07:00
3dtours e6c050581f web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.

  EV          the mean luma, unchanged
  HIGHLIGHT   the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
  SHADOW      the bottom 1% (p01 < 0.02 opens up), to +5
  TEMPERATURE/TINT  the gain that puts the three channel means on each other,
              green as the anchor: gray-world on linearised means, then the
              closest of 76 temperatures x 21 tints under the renderer's own
              kelvinToRGB, so the pair cannot drift from what the ruler applies.

The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.

Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.

scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
2026-09-28 15:24:50 +07:00
3dtours 432acba9c1 web: put the mask's column away with the tool, and keep a blown highlight's hue
The column of mask knobs belongs to an armed LINEAR or RADIAL shape, and it
stayed on the stage after the hand had moved on: arm a shape, draw it, click
another chip or another tab, and the strip of mask sliders was still there
belonging to a tool that was no longer in hand. A capture-phase `pointerdown` on
`window`, armed only while `maskTool` is set, now puts the tool down in the same
gesture that reaches for something else. A press on the rail (the tabs) or on
any chip except the shape's own two dismisses; a press inside the column is left
alone, because the column's own chips handle their click themselves, and a press
on the photo is left alone, because dragging on the photo is how the shape is
drawn. It is the dismissal the STRAIGHTEN tool already had, one effect over, so
the tab-switch effect needed no `setMaskTool(null)` of its own.

A RAW's blown highlight came out magenta, and was measured before it was
touched. `example-sony.ARW` through the lab (`rawblow.html`, the same camera
white/black and the same `rgb_cam` the app's develop uses): white 16380, black
512, cam_mul green-normalised to (2.581, 1, 1.553). The pixels the sensor could
not hold — 0.7% of the frame, raw max channel at or past 0.99 of the white level
— average (0.775, 1.416, 1.108) in raw, and per channel 26.3% / 96.0% / 46.7% are
at or over white: green is 1.4x the white level while red is still under it.

The develop shader clamped every channel to 1.0 BEFORE the white-balance gains,
and that one clamp is the whole cast. Green is the channel the gains are
normalised to, so it stopped at 1.0, while red and blue — which need their 2.581
and 1.553 — were already past it and were carried over by the multiply. The
blown area therefore left the matrix at (1.0, 0.53, 0.62) instead of at white:
measured on the develop output, (254.4, 217.0, 242.9) — red and blue 38 above
green, which is magenta. On the stage, pixels with red and blue over 235 and
green under 225 in the same framing: 1191 with the clamp, 33 without it.

The shader now only floors at zero, keeps the channel ratios through the matrix,
and fades whatever ran past white towards white (`mix(rgb / mx, 1, 1 - 1/mx)`,
the desaturate-to-white dcraw uses for the same problem). The blown area comes
out (254.5, 253.9, 246.6): an overflow that stays bright and stops taking a hue,
broken per channel at sd 8.7 / 28.0 / 26.4 today against 5.4 / 3.3 / 17.8 now.
Whole-frame averages move by 0.008/0.278/0.140 of a level — the fade only
touches pixels that were over white, which are the 0.7%.

"cannot be rescued" is the second half of the same fact, and it is now a
measurement rather than a hope: LIGHT's HIGHLIGHT row is a curve over what
develop emitted, and while red and blue were pinned at 255 the curve had nothing
to pull on. The fade leaves a compressed ramp there instead, which is what the
row now pulls. LibRaw's own reconstruction modes are not the answer on this
file: `-H` 1, 2 and 3 hand back byte-identical develop output to `-H` 0
(`pxAt65535` is 0 — the sensor never reached its 65535, only the camera's white
level), so `SETTINGS.highlight` stays 0. What the app cannot do is keep the two
stops above white, because the band still leaves develop as 8-bit JPEG; that
ceiling is named at the point of the fade, for whoever needs RAW highlights
recovered rather than merely correct.

`npm run typecheck` and `npm run build` are clean (bundle `index-BJy_3HCz.js`).

Probes: verify-mask-column (dev server, real photo, arm a shape and draw it,
then reach for another chip and for LIGHT and FX — 8 checks, 8 pass: the column
stands while the shape is armed, survives a press on the photo and on the
shape's own kind chips, and goes on any other chip or tab), rawblow (the real
ARW through the real develop maths in the page, before/after chains side by
side, which is where the magenta and the reconstruction modes were measured),
probe-raw-highlight (the app itself, ARW uploaded, blown pixels counted and the
HIGHLIGHT row driven to both ends).
2026-09-26 20:55:05 +07:00
3dtours 9164bf3228 web: read DEHAZE off the dark channel, and let it run both ways
DEHAZE read its haze estimate out of the frame's own bilateral reference — the
patch AVERAGE — where the Dark Channel Prior asks for the patch MINIMUM. That
one word is the whole prior: `dark = min(min(r,g,b)/A)` over a neighbourhood
reads 0 for any patch that holds a shadow or a black frame line, so the
transmission stays at 1 and the patch is left alone, while the average of a
patch that holds a dark pixel is still bright, so every patch looked hazy. The
positive end therefore ground the frame down instead of taking haze out of it:
at +9 the mask moved its own middle band -0.2127 and the frame-wide row moved
the whole frame -0.2311, and the local contrast went the WRONG way (dhp -0.0060
on the mask, -0.0056 frame-wide) — a haze remover that lowers contrast is a haze
remover that is lowering everything.

The pass reads the dark channel from the image it is correcting, five by five
taps at DEHAZE_PATCH_STEP (0.625% of the frame's width per tap, a 2.5%-wide
patch — the DCP's own 15 pixels on a 600px frame, and the same fraction of a
4000px export) in DEHAZE_SKSL and in gradientMask's block, so the mask and the
frame-wide row are the same neighbourhood at every render size. Five by five
rather than fifteen by fifteen because 225 child reads per pixel is what
CLARITY_BLUR_SKSL already refused for a reference the prior does not need to be
that wide. The bilateral reference is now only what CLARITY compares against, so
DEHAZE no longer takes a second child at all.

DEHAZE is signed, which it was not: the knob was 0..10 and the export engine
skipped the pass unless the amount was above zero, so a negative value was a
slider the UI would not even offer. It is -10..+10 now, and the transmission
carries the sign — positive pushes t below 1 and `J = (I - A)/t + A` takes the
scattered light out, negative pushes it above 1 and the same expression scatters
light back in. That is the direction a photo shot through mist wants, and it
needs no second formula: one expression, both signs, the ceiling at
1 + DEHAZE_MAX_OMEGA.

CLARITY's negative side was the last place where a knob meant two different
things depending on where it was read: the frame-wide row softened with a mist
blur of its own radius (MakeBlur, sigma |c|/10*4) while a mask mixed toward the
bilateral reference the positive side reads — two neighbourhoods, two strengths,
one name. CLARITY_BLEND_SKSL now carries both directions of the one move (above
zero the doc's unsharp, below it the mix back toward the same reference, gain
1), so the frame-wide row and a mask's CLARITY are the same reference at the
same strength, and the frame-wide mist blur is gone.

Measured in one harness, one photo, one session, knob at +-9, before -> after,
mask phase and frame phase in the same run (the box is the mask's own middle
box for the mask, the stage's own box for the frame-wide row):

  - FRAME DEHAZE +9: dmean -0.1680 -> -0.0751, dhp -0.0056 -> +0.0036, white
    band -0.2156 -> -0.0522 — it darkens the haze and raises the contrast
    instead of lowering both.
  - FRAME DEHAZE -9: dmean +0.0469 (was not offered), dhp -0.0010 — the same
    knob on the other side, and the frame gets hazier.
  - MASK DEHAZE +9: dmean -0.1490 -> -0.0513, dhp -0.0060 -> +0.0039, white band
    -0.1234 -> -0.0274, dark band -0.0595 -> -0.0075 — a mask's DEHAZE is now
    the frame-wide move on the mask's own pixels (dhp +0.0039 against the
    frame's +0.0036).
  - MASK DEHAZE -9: dmean +0.0319, dhp -0.0013.
  - FRAME CLARITY -9: dhp -0.0200 -> -0.0094, white band -0.1112 -> -0.0203, so
    the frame-wide row no longer pays for its soften by flattening every white
    in the frame; MASK CLARITY -9 is the same move (dhp -0.0150, white band
    -0.0103) and the two now agree in direction, sign and rough magnitude at
    -9. CLARITY +9 is untouched on both sides (+0.0335 mask, +0.0307 frame) and
    every other knob's numbers are unchanged to within +-0.0005, which is the
    run-to-run noise of the same harness.

`step` was the uniform's first name and SkSL refused the shader with it (a
builtin), which is how a whole DEHAZE row came back with all-zero deltas in the
first measurement after the change; `stepPx` is what compiles. `npm run
typecheck` and `npm run build` are clean, and the stage draws with no page error
(the only console error is the dev server's own `/api/events` 404).

Not ported: nothing. The phone's renderer has no gradient mask and no
atmospheric-light estimate to mirror; `shared/utils/toneShader.ts` and
`shared/utils/gradientMask.ts` are the web engine's own files.

Probes: measure-parity (both phases in one run, one photo, before and after —
the same harness the previous commit was scored with), measure-dehaze2 (the same
script with only DEHAZE in both phases, plus a console listener, which is how
the `step` uniform was caught), sim-dehaze-dcp (the offline simulation that
picked the min-patch over the average: clear frame +9, contrast 0.0248 -> 0.0292
against the average's 0.0248 -> 0.0235).
2026-09-26 20:16:13 +07:00
3dtours 5f3257a4d8 web: make a mask's knobs the moves the frame-wide row of the same name makes
A gradient mask carried its own copy of the six tonal and spatial formulas, and
four of them had drifted from the columns of the same name. HIGHLIGHT was
inverted: the single shift `0.5 * (shadows*ms - highlights*mh)` put the knob's
`x` on the shadow mask and its `y` on the highlight mask, so turning HIGHLIGHT
up pulled the bright band DOWN and turning it down lifted it — measured at the
mask, +9 moved the top band -0.2295 and -9 moved it +0.0454, and the whole
frame's HIGHLIGHT row reads the other way. WHITE and BLACK were a flat gain on
the end each one owns (`c * (1 + 0.5*k*mh)`), which scales every pixel above the
midtone by the same fraction and so drags the near-whites into the greys rather
than leaving them white: a lowered WHITE took the top band down -0.2117 while
the middle band moved -0.0036, and a raised BLACK pushed the middle band up
+0.0195 for +0.0414 at the bottom — the lift went everywhere except where it was
asked for. CLARITY's negative side was the same unsharp as its positive side
with the sign flipped — `c + k*(c - blur)` with k negative — which is a soften
only in name: it sank the mask's whites (top band -0.0543 at -9) and left the
mask's own contrast where it was (dhp -0.0008), the opposite of what the knob is
named for.
DEHAZE ran after CLARITY, so a mask sharpened its haze and then tried to remove
it; frame-wide the two are the other way round, and for a reason.

The four now mean inside a mask what they mean on the whole frame, because
toneShader.ts is the reference the app's own rows are written from and the same
name on the same knob should not be two different moves. HIGHLIGHT and SHADOW
are TONE_SKSL's additive luma shifts — HIGHLIGHT weighted by the headroom it has
left (1 - t) so it cannot drag a blown white to grey, SHADOW by its own floor —
with the colour difference riding along at TONE_SKSL's damped gain so a lift
cannot collapse a colour. WHITE and BLACK are TONE_SKSL's per-channel point
moves, cubic in each channel's distance from the end it owns, so the toe and the
shoulder move and the midtones stay put. DEHAZE runs before CLARITY, the
frame-wide order. CLARITY's negative side is a real soften, `mix(c, blur, -k)`
toward the same bilateral reference its positive side works against. TONE_SKSL's
two smoothsteps (0.50..1.00 and 0.00..0.55) replace the mask shader's own pair,
and the soft masks are computed in float and narrowed once, the way the
frame-wide shader does it.

Measured in one harness, one photo, one session (the mask's own middle box, knob
at +-9, before -> after on the mask, with the frame-wide knob of the same name
as the reference it is now written from):

  - HIGHLIGHT +9: top band -0.2295 -> +0.0298 (frame-wide +0.0547), dark band
    0.0000 -> -0.0001 — the lift is a lift, and the inversion is gone.
    HIGHLIGHT -9: +0.0454 -> -0.0385 (frame-wide -0.0674).
  - WHITE -9: top band -0.2117 -> -0.0646 (frame-wide -0.0860) — a lowered
    white stays a white instead of becoming a grey — while the middle band goes
    -0.0036 -> -0.0150 (frame-wide -0.0139), which is the move a white ends up
    making when it is a point move rather than a gain.
  - BLACK +9: bottom band +0.0414 -> +0.0997 (frame-wide +0.1036) and the middle
    +0.0195 -> +0.0347 (frame-wide +0.0402) — it goes to the toe it owns.
  - CLARITY -9: dhp -0.0008 -> -0.0149 (frame-wide -0.0200) — negative CLARITY
    softens now — and the top band -0.0543 -> -0.0113, so it no longer pays for
    that soften by sinking the mask's whites. CLARITY +9 was already right
    (+0.0333 both sides) and stays.
  - DEHAZE, the one knob with nothing on the negative side: unchanged at -9
    (-0.1490 both sides, the pass order was the only thing wrong with it), and
    the mask and the frame-wide row now agree across the whole range
    (1/3/5/7/9 at -0.0124/-0.0397/-0.0711/-0.1074/-0.1490 on the mask against
    -0.0133/-0.0423/-0.0759/-0.1151/-0.1619 frame-wide), monotone.

DEHAZE's own numbers are therefore not a mask-only bug: an estimate of the haze
that reads a mask differently from the frame would be inside `atmosphericLight`
and `DEHAZE_MAX_OMEGA`, which both paths share, and changing either moves the
frame-wide DEHAZE column too — left as it is rather than changed under a mask
report.

Everything else about the mask is untouched: `dctrl` is +0.0000 on all six
knobs (a knob still moves the mask's own pixels and nothing outside it), the
shader compiles and the stage draws with no page error.

Not ported: nothing. `shared/utils/gradientMask.ts` is the web engine's own file
and the phone's renderer has no gradient mask to mirror.

Probes: measure-mask-knobs (the six knobs on a selected mask, before and after),
measure-frame-knobs (the same six frame-wide, the reference the mask is now
written from), measure-parity (both phases in one run so the two are the same
photo in the same session), png-parity-report (the before half of that run died
in its frame phase and left no log, so its already-captured mask frames are
re-read off the PNGs with the same box and the same bands), measure-dehaze-curve
(DEHAZE 1..9 on the mask against 1..9 frame-wide, for monotonicity and for the
pass order).
2026-09-26 19:04:32 +07:00
3dtours 97bdf605e2 web: import the camera's RAW, and grade it like the phone
The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own
negatives never reached it. A RAW now loads the way any other file does —
`isRawName` reads the extension off a 24-entry list, the file goes into OPFS
under one slot (`current_image.raw`, beside `current_image.name`, so a reload
finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit
output, camera white balance and the camera's own 3x3 matrix, in bands of 2M
pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW`
(30.3MB) lands as a 3120x2084 picture, no page error.

DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same
PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is
the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the
photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at
71.8% of the stage's pixels moved between 0 and 10.

The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW,
WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than
a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus
the air A as a second child of the mask shader — so a mask's clarity is clarity
and not a flat brightness lift. The column shows all nine rulers; CLARITY 9
moves 42.2% of the stage, DEHAZE 9 moves 27.9%.

CLARITY stops reading the whole photo per pixel: the single pass that sampled a
15x15 box 225 times is now the three passes the same math wants — 1x15, then
15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work
(77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps
its mist as it was.

The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or
a recipe chip lays that look on the photo while it stays there and gives it back
the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%,
24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO
stack and the session stay on the look the click left. A hovered look brings its
colour alone: the masks, the dust spots and the mosaic of the photo being edited
ride along, or a pointer crossing a chip row would rub them off. A PRO sim is
left out, since a hover that showed its look would hand over what the click
gates.

Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify,
e2e-hover-preview2.
2026-09-26 18:18:22 +07:00
3dtours 6814b055cb web: frame through the recipe, with the viewfinder where the stage was
The live view is the one place where the look is chosen before the picture
exists, so the columns that hold it have to stay in reach while the camera is
open. The viewfinder was a fixed black sheet over the whole app: the rail and
every slider were behind it, and the only way to change the look was to close
the camera, grade the file, and open it again. It now covers the stage and
nothing else — the stage and the view share a slot (`.stage-slot`, position
relative) and the view is absolutely placed inside it, z-index 5, black in
either theme. Measured at 1600px: the rail and the columns are on screen, the
feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider
moved with the camera open reaches the very next frame (the render loop reads
the recipe per frame, so nothing had to be wired), a monochrome sim takes the
live feed from sat 42.1 to 0, and the shutter hands the studio that same frame
with the sim already on it.

The preview render is skipped while the viewfinder is up (`|| shooting` in the
guard, and in the effect's deps): it would run the same recipe through the same
renderer as the frames the user is actually watching, and the live one wins.

Opening it is PRO, like the HSL chips and the geotag — the frames are the paid
ones. The button stays on the page for everyone and carries the badge, and the
click runs the app's own `promptPro`: the account dialog for a guest, the
verification panel for a signed-in account, `/api/auth/me` being what says
which. A verified account gets the camera; a guest gets `.modal-backdrop` and no
`camera-view` at all.

Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no
viewfinder; verified opens it), cam-live-edit (rail and columns survive the
live view, feed covers the stage, the recipe reaches the live frames and the
shot), cam-smoke, cam-renegotiate, cam-close-flip.
2026-09-26 08:39:17 +07:00
3dtours cb1839e36b web: shoot through the live camera
The one path where the look is chosen before the picture exists: OPEN CAMERA
grades the camera's own feed with the recipe in force, many times a second, and
the shutter hands the studio the sensor's still under that same recipe. Preview
and file differ in resolution only — the still is `takePhoto`'s own frame, not a
copy of the small preview video, with `grabFrame` and a 2d copy of the element
behind it for the browsers that ship no ImageCapture.

The renderer gains two inputs for it: `sourceImage`, a picture the caller
already decoded (re-encoding the camera's frame to JPEG only to decode it again
would cost more than the whole render), and `drawTo`, which paints the finished
picture instead of encoding it. One render is in flight at a time; a frame that
arrives during one is dropped, so a slow device shows a lower frame rate rather
than a queue of moments that have passed.

The view flashed black on a phone. Setting width/height on a canvas resets its
bitmap: measured on the preview, a resize leaves mean 0 until the next render
lands, which on this box is 0.5s and on a phone more. The buffer was sized from
every incoming frame, and a capture that renegotiates its resolution — which
Chromium does when the page is too slow to consume its frames, and this pipeline
runs ~2 fps at 720p under software GL — strobed black/picture at every switch.
The buffer is now sized on the first frame and after that only when the frame's
aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720
stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no
black frame, and 640x360 renders at 6-13 fps instead of 2.

The frames are read from a <video>, which is now IN the document (1px, behind
the black backdrop) rather than detached: Safari draws blank frames from a
detached video, which is the same black-between-pictures. It leaves the document
with the view, and the tracks are stopped, so the camera light goes out.

Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim
reaching the live frames, shutter into the studio, close, console clean),
cam-renegotiate (no resize, no blank frame, status line on the frames),
cam-close-flip (flip returns a picture; video gone on close).
2026-09-26 08:26:28 +07:00
3dtours fd2d9935c1 web: filter the exports the model was only smearing
The upscaler ran for every enlargement, including the ones a plain resample
wins: measured on a 2048px source cropped and blown back up it loses to
lanczos on PSNR and SSIM at 2x and 3x, and its smoothness reads as plastic
skin and lost texture next to it. From 4x — the model's own factor — it
stops losing, so the threshold moves to 4 and the crop no longer drags a
2x export through it.

The resamples it now carries never set imageSmoothingQuality, and the
default 'low' point-samples: a 1px stripe comes out at full amplitude
instead of the average of what it crossed. Both callers ask for 'high'.

Probe on a 2400x1800 source exported at 4K: 299.9s -> 7.8s, correlation
with the source's 1px/2px bands 0.89/0.95 -> 0.98/0.98, grain sd 25.4 ->
47.1 (a plain HQ resize of the same source keeps 16.1).
2026-09-25 20:32:52 +07:00
3dtours 7e47a153b8 web: make EXPOSURE, EV and HIGHLIGHT mean what Lightroom means
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB
colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL:
linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded
values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral
PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239.

The pass sits between the matrix and the tone shader, and the tone / cinema /
curve / glow / halation children all sample through it, so HIGHLIGHT finally
sees the value exposure produced instead of the one before it. Recovery keeps
`L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the
colour comes back as `color * (luma_new / luma)`: a blown white stays white
(255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside
it do not move.

AUTO is the histogram the LIGHT tab already draws: weighted mean luminance
(guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to
the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the
83.3 predicted, idempotent on a second press. A stock's own bias rides the same
pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so
-1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177).

ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the
two copies have to move together; recipes saved before this commit (EXPOSURE 2,
HIGHLIGHT +-1) render under the new stop semantics.

Verified on the rebuilt container (BASE=http://localhost:8090):
- web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio
  2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO
  EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console
  errors)
- sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1
  returning 0.4177, bias 0.0382)
- regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0,
  heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch
  33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0,
  save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a
  size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and
  progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing.
- npx tsc --noEmit clean.
2026-09-25 17:44:28 +07:00
3dtours c70edce8c1 web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and
stay on through the quarter turns and STRAIGHTEN, which makes them compose
with every rotation the strip already offers. ROTATE's own RESET levels the
whole frame, mirrors included.

The flip itself lands last, in screen space, so a mirrored photo is what the
eye sees rather than what the sensor saw; the pixels are copied axis-aligned,
so there is nothing to resample. Session state carries the two flags, so a
reopened photo comes back mirrored.

Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its
own (latitude/longitude ride in as NaN), instead of the whole stamp and its
box being skipped for want of coordinates.
2026-09-25 08:46:27 +07:00
3dtours 08a4570b2d web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one
The FX row had grown into a flat list where a brush and a filter and a
shape sat side by side, and it lied about what the tools are: HEAL and
MOSAIC only paint, LINEAR and RADIAL only make a mask.  The row now
carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and
each one opens its own strip holding the tools that belong to it —
turning amber when it has a spot or a mask to show for itself, so the
state still reads from the row without opening anything.

The two brushes moved into the FIX strip with the header FIX above
them, the two shapes into the GRADIENT MASK strip under the header
GRADIENT MASK, and each CLEAR moved in with the tool it clears instead
of sitting at the end of the row.  Opening FIX still arms the brush the
same way — the strip only changes where the chip lives, not what
clicking it does.

The mask column (shine, bearing, feather, and the rest) still hangs off
the shape you pick, so it now stands right after the options column:
the strip that brought it out comes first, then the column it belongs
to.  Nothing else in the column order moved.

ponytail: the two strips ride the existing openGroup and toggleGroup, so
a strip key is just a widened GroupKey rather than new state to keep in
sync; the option-strip body itself stayed where it was, keeping the
diff to the chips that moved.

Verified:
- npx tsc --noEmit clean.
- Frontend probes against the built production bundle on :8090 and the
  dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe
  23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch
  33/0, and landing, pro-gate, award-column, otp-code, tone-curve,
  hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0.
- Backend npm test 180/0.
- panel-test keeps exactly its four pre-existing failures (rail labels,
  WB swatch); they reproduce on the commit before this one.
2026-09-24 12:20:33 +07:00
3dtours 1f6c97be62 web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the
pin of a linear mask sits on the middle of its own line, so the press a hand aims
at "that line" was the press that moved the shape. What it moved by was worse: a
ramp is stored as the two points it falls between, and 'move' measured the hand
against the shape's first point, so the first move of a drag put that end under
the pointer and threw the rest of the ramp sideways by half its length — the
shape landed somewhere off to the side of the hand instead of under it. The move
now takes its delta from the pin the press landed on, which is what the press
wanted, and which for an ellipse was already the centre it moved by.

The drawn outline is the shape's own handle. A press on the line a ramp falls
across — any of the three, anywhere along it — or on an ellipse's rim, turns the
shape; the nodes that resize it sit on that same outline and are drawn over it,
so a press on one of those is still a resize. That is the Lightroom gesture: the
edge is what you drag to aim a gradient, and the ends and the axes are what you
drag to lay it out again. Until now a press on the edge was a press on the layer,
which read it as the start of the next shape and laid a second mask down while
the first was being turned. The band is sixteen screen pixels wide and does not
scale with the zoom, so a finger finds it at any size of photo.

A mask is chosen by pressing it on the picture, so the keys the hand is on are
DELETE, which is what the chip beside the photo already says. And the column the
chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the
mask tool: another chip puts that tool down and takes the column with it, because
a photo still armed to draw shapes is not what a hand reaching for a knob is
asking for. The shape stays chosen and stays live in the render; the mask chip
brings its column straight back.

ponytail: Backspace is read as DELETE as well, since that is the key the label
sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still
only be chosen while a mask tool is armed — the shapes answer the pointer through
the layer that only exists then — so the column coming back with the mask chip is
also the way back to a shape drawn a moment ago.

Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp
lands with its pin on its middle, dragging its own edge turns it (ends at 0.200
and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns
that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where
the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask
columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the
photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle
23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/
hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL
PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail
exactly as they do on the build before this one, on their own tabs.
2026-09-24 11:18:59 +07:00
3dtours b7ff298789 web: give a mask its knobs as rulers and let the ramp be turned
A mask's knobs were a card of small sliders side by side, three abreast under the
colour they were moving, which is the shape the mixer needs because its whole
point is reading three bands at once. A mask has nothing to read against: its
knobs are a list, and the strip beside the photo has always had the shape for a
list — the ruler, one parameter to a row, with its own name, its own value and
the width to aim with. So the card goes and the rows come: EXPOSURE, CONTRAST,
SATURATION, and FEATHER below them for the shape that fades over one, each the
same ruler the FX panel opens, stacked in the mask's own column next to the chip
that says which shape is chosen.

Only the ramp could be moved and not aimed. An ellipse is stored as an angle, so
turning it is writing a new one; a line is stored as the two points it falls
between, and turning one is moving both of them about their own middle by the
same turn — which is the point of doing it that way: the length survives, the
middle survives, and only the direction the gradient falls in changes. The turn
handle a chosen ramp now wears hangs clear of its middle along the ramp's own
normal, so it never sits on the pin the shape is dragged by, and the angle the
hand asks for is measured in the photo's own pixels, so a quarter turn of the
hand is a quarter turn of the ramp however the photo is shaped or zoomed. Both
handles for both kinds now come out of one list and one map, which is how a ramp
and an ellipse ended up wearing the same class, the round one that says "this
turns me".

ponytail: the ruler row takes no data-key of its own — the parameter's key is
already on the chip that opened it, and the panel's fourth column opens a ruler
under the same name a strip chip answers to, so a second element carrying it
would make an existing selector ambiguous. The mask's rows are found by the label
they print; if a probe ever needs to hook a row directly, the key belongs on the
row and the panel's ruler needs its own name first. Highlights/Shadows and
per-mask invert and range are still out, as before.

Verified: tsc clean; mask-probe 42/0 on the dev server and again on 8090 — the
three rows share an edge and stack, a linear mask carries no feather row, a
radial one carries it fourth, and dragging the ramp's turn handle stands the
gradient up the photo: 61 at the top and 244 at the bottom where it was 61 -> 244
across — brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/
award-column/otp-code/tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:54:56 +07:00
3dtours b568fa3fdc web: let FX carry Lightroom's two gradient masks, and grade inside them
FX had two tools that change the photo where it is — HEAL repairs a speck,
MOSAIC hides a patch — and every knob that graded the frame graded all of it.
The scratchpad's gradient_mask.md asks for the two local adjustments the phone's
own editor has and Lightroom made familiar: a linear gradient and a radial one.
This is that spec, written for the renderer this app actually has.

A mask is a SHAPE rather than a value, so it is dragged rather than turned: the
LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at
the press, one at the release, the spec's own convention, which is what makes the
same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre
is the press, whose semi-axes are the drag's own distance and whose axis lies
along the direction the hand went, so the circle a drag describes is the circle
the mask starts life as. Both shapes keep a pin (the whole shape travels by it)
and, while chosen, the handles that move the ends or the axes and the one that
turns the ellipse; what is drawn is the shape the render will read, so the ramp
and the rim are visible before a knob is moved.

Inside the shape, three knobs grade in the spec's own order and its own maths:
exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle,
saturation as a mix away from the pixel's own REC-709 luma — the mixer's
-10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades
over, which is the fraction of its own axis the alpha holds full before it dies
at the rim. Several masks run in the order they were drawn, each reading what the
one before it left, which is what a stack of local adjustments is.

The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL
runtime effects), so it is ported stage for stage: one pass, after the frame-wide
grade and the vignette and before HEAL, because a local adjustment is part of the
look and not a repair — the pixels a repair borrows are then meant to carry the
mask's light already. Preview and export both come through renderPhoto, so the
file carries the masks the stage is showing by construction, and the shape and
the knobs ride in the recipe's own JSON, which is what makes them survive a save.

The chips sit with HEAL and MOSAIC because all four take the pointer on the
photo, and they are exclusive with every other armed tool, the eyedropper
included — while a mask tool is armed the layer takes the photo, so a drag means
"draw the next shape" and a press on a pin means "take hold of this one", which
is why the shapes already laid are answered through their pin and handles alone.
A knob drag on a mask is one undo step, a shape drag is one more, a press that
only chose a mask records nothing at all, and RESET is the way back with the
whole frame as it was imported.

ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated
with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is
Lightroom's per-mask invert and colour/tone range. The three knobs are what
"gradient mask" means until a photo shows a sky that has to be rescued apart from
the grass under it; the md itself calls it an upgrade, not the feature.

Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two
chips, both shapes drawn and moved and turned, the ramp read off the pixels —
61 -> 244 at the release and 61 at the press — the feather read off the rings,
DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0,
heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/
tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:42:58 +07:00
3dtours abfc6595e7 web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.

HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.

The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.

Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:

  - The × first sat on the spot's corner at the brush's own radius. The default
    brush is 6px wide and the badge is 18px across, so the badge covered the
    circle: the next press on the repair — a user putting the spot down again —
    deleted it. Measured: after undo/redo, a press at the spot's centre left one
    spot instead of two. The badge now sits on the top-right diagonal at the
    circle's edge plus a badge's radius, so it can never take a press meant for
    the spot.
  - The hit test first took the hole before the patch. On the default brush the
    patch the search borrows sits about 8px from the hole it fills — inside any
    reach a pointer can use — so dragging the patch's own centre grabbed the hole
    and the patch never moved (measured: dragging the patch from (0.331, 0.300)
    to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
    instead). The nearest circle now wins, and the hole wins a tie with its own
    patch.
  - The reach was first the circle plus 8px, for a brush turned down to a few
    pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
    12.8px apart were meant to lay eight repairs and four of them landed, because
    four were within 8px of a patch circle and grabbed the spot instead. The
    reach is now the circle plus 4px: the same probe is 49/0 and the patch's
    centre is still 0px from the pointer that grabs it.

MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.

Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):

  brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
    brush's width through all 15 spots of a drag, its length inside 2px of the
    polyline the spots stand for, every joined spot's border transparent and a
    lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
    was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
    and forth in one step each; dragging the patch onto the neighbouring speck
    puts the speck back into the repair (level 5 on a field of 151) and UNDO
    returns it; a press chooses a spot and shows the ×, that press records no
    step (the next UNDO still takes the last repair back), the × deletes that
    spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
    are one band and every cell in the run joins it, painting across mosaic
    already laid down paints more cells, and no mosaic spot is ever offered an ×.
  Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
    heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
    heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
    mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL.
  Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
2026-09-24 07:21:01 +07:00
3dtours f1385d8a08 web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.

What a mosaic spot does is destroy what it covers rather than replace it. The
frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the
probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the
colour found at that cell's own middle, read with img.eval so the block is the
snapshot's bilinear tap and not a neighbour's cell. What is under the circle is
still a picture of that place, at a resolution nothing can be read out of. A blur
was never in the running: it leaves the SHAPE of what it hides — a face under a
blur is still a face, a plate still a plate — and the arrangement is exactly what
the user is asking to keep to themselves. Cells coarse enough to lose the
arrangement are what "do not show this to anyone" needs, and the blockiness is
the price of it.

The cells are one grid over the whole frame, not one grid per spot: a pixel's
cell comes from its own position, and every block reads the snapshot rather than
the output, so two overlapping spots never pixelate a pixelation and a run lays
one band with no seam where its circles cross. The rim is hard for the same
reason in reverse — a feather would mix the cells back into the sharp photo along
the edge, which is a half-hidden thing leaking the arrangement it exists to hide.
A mosaic spot borrows nothing, so the layer draws no donor circle beside the
cursor: the second circle appears only when a spot has a source ('sx' in it),
which is the one place the two tools' DOM parts company. Each tool keeps its own
brush size, and each CLEAR chip clears only its own list, because the size a
dust speck is healed at is never the size a face is hidden at.

The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions
HEAL stores, and readMosaic guards them the same way — and the renderer builds
one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the
pass sitting right after the heal pass so a repair made on the same photo ends up
underneath the cells that hide the rest of it. The backend needed nothing: a
recipe is spread through as it stands, so a saved photo keeps its mosaic and a
shared one opens with it.

Verified:
  mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27
    passed, 0 failed: the cell rides in the frame block in the render's own
    pixels and is a fraction of the WIDTH, so it is square on any shape; 4912
    cells inside a spot each carry one colour, and 164/164 of them carry the
    colour at their own middle; the 2px white dot on the dark square reads
    250 -> 20; nothing outside the circle changed (0 stray pixels) while the
    cells reach the rim (852 pixels at the edge); a spot wider than the frame
    still runs; overlapping spots share one grid over 6335 pixels with 0
    differing between them (no cascade); readMosaic refuses a zero radius, an
    off-photo spot, junk and a missing list, and keeps a forty-spot list whole.
  mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL,
    no page errors: FX offers a MOSAIC chip that arms the same brush layer and
    says which tool it is painting for; the wheel sizes each tool on its own
    (8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot
    with no borrowed patch beside it; the pixels of the cell are one colour (0
    levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing
    outside the circle changed (0 pixels, worst 0) and the cells are not the
    photo that was there (221/509 pixels changed); UNDO gives the photo back
    exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide
    as the brush, standing for 5 points of travel and laying 5 spots that leave
    0 pixels outside them changed, with the step within a cell 3.43 levels
    against 21.25 between cells (635 + 157 pairs) — the cells are flat and their
    borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC
    hand the pointer over and back with each tool's spots intact; CLEAR hands the
    photo back pixel for pixel and leaves no chip behind.
  The probe's own reading is deliberately a shape, not a colour: the app's
    preview is the engine's render at preview scale with a JPEG on top (and its
    auto dynamic range), so a cell's colour read back from the base would be two
    encodings apart. The exact cell colour is the Skia lab's claim, where no
    encoder sits between the shader and the reading.
  heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15,
    heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush
    HEAL paints with is the one MOSAIC now paints with.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
2026-09-23 22:03:33 +07:00
3dtours 88cff5ca87 web: draw the dust brush into strokes, size it by the wheel, uncap the list
A speck of dust is small and there is never only one, so the brush had three
things wrong with it: the list stopped at sixteen and the seventeenth repair
pushed the first one out of the shader, the size was a choice of three buttons,
and one gesture laid exactly one spot — a scratch across a hundred pixels was a
dozen clicks.

The cap is gone rather than raised. SkSL indexes a uniform array by a constant
only (the trick the tone curve's mixer already uses), so HEAL_SKSL carried
sixteen unrolled blocks and the list was trimmed to fit them. The shader is now
built for the count it is handed — healSkSL(n), with healUniforms returning
(n * 2 + 1) * 4 floats, the same declaration order for any n — and the renderer
caches one compiled effect per count (exportEngine's healEffectFor). readHeal
no longer slices and the app appends whatever a gesture reported. No repair is
dropped to make room for a later one: the speck healed first is the speck that
stays healed.

The wheel is the size now. wheelHealR multiplies the radius by
exp(-deltaY * 0.0015), so a trackpad's small deltas and a mouse's 100px notch
are the same gesture at two speeds, bounded at 0.3% and 25% of the photo's
width — below the first a spot is finer than the pixels it is drawn on, past
the second it would borrow its patch from off the frame. S, M and L are gone,
and because there is nothing left to point at, the HEAL chip's own readout is
the size: the number the brush is set to is the number on the chip.

The pointer paints. Down starts a stroke, move adds a point every HEAL_SPACING
(0.6) radii of travel, and up turns the whole run into spots in one report — so
a stroke is one undo step however long it was, and the trail drawn while the
pointer is down is a preview of that run, in the accent, cleared the moment the
spots land. The part of a stroke that leaves the photo lays nothing down, and
the pointer is captured so a stroke that runs past the edge ends where the
pointer does rather than leaving a spot hanging at the frame.

The wheel had to be stopped, not merely claimed. The heal layer is a child of
the stage, and the stage has its own wheel listener that zooms the photo, so a
wheel over the brush grew the brush AND zoomed the view: the probe caught it as
a cursor circle 15% wider than the readout it was drawing. The layer's listener
(native, because React's own onWheel is passive) now stops propagation — while
the brush is up, the wheel sizes the brush and nothing else.

One number moved that none of the three asks mentioned, and it is what the
probe's remaining failure was about. The feather band was 45% of the radius,
and that band is the only place the pixels being repaired are mixed back into
the patch, so with the default 6px brush it left a ring of the speck's own edge
one pixel inside the circle (115 in a field of 150) — which the preview's own
JPEG then rang around, reading 177 a pixel off the centre of a repair that
should be flat. Narrowing the band to the outer 15% copies the patch over
everything inside 0.85r: sub-pixel at the default brush, still a soft edge at a
big one, and that pixel now reads 151.

Verified:
  heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 27 PASS,
    0 FAIL: the shader for a count compiles through RuntimeEffect.Make and its
    uniform block is (n * 2 + 1) * 4 floats (n=1 -> 12, n=40 -> 324); a single
    spot copies the donor exactly and leaves the rest of the frame untouched,
    pixel for pixel; forty spots are carried whole with the first and the last
    both drawn; three spots in one run each borrow their own patch; readHeal
    clamps and drops zero-radius spots and no longer trims the list;
    wheelHealR grows, shrinks and clamps at both ends (0.3% and 25%); the
    search finds a patch and still refuses a brush that covers the frame.
  heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
    48 PASS, 0 FAIL, no page errors: the circle under the cursor is exactly
    the size the chip reads, before and after a wheel, and the wheel grows,
    shrinks, stops at 25% and at 0.3% and returns to where it started; there
    are no size chips left; one click is one spot, the speck reads 151 at its
    centre and its four neighbours are field too; a drag shows at least three
    trail circles, lays exactly that many spots, clears the trail on release,
    and UNDO takes the whole stroke back at once while leaving the repair made
    before it alone; REDO repaints it; a bigger brush takes a ten-pixel blob;
    twenty-five spots are carried with the first healed speck still first and
    still healed; every speck is gone after a reload; CLEAR brings them all
    back and lays no spot of its own; the chip goes amber only while spots are
    on the photo.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: a stroke's repairs land when the pointer comes up, not under it as
they are painted — a live repair would mean recompiling the pass and re-cutting
the preview per point mid-gesture; the trail is what the pointer has drawn, and
it is drawn in the accent so the difference reads. The list is uncapped, so a
runaway stroke pays one shader compile per distinct count it reaches, cached
for the rest of the session: a ceiling would have to come back with the trim.
The search still has no colour-matching term, so the donor is chosen by
resemblance alone, and the spots still live in the rendered photo's
coordinates, so re-cropping or re-rotating after healing slides them.
2026-09-23 21:08:09 +07:00
3dtours 3ee0137d0d web: repair dust with a brush that borrows a patch of the same photo
A sensor speck is not a filter: it is a small lie in one place, and every
slider in the panel is global, so there was no way to say "here, and only
here". The FX row now has a HEAL chip. Arming it turns the pointer into a
circle you can size S, M or L, and every click on a speck covers it with a
patch of skin borrowed from a few radii away — the repaired sites persist in
the recipe like any other edit, and UNDO takes them back one click at a time.

The spot is stored in the rendered photo's fractions, not in the preview's
pixels: x, y and a radius that is a fraction of the photo's WIDTH, so the
circle stays round on a tall or a square frame and the same recipe heals at
preview resolution and at export resolution without a second code path.
`readHeal` is the only door in, and it validates, clamps and drops the spots
with no radius before anything downstream sees them.

The source patch is searched for, not asked for. `findHealSource` walks eight
directions at three distances — 2.6r, 4.2r, 6.5r — and each candidate's mirror
through the spot as well, scores every one with a nine-tap comparison of the
neighbourhood, and hands back the first that actually resembles the ring around
the speck. When nothing fits — a brush wide enough to swallow the whole frame —
it returns null and the click is refused rather than smearing a wrong colour
over it. There is no colour-matching model here and no second draggable source
circle: Lightroom lets you place the donor, this finds one.

The pass runs last on the photo's own pixels. It is inserted after the grade,
the curve and the grain and before the frame, so the patch it pastes is copied
from pixels that have already been graded and grained — it matches by
construction, with no second copy of the pipeline to keep in step — and the
frame, the card and the watermarks are drawn over the result, so healing can
never erase the furniture of the render. The brush is a feathered circle at
0.55r, which is what keeps a repair from reading as a sticker.

SkSL indexes a uniform array by a constant only, so the shader is the block
unrolled HEAL_MAX = 16 times, the same trick the tone curve's mixer already
uses. Sixteen is the ceiling and the oldest spot falls out when the
seventeenth arrives. CLEAR drops the whole field — turning the chip off keeps
the repairs, which is the distinction between disarming the brush and undoing
the work.

Verified:
  heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 15 PASS,
    0 FAIL: HEAL_SKSL compiles through RuntimeEffect.Make and
    makeShaderWithChildren; the uniform block is 132 floats in declaration
    order (16 spots + 16 sources + size, w/h/feather); a dust speck pinned on
    the canvas comes back as the borrowed patch while the rest of the frame is
    untouched, pixel for pixel; readHeal clamps, drops zero-radius spots and
    caps the list at 16; the search finds a valid donor and returns null for a
    brush that covers everything.
  heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
    29 PASS, 0 FAIL, no page errors: the cursor circle is 2 x 0.012 x width and
    centred on the pointer, L is visibly bigger, S and L are exclusive; one
    click is one spot; a speck at 151 reads 154 at its centre after the heal
    and the photo's other specks and empty skin are unchanged; the spot and its
    borrowed source are both drawn; the chip goes amber; CLEAR appears and
    restores everything; UNDO (the TopBar button) brings the dust back and REDO
    heals it again; three specks and one L-sized blob all go; the repairs
    survive a reload.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: spots live in the rendered photo's coordinates, so re-cropping or
re-rotating after healing slides them — re-heal or CLEAR when that matters; a
coordinate space pinned to the sensor would need the crop and rotation to carry
the spots through. No live brush-size gesture and no colour-matching term: the
donor is chosen by resemblance alone, add a colour term if skin tones ever
mismatch. The list is capped at 16 with oldest-out rather than refusing the
seventeenth click.
2026-09-23 20:51:41 +07:00
3dtours b24bd78ddd web: draw the picture's own distribution behind the tone curve, and let the card be dragged
Setting a point on the curve was guesswork: the graph showed the mapping but
nothing about the picture it was mapping, so you placed a point where the tones
"probably" were. The graph now draws the picture's own histogram behind the
grid, and the panel can be dragged off the photo it is editing — the two halves
of the same complaint, that the card was describing a picture you could not look
at while you used it.

The histogram is not a second measurement. `ToneCurvePanel` takes the same
`previewUrl` the stage already renders and reads it through `readHistogram`, the
function the HISTOGRAM overlay beside it uses: one 320px sample, luminance bins
on the RGB tab and the channel's own bins on an R, G or B tab, so the shape
follows the tab the way the line does. The bins become one filled path in the
graph's own square, scaled to its own tallest bucket and closed along the floor,
and it is the SVG's first child — grid and curve draw over it, so the graph
reads as curve on distribution rather than two lines crossing. Nothing new is
rendered, sampled or cached: the panel reads the frame that is already there.

It is read from the render, which is post-curve, so the band shifts as the curve
moves. That is Lightroom's behaviour, not an accident, and it is the honest one:
the point of the picture is what you are looking at. A percentile or log scale
would show a shadow-heavy frame better than a linear max does, and the overlay
beside it does not have one either, so the two agree.

The drag is the panel's own head. `pos` is the card's position in the layer's
coordinates (null until first moved), and the first position is materialised
from `offsetLeft/offsetTop`, which is exactly the CSS bottom-left the card sits
at before anyone touches it — so the default layout costs no code and the card
carries no second positioning system. It is bounded by the STAGE, not the photo:
the card may sit off the photo, that is the point of moving it, but never off
the canvas the stage clips at 8px. Window `resize` and a `ResizeObserver` on the
stage re-clamp an existing position, because the stage can shrink under a parked
card and `overflow: hidden` would hide it with no way to reach it.

One real bug, found by the probe rather than by reading: with the head as the
handle, `setPointerCapture` retargets the click that follows, so the close
button in that same head never fired — pressing it started a drag and swallowed
the click. `panStart` now returns early when the pointer went down on a button.
The pre-existing `Histogram` overlay carries the same latent pattern; it has no
interactive children in its chrome, so it was left alone.

Verified:
  tone-curve-probe.cjs (extended, scratchpad) — the rebuilt app at
    http://localhost:8090, 42 PASS, 0 FAIL, no page errors. New checks: the
    graph draws the picture's own distribution and it is the graph's first child
    (`curve-hist`); the drawn band matches a histogram binned independently in
    the page (256 buckets, worst deviation 0.00px); the distribution piles where
    the curve put the tones (peak 128/255 after the black lift, against 9-246
    before it); the card is dragged by its head (729,280 -> 689,190, the exact
    delta); the drag bends no curve and drops no point; the card cannot be
    dragged out of the stage (clamped to stage bounds); it is pulled back in
    when the viewport shrinks to 900x640 (card 636,239 240x291 inside stage
    269,109 615x429); the close button still takes the graph off the photo.
  tone-curve-math.cjs — unchanged, 11/11.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10;
    backend npm test 180 passed, 0 failed.
  web tsc --noEmit clean.

ponytail: the histogram is read from the render, so it is post-curve and the
band moves with the curve; read it pre-curve by exposing pass 3e's input if the
feedback ever misleads. The card's position is component state, so it resets to
bottom-left when the panel closes — persisting it across a close is a key on the
recipe, add it when someone asks for the card to stay put. Percentile and log
scaling are not implemented: linear max, the same as the overlay beside it.
2026-09-23 20:18:46 +07:00
3dtours 56d4b9df67 web: give LIGHT a tone curve, edited on the graph drawn over the photo
The LIGHT rail was sliders only, so the one control that describes a tone
mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip;
pressing it puts a curve graph on the photo itself — four channels, RGB plus R,
G and B, exactly the shape Lightroom's point curve has — and dragging a point
bends the picture under it while you drag.

A recipe carries the curve as `adjustments.toneCurve`, an optional map from
channel to point list, `Partial<Record<'rgb'|'r'|'g'|'b', [number, number][]>>`.
The field is optional and the API stores the recipe JSON opaquely, so every
recipe and session written before this commit loads unchanged and simply has no
curve; nothing on the API or in the database moved.

The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into
a 256-entry table per channel and the shader looks the table up in a 256x1
texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup
has to come from a texture, and a table is the cheaper shape anyway — one
`lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between
points is a monotone cubic (Fritsch–Carlson) rather than a natural spline,
because a spline overshoots between two close points and that overshoot is the
classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic
through the points bends through them and never turns back on itself. The table
is built per channel and then composited through the master, the order the graph
draws it in, so an R point in the shadows survives an RGB contrast S and both
land where the lines say.

Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last —
after the stock, the matrix, the mixer and the seasonal grade, so a point placed
on the graph is the last word on that pixel. Preview and export both call
`renderPhoto`, so the two agree by construction rather than by two matching
implementations. The pass wraps whatever shader the pipeline had built
(`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts
towards `graded` for the same reason the tone shader does: the curve reads the
matrix's output, so when there is a matrix it has to be in the pixels the curve
samples. Turning the curve on costs one extra render pass and nothing else; off,
`curveIsActive` is false and the pass is not built at all.

That pass is also where this spent its time being invisible. The curve data
reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not
exist in the shim, so the call threw a TypeError inside the render, the preview
effect's catch swallowed it into `setError('err.generic')`, and the chip, the
graph and the recipe all looked healthy while the canvas kept the old frame. The
fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`)
and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists
on an explicit `colorSpace` where RN Skia's does not — everything this pipeline
builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape.
Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call
throws, the translated one returns a 256x1 image.

`ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no
zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost
behind a channel line so a channel edit is still visible against the other
three. Ends are pinned to x 0 and 1, a point cannot be dragged past its
neighbours (2% of the axis is the closest they may sit) and cannot be dragged
out of the square, so the graph can never describe a curve the renderer cannot
apply. One pointerdown grabs the nearest point inside 11px or adds one on the
line under the cursor and keeps dragging, so a click is a point and a drag is a
bend. Deleting a point is the graph's own double-click, not the circle's, and it
has to be: grabbing a point takes pointer capture, so the click that follows is
delivered to the SVG rather than the circle under the cursor.

RESET clears the whole graph, all four channels, and hands back an empty object
that `App.tsx` maps to `undefined` so the recipe drops the field rather than
keeping a `toneCurve: {}` — the field's presence is what "this picture has a
curve" means, and an empty map that means the same as no map is a state two
pieces of code would eventually disagree about. One undo step per visit to the
graph, the rule the ruler and the watermark box already ride: a drag is one
edit, not one per pointer move.

No new i18n keys: the chip and the panel labels are literal uppercase, the same
as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT
control like the rest of the tab.

Verified:
  tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to
    http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL,
    no page errors. The ramp is a ramp before (9..246), a flat curve is two
    points and no pass, the graph is drawn on the photo (graph 729,280 240x291
    against photo 719,325 256x256), every stop of the ramp lands on the drawn
    curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252,
    a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the
    R tab takes the graph over while the composite stays visible behind it and R
    drives red at black to 255 with G and B still on the composite (133,132
    against 132), the recipe carries toneCurve, it survives a reload (254 -> 254,
    chip still amber), a click adds a point and a double-click removes it again,
    RESET returns the ramp to its start (worst 0) and drops the field, and close
    takes the graph off the photo.
  tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own
    arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the
    graph says, a steeper segment never turns back on itself, a channel curve
    runs before the composite, a click lands on the line, two points cannot
    share a spot, an end cannot leave the axis, and the two ends survive a
    delete where a middle point does not.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10.
  web tsc --noEmit clean.

ponytail: the graph is anchored over the photo, not draggable — it sits at the
photo's own layout box the way the crop frame and the straighten ruler do, and
the one time it would want to move it is when the photo under it is small, at
which point a token drag offset is cheaper than the second positioning system.
Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here:
the point curve is the one the request asked for, and a parametric curve is a
second graph, not a second line on this one — add it as another channel row when
someone asks. The LUT is a texture rather than Skia's table colour filter
because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and
hit radius are literals, since exactly one graph exists.
2026-09-23 20:07:51 +07:00
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00
3dtours c2a4740a3f web: let the white balance carry its tint, and take its ratio in the light
TEMP was a kelvin and nothing else, and the seven presets were seven numbers
the two platforms disagreed about: AUTO 5500, DAYLIGHT 5600, DAYLIGHT -3R
5800, CLOUDY 6500, SHADE 7500 here and 8000 in both recipe-creation forms,
TUNGSTEN 3200, FLUOR 4000. A chip tapped in the recipe panel and the same
chip tapped on the photo printed different frames.

The presets are now the phone's own WB_PAIRS — kelvin AND tint — in all three
places, so every chip lands the same pair on either platform:

  AUTO 5500/0   DAYLIGHT 5500/0   DAYLIGHT -3R 5500/-3   CLOUDY 6500/1
  SHADE 7500/2  TUNGSTEN 3200/0   FLUOR 4000/3

SHADE read 8000K in RecipeCreatePanel and RecipeCreateModal; it is the WB
tab's 7500K/+2 now, so a recipe created in a form prints what the same chip
prints on the photo.

AUTO and DAYLIGHT stand for one pair, so the pair alone cannot say which of
the two is lit. TEMP is therefore keyed by preset and not by value: `wbChoice`
remembers the chip last tapped (the phone's own wbChoice), and `wbValue()`
answers it only while the engine pair still matches, else the first preset
that pair maps to, else the bare kelvin. `wbLabel()` names that pick on the
chip, which now always carries one: TEMP AUTO at the neutral pair, TEMP SHADE
on a preset, TEMP 6300K on the app's own default recipe where a hand-dragged
ruler landed. Measured on the deployed build (`wb-preset-test.cjs`, 33/0):
AUTO and DAYLIGHT print the same frame to the level, DAYLIGHT -3R moves the
green away from DAYLIGHT at the same 5500K, the ruler warms monotonically
across TUNGSTEN 0.525 / FLUOR 0.730 / AUTO 1.000 / CLOUDY 1.141 / SHADE 1.295,
a hand-drag to 10000K names the chip `10000K` and warms the picture R x1.183
B x0.793, SHADE tapped after that drag restores the pair 7500/2 and prints the
same frame the first tap did (R 156.8 vs 156.8), and a temperature drag leaves
a preset's tint standing (FLUOR's +3).

kelvinToRGB itself was the other half. It took the ratio of the sRGB-ENCODED
blackbody colours and square-rooted it, which measured R x1.06 / B x0.89 from
5500K to 10000K — a shift a swatch shows and a sunset does not. White balance
is a gain on LIGHT, so the ratio is taken in linear light now
(`planckianLinear`) and tamed by a new `KELVIN_TAME = 0.5`: the same 10000K
moves the frame R x1.16 / B x0.76, and 2500K its mirror, which is what a
camera does with its WB set 4500K off the scene. One constant scales the whole
ruler and both platforms carry the same one. Measured through the app
(`_temp-probe.cjs`, gains against 5500K): 2500 R x0.569 B x1.640, 4000 R x0.866
B x1.197, 6500 R x1.057 B x0.940, 10000 R x1.140 B x0.759 — the readout is
compressed against the raw gain because the cast lands on encoded, clipping
pixels, which is the reason for the tame in the first place.

ponytail: no scene meter, so AUTO stays the neutral 5500K/0 pair and is a name
for it, not a measurement. Add one when the engine reads the frame.

ponytail: KELVIN_TAME scales the whole ruler both ways. Split it into a warm
and a cool constant only if the two ends are ever asked to move apart.

Verified: `wb-preset-test.cjs` 33/0 and `_temp-probe.cjs`, `sims-test.cjs`
31/0, `fx-mono-test.cjs` 15/0, `wm-font-test.cjs` 38/0 against the deployed
build; web `tsc --noEmit` clean, the phone's scoped check down to its two
pre-existing `skiaImage.ts` nulls.
2026-09-23 13:00:14 +07:00
3dtours 7a8c0aa2b5 web: print what the temperature ruler does, not the colour of the light
COLOR TEMP's swatch was Tanner Helland's blackbody fit, so it painted the
colour of the LIGHT the number names: 2500K came out orange #ff9f46 while the
engine cooled the frame on the same knob. Read off the render of a neutral
128-grey (mean of the painted preview, CCT via McCamy), the two ran opposite
ways at every stop:

  K      old swatch        swatch CCT   picture mean    picture CCT
  2500   #ff9f46           2384K        115,135,204     47691K  (blue)
  3200   #ffb87b           3096K        128,141,171     11246K
  4000   #ffcea6           3931K        135,141,156      8203K
  5500   #ffedde           5414K        144,139,143      6479K  (unity)
  6500   #fffefa           6322K        148,138,139      6029K
  7500   #e6ebff           7730K        149,138,132      5555K
 10000   #cadaff          10024K        153,138,128      5180K  (warm)

So the number is a Kelvin of the scene's light — which is exactly why the
engine warms the picture as K rises — and the swatch was the one thing on the
ruler that disagreed with the ruler. It is now painted from kelvinToRGB
itself, the same gains the render puts on every pixel, on a mid grey, so the
band under the knob can never drift from the frame above it. kelvinToHex goes
away with it: one Kelvin->colour mapping in the app, not two.

Measured after: the swatch's R-B tracks the render's own cast at every stop
(-82 vs -89.8 at 2500K, +23 vs +24.0 at 10000K), 5500K is flat #808080 — the
engine's unity point — and 3200K sits cool / 7500K warm either side of it.

temp-swatch-test.cjs (scratchpad): 33 PASS / 0 FAIL.
2026-09-23 11:17:59 +07:00
3dtours d55b7b49ca web: give each watermark its own collapse, and a face to print in
The panel shared one column between the two marks, so GPS's colour, its two
switches and its hand-typed place stood open beside the custom mark's text,
colour and size whether or not either mark was on. The two are now collapses,
one per mark: the header chip is the section, and that mark's own controls sit
under it. What opens a section is the mark itself — GPS WATERMARK ON opens
GPS's controls, CUSTOM WATERMARK ON opens the custom mark's — so there is no
new state and no way for a panel to disagree with the pixels.

Both marks gain the FONT strip the phone has had (TEXT FONT for the custom
mark, FONT for GPS, whose stamp the phone also lets you set a face on). A
browser has no font service, so the list is exactly what the bundle carries:
the site's two self-hosted families, Inter and Fraunces (SIL OFL), their latin,
latin-ext and vietnamese woff2 subsets decompressed, pinned to weight 400 @
opsz 14 and merged into ONE TTF per family — drawText has no glyph fallback, so
a family mapped to only the latin subset would print a Vietnamese place name as
tofu. DEFAULT stays the bundled Cousine face, which is what every existing
session and every mark without a family prints.

Two engine bugs came out of it. CanvasKit 0.42's Font.getGlyphWidths passes its
output pointer where the wasm export wants the bounds pointer, so every glyph in
a run comes back holding one identical, rounded width — at 64px on the merged
Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and 0.242em,
and a box measured off it was 27% too wide ("Hà Nội 09/23" 510px against a true
403px). The shim now rebinds it with the pointers in the order
_getGlyphWidthBounds reads them, and the stage's boxes measure with linear
metrics, which land on hmtx exactly (403.28px against 403.28; hinted is 407).
And CanvasKit's TypefaceFontProvider.matchFamilyStyle answers null for every
style shape this binding accepts, so a name registered with it never resolved —
the shim keeps its own registry keyed by family name instead.

Measured: tsc clean; the engine harness on the merged faces 26/26, including the
registry's advances against hmtx (Inter 6.3013em, Fraunces 6.3475em); the
deployed app under Playwright 38/38 over the two collapses and both FONT strips
— each mark's controls appear only with its own mark on, the DEFAULT/INTER/
FRAUNCES box widths match hmtx, the baked ink fills the box, the top edge
re-hangs off the new ascent (Inter 0.96875em against Cousine's 0.8325em, 3.4px
at this size) with the left edge fixed, and UNDO round-trips. Opening a section
narrows the stage by 168px with no window resize (955px -> 787px), so the stage
now re-measures its drop boxes off a ResizeObserver on the frame and the
picture rather than on the next render.

Not ported: the phone's GPS watermark still prints in the bundled face only
(no emulator here to verify a phone-side font strip), and the FONT options are
not behind the PRO gate the way the phone gates non-default families.
2026-09-23 10:59:49 +07:00
3dtours cf0091b741 web: name a position that lands before the account is known
A photo restored from the session reaches the stage while /me is still in
flight, so the account reading that put it there saw `pro` as false, the place
lookup was skipped, and reloading a photo left the stamp with bare coordinates.

Naming now happens in one effect that watches the position itself: the first
moment it is on the stage unnamed and the account is PRO, it gets a name,
wherever it came from. A position typed in by hand earns its name too, and a
guest's photo is named the moment they sign in.

The two call sites that used to ask for the name — in locateMe and adoptPhoto —
are gone, since the effect covers both.
2026-09-23 09:33:26 +07:00
3dtours 3bfa82e7f2 web: stamp the photo's own day on the GPS mark
The GPS mark printed Date.now(), so a photo taken in 2019 carried the day it
was opened. It now prints the frame's EXIF date — DateTimeOriginal, falling
back on CreateDate then ModifyDate — wherever the position came from:

- readCapturedAt() reads the date off the file, and readGps() uses it for a
  position found in the same EXIF.
- adoptPhoto holds it in its own state, so a frame with a date but no position
  still stamps the date when the position is typed in by hand.
- The device's own position stamps it too. That path runs inside adoptPhoto,
  where the render still holds the previous photo's date, so locateMe takes the
  date as an argument rather than reading state — the panel's own button, which
  has no such date to hand, passes none and reads the state as before.

A file with no date at all still falls back on the visitor's clock: there is
nothing else to believe.
2026-09-23 09:22:19 +07:00
3dtours 28a688fd82 web: hand the upscaler only the pixels the export is asking for
The model's own factor is 4 and the export's target is some number of pixels,
and the two were never reconciled: a 2400x1800 photo exporting at 4K was run
through the model at 4x — 9600x7200 of invented detail — and then three
quarters of it were thrown away by the draw that lands the file on 3840. The
arithmetic was the whole wait. Measured on the wasm path, one export: 153.2s.

The photo is now resampled once to `targetLongest / 4` before the model reads
it, so the model still answers at its own 4x and the answer is the size the
export asked for. Same 2400x1800 to 4K: 42.7s, 80 tiles of model for 20. Half
the photo's pixels is the floor — below that the model is no longer enlarging
the picture, it is drawing a new one from memory — and the ceiling is the
photo's own size, so a gain past 4 behaves exactly as it did.

Nothing in the finished file gives the smaller input away: the 6px stripes come
back at full contrast (254.9 vs 254.8), the black-to-white step lands on the
same pixel (x=625 in both) and rises in 1px instead of 3.

The 32MB of runtime and model are also fetched, and one 16x16 tile pushed
through the graph, when the export menu opens rather than after a size is
picked: the visitor waits for the pixels, not for the download.

crop 1:1 2400x1800 to 4K: 155.8s -> 52.6s, crop 3:4: 153.0s -> 54.1s.
2026-09-23 08:27:00 +07:00