Where the code lives and the rules that keep it in place. Keep this file true: when a change moves a responsibility, update it in the same change.
| Path | Owns |
|---|---|
src/main.tsx |
Mounts the GPU provider and the app. |
index.html, about.html |
The two pages. index.html holds the welcome's heading, visually hidden, for crawlers that don't run the script; about.html is static, without script. |
app/index.tsx |
The app: workspace, file drop, draft notice. |
app/workspace |
The open document, its replacement, and loading state. A replacement loads beside the open document and takes its place only once it opens; a failure leaves the open document in place. |
app/loaders |
Image, scene file, Camera Raw XMP, and .cube LUT loaders. A loader reads one file format and opens a document or edits the open one through the same edits the UI uses. |
app/draft |
The draft autosaved in the browser. |
app/controls.ts |
window.openlight, documented in API.md. |
app/commands.ts |
The commands: serializable, validated edits that run, WebMCP, and other callers share; see API.md. |
app/webmcp.ts |
WebMCP tools for browser agents, one per command; see API.md. |
app/assistant |
Experimental chat that edits the photo, mounted only at /?assistant. index.tsx sends a message with a summary of the photo and runs the commands that come back; jev.ts asks the Jev evaluation model and turns its answers into commands. |
/api, at the repository root |
Vercel functions: assistant.ts answers the chat through jev.ts; vercel dev serves them locally. |
app/editor/index.tsx |
The editor with a document: header, then a tool's canvas and controls in the layout. A tool's View replaces both. Active brush subscriptions update their input context without rendering the layout. |
app/editor/empty.tsx |
The editor before a document opens: welcome or loading status, and the placeholder sidebar. |
app/editor/sidebar.tsx |
Sidebar sections in order: EditorSidebar with a document, PlaceholderSidebar without one. |
app/editor/tools.tsx, tool-rail.tsx |
The tools and the desktop rail. A tool brings a Canvas overlay, Options for the bar over the image or the dock, or its own View. |
app/editor/tool-shortcuts.tsx |
Group shortcuts: return to a tool's remembered mode, then cycle its available modes while it is active. |
components/editor/brush-input.tsx |
Brush settings and the active input context. Brush and Healing own separate settings above the views; the canvas, cursor, menu, and shortcuts consume the active family. Size is a viewport diameter independent of image resolution and zoom. BrushCanvas converts it to source pixels once at stroke start, covering the same area there under perspective; recorded strokes retain their image-space geometry, round in source pixels, and the cursor shows the dab as the viewport does. |
components/editor/mapping.ts |
Viewport ↔ document positions through the frame, perspective included, for every canvas tool: exact both ways, with source circles and polygons drawn as their exact images. Pointer input stops at a straight line short of the correction's horizon, joining a stroke that crosses it along that line; a shape the viewport cannot show whole is clipped where it leaves the viewport. |
components/editor/brush-canvas.tsx, brush-cursor.tsx, brush-wheel.ts |
Shared brush gestures, cursor, and wheel input for color, masks, and retouch. Input batches each frame and flushes on release. Remove and modifier edits keep a local contour outside document history and record one edit on release; Remove also previews destination moves until drop. Modifiers and the release policy are latched at gesture start; the retouch feature owns their meaning. Portaled controls retain their own input. Vertical wheel resizes the active brush and horizontal wheel is captured without panning; trackpad pinch and Space override return navigation to the viewport. The cursor adds a center cross above a 100 px visible diameter. |
features/heal/modes.ts |
Retouch names, descriptions, and order: Remove, Heal, Clone. The initial mode, desktop buttons, mobile tabs, and shortcut cycle share this order; choosing a mode is remembered across tool visits. |
app/editor/brush.tsx |
The Brush tool: one canvas that sends color or mask strokes to the feature edits its mode picks, and its options. |
app/editor/dock.tsx |
The mobile dock's tabs and what it shows: the layer stack, a tool's options, or the selected layer's dials. |
app/editor/renderer.ts |
Composes feature passes into the preview and export pipelines. |
app/editor/mask-pass.ts |
A mask's adjustments, curve, and mix in one pass, fusing two features' shaders, which only the app may; backdrop.wgsl is the other shader the app owns. |
app/editor/mask-overlay.tsx |
The only writer of the mask overlay, derived from the selection. |
app/editor/layers.ts |
The effect kinds the app offers and their layer factories. |
app/editor/export |
The export view. |
components/editor |
Editor primitives: layout, panel, dock, parameters, viewport, document and renderer contexts, brush input. |
components/ui |
What @roprgm/ui lacks; see DESIGN.md. |
core/document |
Scene contract, layer tree, history, resources. |
core/image |
Image sources, decoding, geometry, color. |
core/renderer |
Render nodes, graph execution, masks, Remove fields, proxy, transform, display. |
features/<name> |
One capability: usually model.ts (parameters, defaults), edits.ts, pass.ts with its .wgsl, and controls.tsx. |
lib |
Utilities independent of OpenLight. |
tests |
*.test.ts run in Bun with vgpu/mock; *.e2e.ts run in Chromium; fixtures/ holds test images. |
useDesktopLayout in components/editor/layout.tsx is the one switch between two layouts, read from the same media query as Tailwind's md (768 px), so a class and the tree change in the same frame. Every view renders an EditorLayout with its canvas and controls, inside an EditorFrame that outlives the views: rail, canvas, and sidebar in a row on desktop; canvas, dock, and tab bar in a column on mobile. The app supplies the rail and the tab bar to the frame.
- The canvas keeps its place in both layouts, so crossing the breakpoint moves it without mounting it again. The sidebar and the dock swap; neither mounts hidden, since each subscribes to the document and the histogram reads the GPU.
- Features describe numbers as
Parameters drawn as sliders in the sidebar and dials in the dock, and other controls once for both. Composition that differs sits beside its desktop form:AdjustPanelandAdjustDock, or a tool'sOptionsunder thedockdensity. - State that outlives a layout, such as the tool, selection, camera, history, and each brush family's size and feather, lives above
EditorLayout. Brush settings last for the document and stay outside history.
| Layer | Owns |
|---|---|
0 — lib/ |
Code independent of OpenLight that could be a standalone library. Being shared or React-free does not qualify code for lib/. |
1 — core/, components/, hooks/ |
Document, image, and renderer infrastructure without React; UI primitives and React bindings above it. |
2 — features/ |
Removable product capabilities: processing, shaders, parameters, edits, and controls. Most product behavior belongs here. |
3 — app/ |
The shell, entry points, and explicit composition of features. |
Dependencies point downward. Features do not import each other; app/ connects them. Shared primitives do not import features. Keep module internals private and add no application-wide barrel.
- Document edits and rendering run without React, a mounted UI, or an implicit active document: pass workspace, document, and GPU explicitly. Feature processing imports without its panel.
- Each document owns a vanilla Zustand scene store, history, and image resources. Scenes hold immutable, serializable content and image-source IDs; files and GPU resources stay outside history.
- UI controls,
window.openlight, and commands call the same edits. A slider or curve gesture is one edit; cancelling restores the previous scene. Preview settings and navigation stay outside history. - The engine owns GPU resources, rendering, and derived data such as histogram bins; frame data stays out of React state. Every resource owner disposes what it creates.
- The model, how it is asked, and its key stay on the server. The browser sends a message and a summary of the photo, and receives commands and a message, so another model can answer without changing the client.
protocol.tsvalidates both directions. Vercel runsapi/assistant.tsunbundled in Node, so the modules it reaches import app modules only as types, and its own imports name their.jsoutput instead of using@/.
core/rendererdefines passes as nodes (node,pipeline,split,merge); the graph owns intermediate textures and timing. Features declare passes;app/editor/renderer.tsconnects them, andapp/editor/mask-pass.tsfuses two features' into one.- Hidden layers retain their composition instances, including descendant layers and Healing patches.
- The renderer keeps the proxy and the intermediate textures of the four reductions it rendered at last, each a quarter of the photo at most, so a zoom in and out allocates and frees nothing, which Safari penalizes; the photo-size set, which a zoom to every source pixel needs, goes once a reduction renders, so a phone's GPU memory holds the display's sizes, not the photo's.
- Use
vgpufor GPU work andvgpu-reactfor bindings. Create pipelines once per engine and reuse them. Keep.wgslbeside its owner. - The working space is linear Rec.2020 in
rgba16float. RAW and TIFF decode into it. An 8-bit source with the sRGB transfer stays as it came, encoded in a texture format that decodes to linear when read, in its own primaries, half the memory of the working format: the image layer's adjustments pass converts it, or its proxy, into the working space, and the display converts the source itself for the comparison. A source with another transfer, such as a 16-bit PNG or an HDR photo, converts at import into the working format. Passes preserve format and primaries unless they explicitly convert. - A LUT layer converts explicitly: its table expects display-referred sRGB, so it receives the working color as the display shows it, headroom above white clipped, and its output returns to linear Rec.2020.
- A node's storage arrays upload when the array changes; passing the same array, such as a LUT's table, keeps its buffer.
- The image layer's adjustments and tone curve develop the photo first; its children and the layers above process that result in stack order. Paint colors therefore follow the photo's adjustments. Working textures stay floating-point through composition; display and export encode the final result.
- Preserve HDR headroom through exposure, curves, and vibrance. Exposure multiplies linear RGB by
2^EV, without clipping, so +3 EV followed by -3 EV recovers the original light even when intermediate values exceed 1. - The adjustment shader's parameters use UI units; its fitted constants are calibration data.
- Strokes are scene content; the renderer caches their rasterized coverage, or a Paint layer's premultiplied 8-bit color, at half the source's resolution each way, a quarter of its pixels, which the passes that read them sample back to full size, and stamps only appended dabs. A Paint layer's or brush mask's raster comes with its first paint and stays, cleared if need be, until the layer goes, so painting and undoing allocate nothing. A photo holds up to 4 Paint layers and 10 brush masks.
- A mask's or Paint layer's latest stroke builds up in a half-float stroke buffer at the same resolution, one stroke at a time; its raster takes it in one pass when the next stroke starts, so it rounds to 8 bits once, however many moves drew it. Until then a Paint layer composes the buffer over its raster, and a mask's readers get its raster with the stroke laid over, in a view redrawn where the stroke grows. A Healing or Clone patch records its mode, reuses the same coverage, and keeps editable feather and opacity. Clone copies donor color directly, skipping the correction passes. A Healing patch records ordered hard paint/erase strokes, stamped through the shared GPU brush engine into one raster over only the 256 px tiles its paint reaches. Appending a gesture stamps only new dabs; undo or moved geometry replays the sequence. Shift adds and Alt/Option subtracts on the selected patch, preserving its donor and blend; separate strokes stay separate. The local SVG preview composes the same ordered operations and shows union boundaries and erased holes. Scene loading migrates earlier single-stroke patches once at the boundary.
- Remove patches store hard paint/erase strokes without a donor offset.
features/heal/inpaintsynthesizes a field of each hole texel's offset to its donor with a local, multiscale PatchMatch graph, entirely on the GPU: exclusion mask, preserved texture descriptors, nearest valid donor initialization, parallel propagation/random search, and overlap voting. Matching is bounded to 512 texels along the long side; the output samples donors at full source resolution. The Heal compositor applies the patch's coverage, feather, and opacity. - A Remove field is synthesized once per stroke version and kept. Each edit that changes a Remove patch's strokes, adding, erasing, or moving them, reserves a new field in the document's resources, and the patch names it, so every scene with those strokes, in history, drafts, scene files, and exports, shows one fill however late it arrives; filling it never changes the scene or history. The renderer keeps fields by ID: it synthesizes a waiting one once, at full resolution, for the patch's whole shape, from the image below, and reads it back for the document at once, rendering nothing meanwhile. Patches that share a field, such as duplicates, share its synthesis. The layers below, a proxy, hiding, undo, export, and a reopened scene all use that field and never solve again; a version never shown synthesizes when it first shows. Export, scene files, and drafts complete their snapshot with the fields the editor shows that the document still waits for, and fail when that readback fails. Composition samples donor color from the current image below, so the patch follows changes under it, such as exposure. A stroke added over part of an object the first stroke missed therefore leaves none of it, since the whole shape synthesizes from context without it. Synthesis needs no React. See the solver for its constraints and standalone benchmarks.
- A brush mask paints coverage as a Paint layer paints color: both are a painting, the latest strokes over settled pixels, in one raster module (r8 for coverage, rgba8 for color), which also owns the open stroke coverage view. At 100 strokes, once no gesture is open, the renderer reads a painting's raster band by band through one mapped buffer, and the pixels, deflated, become a document resource the layer names in place of its strokes, without a history step. Rendering waits until both the scene and cached raster accept the settled pixels. History scenes name the pixels they drew over, so undo reaches past a settle: the renderer loads those pixels, inflating them band by band through a staging texture, and draws the strokes again. Resources go once no retained scene names them.
- A selected layer's curve input, with the mask's coverage as alpha, renders at the few texels its histogram samples, never at full size.
- A mask without child effects renders its adjustments, curve, and mix in one pass over the image below, so no full-size texture holds its edited image; while it is inspected, its curve input reads the adjusted image, so the three render separately. A mask that only gradients shape needs no texture: the mix pass computes it, children included. A mask with a painted brush or a range combines in the render graph: each child's coverage, a brush's cached raster, a gradient drawn by a node, or a range read from the image below the mask, folds into the group's in stored order through one combine pass, and the mix pass shares its add, subtract, and intersect. The group takes the image's resolution when a range takes part, since a range follows the photo's edges, and the brushes' otherwise. The renderer gives it out, with each range's own coverage, for the overlay and thumbnails. While a color is picked, renders also keep the image below the mask group, which the picker reads through one GPU pass and 16 bytes of readback per pick.
- Renders reduce the source by the largest whole factor that still gives every device pixel a texel, a proxy, in gestures or not, so the photo's own size renders once the display shows more than half a device pixel per source pixel; the main canvas writes that density into the preview state, and the provider renders again when it changes. A proxy that lacks a Remove patch's field synthesizes it apart, outside a gesture: the layers below the patch render in full for the field alone, and the proxy composes again with it, so the display never shows a full render for it. The export view renders its own full snapshot, which its preview and the saved file both encode. Every render image carries
scale, its source pixels per texel; shaders that take document coordinates or radii apply it. - The scene's frame maps output to source in one homogeneous transform,
frameTransformincore/image/frame.ts: crop, rotation, flips, and scale, then the perspective correction about the source's center. Layers, paint, masks, and patches stay in source pixels, so correcting perspective leaves them in place;transformImageresamples the composite once, the display draws the comparison and the mask overlay from the source through the same transform, and a frame that changes nothing adds no pass. The proxy follows the frame's strongest magnification, perspective's included.lib/projective.tsholds the plane projective geometry the frame, the Crop tool's coverage, and the canvas mapping share. - TIFF and camera RAW decode through
raw-webgpu; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected; another balance develops into the renderer's own texture.