Commit Graph

377 Commits

Author SHA1 Message Date
3dtours d5da6ebcba web: a row's menu is the tree's own and speaks only of that row, and only the row being read admits to it
The menu that hangs off a folder is a menu read mid-scan, with the eye still
moving down the column, and it was drawn to the measure of a page rather than of
a list: a box wide enough to be a destination, type the size of a heading. A
catalogue's own context menu is a thing passed over on the way somewhere else —
it is sized to its words and to the row it hangs off, and the reader's eye steps
across it without stopping. The type comes down a point, the rows tighten to the
tree's own measure, and the frame around them thickens to a radius the rows
themselves use. The clamp that keeps it in the window follows the smaller box,
so a menu opened near the right or the bottom edge no longer floats an inch off
the pointer for room it does not use.

A row that is being read has exactly one thing to be asked of it — that it stop
— where a row at rest has the reading that brings it up to date. The menu said
RESCAN either way, greyed while a scan was up, which is a command offered and
refused in the same breath. The two never both apply, so the menu now carries
the one the row is actually in: a roll being read offers STOP SCAN and stops
that roll; a roll at rest offers UPDATE... and reads it again. A row with
folders under it grows a third thing — a fold that closes the branch and not
merely the row, because a reader who is done with a tree means the whole of it
shut, and closing only the row they happened to point at leaves the children
standing open underneath it.

The last is a matter of what a row is allowed to claim. Every row of a roll
spanning the tree spun while that roll was being read, so a reader looking at a
tree of a hundred folders saw a hundred rows each insisting it was the one being
read, when the reading had been pointed at one of them. A row now says it is
being read only when it is the row the reading was kept to — the folder it was
pointed at, which for a whole roll is the roll's own row. A tree that says all
of itself is busy when one branch of it is says nothing a reader can trust.
2026-10-01 19:00:21 +07:00
3dtours 4f87f022da web: a reading of a hundred thousand frames says where it is once, counts what the catalogue holds, and keeps a quarter of the disk it kept
A roll of a hundred thousand frames is read by a walk that tells the screen
about every frame it lands. The column is redrawn out of that count, so the
telling was a whole tree rebuilt a frame at a time — on the large library that
is the reading spending its afternoon drawing itself, and to the reader it looks
like a scan that has started over from the top every time the column breathes.
Five tellings a second is a counter that still moves to the eye and a page that
is doing the reading instead; every frame still lands in the counts, and only
the copy the screen reads waits.

What the counter said was wrong twice over, and both were the same mistake told
two ways. The total was the frames already reached plus the frames still in the
queue, so a roll whose catalogue already holds most of it announced itself as
`29980/61211` — the numbers of the reading underneath, printed as if they were
the numbers of the roll. The reading now measures itself against the frames the
catalogue already holds of the folder it was pointed at: the roll's own size
while the walk is still up in its first folders, and the frames under a
subfolder alone when it was kept to one. And a row says at least what the
catalogue holds of it, never less — where the reading's number alone had the
head of the tree count 29980 while the line above it counted a hundred
thousand, one question with two answers. Nor is a scan of nothing announced as
`0/0` before its first pass has come back; the line waits until it knows what it
is reading.

The third thing is not a number but a weight. The tile kept for a frame is the
tile the grid cell and the strip both paint, and there is one of them per frame
of a roll that reaches six figures. At 512 on the long edge a tile weighed 42KB
and a hundred and sixty thousand of them weighed six and a half gigabytes of the
reader's disk — for a grid cell that is ~240px wide and a strip tile that is 132.
A quarter of the pixels at the quality Lightroom keeps its own grid previews at
puts the same picture in the same cell: the tile is now ~12KB and the roll a
quarter of the disk it was. The canvas a tile is drawn onto is told it is opaque,
so it no longer carries a channel of noughts through every draw and every encode.
The stage loses a little softness at 2.4x, which is the trade and is known.
2026-10-01 18:31:08 +07:00
3dtours c93f9fdd19 web: a scan right-clicked inside the tree reads that folder and what lies under it, and leaves the rest of the roll alone
A roll is dated folders inside dated folders, and the reader who wants April
re-read is not a reader who asked for the four years around it. Right-clicking a
subfolder row carried the roll's own menu, and RESCAN on it read the whole tree
from the top — every folder of every layer opened again, every frame in them
asked for its size and its time, to reach the one folder the pointer was on. On
a library of a hundred thousand that is a walk of minutes for a folder of
twenty.

