Commit Graph

208 Commits

Author SHA1 Message Date
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 166a677590 light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72
The ramp was a0..a4 with the pixel's base interpolated straight between them.
A segment meets its neighbour at an ANGLE, and the second derivative of a tone
curve is what a gradient reads as a band — so a knob left a line across the
mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift
inside 0.25 and the stretch from 0.25 up came back identical to the untouched
frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100
folded a seam into 0.747, between the highlights it pulled and the shadow it
left under them.

Measured on a 1024-step luma wedge through the exported pass, second
difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from
0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across
the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and
1.146 is a sign flip, not a bend.

So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on
0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel
(1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots
ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves
without a fold at its own top; level at u = 1, so a move lands on the identity
and on its neighbour without an angle; C1 everywhere between. The two bumps of
a half meet on 0.50 both on zero, which is the same fixed midpoint as before,
and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of
each on the heads beside them).

A sum of bumps can overshoot where two steep sides land on one stretch — past a
slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces,
and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were
not (unguarded min slope -0.0141). The guard reads each pair at its own
steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792,
TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up
together past it. A single knob never reaches it (a full BLACK is 0.77, a full
SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at
full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and
BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead
of folding. Guarded, the sweep over five levels of all four knobs and DR reads
a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005.

After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and
0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK
+100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at
0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all
the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and
spends it over the quarter instead of into a line. Every knob on zero is the
identity to the last bit — the pass also runs for the stock split tones and for
DR alone — and 0.50 is still the one value no knob moves.

Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic
beside the shader (with the negative control: the unguarded pair still folds,
the guard is what stops it). highlight-knee-check.mjs reads the kernel and the
two slope constants off the source, pins the four amplitudes and the guard, and
sweeps the travel of every knob as before. All ten checks that run without a
browser pass, build clean.

Skipped: the guard's ceiling is a constant, not a search for the widest travel
that still clears a band we cannot see. Add when a frame shows a band the
deflections in hand cannot explain.
2026-09-30 21:24:01 +07:00
3dtours 097e383b86 light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle
The base layer shipped last commit was nine POINT SAMPLES of the child, one
ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with
texture at the ring's own scale it is worse than one: the nine lumas differ,
the sample pattern beats against the texture, and the base field comes out
aliased. The gain the pixel then rides, o(base)/base, is a function of the
base with a kink at every knot — so each alias of the base becomes an alias
of the gain, and the reconstruction paints it straight back over the detail
it was supposed to leave standing. Reported on a waterfall: grey patches
loose on a mountainside, a smear across the face of the falls, and plateaus
in cloud and sky (the pass runs whole-frame, so its base is read in the
bright end too).

Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field
(9px high-pass, where a real base can hold none): 0.0627 for the ring,
0.0048 for a blur of the same radius. Thirteen times. The fix is not more
taps — a 7x7 at a third of the step is still point samples, only smaller —
it is to stop sampling: the caller blurs.

So the base is now a second CHILD of the tone pass, the frame blurred by
Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred
already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width
and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius,
so the neighbourhood is the one the radius always named and the cuts are
smooth instead of hard. The shader reads it ONCE per pixel and baseLuma
loses its loop and its `bx`. That is also the cheaper pass: nine child evals
walked the exposure/matrix chain nine times, one eval does not.

A caller with no frame to blur hands in the image it is already shading as
the base. The tap then lands exactly on t and the ramp is the global move
again — a mask's degenerate bx of zero, spelled as a child that IS the
source, which is what gradientMask.ts's own call (base == t) already meant.

Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and
nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered
for real, with the frame and the base held at two flat values a little
apart: the pixel has to land on the value the ramp over THAT base predicts
(171 for a 128 pixel over a 76 base), and a base equal to the pixel has to
be the identity. highlight-knee-check now pins the one tap, the absence of
`bx`, the blur, and the two-child wiring. 9/9 pass, build clean.

Skipped: no guided filter proper — the base is a plain blur, so a strong
edge is no longer held out of it the way the range weight held it (a dark
rock a blur's width from the water reads a lifted base and keeps its own
darkness). Add when a frame shows the halo; the doc asks for a plain blur
and this is one.
2026-09-30 19:03:55 +07:00
3dtours e61dccc784 light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a
global curve: every pixel at luma t lands on the same o whatever surrounds
it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed
that whole quarter to the half slope left over — measured on a real frame,
0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey
sheet the knob was reported for. A curve drawn through the pixel cannot see
local contrast; that is what the eye was reading.

So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma
around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x
Detail split — and the pixel then rides the neighbourhood's gain o/base,
keeping its own difference from it. Base moves, detail stays: the same lift
on the same pixels, with the texture inside the region left standing. Full
deflection keeps 0.78 of the band's spread now.

The base is the same maths in every caller: bx = 0 (a mask, which has no
neighbourhood) reads the pixel nine times and gets the old global move back,
and with every knob on zero the ramp at base IS base, so the ratio is 1 and
the pass is the identity however coarse the base is.

Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation
(0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either:
the multiply is a uniform gain on encoded values, which is the same stop
exposureMove already argues for.
2026-09-30 18:46:36 +07:00
3dtours 25b1312e0a light: AUTO and the shipped recipes ride the halved knobs too — AUTO's ceiling doubles with them so a blown frame still comes back the way it did, and each recipe's HIGHLIGHT/SHADOW doubles so its look stays on the knot it was tuned to 2026-09-30 18:10:51 +07:00
3dtours a56581c757 light: the HIGHLIGHT knob rides half its anchor too, so a lift stops drawing a cloud to paper and a pull stops flattening the quarter under it — the top quarter came back at 0.26x of its own contrast, 0.62x now 2026-09-30 17:59:57 +07:00
3dtours 36fb93658b light: the SHADOW knob rides half its anchor, so a lift stops drawing the band above it flat — a waterfall's spray came back at 0.10 of its own contrast, 0.55 now 2026-09-30 17:36:29 +07:00
3dtours 3e3045d912 library: the reading is copied into a folder of the reader's own, so a cleared profile gets it back without a frame being read twice 2026-09-30 16:41:11 +07:00
3dtours 325d5c0470 library: the walk position belongs to the origin, so the app beside the browser carries a reading on instead of listing the roll again 2026-09-30 16:26:42 +07:00
3dtours 851563ed73 light: the pixel rides o/t, not a held chroma — SHADOW and BLACK drained a dark red to 0.505 of its saturation, it is 0.742 now 2026-09-30 16:26:42 +07:00
3dtours 75ff444621 library: the screen reads the catalogue back when a batch lands, not on a 700ms timer, and a tile keeps the object URL it was given 2026-09-30 15:41:18 +07:00
3dtours c9e72595f7 library: the other window on the same origin draws the scan instead of running a second one
An installed app and a browser tab are one origin, one catalogue and one roll.
Without a word between them the second window opens, finds nothing in flight,
and reads the same frames again — two readers of one folder, two RAW decoders at
256MB apiece, for a progress the first window already has.

The window holding the reading now announces it on every frame and on a
heartbeat, and answers a window that has just opened and asks. A window that
does not hold the reading takes it as its own: the same progress line, the
folder marked as being read, the same STOP — which it relays rather than
redirects — and the same jump. What it does not do is read the disk.

A reading that is through leaves the catalogue behind, and a window watching it
has nothing left to be told, so the screen reads the catalogue back once more
when the session goes away: the frames that arrived with the last flush would
otherwise never be drawn in the window that was only watching.

Measured over one origin (two pages, 24 RAW frames): the second window read 25
frames before and 1 after (the open frame), the peak over an idle browser fell
from 1412MB to 1155MB against 944MB for a single reader.
2026-09-30 12:58:08 +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 92776d2cfc A panel head keeps its label when it folds: the title is a span, not a bare
text node beside the caret

On iOS the LIGHT column's heads come back from a fold with no label — white
rows, the caret still drawn. The one structural difference between the two
things in that button is that the caret is an element (`span.dev-caret`) and
the title is a bare text node, which a flex container renders as an anonymous
item of its own; the anonymous item is what stops being painted when the
column re-lays-out inside the `.chips` scroller the heads live in. The title
is wrapped in `span.dev-panel-label` so the label is a real box, the same
shape as the caret beside it. No CSS, no rule of its own: the span is a flex
item where the text node was an anonymous one, so the reading is unchanged.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-_Bzj6oMZ.js`, CSS unchanged at `index-CjhObVYI.css`).
Against the built page (393x852 @3x, touch) on `127.0.0.1:8090`, LIGHT tab,
tap PROFILE to open then collapse, with and without the `.chips` scroller at
its foot: the heads read `PROFILE▸ / WB▸ / TONE▸ / PRESENCE▸ / DETAIL &
EFFECTS▸`, each `c=rgb(20,24,29)` `fs=11px` `vis=visible clip=none` at 377x17,
no ancestor in the font-size chain at 0, and every chip beside them unchanged
(`c=rgb(20,24,29) bg=rgb(255,255,255) fs=12px`); `errors: []`. The iOS report
itself is not reproducible on this machine — WebKit 26.5 (MiniBrowser) and
Chromium are clean at 320-430px wide in both themes — so the wrap is the
shape of the fix the symptom points at, not a fault seen and closed here.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-30 06:15:18 +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 642fccd2b0 The trial notice crosses the bar instead of looping inside it
The last commit set the sentence moving, and on a wide window it moved the
wrong way: two copies rode the track and the track slid half its own width, so
the loop was seamless because the second copy arrived exactly where the first
had left. That is the right shape for a marquee and the wrong one for this
sentence. A seamless loop means the string never enters the screen and never
leaves it — the pair is already in flight at t=0 and the seam hides the moment
one copy hands over to the other. At 1440 with a 713px string both copies fit
in the window, so what the visitor saw was two identical sentences standing
side by side, walking, and neither of them had ever come from anywhere.

The ask is one sentence at a time, in from the right edge and out past the
left, and the same on a phone. So the track holds one copy now — the second
span and its `aria-hidden` are gone, and with them the reason the notice was
announced once instead of twice — and the keyframes run `translateX(100vw)` to
`translateX(-100%)`. The units differ because the two ends are measured in
different things: 100vw is the window, which is where the sentence starts, and
100% is the track, which is the sentence, and `-100%` is therefore exactly the
width of the string it has to clear. At rest the sentence sits wholly off the
right edge — left edge at 1440 on a 1440 window — and 30s later its right edge
is at 2.1, i.e. past the left edge, so an observer never sees two and never
sees half of one appear from nowhere.

30s rather than 40s, and this is the compromise worth naming: the trip is
100vw plus the sentence, and CSS cannot add a viewport unit to an element width
in a transform, so the duration is one number for both sizes. At 1440 the
sentence covers 2081px in 30s, at 390 it covers 1031px in the same 30s — the
phone walks it slower in pixels-per-second and the visitor on a phone is
waiting proportionally longer. Fitting both would need the width in a custom
property and a keyframe per breakpoint, which is machinery around a 12px line
of legal copy.

`padding-right: 72px` goes with the seam it was written for: that air was the
gap between two copies, and with one copy there is no between. The band keeps
its own 6px so the sentence is clipped at the band's edge and not at the
window's.

Under `prefers-reduced-motion` the animation was already off and the sentence
sat at the left edge of the band clipped in half. A static notice that is
half a sentence is not a notice, so the band is allowed to scroll there: the
track stops animating and `.lp-notice` gets `overflow-x: auto`, which is the
platform's own answer — the reader drags the line if they want its end, and
nothing moves on its own.

The band keeps its height, so the hardcoded offsets that pay for it — 148px of
scroll padding, 216px of hero top padding, 144px on mobile — are untouched.

Verified: npx tsc --noEmit clean, npm run build clean, the built CSS carries
`@keyframes lp-notice-roll{0%{transform:translate(100vw)}to{transform:translate(-100%)}}`
and `animation:lp-notice-roll 30s linear infinite`. In the running app through
a probe, sampled over one 30s cycle: one span in the band, no `aria-hidden`
span beside it, animation 30000ms linear, and the string's box tracked from
left 1440 / right 2081.3 at t=0, through 711.5/1352.9 at 10.5s, to -639.2/2.1
at 29.97s — entering exactly at the right edge and gone past the left. At
390x800 the same shape: 390/1031.3 at t=0 to -640.3/1 at 29.97s, so the phone
gets the whole crossing too. `overflow: 0` on the document at both widths,
i.e. a 100vw transform inside the clipped band adds no page-level scroll. The
band was viewed at both widths.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:46:24 +07:00
3dtours 8e330ee28b The trial notice walks the width of the bar instead of standing in it
cc186c4 put the news about the trial into the bar, one line, and asked nothing
more of it. On the landing page that line is a sentence, not a label: at 1440 it
fits, on a phone it wraps into a paragraph and the bar grows a shelf of text
above the hero. So the band is drawn as a track and the sentence is set in
motion — one line that is never wider than the window because it is never asked
to fit in it.

It is the same band cc186c4 wrote, moved to the last shelf of the bar, under
the menu line rather than beside the header. Two copies of the string ride the
track, the second hidden from screen readers so the news is announced once, and
the track slides exactly half its own width: the copy that was waiting arrives
where the one before it left, so the loop has no seam and the 72px of air lives
in each copy's right padding rather than between the two. `width: max-content`
on the track is what makes that half-exact — the track is the two copies and
nothing else, so -50% is one copy by construction and not by measurement.
40s, linear, infinite; the animation is dropped under
`prefers-reduced-motion: reduce`, where the sentence simply sits at the left
edge of the band, clipped the way it already is mid-scroll.

The colour is the theme's own ink, not the page's amber: the notice is the
product's voice about itself, so it wears the accent the rest of the landing
page wears and follows the visitor through all five themes. The bare accent is
too bright to read on the light theme's paper, though, and the worst pair is
itself in the theme set — the bare accent lands at 4.0:1 on the violet dark
page, which a 12px line may not wear. So the ink is `color-mix`ed 78% accent
toward the page's text colour: the hue that says "theme colour" survives, and
the value walks toward whichever extreme the page already uses, which is the
one direction that is always safe. Worst pair across the ten theme/page
combinations is 5.66:1.

The bar is fixed, so the shelf it gained is a shelf the hero has to pay for:
`scroll-padding-top` goes 116 to 148 (62px of nav + 43px of subnav + 32px of
notice, plus a little air) and the hero's top padding 184 to 216. Below 1160px
the subnav is gone and only the notice is owed: 144, up from 112. The numbers
are hardcoded rather than measured — the bar's height is a sum of three fixed
shelf heights, and a ResizeObserver watching a 32px band would be machinery
that reports what the stylesheet already says.

Verified: npx tsc --noEmit clean, npm run build clean. In the running app
through a probe: navBottom 139.08, subBottom 105.89, notice.top 105.89 then
height 32.19, so the band sits directly under the menu line; trackW 1426.625 is
exactly 2 x spanW 713.312, so -50% is one copy and the loop is seamless; the
transform was observed mid-flight (matrix -12.48 to -39.53 over 1.5s) with
overflow hidden and the animation named; h1Top 267.59 clears navBottom 139.08,
the same gap the hero had before. At 390x800: noticeTop 62, height 32.19,
subnav hidden, one line, no wrap, h1Top 195.59. The band was viewed in both
themes and at both widths. Contrast of the mixed ink against each page, all ten
theme/page pairs, measured from the rendered pixels: 6.79 / 9.89 / 5.66 / 10.22
/ 12.64 light and 10.98 / 7.69 / 12.72 / 6.76 / 5.67 dark.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:37:07 +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 899921ef8a A dark detail stops being copied beside itself: DEHAZE reads its prior off a copy of the frame
Raising DEHAZE drew bright copies of every dark detail in the photo, stacked
alongside it. The prior was the reason, and the prior was read wrongly.

The 5x5 patch of DEHAZE_SKSL was sampled inline, five taps out at 0.625% of the
frame's width each — one tap every 6.67 pixels of the 1067-pixel preview, an
average spacing over a 26-pixel patch that is meant to be the minimum over it. A
detail thinner than that spacing therefore sat between two taps on one row and
under a tap on the next, and the transmission swung between "this patch holds a
shadow, leave it alone" and "this patch is all haze, divide hard" with a
7-pixel period around every dark thing on the frame. A rising DEHAZE drew that
period: `t` is a per-pixel divisor, so the rows of the detail that were left
alone stayed put while the rows read as haze came up bright, and the 13.3-pixel
column spacing made the next copy and the copy after that. That is what was
stacking.

Measured on a synthetic frame of sloped haze with four one-pixel dark lines: the
old shader departs from the clean correction by +58 to +102 codes (8-bit) at
exactly +/-6.67 and +/-13.3 pixels around each line — the two spacings, in both
directions, which is the whole signature of the bug. That frame is otherwise
flat, so those deviations are the copies.

Three things had to be got right, and each is the smallest fix that removes one
of them:

- The patch is no longer sampled. The caller builds the dark channel as an image
  — a 64x64 copy of the frame, smallest channel over a cell-wide neighbourhood,
  then two box passes — and hands it to the pass as its second child, so the
  shader's own `dark.eval` IS the patch: one cell covers a whole neighbourhood
  rather than sampling it, and the bilinear upscale interpolates it back up with
  no period left in it. The box passes are the doc's soft matting in the one form
  free here — the map is smoothed, not the pixels. The copy is made with
  drawImageRect, rect to rect: a paint shader drawing a 64x64 rect reads only the
  source's 4x4 corner, and a one-pixel line in such a copy lands at 166, i.e.
  pure haze, because the cell covering it is mostly sky.

- What travels as that image is the dark channel and not the transmission. t is
  1 + 0.95 at the negative end of the knob, more than a channel can carry, so a
  copy of t would arrive here clipped to 1 and "put the scattered light back"
  would become a pass that returns its input. The dark channel is 0..1 by
  construction and the signed amount stays a uniform, where it costs no range —
  the knob keeps both of its directions. Swept on the real photo, DEHAZE -100
  moves 779,237 pixels brighter and 703,114 darker (worst 110 codes) while the
  same frame at 0 either side of it moves exactly none, and +100 moves the frame
  the other way at atmosphericLight [0.93155, 0.90980, 0.93084].

- The pass was reading a shader that does not exist yet. `effects()` is what it
  asks now, not the module variable: nothing above DEHAZE has asked for the
  effect, so on the first render the variable is still null and the knob stayed
  dead until some later render happened to fill it in.

A map this small only covers the frame if it is told to, and the matrix that does
it is the last thing that had to be right: CanvasKit reads a shader's local
matrix as the map's own pixels to the frame's, so the 64x64 copy needs frame over
map, `[W/64, 0, 0, 0, H/64, 0, 0, 0, 1]`. Without it the pass covers only the
top-left 64 pixels and clamps every pixel past them onto the map's last texel —
one constant t over the whole photo, a global inversion and not a dehaze.
Measured against the ideal ramp, `scaled(n/w)` clamps the same way; the
reciprocal lands on it.

The knob is left to over-correct at the top of its range, and that is deliberate.
A hazy sky still goes white and a saturated colour beside a dark edge still
deepens: `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, which is
the arithmetic the doc asks for. It is smooth on the frame — 6x zoom panels of
the hazy frame at DEHAZE 100 show one wide gradient and no band repeating at any
period — so no knee is added to soften a correction that is no longer producing
the symptom. If that side ever needs taming, DEHAZE_MAX_OMEGA is the one number.

The MASK's DEHAZE still samples the old 5x5 patch inline (gradientMask.ts,
unchanged): the frame-wide pass is what the report was about and what is fixed
here.

Verified: the synthetic harness over four patch configurations puts the new
shader at zero deviation from the clean correction at N=64 — the 16.7-pixel cell
swallows the test line, which is why the real photo is the judge. The real photo
through the running app, with the slider swept 0, 100, -100, 0, is exact at both
zero points and moves the frame at both ends, and its zoomed panels at DEHAZE 100
— roof, floor and a wooden rail at 3x and 6x — show the remaining change as one
smooth region, the blue of a tarp and the green of a floor stain deepening where
the haze was hiding them, with nothing repeated around the dark detail that used
to copy itself. npx tsc --noEmit clean, npm run build clean,
scripts/mask-wb-check.mjs and scripts/highlight-knee-check.mjs both pass.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 20:02:35 +07:00
3dtours 2ced93a425 A fixed mask EXPOSURE that never arrives: the shell stops being cacheable by guesswork
The request was a mask's EXPOSURE losing hue, and the mask pass has not done that
since 6d60d45: measured today through a page the service worker controls, with an
ellipse over the whole frame and +1 EV, the pixels inside move exactly as the
frame's own knob moves them — identical to the byte (mask+1 vs frame+1 over four
codes: 619 pixels of 1,709,450, all of them on the rim), and the hue each one
leaves behind is the same distribution to a hundredth of a degree over the
370,606 pixels that carry a hue in both reads (mean 2.07°, p99 15.31° for the mask
against 15.32° for the frame; on the vivid pixels, mean 0.83° at +1 EV). The one
place that still says otherwise is a doc on the Android branch, whose §3.2 quotes
the old line.

