# Changelog All notable changes to screengraft. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). One convention worth knowing: entries say what was **measured**, not what was attempted. Where a change was driven by a real photograph or a real failure, the numbers are here. ## [0.67.0] - 2026-09-21 Chrome, copy and busy states, all measured. Three of the changes below are declarations that had never applied at all — a CSS shorthand and a `var()` naming a token that does not exist both fail silently and look correct in the source. ### Added - One busy state for every slow operation — `busy(el, label)`. It arms after 250ms, so a fast operation never flashes a spinner it did not need, and it counts seconds past 2, because a number that keeps moving is the difference between slow and stuck. Detection, Point at screen, loading a `.fit.json` and loading the photograph all use it. Why it was needed, measured on a 4000x4000 photograph: the cold fetch and browser decode take **0.15s**, and `scripts/detect.py` takes **11.24s** — it runs at full resolution. The wait was always detection, and a static "Detecting..." held for eleven seconds is indistinguishable from a page that has stopped. - `Esc` and the trackpad pinch are in the Keys legend. Pinch has always zoomed — the wheel handler takes `ctrlKey` as well as `metaKey` — and was never written down. `Esc` is the way out of the fit canvas's Tab cycle, so the one line saying how to get in had no counterpart. ### Changed - Every Button and Segmented-control token re-read from the design file. The button had no token of its own for the REST state: it borrowed the shared raise/edge ladders, and both had drifted. `--btn-rest` `#2b2c31` and `--btn-rest-edge` `#46464e` complete the family; `--btn-hover` `#2b2b30`; `--btn-press-edge` `#46464e`; `--acc-dis` `#992800` with `--acc-dis-edge` `#f23b0d` (its old value is PRESSED only, so `--acc-press-edge` now exists). The disabled primary label measures 7.53:1 on the darker container. - Weight 600 is gone from every control. All sixteen Button variants and both segments are regular, which also retired the segmented control's hidden ghost label — it existed to stop the control reflowing when the selection changed weight, and there is no weight change now. - Save and Send to Claude are ordinary Default buttons. They carried an accent class so they could be accent-coloured **while disabled**, which meant a cold load showed two lit buttons offering actions that could not be taken. - The Keys legend is a description list, one key per row, with the caps in a single column. As prose it read acceptably in a wide rail and badly in a narrow one, with the caps landing mid-sentence and mid-wrap. - Corner-rail glyphs re-exported: shorter arms, so the four read as brackets rather than a nearly-closed box, and the frame moved to the border ladder. - The input chip's unselected Active state takes a grey stroke, not the accent. It was the one place a control that is merely READY wore the colour reserved for the next action — and with an empty chip nothing has been chosen yet. - Key caps are a step larger. ### Removed - Three messages that named a step or a control the auto-preview workflow had already removed: the toast on load, "Preview first" on the Save button, and "Press Preview" in the result pane's status pill. "Preview first" was only ever on screen for the 350ms preview debounce — except after a **failed** preview, where it persisted, telling the user to do the thing that had just failed. ### Fixed - Clearing the photograph left it drawable. `draw()` guarded on the ``'s `naturalWidth` alone and clearing wiped only the canvas, so one click in the fit pane repainted a photograph the chip said was gone. `draw()` asks the application state now, and clearing releases the decode (~64MB on a 4K file). - `detect()` had no error handler, so a failed request left "Detecting..." spinning permanently — the one state the page could never leave. - The photograph `` had no `onerror`, so a file that would not decode left the page silent: no canvas, no message, nothing to do next. - The busy spinner's accent arc never rendered. `.status.busy::before` asked for `var(--accent)`, which is not a token in this project, so the declaration was invalid and the lit arc fell back to the label's own grey. - Key caps never rendered at the size the stylesheet specified. The rule used `font: 11px/1.6 inherit`, an invalid shorthand — `inherit` is not a permitted font-family there — so the whole declaration was dropped and the caps inherited the surrounding 12px. - Pressed no longer moves the button. Half a CSS pixel is a whole device pixel at 2x, so it read as a jump rather than a depression, and inside a clipping parent it pushed the bottom stroke out of the box. - The fit canvas no longer traps Tab. It is captured only while the canvas itself holds focus; `Esc` gives focus back. - A device chip that is already active no longer re-applies its preset over a hand-set corner radius. - The depth-of-field advanced row no longer clips its wrapped controls. ## [0.66.0] - 2026-09-18 ### Changed - The edge-view dock re-ported from the design file: the 28px header is gone and the zoom controls and status float over the strip itself. Contrast is its own group below the zoom pair. - The settings rail collapses, from a toggle last in the top bar, and slides rather than vanishing. - Top bar button groups, both sidebar glyphs, the real contrast icon, and a darker section info icon. ### Fixed - The rectified strip's label inset is measured off the zoom group rather than a literal 59px, so it stays clear at any canvas width. ## [0.65.0] - 2026-09-17 ### Changed - The slider, the Segment Button divider and the result pane's video bottom rail rebuilt from the design file. - `--hi`, the top inner highlight, removed from every control. ### Fixed - The video bar no longer clips its drop shadow, and its blend-mode ring stroke is no longer blocked by a stacking context. ## [0.64.2] - 2026-09-14 ### Changed - Every orange line in the fit overlay is 1px — the quad, the active edge (was 1.5) and the dashed guide — matching the depth-of-field lines. Handle rings keep their 2px stroke. ## [0.64.1] - 2026-09-14 ### Fixed - The fit overlay was softer than the result pane's gizmo on a Retina display: the canvas store was sized in CSS pixels and upsampled. It is now sized at the device pixel ratio, so the photo and every line are drawn sharp. - The dashed guide read as ~9/2 instead of 6/5: round caps were extending every dash by the line width. Dashed strokes use butt caps, matching the depth-of-field lines. ## [0.64.0] - 2026-09-14 ### Changed - Depth-of-field lines are 1px (the far lines were 0.9), and every gizmo stroke and dash is now sized in screen pixels at every zoom. In Safari the dashes used to grow as you zoomed out. - Experiment: a white casing under the overlay instead of black — add `&case=light` to the page URL. One token (`--overlay-case`) drives the quad, the guides, the handles and the DoF gizmo alike. Off by default. ## [0.63.10] - 2026-09-14 ### Fixed - The depth-of-field lines sat off the picture on any photo wider than 1600px. The gizmo was drawn in the preview's pixels; the preview is resampled to 1600px wide while the corners and lines are in the photo's own pixels. The gizmo (and its pointer maths) now use the photo's size. ## [0.63.9] - 2026-09-14 ### Fixed - The photo stopped short of the bottom of the well — a black band, both panes, at every zoom. With a mouse attached macOS draws classic, always-on scrollbars, and each well was giving 17px at the bottom and right to the track. Both wells now hide their native scrollbars; panning is unchanged (wheel, ⌘-wheel to zoom, space-drag). ## [0.63.8] - 2026-09-14 ### Fixed - Zooming with a trackpad pinch let macOS rubber-band the well past the picture's bottom edge, so the last rows of the photo sat under a band of well for the length of the gesture — in both panes. Both wells now set `overscroll-behavior: none`. ## [0.63.7] - 2026-09-14 ### Fixed - Safari fallback: a rail outside the picture — the left rail over the well — sampled the picture's nearest edge column and came out light. Points outside the picture now take the well's colour. ## [0.63.6] - 2026-09-14 ### Fixed - **The blended stroke in Safari.** WebKit does not blend `mix-blend-mode` elements against an accelerated ``, so the rails and pills showed no stroke there. Under WebKit the four rings collapse to one plain ring whose four side colours are computed in JavaScript from the pixels under each edge, through the same four blends (the W3C formulas; checked against Chrome's compositor to within one level on five backdrops), refreshed on every draw, scroll and result update from a small sampled copy of the picture. Chrome keeps the live per-pixel blend. ## [0.63.5] - 2026-09-14 ### Fixed - **The blended stroke did not show until the picture was moved.** At first paint the rings were composited against the empty well and cached; the photograph landed in the canvas afterwards and nothing invalidated them. Each pane is an isolated group now (`isolation: isolate`), so the rings always composite against the pane's own content, and the rings are repainted whenever a new picture is drawn on the fit canvas or lands in the result pane. ## [0.63.4] - 2026-09-14 ### Changed - **Button Group stroke 0.75px** (set in the file; the pills stay at 1px). A CSS border of 0.75px snaps to whole device pixels and painted as 0.5, so the four blend rings are spread shadows now, which antialias to the true weight. ## [0.63.3] - 2026-09-14 ### Changed - **A fourth stroke paint: white at 40% in soft light**, on top of the three blended ones, added to the Button Group and Status pill components in the design file and ported. The three alone cancel on mid-tones — over an orange table the edge moved one level — because their luminosity target sits at the orange's own luminance. Soft light lifts mid-tones and does nothing at white, so the hairline now reads on wood, marble and orange (+20 to +36 levels) while black (+36) and white (−13) keep their character; measured with the browser's own blend maths across six backdrops. The status pills now carry all four paints as real blend rings: the box and rings moved to a `.pillwrap` around the text the script rewrites, hidden with `:has(> .status:empty)`. ## [0.63.2] - 2026-09-14 ### Changed - **The group and pill strokes are the design's three blended paints.** Read from the nodes themselves (the code export had flattened them to one colour twice): a half-pixel stroke *outside* the box made of `#0F6BFF` at 20% in *saturation*, `#FF4646` at 15% in *luminosity* and `#7E7E7E` at 20% in *hue* — a hairline that takes its look from the photograph under it, lighter on a dark backdrop and darker on a light one. Drawn as three rings with `mix-blend-mode` (two pseudo-elements and a ``) a half pixel outside each Button Group; the rails no longer carry a `z-index`, because a stacking context between the rings and the photograph would have left them blending against nothing. The status pills get the same at 1px: the luminosity paint as a real blend ring, the two 20% paints as plain rings (a pill's `::before` is its status glyph and its text is rewritten by the script), and they centre with auto margins instead of a transform for the same stacking reason. ## [0.63.1] - 2026-09-14 ### Changed - **Segment Button and Button Group have an Orientation variant** (138:255, 145:372, re-read 14 Sep): a vertical group rounds its first segment's top corners and its last segment's bottom corners, a horizontal group the left and right ones, a solo segment all four — at **radius 6** (radius/sm, was 4). The group's stroke is **`rgba(255,255,255,.32)`** in both orientations (the frame instances still carried the older 55% grey), the shadow unchanged. Verified per corner in the page. ## [0.63.0] - 2026-09-14 ### Changed - **Rails re-read from the Canvas well frame (7:36).** The bottom rail is one horizontal Button Group of four segments — Re-detect · Point at screen · Rotate · Reset — centred 20px off the bottom edge, not four separate groups; the left rail sits at x=16 (was 24); every group's stroke is a 55% mid grey (`rgba(139,139,139,.55)`, was a 20% cool grey) and its shadow is the frame's (`.08 / .12 / .25`). Measured in the page: left rail groups 33×97 / 33×33 / 33×129 at (16, 48) with 8px and 32px gaps, the bottom group centred to the pixel. ## [0.62.4] - 2026-09-14 ### Changed - The 1x icon has no frame — the design is the glyph alone; the box was carried over from the earlier read by mistake. ## [0.62.3] - 2026-09-14 ### Changed - The 1x zoom icon is the design's own glyph (icons / 1x, 137:3649): the label is an outlined vector at the frame's offset, not rendered text, so it is identical on every machine and font. ## [0.62.2] - 2026-09-14 ### Fixed - Code review of v0.55–v0.62: the recents store re-seeded itself from every session directory on each read until the first record (the seed was never saved); a clip thumbnail was named with Python's per-process `hash()`, so the cache missed on every launch; `/api/recent?limit=abc` raised; and `current.json`, which carries the session token, was world-readable — it and `recents.json` are now owner-only. Checks for each. ## [0.62.1] - 2026-09-14 ### Changed - The source chips sit above the popover scrim, so the chip whose popover is open stays visible and lit, and the other stays clickable to switch. ## [0.62.0] - 2026-09-14 ### Changed - **The corner loupe frames the whole arc.** It magnified a corner as much as the edge strip does (±14px, ~4.6×), and a rounded corner at that scale is a blank curve with neither edge in view. The corner view now covers 2.5 radii on each side of the corner — the radius in photo pixels is the radius fraction times the quad's top edge — never tighter than the strip, and the caption states the magnification. - **The source chip wears its Active state while its popover is open** — the same accent stroke (empty) or accent fill (filled) a dragged file shows — so the popover reads as belonging to that chip. A drag leaving the chip no longer un-lights it while the popover is up. - **The drop zone's dashed rule is drawn, not bordered**: a masked pseudo element paints `--edge-mid` through an SVG rounded rect with a 5px dash and 4px gap (2 and 1 longer than the browser's), half a pixel inside the edge, so it is lighter and looser as asked; hover and drag-over recolour it through the same mask. - **The popover is glass**: `--float` at 86% over a 5px backdrop blur (a Figma background blur of 10 — Figma's radius is about twice CSS's), with a hairline stroke. ## [0.61.0] - 2026-09-14 ### Changed - **The picker lists what you have used, not what is newest in two folders.** Every source that reaches the session — picked by path, dropped, browsed, imported — is recorded per role in `~/.screengraft/recents.json`, and the photo popover and the screenshot popover each list their own, newest-used first, with when and from where ("2 min ago · ~/Downloads"). Files that have since gone are dropped. First run seeds the list from the sessions that already exist. A dropped file stays recent for as long as its session copy does. A clip gets its first frame as a thumbnail. The Desktop/Downloads scan (`scan.py`) remains for the CLI. - **Picker popover, on request:** the preview slot is as tall as the list beside it and no longer stroked; the drop zone is 32px taller; a list item at rest has no fill and no stroke — picture, name, and a second line for when it was used — hover raises it, the chosen one is the raised neutral every selected control uses. ## [0.60.0] - 2026-09-14 ### Changed - **Rails re-measured against the updated design file.** The Segment Button grew to 32px (from 28) with 8px side padding, a text label padded a further 6px; the zoom rail keeps 8px between its two groups and 32px separates it from the corner rail; the bottom rail sits 8px off the edge. Measured in the page against the frame: groups 86 / 114 / 61 px against Figma's 85 / 113 / 61 (the difference is the two half-pixel strokes), the left rail 33×97 / 33×33 / 33×129 against 32×96 / 32×32 / 32×128. The edge-view zoom stepper is two Md buttons joined at a hairline (Frame 263: 66×28), not two Sm ones, and both carry accessible names. ## [0.59.2] - 2026-09-14 ### Fixed - A shipped comment carried a name; `check_leaks.py` caught it after the v0.59.1 tag was already pushed. No code change. ## [0.59.1] - 2026-09-14 ### Fixed - **The sharp line stopped at the dashed line.** `start` was held below `end − 0.05`, so with the far line sitting on the phone's near edge the plane of focus could not be dragged over the phone — it reached the dashed line and stayed there (a screen recording, 14 Sep). Moving the sharp line now moves the plane of focus and **carries both limits with it**, each keeping its distance, the way a tilt-shift band moves as one thing; the dashed lines alone change the ramp's length. A limit that reaches its own end of travel stops there and the ramp shortens. Three UI-audit checks. ## [0.59.0] - 2026-09-14 ### Fixed - **The depth-of-field handles could not be grabbed.** v0.54.7 made them hollow rings, and an unfilled SVG shape under `pointer-events:auto` is hit only on its stroke — the centre of every handle passed the pointer through to the image underneath. On a steep, flat phone, where a third of the screen's extent is nine pixels of mouse travel, that made the sharp line immovable (reported on a real fit, 14 Sep). `pointer-events:all` on the handles, and every handle now carries an invisible 10px-radius hit disc. Two UI-audit checks pin it: a probe handle must compute to `all`, and every painted handle must have its disc. ### Changed - **The sharp line can sit in front of the glass.** `dof_start` reaches −0.5, the same distance past the screen the far line already had past the far edge, so the plane of focus can be on the table in front of a steep phone rather than pinned to its near edge; the near edge is then already part-way up the ramp. The gizmo labels it "sharp · in front of the screen". Old sidecars are unaffected (none carries a negative start). Checked in `test_warp.py`: start −0.5 / end 0.5 puts the near edge at exactly half blur, and −5 clamps to −0.5. ## [0.58.0] - 2026-09-13 ### Changed - **The fit pane is rails over the well** (Figma 7:12, Button Group 145:372, Segment Button 138:255, Status pill 5:10). Neither pane has a header any more: the photograph gets the whole pane and the two wells line up. What the header held now floats as Button Groups — 28px columns of 28px segment buttons on the raised surface, one 0.5px translucent stroke and one shadow around the group, so it reads on any photo tone. **Left rail:** the zoom group (+ / 1x / −), a lone Fit button, then the **corner rail** (TL / TR / BL / BR as the design's corner glyphs — the corner picked on the canvas is raised, kept in step from `draw()`). **Bottom rail:** Re-detect, Point at screen, Rotate and Reset, each its own one-button group, centred — the actions on the fit as a whole; corner selection left it because it is navigation. **Status pill** top-centre of each pane: the detection state on the fit, the render state on the result; hidden while empty. The zoom percentage readout is no longer on screen (the rail is icon-only, as designed) but is kept for assistive tech as a live region; the canvas navigation tip went with the header — the Keys section already says it. Rotate is not in the design frame and is kept until ruled on. UI audit 36/36 at 1600 and at 742. ## [0.57.0] - 2026-09-13 ### Changed - **Controls re-read from the Figma component sets** (Button 4:14, Chip 5:6, Input chip 159:4117, Segmented 84:177, Switch 17:61, every variant) and the variable collection, and brought back into line where the port had drifted: strokes are **0.5px** throughout (they were 1px); both button sizes are **radius 6** (Md had been 8); the primary button's rest is the **solid accent** — the design's rest and hover containers are the same colour — and its disabled state is the solid press colour with a solid label, not three alpha variants; the disabled neutral label is `text/mute`, not `text/faint`; the segmented control's track is `surface/raise-low` in a 6px radius with a 24px thumb at radius 4, hover lifting the thumb to `raise-hi`; the input chip has its own surfaces — filled is the selected chip's, empty is a recessed dashed pill with a `#c2c3cb` label — and gains the design's **Active** state: drag a file onto the chip itself and it takes the drop for that role (accent stroke and label on an empty chip, accent fill on a filled one); the switch's thumb and grip sit at the design's half-pixel insets. New tokens: `--bw`, `--r-xs`, `--icon`, `--inchip-ink`, `--inchip-empty`, `--group-edge`, `--group-shadow`, `--pill-shadow` (the last three for the floating rails that follow). Contrast audit: the primary label at rest now measures the 3.70:1 already accepted for hover — the same surface — and is accepted with it; nothing else moved. UI audit 36/36. ## [0.56.1] - 2026-09-13 ### Fixed - Review of v0.55–v0.56: a refused `POST` drained its body one byte per loop once the client had gone (a claimed 64 MB body meant 64 M empty reads); a garbage `Content-Length` would have raised; a non-ASCII token header would have reached `compare_digest` and raised instead of being refused; and `_grain_gain`'s docstring described a replay path that does not exist. Two checks added (non-ASCII token, a refused 300 KB upload answered 403). ## [0.56.0] - 2026-09-13 ### Security - **The local server now answers only its own page.** It always bound 127.0.0.1, so nothing off the machine could reach it — but it authenticated nothing, and a web page the person happened to have open in the same browser could sweep localhost ports and fire blind `POST`s: not readable back (same-origin policy), but delivered, and `/api/figma` turns one into a job an agent then acts on. Two checks close two doors. A **per-launch token** (`secrets.token_urlsafe`) rides in the page's URL (`/?t=…`) and comes back on every request — as `X-Screengraft-Token` from the page's fetches, as `t` on ``/`