The scan now takes the folder it was pointed at, spelled as the walk spells a
path: '' for the picked folder, `2026/04/` for a subfolder. The walk is handed
that path as its queue and reads down from there, so the tree is entered three
levels in rather than at its root, and the menu row passes the path of the row
under the pointer. A right click on the head of the tree still reads the whole
roll, which is what a right click on the picked folder has always meant.

What a reading kept to one folder must not do is speak for the roll. What it
names in the column is a branch of the tree, so the column keeps what it has and
the rows outside the folder being re-read stand where they are. What it counts
is a branch too, and its numbers drawn over the rows would count a roll down to
one folder of itself, so the column falls back on the catalogue until the
reading is through — the frame the reader is waiting for is the frame that was
re-read, and it is in the column the moment it lands.

Nor does it write a position down. `walk/ROLL.json` belongs to the reading that
walks the whole roll: a fraction of the tree written into it hands the next
visit a roll with the rest of itself missing from the walk, and it clears a file
another reading may be in the middle of. One folder is short enough to read
again, and the frames it does not re-read are skipped on their size and their
time anyway. A jump is refused for the same reason — the reading answers to the
folder it was kept to, and a click elsewhere is a different reading's business.
2026-10-01 17:29:47 +07:00
3dtours 39f80088c7 web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was
`disabled` while no folder was remembered, and nothing in the handler could ever
pick one, so the reader who had carried a copy over on a stick had no way to
point at it. The button now stands, and the folder it restores from is whatever
the reader picks in the dialog that opens — which is the whole of the choice
there is, because a backup is a folder of rows and tiles. The folder on the other
disk, the one made before the edit: the folder is the version.

BACK UP asked for a folder only the first time one was ever chosen. After that
it wrote into it in silence, and after a restart — when the browser takes the
permission back, since a permission outlives the tab only while the tab does —
it wrote nothing and said so, which is a backup that quietly stops happening.
It now picks whenever it cannot write, and the picker is the only thing that can
hand a remembered folder its permission back; a permission asked for with no
picker behind it is one the browser may refuse. The run that follows a scan is
untouched: it is guarded by the permission it would be asking for.

And the folder is a control, not a caption. The line that names it — with the
frames in it and when they were written — is what tells one backup from another,
and pressing it is how a folder is picked, a name given, a permission handed
back. It keeps the hint's own look through five lines of CSS, so the row still
reads as a caption and not as a third button.

What the line says is now read out of the folder rather than out of this
browser's memory of it. `pickBackupFolder` reads the catalogue file inside the
folder just picked and takes its count and its date as the row's own; a folder
with no catalogue of its own has none, and the row says so instead of showing
the numbers of the folder next door. That is also what the confirm names before
a restore — the frames and the time in the folder just picked, which is the
thing about to be restored. `restoreNow` already read that file; this reads its
header first.

Checked on the running bundle with the picker stubbed, since the dialog is one a
probe cannot press: with nothing remembered, RESTORE now opens the picker where
it did nothing at all, the row names the folder the picker returned, and a
folder without a catalogue of its own is refused with a line saying so rather
than restored as an empty catalogue. BACK UP opens it too, from the same fresh
state. The line computes to a button with no border, no background, the page's
own font and the hint's own 12px dim grey — a caption that happens to be
pressable. The wall, the strip, the grid, the deep link and the phone's recipes
all pass their checks.
2026-10-01 17:12:29 +07:00
3dtours e2d468b77d web: a scan hands its frames to the strip as it reads them, and coming to the library no longer walks the roll behind the reader
LIBRARY drew the strip from the catalogue and the catalogue from a `getAll` of
the `photos` store: every row and every thumbnail in it, read back on a clock to
learn what a scan of its own had just stored. On a catalogue of 150,000 frames
one read measured 2036ms and sixty megabytes of rows; under a scan in the same
window, with four lanes of RAW bytes and a LibRaw of a quarter of a gigabyte
beside it, that read is the read that gives way — and a read that gave way
answered `[]`, so the screen threw away the frames it was showing. That is the
strip that loses its count and its thumbnails under a scan, and the frames the
reader cannot pick while the roll is being read.

The reading now hands its frames over as it stores them. `scanFolder` keeps the
rows it met for the first time in the batch they landed in, and `flush` gives
them to `watchRows` the moment the transaction closes; the screen appends them,
so the strip is the roll arriving and not the roll found again. A batch is fifty
frames, so this is fifty rows over a callback where it used to be a hundred and
fifty thousand rows over IndexedDB. A frame the catalogue already held is not
handed over — it is on the strip already, and it takes its place again when the
reading is through.