But the symptom is real, and the build that produces it is the one from before
that commit, where maskAdjust multiplied the three channels by the stop:

    c = c * half(pow(2.0, a.x));

Three channels clip by three different amounts, so the differences between them
stop being scaled together and the hue goes with them. That bundle could still be
what a visitor runs, because of two files the deploy never took away:

- index.html was the only document the server handed over with no Cache-Control
  at all (the .mjs, /assets, /wasm and /models locations all name their policy,
  sw.js and the manifest both opted out). With no header the browser is free to
  guess a freshness window out of Last-Modified — a tenth of the file's age — and
  answer a navigation from its own cache for hours after a deploy. The page it
  answers with names the previous build's hashed bundle, so the previous shader
  is what runs, and a hard reload is the only way out. The SPA fallback lands on
  the same file (an internal redirect re-matches locations), so /app and /library
  were covered by the same guess.

- sw.js is the second place the pin lived. Its navigations are network-first, but
  a plain fetch is not the network: it can be answered by the browser's cache, so
  the network never came first — and the old shell's hashed bundle, once fetched,
  is a STATIC path that the cache-first rule serves forever.

So: nginx names the policy for the shell, with the isolation pair restated
because an add_header in a location drops every inherited one and index.html is
the document that needs them — the wasm renderer's SharedArrayBuffer is behind
that pair. The worker reads its navigations past the browser's cache, precaches
the shell the same way (a cache.add of '/' consults that cache like any other
fetch, so a shell read inside a stale window would be stored as the offline shell
of the build that replaced it), and VERSION goes to v2, whose activate drops the
cache the old worker pinned — the old shell and the old bundle with it.

