SINGLE is where a roll is read one frame at a time, and the step from one frame
to the next was a trip to the strip below the picture and back. The stage now
carries the pair itself, at the edges the eye already reads as "there is more
this way", dimmed at the ends of the roll rather than taken away so the pair does
not jump about. The press stops at the arrow: the zoom, the pan and the open
under it are the picture's.
The phone's full-screen view has no room for a pair, so the same step is the
reader's own finger. A drag that travelled sideways is a step the way the list is
read; one that wandered up and down, and a tap in place, are left alone.
Both walk the list on screen — filters and sort included — so what is drawn after
the step is what stood beside it.
Verified against a local preview with a seeded shelf: the arrows appear, the left
one is dimmed on the newest frame and the right one on the oldest, and a left
swipe on the phone moves to the next frame and back.
The dust brush had two faults and the second was hidden behind the first.
A stroke was a row of independent repairs. Every circle the brush laid down
found a patch of its own somewhere else in the frame and pasted it, so a stroke
of twenty spots was twenty pastes with twenty rims to match, and what showed on
the photo was the run of circles itself. The mask was their union on paper but
not on the picture.
And the mask was not a stroke either. The brush only ever dropped a spot where
the pointer's own sample fell, and a fast drag reports a sample every few
centimetres of screen — several brush radii apart. Circles that far apart do not
touch, so their union was the row of dots the tool was complained about. The
segment the pointer travelled is now walked at the brush's own spacing and a spot
is laid at every step, whatever speed the stroke was drawn at.
With the mask one shape, the patch had nothing left to justify it. The fill is
now a push-pull pyramid over the whole mask: the painted pixels are cut out of
the frame as a hole, the remaining pixels are halved and halved again until a
level is small enough that no hole is left in it, and each level is drawn back
over the one below it. What comes up out of that is the photo where the photo
was, and the light of the mask's own rim carried inward where it was not —
nothing is copied from anywhere, and a repair matches the grade, the blur and
the grain around it because those are the pixels it was made from.
Non-destructive as before: the recipe still holds the circles, and the preview
and the full-size export run the two passes in engine/exportEngine.ts, the
shaders and the mask stops in shared/utils/heal.ts. A recipe written by the old
brush still loads; the patch coordinates it stored are simply not read any more.
- RAW opens at the colour the camera wrote into it: the develop carries the
difference against the file's own preview (previewResidual) instead of the
per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
`saveWalkedDirs` filled in a nought beside every folder a reading had listed but
never opened. A reading stopped by a closed tab, and one kept to a branch, list
the rest of the roll before they have been into any of it — 484 folders of one
real roll read empty over tens of thousands of frames — and the record is folded
back into itself, so the noughts stayed.
Only a reading that met the whole roll may say nought now (`WalkedDirs.full`), and
the screen drops a nought from a record that does not say so. An old record has no
word to give, so its rows fall back on the catalogue instead.
feat(library): search Immich from the filter row
One box, one query, one node: the backend reads what is typed as a tag name when
the key can see one and as a file name otherwise, and the answer is filed under the
node itself. A share link searches its one album in place. The box is not a filter —
it leaves the shelf, which is what the md's search permission is for.
feat(ui): a shorter status line, and a wall that keeps its own row height
The status line is one short 12px row at the foot of the library. A wall with fewer
frames than there are columns no longer stretches its one row — and the cards' own
borders with it — down to the stage.
- boundary: src/ui/ErrorBoundary.tsx, around the whole app rather than around a
screen. A throw on the way to the first paint used to be a white page with
nothing in it to report; it is a sentence, the message and the stack now, with
a reload beside them, so the next report of a crash carries what went wrong.
- one window: src/ui/SingleTab.tsx holds the app to one tab over localStorage and
a BroadcastChannel. A second tab on /app or /library is told so and offered the
roll — "use this tab" takes it, and the tab that held it gets a bar and the
offer back. A tab holding a reading is a tab the machine has already spent its
memory on, and a second one beside it is the memory the first will not get.
- mount: the studio and the library are mounted the first time their route is
asked for and stay mounted after that, not on every visit. Booting CanvasKit
(~20MB) and reading the whole catalogue on a page that shows neither is the
read and the memory a machine short of either takes for a crash; a trip back
from the studio is a screen opening rather than a page loading.
- reading: READ_MS bounds one touch of the disk at 30s. getFile and arrayBuffer
on a drive that has gone away do not fail, they never settle — the lane waits
for ever, the reading never ends, and STOP is a button with nothing left to
read it. A frame given up on is a frame the next reading takes up again.
- reading: the rows of a walk go down a pass at a time, so a tab that never comes
back leaves the column with the folders this reading walked rather than the ones
the last reading did.
- reading: a stopped reading hands over the numbers it counted instead of null.
Handed null it threw its own count away and had noughts written for every folder
it walked that the file did not already hold, so a folder added to a drive read
nought until the next scan.
- repair: STOP ends the afternoon. A repair is a loop the button has no handle on,
so it holds the count of stops it started at and compares between rolls, and
says it was cut short rather than reporting a run it did not make.
- add: a folder the browser could not name — every drive root on Windows is "\" —
is asked for one, and a folder whose name is already another folder's is refused
rather than folded in with it. The record, the frame ids, the walk's position
and the number the column draws are all keyed by that name.
- wall: four hundred tiles queued ahead is a reader waiting on frames three
screens below the one they are looking at; sixty is a screenful and the wall
mints the rest as it scrolls. The catalogue is read back every 30s, not every
5s — reading it back costs every thumbnail in it.
- library: the note is drawn under the frame rather than above it. A line that
comes and goes in the flow moved the frame, the column and the strip by its own
height every time a scan started or ended.
- manifest: "id": "/", so the installed app and the tab are one app.
- checks: tree-scan-check waited for a count of exactly four rows — a state the
screen does not have. The wait ran out its clock and then measured whatever the
reading had done since; it waits for the whole tree now.
A saved Immich server is a node on the column beside the drives: no handle,
no picker — the key lives on the backend and the page only ever calls
`/api/immich/*` on its own origin. The dialog takes the address, the name and
the key, checks the key before storing anything, and offers the albums the key
can see; a blank tick list and a blank id field both mean every album. A share
key is one album and so has no picker.
`syncImmich` turns a server's albums into the shapes the tree already draws —
rows in `photos`, names in `walked` — so the column, the wall and the strip
need nothing new. The node's menu is the disk's menu minus what a server has
no disk for.
Also fixes `saveWalkedDirs` filling its noughts in before folding the keys:
a reading that spelled its keys the way it found them (a server's album
labels) had the lower-case spelling of a counted key written in as nought,
and the fold that settles the two spellings kept the nought.
A column count read back off the cards the window drew is an input the window
feeds itself with: the drawn run is a whole number of rows, and those rows were
read back into the count. A wall hidden behind the studio read nothing but zeroes
off its own cards and reset itself to the top on the way out. Either way a window
was written from a window, pass after pass, until React gave up with #185 and the
whole screen went white — there is nothing between that throw and the floor.
The count now comes off the grid, which is the same read the settle effect above
already scrolls the wall by; a wall with no box is not measured at all, and is
measured on the frame it comes back up; the one reset is written off the shelf's
own length rather than off the last window; the scroller is not a place the
browser may anchor; and a chain of writes past four is cut until something
outside moves — a scroll, a resize, or another shelf.
The library is the accounts' shelf, but the tab kept two copies of that fact: the
screen read `/me` once as it mounted and then stopped listening, so a visitor who
signed in at the studio came back to the panel asking for the login they had just
given. One record, read by the studio, the landing page and the shelf alike, is
now the only copy — a sign-in anywhere is a sign-in everywhere.
- auth.ts (new): the tab's one session record. `useAuth()` reads it through
`useSyncExternalStore`, a `/me` already in flight is joined rather than
repeated, and a call that fails leaves the record *unknown* rather than signed
out, so a cold API never reads as a guest.
- TopBar: LIBRARY is a link for a member and a dead button for everyone else. An
anchor wearing `aria-disabled` still navigates on a click, and the screen
behind it would only send the guest home again — so a guest gets the shape of
the button and nothing to press.
- Library: the gate is that shared record, not a copy taken at mount. A guest who
opens `/library` straight is turned home with `replace`, so a Back press does
not walk them into the same wall. The screen is mounted the whole time the tab
is open, so the turn is for the address the visitor actually opened: a guest on
the studio stays on the studio.
- engine/library.ts: the thumbs hand the thread back. Two lanes instead of four,
a frame's worth of idle between them, and one job per frame — a second caller
joins the decode already running instead of starting another.
Verified in Chrome (stubbed API, harness run against both the dev server and the
built bundle): a guest gets a dead LIBRARY button and stays on /app; signing in at
the studio turns it into the link and opens the shelf, with no second login and no
"log in to browse" note; the same after a sign-in on the landing page; /library
opened cold as a guest lands on / with Back going to /app; opened cold signed in,
the shelf is up. On a folder of 22 frames the open runs 2 decodes at a time (was
5) and repeats no frame (44 decodes, was 56 — 12 frames were read twice); the
tiles still come out 22 on the open, 24 by hand, 22 again after a reload.
Two halves of one report: the R/G/B channel picks belong on the graph, and
the chips down the sidebar were reading as a second copy of the sliders.
- ToneCurvePanel.tsx + app.css: the channel row (RGB/R/G/B and RESET) and
the .curve-tabs rules come back, so the card picks a channel again and
bends that one. Undoes the row's removal; the graph itself is unchanged.
- app.css: the strip a LIGHT panel opens is hidden on a desktop, with the
row of panel chips it is opened from. Both name the seven rows the panel
already stands in, so a desktop was drawing every knob twice — the strip
beside the sliders, and the sliders. The phone keeps it: there the panels
are the strip, not the column.
Verified in Chrome at 1440x900 and 820x700 with a photo loaded: on the
desktop the strip is gone and the card carries RGB/R/G/B/RESET, the R tab
bends the red curve while the composite stays drawn beneath it; on the
phone the strip still opens TONE -> EV..BLACK, TONE CURVE, and the card is
the same.
The floating tone-curve card opened with its own row of channel tabs
(RGB / R / G / B) and a RESET button. The panel is meant to be a graph,
so that row is gone: the card is now the head and the curve, nothing
else. The curve edits the composite rgb channel, which is what the row
selected by default anyway.
- ToneCurvePanel.tsx: drop CURVE_CHANNELS, the channel state and the
curve-tabs block; every read and write goes through 'rgb'.
- app.css: drop .curve-tabs / .curve-tab / .curve-dot / .curve-reset
and the .curve-line.ghost path that only drew the other channels.
skipped: selecting a channel other than the composite one, and RESET,
add when a channel chooser comes back on purpose.
- scan: rows go down on a clock as well as on a full batch. The reader's folder
of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
the reading was over — a wall that stood empty for the whole scan and then
filled in one go. Frames now reach the screen every 400ms while the roll is
still being read, so the count climbs and the first tiles are drawn as the
frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
`recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
document — a worker that precached only the document answered an offline
launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
resume after a reload, tiles of a long roll), offline-read-check,
offline-queue-check.
Every range rule now names `input[type="range"]` itself, so the class-only WB
rules tied with the plain hairline that follows them and lost — both sliders
were drawing the same grey line as every other knob, and their node sat a pixel
off the thicker track. Measured on the panel: the gradients are back in amber
and blue, and the ring is centred on the line.
EXPORT only ever wrote a file, and posting it meant leaving the studio, finding
the file and uploading it again. The dialog now offers FACEBOOK, INSTAGRAM,
REDDIT and X beside the sizes, and the render goes to the network instead of the
download folder.
What the browser can do decides how, since a page may not touch another origin's
composer: a phone's share sheet opens the target app with the picture already
attached, a desktop gets the picture on the clipboard and the site in a new tab
for one paste, and an origin with no clipboard API downloads the file beside the
open site. The line under the row says so before the click.
The default ring was a solid disc on a coloured bar, which read as a crude
block next to the rest of the rail; every range input is now a hairline
track with an outline node in the accent colour, and the temperature and
tint strips keep their gradients on the track itself.
CROP, ROTATE and WATERMARK reshape the frame rather than grade it, so they
belong with the tools that work on the whole image.
The bar under the picture is the grid it needs to be: equal free tracks either
side of the two buttons land them on the picture's own centre whatever OPEN and
the stars measure, and the pair keeps the ends it does not own. Under 700px the
stage is too narrow for one line, so the bar wraps as it did and the lines it
wraps to are centred.
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.
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.
On a phone the bar holds one level: the tab's chips, or the strip a chip of
theirs opened. The RECIPES list was the exception — it stood over the bar
with the bar still under it, so the level the finger had just asked for was
drawn twice, once as the list and once as its own row of chips.
The list now displaces the bar, beside the option groups and the watermark,
and wears the bar's slot and skin: that row IS the bar now.
IMPORT .RECIPE is not one of the looks the list holds, so it does not go
into the list — the way in belongs to the chip that opens it, not to the
content it fills. It stands in the tab's own row, one chip over from RECIPES
and at the same level, which is where it already stood on a wide screen. It
stands down with the bar while the list is open, as every chip of that row
does.
With the bar gone the tab's chip is gone too, so the path under the strip
grows the level: TABS > PRESETS > RECIPES, and PRESETS in it returns to the
tab's own chips. A wide screen is untouched — the bar never stands down
there; it simply gains the chip in its own row.
Checked on the running bundle (390x844 and 1280x900): the bar holds the
three chips in one row, the list takes the bar's slot under the ceiling
holding looks and nothing else, IMPORT is not among them, the path reads and
walks back, and a wide screen keeps its bar.
The strip stopped building every tile two commits ago, but the wall was left holding
every frame on purpose: the grid view was the one list that still made a card per
frame. A card is an `<article>`, an `<img>`, an object URL and a date the browser
formats, and on a roll of twenty thousand the first one did not paint for 7410ms —
19998 cards and 19998 pictures, 7007ms of long tasks, the largest of them 4882ms. The
object URL is only about a tenth of it (83µs each); the rest is the DOM and the i18n
date formatting behind every card.
The wall now draws the rows under its viewport and `WALL_ROWS` either side, measured
from `scrollTop` and the client height on scroll and on resize, and two spacers stand
in for the rows that are not drawn, each as tall as the rows it replaces so the
scrollbar still spans the whole shelf. The step from one row to the next is the
average of the rows in hand rather than the smallest of them: the cards do not all
stand the same height — a caption that wraps makes its row taller (274.34 / 259.36 /
274.36 measured) — and `offsetTop` is rounded to whole pixels, so the least step was
a pixel short on every one of thousands of rows and the scrollbar came up 1436px shy
of the end. The average puts `scrollHeight` back on the number the fully drawn wall
had, to the pixel (1085426).
Measured against a seeded roll of 20000: the first card 7410ms → 212ms, cards drawn
19998 → 25, long tasks 7007ms → none, and the scrollbar unchanged. A check on a roll
of 4000 walks the wall end to end — the last frame drawn at the bottom, the first
drawn again at the top, spacer 215719px either side, `scrollHeight` the same 217076
at both ends, no drift — and coming back to the strip still leaves eighteen tiles.
A card that has a thumbnail and has not been handed its URL yet now keeps its box in
silence instead of saying RAW, since the wall hands pictures out only around the eye;
the word is left for a frame that has no thumbnail to give.
Coming back to LIBRARY froze the screen for a second and a half. It was not the tree
— the tree has been up in under a hundred milliseconds all along. It was the strip:
it made a `<button>`, an `<img>` and an object URL for every frame the open node
held, and an object URL costs about a tenth of a millisecond, which is a second of
blocked main thread on a roll of twenty thousand. Six and a half thousand tiles were
built for pictures nobody had scrolled to.
The strip now draws the run under its viewport and `STRIP_KEEP` tiles either side of
it, measured from `scrollLeft` and the client width on scroll and on resize. Two
spacers stand in for the runs that are not drawn, each as wide as the tiles it
replaces, so the scrollbar still measures the shelf rather than the window: the sum
is the same `140n − 8` in every case. The frame on the stage is given its URL
wherever it sits, since the stage is not the strip.
Measured against a seeded roll of 19998 frames: the first tile 1816ms → 663ms on a
cold visit and 1728ms → 681ms coming back from the studio; the long tasks 1302ms
across five of them → 184ms across one. Tiles drawn 6666 → 18, with the scrollbar
unmoved (the far end draws the far frames, and the frame on the stage keeps its
picture once its tile is out of the window).
The wall is the other view and still holds every frame on purpose; it is the one
list left that builds a card per frame.
The strip a chip opened kept a row of its own while a knob's ruler opened over
it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip
and the panel under them — and charged the photo a row for the saying. The path
under the bar (.crumb) already names the panel the ruler came from and is one tap
from its strip, so the ruler now stands in the strip's slot and the strip stands
down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px,
COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB >
COLOR TEMP walking back to the strip and then to the bar.
A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP
as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled
twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the
name alone. The ruler's own "<" goes with them: the path names the strip it would
reopen, and a wide screen draws neither the path nor a ruler in the bar.
Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css
71.42 kB.
- Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB
open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's
ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB >
COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at
641px. No page error and no sideways scroll (390px of 390px) in any state; on
1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
Every tab's bar is one strip of chips, and a chip that opens a strip of its own
opened a second one under the first: WB, TONE, PRESENCE and DETAIL & EFFECTS
kept the bar up while the panel's own strip sat above it. The way back to the
tab's chips is the path under the bar (.crumb), which names the strip the
visitor is in either way, so the bar only said it a second time — and charged
the photo a row for the saying.
So the strip a chip opens takes the bar's slot and the tab's own chips stand
down. Only the two columns that ARE a strip a chip opened displace the bar:
.col-sub[data-col="options"], which is where a panel's strip is drawn (LIGHT's
four, FRAME's tools), and .col-sub[data-col="wm"], a watermark, which is a
column and not a row of chips. Both wear the bar's slot and the bar's own skin —
--bg, not the sub-column's --bg-elev, so in dark the strip paints black like the
bar and not #131315 — and .col-main is dropped while one of them is up. The
RECIPES list, a photo's history and the mask's own column are an open chip's
content, not the strip its chip opened, so they keep the bar in sight under them.
The ruler is unchanged: a knob opens it in the row above, and the strip stays
under it, so the panel is still on screen while the number moves.
Measured on 390x844, LIGHT, light and dark: with the bar up the photo is 641px;
WB open, the bar is gone and its strip measures 73px in the bar's slot, the
photo 608px (568px before, when the bar held its 40px over the same strip);
COLOR TEMP opens its 60px ruler over that strip, photo 548px (508px before),
path TABS > LIGHT > WB > COLOR TEMP; the ruler's "<" lands back on the WB strip
(TABS > LIGHT > WB); crumb-strip and crumb-tab walk back up, the bar returning
its 40px and the photo 641px. FRAME's WATERMARK takes the slot as a column
(131px, --bg in both themes, path TABS > FRAME > WATERMARK) and crumb-tab stands
the bar back up. PRESETS' recipes list leaves the bar at 40px with the list 40px
under it. No page error and no sideways scroll (390px of 390px) in any state; on
1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-kQeKfF3z.css
71.95 kB.
- Chromium 390x844 light and dark: the strip-in-slot walk above, plus 1280x900
unchanged.
LIGHT is the one tab whose picks are panels, and on a phone those panels were
being drawn the way a wide screen draws them: four heads stacked down the bottom
bar, each a disclosure for rows that were already there. The bar is a strip on
every other tab, so it is a strip here: .dev-chips now carries the four panels
as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under
860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip
without scrolling.
WB, TONE, PRESENCE and DETAIL & EFFECTS become strip keys of their own
(DEV_PANELS holds the rows each owns; DEV_DEF is one lookup for the defs, so a
row the phone has and the web has not draws nothing). A panel's strip is its
knobs first, then the picks that close it where they belong: WB's presets and
its two Color Chromes, TONE's curve, DETAIL & EFFECTS' four D.RANGE stops.
A knob in that strip opens its ruler in the row above, and the strip stays under
it, so the panel is still on screen while the number moves — the first level of
the cascade the other tabs already walk, one turn deeper.
PARAM_GROUP is what that costs: every knob LIGHT owns names its panel
(temperature -> wb, exposure -> tone, dehaze -> presence, grain -> effects), so
the ruler's own "<" reopens the panel it came from instead of dropping the strip
off the screen. A slider row opened from a strip returns to it.
The way back up is a path under the strip: TABS > LIGHT > WB > COLOR TEMP, one
name per level, each name returning to the strip it names and the last one drawn
as where the visitor stands. It replaces the single ☀TABS chip: that chip said
the same thing with one name where the path says how far down the visitor is,
which is the half of it the glyph could not. It is the last line of the stack,
so it, not the strip, carries the home-indicator inset now. A wide screen draws
no path: every column is in sight there already.
RESET leaves the bar. The topbar's RESET and this row both call the same reset(),
so the row was a second button for one action, and on a phone it sat at the end
of a strip that already scrolls sideways. CREATE keeps its own: that row also
bumps createReset, which the topbar's button does not do.
Measured on 390x844, LIGHT open: the bar 215px -> 40px, the photo 498px ->
641px, the path 32px; the four chips 390px of scroll width in a 390px strip, and
the page itself does not scroll sideways. On 1280x900 .dev-panels is painted,
.dev-chips and .crumb are not.
Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-BAbl_ECT.css
71.65 kB, index-RU9cTwG7.js 735.50 kB.
- node scripts/{tone-base,highlight-knee,half,white-level,auto-tone,preview-match,
raw-develop,roll-walk,wb-table,mask-wb}-check.mjs: all pass.
- Chromium 390x844 light and dark: rail -> LIGHT gives one 40px row of
dev-wb/dev-tone/dev-presence/dev-effects and the path "TABS > LIGHT";
dev-wb gives the panel's strip over the bar (13 chips, 824px of scroll);
COLOR TEMP opens its ruler above that strip, strip still up, path
"TABS > LIGHT > WB > COLOR TEMP"; the ruler's "<" lands back on the WB strip;
crumb-strip drops the ruler, crumb-tab drops the strip, crumb-tabs stands the
rail back up; TONE closes on the curve, DETAIL & EFFECTS on the four D.RANGE
stops; no page error, no sideways scroll.
- Chromium 1280x900: .dev-panels painted, .dev-chips/.crumb none, WB panel opens
with its presets, rows, chromes; no ruler column, as before.
Skipped:
- The row RESET on CREATE is kept, so that one bar still ends on a chip, where
the topbar's button would leave the form's own reset unrun.
- The path is not drawn over 860px, where the columns are already in sight.
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.
Three small moves in the column, plus the rule they needed.
LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers
what it did — and the tone curve chip leaves the column's chip row. It becomes
the last line of the TONE panel's body, at the foot TONE always had room for.
PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column,
directly under the RECIPES chip, which is the set it imports into. The strip now
carries the recipes and nothing else, so its empty hint fires at length zero.
HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule
is a chip — divider: true in ChipDef — that renders a separator instead of a
button, so a row can say that what follows is a different kind of thing.
Verified by probe against the built app (exit 0, no console errors):
- LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE;
the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27].
- PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under
RECIPES at y 124), RESET; the strip no longer carries the import chip.
- HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule
[94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852,
where the rules measure [8,560,377,1] and [8,639,377,1].
- The rule is a 1px line: width 100% with height 1px, which keeps it a line in
both the desktop column and the phone's wrapped strip.
Co-authored-by: PenguinHarness <noreply@penguin.local>