Which is why the read on the clock is now done for none of them. It stands for
the reading another window holds, whose frames never come through this one: a
reading of this window's hands over every frame it stores, and the counter that
says so is what decides. The scan's own tail read went with it — the screen
watching a reading reads the catalogue back once the reading ends, and that is
the whole catalogue read once per scan instead of twice at the end of every one.

`readPhotos` answers null where `listPhotos` answered an empty list. A read that
came back with nothing is a read that failed, not a catalogue that emptied, and
the frames it would have cleared are the frames the reader is working their way
through. The strip keeps them.

And the strip starts on what the last screen read. STUDIO and LIBRARY are two
screens of one page, not two pages: the reader who goes to develop a frame and
comes back was reading a hundred thousand rows again to see the strip they had
just left.

The folder is walked on the way in only when the last reading of it never
finished. A reading that reaches its end clears its position, so "is there a
position" is "was this roll cut off", answered by one small file opened and
shut. Before this, every visit to the library paid a walk of the folder the
reader was opening — a hundred thousand names off the disk, on the folder they
had just asked to browse — and the reader who came to choose a frame paid for a
scan they never asked for. A folder the reader wants looked at again says so
itself, from the menu on its row.

Checked on the running bundle: a 3000-frame scan reads the catalogue back twice,
one of the two the screen coming up on a catalogue that is still empty — against
three for the commit before this one and thirteen for the one before that, and
nothing read back for the whole of the scan. The peak heap is 116-138MB with no
long tasks, and 3001 rows went in. A reading stopped at 59 of 4000 still leaves
`walk/ROLL.json` at 113,626 characters with no local storage key beside it, and
the visit after a reload carries on with the strip filling under it — 162 rows,
312, 463, 612, 762, 913, 1062, 1212 as the scan went on. LIBRARY on 150,000
frames shows no "No folder yet" at any point and settles on "150000 photos" in
4.9s against the 8.1s it took. The wall, the strip, the grid, the deep link and
the phone's recipes all pass their checks.
2026-10-01 17:00:53 +07:00
3dtours 137d54d37d web: a scan watches the catalogue on a clock and keeps its place in a file, not in five megabytes of local storage
LIBRARY read the whole catalogue back on every batch the scan wrote: `reload()`
is a `getAll` of the `photos` store, every row and every thumbnail in it, and on
a catalogue of 150,000 frames one read was measured at 2036ms, twenty of them at
315s with the worst at 58s, the heap behind them going from 44MB to 89MB — beside
a LibRaw open of a quarter of a gigabyte and four lanes of RAW bytes. On a folder
of RAW that is the crash, and on any long roll it is the strip taking the roll's
own time to move.

The read-back now runs on a clock: at most once every five seconds of a scan, and
once more when the reading ends, which is when the last of it is due. A batch
lands every second or so, and reading it back costs every frame in the catalogue
— so a scan that read it back each time spent the roll doing nothing else. The
strip still fills while the scan runs; it fills in steps.

The walk's own position went to the origin private file system. It is one path
per frame the walk has found and not read — about twenty-eight characters each,
measured over a folder of three thousand, which is the 85,626-character write —
and local storage on this origin takes 5,000,000 characters and then throws,
measured the same way. That is a hundred and seventy-five thousand frames: past
it the first write of a long roll fails, the position is nowhere, and the next
visit walks the whole roll from the top. That is the reading that goes back to
the start, and OPFS takes the same text as a file. It goes down at most once
every two seconds, for the same reason the catalogue is read back on a clock:
the string is a few megabytes of paths.

The folder is walked again for being raised no more than once every thirty
seconds. A hundred thousand frames is a hundred thousand stats on the disk, and
this screen is raised every time the reader comes back from the studio.

And the column no longer says there is no folder before it has looked. The note
waited on `folders` alone, which is empty for as long as the screen is coming up:
on the catalogue of 150,000 it said so for the whole of the load and showed the
folder after. It now waits on `loaded`, and says nothing until there is something
to say.

Checked on the running bundle: a 3000-frame scan reads the catalogue back 13
times where the code read it back once per batch, writes the position 21 times,
and leaves the peak heap at 116MB with no long tasks. A reading stopped at 483 of
4000 leaves `walk/ROLL.json` at 101,107 characters naming 3,550 unread frames,
with no local storage key beside it, and the visit after a reload carries on at
585 rather than at the top. LIBRARY on 150,000 frames shows no "No folder yet" at
any point and settles on "150000 photos". The wall, the strip, the grid, the deep
link and the phone's recipes all pass their checks.
2026-10-01 16:34:46 +07:00
3dtours 9b24ce7bbc web: the RECIPES chip hands the phone's bar to the list, not a second row over it — IMPORT .RECIPE stays the tab's own chip, and the path says TABS > PRESETS > RECIPES
On a phone the bar holds one level: the tab's chips, or the strip a chip of
theirs opened. The RECIPES list was the exception — it stood over the bar
with the bar still under it, so the level the finger had just asked for was
drawn twice, once as the list and once as its own row of chips.

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

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

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