The root fix is still 6d60d45: this commit is what lets it reach the browser.

Verified: nginx -t on the shipped config, and a container of this config against
a copy of index.html hands / and /app `Cache-Control: no-cache` with all five
original headers intact, while /assets/index-abc.js keeps `public, immutable`
(30 days) — the exact location does not shadow the hashed bundle. node --check on
sw.js. The measurements above come from the live 8090 build inside a persistent
profile whose page is controlled by the worker (controlled true,
crossOriginIsolated true, bundle index-CKOd57MG.js), the same session that pinned
the build the fix is about.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 18:33:10 +07:00
3dtours 6d60d452e0 One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
A gradient mask had its own tone formula and its own exposure, and both
disagreed with the frame's. Before any of this was tidied, the mask ran a
smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame
moved the knots of a four-zone ramp, and the mask's exposure was a stop on
sRGB-encoded values while the frame's was a stop on light. Two names, four
moves, and the same slider meant different things depending on whether the
pixels were inside the shape you drew — the divergence §3.3 of the Android
port's compat doc warns about.

The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the
ramp the four knots build, the hue-preserving rebuild behind it, the transfer
pair, and exposureMove. A mask calls the same functions the frame calls.

The rebuild carries the chroma instead of re-scaling it. Lightness takes the
curve and the colour rides the difference — the channel differences move by ONE
shared scale k, pulled back only where the cube has no room left. The doc's
ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact
only while nothing clips: a channel past 1.0 stops being scaled with its
neighbours and the hue goes with it. Measured on a flat patch frame, a skin
tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at
57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by
x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The
scale is the chroma's own now: over 135 knob combinations on seven colours and
five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°).

