c93f9fdd19b6e2b617f8958e93e856dc25e7be37
375 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 | ||
|
|
31435aac77 | web: the offer to install is made once, and the answer is kept | ||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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. |
||
|
|
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 | ||
|
|
61b1210a47 | TOOLS: AUTO and MONOCHROME come with any account, FIX and GRADIENT MASK stay PRO and answer with the reason | ||
|
|
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 | ||
|
|
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 | ||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
8e330ee28b |
The trial notice walks the width of the bar instead of standing in it
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
426488a788 |
studio: say the roll is still being read while the catalogue is off screen
The reading of a folder belongs to the tab, not to the catalogue screen: it was
made to survive the hand-over to the studio in
|
||
|
|
53cb1c97e9 |
web: a frame is scored under the picture, and the wall is read through the score
The catalogue could say what a frame was and when it was shot, and nothing about
whether it was any good. A reader who had been through a roll of two thousand
had no way to say "these four", and the thumbnail wall drew every frame the open
folder held in the one order it had: newest shutter first, always.
The frame that is up now carries its own score — five stars under the picture,
and the star the click lands on is the score it gets. The star it already has
takes the score back, so a score that was given by mistake is taken off without
a sixth control. It is the catalogue's own row and nothing on the disk is
touched: the file is not written and not read, the browser only stores one more
number against a frame it already holds, and the number is the reader's.
The wall is read through three of them. The score is the first — four stars and
up is the floor, any rating is the wall as it was — because that is what a
reader who has just been through a roll wants next. The year the shutter fired
in is the second, offered as the years the open folder actually holds and not a
century of empty ones. The hours it fired in are the third, as a from and a to:
16:00 to 18:00 is the afternoon the reader was out, and either end alone is
"from 16:00" or "up to 18:00". All three read the shutter time, on this
machine's own clock — the same reading the frame's date line prints, so the
afternoon is the afternoon and not an offset of it. Beside them, the order: the
newest shutter first, the oldest first, or the most stars first.
What the filters hold is the wall and only the wall. The strip under the picture
is the shelf the open folder is, and a shelf that quietly loses three quarters
of its frames is not a shelf any more; the frame that is up has to stay reachable
while the wall is narrowed around it. That is also why the filters are drawn in
the thumbnail view: they are a way of looking through the shelf, not a property
of the roll, and nothing about them outlives the visit.
Verified:
library-check.mjs — 52 steps, all passed, five of them new. The frame that is
up is scored from the row under it and the catalogue holds star 4 against it
(aria-pressed on the fourth star true); the wall narrows to one tile at 4★,
to none at 5★, and back to three with the rating cleared; the year 2016 holds
the two JPEGs of three and 16:00–18:00 the same two, out of a roll whose
frames are written years apart in different parts of the day (the JPEG in the
afternoon of 15 Feb 2016, the RAW at seven in the morning of 1 Jan 2026);
read by score, the scored frame comes first. Every earlier step still holds,
including the two that count what a reading costs and the stand-in folder's
two frames — the JPEG carrying the GPS tag and the RAW printing
"26.4mm · f/2.8 · 1/320s".
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the score is one number on the frame's own row — no rating table, no
votes, no accounts; the catalogue is a per-browser shelf and so is the score. The
filters live in the thumbnail view's own state, so a reload starts with the whole
shelf again, which is what a filter is for. No text search: a roll of date folders
is three levels deep at most, and the three that a reader actually digs with are
the ones here — add the box when a folder of mixed names makes one worth typing
into.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|