8f286506b2e7958d0147fbe0a3fd41c6d95c407b
95 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8f286506b2 |
feat(studio): hand an export straight to a social network
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. |
||
|
|
5f78875549 |
feat(studio): draw every slider as a line with a hollow node, and move the shapes into TOOLS
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. |
||
|
|
9bfb39be91 | fix(library): resolve root folder rename, open in explorer, open at location centering, UPDATE rescan bug, and optimize scan RAM memory usage | ||
|
|
57005ae1e2 | fix(library): contain zoom in stage viewport and add drag/pan support for single view | ||
|
|
5dbcdd36e7 | fix(ui): format LIGHT tab main chips to bold and accent, unbold TONE CURVE and DYNAMIC RANGE sub-chips | ||
|
|
347b66099a | style(studio): place Color Chrome sub-buttons on 1 single row and format chip labels bold with theme accent colors | ||
|
|
4704e936ba | feat(studio): add right-click context menu (Solo mode, Collapse all, Expand all) for DevelopPanels, pin Color Chrome sub-chips, and fix library single view sorting & time range select | ||
|
|
3b4c49c41d | feat(library & studio): add EXIF info bar, portrait frame panning, and multi-photo selection with Panorama/HDR context menu | ||
|
|
66b159b3bd |
library: centre the quarter-turn pair on the stage bar
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. |
||
|
|
77cf872b16 | fix(library): optimize image scanning, fast raw preview extraction and lazy thumbnail loading | ||
|
|
79f0d77a8e |
feat(library): update, rescan and stop on the folder menu, queued per roll
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. |
||
|
|
d5da6ebcba |
web: a row's menu is the tree's own and speaks only of that row, and only the row being read admits to it
The menu that hangs off a folder is a menu read mid-scan, with the eye still moving down the column, and it was drawn to the measure of a page rather than of a list: a box wide enough to be a destination, type the size of a heading. A catalogue's own context menu is a thing passed over on the way somewhere else — it is sized to its words and to the row it hangs off, and the reader's eye steps across it without stopping. The type comes down a point, the rows tighten to the tree's own measure, and the frame around them thickens to a radius the rows themselves use. The clamp that keeps it in the window follows the smaller box, so a menu opened near the right or the bottom edge no longer floats an inch off the pointer for room it does not use. A row that is being read has exactly one thing to be asked of it — that it stop — where a row at rest has the reading that brings it up to date. The menu said RESCAN either way, greyed while a scan was up, which is a command offered and refused in the same breath. The two never both apply, so the menu now carries the one the row is actually in: a roll being read offers STOP SCAN and stops that roll; a roll at rest offers UPDATE... and reads it again. A row with folders under it grows a third thing — a fold that closes the branch and not merely the row, because a reader who is done with a tree means the whole of it shut, and closing only the row they happened to point at leaves the children standing open underneath it. The last is a matter of what a row is allowed to claim. Every row of a roll spanning the tree spun while that roll was being read, so a reader looking at a tree of a hundred folders saw a hundred rows each insisting it was the one being read, when the reading had been pointed at one of them. A row now says it is being read only when it is the row the reading was kept to — the folder it was pointed at, which for a whole roll is the roll's own row. A tree that says all of itself is busy when one branch of it is says nothing a reader can trust. |
||
|
|
39f80088c7 |
web: a folder is chosen by picking one — the backup row names it and opens the picker, and restore always asks which backup
RESTORE was dead on a machine that had never written a backup: the button was `disabled` while no folder was remembered, and nothing in the handler could ever pick one, so the reader who had carried a copy over on a stick had no way to point at it. The button now stands, and the folder it restores from is whatever the reader picks in the dialog that opens — which is the whole of the choice there is, because a backup is a folder of rows and tiles. The folder on the other disk, the one made before the edit: the folder is the version. BACK UP asked for a folder only the first time one was ever chosen. After that it wrote into it in silence, and after a restart — when the browser takes the permission back, since a permission outlives the tab only while the tab does — it wrote nothing and said so, which is a backup that quietly stops happening. It now picks whenever it cannot write, and the picker is the only thing that can hand a remembered folder its permission back; a permission asked for with no picker behind it is one the browser may refuse. The run that follows a scan is untouched: it is guarded by the permission it would be asking for. And the folder is a control, not a caption. The line that names it — with the frames in it and when they were written — is what tells one backup from another, and pressing it is how a folder is picked, a name given, a permission handed back. It keeps the hint's own look through five lines of CSS, so the row still reads as a caption and not as a third button. What the line says is now read out of the folder rather than out of this browser's memory of it. `pickBackupFolder` reads the catalogue file inside the folder just picked and takes its count and its date as the row's own; a folder with no catalogue of its own has none, and the row says so instead of showing the numbers of the folder next door. That is also what the confirm names before a restore — the frames and the time in the folder just picked, which is the thing about to be restored. `restoreNow` already read that file; this reads its header first. Checked on the running bundle with the picker stubbed, since the dialog is one a probe cannot press: with nothing remembered, RESTORE now opens the picker where it did nothing at all, the row names the folder the picker returned, and a folder without a catalogue of its own is refused with a line saying so rather than restored as an empty catalogue. BACK UP opens it too, from the same fresh state. The line computes to a button with no border, no background, the page's own font and the hint's own 12px dim grey — a caption that happens to be pressable. The wall, the strip, the grid, the deep link and the phone's recipes all pass their checks. |
||
|
|
9b24ce7bbc |
web: the RECIPES chip hands the phone's bar to the list, not a second row over it — IMPORT .RECIPE stays the tab's own chip, and the path says TABS > PRESETS > RECIPES
On a phone the bar holds one level: the tab's chips, or the strip a chip of theirs opened. The RECIPES list was the exception — it stood over the bar with the bar still under it, so the level the finger had just asked for was drawn twice, once as the list and once as its own row of chips. The list now displaces the bar, beside the option groups and the watermark, and wears the bar's slot and skin: that row IS the bar now. IMPORT .RECIPE is not one of the looks the list holds, so it does not go into the list — the way in belongs to the chip that opens it, not to the content it fills. It stands in the tab's own row, one chip over from RECIPES and at the same level, which is where it already stood on a wide screen. It stands down with the bar while the list is open, as every chip of that row does. With the bar gone the tab's chip is gone too, so the path under the strip grows the level: TABS > PRESETS > RECIPES, and PRESETS in it returns to the tab's own chips. A wide screen is untouched — the bar never stands down there; it simply gains the chip in its own row. Checked on the running bundle (390x844 and 1280x900): the bar holds the three chips in one row, the list takes the bar's slot under the ceiling holding looks and nothing else, IMPORT is not among them, the path reads and walks back, and a wide screen keeps its bar. |
||
|
|
e53d6264ca |
web: the wall draws the rows under the eye, not the shelf — twenty thousand cards no longer cost seven seconds of dead screen
The strip stopped building every tile two commits ago, but the wall was left holding every frame on purpose: the grid view was the one list that still made a card per frame. A card is an `<article>`, an `<img>`, an object URL and a date the browser formats, and on a roll of twenty thousand the first one did not paint for 7410ms — 19998 cards and 19998 pictures, 7007ms of long tasks, the largest of them 4882ms. The object URL is only about a tenth of it (83µs each); the rest is the DOM and the i18n date formatting behind every card. The wall now draws the rows under its viewport and `WALL_ROWS` either side, measured from `scrollTop` and the client height on scroll and on resize, and two spacers stand in for the rows that are not drawn, each as tall as the rows it replaces so the scrollbar still spans the whole shelf. The step from one row to the next is the average of the rows in hand rather than the smallest of them: the cards do not all stand the same height — a caption that wraps makes its row taller (274.34 / 259.36 / 274.36 measured) — and `offsetTop` is rounded to whole pixels, so the least step was a pixel short on every one of thousands of rows and the scrollbar came up 1436px shy of the end. The average puts `scrollHeight` back on the number the fully drawn wall had, to the pixel (1085426). Measured against a seeded roll of 20000: the first card 7410ms → 212ms, cards drawn 19998 → 25, long tasks 7007ms → none, and the scrollbar unchanged. A check on a roll of 4000 walks the wall end to end — the last frame drawn at the bottom, the first drawn again at the top, spacer 215719px either side, `scrollHeight` the same 217076 at both ends, no drift — and coming back to the strip still leaves eighteen tiles. A card that has a thumbnail and has not been handed its URL yet now keeps its box in silence instead of saying RAW, since the wall hands pictures out only around the eye; the word is left for a frame that has no thumbnail to give. |
||
|
|
fb9c9f98db |
web: the strip draws the tiles under the eye, not the shelf — a roll of twenty thousand no longer builds every tile before the first one is seen
Coming back to LIBRARY froze the screen for a second and a half. It was not the tree — the tree has been up in under a hundred milliseconds all along. It was the strip: it made a `<button>`, an `<img>` and an object URL for every frame the open node held, and an object URL costs about a tenth of a millisecond, which is a second of blocked main thread on a roll of twenty thousand. Six and a half thousand tiles were built for pictures nobody had scrolled to. The strip now draws the run under its viewport and `STRIP_KEEP` tiles either side of it, measured from `scrollLeft` and the client width on scroll and on resize. Two spacers stand in for the runs that are not drawn, each as wide as the tiles it replaces, so the scrollbar still measures the shelf rather than the window: the sum is the same `140n − 8` in every case. The frame on the stage is given its URL wherever it sits, since the stage is not the strip. Measured against a seeded roll of 19998 frames: the first tile 1816ms → 663ms on a cold visit and 1728ms → 681ms coming back from the studio; the long tasks 1302ms across five of them → 184ms across one. Tiles drawn 6666 → 18, with the scrollbar unmoved (the far end draws the far frames, and the frame on the stage keeps its picture once its tile is out of the window). The wall is the other view and still holds every frame on purpose; it is the one list left that builds a card per frame. |
||
|
|
fd330c05e0 | web: the histogram waits to be asked for, the phone's ruler is a finger tall, and its x answers a real press — a 22px strip under a 44px thumb, a histogram that opened over the photo, and a close button whose press the drag's own preventDefault ate | ||
|
|
7b6a1a805f |
web: a knob's ruler stands in the strip's slot, and a sub-chip is its name again
The strip a chip opened kept a row of its own while a knob's ruler opened over it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip and the panel under them — and charged the photo a row for the saying. The path under the bar (.crumb) already names the panel the ruler came from and is one tap from its strip, so the ruler now stands in the strip's slot and the strip stands down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px, COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP walking back to the strip and then to the bar. A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the name alone. The ruler's own "<" goes with them: the path names the strip it would reopen, and a wide screen draws neither the path nor a ruler in the bar. Checks: - npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css 71.42 kB. - Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at 641px. No page error and no sideways scroll (390px of 390px) in any state; on 1280x900 .dev-panels is painted and .dev-chips/.crumb are not. |
||
|
|
9aa3424e92 |
web: a chip's strip stands in the bar, not over it — LIGHT's WB strip sat on top of the bar it belongs to, so the photo paid 40px of a 390x844 screen to repeat where the finger already was
Every tab's bar is one strip of chips, and a chip that opens a strip of its own opened a second one under the first: WB, TONE, PRESENCE and DETAIL & EFFECTS kept the bar up while the panel's own strip sat above it. The way back to the tab's chips is the path under the bar (.crumb), which names the strip the visitor is in either way, so the bar only said it a second time — and charged the photo a row for the saying. So the strip a chip opens takes the bar's slot and the tab's own chips stand down. Only the two columns that ARE a strip a chip opened displace the bar: .col-sub[data-col="options"], which is where a panel's strip is drawn (LIGHT's four, FRAME's tools), and .col-sub[data-col="wm"], a watermark, which is a column and not a row of chips. Both wear the bar's slot and the bar's own skin — --bg, not the sub-column's --bg-elev, so in dark the strip paints black like the bar and not #131315 — and .col-main is dropped while one of them is up. The RECIPES list, a photo's history and the mask's own column are an open chip's content, not the strip its chip opened, so they keep the bar in sight under them. The ruler is unchanged: a knob opens it in the row above, and the strip stays under it, so the panel is still on screen while the number moves. Measured on 390x844, LIGHT, light and dark: with the bar up the photo is 641px; WB open, the bar is gone and its strip measures 73px in the bar's slot, the photo 608px (568px before, when the bar held its 40px over the same strip); COLOR TEMP opens its 60px ruler over that strip, photo 548px (508px before), path TABS > LIGHT > WB > COLOR TEMP; the ruler's "<" lands back on the WB strip (TABS > LIGHT > WB); crumb-strip and crumb-tab walk back up, the bar returning its 40px and the photo 641px. FRAME's WATERMARK takes the slot as a column (131px, --bg in both themes, path TABS > FRAME > WATERMARK) and crumb-tab stands the bar back up. PRESETS' recipes list leaves the bar at 40px with the list 40px under it. No page error and no sideways scroll (390px of 390px) in any state; on 1280x900 .dev-panels is painted and .dev-chips/.crumb are not. Checks: - npm run build (tsc --noEmit + vite) clean: dist/assets/index-kQeKfF3z.css 71.95 kB. - Chromium 390x844 light and dark: the strip-in-slot walk above, plus 1280x900 unchanged. |
||
|
|
c47ce212eb |
web: LIGHT is four strips on a phone, not four folded rows — the tab's column measured 215px of a 390x844 screen with its panels stacked, and the photo 498px; the strip is 40px and the photo 641px
LIGHT is the one tab whose picks are panels, and on a phone those panels were
being drawn the way a wide screen draws them: four heads stacked down the bottom
bar, each a disclosure for rows that were already there. The bar is a strip on
every other tab, so it is a strip here: .dev-chips now carries the four panels
as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under
860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip
without scrolling.
WB, TONE, PRESENCE and DETAIL & EFFECTS become strip keys of their own
(DEV_PANELS holds the rows each owns; DEV_DEF is one lookup for the defs, so a
row the phone has and the web has not draws nothing). A panel's strip is its
knobs first, then the picks that close it where they belong: WB's presets and
its two Color Chromes, TONE's curve, DETAIL & EFFECTS' four D.RANGE stops.
A knob in that strip opens its ruler in the row above, and the strip stays under
it, so the panel is still on screen while the number moves — the first level of
the cascade the other tabs already walk, one turn deeper.
PARAM_GROUP is what that costs: every knob LIGHT owns names its panel
(temperature -> wb, exposure -> tone, dehaze -> presence, grain -> effects), so
the ruler's own "<" reopens the panel it came from instead of dropping the strip
off the screen. A slider row opened from a strip returns to it.
The way back up is a path under the strip: TABS > LIGHT > WB > COLOR TEMP, one
name per level, each name returning to the strip it names and the last one drawn
as where the visitor stands. It replaces the single ☀TABS chip: that chip said
the same thing with one name where the path says how far down the visitor is,
which is the half of it the glyph could not. It is the last line of the stack,
so it, not the strip, carries the home-indicator inset now. A wide screen draws
no path: every column is in sight there already.
RESET leaves the bar. The topbar's RESET and this row both call the same reset(),
so the row was a second button for one action, and on a phone it sat at the end
of a strip that already scrolls sideways. CREATE keeps its own: that row also
bumps createReset, which the topbar's button does not do.
Measured on 390x844, LIGHT open: the bar 215px -> 40px, the photo 498px ->
641px, the path 32px; the four chips 390px of scroll width in a 390px strip, and
the page itself does not scroll sideways. On 1280x900 .dev-panels is painted,
.dev-chips and .crumb are not.
Checks:
- npm run build (tsc --noEmit + vite) clean: dist/assets/index-BAbl_ECT.css
71.65 kB, index-RU9cTwG7.js 735.50 kB.
- node scripts/{tone-base,highlight-knee,half,white-level,auto-tone,preview-match,
raw-develop,roll-walk,wb-table,mask-wb}-check.mjs: all pass.
- Chromium 390x844 light and dark: rail -> LIGHT gives one 40px row of
dev-wb/dev-tone/dev-presence/dev-effects and the path "TABS > LIGHT";
dev-wb gives the panel's strip over the bar (13 chips, 824px of scroll);
COLOR TEMP opens its ruler above that strip, strip still up, path
"TABS > LIGHT > WB > COLOR TEMP"; the ruler's "<" lands back on the WB strip;
crumb-strip drops the ruler, crumb-tab drops the strip, crumb-tabs stands the
rail back up; TONE closes on the curve, DETAIL & EFFECTS on the four D.RANGE
stops; no page error, no sideways scroll.
- Chromium 1280x900: .dev-panels painted, .dev-chips/.crumb none, WB panel opens
with its presets, rows, chromes; no ruler column, as before.
Skipped:
- The row RESET on CREATE is kept, so that one bar still ends on a chip, where
the topbar's button would leave the form's own reset unrun.
- The path is not drawn over 860px, where the columns are already in sight.
|
||
|
|
6bbf18dc44 |
web: the phone's bar wears icons, stays at the top and ends on EXPORT — the four words wrapped the header to 135px over a 390px screen, 88px now, and the tab pills were 28px tall under a thumb, 44px now
LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The recipe is the one the file already used for the stage's toolbar: font-size 0 takes the word out of the paint, never out of the button, so every accessible name is exactly the word that is no longer drawn, and a ::before carries the glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content measured 990px wide with the words and 716px with the icons, and the bar it sat in measured 135px tall on a 390px screen before this and 88px after: three rows to two. RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it. EXPORT moves to the end of the <header>, after the three menus. It is the end of the job and now the end of the row, which is the button the eye should land on once the edit is done. SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is a word in a box too small for it, and the count that shares the label has no room either. The button keeps its title, and the count is the one thing the phone loses here — the wide screen still counts. The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page never scrolls (the stage does), so nothing moved before this and nothing moves now — but that is a promise the layout can keep rather than a coincidence of who scrolls. `.adm-bar` already carried the same three lines. The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and 28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes 6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px. This commit also lands the strip work from the session before it, which was written and probed but never committed: under 860px the rail of tabs and the tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip stands the rail back up, the open chip's sub-chips stand over the bar as 60px tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with the bar above, so the phone's block lands in one piece. Checks: - npm run build (tsc --noEmit + vite) clean. - The ten browser-free image checks (tone-base, highlight-knee, half, white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table, mask-wb) all pass. - Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before; the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0; strip-open keeps headerY 0 with the rail display:none; no page errors. - Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row 50px, the same four buttons at 14px with no ::before, EXPORT last. Skipped: no new aria-label — the words the icons replace are still the buttons' own text, so nothing needs one. Add one only if a label leaves the DOM. |
||
|
|
74c0b2747f | Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column | ||
|
|
f7031c1809 |
LIGHT drops its PROFILE panel, TONE CURVE folds into TONE, and IMPORT .RECIPE moves under the RECIPES chip
Three small moves in the column, plus the rule they needed. LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers what it did — and the tone curve chip leaves the column's chip row. It becomes the last line of the TONE panel's body, at the foot TONE always had room for. PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column, directly under the RECIPES chip, which is the set it imports into. The strip now carries the recipes and nothing else, so its empty hint fires at length zero. HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule is a chip — divider: true in ChipDef — that renders a separator instead of a button, so a row can say that what follows is a different kind of thing. Verified by probe against the built app (exit 0, no console errors): - LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE; the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27]. - PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under RECIPES at y 124), RESET; the strip no longer carries the import chip. - HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule [94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852, where the rules measure [8,560,377,1] and [8,639,377,1]. - The rule is a 1px line: width 100% with height 1px, which keeps it a line in both the desktop column and the phone's wrapped strip. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
4476372b9e |
The two Color Chromes close the WB panel and D.RANGE gets a box of its own
Two picks that were drawn as rows of the column rather than as picks of a panel, both of them because of where a slot happened to be rather than what the control is. The pair of Color Chromes was LIGHT's own `chip-row grid` at the foot of the column, under TONE CURVE, where it read as a third strip of the tab and sat far from the two tracks it is the other axis of. D.RANGE was the effects slot, four chips poured into the foot of DETAIL & EFFECTS where they were four more rows of the panel and not the one setting they are. WB now owns the pair. `DevelopPanels` gains a `foot` on a panel — a slot drawn under the rows instead of above them — and the `wb` entry names `wbFoot`, so COLOR CHROME and CHROME BLUE are rendered under COLOR TEMP and TINT, on the tab whose colour they are and against the pair of tracks they are read with. They keep everything else: the `chip-row grid` table layout the WB presets wear, their keys, their OFF/ON labels and their pointer behaviour. The `wb` panel had no slot but its presets and its two rows, so the foot is the whole of what changed there; the four panels that name no foot are untouched, and the slot is looked up per panel (`slots?.[panel.foot]`) so a panel that never asked for one draws nothing. D.RANGE closes DETAIL & EFFECTS inside a box that carries its name. `.dev-box` is a frame in `--border-soft` with a `.dev-box-title` in the small-caps the panel heads use, and the four stops sit in it as one set — which is what they are: a hold on the whole frame, read as a set of stops, not as four more knobs of the effects. The box is the slot's own wrapper, so it holds whichever chips the slot is given and the chips keep their own keys and their amber AUTO. Verified: `npx tsc --noEmit` clean, `npm run build` clean (`dist/assets/index-v5g7p7bS.js`, `dist/assets/index-RYLacibB.css` at 66.29 kB). Against the built page (`127.0.0.1:8090`, 1440x900 desktop and 393x852 phone), LIGHT tab, panels open: - LIGHT's own row is now `∿ TONE CURVE` alone — the pair is out of the column. - WB's children read `[chip-row grid (presets), mini-slider COLOR TEMP, mini-slider TINT, chip-row grid COLOR CHROME OFF / CHROME BLUE OFF]`, and both chromes report `inWb: true` — `COLOR CHROME [94,424,147,37]`, `CHROME BLUE [247,424,147,37]` at 1440 wide, `[8,599,186,37]` and `[200,599,186,37]` at 393 — in the WB body, under the two tracks. - The D.RANGE box reports `data-key="dev-box-dr"`, title `DYNAMIC RANGE`, `[94,754,300,162]` under `effBox [94,569,300,347]` at 1440 wide, border `1px rgb(236,238,241)` (`--border-soft`); the four chips are full-width rows (282px) at y=781/814/847/880, AUTO `aria-pressed=true` in amber `rgb(206,117,9)` on `rgba(206,117,9,0.14)` and DR100/200/400 on white. On the phone the box is `[8,929,377,63]` and the four chips come back to one line at `.chip-row`'s own `@media (max-width:860px)` override, `[17,956,58,27]`, `[81,956,62,27]`, `[149,956,64,27]`, `[219,956,65,27]` — no clipping, nothing over the caption — and `errors: []` on both. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
6996d1bdb0 |
The marks get a tab of their own and the rulers read on one line
Four asks, one reading of the LIGHT column. EV's ruler sat under PROFILE, which
is the film simulation and nothing else — the range a picture is on is a knob of
the tonal range, not of the stock it was shot on. D.RANGE sat with the tone
controls for the same wrong reason: it is a hold on the whole frame, so it
belongs at the foot of the effects. The two Color Chromes had gone missing from
the eye, because the only place they were drawn was the effects slot and
DETAIL & EFFECTS now opens folded. And every ruler was two lines: a name with
its number on one, the track alone under them, where Lightroom reads a ruler as
one line. The four feature buttons answered a fifth ask that arrived with them:
AUTO, FIX, GRADIENT MASK and MONOCHROME do not name a look, they make a move on
the frame, and they now share a tab of their own.
TOOLS is the rail's ninth tab, between LIGHT and HSL, marked with the hammer and
pick (`⚒`) because the four features work ON the photo rather than pick a look
for it. The chips keep their keys, their amber-value logic and their pointer
behaviour — FIX and GRADIENT MASK still put down whatever tool was up before
they open their strip — and each carries its own mark at the head of its label
so the four read as one set: `✦ AUTO`, `✚ FIX`, `▚ GRADIENT MASK`,
`◑ MONOCHROME`. The marks were probed in the page's own stack before they were
spent (Inter, then the system sans): four-pointed star, thick cross, two
diagonal quadrants, half-filled circle — 26px, 47px, 77px and 15px of ink at
11px against the 36px a tofu box draws, so none of them is a missing glyph. The
strips still open in the last column, the same `stripChips` any other tab uses.
LIGHT keeps the one chip that is not a knob — TONE CURVE, `∿ TONE CURVE`, which
opens the graph on the photo rather than a ruler in the column, and stands over
the TONE panel whose shape it is — and gains the pair under it. COLOR CHROME and
CHROME BLUE are the same switch over two axes of one colour, so they are read
against each other rather than scanned in turn: one row, two chips, the table
layout the WB presets already wear (`.chip-row.grid`), 147px each side by side
at top 123 with nothing overflowing. Each names the strength it is on, and names
`OFF` when it is on `none` — the pair is compared, so the neutral value is one
of the two being read (`groupChip(g, nameAlways)`). Either one still opens its
own strip in the last column; picking STRONG lands on the chip and, at rest,
goes amber.
Every ruler is one line now: the knob's name at the head of the row, the track
taking the width they leave, the number it is on at the foot. The track moved
inside `MiniSlider`'s `.mini-head` and inside `SliderRow`'s `.head`, and both
rules became a flex row (`span` and `b` `flex: 0 0 auto`, the input
`flex: 1 1 40px; min-width: 0`). The eight TONE rows, the six of DETAIL &
EFFECTS and the mask column's eleven all read as one line, track included. The
HSL mixer's three knobs are the exception and keep the stacked form: three to a
narrow panel, a track between two labels is a sliver, so `.hsl-card-knobs`
wraps the readout onto its own line and lets the track take the second. The mask
column takes the ruler column's own width (`col-slider`, 232px) rather than a
strip's 168px, which is what the one-line form costs: eleven rows, and in a
strip's width the labels leave the tracks nothing.
PROFILE is the simulation strip alone, TONE leads with EV and then the finer
move on the same axis, and DETAIL & EFFECTS closes with the D.RANGE strip.
Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-CJSnLD0u.js`). Off the running page, through a probe: the
rail reads `◉ PRESETS ★ FAVORITED ☀ LIGHT ⚒ TOOLS ◍ HSL ▣ FRAME ⤓ SAVE RECENT
✎ CREATE RECIPES`; LIGHT's non-grid row is `[{key: curve, label: "∿ TONE
CURVE"}]` and the grid row under it is `["grp-cx", "grp-cxb"]` — 147px + 147px
in a 300px column, both boxes at top 123, nothing overflowing, `COLOR CHROME
OFF` and `CHROME BLUE OFF` each on its chip; its strip is
`[hint-cx, cx:none, cx:weak, cx:strong]` and picking STRONG lands
`{value: "STRONG", amber: false, on: true}` and then `{value: "STRONG", amber:
true}` once the strip closes. The TONE rows come back `EV 15+229+36
one-line=true`, `EXPOSURE 58+198+24`, `CONTRAST 60+214+6`, `HIGHLIGHT
60+214+6`, `SHADOW 50+224+6`, `WHITE 36+238+6`, `BLACK 38+236+6` (label, track,
readout; one line on every one), and the mask column's eleven the same at 232px.
PROFILE holds `{ev: false, dr: false, sim: […11 sims]}` and DETAIL & EFFECTS
holds `{dr: [dr:auto, dr:100, dr:200, dr:400], chrome: false, last: "AUTO DR100
DR200 DR400"}`. The TOOLS row is `[✦ AUTO, ✚ FIX, ▚ GRADIENT MASK, ◑
MONOCHROME]` at 148px each, the marks drawn 26/47/77/15px against the 36px tofu
control, and FIX's and GRADIENT MASK's strips still open
(`[hint-fix, heal, mosaic]`, `[hint-gradient, linear, radial]`) with MONOCHROME
still switching `false → true`. No page errors. `studio-open-check` (updated
for the pair and the single LIGHT chip), `library-check` and `scan-nav-check`
all pass, and the column was viewed with TONE and DETAIL & EFFECTS open.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
2f34c36131 |
The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left
Four asks, one shape: the LIGHT column had a chip that opened the same two
knobs DETAIL & EFFECTS already shows, the WB table was reading as one long
line per preset, the disclosure carets were too small to be the affordance
they are, and both folds — the studio's panels and the catalogue's folder tree
— opened fully every time. What the fold is for is the same in both places: a
column of a dozen controls is a wall to scroll past, and the control being
looked for is named by the head it sits under. So both open folded, and both
remember what was opened.
GRAIN's chip is gone from the LIGHT row. Its strip held a knob `grain` and the
"size" behind it, and both are rows of DETAIL & EFFECTS now — MONOCHROME GRAIN
and GRAIN SIZE — so the chip was a second way to one pair. The strip machinery
stays (`GroupKey 'grain'`, `groupDefs.grain`, `grainInch()`): it is reachable
from nothing today, and taking it out is a diff of its own. What went with the
chip is the `/INCH` readout, which only the strip drew.
The WB presets read as a name on one line and its kelvin under it: the label is
the chip's own label and the kelvin moved from the label into the value slot,
which the grid's rule stacks (`flex-direction: column`, `font-size: 11px`,
`white-space: nowrap`). Seven buttons, 147x39 each, name
box 14px — one line at 11px/1.25 — kelvin at 10px, `scrollWidth` equal to
`clientWidth` on every one of them. The kelvin rides a swatch, so it wears the
fill's own ink rather than the panel's dim grey (`.chip.tinted .val { color:
inherit }`): readableInk already picks #0b0e12 or #ffffff off that fill for
4.5:1, and dimming it a step costs contrast on a mid grey fill. Measured off
the running page: 4.88:1 (CLOUDY) to 5.00:1 (TUNGSTEN), name and kelvin alike.
The panels: `recipescam.develop.open` holds the slugs that are open, comma-
joined, and a slug no panel answers to is dropped on the way in, so a renamed
or removed panel leaves no hole. A visit that touches nothing writes nothing —
the column opens folded from an empty store — and a click on a head writes the
list as it stands. The caret is drawn a size up from the label it sits beside
(17px against an 11px head) and takes full opacity under the pointer, because
a line of uppercase does not read as a control on its own.
The tree: a row's click still opens it and folds it again, but the set it moves
in is inverted — what is *drawn open* rather than what is folded away — which
is what makes the state storable at all (a folded set is the whole tree minus
what is open, and the whole tree is not in that state to be stored). The
column opens with every folder shut, and the remembered set comes back over
it. A first visit under the new rule has no stored set and can still have a
folder remembered from before, so that one case seeds its ancestors: a screen
that opens on a node shrunk away behind a shut row is worse than a
remembered fold. COLLAPSE ALL folds the top-level row too now, which is what
makes it the way back to the column's rest — it was written when a top-level
row could not be folded, and the item is disabled when there is nothing open.
The caret is a leaf on a row with nothing under it, as it was.
`remember.ts` is the two functions both screens now share; Library had its own
copy of them.
The two checks that read these screens were wrong about the new fold and are
right about it now, not the other way round. `library-check` gains the rest
state (`the tree opens with the folders folded away — 1 rows`), walks the
tree a level per click, and reads the visit it left behind off a reload:
`lib-node-CheckRoll:true lib-node-CheckRoll/2026:true .../04:- .../Empty:-`,
with the screen marked on the subfolder. Two of its steps were stale
expectations rather than a fault: the fold-the-tree claim is one click over
four rows once the rows are opened for it, and the folder the screen reopens
on is the last row clicked, not the one clicked before it. `scan-nav-check`
asks the same tree for its deepest folder, so it opens the two rows above it
and folds them back — the reading it is watching does not move with rows.
Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-T721P-Nv.js` 711.33 kB, `dist/assets/index-Co_dQD6t.css`
65.69 kB). `scripts/library-check.mjs` — "all checks passed", 55 steps, none
failing — including `the tree opens with the folders folded away — 1 rows,
aria-expanded=false`, `a row draws its own children, not the whole branch — 3
rows`, `and one click folds every row, leaving the frames where they were — 4
rows → 1, lib-node-CheckRoll:false, 2 tiles kept`, `the tree reopens with the
rows that were left open`, `the screen reopens on the folder it was left on —
lib-node-CheckRoll/2026`, `the screen reopens on the frame that was raised`,
and the column `198px, then 198px`. `scripts/scan-nav-check.mjs` — all steps
passed, its tree walk reading
`["lib-node-SlowRoll","lib-node-SlowRoll/2026","lib-node-SlowRoll/2026/04"]`.
In the running page: 5 panel heads and 0 panel bodies at rest with
`recipescam.develop.open` unset, caret `17px` `▸`; the WB table's seven rows
at 147x39 with name 14px one line, kelvin below at 10px, `nowrap`, no
overflow, ink 4.88–5.00:1; the LIGHT chip row `["auto","curve","fix",
"gradient-mask","mono","wb-temp:*","reset-all"]` — no `grp-grain` — with
MONOCHROME GRAIN and GRAIN SIZE still the two rows of DETAIL & EFFECTS; two
heads opened, `wb,effects` stored, and the same two open after a reload with
the other three shut. The deployed bundle was read too: `docker compose build
frontend && docker compose up -d frontend`, and `index-DpYWAhJ3.js` off
`127.0.0.1:8090` carries `recipescam.develop.open` and
`recipescam.library.open` and no `grp-grain`, with the same probe readings
taken against it.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
cc186c4d09 |
The trial gets a date and WB's presets get a table
Two asks, one row of the studio each. The first is copy: a band under the header, the same shape the verify bar below it takes, saying which features are on trial, on whom, and until when. It is a notice and not a task, so it wears the plain surface rather than the accent, and it carries no dismiss: the date is the message. The string is in the dictionary like every other, Vietnamese as written and English beside it — the band is one line at both lengths in a 1440-wide window, and it sets no nowrap, so a narrow window wraps it rather than cutting it. The second is the WB panel's presets. They were a wrapping strip of seven identical pills, which is the wrong shape for one kind of value read against the others: nothing about "SHADE" said 7500K, and nothing about the row said which end of the scale anything was on. They are a TABLE now — two columns of buttons, laid out as a grid — and each button is painted with the cast it puts on the frame: the same swatch the TEMPERATURE ruler already prints under its track (temperatureSwatch, the engine's own gains on a mid grey, halved), so the button and the ruler cannot disagree, and the kelvin is printed on the button beside the name. A button that is a swatch cannot use the theme's text colour for its label: the fill is a mid grey by construction, and mid grey is exactly where white and the light theme's near-black both sit near the 4.5:1 line. So the label is given the ink that fill can carry, chosen by WCAG's relative luminance against black and against white — whichever of the two ratios is larger, which is the one that cannot fall under 4.5:1. The border is that same ink, which is what makes a button stand out from the page it sits on and from the six beside it; the accent takes the border over while the preset is the one in force, since the fill can no longer say so. Both the fill and the ink ride in as inline styles — they are the value, not the theme — so the chip needed two fields and a tinted class, and the row needed a grid mode. The TEMP strip reads the same two fields through choiceChips, because the same seven presets are painted in both places and two opinions about 3200K is the bug this would have been. The chip's own value slot is deliberately unused here: `.chip .val` sets its own dim colour, and a dim grey on a mid grey button is the failure this commit is about. Verified: scripts/wb-table-check.mjs — readableInk picks the better of its two inks for black, white and four mid greys, all 151 swatches across the ruler (2500..10000K, every 50) and all seven presets clear 4.5:1 under the ink they are given, the swatch runs the way the ruler does (red up, blue down, #517eff at 2500K to #947d61 at 10000K), and the seven presets collapse to the five fills their kelvins do. In the running app through a probe: the notice prints in both languages (Vietnamese one line, unclipped), 7 buttons in a 147px + 147px grid, TUNGSTEN filled rgb(98,130,187) with ink rgb(11,14,18) and a 2px border of the same, AUTO the accent border rgb(206,117,9) while it is the preset in force, and the panel holds at 420px wide without overflowing. The panel was viewed in both themes. npx tsc --noEmit clean, npm run build clean. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
03ec925aec |
The phone gets its picture back: a one-row icon toolbar, a histogram that shows the tones below the sky, and crop corners a thumb can actually grab
Three things were wrong on a 390px phone, and all three cost the photo its screen. The toolbar's six labelled buttons wrapped to three lines (141px of an 844px screen, 17% of it) and now sit on one 30px row of icons; the histogram read as an empty white plot; and a crop corner was a 9x16px target to a touch, so a thumb a few pixels off it moved the frame instead of resizing it. The toolbar keeps its labels in the DOM and only takes them out of the paint — font-size 0 on the button, the glyph on ::before — so every button keeps the accessible name it always had, and the two toggles still report their own state. Verify: measured on a 390x844 viewport with a real P1010256.JPG, the row is 30px tall, one row, buttons 34x30, `fontSize` 0px, the labels still in the DOM as text, and hist-toggle still flips aria-pressed both ways. The histogram was two separate faults, and the first one was invisible to the DOM. Measured: after upload or after a close/reopen the plot is an empty frame for ~100ms (4 paths land at 104ms / 126ms) — that is the mount, not a bug. But the SVG then carried four full-length paths whose own map was flat: the lum curve sat 0.9 of the panel high on ONE bin and every other bin measured 0.066 or less, so the photo read as a line along the floor. That is real: the sampled frame has 34% of its pixels on bin 255, and under a linear scale a blown sky owns the whole panel. The scale is log1p now — an empty bin still sits exactly on the floor — and the bins go from 1 bin above half to 226. The second fault was the paint: `.hist-ch` screens its three curves, which is how light adds up on a dark panel, and it is measured against the backdrop — so on the light theme (a white plot) it screened every channel straight to white. Screen is now gated to `[data-theme='dark']`; measured on the light theme, the plot went from 52 red / 39 green pixels to 931 / 1143 with the grey fill under them. The crop corner keeps its 16px square, which is the size a corner reads at, and grows only its touch target: 44px centred on the node, so the outward half is clipped by the photo's own layer and the target never overlaps the rect it would otherwise hand the drag to. Measured: 23x44px reachable around each node, and a drag from the bottom-right corner's centre still resizes (w 358 -> 297, h 201 -> 167) without moving the frame. Verified: `npx tsc --noEmit` clean, `npm run build` clean, and every number above read back off the built bundle in a 390x844 mobile context. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
34f8601c91 |
studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become shapes of the tone curve, the way the mapping spec measures them. The column. The left rail used to hand LIGHT a row of chips and nothing else; the four tone knobs were chips that opened a curve, and the rest of the develop state lived in the chip row's own vocabulary. `DevelopPanels` renders the five sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS — as an accordion, all open, and every parameter the recipe holds has a row in it with a `data-key` off the parameter name: slider, value readout, double-click to default. Sliders are always visible, so a knob is one drag away instead of two taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber, green through pink) so the direction is on the control. The PRO looks the spec marks stay in the list but locked, tagged PRO, and tapping one asks for PRO — they are shown, not hidden, and not silently dropped. WHITE and BLACK move out of the WB group. They were the temperature group's extremes, which is what a white balance control does — the toe and the shoulder of the same ramp — but the spec puts them with the tone knobs and gives them the two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights` and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives. A recipe written before this commit still reads: the keys are unchanged. The four knobs. The first pass of the mapping spec added a mask per zone onto the channel: `luma += knob * mask * intensity`, and the shader followed it. It is the wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the four masks are not a partition of the ramp. They sum to one at the ends and to zero at the midpoint, so an adjustment in the middle of a zone is applied where the mask is half and not at all where the mask has fallen to nothing, and the ramp inverts: with every knob at its stop the curve folds over itself, slope −5 at t=0.87, and the twin catches it as a non-monotone ramp. So each knob moves a knot on the curve instead, which is the reading the spec's own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50, HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a quarter of the range at that knot, so the range is 0.75..1.00 at the top and 0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`) by clamping each against its neighbour. Between knots the curve is a straight line, and 0.5 is untouched by every knob, so a knob at zero is the identity exactly rather than nearly, and any combination of the four is monotone. The mask sum survives where the spec is right about it: it hints the split between the two dark zones and the two light ones, nothing else. The hue is kept the way the spec keeps it: work in luma, then scale the chroma offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a saturated red stays the same red and only its brightness moves. The ratio is clamped to 0.55..1.35 because at luma near zero the division is the whole highlight of the picture on one code value. Verified: - `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader — four masks, four anchors, the four `mix` lines — and asserts the constructions it replaced are gone, then drives a twin of the ramp in JS: the masks do not overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162 combinations of the four knobs, every combination is monotone, the amplitude at each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded case from the additive build is in the harness as a regression. - `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`, `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check` and `white-level-check` all pass against the bundle — the catalogue, the RAW path, auto tone and the white level are untouched by the panel move. - Driven in a real browser (`tone-live-check.mjs`, Chromium against `vite preview`, a P1010256.JPG in the source control, mean luma of the preview canvas read before and after each knob): neutral 184.25, WHITE +1 187.35, BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80. Every knob moves the picture the way the spec says it should and none of them moves it much — a stop of a knob is a quarter of a zone, not a level. - The same run asserts the built DOM: five panels, the 23 `data-key` rows, `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and `dev-shadow` together in TONE, the gradient classes on the two white balance sliders, and the chip slots the panel is handed. The only failed request is `/api/events`, which is the backend this preview does not run. ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a per-channel rolloff in linear light — is gone, deleted rather than ported. The additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends, and the per-channel rolloff is a second knob for the same picture. Bring it back as its own parameter if a frame ever clips badly enough to need it. Also dropped: DR used to ride along as two additive terms. That is where the fold at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the ramp past its own end. It shifts the knots now, which is what the film sims always meant by it, and the numbers in the sims were kept and their meaning recommented (classic-chrome toe 0.22, head 0.7375, etc.). Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
426488a788 |
studio: say the roll is still being read while the catalogue is off screen
The reading of a folder belongs to the tab, not to the catalogue screen: it was
made to survive the hand-over to the studio in
|
||
|
|
53cb1c97e9 |
web: a frame is scored under the picture, and the wall is read through the score
The catalogue could say what a frame was and when it was shot, and nothing about
whether it was any good. A reader who had been through a roll of two thousand
had no way to say "these four", and the thumbnail wall drew every frame the open
folder held in the one order it had: newest shutter first, always.
The frame that is up now carries its own score — five stars under the picture,
and the star the click lands on is the score it gets. The star it already has
takes the score back, so a score that was given by mistake is taken off without
a sixth control. It is the catalogue's own row and nothing on the disk is
touched: the file is not written and not read, the browser only stores one more
number against a frame it already holds, and the number is the reader's.
The wall is read through three of them. The score is the first — four stars and
up is the floor, any rating is the wall as it was — because that is what a
reader who has just been through a roll wants next. The year the shutter fired
in is the second, offered as the years the open folder actually holds and not a
century of empty ones. The hours it fired in are the third, as a from and a to:
16:00 to 18:00 is the afternoon the reader was out, and either end alone is
"from 16:00" or "up to 18:00". All three read the shutter time, on this
machine's own clock — the same reading the frame's date line prints, so the
afternoon is the afternoon and not an offset of it. Beside them, the order: the
newest shutter first, the oldest first, or the most stars first.
What the filters hold is the wall and only the wall. The strip under the picture
is the shelf the open folder is, and a shelf that quietly loses three quarters
of its frames is not a shelf any more; the frame that is up has to stay reachable
while the wall is narrowed around it. That is also why the filters are drawn in
the thumbnail view: they are a way of looking through the shelf, not a property
of the roll, and nothing about them outlives the visit.
Verified:
library-check.mjs — 52 steps, all passed, five of them new. The frame that is
up is scored from the row under it and the catalogue holds star 4 against it
(aria-pressed on the fourth star true); the wall narrows to one tile at 4★,
to none at 5★, and back to three with the rating cleared; the year 2016 holds
the two JPEGs of three and 16:00–18:00 the same two, out of a roll whose
frames are written years apart in different parts of the day (the JPEG in the
afternoon of 15 Feb 2016, the RAW at seven in the morning of 1 Jan 2026);
read by score, the scored frame comes first. Every earlier step still holds,
including the two that count what a reading costs and the stand-in folder's
two frames — the JPEG carrying the GPS tag and the RAW printing
"26.4mm · f/2.8 · 1/320s".
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the score is one number on the frame's own row — no rating table, no
votes, no accounts; the catalogue is a per-browser shelf and so is the score. The
filters live in the thumbnail view's own state, so a reload starts with the whole
shelf again, which is what a filter is for. No text search: a roll of date folders
is three levels deep at most, and the three that a reader actually digs with are
the ones here — add the box when a folder of mixed names makes one worth typing
into.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
fbe9a1bb5b |
web: a roll is read where it was left, and only for the frames that moved
A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.
The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.
A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):
first reading 5209ms
the same roll again 2887ms 24/36 frames read again
first reading 5320ms
the same roll again 603ms 0/36 frames read again
And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.
The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.
Verified:
library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
three marks, the row menu's four, the add-only menu's one, the refused
folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
itself (three rows back, and the bytes read are the two frames the
catalogue had lost, not the one it still held), and the folder read again
from its own menu. The stand-in folder now carries `__fake` on both handle
kinds — a folder handle that does not is one the screen cannot ask about
after a reload, which is a folder it offers to reconnect — and the frames
it hands out report one write time instead of `Date.now()` per call, which
is what a real handle does and what a frame is skipped on.
scan-nav-check.mjs — all passed, the row still counting the reading as it
comes rather than the catalogue standing still. roll-walk-check.mjs — all
passed. frontend tsc --noEmit clean.
ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.
Co-authored-by: PenguinHarness <noreply@penguin.local>
|
||
|
|
36cd711302 |
web: the header wears the theme, and the rail says which build it is
Two things the studio could not say about itself.
The preset the header has open was painted in the dim text colour, the same grey
as the page name beside it — so the one label up there that changes with the look
read as chrome. It now takes the theme's own accent, the colour the brand mark
and the open tab already carry, which means it follows both the light/dark mode
and the accent group the reader picked, out of the CSS token and with no colour
written into the component.
And an image has no other mark on it: once the frontend tarball is loaded on the
NAS as `:latest`, nothing on the box says which revision came off. The rail now
names the build at its foot — quiet, mono, wrapping rather than widening the
column, and gone under 860px where the rail is the phone's scrolling tab bar
instead of a column.
The name is stamped into the bundle at build time: vite.config reads VERSION
from the environment (`docker compose build frontend --build-arg
VERSION=$(git rev-parse --short HEAD)`) and falls back to the package version
plus the minute it was built, so two builds of the same tree are never the same
name. `ARG VERSION=` in the Dockerfile is the knob; the bare `npm run build` —
dev server, check scripts — still stamps its own time, and the dev server passes
nothing at all, so the line only draws when there is something to say.
Verified:
library-check.mjs — 37 steps, all passed, the two new ones reading the header
off the running studio: the rail foot names the build (0.1.0+202609281504)
and the preset chip's computed colour is the brand's accent, not a grey —
rgb(206, 117, 9) amber, rgb(93, 24, 191) after switching to violet.
scan-nav-check.mjs and roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean. Live 8090 on index-… matching dist/: /, /library and /app 200 with 0
console errors.
ponytail: the name is the package version plus a caller-supplied tag, and nothing
bumps the package version, so the tag is the whole identity — have the release
job write it into package.json if the numbers ever need to mean something.
|
||
|
|
067b32131e |
web: drop the heading over the folder column
The column carried the name of the folder its tree belongs to, painted above the rows. The rows already say it — the head of the tree is a row like any other — so the heading only said it twice, and because it was not a row it stayed on screen when the tree was folded away: the one label left with nothing under it. The right click that raised it now finds the row itself, which is where a reader aims anyway. The label, its menu hook, its translation and its rule go; the tree, the row menus and fold-all keep working as before. Co-authored-by: PenguinHarness <noreply@penguin.local> |
||
|
|
2c78ff80e8 | web: fold a folder's rows without folding its frames | ||
|
|
2b45419ec4 |
web: scan the whole roll, title and size the column, reopen where you left
The tree column now walks every folder under the one you picked instead of its first level, so a roll of dated folders inside dated folders is walked to the bottom. A folder being read turns a ring on its own row, the column is titled with the folder the tree belongs to, and a divider beside it drags the width, which is remembered along with the folder the screen was left on. |
||
|
|
4c65923eff |
web: rename a folder, and add one from the empty column
A folder's own menu gains a rename, which paints a label over the folder without moving it — the frame ids and the recipes hang off the directory's name, not the label. The empty part of the column answers a right click with ADD FOLDER, the FOLDERS heading goes (the count already rides on each row), and the column narrows to 118px so the stage takes the room. |
||
|
|
19fd5b644f |
web: trim the library to one toolbar and a folder menu
Fold the hint, ADD FOLDER and the two view switches onto a single row, with the switches drawn as icons, and move a folder's rescan and remove onto a right click — they belong to the folder, so they are asked for rather than always on screen. |
||
|
|
12936731b3 |
web: keep the catalogue on one screen
Pin the thumbnail strip to the bottom edge of the window, let the wall of thumbs scroll inside the stage with its own scrollbar, and letterbox the preview so the frame fits the screen instead of growing the page. |
||
|
|
81637b8502 |
web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library now takes a folder through Chromium's directory picker, keeps the handle in IndexedDB so the folder is there on the next visit, and walks it into a grid: one thumbnail per frame, the frame's own date, and the recipe it was last graded with. Nothing is uploaded and nothing is read twice — the RAW itself is opened only when a tile is clicked, at which point the studio develops it and the recipe comes back on top. The studio files every change back against the frame, debounced, so reopening a RAW is not doing the grade again. Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and a copy where a develop is a full decode of every pixel, and off createImageBitmap for anything else. A RAW with no preview inside it gets a placeholder tile rather than a minute of decoding per file. node scripts/library-check.mjs ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2 ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview) ok studio developed the frame from its handle ok the address was handed back — url=/app (no ?lib= left behind) ok the look was filed back against the frame — baseFilter=none, 19 knobs ok the tile says the frame is edited |
||
|
|
6814b055cb |
web: frame through the recipe, with the viewfinder where the stage was
The live view is the one place where the look is chosen before the picture exists, so the columns that hold it have to stay in reach while the camera is open. The viewfinder was a fixed black sheet over the whole app: the rail and every slider were behind it, and the only way to change the look was to close the camera, grade the file, and open it again. It now covers the stage and nothing else — the stage and the view share a slot (`.stage-slot`, position relative) and the view is absolutely placed inside it, z-index 5, black in either theme. Measured at 1600px: the rail and the columns are on screen, the feed takes 1347px of the 1600 and the stage's own 253px rail the rest. A slider moved with the camera open reaches the very next frame (the render loop reads the recipe per frame, so nothing had to be wired), a monochrome sim takes the live feed from sat 42.1 to 0, and the shutter hands the studio that same frame with the sim already on it. The preview render is skipped while the viewfinder is up (`|| shooting` in the guard, and in the effect's deps): it would run the same recipe through the same renderer as the frames the user is actually watching, and the live one wins. Opening it is PRO, like the HSL chips and the geotag — the frames are the paid ones. The button stays on the page for everyone and carries the badge, and the click runs the app's own `promptPro`: the account dialog for a guest, the verification panel for a signed-in account, `/api/auth/me` being what says which. A verified account gets the camera; a guest gets `.modal-backdrop` and no `camera-view` at all. Probes: cam-pro-gate (guest sees the badge, the click opens the dialog, no viewfinder; verified opens it), cam-live-edit (rail and columns survive the live view, feed covers the stage, the recipe reaches the live frames and the shot), cam-smoke, cam-renegotiate, cam-close-flip. |
||
|
|
cb1839e36b |
web: shoot through the live camera
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). |
||
|
|
1f6c97be62 |
web: let a mask's own edge turn it, and another chip put it down
A mask could only be taken hold of by its pin or by one of the handles, and the pin of a linear mask sits on the middle of its own line, so the press a hand aims at "that line" was the press that moved the shape. What it moved by was worse: a ramp is stored as the two points it falls between, and 'move' measured the hand against the shape's first point, so the first move of a drag put that end under the pointer and threw the rest of the ramp sideways by half its length — the shape landed somewhere off to the side of the hand instead of under it. The move now takes its delta from the pin the press landed on, which is what the press wanted, and which for an ellipse was already the centre it moved by. The drawn outline is the shape's own handle. A press on the line a ramp falls across — any of the three, anywhere along it — or on an ellipse's rim, turns the shape; the nodes that resize it sit on that same outline and are drawn over it, so a press on one of those is still a resize. That is the Lightroom gesture: the edge is what you drag to aim a gradient, and the ends and the axes are what you drag to lay it out again. Until now a press on the edge was a press on the layer, which read it as the start of the next shape and laid a second mask down while the first was being turned. The band is sixteen screen pixels wide and does not scale with the zoom, so a finger finds it at any size of photo. A mask is chosen by pressing it on the picture, so the keys the hand is on are DELETE, which is what the chip beside the photo already says. And the column the chosen mask puts up — its name, DELETE, and its knobs as rulers — belongs to the mask tool: another chip puts that tool down and takes the column with it, because a photo still armed to draw shapes is not what a hand reaching for a knob is asking for. The shape stays chosen and stays live in the render; the mask chip brings its column straight back. ponytail: Backspace is read as DELETE as well, since that is the key the label sits under on a Mac keyboard, and is the one shortcut this adds. A mask can still only be chosen while a mask tool is armed — the shapes answer the pointer through the layer that only exists then — so the column coming back with the mask chip is also the way back to a shape drawn a moment ago. Verified: tsc clean; mask-probe 50/0 on the dev server and again on 8090 — a ramp lands with its pin on its middle, dragging its own edge turns it (ends at 0.200 and 0.800, one pin, no second shape laid), the same drag on an ellipse's rim turns that (90deg -> -39deg, one pin), the pin takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw), another chip leaves 0 mask columns and the mask chip brings 1 back, and DELETE takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/tone-curve/ hsl-panel/grain-controls/chip-edge/chips-desk/slider-reset/temp-swatch all ALL PASS, backend 180/0. panel-test (4), histogram-wb (1) and studio-save-hl fail exactly as they do on the build before this one, on their own tabs. |
||
|
|
b568fa3fdc |
web: let FX carry Lightroom's two gradient masks, and grade inside them
FX had two tools that change the photo where it is — HEAL repairs a speck, MOSAIC hides a patch — and every knob that graded the frame graded all of it. The scratchpad's gradient_mask.md asks for the two local adjustments the phone's own editor has and Lightroom made familiar: a linear gradient and a radial one. This is that spec, written for the renderer this app actually has. A mask is a SHAPE rather than a value, so it is dragged rather than turned: the LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at the press, one at the release, the spec's own convention, which is what makes the same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre is the press, whose semi-axes are the drag's own distance and whose axis lies along the direction the hand went, so the circle a drag describes is the circle the mask starts life as. Both shapes keep a pin (the whole shape travels by it) and, while chosen, the handles that move the ends or the axes and the one that turns the ellipse; what is drawn is the shape the render will read, so the ramp and the rim are visible before a knob is moved. Inside the shape, three knobs grade in the spec's own order and its own maths: exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle, saturation as a mix away from the pixel's own REC-709 luma — the mixer's -10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades over, which is the fraction of its own axis the alpha holds full before it dies at the rim. Several masks run in the order they were drawn, each reading what the one before it left, which is what a stack of local adjustments is. The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL runtime effects), so it is ported stage for stage: one pass, after the frame-wide grade and the vignette and before HEAL, because a local adjustment is part of the look and not a repair — the pixels a repair borrows are then meant to carry the mask's light already. Preview and export both come through renderPhoto, so the file carries the masks the stage is showing by construction, and the shape and the knobs ride in the recipe's own JSON, which is what makes them survive a save. The chips sit with HEAL and MOSAIC because all four take the pointer on the photo, and they are exclusive with every other armed tool, the eyedropper included — while a mask tool is armed the layer takes the photo, so a drag means "draw the next shape" and a press on a pin means "take hold of this one", which is why the shapes already laid are answered through their pin and handles alone. A knob drag on a mask is one undo step, a shape drag is one more, a press that only chose a mask records nothing at all, and RESET is the way back with the whole frame as it was imported. ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is Lightroom's per-mask invert and colour/tone range. The three knobs are what "gradient mask" means until a photo shows a sky that has to be rescued apart from the grass under it; the md itself calls it an upgrade, not the feature. Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two chips, both shapes drawn and moved and turned, the ramp read off the pixels — 61 -> 244 at the release and 61 at the press — the feather read off the rings, DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/ tone-curve all ALL PASS, backend 180/0. |
||
|
|
01af863fd8 |
web: let a photo's repairs read as the marks they are, not as a field of rings
HEAL draws every repair it holds as the circle the shader fills — the brush's own size at the moment it was laid — so a photo with two repairs has two rings and a photo with twenty has twenty. Each one is loud enough to be the loudest thing on the picture, and none of them is the one the user is looking for: the speck that was mended, and the patch borrowed to mend it, are both inside the ring that covers them. What the ring is for is the spot that is about to be taken hold of, and there is only ever one of those. A repair now wears its circle only while it is the spot in hand — the one the pointer is over, the one just laid, which is the one the user is watching, or the one a press chose, which is the one wearing the ×. The moment the pointer walks elsewhere the ring goes, and what is left is the pair the renderer works with: the patch it borrowed, still dashed, and a soft print of the place it mended. The ring comes back under the pointer, which is where the spot is taken hold of again; the grab was always the geometry — the circle plus a few pixels of slop — and never depended on the ring being drawn, so an idle spot is as easy to move as a ringed one (measured: a 60,40px drag on an idle spot moved it 970.2,390.7 -> 1030.3,430.7). The run a stroke left is the mark of that stroke, so the spots the band stands for keep their boxes and give up their own edges as before, and no print is laid under the band: a row of blurred discs under one translucent band is a second, blurrier band. The one spot of the run that is in hand wears its circle again over the band, because that is the spot a press would take hold of. MOSAIC keeps its rings. Its spots are not repairs to be placed and moved — the circle is the only thing that says where the cover is, and the cover is the edit. ponytail: the print is a blurred translucent disc (14% grey, a soft dark shadow, 1px of blur) rather than a tint read off the pixels under it, so it reads on a photo of any tone without a second pass over the render — the upgrade path, if a print that sits on the repaired pixels themselves is wanted, is the shader the repair already runs through. The ring under the pointer is reported from the pointer's own move events rather than from a hit test per frame, so the one case it does not cover is the pointer that has not moved since the repair landed; that case is covered by the spot just laid being in hand, and a twitch of the mouse covers it everywhere else. The idle/hover split is HEAL's only: MOSAIC's spots keep the border they always had, which is the asymmetry this tool set already had about moving them. Verified: heal-idle-probe.cjs (new, 23 checks) 23 PASS / 0 FAIL on :5199 and on :8090 after deploy — a press on the speck lays one repair and that repair is in hand, so it wears its circle (rgba(255,255,255,0.85)); a repair the pointer has left is idle with the ring given up (rgba(0,0,0,0)), the print of the place it mended left (rgba(127,127,127,0.14)) and the patch it borrowed still dashed and in the same place (640.3,297.9 vs 640.4,297.9); the ring comes back under the pointer; a press chooses it, puts its × on the photo and the × stays while the pointer walks off; laying the next repair takes the choice and the × off the first and gives its ring up; hovering the second leaves the first idle; a drag still takes hold of an idle spot; a run of 13 spots shows the band, no print under it and no ring while the pointer is away, and the spot of the run under the pointer wears its circle again; a MOSAIC spot keeps its ring; 0 page errors. brush-edit-probe.cjs 33/0 — its "every spot of the run gave up its own edge" now reads "but the one in hand", which is the rule this commit adds, and its "a spot with no neighbour keeps its own circle" passes off the repair just laid being in hand. heal-zoom-drag-probe 28/0 (the wheel, the pan, the × and taking hold of a repair, at the fit and at x1.52, unchanged), heal-blotch-lab 12, heal-edge-lab 9, heal-seam-lab 10, heal-skia-lab 28, heal-search-lab 15, heal-probe 49, heal-zoom-geom 5, heal-zoom-probe 8, mosaic-skia-lab 27, mosaic-probe 51 — all green on :8090. Regression: landing-test 172/0, pro-gate-test 27/0, award-column-probe 18/0, otp-code-probe 10/0, tone-curve-probe 42/0, rc=0. Backend npm test 180 passed, 0 failed. npx tsc --noEmit clean. |
||
|
|
abfc6595e7 |
web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.
HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.
The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.
Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:
- The × first sat on the spot's corner at the brush's own radius. The default
brush is 6px wide and the badge is 18px across, so the badge covered the
circle: the next press on the repair — a user putting the spot down again —
deleted it. Measured: after undo/redo, a press at the spot's centre left one
spot instead of two. The badge now sits on the top-right diagonal at the
circle's edge plus a badge's radius, so it can never take a press meant for
the spot.
- The hit test first took the hole before the patch. On the default brush the
patch the search borrows sits about 8px from the hole it fills — inside any
reach a pointer can use — so dragging the patch's own centre grabbed the hole
and the patch never moved (measured: dragging the patch from (0.331, 0.300)
to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
instead). The nearest circle now wins, and the hole wins a tie with its own
patch.
- The reach was first the circle plus 8px, for a brush turned down to a few
pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
12.8px apart were meant to lay eight repairs and four of them landed, because
four were within 8px of a patch circle and grabbed the spot instead. The
reach is now the circle plus 4px: the same probe is 49/0 and the patch's
centre is still 0px from the pointer that grabs it.
MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.
Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):
brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
brush's width through all 15 spots of a drag, its length inside 2px of the
polyline the spots stand for, every joined spot's border transparent and a
lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
and forth in one step each; dragging the patch onto the neighbouring speck
puts the speck back into the repair (level 5 on a field of 151) and UNDO
returns it; a press chooses a spot and shows the ×, that press records no
step (the next UNDO still takes the last repair back), the × deletes that
spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
are one band and every cell in the run joins it, painting across mosaic
already laid down paints more cells, and no mosaic spot is ever offered an ×.
Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL.
Regressions against the rebuilt app, rc=0, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
tsc --noEmit clean.
ponytail: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
|
||
|
|
f1385d8a08 |
web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.
What a mosaic spot does is destroy what it covers rather than replace it. The
frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the
probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the
colour found at that cell's own middle, read with img.eval so the block is the
snapshot's bilinear tap and not a neighbour's cell. What is under the circle is
still a picture of that place, at a resolution nothing can be read out of. A blur
was never in the running: it leaves the SHAPE of what it hides — a face under a
blur is still a face, a plate still a plate — and the arrangement is exactly what
the user is asking to keep to themselves. Cells coarse enough to lose the
arrangement are what "do not show this to anyone" needs, and the blockiness is
the price of it.
The cells are one grid over the whole frame, not one grid per spot: a pixel's
cell comes from its own position, and every block reads the snapshot rather than
the output, so two overlapping spots never pixelate a pixelation and a run lays
one band with no seam where its circles cross. The rim is hard for the same
reason in reverse — a feather would mix the cells back into the sharp photo along
the edge, which is a half-hidden thing leaking the arrangement it exists to hide.
A mosaic spot borrows nothing, so the layer draws no donor circle beside the
cursor: the second circle appears only when a spot has a source ('sx' in it),
which is the one place the two tools' DOM parts company. Each tool keeps its own
brush size, and each CLEAR chip clears only its own list, because the size a
dust speck is healed at is never the size a face is hidden at.
The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions
HEAL stores, and readMosaic guards them the same way — and the renderer builds
one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the
pass sitting right after the heal pass so a repair made on the same photo ends up
underneath the cells that hide the rest of it. The backend needed nothing: a
recipe is spread through as it stands, so a saved photo keeps its mosaic and a
shared one opens with it.
Verified:
mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27
passed, 0 failed: the cell rides in the frame block in the render's own
pixels and is a fraction of the WIDTH, so it is square on any shape; 4912
cells inside a spot each carry one colour, and 164/164 of them carry the
colour at their own middle; the 2px white dot on the dark square reads
250 -> 20; nothing outside the circle changed (0 stray pixels) while the
cells reach the rim (852 pixels at the edge); a spot wider than the frame
still runs; overlapping spots share one grid over 6335 pixels with 0
differing between them (no cascade); readMosaic refuses a zero radius, an
off-photo spot, junk and a missing list, and keeps a forty-spot list whole.
mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL,
no page errors: FX offers a MOSAIC chip that arms the same brush layer and
says which tool it is painting for; the wheel sizes each tool on its own
(8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot
with no borrowed patch beside it; the pixels of the cell are one colour (0
levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing
outside the circle changed (0 pixels, worst 0) and the cells are not the
photo that was there (221/509 pixels changed); UNDO gives the photo back
exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide
as the brush, standing for 5 points of travel and laying 5 spots that leave
0 pixels outside them changed, with the step within a cell 3.43 levels
against 21.25 between cells (635 + 157 pairs) — the cells are flat and their
borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC
hand the pointer over and back with each tool's spots intact; CLEAR hands the
photo back pixel for pixel and leaves no chip behind.
The probe's own reading is deliberately a shape, not a colour: the app's
preview is the engine's render at preview scale with a JPEG on top (and its
auto dynamic range), so a cell's colour read back from the base would be two
encodings apart. The exact cell colour is the Skia lab's claim, where no
encoder sits between the shader and the reading.
heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15,
heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush
HEAL paints with is the one MOSAIC now paints with.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
tsc --noEmit clean.
ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
|
||
|
|
b795517d9f |
web: read a patch's light before pasting it, and paint with the brush
The brush was not healing: clicking a speck deleted one black spot and made
another, and the borrowed patch landed in a light the spot was not in, so the
repair read as a mark of its own. The circle the brush draws also slid off to
the side of the pointer as soon as the photo was zoomed in, and a drag showed
itself as a row of overlapping circles rather than as a brush being drawn.
The search was comparing the wrong thing. findHealSource scored a candidate
against the spot's own PATCH_TAPS — the centre and a ring at half the radius,
which is INSIDE the brush, where the dust is. The patch that matches a speck
best is then the one carrying a speck of its own, which is exactly how "heal a
spot" became "move it a few pixels": with a neighbour sitting at the 2.6r ring
the search itself prefers, the winner was that neighbour, 26 dark pixels pasted
where the repair was meant to be.
The taps are split now, by what they are for. The light a repair has to sit in
is read off the spot's RING — twelve taps at 1.15r, just outside the dust, the
scale the eye reads a spot's surroundings at — and taken as their MEDIAN,
because the ring can only be a little way out: some of its taps land on the
speck's own softened edge, and a mean drags the whole light down by them (the
eight-tap mean read 84 where the ground was 150, and with the gate below that
refused every candidate on the frame). What a candidate would actually paste is
the mean of its own inside taps, now including the ring at HEAL_FEATHER of the
radius — the circle is copied at full strength out to there, so that is where a
neighbour's dust leaking into the patch shows up and the middle of the patch
would never see it — and its cleanliness is how much those taps spread around
their own mean: dust is an outlier in its own neighbourhood, grain is not.
A candidate from another light is not scored at all. Past LIGHT_GATE (20 levels
of the 0-255 the sampler answers in) the patch IS the mark the user is
complaining about, so the search returns null rather than sending a wrong clone
and the caller leaves the speck alone. Within the gate the score is light * 3 +
cleanliness, so the light decides and cleanliness breaks the ties the eye would
not see. A spot the search refuses is not laid down at all — healUp skips it
instead of recording a self-patch, which was a repair that changed nothing —
and a stroke that is refused end to end reports no spots, which addHealSpots
already treats as nothing to do: no step in the history, no spot on the photo.
The ring had to be a fraction, not an offset. healPos was the pointer's pixels
inside the layer, and the layer carries the stage's transform, so a zoom scaled
that offset a second time: at 1:1 the pointer sat at screen x 846.5 and the
ring was drawn at 1288 — 442px away, the same distance the user sees as "the
circle is in the wrong place when I zoom in". The pointer is stored as a
fraction of the photo now — healPoint already answers one for the spot it lays
— and drawn as a percentage of the layer, so the layer's own transform scales it
once; off the photo there is no ring. The eyedropper's icon had the same shape
of bug (its sample was always right — pickAt reads the photo's own rect) and got
the same fix in the same file, since it was two lines.
The stroke is one mark of the brush. The trail was a circle per point of travel,
laid one HEAL_SPACING (0.6) radii apart, which is what a row of beads looks
like; it is one SVG path with round caps and round joins now, its width the
brush's own diameter and its colour the accent at 45%, so what the pointer draws
reads as the band it is about to lay down. The count of travel is kept on the
element (data-points) so the probe can still hold the run it becomes to the run
it showed.
Verified:
heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS,
0 FAIL: one speck alone is repaired, from a patch that is clean field, and
its light is 0.0 levels off the spot's own; a speck with a neighbour exactly
at the search's first ring borrows from the far side with 0 dark pixels
pasted; a speck ringed with dust in all eight directions skips past the ring
(0 pasted); a speck in the corner stays inside the frame; a speck at the lip
of a shadow, where every reachable patch is 60 against a ground of 150, is
refused (null); ground with a dark edge through it is not a refusal — the
repair comes from the light side and its light is 0.0 levels off.
heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in
everything the search is not asked here).
heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL,
no page errors: the circle rides the pointer at the size the chip reads; one
click heals a speck to 151 with its four neighbours field; the borrowed
patch is a real distance away and is clean field; a drag shows ONE mark,
6.1px wide against a 6.1px brush, standing for 15 points of travel, lays
exactly 15 spots, clears on release, and UNDO takes the whole stroke back;
25 spots carried with the first healed speck still first; everything gone
after a reload; CLEAR brings it all back.
heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen
centre is the pointer (846.5,452.5 both times, against 1288 before), the
ring keeps the brush's size on screen, and a repair made at a zoom lands
under the pointer.
heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the
speck goes, the donor is at least a ring away, the patched circle is within
1.07 levels of the ground it landed on, the donor's own circle is drawn on
the pixels it borrowed; on a navy field with three specks, two repairs land
0.0 levels from their ground.
heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to
paste its neighbour and read min 3 inside the healed circle — the pasted
dust — and reads 151 now, the untouched second speck alone in the frame;
the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all,
which is the refusal working: the speck is left alone instead of smeared.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: a refusal leaves the speck on the photo and nothing on the screen —
the user closes the brush in a size that covers it and clicks again — which is
the honest half of the trade the user asked for, but it is silent; a hint would
mean a toast or a shake, and neither is worth a component. The gate is a
flat 20 levels, not a percentage of the local contrast, so a photo with a hard
edge through the brush's own ring reads as one light and can still take a donor
from the other side of it. The search reads the preview JPEG rather than the
original, so a patch near the preview's own edges is chosen from the pixels the
user is looking at, not from the ones the export will print. And the run a
stroke leaves behind is still drawn as its spots, circle by circle, because each
one is a repair with a borrowed patch of its own — drawing the laid run as one
band would need the recipe to remember the gesture (a stroke id on the spots),
which is a recipe change and not what was asked.
|
||
|
|
88cff5ca87 |
web: draw the dust brush into strokes, size it by the wheel, uncap the list
A speck of dust is small and there is never only one, so the brush had three
things wrong with it: the list stopped at sixteen and the seventeenth repair
pushed the first one out of the shader, the size was a choice of three buttons,
and one gesture laid exactly one spot — a scratch across a hundred pixels was a
dozen clicks.
The cap is gone rather than raised. SkSL indexes a uniform array by a constant
only (the trick the tone curve's mixer already uses), so HEAL_SKSL carried
sixteen unrolled blocks and the list was trimmed to fit them. The shader is now
built for the count it is handed — healSkSL(n), with healUniforms returning
(n * 2 + 1) * 4 floats, the same declaration order for any n — and the renderer
caches one compiled effect per count (exportEngine's healEffectFor). readHeal
no longer slices and the app appends whatever a gesture reported. No repair is
dropped to make room for a later one: the speck healed first is the speck that
stays healed.
The wheel is the size now. wheelHealR multiplies the radius by
exp(-deltaY * 0.0015), so a trackpad's small deltas and a mouse's 100px notch
are the same gesture at two speeds, bounded at 0.3% and 25% of the photo's
width — below the first a spot is finer than the pixels it is drawn on, past
the second it would borrow its patch from off the frame. S, M and L are gone,
and because there is nothing left to point at, the HEAL chip's own readout is
the size: the number the brush is set to is the number on the chip.
The pointer paints. Down starts a stroke, move adds a point every HEAL_SPACING
(0.6) radii of travel, and up turns the whole run into spots in one report — so
a stroke is one undo step however long it was, and the trail drawn while the
pointer is down is a preview of that run, in the accent, cleared the moment the
spots land. The part of a stroke that leaves the photo lays nothing down, and
the pointer is captured so a stroke that runs past the edge ends where the
pointer does rather than leaving a spot hanging at the frame.
The wheel had to be stopped, not merely claimed. The heal layer is a child of
the stage, and the stage has its own wheel listener that zooms the photo, so a
wheel over the brush grew the brush AND zoomed the view: the probe caught it as
a cursor circle 15% wider than the readout it was drawing. The layer's listener
(native, because React's own onWheel is passive) now stops propagation — while
the brush is up, the wheel sizes the brush and nothing else.
One number moved that none of the three asks mentioned, and it is what the
probe's remaining failure was about. The feather band was 45% of the radius,
and that band is the only place the pixels being repaired are mixed back into
the patch, so with the default 6px brush it left a ring of the speck's own edge
one pixel inside the circle (115 in a field of 150) — which the preview's own
JPEG then rang around, reading 177 a pixel off the centre of a repair that
should be flat. Narrowing the band to the outer 15% copies the patch over
everything inside 0.85r: sub-pixel at the default brush, still a soft edge at a
big one, and that pixel now reads 151.
Verified:
heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 27 PASS,
0 FAIL: the shader for a count compiles through RuntimeEffect.Make and its
uniform block is (n * 2 + 1) * 4 floats (n=1 -> 12, n=40 -> 324); a single
spot copies the donor exactly and leaves the rest of the frame untouched,
pixel for pixel; forty spots are carried whole with the first and the last
both drawn; three spots in one run each borrow their own patch; readHeal
clamps and drops zero-radius spots and no longer trims the list;
wheelHealR grows, shrinks and clamps at both ends (0.3% and 25%); the
search finds a patch and still refuses a brush that covers the frame.
heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
48 PASS, 0 FAIL, no page errors: the circle under the cursor is exactly
the size the chip reads, before and after a wheel, and the wheel grows,
shrinks, stops at 25% and at 0.3% and returns to where it started; there
are no size chips left; one click is one spot, the speck reads 151 at its
centre and its four neighbours are field too; a drag shows at least three
trail circles, lays exactly that many spots, clears the trail on release,
and UNDO takes the whole stroke back at once while leaving the repair made
before it alone; REDO repaints it; a bigger brush takes a ten-pixel blob;
twenty-five spots are carried with the first healed speck still first and
still healed; every speck is gone after a reload; CLEAR brings them all
back and lays no spot of its own; the chip goes amber only while spots are
on the photo.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: a stroke's repairs land when the pointer comes up, not under it as
they are painted — a live repair would mean recompiling the pass and re-cutting
the preview per point mid-gesture; the trail is what the pointer has drawn, and
it is drawn in the accent so the difference reads. The list is uncapped, so a
runaway stroke pays one shader compile per distinct count it reaches, cached
for the rest of the session: a ceiling would have to come back with the trim.
The search still has no colour-matching term, so the donor is chosen by
resemblance alone, and the spots still live in the rendered photo's
coordinates, so re-cropping or re-rotating after healing slides them.
|
||
|
|
3ee0137d0d |
web: repair dust with a brush that borrows a patch of the same photo
A sensor speck is not a filter: it is a small lie in one place, and every
slider in the panel is global, so there was no way to say "here, and only
here". The FX row now has a HEAL chip. Arming it turns the pointer into a
circle you can size S, M or L, and every click on a speck covers it with a
patch of skin borrowed from a few radii away — the repaired sites persist in
the recipe like any other edit, and UNDO takes them back one click at a time.
The spot is stored in the rendered photo's fractions, not in the preview's
pixels: x, y and a radius that is a fraction of the photo's WIDTH, so the
circle stays round on a tall or a square frame and the same recipe heals at
preview resolution and at export resolution without a second code path.
`readHeal` is the only door in, and it validates, clamps and drops the spots
with no radius before anything downstream sees them.
The source patch is searched for, not asked for. `findHealSource` walks eight
directions at three distances — 2.6r, 4.2r, 6.5r — and each candidate's mirror
through the spot as well, scores every one with a nine-tap comparison of the
neighbourhood, and hands back the first that actually resembles the ring around
the speck. When nothing fits — a brush wide enough to swallow the whole frame —
it returns null and the click is refused rather than smearing a wrong colour
over it. There is no colour-matching model here and no second draggable source
circle: Lightroom lets you place the donor, this finds one.
The pass runs last on the photo's own pixels. It is inserted after the grade,
the curve and the grain and before the frame, so the patch it pastes is copied
from pixels that have already been graded and grained — it matches by
construction, with no second copy of the pipeline to keep in step — and the
frame, the card and the watermarks are drawn over the result, so healing can
never erase the furniture of the render. The brush is a feathered circle at
0.55r, which is what keeps a repair from reading as a sticker.
SkSL indexes a uniform array by a constant only, so the shader is the block
unrolled HEAL_MAX = 16 times, the same trick the tone curve's mixer already
uses. Sixteen is the ceiling and the oldest spot falls out when the
seventeenth arrives. CLEAR drops the whole field — turning the chip off keeps
the repairs, which is the distinction between disarming the brush and undoing
the work.
Verified:
heal-skia-lab.cjs (scratchpad, Node + the full CanvasKit build) — 15 PASS,
0 FAIL: HEAL_SKSL compiles through RuntimeEffect.Make and
makeShaderWithChildren; the uniform block is 132 floats in declaration
order (16 spots + 16 sources + size, w/h/feather); a dust speck pinned on
the canvas comes back as the borrowed patch while the rest of the frame is
untouched, pixel for pixel; readHeal clamps, drops zero-radius spots and
caps the list at 16; the search finds a valid donor and returns null for a
brush that covers everything.
heal-probe.cjs (scratchpad, the rebuilt app at http://localhost:8090) —
29 PASS, 0 FAIL, no page errors: the cursor circle is 2 x 0.012 x width and
centred on the pointer, L is visibly bigger, S and L are exclusive; one
click is one spot; a speck at 151 reads 154 at its centre after the heal
and the photo's other specks and empty skin are unchanged; the spot and its
borrowed source are both drawn; the chip goes amber; CLEAR appears and
restores everything; UNDO (the TopBar button) brings the dust back and REDO
heals it again; three specks and one L-sized blob all go; the repairs
survive a reload.
Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed.
web tsc --noEmit clean.
ponytail: spots live in the rendered photo's coordinates, so re-cropping or
re-rotating after healing slides them — re-heal or CLEAR when that matters; a
coordinate space pinned to the sensor would need the crop and rotation to carry
the spots through. No live brush-size gesture and no colour-matching term: the
donor is chosen by resemblance alone, add a colour term if skin tones ever
mismatch. The list is capped at 16 with oldest-out rather than refusing the
seventeenth click.
|