EXPOSURE gets the same treatment, which is what the second half of the request
was: the linear domain decides where the luma is going, and the pixel is
rebuilt onto it through the same lightMove. The old pass multiplied the three
channels in linear light, so +1 EV clipped them by three different amounts:
measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV
and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the
pixel's own encoded luma rather than pointed straight at the encoded linear
target, which is what makes the knob exactly the identity at 0 EV — the
transfer does not commute with the luma weights, so pointing at it brightened a
colour by a couple of code values even at zero. A grey is the knob it always
was: 128 through +1 EV is 176, the same number the linear per-channel multiply
put there, so nothing a user has dialled in moves.

Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check
now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels
pushed through it, agreeing with the twin to a code value on a grey at +1 EV
(176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the
hue, the cube, the identity at 0 EV and the black pixel that has no light to
move. mask-wb-check compiles the mask pass and pushes the same stops through
it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on
the ceiling at 255 and holds the hue within 3°. auto-tone-check,
preview-match-check, white-level-check, raw-develop-check, half-check and
roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the
LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from
0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV
(Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move
the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the
corner outside it does not move at all (220.2 -> 220.2), with no console error.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 18:04:50 +07:00
3dtours 03ec925aec The phone gets its picture back: a one-row icon toolbar, a histogram that shows the tones below the sky, and crop corners a thumb can actually grab
Three things were wrong on a 390px phone, and all three cost the photo its
screen. The toolbar's six labelled buttons wrapped to three lines (141px of an
844px screen, 17% of it) and now sit on one 30px row of icons; the histogram
read as an empty white plot; and a crop corner was a 9x16px target to a touch,
so a thumb a few pixels off it moved the frame instead of resizing it.

The toolbar keeps its labels in the DOM and only takes them out of the paint —
font-size 0 on the button, the glyph on ::before — so every button keeps the
accessible name it always had, and the two toggles still report their own
state. Verify: measured on a 390x844 viewport with a real P1010256.JPG, the row
is 30px tall, one row, buttons 34x30, `fontSize` 0px, the labels still in the
DOM as text, and hist-toggle still flips aria-pressed both ways.

The histogram was two separate faults, and the first one was invisible to the
DOM. Measured: after upload or after a close/reopen the plot is an empty frame
for ~100ms (4 paths land at 104ms / 126ms) — that is the mount, not a bug. But
the SVG then carried four full-length paths whose own map was flat: the lum
curve sat 0.9 of the panel high on ONE bin and every other bin measured 0.066
or less, so the photo read as a line along the floor. That is real: the
sampled frame has 34% of its pixels on bin 255, and under a linear scale a
blown sky owns the whole panel. The scale is log1p now — an empty bin still
sits exactly on the floor — and the bins go from 1 bin above half to 226.

The second fault was the paint: `.hist-ch` screens its three curves, which is
how light adds up on a dark panel, and it is measured against the backdrop —
so on the light theme (a white plot) it screened every channel straight to
white. Screen is now gated to `[data-theme='dark']`; measured on the light
theme, the plot went from 52 red / 39 green pixels to 931 / 1143 with the grey
fill under them.

The crop corner keeps its 16px square, which is the size a corner reads at,
and grows only its touch target: 44px centred on the node, so the outward half
is clipped by the photo's own layer and the target never overlaps the rect it
would otherwise hand the drag to. Measured: 23x44px reachable around each
node, and a drag from the bottom-right corner's centre still resizes (w 358 ->
297, h 201 -> 167) without moving the frame.

Verified: `npx tsc --noEmit` clean, `npm run build` clean, and every number
above read back off the built bundle in a 390x844 mobile context.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:54:45 +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 bd57dd72b3 EXPOSURE reads in stops: -5..+5, two decimals, on an unchanged store
The tone spec draws EXPOSURE as `min="-5" max="5" step="0.01"` with a `0.00`
readout, and the app's row was still the original whole-unit knob: -10..+10 in
one step, printed as a bare `+2`. The recipe file behind it has always carried
units where one unit is EV_PER_UNIT = 0.25 of a stop, which is the same ±2.5 EV
travel, so the two are the same range said two ways.

The def now talks stops on its face and units in the store: `min: -5,
max: 5, step: 0.01`, a `twoStops` readout (`0.00`, `+0.50`, `-0.25`, no unit
because the label is the unit), and accessors that translate — `get` multiplies
by EV_PER_UNIT, `set` divides and rounds to 1/10000. EV_PER_UNIT is exported for
that one use. Because the ×10 of `deepen` is not applied here and the store's
field is untouched, a recipe that shipped with EXPOSURE 2 still means the two
units it always meant: nothing gets brighter or darker, and half a stop is
still 2 units in the file. The knob steps by 0.01, which is 0.04 units — the
slider can express a quarter stop where the old one could only express quarter
stops, and everything between them.

TINT and TEMPERATURE keep the web's ranges (tint ±100 on the ±10 store scale,
temperature 2500..10000 K). The spec's ±150 tint and its 2000..50000 K
temperature were reviewed and left as they are: the tint at ±100 is already
what `deepen` publishes and the wider pair would move stored looks.

Verified:
- `npx tsc --noEmit` clean; `npm run build` clean.
- Repo checks re-run green: `highlight-knee-check`, `auto-tone-check`,
  `half-check`, `white-level-check`, `preview-match-check` — the last two render
  real looks through the engine, so a shifted EXPOSURE scale would have shown up
  as a brightness change in recipes that carry a non-zero one.
- Live browser pass on the deployed build: the row reports `min=-5 max=5
  step=0.01`, prints `+1.00` at a full stop, `+0.01` at a hundredth, `-0.25` at a
  quarter stop down and `0.00` at rest, and EXPOSURE +1.00 still lifts the
  graded frame (mean luma 184.3 -> 203.4).