Checked on the running bundle (390x844 and 1280x900): the bar holds the
three chips in one row, the list takes the bar's slot under the ceiling
holding looks and nothing else, IMPORT is not among them, the path reads and
walks back, and a wide screen keeps its bar.
2026-10-01 14:53:09 +07:00
3dtours e53d6264ca web: the wall draws the rows under the eye, not the shelf — twenty thousand cards no longer cost seven seconds of dead screen
The strip stopped building every tile two commits ago, but the wall was left holding
every frame on purpose: the grid view was the one list that still made a card per
frame. A card is an `<article>`, an `<img>`, an object URL and a date the browser
formats, and on a roll of twenty thousand the first one did not paint for 7410ms —
19998 cards and 19998 pictures, 7007ms of long tasks, the largest of them 4882ms. The
object URL is only about a tenth of it (83µs each); the rest is the DOM and the i18n
date formatting behind every card.

The wall now draws the rows under its viewport and `WALL_ROWS` either side, measured
from `scrollTop` and the client height on scroll and on resize, and two spacers stand
in for the rows that are not drawn, each as tall as the rows it replaces so the
scrollbar still spans the whole shelf. The step from one row to the next is the
average of the rows in hand rather than the smallest of them: the cards do not all
stand the same height — a caption that wraps makes its row taller (274.34 / 259.36 /
274.36 measured) — and `offsetTop` is rounded to whole pixels, so the least step was
a pixel short on every one of thousands of rows and the scrollbar came up 1436px shy
of the end. The average puts `scrollHeight` back on the number the fully drawn wall
had, to the pixel (1085426).

Measured against a seeded roll of 20000: the first card 7410ms → 212ms, cards drawn
19998 → 25, long tasks 7007ms → none, and the scrollbar unchanged. A check on a roll
of 4000 walks the wall end to end — the last frame drawn at the bottom, the first
drawn again at the top, spacer 215719px either side, `scrollHeight` the same 217076
at both ends, no drift — and coming back to the strip still leaves eighteen tiles.

A card that has a thumbnail and has not been handed its URL yet now keeps its box in
silence instead of saying RAW, since the wall hands pictures out only around the eye;
the word is left for a frame that has no thumbnail to give.
2026-10-01 11:31:24 +07:00
3dtours fb9c9f98db web: the strip draws the tiles under the eye, not the shelf — a roll of twenty thousand no longer builds every tile before the first one is seen
Coming back to LIBRARY froze the screen for a second and a half. It was not the tree
— the tree has been up in under a hundred milliseconds all along. It was the strip:
it made a `<button>`, an `<img>` and an object URL for every frame the open node
held, and an object URL costs about a tenth of a millisecond, which is a second of
blocked main thread on a roll of twenty thousand. Six and a half thousand tiles were
built for pictures nobody had scrolled to.

The strip now draws the run under its viewport and `STRIP_KEEP` tiles either side of
it, measured from `scrollLeft` and the client width on scroll and on resize. Two
spacers stand in for the runs that are not drawn, each as wide as the tiles it
replaces, so the scrollbar still measures the shelf rather than the window: the sum
is the same `140n − 8` in every case. The frame on the stage is given its URL
wherever it sits, since the stage is not the strip.

Measured against a seeded roll of 19998 frames: the first tile 1816ms → 663ms on a
cold visit and 1728ms → 681ms coming back from the studio; the long tasks 1302ms
across five of them → 184ms across one. Tiles drawn 6666 → 18, with the scrollbar
unmoved (the far end draws the far frames, and the frame on the stage keeps its
picture once its tile is out of the window).

