Guidance for Claude Code when working in this repository.
Keep this file current. When a change alters something documented here — pipeline stage order, the uniform contract, an analysis default, the sidecar format, or the build/test commands — update the relevant section in the same change. After adding an adjustment control, re-check the "Adding a new adjustment control" checklist and the uniform layout table.
SwiftInvert is a native macOS film-negative conversion app (SwiftUI + Metal),
a deliberate ~10% rewrite of the Python app NegPy
(~/Documents/code/NegPy), which remains the numerical reference
implementation. Scope: library grid, C-41 conversion with NegPy-quality auto
metering, exposure/color controls, histogram, export. Camera RAW only; no
B&W/E6, papers, crosstalk, retouch, or toning.
swift build # debug build
swift run -c release SwiftInvert # the app (release: debug decode is ~10x slower)
make test # Swift Testing suite — MUST use make, not bare `swift test`
swift build -c release && .build/release/negcli # headless CLI (decode/thumb/render/bench/meter)
make app # package self-contained dist/SwiftInvert.app (bundles Homebrew dylibs)
make install # copy the bundle to /Applications
# Regenerate parity fixtures (after deliberate NegPy kernel/constant changes):
cd ~/Documents/code/NegPy && uv run python ~/Documents/code/SwiftInvert/scripts/dump_fixtures.pyToolchain constraints (this machine has Command Line Tools, no Xcode):
- No XCTest and Testing.framework lives outside default search paths → tests
use Swift Testing with the framework/rpath flags encoded in the
Makefile. Bareswift testfails with "no such module 'Testing'". - No build-time
metalcompiler → shaders are compiled at runtime fromSources/MetalRenderKit/Shaders/NegPipeline.metal(a bundled.copyresource) viaMTLDevice.makeLibrary(source:). - LibRaw comes from Homebrew (
brew install libraw), linked dynamically via theCLibRawsystemLibrary target (pkgConfig: "libraw_r"). LGPL-2.1. - If the repo directory is ever moved/renamed:
rm -rf .build(the module cache embeds absolute paths).
SwiftInvert (app) ──► MetalRenderKit ──► NegativeKit
│ │
└──► RawDecodeKit ───┴──► NegativeKit
│
└──► CLibRaw (Homebrew libraw_r)
negcli ──► RawDecodeKit + NegativeKit + MetalRenderKit
- NegativeKit — pure Swift math, no UI/Metal/IO. The port of NegPy's conversion kernel. Only Foundation/simd/Accelerate.
- MetalRenderKit — the GPU render chain +
ColorIO(CGImage/color-space conversion, used by export). - RawDecodeKit — LibRaw wrapper + embedded-thumbnail extraction.
- SwiftInvert — SwiftUI app (
AppModel,ImageSession, views). - negcli — headless driver;
benchmeasures slider-latency and analysis;meterprints the darkroom read-outs (negative character + spot-densitometer probes) for a real RAW, which is how the read-out math gets exercised on actual scans (the fixtures can't cover a hover).
End-to-end: decode → orient → analyze (CPU) → derive params (CPU, µs) → render (GPU) → display/export. Slider changes re-run ONLY derive+render (~5 ms at preview size). Analysis re-runs only when its inputs change.
LibRaw parameters mirror NegPy's rawpy decode exactly (verified byte-identical
against rawpy on real files): gamma=(1,1), no_auto_bright, output_bps=16,
output_color=RAW (sensor-native, no camera color matrix), unity WB
(user_mul=(1,1,1,1), use_camera_wb=0), user_flip=0. Output is scene-linear
RGBImage (interleaved RGB float32 in [0,1], u16/65535 via vDSP).
- Preview path:
half_size=1+ linear demosaic (user_qual=0) — except X-Trans sensors (idata.filters == 9), where half_size aliases the 6×6 CFA; they decode full and downsample. Preview is capped at 1536 px long edge — unless the HQ toggle (canvas control bar,AppModel.hqPreview, session-only) is on: then the display render runs on the cached full-resolution decode (ImageSession.hqSourceTexture; analysis stays on the proxy, matching export), freed by the first non-HQ render. - Full path (export): LibRaw default (best) demosaic, full resolution.
- EXIF orientation baked in Swift afterwards (
applyingFlip, dcraw codes: 3=180°, 5=90°CCW, 6=90°CW). C struct's flexible array member must be read via byte offset (MemoryLayout.offset(of: \.data)) —withUnsafePointer(to: pointee.data)copies to a misaligned temporary and traps. - User orientation (
settings.rotation0/90/180/270 CW +flipHorizontal) is baked into pixels right after decode (RGBImage.oriented), so analysis, display-space rects, rendering and export all share one coordinate space with zero mapping code.
Fine-rotation invariant: analysis runs on the orientation-only image
(90° steps + flip — ImageSession.meterPreview), never the fine-rotated
one: the inscribed auto-crop changes with the straighten angle, and
re-metering it would drift the conversion as the user rotates (it also
made the 0° straighten base's look differ from the committed re-bake).
Rendering/export still bake the angle; exportRender shares prepare(),
so preview and export agree.
Two stages, both offset-independent since the 2125a34 port
(ExposureKernel.prepare ≈180 ms once per image/crop; finalize ≈45 ms,
cached with it — white/black-point drags re-run NO analysis at all; the
offsets fold into finalBounds at derive time only):
prepare(linearImage:cropRect:analysisRect:analysisBuffer:) →
Prepared (offset-independent, cached per rect state):
- Region priority (NegPy
resolve_analysis_region): a freehandanalysisRectwins and disables the buffer inset; else the outputcropRectscopes metering (borders outside the crop can't skew the inversion) with the buffer applied inside; else the centered buffer alone. Default buffer 0.10 = middle 80% of the frame (NegPy ships 0.05 — deliberate divergence). Degenerate rects (<2 px) are ignored. - Prefilter (
Prefilter.prefilterLogGrid):log10(clip(x, 1e-6, 1.0))via vForce (buffer holds negative density −D since values ≤1), then a block-median grid: block sideb = ceil(maxDim/1024); per-cell, per-channel median kills dust/speculars and makes stats resolution-invariant. The preview path always hitsb=2, which has a closed-form median-of-4 fast path. - Bounds (
BoundsAnalysis.analyze, NegPyanalyze_log_exposure_bounds): two independent percentile axes on the grid, one sort per channel (vDSP): the luma axis (base clip 0.01%) sets the mean center+span (dynamic range); the colour axis (base clip 1.0%) sets per-channel cast offsets relative to the median channel. Since the 127bcd7 port (NegPy 0.43) the colour axis's dense end (floors = print whites) prefers same-pixel refs (samePixelColorFloorRefs, float64 end-to-end like upstream): one shared chroma-gated pixel set — the luma-extreme band[clip, clip+4]percentile, lowest-RMS-chroma 30%, chroma base-anchored against the thin-end refs with a two-pass provisional-gamma refinement — so coloured highlight content can't masquerade as film cast; falls back to the percentile pass when the band has no trustworthy neutrals (< 64 px or median chroma over the caps). Thin-end ceils stay percentile-based (film density is bounded below by base). Recombined:floor[ch] = mean(luma floors) + (colour floor[ch] − median(colour floors))(same for ceils) →LogNegativeBounds(floors < ceils). - Meters (all on the same grid):
anchor: P50 of grid luma normalized by the base bounds, pulled 20% fromassumedAnchor0.46, clamped ±0.12 — the auto-exposure key.texturalRange: |P90 − P10| of raw log luma — drives Auto Contrast.shadowRefs: P98 per channel — the cast-removal fallback tie.
finalize(prepared) → ExposureAnalysis: the neutral axis
(NegPy measure_neutral_axis_from_log) measured against the PRE-trim base
bounds — NegPy 2125a34: the film's cast is a source property, so creative
WP/BP trims must not perturb it (their GPU always measured pre-trim; the
CPU side we'd ported was the bug). Two-pass since the 127bcd7 port
(NegPy 0.43): three normalized-luma bands (highlight 0.10–0.30,
mid 0.40–0.60, shadow 0.72–0.92); per band, keep the lowest-RMS-chroma
30% (hue-uniform pairwise RMS, not max−min; ≥64 px). Pass 1 selects under a
loose cap (0.55 — admits strong-but-correctable casts, rejects saturated
content); the affine R/B→G correction implied by its mid+shadow refs
re-ranks chroma; pass 2 selects true neutrals under the strict cap (0.29)
and takes per-channel raw-log medians. confidence = tightness × sampleSize × agreement — corrected mid/shadow chroma vs the cap,
n/(n+256) on the mid set, and mid↔shadow deviation agreement (0.10 dead
zone, 0.20 roll-off) — gates Auto cast removal. Returns nil mid or shadow
(either pass) → no neutral axis (shadow-ref fallback used downstream).
ImageSession caches: decode (per image) → oriented preview (per
orientation) → Prepared + ExposureAnalysis (per rects) → GPU source
textures (per crop). The "Analyzing…" indicator shows only when prepare
will run.
AppModel.sessionLRU retains the 2 most recent sessions (active + the one
navigated from), so arrowing back to a frame doesn't rebuild the whole tower.
Retention alone is safe because every tier is keyed — a returning session
re-validates against the current settings and cannot serve stale pixels. Only
the on-screen frame may hold the HQ tier (releaseHQ() strips it from the
others); the proxy caches are tens of MB, the HQ decode is hundreds.
Turns (ExposureSettings, ExposureAnalysis) into RenderParams — the full
GPU uniform payload. Order matters:
finalBounds = baseBounds + (wp, bp)offsets (floors+wp, ceils+bp).dMin = paperDmin ? 0.06 : 0;anchor = autoExposure ? metered : nil.strength = confidence × slider— confidence scaling is always on (NegPy 0.36 removed the auto toggle; slider 0–2, >1 overcorrects past the neutral axis; kernel clamps bound any value).perChannelCurveParams(CurveLogic, the C-41 gray-balance heart) — NegPy-exact. Green is the reference (its pivot rides the anchor). Modes:- Neutral-axis (default): R/B fitted to green's axis — quadratic through 3 green-matched points when a highlight ref exists (divided-differences solve; curvature clamped to ±0.45·slope), else a 2-point line; midtone deviations clamped ±0.2.
- Shadow-ref fallback: one-point slope tie at P98 (clamp ±0.1).
- Base: single shared linear curve.
Inputs:
baseSlope = gradeToSlope(grade, effectiveGradeRange(...))— grade is ISO R 50–180 (115 ≈ grade 2),k = 2.9·range/(R/100)clamped [2,10]; Auto Contrast damps the floor-ceil/textural ratio toward 2.0.lumRangecomes from the pre-offset base bounds (NegPy quirk).computePivotsolves so the reference tone prints at density 0.74.
- Overall contrast folds exactly into the core:
v→v+k(v−v*)⇒ slopes,curvatures ×(1+k), pivot += k·v*/s′ (anchor invariant). k = slider×0.5, slider −1…+2. cmyOffsets(pre-curve, normalized space): WB filtration (slider×0.2 / channelRange) with Temp folded along the Planckian direction (yellow + magenta×0.0029/0.0057) and Tint on magenta, plus Exposure stops (stops × −log10(2)/channelRange— the dodge/burn EV domain; + = brighter).- Grade-coupled toe/shoulder knees; band CMY (
colorShadows/Mids/Highs×0.2 into density units); tone controls passthrough (shadowContrast's negative side remapped so slider −3 lands on the monotone floor −0.8, also hard-clamped in the kernels);preSaturation,vibrance,saturationpassthrough.
One command buffer, passes in order (RenderPipeline.render /
renderDisplay):
normalizeLog—log10(max(c,1e-6))(no upper clamp, matches NegPy GPU) → per-channel stretch(log − floor)/(ceil − floor)(offsets already folded intofinalBounds; uniform wp/bp fields are legacy-zero).printCurve— the asymmetric H&D print curve, per pixel: a. Pre-saturation:c → mean + k(c − mean)(density-deviation gain; default slider 1.15, kernel identity at 1.0). b. Per channel:val += cmyOffsets→ quadratic corev = slope(val − pivot) + curv·val²→ midtone paper-Sv += 0.15·0.6·tanh((v − v*)/0.6)→ tone controls (maskswS = σ(3.5(v−1.40)),wH = σ(3.5(0.30−v))on the incoming v, parallel form: shadows/highlights lifts + anchor-pivoted contrasts) → 3-band color (same masks,wM = max(1−wS−wH,0); NegPy's 2-band regional CMY generalized) → shoulder softplus towardd_min_eff(paper white) → toe softplus towardd_max_eff(paper black). c.t = 10^−D; True Black (BPC, optional):t → (t−b)/(1−b)withb = 10^−dMaxreferenced to the physical d_max so toe lifts survive (negative toe raises the clip point); clamp [0,1] → linear reflectance out.colorPop(dispatched ONLY when a color-pop control is off-default: vibrance/saturation/redSaturation ≠ 1 or redHue ≠ 0) — CIELAB (Adobe RGB (1998) primaries, D65 since the b3490eb port; matrices duplicated in MSL andLabColor.swift— keep in sync): the Color Mixer first (LabColor.applyColorMixer, SwiftInvert-only, no NegPy equivalent — chroma-gated hue-targeted R/Y/G/B bands: per-band raised-cosine hue windows × a shared chroma ramp that zeroes at the neutral axis, so whites/grays/faint casts never move; all weights read the ORIGINAL hue and compose jointly, so overlapping feathers are order-independent; constants tuned on real scans (blues at Lab hue ~235, gate 6→16 — colorimetric-primary values starve real content, whose colorful pixels sit at chroma 12-30):bandCentersDeg/bandHalfWidthsDeg/bandChromaGate*/bandMaxHueShiftDegmirrored as MSL literals; UI: segmented band picker + gradient tracks,ColorMixerSection.swift), then vibrance (muted-chroma boost, /60 range) then saturation (a*,b* scale). Separate pass on purpose: inlining the Lab code into printCurve cost ~3 ms/frame in register pressure even when branched off. Writes into the (already consumed)normalizedtexture, which becomes the content texture.histogram256— 4×256 atomic bins (R,G,B + Rec.709 luma) from the linear content, OETF-encoded in-shader so bins match display values.outputEncode— working-space OETF (Adobe RGB 1998 TRC: pure 563/256 = 2.19921875 power, no linear segment — b3490eb). Display fast path writes the same kernel into an rgba8unorm texture (GPU quantization, 4× smaller readback, zero CPU conversion); export/tests use the float path.
Threading/reuse contract: render is serialized by an internal lock and
returns read-back buffers, never live textures — intermediate textures
are cached per size (≤4 MP; export sizes are not retained) and a later render
overwrites them. Violating this segfaulted the concurrent test runner once.
Uniform structs are mirrored byte-for-byte in ShaderTypes.swift;
LayoutTests pins strides (Norm 48, Curve 256) and key offsets — update both
sides plus the asserts together.
Working space is linear Adobe RGB (1998) → Adobe-RGB-encoded at the end of the chain (b3490eb port, 2026-07-20: upstream moved off ProPhoto because the pipeline ASSIGNS primaries to sensor-native data at output, and ProPhoto's imaginary primaries inflated chroma and skewed hues — reds toward magenta, skies toward cyan).
- Display: the encoded output becomes a CGImage tagged
CGColorSpace.adobeRGB1998; ColorSync converts to the monitor — pinned against NegPy's littleCMS display transform (AdobeCompat-v4 → sRGB-v4, relative colorimetric + BPC) on reference colors (ColorIOTestsoracle values regenerated 2026-07-20 with NegPy's bundled ICC profiles). - Export (
Exporter+ColorIO): default sRGB (NegPy's default; wide-gamut files look washed out in profile-ignorant viewers) via a 16-bit working-space CGImage drawn into an sRGB CGContext (one quantization). Adobe RGB remains selectable (the ExportColorSpace case is still NAMED rommRGB for sticky-options JSON compatibility). JPEG 8-bit (default q=0.92) or TIFF 16-bit, optional long-edge resize, destination next-to-source or a chosen folder.
Tests/Fixtures/ is dumped from NegPy's actual engine by
scripts/dump_fixtures.py (run in NegPy's uv env). The suite (make test)
verifies every stage boundary:
- closed-form oracles (percentile semantics, grade/pivot/softplus/OETF math),
synthetic64(NegPy's golden image, dumped as .bin): prefiltered grid @1e-5, bounds/meters/curve-params @1e-4, full CPU chain @1e-4,synthetic_grid(1600×1066, regenerated bit-exactly in Swift from an integer-hash formula): exercises b=2 block-median + the quadratic cast-removal path (slope/pivot tolerance 2.5e-4: Accelerate-vs-libm ulp amplification),ramp257print-curve shapes,lab_color(vibrance/saturation vs NegPy's CIELAB ops),- GPU vs fixtures and GPU vs CPU reference at NegPy's own gates (mean<0.01, max<0.04); the rgba8 display path within 1.5/255 of the float path.
Beyond parity, the suite covers the seams the fixtures reach only transitively, plus the app layer:
SidecarCodecTests+HistoryLabelTestsare drift-catchers: each pinsExposureSettings' stored-property count (45) and exercises every field — adding a settings field fails both until the decoder,HistoryLabels, and the tests' mutation lists all get their line (see the control checklist).ImagePipelineSeamTests: the prepare/finalize cache split (a reusedPreparedmust equal fresh analysis; offsets may only move the neutral axis) andRGBImage.downsampled(dims/identity/mean preservation).SwiftInvertTests— the app-target suite. SwiftPM tests the@mainexecutable directly (@testable import SwiftInvert— works since Swift 5.5, verified under the Makefile's CLT flags):SidecarStorefile behavior (legacy.negswift.jsonfallback + delete-on-save),ExportOptions.destinationURL,DensitometerStateprobe mapping,ImageConversionshapes. Views/AppModel/ImageSession stay UI-verified — and pointer paths need a HUMAN pointer: synthetic mouse events never reach the unbundled binary (no TCC grant, won't take focus), so verify hover logic headlessly (negcli meterpattern) and hand off the gesture.
Upstream review log: see UPSTREAM.md — it records the last NegPy
commit reviewed (the baseline for "what changed upstream?" requests) and
the port/skip decisions per review. Update it after every upstream review.
For "what changed in NegPy?" requests, run the /negpy-review skill
(.claude/skills/negpy-review/) — it fetches upstream, triages the diff
around the inversion pipeline, and maintains UPSTREAM.md.
Analysis semantics and kernel constants are synced with NegPy 0.43
(0369b10 tip; the estimator rewrite landed in 127bcd7: two-pass
RMS-chroma neutral axis with the rebuilt confidence, same-pixel colour
floors, neutral_axis_chroma_cap 0.29 + the five new estimator constants —
on top of the earlier syncs: 2125a34 pre-trim neutral axis, b3490eb-coupled
auto constants, the 0.38 set with paper_dmin off + true_black on, the
0.36 set). Fixtures were re-dumped from 0369b10 (the manifest records
paper_dmin and true_black per config; the parity harnesses read both,
so default flips on either side can't silently skew parity). NegPy's per-layer R/G/B trims,
Split Grade and Zone Density (their convergent take on our tone controls)
are NOT ported — our tone controls + 3-band grading cover the achromatic
cases; per-channel crossover trims are a candidate future feature.
Deliberate divergences from NegPy (fixture tests pin the NegPy-neutral values where needed):
preSaturationdefault 1.15 (NegPy has no equivalent; parity tests set 1.0),RETIRED 2026-07-20: it countered reds skewing magenta, which was the ProPhoto-interpretation hue skew the b3490eb port removed at the root — default is 0 again (sidecars that explicitly set a redHue keep their value; keyless sidecars now decode to neutral),redHuedefault +0.5- Color Mixer band constants were tuned in the old ROMM/D50 Lab; re-checked numerically after b3490eb (real-content reds/greens/sky-blues still land in-band; only colorimetric-primary blue sits at the feather edge, which matches the tuned-on-real-content philosophy) — but an on-scan re-tune pass is still worth doing,
- default analysis buffer 0.10 vs NegPy 0.05 (tests pass 0.05 explicitly),
- NegPy's default lab sharpen (0.25 since 8bc9678; was 0.5 earlier in 0.38) is not implemented,
- SwiftInvert-only controls: exposure stops, tone controls (shadows/highlights ± contrasts), overall contrast, temp/tint, 3-band color grading, pre-saturation — all identity-at-default so the NegPy fixtures still pass.
AppModel(@MainActor @Observable): selection +multiSelection, settings (didSet → coalesced latest-wins render task + debounced 1 s sidecar save;isRestoringSettingssuppresses saves on open), tool modes (crop/analysis-region draw), baseline hold-to-compare (showingBaselinerenders stock settings with current geometry), export batch task with progress/cancel.ImageSession(actor): the per-image cache tower (see §2) + render.- Default profiles (
DefaultProfile.swift,ProfileStore.swift,ProfileWindows.swift): the house look applied wherever a frame is seen fresh (open with no sidecar, Reset All, sidecar-less frames in batch export and Copy Adjustments) — resolved asDefaultProfile.settings= the ACTIVESettingsProfileinProfileStore(two reserved rows from code, never persisted/editable: "None" = stock, first in the list and the out-of-the-box active choice, then "SwiftInvert Default" = the house look; user profiles + active choice as UserDefaults JSON, so a picked default survives restarts). File → Choose Default Settings… opens the picker (Accept = set active; Create New seeds from the selection); Create/Edit opens a profile-editor window: the full app UI underAppModel(profileEditor: true), where one shared adjustments draft (profileDraft) applies to EVERY frame — geometry stays per-frame from sidecars, and NO sidecar is ever written in that mode (guards in scheduleSave/paste/export-flush). Multi-window plumbing:KeyModelTracker+WindowKeyObserverroute menu commands and each ContentView's key monitor to the key window's model. "Start from Scratch" (under View Original) cancels the profile to stockExposureSettings(), which stays NegPy-parity-neutral — fixtures, negcli and the parity suite never see profiles.DefaultProfileTests+ProfileStoreTestspin the built-in's field list, adjustments-only stripping, built-in protection, and persistence round-trip. - Darkroom read-outs (
Densitometryin NegativeKit — pure measurement, no render path, so no parity surface): the spot densitometer (hover the canvas → D + zone in the control bar, with an 11-cellZoneStrip) and the Negative character row under the Grade slider ("0.90 · normal"), which is the measured pre-offset density range vsCurveLogic.defaultGradeRange. Both mirror NegPy (densitometer.py,stats._negative_row), including its quirk of taking luma on the ENCODED triplet before decoding. The probe reads the displayed rgba8 bitmap (working-space-encoded, so the bytes already are the working-space values) rather than a GPU metric —DensitometerStatecaches the provider bytes once per render and is a separate@Observableso pointer moves invalidate only the read-out label, never the canvas. NegPy's 120-bindensity_histogramis deliberately NOT ported: it exists to feed their H&D chart, which we don't ship. - Edit history: per-image undo/redo in AppModel (
historyEntries/historyIndex, session-scoped per URL). Slider/handle drags commit on RELEASE (drag = preview:setControlEditingvia thecontrolEditingChangedenvironment hook holds commits while any control is held); non-drag changes coalesce via a 0.7 s debounced commit; labels come fromhistoryLabel(from:to:)(HistoryLabels.swift — add a line there for every new settings field) orpendingHistoryLabelfor named actions (Rotate/Crop/Reset). New edits truncate the redo tail. Undo flushes any in-flight uncommitted change first. UI: HistoryPanel (⌘Z/⇧⌘Z, click-to-jump), below the collapsible Adjustments section. - Unified Crop & Straighten (Lightroom model):
ToolMode.croprenders the full UNROTATED frame (scheduleRender substitutes fineRotation 0, uncropped) fitted by its rotated bounding box; the image rotates behind an axis-alignedCropBoxOverlay(dim surround, thirds, corner handles, clamped move) whose math lives inNegativeKit/CropGeometry(rotated-space boxes, fitScale/constrain, content-preservingremapCrop; tested). The desired box is DetailView@State(cropBox, nil = follow committed); exit commits viacommitCrop()(near-full box ⇒ crop cleared). Straighten commits outside the mode go throughAppModel.commitFineRotation, which remaps a committed crop so its content doesn't drift with the inscribed auto-crop;RenderOutput.frameSize(orientation-only dims) is the coordinate base. The analysis tool keeps the old draw-a-rectSelectionOverlay. - Menu bar (
SwiftInvertApp.commands): File = Open Folder ⌘O / Choose Default Settings… / Export ⌘E / Show in Finder ⇧⌘R; Edit = Undo/Redo (replacing the system group — the ⌘Z shortcuts live HERE, not on HistoryPanel's buttons), Copy/Paste Adjustments ⇧⌘C/⇧⌘V (geometry never pasted), Reset All ⌥⌘R; View = Show Library ⇧⌘L / Show Grid Lines ⇧⌘G / HQ Preview ⇧⌘P (@AppStorage keys shared with the in-window controls); Image = Previous/Next Image ←/→ / Rotate Left/Right ⌘[/⌘] / Flip ⇧⌘H / Crop ⌘K / Crop for Analysis ⇧⌘K + clear items (tool toggles checkmark while active; Escape exits). Frame navigation also answers ↑/↓ (↑ = previous, ↓ = next) via the window's keyDown monitor inSwiftInvertApp— a menu item takes exactly one key equivalent, so the vertical pair can't live on the menu; the monitor mirrors the menu items' enablement (no files / export sheet up ⇒ pass through). - Sidecars:
<basename>.swiftinvert.jsonnext to the source (SidecarStore); pre-rename.negswift.jsonread as fallback and removed on next save. Missing keys decode to defaults (custominit(from:)inExposureSettings— every new field needs a line there). - UserDefaults:
libraryFolder,canvasColor,exportOptions(JSON blob); one-time migration from the pre-rename "NegSwift" domain. - SwiftUI pitfalls already hit (don't regress):
- Lazy grids + @Observable: cells must read observable state inside their own body — precomputed Bools leave stale cells (selection halo bug).
.position()expands a view's frame to its container: attachcontentShape/gestures BEFORE positioning (histogram handle bug).NSEvent.modifierFlagspolling in tap handlers is unreliable — useTapGesture().modifiers(.command)etc.- The app runs unbundled via
swift run: activation policy is set manually; UserDefaults/TCC key off the process name.
ExposureSettings: field + default, plus a line in the custom decoder (sidecar back-compat), plushistoryLabel(HistoryLabels.swift) — andRenderParams+ its init if the kernel needs it. Two tripwires enforce this:SidecarCodecTestsandHistoryLabelTestsboth pin the stored-property count (45) and mutate every field, somake testfails until all the lists have their line.deriveRenderParams: map settings → params (fold into existing params where the algebra allows — see overall contrast/exposure — before adding uniforms).- Kernels, BOTH sides in the same change:
ReferenceCurve.swift(CPU) andNegPipeline.metal(MSL), in identical order/parallel form. New uniforms: extendCurveUniformsin both files (16-byte alignment) + updateLayoutTestsstrides/offsets. Guard non-default-off work behind uniform branches or a separate pass (occupancy!). - UI:
LabeledSlider/GradientSlider(sets the reset-⨯ default), section inControlsSidebar;negcli renderflag if useful. - Tests: default-is-identity (fixtures must still pass), direction/region properties, monotonicity if it touches the tone curve, sidecar round-trip, GPU-parity case with the control active.
- Run
make test,negcli bench(watch for regressions), launch the app headlessly (render log line), commit with the reasoning.
K(ExposureConstants.swift) is the single Swift source; the MSL duplicates: tone anchors/sharpness (TONE_SHARPNESS,SHADOW_ANCHOR,HIGHLIGHT_ANCHOR), Lab matrices/eps/kappa/white, the working-space OETF exponent (0.45470693 in MSL = 256/563). GPU/CPU parity tests catch drift but update them together.- If NegPy's
EXPOSURE_CONSTANTSchange deliberately: updateK, re-dump fixtures, re-runmake test.