ponytail: the CREATE RECIPES form (RecipeCreatePanel) still edits a sim's raw
adjustments in store units, EXPOSURE included. Leave it there until that form is
asked for stops: it is a store editor, not the develop panel, and every field on
it is in the same units.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 17:09:01 +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 64d41f67e9 library: read one RAW at a time, and hand the decoder a size it already read
A folder of RAW took the page down. The catalogue reads four frames at once —
that is what makes a roll land quickly, and for a JPEG it is free — but a RAW is
not read by this page at all: it is handed to libraw-wasm, which opens it inside
a worker of its own, and that worker is built with a quarter of a gigabyte of
linear memory (emscripten's shared memory, 256MB, instantiated per instance) and
the whole frame is copied into it. Four of those at once, on frames of 22.6MB,
is past what a renderer is given before a single tile is drawn, and the page goes
with it — which is the crash this answers.

One open at a time now, whoever asks for it: the gate wraps the read of a RAW and
nothing else, so the frames around the one being opened still have their bytes
read off the disk, their decodes run and their tiles encoded side by side. Only
the open queues. A folder of JPEGs never touches the gate and keeps all four
lanes.

The tile also stops asking the decoder a question the bytes answer. A JPEG — and
the preview a camera writes inside a RAW is one — carries its frame size in its
own header, in the first few hundred bytes the read already holds for the date.
`jpegSize` reads it there, and `tile` is handed the size instead of decoding the
whole frame a second time only to learn which edge is long. `jpegSize` existed
for exactly this and had no caller; it has one now.

Verified:

- library-check.mjs, scan-nav-check.mjs, roll-walk-check.mjs all pass against the
  built bundle — the catalogue still reads a roll, a RAW still becomes a tile off
  its own preview, a frame still says where it was shot, an interrupted reading
  still goes on by itself.
- A stand-in folder answered `showDirectoryPicker`, 48 frames of 22.6MB, resident
  memory of the whole browser process tree sampled every 150ms:
  - before: 4 frames read at once, peak RSS 1335MB, 565MB before the roll was
    picked, 12s, 48 tiles, no error;
  - after: 1 frame read at once, peak RSS 1180MB, 32s, 48 tiles, no error.
  The 2.6× on the clock is mostly the harness: its `getFile` copies 22.6MB per
  frame, so serialising the open serialises that copy too. A real folder pays a
  disk read and an open in sequence instead.
- 200 frames of 8.5MB JPEG, same harness: peak 1713MB, 4 at a time, 200 tiles,
  no error — the JPEG path is untouched by this commit.

ponytail: reading a RAW could skip LibRaw entirely — the camera's preview is a
plain JPEG and could be cut out of the head of the file this page has already
read. Probed on four samples: RW2 carries a 1920×1280 preview at 54KB, NEF one at
6016×4016 of 1.08MB, but DNG has only 720×480 inside its first 4MB and RAF keeps
almost none, and picking the wrong segment risks a thumbnail for a tile. Not
worth it until a RAW that is neither DNG nor RAF is the common case. The JPEG
path still peaks high (1713MB over 200 frames) and that is `createImageBitmap`
decoding every 5472×3648 frame whole, at four lanes — an app that reads fewer at
once trades time for the same headroom, if a page that large is ever the crash
again.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 16:08:26 +07:00
3dtours 426488a788 studio: say the roll is still being read while the catalogue is off screen
The reading of a folder belongs to the tab, not to the catalogue screen: it was
made to survive the hand-over to the studio in 753eea0, and it does. What was
missing was any sign of it up there — a visitor who hands a half-read roll to the
studio saw a page that said nothing about the frames still landing, and had no
way to tell a reading in flight from one that had quietly died. The one thing
that did say so, the toolbar line, lived on the screen they had just left.

The header now carries it, immediately left of the way back into the catalogue,
where the two belong together: the ring the catalogue already uses for a roll in
hand, and the count the toolbar states, "4/12", whose title is the same
"Scanning 4/12 — 0 new…" line in the visitor's language. It reads the session
through scanSession() and watches it with watchScan() exactly as the catalogue
does, so a pass that moves the progress moves the header too — the session object
outlives them both, only its progress is replaced each pass. No new state is
introduced, no new copy: the markup borrows .lib-spin and the lib.scanning key
the catalogue already had.

Verified: tsc --noEmit and vite build clean; scripts/scan-nav-check.mjs grew one
step at the first hand-over, "the studio header shows the reading the tab is
doing", which waits for [data-key="studio-scan-count"] mid-scan and matches it
against \d+/\d+ — it passes with 4/12, the same reading the toolbar shows at that
moment; the other 18 steps of that check, the 52 steps of library-check.mjs and
roll-walk-check.mjs all still pass.

ponytail: the header states progress, it does not offer to stop the scan; the
catalogue's own STOP stays the one place that ends a reading. Add a control here
when someone asks to stop a roll from the studio.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 10:55:07 +07:00
3dtours 53cb1c97e9 web: a frame is scored under the picture, and the wall is read through the score
The catalogue could say what a frame was and when it was shot, and nothing about
whether it was any good. A reader who had been through a roll of two thousand
had no way to say "these four", and the thumbnail wall drew every frame the open
folder held in the one order it had: newest shutter first, always.

The frame that is up now carries its own score — five stars under the picture,
and the star the click lands on is the score it gets. The star it already has
takes the score back, so a score that was given by mistake is taken off without
a sixth control. It is the catalogue's own row and nothing on the disk is
touched: the file is not written and not read, the browser only stores one more
number against a frame it already holds, and the number is the reader's.

The wall is read through three of them. The score is the first — four stars and
up is the floor, any rating is the wall as it was — because that is what a
reader who has just been through a roll wants next. The year the shutter fired
in is the second, offered as the years the open folder actually holds and not a
century of empty ones. The hours it fired in are the third, as a from and a to:
16:00 to 18:00 is the afternoon the reader was out, and either end alone is
"from 16:00" or "up to 18:00". All three read the shutter time, on this
machine's own clock — the same reading the frame's date line prints, so the
afternoon is the afternoon and not an offset of it. Beside them, the order: the
newest shutter first, the oldest first, or the most stars first.

What the filters hold is the wall and only the wall. The strip under the picture
is the shelf the open folder is, and a shelf that quietly loses three quarters
of its frames is not a shelf any more; the frame that is up has to stay reachable
while the wall is narrowed around it. That is also why the filters are drawn in
the thumbnail view: they are a way of looking through the shelf, not a property
of the roll, and nothing about them outlives the visit.

Verified:
  library-check.mjs — 52 steps, all passed, five of them new. The frame that is
    up is scored from the row under it and the catalogue holds star 4 against it
    (aria-pressed on the fourth star true); the wall narrows to one tile at 4★,
    to none at 5★, and back to three with the rating cleared; the year 2016 holds
    the two JPEGs of three and 16:00–18:00 the same two, out of a roll whose
    frames are written years apart in different parts of the day (the JPEG in the
    afternoon of 15 Feb 2016, the RAW at seven in the morning of 1 Jan 2026);
    read by score, the scored frame comes first. Every earlier step still holds,
    including the two that count what a reading costs and the stand-in folder's
    two frames — the JPEG carrying the GPS tag and the RAW printing
    "26.4mm · f/2.8 · 1/320s".
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the score is one number on the frame's own row — no rating table, no
votes, no accounts; the catalogue is a per-browser shelf and so is the score. The
filters live in the thumbnail view's own state, so a reload starts with the whole
shelf again, which is what a filter is for. No text search: a roll of date folders
is three levels deep at most, and the three that a reader actually digs with are
the ones here — add the box when a folder of mixed names makes one worth typing
into.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 08:02:05 +07:00
3dtours 477c71d1f4 web: the frame under the preview says where it was shot and what it was shot with
A negative is opened to be looked at, and the two things a photographer reads
off it first are the numbers the camera wrote and where it stood when it wrote
them. The stage gave the name, the folder, the date and the weight of the file,
which is what the catalogue knows; the rest was a trip into the studio.

Both are now under the picture. The numbers are the camera's own — ISO, focal
length, aperture, shutter, frame size — printed in the order a photographer
says them, and every one the file does not carry is left out rather than stood
in for: a Fuji RAF gets no line at all, a Panasonic RW2 gets the glass and the
shutter and no ISO, and a JPEG gets the lot. Where the frame was shot is the
GPS it carries, named by the geocoder when one answers and left as the
coordinates it holds when none does — a naming is a network round trip on a PRO
account, and the numbers do not wait for it, so a refusal or a miss costs the
reader nothing.

What this costs is one read of the frame's first few hundred kilobytes, the
same head a scan hands the parser, and only the frame that is up pays it: the
strip walks past a hundred negatives without reading one of them. The read is
held by the read itself and not by the frame object under it, because the
catalogue is read back on a timer while a scan runs, which hands the screen a
fresh object every few hundred milliseconds — a screen that went by the object
would read the same file again and again for as long as the scan lasts.

The check's stand-in folder had no frame of its own with a GPS tag on it, and
none of the samples carries one, so the check writes one: an APP1 segment
holding a GPS IFD and nothing else, spliced in right after the frame's SOI. It
is deliberately the first APP1 — a JPEG carries one EXIF segment and the
catalogue reads the first, so a file that has a place is a file that spent its
EXIF on the coordinates. That is also what makes the RAW the frame that shows a
spec line, and the two frames now check the two halves of the same feature.

The run counts what a reading costs, and a head is not what it counted before:
it counts the whole file a lane develops apart from the head a parser is handed,
because "the RAW was read" is a claim about the scan and the preview legitimately
reads the same file's head.

Verified:
  library-check.mjs — 47 steps, all passed, two of them new. The JPEG off the
    check's server carries a GPS tag and the page keeps 16.0544, 108.2022 under
    the frame, with no geocoder behind it to name them; the RAW prints
    "26.4mm · f/2.8 · 1/320s" — the glass and the shutter it has, no ISO and no
    frame size it does not. Every earlier step still holds, including the one
    that says a scan does not read a RAW whose size and write time have not
    moved: whole reads {}, heads {"P1010256.RW2":1,"P1010256.JPG":2}, the one
    RAW head being the frame that is up.
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the place is named by the API's geocoder and nothing else — no map, no
picker, no place a reader can type. The coordinates are what the file carries,
and a file that carries none shows no line, which is the honest answer and the
common case for a phone frame with location off. The line is read for the raised
frame only; a grid of hundreds is a list of names, and a row of ISO numbers
under each tile is not what it is for.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:57:36 +07:00
3dtours 753eea0d7b web: a reload in the middle of a reading goes on, it does not start over
The reader opens a roll of a few thousand frames and holds Ctrl+Shift+R while
it is being read — over a reading that takes minutes, that is the one thing
they do. What came back was a reading that started again at the top: every
folder listed a second time, the toolbar counter back at zero, the frames that
were already in the catalogue walked over so that their size and their write
time could say they had not moved.

None of that was in the catalogue, because the catalogue only holds the frames
that were read. What was lost with the page was the reading's position: the
folders the walk had been through, the folders still in its queue, and the
frames it had found and not yet read. The catalogue alone can never answer
where a reading was.

The position is now written to session storage as the reading goes — walked,
pending and the frames in hand — and read back on the way in. Session storage
and not local storage, because a position belongs to the tab: the tab that
reloads carries on from the frame it stopped at, and a tab opened beside it
starts a reading of its own. The key goes when the reading finishes, and goes
with the folder when the folder is removed.

The frames in hand are the part worth spelling out. A frame a lane is in the
middle of, and a frame whose row is in the batch and not yet in the catalogue,
are on neither side of the line the position is written on: the catalogue does
not hold them and the walk will not find them again, so both are written into
the queue and read again. They also were counted when they left the queue, and
the count is written with them — else the reloaded reading would count them a
second time. The counter now carries on from where it was instead of restarting
from zero.

One thing was hidden behind the other: the stand-in folder the check hands the
page had no `getFileHandle` and no `getDirectoryHandle`, so the path that asks
the root for the frames a position names threw and answered nothing — and the
reading then walked the roll from the top, exactly the behaviour the check was
meant to catch. The check's folder answers both now, the way the browser's does.

Verified:
  library-check.mjs — 45 steps, all passed, two of them new. The reading is
    stopped on the frame it is reading (`check.hold`), the tab is reloaded, and
    it comes back at "Scanning 3/3 — 0 new…" — the same line it was stopped at,
    not a reading starting over — with the position still holding 4 folders
    walked, an empty queue and 3 frames in hand. Listing 3 tiles and letting the
    key go, the reading finishes the roll: 3 tiles in the strip, the catalogue
    written, the position cleared, and every folder listed once
    ({"2026":1,"CheckRoll":1,"Empty":1,"04":1}) where a reading that started
    over lists each of them twice.
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the position is the tab's, so a reload keeps it and a new tab does
not — the tab that reloaded is the one the reader is looking at, and a second
tab reading the same roll from the top costs a walk and no bytes, because a
frame that has not moved is dropped on its size and its time. The position is
written at the batch beat, so a reload reads at most one batch again, and the
frames in hand are read again on purpose: their rows were never stored.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:50:01 +07:00
3dtours d7241531f9 web: a reading walks past the recycle bin, not into it
The walk already turned its back on the folders a camera and an editor leave
behind: a name beginning with `.` or `@` is skipped, which is `.thumbnails`,
`.git`, `.Trash`, and the `@eaDir` a Synology writes beside every frame.

An external volume drags along a second set of names, and none of them begins
with a dot, so every one of them was walked. `$RECYCLE.BIN` and `RECYCLER` are
where Windows parks what the reader deleted — frames among them, at full size,
and every one of them decoded into a thumbnail on the way in. `System Volume
Information` is Windows' own bookkeeping. `#recycle` is the Synology share, and
`lost+found` is the directory a Linux volume keeps for repairs. A frame that
comes back out of a bin is a frame the reader threw away, and the pictures in a
share's recycle folder are pictures someone else deleted; neither belongs in
the catalogue, and both cost the reading the same seconds a real frame does.
The folder also stood in the column as a row of its own.

The rule is now one `SYSTEM_DIR` expression over both sets, matched
case-insensitively — the same volume spells the bin `$RECYCLE.BIN` on one drive
and `$Recycle.Bin` on the next — and the walk asks it before it looks at what
the entry is, so a refused folder is neither read nor named.

Verified:
  roll-walk-check.mjs — all passed, with two new assertions: the stand-in roll
    now carries `$RECYCLE.BIN`, `$Recycle.Bin`, `RECYCLER`, `System Volume
    Information`, `#recycle` and `lost+found`, and every one of them holds a
    file named like a frame, so nothing about the files can be what keeps them
    out. No frame is read from them, and none of them reaches the column.
  library-check.mjs — 43 steps, all passed. scan-nav-check.mjs — all passed.
    frontend tsc --noEmit clean.

ponytail: the list is the names the volumes being read actually use, not a
guess at every spelling there is — a box that files its junk under something
else gets a line in that expression, which is the whole of the change. A folder
of the reader's own that happens to be called `#recycle` is skipped too; that
name is worth the trade.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 07:10:41 +07:00
3dtours fbe9a1bb5b web: a roll is read where it was left, and only for the frames that moved
A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.

The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.

A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):

  first reading        5209ms
  the same roll again  2887ms   24/36 frames read again
  first reading        5320ms
  the same roll again   603ms    0/36 frames read again