The wall is the other view and still holds every frame on purpose; it is the one
list left that builds a card per frame.
2026-10-01 11:03:41 +07:00
3dtours 2c3565cf85 web: the library's strip comes back drawing what it drew — the branch under the open folder, or only the folder, whichever the last visit chose
The strip's one switch, whole-branch or this-folder-only, was a plain useState and
started every visit deep. Every other thing this screen remembers — the open node,
the frame that was up, the column width, the rows left open — is kept by the same
helper under a key of its own; this one was left out. It gets a key, a default that
only a stored 'false' turns off, and the effect the others have.
2026-10-01 10:45:35 +07:00
3dtours fd330c05e0 web: the histogram waits to be asked for, the phone's ruler is a finger tall, and its x answers a real press — a 22px strip under a 44px thumb, a histogram that opened over the photo, and a close button whose press the drag's own preventDefault ate 2026-10-01 09:00:56 +07:00
3dtours 31435aac77 web: the offer to install is made once, and the answer is kept 2026-10-01 07:46:10 +07:00
3dtours 7b6a1a805f web: a knob's ruler stands in the strip's slot, and a sub-chip is its name again
The strip a chip opened kept a row of its own while a knob's ruler opened over
it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip
and the panel under them — and charged the photo a row for the saying. The path
under the bar (.crumb) already names the panel the ruler came from and is one tap
from its strip, so the ruler now stands in the strip's slot and the strip stands
down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px,
COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB >
COLOR TEMP walking back to the strip and then to the bar.

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

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css
  71.42 kB.
- Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB
  open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's
  ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB >
  COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at
  641px. No page error and no sideways scroll (390px of 390px) in any state; on
  1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
2026-10-01 07:25:37 +07:00
3dtours 9aa3424e92 web: a chip's strip stands in the bar, not over it — LIGHT's WB strip sat on top of the bar it belongs to, so the photo paid 40px of a 390x844 screen to repeat where the finger already was
Every tab's bar is one strip of chips, and a chip that opens a strip of its own
opened a second one under the first: WB, TONE, PRESENCE and DETAIL & EFFECTS
kept the bar up while the panel's own strip sat above it. The way back to the
tab's chips is the path under the bar (.crumb), which names the strip the
visitor is in either way, so the bar only said it a second time — and charged
the photo a row for the saying.

So the strip a chip opens takes the bar's slot and the tab's own chips stand
down. Only the two columns that ARE a strip a chip opened displace the bar:
.col-sub[data-col="options"], which is where a panel's strip is drawn (LIGHT's
four, FRAME's tools), and .col-sub[data-col="wm"], a watermark, which is a
column and not a row of chips. Both wear the bar's slot and the bar's own skin —
--bg, not the sub-column's --bg-elev, so in dark the strip paints black like the
bar and not #131315 — and .col-main is dropped while one of them is up. The
RECIPES list, a photo's history and the mask's own column are an open chip's
content, not the strip its chip opened, so they keep the bar in sight under them.

The ruler is unchanged: a knob opens it in the row above, and the strip stays
under it, so the panel is still on screen while the number moves.

Measured on 390x844, LIGHT, light and dark: with the bar up the photo is 641px;
WB open, the bar is gone and its strip measures 73px in the bar's slot, the
photo 608px (568px before, when the bar held its 40px over the same strip);
COLOR TEMP opens its 60px ruler over that strip, photo 548px (508px before),
path TABS > LIGHT > WB > COLOR TEMP; the ruler's "<" lands back on the WB strip
(TABS > LIGHT > WB); crumb-strip and crumb-tab walk back up, the bar returning
its 40px and the photo 641px. FRAME's WATERMARK takes the slot as a column
(131px, --bg in both themes, path TABS > FRAME > WATERMARK) and crumb-tab stands
the bar back up. PRESETS' recipes list leaves the bar at 40px with the list 40px
under it. No page error and no sideways scroll (390px of 390px) in any state; on
1280x900 .dev-panels is painted and .dev-chips/.crumb are not.

Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-kQeKfF3z.css
  71.95 kB.
- Chromium 390x844 light and dark: the strip-in-slot walk above, plus 1280x900
  unchanged.
2026-10-01 07:09:08 +07:00
3dtours c47ce212eb web: LIGHT is four strips on a phone, not four folded rows — the tab's column measured 215px of a 390x844 screen with its panels stacked, and the photo 498px; the strip is 40px and the photo 641px
LIGHT is the one tab whose picks are panels, and on a phone those panels were
being drawn the way a wide screen draws them: four heads stacked down the bottom
bar, each a disclosure for rows that were already there. The bar is a strip on
every other tab, so it is a strip here: .dev-chips now carries the four panels
as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under
860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip
without scrolling.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Skipped: a GUEST who clicks a RAW still loses it across the sign-in — the
handover has run by then and the `?lib=` is gone. Add when someone reports it;
the tier that can open a RAW is the operator's own account, which is the case
this fixes.
2026-09-30 21:24:01 +07:00
3dtours 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 e0b8f14dfc PRO cutoff: an account that signed up before 2026-12-01 keeps the whole studio with its Activated Pro box ticked by the calendar, signups from the cutoff on start basic 2026-09-30 12:22:59 +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