4476372b9e624feb0f5f36088e38879a42850511
343 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
477c71d1f4 |
web: the frame under the preview says where it was shot and what it was shot with
A negative is opened to be looked at, and the two things a photographer reads
off it first are the numbers the camera wrote and where it stood when it wrote
them. The stage gave the name, the folder, the date and the weight of the file,
which is what the catalogue knows; the rest was a trip into the studio.
Both are now under the picture. The numbers are the camera's own — ISO, focal
length, aperture, shutter, frame size — printed in the order a photographer
says them, and every one the file does not carry is left out rather than stood
in for: a Fuji RAF gets no line at all, a Panasonic RW2 gets the glass and the
shutter and no ISO, and a JPEG gets the lot. Where the frame was shot is the
GPS it carries, named by the geocoder when one answers and left as the
coordinates it holds when none does — a naming is a network round trip on a PRO
account, and the numbers do not wait for it, so a refusal or a miss costs the
reader nothing.
What this costs is one read of the frame's first few hundred kilobytes, the
same head a scan hands the parser, and only the frame that is up pays it: the
strip walks past a hundred negatives without reading one of them. The read is
held by the read itself and not by the frame object under it, because the
catalogue is read back on a timer while a scan runs, which hands the screen a
fresh object every few hundred milliseconds — a screen that went by the object
would read the same file again and again for as long as the scan lasts.
The check's stand-in folder had no frame of its own with a GPS tag on it, and
none of the samples carries one, so the check writes one: an APP1 segment
holding a GPS IFD and nothing else, spliced in right after the frame's SOI. It
is deliberately the first APP1 — a JPEG carries one EXIF segment and the
catalogue reads the first, so a file that has a place is a file that spent its
EXIF on the coordinates. That is also what makes the RAW the frame that shows a
spec line, and the two frames now check the two halves of the same feature.
The run counts what a reading costs, and a head is not what it counted before:
it counts the whole file a lane develops apart from the head a parser is handed,
because "the RAW was read" is a claim about the scan and the preview legitimately
reads the same file's head.
Verified:
library-check.mjs — 47 steps, all passed, two of them new. The JPEG off the
check's server carries a GPS tag and the page keeps 16.0544, 108.2022 under
the frame, with no geocoder behind it to name them; the RAW prints
"26.4mm · f/2.8 · 1/320s" — the glass and the shutter it has, no ISO and no
frame size it does not. Every earlier step still holds, including the one
that says a scan does not read a RAW whose size and write time have not
moved: whole reads {}, heads {"P1010256.RW2":1,"P1010256.JPG":2}, the one
RAW head being the frame that is up.
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the place is named by the API's geocoder and nothing else — no map, no
picker, no place a reader can type. The coordinates are what the file carries,
and a file that carries none shows no line, which is the honest answer and the
common case for a phone frame with location off. The line is read for the raised
frame only; a grid of hundreds is a list of names, and a row of ISO numbers
under each tile is not what it is for.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
753eea0d7b |
web: a reload in the middle of a reading goes on, it does not start over
The reader opens a roll of a few thousand frames and holds Ctrl+Shift+R while
it is being read — over a reading that takes minutes, that is the one thing
they do. What came back was a reading that started again at the top: every
folder listed a second time, the toolbar counter back at zero, the frames that
were already in the catalogue walked over so that their size and their write
time could say they had not moved.
None of that was in the catalogue, because the catalogue only holds the frames
that were read. What was lost with the page was the reading's position: the
folders the walk had been through, the folders still in its queue, and the
frames it had found and not yet read. The catalogue alone can never answer
where a reading was.
The position is now written to session storage as the reading goes — walked,
pending and the frames in hand — and read back on the way in. Session storage
and not local storage, because a position belongs to the tab: the tab that
reloads carries on from the frame it stopped at, and a tab opened beside it
starts a reading of its own. The key goes when the reading finishes, and goes
with the folder when the folder is removed.
The frames in hand are the part worth spelling out. A frame a lane is in the
middle of, and a frame whose row is in the batch and not yet in the catalogue,
are on neither side of the line the position is written on: the catalogue does
not hold them and the walk will not find them again, so both are written into
the queue and read again. They also were counted when they left the queue, and
the count is written with them — else the reloaded reading would count them a
second time. The counter now carries on from where it was instead of restarting
from zero.
One thing was hidden behind the other: the stand-in folder the check hands the
page had no `getFileHandle` and no `getDirectoryHandle`, so the path that asks
the root for the frames a position names threw and answered nothing — and the
reading then walked the roll from the top, exactly the behaviour the check was
meant to catch. The check's folder answers both now, the way the browser's does.
Verified:
library-check.mjs — 45 steps, all passed, two of them new. The reading is
stopped on the frame it is reading (`check.hold`), the tab is reloaded, and
it comes back at "Scanning 3/3 — 0 new…" — the same line it was stopped at,
not a reading starting over — with the position still holding 4 folders
walked, an empty queue and 3 frames in hand. Listing 3 tiles and letting the
key go, the reading finishes the roll: 3 tiles in the strip, the catalogue
written, the position cleared, and every folder listed once
({"2026":1,"CheckRoll":1,"Empty":1,"04":1}) where a reading that started
over lists each of them twice.
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the position is the tab's, so a reload keeps it and a new tab does
not — the tab that reloaded is the one the reader is looking at, and a second
tab reading the same roll from the top costs a walk and no bytes, because a
frame that has not moved is dropped on its size and its time. The position is
written at the batch beat, so a reload reads at most one batch again, and the
frames in hand are read again on purpose: their rows were never stored.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
d7241531f9 |
web: a reading walks past the recycle bin, not into it
The walk already turned its back on the folders a camera and an editor leave
behind: a name beginning with `.` or `@` is skipped, which is `.thumbnails`,
`.git`, `.Trash`, and the `@eaDir` a Synology writes beside every frame.
An external volume drags along a second set of names, and none of them begins
with a dot, so every one of them was walked. `$RECYCLE.BIN` and `RECYCLER` are
where Windows parks what the reader deleted — frames among them, at full size,
and every one of them decoded into a thumbnail on the way in. `System Volume
Information` is Windows' own bookkeeping. `#recycle` is the Synology share, and
`lost+found` is the directory a Linux volume keeps for repairs. A frame that
comes back out of a bin is a frame the reader threw away, and the pictures in a
share's recycle folder are pictures someone else deleted; neither belongs in
the catalogue, and both cost the reading the same seconds a real frame does.
The folder also stood in the column as a row of its own.
The rule is now one `SYSTEM_DIR` expression over both sets, matched
case-insensitively — the same volume spells the bin `$RECYCLE.BIN` on one drive
and `$Recycle.Bin` on the next — and the walk asks it before it looks at what
the entry is, so a refused folder is neither read nor named.
Verified:
roll-walk-check.mjs — all passed, with two new assertions: the stand-in roll
now carries `$RECYCLE.BIN`, `$Recycle.Bin`, `RECYCLER`, `System Volume
Information`, `#recycle` and `lost+found`, and every one of them holds a
file named like a frame, so nothing about the files can be what keeps them
out. No frame is read from them, and none of them reaches the column.
library-check.mjs — 43 steps, all passed. scan-nav-check.mjs — all passed.
frontend tsc --noEmit clean.
ponytail: the list is the names the volumes being read actually use, not a
guess at every spelling there is — a box that files its junk under something
else gets a line in that expression, which is the whole of the change. A folder
of the reader's own that happens to be called `#recycle` is skipped too; that
name is worth the trade.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
fbe9a1bb5b |
web: a roll is read where it was left, and only for the frames that moved
A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.
The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.
A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):
first reading 5209ms
the same roll again 2887ms 24/36 frames read again
first reading 5320ms
the same roll again 603ms 0/36 frames read again
And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.
The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.
Verified:
library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
three marks, the row menu's four, the add-only menu's one, the refused
folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
itself (three rows back, and the bytes read are the two frames the
catalogue had lost, not the one it still held), and the folder read again
from its own menu. The stand-in folder now carries `__fake` on both handle
kinds — a folder handle that does not is one the screen cannot ask about
after a reload, which is a folder it offers to reconnect — and the frames
it hands out report one write time instead of `Date.now()` per call, which
is what a real handle does and what a frame is skipped on.
scan-nav-check.mjs — all passed, the row still counting the reading as it
comes rather than the catalogue standing still. roll-walk-check.mjs — all
passed. frontend tsc --noEmit clean.
ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
36cd711302 |
web: the header wears the theme, and the rail says which build it is
Two things the studio could not say about itself.
The preset the header has open was painted in the dim text colour, the same grey
as the page name beside it — so the one label up there that changes with the look
read as chrome. It now takes the theme's own accent, the colour the brand mark
and the open tab already carry, which means it follows both the light/dark mode
and the accent group the reader picked, out of the CSS token and with no colour
written into the component.
And an image has no other mark on it: once the frontend tarball is loaded on the
NAS as `:latest`, nothing on the box says which revision came off. The rail now
names the build at its foot — quiet, mono, wrapping rather than widening the
column, and gone under 860px where the rail is the phone's scrolling tab bar
instead of a column.
The name is stamped into the bundle at build time: vite.config reads VERSION
from the environment (`docker compose build frontend --build-arg
VERSION=$(git rev-parse --short HEAD)`) and falls back to the package version
plus the minute it was built, so two builds of the same tree are never the same
name. `ARG VERSION=` in the Dockerfile is the knob; the bare `npm run build` —
dev server, check scripts — still stamps its own time, and the dev server passes
nothing at all, so the line only draws when there is something to say.
Verified:
library-check.mjs — 37 steps, all passed, the two new ones reading the header
off the running studio: the rail foot names the build (0.1.0+202609281504)
and the preset chip's computed colour is the brand's accent, not a grey —
rgb(206, 117, 9) amber, rgb(93, 24, 191) after switching to violet.
scan-nav-check.mjs and roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean. Live 8090 on index-… matching dist/: /, /library and /app 200 with 0
console errors.
ponytail: the name is the package version plus a caller-supplied tag, and nothing
bumps the package version, so the tag is the whole identity — have the release
job write it into package.json if the numbers ever need to mean something.
|
||
|
|
77729ad99a |
web: read the roll through four lanes and a JPEG's header
A scan of 36 real files off the local disk took 8957ms — 249ms a frame — and
the page was idle for nearly all of it: a CPU profile over the walk is 61.5%
idle, so what the catalogue was doing was waiting, not working. Of that time the
bytes and the LibRaw preview are 2656ms and the tile 5761ms, while the EXIF read
is 14ms of the lot. One frame at a time spends the disk's latency and the
decoder's thread on nothing, and a JPEG was read whole — 8MB through a JS array
to find a date in its first kilobyte — then decoded at 24MP to be drawn at 512px.
The walk now runs four frames at a time. A frame that is new is read as a 256KB
header slice unless it is a RAW, which LibRaw has to have whole to seek to the
preview inside it; the shutter time comes off that slice. The tile is asked of
the decoder at 512 on the long edge, so a quarter of the pixels of a 24MP frame
are ever allocated, and the aspect comes from the frame's own SOF header for a
JPEG and from an eight-pixel decode for anything else; the canvas is left to
re-encode what comes back. The column still counts a frame the moment the scan
reaches it, so which frames are up is unchanged, and a frame that has not moved
is still dropped on its size and its time before a byte of it is read.
Measured on the same roll (24 JPEG 8.2MB + 12 RAW 22.6MB, two levels deep,
served over loopback with no added delay):
one frame at a time 8957ms 249ms/frame
four lanes 5474ms 152ms/frame
+ the header slice 4793ms 133ms/frame
Six lanes came out worse than four (5259ms) and is not what this does: past a
few the disk and the decoder are the limit.
Verified:
library-check.mjs — 35 steps, all passed. scan-nav-check.mjs and
roll-walk-check.mjs — all passed.
frontend tsc --noEmit clean. Live 8090 on index-CdakqRi8.js matching dist/:
/, /library and /app 200 with 0 console errors.
ponytail: the lanes run on the main thread, and a RAW still reads whole per file
— a few LibRaw opens at once now — so a 5000-frame folder is still lumpy; move
the walk into a worker and a RAW's preview onto a sync handle when that is real.
A frame smaller than 512 is now scaled up to it rather than drawn at its own
size, which is invisible at the 132–220px a tile is painted at; keep the old fit
if a print is ever taken off a tile.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
134489a5ec |
web: come back to the frame that was raised, and zoom it by the wheel
Two things the preview owed a reader. The frame raised in the strip outlived nothing: leaving for the studio and coming back dropped the choice and the node opened on its first negative instead. It is now remembered beside the folder and the column width — the same one-line store, read on the way in, written on the way out — and a frame that is gone from the catalogue falls through to the first, the way a node that is gone falls back to its folder. A wheel over the frame now magnifies it. The listener goes on the element rather than through onWheel, because React's own wheel is passive and this one has to hold the page still while it zooms; the origin is the point under the pointer, which is what keeps a reader's aim on the thing being looked at, and the step comes from how far the wheel turned so a notched mouse and a trackpad travel the same. Six times is where it stops — the thumbnail behind the preview has no more pixels to give — and a frame that has just come up is fitted again. library-check: 35 steps, the two new ones raising a nested frame, reloading on it, and ticking the wheel up and back down. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
067b32131e |
web: drop the heading over the folder column
The column carried the name of the folder its tree belongs to, painted above the rows. The rows already say it — the head of the tree is a row like any other — so the heading only said it twice, and because it was not a row it stayed on screen when the tree was folded away: the one label left with nothing under it. The right click that raised it now finds the row itself, which is where a reader aims anyway. The label, its menu hook, its translation and its rule go; the tree, the row menus and fold-all keep working as before. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
5e28b42067 |
web: fold all keeps the top-level row open
COLLAPSE ALL folded the top-level row as well, and that row is already its own
fold: one click on its name shuts it and takes the whole subtree with it. Folding
it from the menu hid every row of the tree — the item repeated a click the reader
already had, while the one thing a click there cannot reach, the folders nested
below, was the part that got lost with it.
So the item folds the folders nested below the top level: every row with a
subfolder under it that is not itself a folder picked at the top level. The
top-level row stays drawn and stays open, its own subfolders are drawn folded,
and no top-level row ever leaves the column. `nested` is computed off `parents`
once, and the item is disabled on a folder with nothing nested under it.
Verified:
library-check.mjs — 35 steps, all passed. The fold step now reads the rows the
click leaves behind instead of a row count: the fully open roll (4 rows)
becomes exactly CheckRoll aria-expanded=true, CheckRoll/2026
aria-expanded=false, CheckRoll/Empty (a leaf, so no aria-expanded) —
CheckRoll/2026/04 is away, the top-level row is not — with the strip's 2
tiles unmoved and the row menu still leading with lib-menu-collapse.
frontend tsc --noEmit clean. Live 8090 on index-DAIpyDNV.js matching dist/:
/, /library and /app 200 with 0 console errors.
ponytail: with several folders picked, one COLLAPSE ALL folds the nested rows of
all of them, not only the row that was clicked — no top-level row is hidden
either way; scope it to the clicked folder's prefix when a column routinely
holds many picked folders.
|
||
|
|
4a740b1e28 |
web: fold the tree from the row a reader right-clicks
COLLAPSE ALL lived only on the column's own name — the label above the tree —
and that label scrolls with the list it heads: on a roll read deep the column is
scrolled past it, so the item looked like it only appeared once the top-level row
had been folded to bring the list back to the top. The row is where the pointer
already is, and it already carries the folder's menu.
The top-level folder row now leads its own menu with COLLAPSE ALL — rename,
rescan and remove follow it, and a subfolder row is unchanged, since folding a
whole tree is what a row that a tree hangs from can do and a row inside one
cannot. Both entry points run the same one: `root` on the menu state now means
"the head of a tree", the column's own name or the row of a folder picked at the
top level, and the menu draws the item first and then whatever the subject
itself has — the folder's items, or, on the empty part of the column, ADD
FOLDER. The empty part keeps ADD FOLDER alone: it is not the head of a tree.
Verified:
library-check.mjs — 35 steps, all passed. The new step right-clicks
lib-node-CheckRoll with the tree fully open (4 rows) and gets back exactly
[lib-menu-collapse, lib-rename-CheckRoll, lib-rescan-CheckRoll,
lib-drop-CheckRoll]; a right click on the column's name still answers with
lib-menu-collapse alone; the subfolder menu above is still the three folder
items with no fold in it; the empty column still offers lib-menu-add alone.
The fold itself is unchanged and still measured by opening the root back up:
4 rows → 1, and 3 back, with CheckRoll/2026/04 still away.
frontend tsc --noEmit clean. Live 8090 on index-DEvUJDNY.js matching dist/:
/, /library and /app 200 with 0 console errors.
ponytail: the column's name is still the second way to the same item and still
scrolls with the list — left alone because the row is now the one that matters;
make the name sticky when a roll routinely fills the column past one screen.
|
||
|
|
c1ee2cf30a |
web: fold the whole roll from the head of the tree
A roll read to the bottom of its dates is a long column of indented rows, and
the only way to shut it was one row at a time — the click that folds a row also
opens it, which is the right trade for reading and a poor one for putting away.
The column's own name is now the whole tree's control: a right click there opens
a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row
that has something under it at once — the root's own row included, so the tree
comes back to one line per picked folder and the subfolder two deep is away with
the rest. The menu is the folder menu's shape at a third subject rather than a
second menu: one `root` flag on the state that already knows where a right click
landed, and the same Escape, same click-away, same clamp to the window edge.
The frames do not move with the rows: folding is a way of looking at the tree,
and the strip already reads the open folder, which the fold leaves alone. The
name keeps reading as the folder it belongs to — the folder's label, with the
hint appended to its title — and the item is disabled rather than hidden when
nothing in the column has children, so a fresh folder does not open a menu with
a dead line in it.
Verified:
library-check.mjs — 34 steps, all passed, 2 new: a right click on the root
name offers exactly [lib-menu-collapse] while the name still reads "Roll A",
and one click takes the fully open roll (4 rows: the picked folder, its
subfolder, the subfolder under that, and one holding no frame) to a single
row. The fold is measured against the ruler of opening the root back up:
the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty —
CheckRoll/2026/04 is still shut, which is what says the deep row went with
the fold and not just the root. The strip held its 2 tiles throughout, and
the folder the visit was on (lib-node-CheckRoll/2026) is what the reload
below still reopens on.
scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed.
frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js
matching dist/: /, /library and /app all 200 and 0 console errors.
ponytail: only folding, no unfold-all to match — the click on a row already
opens it and the root's own row is one click away; add the pair when the column
routinely holds many picked folders and opening them one by one stops being
cheap.
|
||
|
|
291968a342 |
web: give the hero's award column a frame on a quiet week
The landing's award column read two windows of the clock — today's votes and
this ISO week's — and both of them empty meant no column at all: no
[data-key="lp-award"], and the hero back to a single block of copy. That is the
normal state of the install, not an edge case. On the live data the newest vote
is 2026-09-22T09:48 and this week's window opened 2026-09-28T00:00, so
GET /api/highlights answered {"day":[],"week":[]} with HTTP 200 while
/api/photos handed out fifteen frames: the query was right and the clock had
simply moved past every vote.
So /api/highlights grows a third list, `ever`: every vote ever cast, top-rated
first, computed only when both windows come back empty — a day with one vote in
it pays nothing for the extra query. The column draws day, then week, then ever,
deduped by photo id, so a frame keeps the tighter claim it won. The label
follows the frame: `ever` claims no window — "Most-loved recipe" / "Công thức
được yêu nhất" — because dressing a month-old best as today's is a lie the page
does not have to tell. A vote in the window still reads "Recipe of the day" or
"Recipe of the week" exactly as before.
Verified:
backend npm test — 183 passed, 0 failed. Three new checks in the ratings
section: a vote older than both windows leaves them empty, the all-time
bests stand in for them, and with nothing ever rated there is no frame at
all. The aged vote is backdated in the throwaway database, because both
windows are the clock's and no API path can cast a vote in the past.
Live: GET /api/highlights returns `ever` with five frames (ids 12,13,51,50,3,
every one avg 5, n 1) and 8090's landing draws .lp-award 440px wide beside
the copy — 5 slides, 1 standing, 2 arrows, label "Most-loved recipe", score
"5.0★1 vote", meta #LC_LANDSCAPE_VIVID. The column stepped slide-12 →
slide-13 across one 5s clock, and the page logged 0 console errors.
frontend tsc --noEmit clean; the served asset index-DPZxk4Vo.js matches dist/.
ponytail: the fallback is a flat all-time list, not a walk back through the
weeks to the newest one that has votes — the query stays one line and the label
stays honest; when the column should also read "Recipe of last week", walk the
windows back instead.
|
||
|
|
1cb2618fd7 |
web: count a roll as it is read, and open a frame without losing the count
Two holes left by the last change. A frame is opened with `window.location.href` rather than a link, so the studio was still handed a fresh page and the scan with it — every route out of the catalogue now goes through `go()`, which pushes the address instead of reloading while a scan is in flight. And a row counted what was filed away rather than what had been read, so it sat at zero for the whole of a first scan: the catalogue's frames only reach IndexedDB in one batch at the end. `ScanProgress.counts` carries the frames the scan has reached, per row, off the scan's own bookkeeping, and the column reads it while the scan runs — the row under the reader's eye moves as the roll is read, and the toolbar's line stays the whole picture. A re-read counts from the start, which is what it is doing. The regression check grows the two: a frame opened mid-scan from the catalogue (of a roll it already holds) has to stay in the page, and the row has to count. |
||
|
|
79b0d0db86 |
web: keep reading a roll while the studio is up
The catalogue hands the visitor to the studio with a plain `<a href>`, and the app has no router: following it threw the page away, so a scan that was halfway through a roll died with it. A scan belongs to the tab, not to the screen that started it. The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`, watched by whoever is up), so the catalogue can unmount and come back to a reading that never stopped; a screen that returns joins it and sees the toolbar line, the stop button and the rows filling in. The internal links are taken over for a history push *only while a scan is in flight* — everywhere else the browser navigates exactly as before, so the landing-to-studio flow is untouched. `scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time off a slow server, handed to the studio mid-scan, back to the catalogue, and the whole roll read to the end with the studio up, counting documents along the way. |
||
|
|
3aedd67ca0 | web: open the catalogue without a version another tab can block | ||
|
|
52732a17ee | web: name a roll's folders before reading their frames | ||
|
|
d0fddba1e8 | web: walk a roll layer by layer, and let the strip drop the branch | ||
|
|
bd206a8be9 | web: let a wheel tick walk the strip sideways | ||
|
|
2c78ff80e8 | web: fold a folder's rows without folding its frames | ||
|
|
2b45419ec4 |
web: scan the whole roll, title and size the column, reopen where you left
The tree column now walks every folder under the one you picked instead of its first level, so a roll of dated folders inside dated folders is walked to the bottom. A folder being read turns a ring on its own row, the column is titled with the folder the tree belongs to, and a divider beside it drags the width, which is remembered along with the folder the screen was left on. |
||
|
|
4c65923eff |
web: rename a folder, and add one from the empty column
A folder's own menu gains a rename, which paints a label over the folder without moving it — the frame ids and the recipes hang off the directory's name, not the label. The empty part of the column answers a right click with ADD FOLDER, the FOLDERS heading goes (the count already rides on each row), and the column narrows to 118px so the stage takes the room. |
||
|
|
19fd5b644f |
web: trim the library to one toolbar and a folder menu
Fold the hint, ADD FOLDER and the two view switches onto a single row, with the switches drawn as icons, and move a folder's rescan and remove onto a right click — they belong to the folder, so they are asked for rather than always on screen. |
||
|
|
12936731b3 |
web: keep the catalogue on one screen
Pin the thumbnail strip to the bottom edge of the window, let the wall of thumbs scroll inside the stage with its own scrollbar, and letterbox the preview so the frame fits the screen instead of growing the page. |
||
|
|
d1abda141a |
web: browse the catalogue as a tree of folders
The library page was a flat wall of thumbnails. It now reads like the admin pictures tab: a folder tree on the left, the picked node's frame on the stage, that node's frames in the strip below, and a chip pair to swap the stage for a wall of every thumbnail in the node. Folders are walked recursively (dot/@-prefixed names skipped, an unreadable subfolder is dropped rather than failing the scan), so a roll with a dated subfolder keeps both levels. Node counts include the subfolders underneath. |
||
|
|
7ba1e64e7c | docs: record the local-catalogue decision, and what it is not | ||
|
|
81637b8502 |
web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library now takes a folder through Chromium's directory picker, keeps the handle in IndexedDB so the folder is there on the next visit, and walks it into a grid: one thumbnail per frame, the frame's own date, and the recipe it was last graded with. Nothing is uploaded and nothing is read twice — the RAW itself is opened only when a tile is clicked, at which point the studio develops it and the recipe comes back on top. The studio files every change back against the frame, debounced, so reopening a RAW is not doing the grade again. Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and a copy where a develop is a full decode of every pixel, and off createImageBitmap for anything else. A RAW with no preview inside it gets a placeholder tile rather than a minute of decoding per file. node scripts/library-check.mjs ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2 ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview) ok studio developed the frame from its handle ok the address was handed back — url=/app (no ?lib= left behind) ok the look was filed back against the frame — baseFilter=none, 19 knobs ok the tile says the frame is edited |
||
|
|
0f2e109aa2 |
web: make the studio installable, and give it a shell that opens offline
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.
The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.
The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.
node scripts/make-icons.mjs 192x192 39785B / 512x512 159296B / maskable 512x512 123723B
node scripts/pwa-check.mjs manifest 3 icons · worker activated · shell cached
· offline reload of /app paints
off (https://localhost:8090) same four, through nginx
|
||
|
|
3312facd82 |
docs: write down the camera port and the live-view plan
CAMERA_PORT_ANDROID.md lists only what the web build has that Android does not, one row per gap, with the web file to read next to the Android one. The plan alongside it is the sketch for a live camera with a Skia filter on the frame thread. |
||
|
|
8dc409777e |
web: keep the probe that develops a raw in a bare page
A page with no React, no store and no router around it: hand /src/engine/skiaShim.ts a file, develop it, and read the plane back. It is the shortest path to the answer when the stage looks wrong and the question is whether the engine or the app around it is at fault. |
||
|
|
e24ea9e71d |
web: open the stage on the neutral stock, not the CLASSIC NEGIPES preset
The boot recipe was DEFAULT_RECIPES[0], so every file that landed — NEF or JPEG — came in with exposure +2 (+0.5 EV), contrast +1, saturation -1, 6300K / tint +2, highlight -2, clarity +2, grain +4 and the classic-neg filter already on. On APACHAI-20160215-357.NEF that read as pink skin and blown highlights, and it made a correct decode look like a decode bug. Develop itself was fine: 179.0/168.2/171.2 against the embedded preview's 178.2/167.1/170.7. Boot is now BASE_RECIPE — no filter, all knobs default, the same look COMPARE splits against. A session left by the old boot holds that preset verbatim, which was never the visitor's pick, so it is dropped; anything the visitor actually touched moves the stored object off the exact fingerprint and is kept. Render bytes, same 1600x1068 view of the JPEG (R/G/B means): source file 170.7 / 159.1 / 163.1 before 194.7 / 178.0 / 177.2 after 169.5 / 158.5 / 162.0 NEF render stage: 202.4 / 186.3 / 185.4 -> 177.9 / 167.3 / 169.9 Output is byte-identical to the old neutral render (md5 a3932746...), while a clicked chip (classic-neg-default) and a tweaked session both still restore their own bytes. |
||
|
|
d1e9425b9c |
web: read a frame's white off its pile, not off its largest sample
sensorWhite hung its nine counts of window off the plane's largest sample. A hot pixel sits hundreds of counts above the level the sensor stops at, so on a frame that carries one the window held the stray alone, found no pile in it, and handed the develop the factor two instead of the frame's own level. Measured on a Panasonic DMC-LX10 RW2: one sample at 15993 and one at 14665 over a pile of 9,594,544 at 13855. The frame's level is 1.75x the fall-back, so the develop opened 0.81 of a stop bright, clipped the sky the sensor had held to flat, and the fit to the camera's preview could only pull the exposure back after the highlight detail was gone. Against the camera's own JPEG of the shot, mean |dL| 23.48 and dRGB +8.57,+0.90,+4.90 became 19.14 and +5.32,+1.89,+2.04, and the level itself 7908 (gain 8.2872) became 13874 (gain 4.7236) against the 13855 the plane piled at — 0.14%, 0.002 of a stop. The level is now the highest count the plane piled at, off a whole-plane histogram: `floor` counts is a pile, and the same cliff rule as before still has to hold over the count below it, so a smooth bright sky is left alone and a frame that has not clipped still falls back on the factor two. The same stray was in four of the ten bodies to hand. A Nikon _GDN0447.NEF read the fall-back 8190 where its plane piles at 13806 (gain 8.0018 -> 4.6022, 0.79 of a stop), a Fujifilm RAF 29696 where the documented level is 30993, and a Sony ARW 31742 against the 2.002x the body's ratio was measured at. Six were untouched, and a Canon CR2 develops byte-identical through the change — the window moves only on a frame whose largest sample is not its highest pile. scripts/white-level-check.mjs keeps the LX10 shape: a plane piled at 30995 with a stray above it has to answer 30995. |
||
|
|
e6c050581f |
web: make AUTO write the tone and the colour it reads off the frame
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.
EV the mean luma, unchanged
HIGHLIGHT the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
SHADOW the bottom 1% (p01 < 0.02 opens up), to +5
TEMPERATURE/TINT the gain that puts the three channel means on each other,
green as the anchor: gray-world on linearised means, then the
closest of 76 temperatures x 21 tints under the renderer's own
kelvinToRGB, so the pair cannot drift from what the ruler applies.
The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.
Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.
scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
|