And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.

The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.

Verified:
  library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
    three marks, the row menu's four, the add-only menu's one, the refused
    folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
    itself (three rows back, and the bytes read are the two frames the
    catalogue had lost, not the one it still held), and the folder read again
    from its own menu. The stand-in folder now carries `__fake` on both handle
    kinds — a folder handle that does not is one the screen cannot ask about
    after a reload, which is a folder it offers to reconnect — and the frames
    it hands out report one write time instead of `Date.now()` per call, which
    is what a real handle does and what a frame is skipped on.
  scan-nav-check.mjs — all passed, the row still counting the reading as it
    comes rather than the catalogue standing still. roll-walk-check.mjs — all
    passed. frontend tsc --noEmit clean.

ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 06:33:41 +07:00
3dtours 36cd711302 web: the header wears the theme, and the rail says which build it is
Two things the studio could not say about itself.

The preset the header has open was painted in the dim text colour, the same grey
as the page name beside it — so the one label up there that changes with the look
read as chrome. It now takes the theme's own accent, the colour the brand mark
and the open tab already carry, which means it follows both the light/dark mode
and the accent group the reader picked, out of the CSS token and with no colour
written into the component.

And an image has no other mark on it: once the frontend tarball is loaded on the
NAS as `:latest`, nothing on the box says which revision came off. The rail now
names the build at its foot — quiet, mono, wrapping rather than widening the
column, and gone under 860px where the rail is the phone's scrolling tab bar
instead of a column.

