A HEIC is the one camera file no engine on this platform will paint:
Chromium answers createImageBitmap with InvalidStateError and its
WebCodecs take no hvc1 still, and unlike a RAW there is no JPEG preview
inside to lift. It is decoded in software instead, by libheif built to
wasm, in a worker of its own — so a folder of them reads without the tab
stopping for a second, and what leaves the worker is the same JPEG
developRaw hands the studio. Nothing downstream knows the difference.
Into the studio it goes on the same terms as every other frame: dropped,
picked, or opened from the catalogue, no tier asked and the engine's own
colour untouched. The frame's date, ISO and position come off the HEIC
itself, which is passed along as the bytes the stamps read.
The catalogue's tile does not pay the decode: a phone writes a small
HEVC copy of its frame as an item of its own (thmb, 320x240 on an
iPhone), reachable only by walking the container by hand, and it decodes
in milliseconds where the frame takes a second — from the same 256KB the
catalogue already reads for a header.
The shelves themselves now want an account: a guest gets the note and
the way in, and the scan never starts.
The library stage carried no way to correct a frame shot on its side, and
nothing kept the correction. A quarter turn now rides on the catalogue row
(`rot`, beside `star`, carried over by a rescan), the two chips under the
preview step it, and the tile, the strip thumbnail and the stage all read it
through one bake so a frame is never stood on its side in one place and
upright in another. The studio opens a frame at that angle and a turn made
there is filed straight back onto the row, so either end of the turn is
what the frame comes up at next time.
A folder row now asks for the rest of a roll, the whole of it from the top,
or takes its request back. Requests queue behind the reading in flight and
run one at a time; a row waiting its turn draws a ring that does not turn.
BLACK at full deflection returned a soft picture, and on a monochrome frame it
returned the blurred base outright. The tone pass reads the ramp at the
neighbourhood's base and then rebuilds the pixel, and it rebuilt it by the RATIO
the base had moved by — `Base' * (Input / Base)`, the other half of
fix_shadow.md's decomposition. A ratio is a gain, and that gain is a function of
the neighbourhood: the knob that takes the base toward zero scales every pixel's
detail by the same coefficient, so the knob that darkens the frame takes its
texture with it. Measured on lightroom_shadow.jpg at 1024px through tone-sim.mjs,
the pass in plain JS with the shader's own constants: at BLACK -100 the finest
gradient came back at 0.75 of the input under the ratio and at 0.98 under the
sum, while the frame darkened the same either way (mean 0.416 to 0.359 both). A
monochrome stock is the whole frame of that error, because all three channels ARE
the pixel's luma there, which is why the picture came back as the base — soft,
and short of every edge it had.
The reconstruction is `Base' + Detail`, ADDED and not scaled, and it costs
nothing where there is no move to make: with every knob on zero the ramp at the
base IS the base, so the difference is exactly zero and the pass is the identity
however coarse the base is. A caller that hands in no neighbourhood at all — a
mask — hands in the pixel's own image as its base and gets the global move back,
which is what the ratio gave it too. `o` is held inside the cube before the
detail is added, so a neighbourhood the ramp has pushed under the floor keeps the
structure around it instead of carrying its pedestal down onto every pixel in the
region. The bright side of the same move is untouched: the lift is still the
neighbourhood's, and at SHADOW +100 the band above the lifted knot still keeps
0.81 of its spread where the global move kept 0.42.
CLARITY drew a light stroke down every contour, and the reason was in what the
blur called a neighbour. The range weight was the colour difference,
`exp(-dot(d, d) * 24)`, which is loose on any coloured edge — two sides of a hair,
a branch or a rail can share a red and differ in green — so the reference reached
across the edge, and the reference is exactly what the blend subtracts. The wider
it reaches, the more a contour reads as detail. It reads LUMINANCE now, one
decision per tap at the doc's own scale (`CLARITY_RANGE_SIGMA` = 0.04), so an edge
of any hue stops the blur dead.
The blend moved the three channels by their own differences, which is what
coloured fringing along every contour was, and the amount it moved them by was
the MASK's `CLARITY_GAIN` borrowed for a different child. It moves LUMINANCE now
— one value carries the whole pixel back with it, so hue is untouchable and skin
does not go sallow at the top of the knob — under the doc's midtone weight
M(L) = 4L(1-L), which deepens the greys a picture is made of and leaves the burnt
ends and the deepest shadows where they are. The positive side's gain lives
inside the shader (4.5, raised from the doc's 1.8 because the range weight above
reaches less far and carries less detail): clarity-halo.mjs, which measures the
pass pushing a pixel outside the range of its own neighbourhood, reads a bright
stroke of 55.3/255 on lightroom_shadow.jpg and 54.3/255 on DSCF1701.JPG at +10,
against the old pass's 145 and 127 at the same knob — and 8.0 already reads 98, so
the knob does not need to go further to keep the flat areas moving.
`exportEngine` passes the knob's own units now, `[clarityKnob / 10]`, since the
gain and the sign are the shader's business and the mask's gain is not the
frame's.
And a backup folder the browser had stopped letting the page write to wrote
nothing and said so, once, with no way back: a permission outlives the tab only
while the tab does, so the row kept reading a permission of a moment ago while
the write went to the disk without one. A refusal that names the permission, or
the folder that is not there, now goes through the picker ONCE — the picker is
the only thing that hands a folder back — and the run is repeated; anything else
is the folder itself saying no, and is not asked twice. The sentence on the
screen is one line over a strip of photographs and cannot carry a reason, so the
reason goes to the console and a word of it into the note (`{why}`, the name the
browser gave the refusal and never a stack), which is the whole of what tells a
folder that was moved from a permission that lapsed. Both dictionaries learn the
word.
Checked: `tsc --noEmit` clean; `tone-base-check.mjs` and `highlight-knee-check.mjs`
updated to the new reconstruction and passing; `tone-sim.mjs` on
lightroom_shadow.jpg at 1024px for the numbers above; `clarity-halo.mjs` for the
stroke.
The menu that hangs off a folder is a menu read mid-scan, with the eye still
moving down the column, and it was drawn to the measure of a page rather than of
a list: a box wide enough to be a destination, type the size of a heading. A
catalogue's own context menu is a thing passed over on the way somewhere else —
it is sized to its words and to the row it hangs off, and the reader's eye steps
across it without stopping. The type comes down a point, the rows tighten to the
tree's own measure, and the frame around them thickens to a radius the rows
themselves use. The clamp that keeps it in the window follows the smaller box,
so a menu opened near the right or the bottom edge no longer floats an inch off
the pointer for room it does not use.
A row that is being read has exactly one thing to be asked of it — that it stop
— where a row at rest has the reading that brings it up to date. The menu said
RESCAN either way, greyed while a scan was up, which is a command offered and
refused in the same breath. The two never both apply, so the menu now carries
the one the row is actually in: a roll being read offers STOP SCAN and stops
that roll; a roll at rest offers UPDATE... and reads it again. A row with
folders under it grows a third thing — a fold that closes the branch and not
merely the row, because a reader who is done with a tree means the whole of it
shut, and closing only the row they happened to point at leaves the children
standing open underneath it.
The last is a matter of what a row is allowed to claim. Every row of a roll
spanning the tree spun while that roll was being read, so a reader looking at a
tree of a hundred folders saw a hundred rows each insisting it was the one being
read, when the reading had been pointed at one of them. A row now says it is
being read only when it is the row the reading was kept to — the folder it was
pointed at, which for a whole roll is the roll's own row. A tree that says all
of itself is busy when one branch of it is says nothing a reader can trust.
RESTORE was dead on a machine that had never written a backup: the button was
`disabled` while no folder was remembered, and nothing in the handler could ever
pick one, so the reader who had carried a copy over on a stick had no way to
point at it. The button now stands, and the folder it restores from is whatever
the reader picks in the dialog that opens — which is the whole of the choice
there is, because a backup is a folder of rows and tiles. The folder on the other
disk, the one made before the edit: the folder is the version.
BACK UP asked for a folder only the first time one was ever chosen. After that
it wrote into it in silence, and after a restart — when the browser takes the
permission back, since a permission outlives the tab only while the tab does —
it wrote nothing and said so, which is a backup that quietly stops happening.
It now picks whenever it cannot write, and the picker is the only thing that can
hand a remembered folder its permission back; a permission asked for with no
picker behind it is one the browser may refuse. The run that follows a scan is
untouched: it is guarded by the permission it would be asking for.
And the folder is a control, not a caption. The line that names it — with the
frames in it and when they were written — is what tells one backup from another,
and pressing it is how a folder is picked, a name given, a permission handed
back. It keeps the hint's own look through five lines of CSS, so the row still
reads as a caption and not as a third button.
What the line says is now read out of the folder rather than out of this
browser's memory of it. `pickBackupFolder` reads the catalogue file inside the
folder just picked and takes its count and its date as the row's own; a folder
with no catalogue of its own has none, and the row says so instead of showing
the numbers of the folder next door. That is also what the confirm names before
a restore — the frames and the time in the folder just picked, which is the
thing about to be restored. `restoreNow` already read that file; this reads its
header first.
Checked on the running bundle with the picker stubbed, since the dialog is one a
probe cannot press: with nothing remembered, RESTORE now opens the picker where
it did nothing at all, the row names the folder the picker returned, and a
folder without a catalogue of its own is refused with a line saying so rather
than restored as an empty catalogue. BACK UP opens it too, from the same fresh
state. The line computes to a button with no border, no background, the page's
own font and the hint's own 12px dim grey — a caption that happens to be
pressable. The wall, the strip, the grid, the deep link and the phone's recipes
all pass their checks.
LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The
recipe is the one the file already used for the stage's toolbar: font-size 0
takes the word out of the paint, never out of the button, so every accessible
name is exactly the word that is no longer drawn, and a ::before carries the
glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content
measured 990px wide with the words and 716px with the icons, and the bar it sat
in measured 135px tall on a 390px screen before this and 88px after: three rows
to two.
RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it.
EXPORT moves to the end of the <header>, after the three menus. It is the end of
the job and now the end of the row, which is the button the eye should land on
once the edit is done.
SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is
a word in a box too small for it, and the count that shares the label has no room
either. The button keeps its title, and the count is the one thing the phone
loses here — the wide screen still counts.
The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page
never scrolls (the stage does), so nothing moved before this and nothing moves
now — but that is a promise the layout can keep rather than a coincidence of who
scrolls. `.adm-bar` already carried the same three lines.
The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and
28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes
6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px.
This commit also lands the strip work from the session before it, which was
written and probed but never committed: under 860px the rail of tabs and the
tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip
stands the rail back up, the open chip's sub-chips stand over the bar as 60px
tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with
the bar above, so the phone's block lands in one piece.
Checks:
- npm run build (tsc --noEmit + vite) clean.
- The ten browser-free image checks (tone-base, highlight-knee, half,
white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table,
mask-wb) all pass.
- Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header
sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key
export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before;
the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside
the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0;
strip-open keeps headerY 0 with the rail display:none; no page errors.
- Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row
50px, the same four buttons at 14px with no ::before, EXPORT last.
Skipped: no new aria-label — the words the icons replace are still the buttons'
own text, so nothing needs one. Add one only if a label leaves the DOM.
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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
The studio took JPEG, PNG and HEIC and nothing else, so a photographer's own
negatives never reached it. A RAW now loads the way any other file does —
`isRawName` reads the extension off a 24-entry list, the file goes into OPFS
under one slot (`current_image.raw`, beside `current_image.name`, so a reload
finds it again) and `rawDevelop` runs it through LibRaw-wasm: half size, 16-bit
output, camera white balance and the camera's own 3x3 matrix, in bands of 2M
pixels so a 30MB file never holds a second copy of itself. `example-sony.ARW`
(30.3MB) lands as a 3120x2084 picture, no page error.
DEHAZE joins the FX tab, where Lightroom keeps it: a chip off the same
PARAM_DEFS entry (`dehaze`, 0..10) so nothing new renders chips, and the pass is
the dark channel prior — `atmosphericLight` reads A off a 32x32 draw of the
photo, `DEHAZE_SKSL` takes omega up to 0.95 over a floor of 0.1 — measured at
71.8% of the stage's pixels moved between 0 and 10.
The gradient mask grows the six knobs the phone's has: HIGHLIGHT, SHADOW,
WHITE, BLACK, CLARITY and DEHAZE. The mask's falloff is a smoothstep rather than
a line, and CLARITY/DEHAZE inside a mask get a blurred copy of the photo plus
the air A as a second child of the mask shader — so a mask's clarity is clarity
and not a flat brightness lift. The column shows all nine rulers; CLARITY 9
moves 42.2% of the stage, DEHAZE 9 moves 27.9%.
CLARITY stops reading the whole photo per pixel: the single pass that sampled a
15x15 box 225 times is now the three passes the same math wants — 1x15, then
15x1, then a blend, `orig + (orig - B) * 3.2` — about 30 reads. Both signs work
(77.4% of the stage moves at +10, 79.6% at -10), and the negative branch keeps
its mist as it was.
The pointer reviews a look before it is taken: resting on a PHOTO STYLE chip or
a recipe chip lays that look on the photo while it stays there and gives it back
the moment it leaves — byte-identical, measured on four of them (24.9%, 23.8%,
24.5%, 25.3% of the stage moves on, 0.00% off) — while the recipe, the UNDO
stack and the session stay on the look the click left. A hovered look brings its
colour alone: the masks, the dust spots and the mosaic of the photo being edited
ride along, or a pointer crossing a chip row would rub them off. A PRO sim is
left out, since a hover that showed its look would hand over what the click
gates.
Probes: e2e-raw-verify, e2e-dehaze-mask, e2e-mask-verify, e2e-clarity-verify,
e2e-hover-preview2.
The one path where the look is chosen before the picture exists: OPEN CAMERA
grades the camera's own feed with the recipe in force, many times a second, and
the shutter hands the studio the sensor's still under that same recipe. Preview
and file differ in resolution only — the still is `takePhoto`'s own frame, not a
copy of the small preview video, with `grabFrame` and a 2d copy of the element
behind it for the browsers that ship no ImageCapture.
The renderer gains two inputs for it: `sourceImage`, a picture the caller
already decoded (re-encoding the camera's frame to JPEG only to decode it again
would cost more than the whole render), and `drawTo`, which paints the finished
picture instead of encoding it. One render is in flight at a time; a frame that
arrives during one is dropped, so a slow device shows a lower frame rate rather
than a queue of moments that have passed.
The view flashed black on a phone. Setting width/height on a canvas resets its
bitmap: measured on the preview, a resize leaves mean 0 until the next render
lands, which on this box is 0.5s and on a phone more. The buffer was sized from
every incoming frame, and a capture that renegotiates its resolution — which
Chromium does when the page is too slow to consume its frames, and this pipeline
runs ~2 fps at 720p under software GL — strobed black/picture at every switch.
The buffer is now sized on the first frame and after that only when the frame's
aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720
stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no
black frame, and 640x360 renders at 6-13 fps instead of 2.
The frames are read from a <video>, which is now IN the document (1px, behind
the black backdrop) rather than detached: Safari draws blank frames from a
detached video, which is the same black-between-pictures. It leaves the document
with the view, and the tracks are stopped, so the camera light goes out.
Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim
reaching the live frames, shutter into the studio, close, console clean),
cam-renegotiate (no resize, no blank frame, status line on the frames),
cam-close-flip (flip returns a picture; video gone on close).
A new BACKUP tab downloads the deployment's whole state — the SQLite file
and both media folders, photos included — as one .tar.gz, and takes the same
file back. That one artefact therefore does both jobs: the operator's backup
and the data package that moves an install onto another box.
The database is snapshotted through SQLite's own backup rather than copied,
because the file is written to while the archive streams; the media folders
are tarred straight off the volume, so no second copy of them is made.
A restore replaces the data on disk and then exits — the container's restart
policy brings the API back on the restored files, which is the only moment the
open handle can be dropped. The state being replaced is tarred aside first,
and the archive is checked for `..` entries before anything is unpacked. The
API authenticates that route before it reads a byte, and nginx lets that one
path past the body cap which holds everywhere else.
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.
Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.
`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.
The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.
On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.
The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.
Verified:
backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
drives the route end to end against the source: signup leaves a six-digit
code beside the link, a wrong code verifies nothing and leaves the account
unproven, a five-digit body is refused, a signed-out caller cannot type one,
the mailed code verifies, spending it spends the link, five wrong guesses
lock the code out and the right code then does not help, a resent letter
hands out a fresh code that is not locked out by the old guesses, and a code
aged past its quarter hour is refused.
otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
against the rebuilt app and api, no page errors: a fresh signup lands on the
verify face with the box ready, a wrong code says so and the account stays a
guest, the mailed code unlocks PRO, and a proven address is not asked again
on the next login.
Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.
ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
The last three PHOTO STYLE looks (B&W HIGH CONTRAST, LC STREETLIFE
CLASSIC and LC STREETLIFE VIVID) and the whole mixer now belong to the
account, the way PRO frames and the geotag already do: the chip wears
the PRO badge, a guest who picks it is shown the way in, and the look
stays off. The HSL tab keeps its place in the rail but offers the one
PRO chip while locked, so the tab itself is not a dead end; a look that
arrives without the chips — an imported .recipe, or a photo saved
before the gate — is still caught where the gate bites, at export.
STRAIGHTEN's scale turns with the wheel, one degree a notch, because
the ruler is where the angle is being judged and reaching for a slider
elsewhere loses the thread. The listener is native and stops the notch
before the stage sees it, so the photo does not zoom under the pointer.
The scale gives up its opaque card, its blur and its shadow: the frame
it is levelling has to stay readable through it, so legibility comes
from a text shadow on the heading and a drop shadow on the graduations
instead.
The server still never sees a photo, so the model has to run in the page.
Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded
lazily on the first export that actually needs it; the wasm runtime is
copied next to CanvasKit at build time and stays lazily fetched, cached
for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB
asset lands in the bundle.
UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only
when the request is larger than the photo being edited, otherwise they
resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px
overlap, so memory follows the target size rather than four times it.
The histogram used to park itself in the top-right corner on the first
paint; it now starts at the top-left of the photo and is dragged from
there, the way the rest of the overlay is. Nothing else changed in it —
same drag, same clamping, same resize.
The stage also gains a CLEAR button, sitting before the picker button,
which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not
before asking: SAVE PHOTO files it first and only then clears, EXPORT
IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it
there and then, and CANCEL leaves everything alone. Saving from that
modal resumes the clear once the file has really landed — a guest, a
capped account or a cancelled name prompt never loses the frame.
Clearing forgets the working photo (source, preview, GPS, ISO, and the
IndexedDB copy session.ts now deletes), while the look, the crop and the
undo history stay put, so the next photo opens on the same settings the
way replacing a photo already did.
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.
LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.
Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
Both columns now have the shape the curator asked for: a narrow shelf of
albums down the left — one per account on the uploads side, one per landing
section on the other — the frame that is up in the middle, and the open
album's thumbnails as a strip across the bottom.
The frame keeps its labels, the four section boxes, the look's QR code and
the delete button under the picture, where before they sat beside it. Each
column previews its own frame; both obey the same name, sort and rating
filters.
The pane is two columns of the same thing: the uploads on the left, one
album per account, and the landing on the right, one album per section —
Film strip, Live preset tester, Custom recipe creator. QR is no longer a
shelf of its own: the code belongs to the frame.
Both columns list their albums the same way and draw the open album's
frames as cards, each with its labels, the four section boxes, its QR
code and the delete button under the picture. Ticking a box files the
frame into that album on the other side straight away.
The landing strip now carries a score: each look and each contributed
frame shows an average, five stars the visitor can press, and how many
votes it has. Votes are keyed photo:<id> or look:<TAG> and one visitor
has one vote per key, so pressing a second star moves a score instead of
stacking one. The API is public and rate-limited; look: scores survive a
cleared pool, photo: scores are pruned with their photo.
PICTURES was four destination rows; it is now an album per uploader with
a search box, a recipe/rating/newest sort, a minimum-star filter, a big
preview and a filmstrip of thumbnails. Deleting and slot picking still
live in the big box.
A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.
The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.
Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
The pictures pane now reads as a pool of every upload on the left, three
destination rows on the right. Each photo carries one button per
destination; picking the one it already sits in takes it off the landing
page without deleting the row (slot off), which is what the old select's
"not on the landing page" option did.
Removing a saved frame is not like removing a note: its history goes with it,
and nothing on screen said so. A shared modal now names the photo, warns how
many looks ride along, offers the stored file as a download first, and only
then deletes — from MY PHOTOS in the studio and from the folder page alike.
Uploads still land in the community strip with consent on, by the uploader's own
tick — that default stays. What was missing is the curator's removal: the slot
select only offered the other three live placements, so "off the strip" meant
publishing the photo somewhere else or deleting the uploader's row.
`off` is a fifth slot value. The reel draws slot === 'strip' and each live slot
draws its own, so an `off` photo renders nowhere on the landing, while its owner
still has it in MY PHOTOS.
Two saves that never had a name of their own now ask for one, through a single
modal (ui/NameModal, shared by both flows).
The first filing of an upload asks what the folder keeps it as, and that name
rides along as the frame's title. A re-save keeps the name it already has, so
it never asks twice.
SAVE RECENT leaves CREATE RECIPES and becomes its own rail tab: it files the
look standing on the stage — sim, WB, light, FX and the frame — as a recipe of
this account's own, refusing a name the account has already spent. The frame
travels in the recipe's JSON, so applying the entry puts the whole look back.
The tab lists those files and is the one place they can be deleted from; the
API already scopes both by user. They also show up in PRESETS/RECIPES, deduped
against anything CREATE filed under the same name in this session.
Opening one of the folder's own photos and hitting SAVE PHOTO used to
make a second copy of it. Now it replaces that row — same id, same place
— and the look the row carried steps into its history, newest first and
capped at three, because the pixels it described are gone. The frame's
own column in MY PHOTOS lists those looks (click one to put its settings
back on the stage) and carries the landing-page consent as a plain tick,
which answers the click at once. A file from the disk clears the open
id, so a fresh frame still adds one.
The panel in Lightroom is a running read of the render, so this one reads
the preview blob itself: one downscaled 320px canvas pass bins 256
values per channel, and four SVG paths draw them — the grey luma fill
with the three channel curves screened over it, from absolute black to
absolute white. Dragged by its header, clamped inside the photo, parked
top-right on first paint, and dismissed either from its own frame or from
the toolbar button. It steps aside while the crop frame is up.
installTracking() beacons one view per page load and one click per control that
carries a data-key, so every existing button is already counted. The new STATS
pane reads it back: a 7/30/90-day range, the three totals, an SVG timeline and
eight proportional bar lists (pages, clicked features, country, region, city,
browser, system, device).