The name is stamped into the bundle at build time: vite.config reads VERSION
from the environment (`docker compose build frontend --build-arg
VERSION=$(git rev-parse --short HEAD)`) and falls back to the package version
plus the minute it was built, so two builds of the same tree are never the same
name. `ARG VERSION=` in the Dockerfile is the knob; the bare `npm run build` —
dev server, check scripts — still stamps its own time, and the dev server passes
nothing at all, so the line only draws when there is something to say.

Verified:
  library-check.mjs — 37 steps, all passed, the two new ones reading the header
    off the running studio: the rail foot names the build (0.1.0+202609281504)
    and the preset chip's computed colour is the brand's accent, not a grey —
    rgb(206, 117, 9) amber, rgb(93, 24, 191) after switching to violet.
  scan-nav-check.mjs and roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean. Live 8090 on index-… matching dist/: /, /library and /app 200 with 0
    console errors.

ponytail: the name is the package version plus a caller-supplied tag, and nothing
bumps the package version, so the tag is the whole identity — have the release
job write it into package.json if the numbers ever need to mean something.
2026-09-28 22:06:22 +07:00
3dtours 77729ad99a web: read the roll through four lanes and a JPEG's header
A scan of 36 real files off the local disk took 8957ms — 249ms a frame — and
the page was idle for nearly all of it: a CPU profile over the walk is 61.5%
idle, so what the catalogue was doing was waiting, not working. Of that time the
bytes and the LibRaw preview are 2656ms and the tile 5761ms, while the EXIF read
is 14ms of the lot. One frame at a time spends the disk's latency and the
decoder's thread on nothing, and a JPEG was read whole — 8MB through a JS array
to find a date in its first kilobyte — then decoded at 24MP to be drawn at 512px.

The walk now runs four frames at a time. A frame that is new is read as a 256KB
header slice unless it is a RAW, which LibRaw has to have whole to seek to the
preview inside it; the shutter time comes off that slice. The tile is asked of
the decoder at 512 on the long edge, so a quarter of the pixels of a 24MP frame
are ever allocated, and the aspect comes from the frame's own SOF header for a
JPEG and from an eight-pixel decode for anything else; the canvas is left to
re-encode what comes back. The column still counts a frame the moment the scan
reaches it, so which frames are up is unchanged, and a frame that has not moved
is still dropped on its size and its time before a byte of it is read.

Measured on the same roll (24 JPEG 8.2MB + 12 RAW 22.6MB, two levels deep,
served over loopback with no added delay):
  one frame at a time   8957ms   249ms/frame
  four lanes            5474ms   152ms/frame
  + the header slice    4793ms   133ms/frame
Six lanes came out worse than four (5259ms) and is not what this does: past a
few the disk and the decoder are the limit.

Verified:
  library-check.mjs — 35 steps, all passed. scan-nav-check.mjs and
    roll-walk-check.mjs — all passed.
  frontend tsc --noEmit clean. Live 8090 on index-CdakqRi8.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: the lanes run on the main thread, and a RAW still reads whole per file
— a few LibRaw opens at once now — so a 5000-frame folder is still lumpy; move
the walk into a worker and a RAW's preview onto a sync handle when that is real.
A frame smaller than 512 is now scaled up to it rather than drawn at its own
size, which is invisible at the 132–220px a tile is painted at; keep the old fit
if a print is ever taken off a tile.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 22:01:03 +07:00
3dtours 134489a5ec web: come back to the frame that was raised, and zoom it by the wheel
Two things the preview owed a reader.

The frame raised in the strip outlived nothing: leaving for the studio and
coming back dropped the choice and the node opened on its first negative
instead. It is now remembered beside the folder and the column width — the
same one-line store, read on the way in, written on the way out — and a
frame that is gone from the catalogue falls through to the first, the way
a node that is gone falls back to its folder.

A wheel over the frame now magnifies it. The listener goes on the element
rather than through onWheel, because React's own wheel is passive and this
one has to hold the page still while it zooms; the origin is the point
under the pointer, which is what keeps a reader's aim on the thing being
looked at, and the step comes from how far the wheel turned so a notched
mouse and a trackpad travel the same. Six times is where it stops — the
thumbnail behind the preview has no more pixels to give — and a frame that
has just come up is fitted again.

library-check: 35 steps, the two new ones raising a nested frame, reloading
on it, and ticking the wheel up and back down.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 21:43:36 +07:00
3dtours 067b32131e web: drop the heading over the folder column
The column carried the name of the folder its tree belongs to, painted
above the rows. The rows already say it — the head of the tree is a row
like any other — so the heading only said it twice, and because it was
not a row it stayed on screen when the tree was folded away: the one
label left with nothing under it. The right click that raised it now
finds the row itself, which is where a reader aims anyway.

The label, its menu hook, its translation and its rule go; the tree, the
row menus and fold-all keep working as before.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-28 21:27:55 +07:00
3dtours 5e28b42067 web: fold all keeps the top-level row open
COLLAPSE ALL folded the top-level row as well, and that row is already its own
fold: one click on its name shuts it and takes the whole subtree with it. Folding
it from the menu hid every row of the tree — the item repeated a click the reader
already had, while the one thing a click there cannot reach, the folders nested
below, was the part that got lost with it.

So the item folds the folders nested below the top level: every row with a
subfolder under it that is not itself a folder picked at the top level. The
top-level row stays drawn and stays open, its own subfolders are drawn folded,
and no top-level row ever leaves the column. `nested` is computed off `parents`
once, and the item is disabled on a folder with nothing nested under it.

Verified:
  library-check.mjs — 35 steps, all passed. The fold step now reads the rows the
    click leaves behind instead of a row count: the fully open roll (4 rows)
    becomes exactly CheckRoll aria-expanded=true, CheckRoll/2026
    aria-expanded=false, CheckRoll/Empty (a leaf, so no aria-expanded) —
    CheckRoll/2026/04 is away, the top-level row is not — with the strip's 2
    tiles unmoved and the row menu still leading with lib-menu-collapse.
  frontend tsc --noEmit clean. Live 8090 on index-DAIpyDNV.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: with several folders picked, one COLLAPSE ALL folds the nested rows of
all of them, not only the row that was clicked — no top-level row is hidden
either way; scope it to the clicked folder's prefix when a column routinely
holds many picked folders.
2026-09-28 21:23:56 +07:00
3dtours 4a740b1e28 web: fold the tree from the row a reader right-clicks
COLLAPSE ALL lived only on the column's own name — the label above the tree —
and that label scrolls with the list it heads: on a roll read deep the column is
scrolled past it, so the item looked like it only appeared once the top-level row
had been folded to bring the list back to the top. The row is where the pointer
already is, and it already carries the folder's menu.

The top-level folder row now leads its own menu with COLLAPSE ALL — rename,
rescan and remove follow it, and a subfolder row is unchanged, since folding a
whole tree is what a row that a tree hangs from can do and a row inside one
cannot. Both entry points run the same one: `root` on the menu state now means
"the head of a tree", the column's own name or the row of a folder picked at the
top level, and the menu draws the item first and then whatever the subject
itself has — the folder's items, or, on the empty part of the column, ADD
FOLDER. The empty part keeps ADD FOLDER alone: it is not the head of a tree.

Verified:
  library-check.mjs — 35 steps, all passed. The new step right-clicks
    lib-node-CheckRoll with the tree fully open (4 rows) and gets back exactly
    [lib-menu-collapse, lib-rename-CheckRoll, lib-rescan-CheckRoll,
    lib-drop-CheckRoll]; a right click on the column's name still answers with
    lib-menu-collapse alone; the subfolder menu above is still the three folder
    items with no fold in it; the empty column still offers lib-menu-add alone.
    The fold itself is unchanged and still measured by opening the root back up:
    4 rows → 1, and 3 back, with CheckRoll/2026/04 still away.
  frontend tsc --noEmit clean. Live 8090 on index-DEvUJDNY.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: the column's name is still the second way to the same item and still
scrolls with the list — left alone because the row is now the one that matters;
make the name sticky when a roll routinely fills the column past one screen.
2026-09-28 21:20:23 +07:00
3dtours c1ee2cf30a web: fold the whole roll from the head of the tree
A roll read to the bottom of its dates is a long column of indented rows, and
the only way to shut it was one row at a time — the click that folds a row also
opens it, which is the right trade for reading and a poor one for putting away.
The column's own name is now the whole tree's control: a right click there opens
a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row
that has something under it at once — the root's own row included, so the tree
comes back to one line per picked folder and the subfolder two deep is away with
the rest. The menu is the folder menu's shape at a third subject rather than a
second menu: one `root` flag on the state that already knows where a right click
landed, and the same Escape, same click-away, same clamp to the window edge.

The frames do not move with the rows: folding is a way of looking at the tree,
and the strip already reads the open folder, which the fold leaves alone. The
name keeps reading as the folder it belongs to — the folder's label, with the
hint appended to its title — and the item is disabled rather than hidden when
nothing in the column has children, so a fresh folder does not open a menu with
a dead line in it.

Verified:
  library-check.mjs — 34 steps, all passed, 2 new: a right click on the root
    name offers exactly [lib-menu-collapse] while the name still reads "Roll A",
    and one click takes the fully open roll (4 rows: the picked folder, its
    subfolder, the subfolder under that, and one holding no frame) to a single
    row. The fold is measured against the ruler of opening the root back up:
    the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty —
    CheckRoll/2026/04 is still shut, which is what says the deep row went with
    the fold and not just the root. The strip held its 2 tiles throughout, and
    the folder the visit was on (lib-node-CheckRoll/2026) is what the reload
    below still reopens on.
  scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed.
  frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js
  matching dist/: /, /library and /app all 200 and 0 console errors.

ponytail: only folding, no unfold-all to match — the click on a row already
opens it and the root's own row is one click away; add the pair when the column
routinely holds many picked folders and opening them one by one stops being
cheap.
2026-09-28 21:03:30 +07:00
3dtours 291968a342 web: give the hero's award column a frame on a quiet week
The landing's award column read two windows of the clock — today's votes and
this ISO week's — and both of them empty meant no column at all: no
[data-key="lp-award"], and the hero back to a single block of copy. That is the
normal state of the install, not an edge case. On the live data the newest vote
is 2026-09-22T09:48 and this week's window opened 2026-09-28T00:00, so
GET /api/highlights answered {"day":[],"week":[]} with HTTP 200 while
/api/photos handed out fifteen frames: the query was right and the clock had
simply moved past every vote.

So /api/highlights grows a third list, `ever`: every vote ever cast, top-rated
first, computed only when both windows come back empty — a day with one vote in
it pays nothing for the extra query. The column draws day, then week, then ever,
deduped by photo id, so a frame keeps the tighter claim it won. The label
follows the frame: `ever` claims no window — "Most-loved recipe" / "Công thức
được yêu nhất" — because dressing a month-old best as today's is a lie the page
does not have to tell. A vote in the window still reads "Recipe of the day" or
"Recipe of the week" exactly as before.

Verified:
  backend npm test — 183 passed, 0 failed. Three new checks in the ratings
    section: a vote older than both windows leaves them empty, the all-time
    bests stand in for them, and with nothing ever rated there is no frame at
    all. The aged vote is backdated in the throwaway database, because both
    windows are the clock's and no API path can cast a vote in the past.
  Live: GET /api/highlights returns `ever` with five frames (ids 12,13,51,50,3,
    every one avg 5, n 1) and 8090's landing draws .lp-award 440px wide beside
    the copy — 5 slides, 1 standing, 2 arrows, label "Most-loved recipe", score
    "5.0★1 vote", meta #LC_LANDSCAPE_VIVID. The column stepped slide-12 →
    slide-13 across one 5s clock, and the page logged 0 console errors.
  frontend tsc --noEmit clean; the served asset index-DPZxk4Vo.js matches dist/.

ponytail: the fallback is a flat all-time list, not a walk back through the
weeks to the newest one that has votes — the query stays one line and the label
stays honest; when the column should also read "Recipe of last week", walk the
windows back instead.
2026-09-28 20:57:32 +07:00
3dtours 1cb2618fd7 web: count a roll as it is read, and open a frame without losing the count
Two holes left by the last change. A frame is opened with `window.location.href`
rather than a link, so the studio was still handed a fresh page and the scan with
it — every route out of the catalogue now goes through `go()`, which pushes the
address instead of reloading while a scan is in flight. And a row counted what was
filed away rather than what had been read, so it sat at zero for the whole of a
first scan: the catalogue's frames only reach IndexedDB in one batch at the end.

`ScanProgress.counts` carries the frames the scan has reached, per row, off the
scan's own bookkeeping, and the column reads it while the scan runs — the row
under the reader's eye moves as the roll is read, and the toolbar's line stays the
whole picture. A re-read counts from the start, which is what it is doing.

The regression check grows the two: a frame opened mid-scan from the catalogue
(of a roll it already holds) has to stay in the page, and the row has to count.
2026-09-28 20:39:51 +07:00
3dtours 79b0d0db86 web: keep reading a roll while the studio is up
The catalogue hands the visitor to the studio with a plain `<a href>`, and the
app has no router: following it threw the page away, so a scan that was halfway
through a roll died with it. A scan belongs to the tab, not to the screen that
started it.

The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`,
watched by whoever is up), so the catalogue can unmount and come back to a
reading that never stopped; a screen that returns joins it and sees the toolbar
line, the stop button and the rows filling in. The internal links are taken over
for a history push *only while a scan is in flight* — everywhere else the
browser navigates exactly as before, so the landing-to-studio flow is untouched.

`scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time
off a slow server, handed to the studio mid-scan, back to the catalogue, and the
whole roll read to the end with the studio up, counting documents along the way.
2026-09-28 20:28:00 +07:00