diff --git a/apps/macos/.gitignore b/apps/macos/.gitignore
new file mode 100644
index 0000000000..a2822081a8
--- /dev/null
+++ b/apps/macos/.gitignore
@@ -0,0 +1,15 @@
+# SwiftPM build products & local tooling
+.build/
+.swiftpm/
+DerivedData/
+
+# Packaging output (.app/.dmg/.zip — built by scripts/package-app.sh)
+dist/
+
+# Xcode
+*.xcodeproj
+*.xcworkspace
+xcuserdata/
+
+# macOS
+.DS_Store
diff --git a/apps/macos/DESIGN.md b/apps/macos/DESIGN.md
new file mode 100644
index 0000000000..75f5735197
--- /dev/null
+++ b/apps/macos/DESIGN.md
@@ -0,0 +1,363 @@
+# Lattice Studio — Design Decision
+
+> **Status:** Decision doc for Ocean. Two refined finalists, head-to-head, with a recommendation and a build sequence.
+> **Target:** macOS 26, SwiftUI, `swift build`. Toolchain verified: Apple Swift 6.3.2 / `arm64-apple-macosx26.0`.
+> **Backend:** the `lattice` pure-Rust engine, driven via CLI subprocesses (line-delimited JSON event stream).
+> **Brand law (governs both directions):** *measure first, the number is the truth.* Bold is spent on the **numbers**, not on chrome. Adaptive light + dark from day one.
+
+---
+
+## TL;DR
+
+Two monochrome, numeral-first directions survived refinement. They share one thesis (the app is the **instrument panel** of the engine, not a consumer wrapper) and diverge on **density vs. speed**.
+
+| | **PRIMARY — Lattice Instrument** | **ALTERNATIVE — Lattice / Console** |
+|---|---|---|
+| Angle | Trading-desk × oscilloscope, data-forward pro tool | Linear/Raycast/Zed minimal, keyboard-first |
+| Hero of the screen | A 56pt tabular-mono number on opaque matte | A 40pt tabular-mono number, Cmd-K runs everything |
+| Density | High (readout wells, dense tables, 3 panes) | Lean (flat config lists, command-palette spine) |
+| Accent | Signal Teal `#00E5C7` / `#00A892` | Voltage Blue `#3D7BFF` / `#2C5FE0` |
+| Feasibility | High | Highest |
+| Distinctiveness | Strong (full instrument metaphor) | Good (risks "another dark dev tool" if type is weak) |
+
+**Recommendation: build Lattice Instrument**, grafting Console's Cmd-K command spine. Rationale in the [comparison](#head-to-head) and [recommendation](#recommendation) sections.
+
+Both directions adopt three shared laws, grafted from the design exploration:
+1. **Numbers never touch glass.** Glass (`.glassEffect` / `.regularMaterial`) is permitted on the navigation/overlay layer only — toolbar + Cmd-K palette. Every content surface holding a numeral is **opaque**. The truth does not shimmer.
+2. **Before↔after ContrastPair** for the QuaRot quantization story — a decisive two-column reveal, never a buried claim.
+3. **Verdict as text, not vibe** — every measurable action ends in a GATE PILL stating the result (`PASS` / `WARN` / `FAIL`).
+
+---
+
+# PRIMARY — Lattice Instrument
+
+**Tagline:** *Measure first. The readout is the truth.*
+
+## Philosophy
+
+lattice owns hand-written Metal/AVX2/NEON kernels with zero framework — no Python, no ONNX, no CUDA, no runtime. The app should look like the **instrument panel of that engine**, not a wrapper around it. So every numeric quantity (loss, grad-norm, tok/s, PPL, MB, rank, lr) is a first-class citizen in tabular monospace that never reflows as digits change, on hairline-ruled opaque panels with almost no chrome — the way a DAW or a Bloomberg terminal trades whitespace for live truth.
+
+One accent (signal teal) is spent only on **movement** — the loss trace, the active token stream, the now-cursor — so the eye always lands on the thing that is actually changing. It honors Ocean's editorial/high-contrast lean by making **bold typography the hero** (a 56pt loss numeral, luminous data on ink) instead of illustration, and it beats a generic dashboard because it commits fully to the instrument metaphor: readouts, sweeps, gates — the literal visual translation of measure-first DNA.
+
+The README is explicit that MLX is faster; lattice's edge is **capabilities + honesty**. So we sell the moat, not raw speed: QuaRot 4-bit, Q4+LoRA hot-swap with no reload, pure-Rust portability.
+
+## Visual Identity
+
+### Palette
+
+Adaptive via semantic `Color` assets in the asset catalog (`Color(.panel)`, `Color(.ink)`, `Color(.signal)`…). Dark is the home key; light is a true first-class peer reading like a lab instrument under daylight — same hairline-and-well skeleton, value-inverted.
+
+| Token | Dark hex | Light hex | Usage |
+|---|---|---|---|
+| Canvas / Panel face | `#0A0B0D` Vantablack | `#F7F8FA` Quartz | Primary background, the instrument face. **Opaque.** |
+| Panel Raise | `#121419` | `#FFFFFF` | Elevated center panel + readout-well body (one step up). |
+| Well Sink | `#070809` | `#ECEEF2` | Recessed fill **inside** a readout well (below the face). |
+| Hairline | `#23262E` | `#DCDFE5` | 1px rules: dividers, grid, table separators, well borders. Depth via line. |
+| Ink (text + numerals) | `#E8EAED` | `#14161A` | Primary text/numerals. ~14.5:1 dark / ~15:1 light. |
+| Ink Dim | `#7C828D` | `#5C636E` | Labels, units, axis ticks, metadata. ~4.8:1 / ~5.2:1. |
+| **Signal Teal** (accent) | `#00E5C7` | `#00A892` Teal Deep | Live trace, token stream, now-cursor, the **one** primary CTA/screen, focus ring. The only color that shifts value between modes (to hold ~4.5:1). |
+| Teal Glow | `#00E5C7 @12%` | `#00A892 @12%` | Area-fill gradient under the live trace; under-glow behind newest segment. |
+| Amber Caution | `#FFB020` | `#FFB020` (value-shifted) | Regression/warning: loss rising, grad-norm spike, PPL over budget, mem pressure. Replaces red for colorblind safety. |
+| Crimson Halt | `#FF4D5E` | `#FF4D5E` (value-shifted) | Hard failure only: NaN loss, OOM, run crashed, parity FAIL. Rare by design. |
+
+**WCAG AA is a hard floor:** hero Ink-on-panel ≥14:1, dim labels ≥4.5:1, teal-on-its-ground ≥4.5:1. Amber/Crimson keep hue and shift lightness for AA on each ground.
+
+### Typography
+
+Three axes, hard separation by role. **Mono is the signature** — the eye registers "this is an instrument" from the numerals alone.
+
+- **DISPLAY / TITLES** — SF Pro Display (system), Bold/Heavy. Screen titles 17pt Semibold (-0.01em).
+- **HERO NUMBER** — the current loss / compression ratio / PPL at **56pt Bold, -0.02em**, rendered in **JetBrains Mono tabular** (not the grotesque) so the headline number never jitters. This is the one place display scale and mono fuse.
+- **NUMERALS + DATA** — JetBrains Mono (bundled, OFL), `.monospacedDigit()` always on. Sizes: 11pt table cells / 13pt dense readouts / 15pt readout-well value / 21pt secondary hero / 56pt hero. Units 11pt.
+- **BODY / LABELS** — SF Pro Text 13pt Regular for prose; 11pt Medium ALL-CAPS +0.06em for instrument labels and units (`LOSS`, `TOK/S`, `GRAD-NORM`, `ΔPPL`). Max prose measure 640px.
+
+Modular scale (pt): **11 · 13 · 15 · 21 · 34 · 56.**
+
+### Spacing & Grid
+
+8pt base grid; dense 4pt variant for table rows (28px row height, 32px "comfortable" toggle). Three-region shell, panels butt against 1px Hairline with **no gaps**: **LEFT RAIL 220px / fluid CENTER / RIGHT INSPECTOR 300px** (collapsible to 0 with `⌘\`). Internal panel padding 16px; section gutter 24px.
+
+Readout wells: inset 1px Hairline border + Well-Sink fill + a 2px inner top-shadow (`rgba(0,0,0,0.5)` dark / `0.06` light) so they read machined-in. **Corner radius:** `0px` on panels and table cells (panels are ruled, not carded), `6px` on wells/controls/gate pills, `10px` on the floating command bar. Data tables go full-bleed. **One elevation step only — no card-on-card.**
+
+### Materials & Motion
+
+**Governing law: numbers never touch glass.** Glass is permitted on the navigation/overlay layer only (top toolbar + the floating ⌘K command bar use thin `.regularMaterial`). Every content surface — panels, wells, tables, the strip chart, transcripts — is opaque. This also dodges the Liquid-Glass perf caveats, because data is solid.
+
+Motion is mechanical, never bouncy:
+1. **Numerals tick per-digit** via `.contentTransition(.numericText())` at ~120ms — throttled to 8Hz on fast values (tok/s) so it never looks frantic.
+2. **Loss curve draws as an oscilloscope sweep** (left→right) with a 1px teal now-cursor and a 12% teal under-glow; chart commits throttled to ~20Hz with rolling-window downsample.
+3. **Panel focus** traces a 1px teal ring in 180ms ease-out.
+4. **Spring (response 0.32, damping 0.85)** is reserved for exactly **one** element: the adapter hot-swap fader, so it feels like throwing a hardware switch.
+
+Durations 120/180/240ms ease-out. No parallax, no decorative particles. Respects `accessibilityReduceMotion` (ticks→crossfade, sweep→instant) and `accessibilityReduceTransparency` (glass→solid material, same teal).
+
+## Component Language
+
+Instruments, not widgets. Eleven primitives, opaque unless noted.
+
+1. **READOUT WELL** — the atomic unit: inset hairline block (Well-Sink fill, 6px radius, inner top-shadow) holding one 11pt ALL-CAPS dim label + one tabular-mono value (15pt) + unit + optional delta caret (▲/▼ teal=good/amber=bad). For loss, lr, grad-norm, tok/s, ETA, size, PPL.
+2. **HERO NUMBER** — 56pt JetBrains-Mono-tabular value with a tiny mono unit and a 1px teal under-rule; ticks per-digit. The visual anchor of TRAIN and QUANTIZE.
+3. **STRIP CHART (oscilloscope)** — Swift Charts `LineMark` (1.5px teal) + `AreaMark` (12% teal gradient) + `RuleMark` now-cursor; mono axis ticks, hairline grid, no legend. A draggable scrub line freezes every readout well to that step. On QUANTIZE it carries a **ghost base-PPL line** so the delta is a visible gap, not a claim.
+4. **CONTRAST PAIR** — the before↔after comparator: two stacked READOUT WELL columns (size/bits/PPL) with a centered Δ chip; on completion the columns slide apart and a single hairline **"fold" wipe** (the QuaRot rotation metaphor) reveals the after-state.
+5. **MASS BARS** — two horizontal bars to **true scale** (fp16 "before" Ink-dim / Q4 "after" teal) that animate to length while the compression ratio counts up into a HERO NUMBER. Lives directly under the CONTRAST PAIR on QUANTIZE.
+6. **DATA TABLE** — 28px rows (32px comfortable), tabular-mono right-aligned numerals, hairline row rules, **no zebra**, sortable 11pt all-caps headers, selected row marked by a 2px teal left-border (no fill flood).
+7. **PARAM ROW** — label (left, dim) + control (right) on one hairline-ruled line; sliders show their value as a live mono readout in the track. Forms are stacks of these, never boxed.
+8. **GATE PILL** — status capsule encoding the verdict: `PASS` (teal) / `WARN` (amber) / `FAIL` (crimson) / `RUN` (animated teal pulse). e.g. `Q4 ok 405MB −74% · est.ΔPPL +0.09 PASS`.
+9. **FADER TOGGLE** — the adapter A/B switch styled as a console fader (the one spring element).
+10. **KEY-CAP CHIP** — a tiny 1px-outlined ⌘-cap on actionable elements so the keyboard map self-documents.
+11. **COMMAND BAR** — a floating ⌘K mono palette (the one glass slab) where `train qwen3.5 r8` or `quantize quarot` parse into argument chips and fire a run without touching a form. *(Grafted from Console — see recommendation.)*
+
+Buttons: rectangular, 6px radius, 1px-bordered hairline default; exactly **one** teal-filled primary per screen. No rounded cards, no drop shadows except the recessed-well inner shadow.
+
+## Information Architecture
+
+Three-pane instrument console (`NavigationSplitView`).
+
+- **LEFT RAIL (220px, persistent):** wordmark `LATTICE` in mono + build hash; a pinned top **RUN block** (live job: model · step · loss · tok/s, or `idle`); vertical nav with mono index labels — `01 MODELS`, `02 TRAIN`, `03 QUANTIZE`, `04 CHAT`, `05 DATA`, `06 RUNS`, each with a ⌘1–⌘6 key-cap chip; pinned at the bottom a live **SYSTEM STRIP** (unified-memory bar, GPU%, active-run mini-readout) always reading out.
+- **CENTER (fluid):** the active instrument screen.
+- **RIGHT INSPECTOR (300px, contextual, collapsible `⌘\`):** details/config for the current selection.
+
+### Screens
+
+- **01 MODELS** — DATA TABLE of `~/.lattice/models` (name, params, dtype, format, size GB, files) with adapters indented under each base model (verified on-disk: `qwen3.5-0.8b` + `-q4` + `-q4-quarot`, plus `qwen3.5-2b`; embedding models — `all-minilm-l6-v2`, `bge-small-en-v1.5`, `qwen3-embedding-0.6b`, the e5/minilm multilingual pair — filtered to a sub-tab). Inspector shows the file manifest, `config.json` as readout wells (the **18-GDN / 6-GQA** layer split called out explicitly, vocab, ctx), and Download / Verify / Reveal-in-Finder. QuaRot weights get a `rotated` badge.
+- **02 TRAIN** — left PARAM ROWS (model, target layers, rank r8, scale α, lr, steps, dataset). Center HERO NUMBER (live loss, ticking) over the oscilloscope STRIP CHART (train_loss + val_loss + grad-norm overlay, teal trace, now-cursor), with a row of READOUT WELLS (step, lr now, grad-norm, tok/s, ETA). Inspector: live stdout console + a GATE PILL on `best_val_loss` verdict. Scrub the curve to freeze all wells at any step.
+- **03 QUANTIZE** — pick model + method (Q4 / QuaRot toggle, QuaRot badged `rotated`). Center the CONTRAST PAIR (size/bits/est.PPL with Δ) over MASS BARS (true-scale fp16→Q4) over a progress sweep, terminating in a VERDICT GATE PILL. Inspector: per-layer quant table (layer, scheme, error).
+- **04 CHAT** — split A | B columns (base vs base+adapter) streaming the SAME prompt token-by-token in teal; the adapter selector is the HOT-SWAP FADER with a `0 ms reload` stamp; sampling READOUT WELLS (temp, top-p, top-k, seed) in the inspector; per-column tok/s + TTFT so testing is also measuring.
+- **05 DATA** — left source files; center a `{prompt, completion}` pair DATA TABLE with a validate GATE PILL (token-count histogram, malformed-row count); inspector single-pair editor + export-to-JSONL that writes straight into the Train dataset field.
+- **06 RUNS** — archive DATA TABLE (status pill, kind, model, last loss/best val, dur, when); selecting a row loads its config back into the relevant screen and shows its frozen strip chart — the lab notebook.
+
+## Signature Interactions
+
+1. **LIVE LOSS OSCILLOSCOPE + SCRUB-TO-FREEZE** — the TRAIN strip chart draws as a real-time left→right sweep with a 1px teal now-cursor and 12% under-glow; the 56pt hero loss numeral above it ticks digit-by-digit. Drag the cursor backward and **every** readout well rewinds to that step's exact lr / grad-norm / tok-s — inspect the precise state at the moment loss spiked. Only possible because wells are wired to the real per-step `EpochMetrics` / `AdaptStepResult` stream, not a redraw.
+2. **HOT-SWAP FADER (the moat made physical)** — in CHAT the adapter selector is a console fader. Sliding it from BASE to BASE+LoRA r8 swaps the adapter with **no reload** — the B column visibly re-streams the same prompt under the new weights in real time and a teal `0 ms reload` stamp confirms it. The one place spring physics is allowed. Both columns share one prompt bar and stream in lockstep.
+3. **THE VERDICT GATE + ⌘K SPINE** — every measurable action ends in a GATE PILL stating the truth as text (`Q4 ok 405MB −74% · est.ΔPPL +0.09 PASS`, flips amber if ΔPPL exceeds the honest budget); the CONTRAST PAIR completes with a single hairline "fold" wipe as the MASS BARS settle. ⌘K opens a glass mono palette where `quantize quarot qwen3.5` parses into argument chips and fires the run without a form.
+
+## ASCII Mockups
+
+```
+MAIN SHELL — 02 TRAIN (dark) ---------------------------------------
++------------+--------------------------------------+---------------+
+| LATTICE | 02 TRAIN qwen3.5-0.8b · lora r8 ⌘K | STDOUT ⌘\ |
+| ·a3f9c1 +--------------------------------------+ step 0420... |
+| ● RUN | L O S S | loss 0.612 |
+| step 420 | 0.6121 ▼0.004 step 420/1000 | lr 1.81e-4 |
+| 01 MODELS ⌘1| +----------------------------------+ | grad 0.93 |
+| 02 TRAIN ◀ | |\__ .· |◀| tok/s 1820 |
+| 03 QUANTIZE| | \\__ ..· | |---------------|
+| 04 CHAT ⌘4| | \\\___\__/\___····· now► | | GATE |
+| 05 DATA ⌘5| +----------------------------------+ | [▣] eval PASS |
+| 06 RUNS ⌘R| STEP LR-NOW GRAD TOK/S ETA | | best val 1.94 |
+|············| 0420 1.81e-4 0.93 1820 4m | +---------------+
+| MEM ████░ | +- PARAMS ------------------------+ | |
+| 11.2/16 GB | rank r8 · α16 · lr 1.8e-4 · 18 GDN | |
+| GPU 74% | dataset claude-lora.jsonl 4,812 rw | [ ■ STOP ] |
++------------+--------------------------------------+---------------+
+
+03 QUANTIZE -------------------------------------------------------
+| 03 QUANTIZE qwen3.5-0.8b method:( Q4 )[ QuaRot ⟳ ] |
+| BEFORE ──fold──▶ AFTER VERDICT |
+| SIZE 1.61 GB 405 MB ▼ −74% |
+| BITS 16 4 rotated ok |
+| PPL 15.86 (ghost)····· 15.95 est. Δ+0.09 [▣]PASS |
+| fp16 ████████████████████████ Q4 ███████ 3.97× smaller |
+| ███████████████░░░░ rotate→pack→verify layer 18/24 sweep► |
+```
+
+## Per-Feature Surfacing
+
+- **1. LoRA Training → 02 TRAIN.** PARAM ROWS configure model / target-layers (18 GDN + 6 GQA selectable strip) / rank r8 / scale α / lr / steps / dataset, slider values shown as live mono readouts. Watched via a 56pt HERO loss numeral (ticking) over the oscilloscope STRIP CHART (train_loss + val_loss + grad-norm overlay) and READOUT WELLS for step / lr-now / grad-norm / tok-s / ETA — wired to the real `EpochMetrics{train_loss, val_loss, learning_rate, examples_seen, duration_secs}` plus per-step `AdaptStepResult{loss, grad_norm}` streamed as line-delimited JSON into an `@Observable` store. Inspector streams raw stdout and shows the eval GATE PILL on `best_val_loss`. Scrub the curve to freeze all wells at any step.
+- **2. Quantization → 03 QUANTIZE.** Pick model + a Q4 / QuaRot method toggle (QuaRot badged `rotated ⟳`). A CONTRAST PAIR shows size / bits / est.PPL with the Δ in teal(improve)/amber(regress); below it MASS BARS animate to true scale (fp16 1.61GB → Q4 405MB) while the 3.97× ratio counts up; a progress sweep labels real phases (rotate→pack→verify); a final VERDICT GATE PILL states the result. The strip carries a ghost base-PPL line so quality cost is a visible gap. PPL is explicitly labeled `est.` unless a calibration set is supplied (measure-first honesty). Inspector: per-layer quant table.
+- **3. Model Management → 01 MODELS.** Dense DATA TABLE of `~/.lattice/models` with adapters indented under their base model — reflecting the real on-disk layout. Tabular-mono right-aligned numerals, sortable headers, no zebra. Inspector reveals the full file manifest and `config.json` as readout wells (18-GDN / 6-GQA split explicit), with Download / Verify / Reveal-in-Finder. QuaRot weights get a `rotated` badge; the adapter row shows a `swappable · no reload` chip.
+- **4. Chat / Sample Testing → 04 CHAT.** Two-column A|B layout streams the SAME prompt token-by-token (teal) through base vs base+adapter from ONE shared prompt bar. The adapter selector is the HOT-SWAP FADER (`0 ms reload` stamp); sliding it re-streams the B column live. Sampling controls (temp/top-p/top-k/seed) are inspector READOUT WELLS; each column shows live tok/s + TTFT so sample-testing doubles as measurement. Single-column mode collapses to one full-width transcript.
+- **5. Dataset Prep → 05 DATA.** Source files on the left; center a `{prompt, completion}` pair DATA TABLE with a validate GATE PILL summarizing a token-count histogram and malformed-row count (tokenized with lattice's own tokenizer so counts match training exactly). Inspector is a single-pair editor; export writes JSONL straight into the Train screen's dataset field — raw to training-set, previewed and validated before it ever feeds a run.
+
+---
+
+# ALTERNATIVE — Lattice / Console
+
+**Tagline:** *Measure first. Every number, one keystroke away.*
+
+## Philosophy
+
+lattice is an instrument, not an app. An oscilloscope doesn't decorate the waveform; it gets out of the way so the engineer trusts the trace. Console is near-monochrome so the one thing that earns color and movement — a live loss ticking down, a PPL delta, a parity PASS — is unmissable and pre-attentive. Everything is reachable from a single command bar (⌘K), because a tool that worships "measure first" should never make you hunt through menus for the measurement.
+
+It earns Ocean's editorial/bold lean by spending all of that boldness on the **numerals themselves**, not on chrome — the visual analog of pure-Rust with no Python, no ONNX, no runtime bloat. Same "numbers never touch glass" law; glass lives only on the navigation layer.
+
+## Visual Identity
+
+### Palette
+
+Four elevation levels resolved per appearance: base(L0) → panel(L1) → raise(L2) → overlay. Dark is default + native habitat; light is a true warm-paper theme, not an inverted hack. Shadows appear **only** in light (single y2/blur8/6% step under cards); dark relies on borders alone.
+
+| Token | Dark hex | Light hex | Usage |
+|---|---|---|---|
+| Base canvas (L0) | `#0B0C0E` Ink | `#FBFBFA` Paper (warm) | Window canvas, deepest surface. |
+| Panel (L1) | `#131519` Slate-900 | `#FFFFFF` Surface | Sidebar, cards, metric rail, table body. |
+| Raise (L2) | `#1B1E24` Slate-800 | `#F2F1EE` | Hover, selected-row base, input wells. |
+| Hairline | `#262A31` | `#E6E4E0` | 1px inset borders + dividers + chart grid. |
+| Text hi (+ hero numerals) | `#ECEEF1` | `#16181C` | Primary text/numerals. |
+| Text mid | `#9AA1AC` | `#5E636B` | Secondary labels, units, ticks. |
+| Text dim | `#5B626D` | (derived) | Tertiary / placeholder / disabled / key-cap outline. |
+| **Voltage** (accent) | `#3D7BFF` | `#2C5FE0` Voltage-deep | Focus ring, the one primary CTA/screen, live-metric line, ⌘K selection, selected-row left-border. Rationed. ~4.6:1 on each base. |
+| Signal-pass | `#2FB079` | `#2FB079` | Correctness only: parity PASS, quant OK, converging caret. Paired with a glyph (✓ / ▼). |
+| Signal-warn | `#D9A227` | `#D9A227` | Drift / PPL regression / OOM-risk / loss-rising caret. Paired with ▲. |
+| Signal-fail | `#E5523C` | `#E5523C` | Parity FAIL, NaN loss, crash. Rare; paired with ✕. |
+
+Every signal color pairs with a redundant glyph for colorblind safety; signal hues shift lightness (not hue) to clear ~4.5:1 on both grounds.
+
+### Typography
+
+Three roles; the proportional-vs-monospace contrast **is** the identity (it signals "this region is a measurement").
+
+- **DISPLAY = the numbers**, tabular-figure monospace (SF Mono on-system, JetBrains Mono bundled fallback for exact metrics; `.monospacedDigit` always). HERO 40pt/600 (loss, PPL, tok/s on Train + Quantize); SECTION 22pt/600 (metric tiles); INLINE 13pt/500 (table cells, config values). Hero gets **-0.01em tracking** with the unit as an 11pt mid-tone superscript-baseline label, so the number reads as authored even at rest (fixes the "console echo" risk).
+- **BODY/UI = SF Pro Text** — 13pt regular for labels + prose, 11pt medium UPPERCASE micro-labels naming each metric (+0.06em: `LOSS · TOK/S · GRAD-NORM`).
+- **MONO-SMALL = SF Mono 11.5pt** for config values, file paths, log console, JSONL preview cells (columns must align).
+
+Modular scale (1.25): **11 / 13 / 16 / 22 / 28 / 40.** No serif anywhere.
+
+### Spacing & Grid
+
+8pt base, 4pt half-step for dense rows. Three-pane shell on a fixed rhythm: **sidebar 232pt · detail flexes · right rail 320pt** (collapsible `⌘/`). Row height 28pt dense / 32pt comfortable (Settings toggle — the named mitigation for 13in density). Card padding 16pt; gutters 24pt; outer margin 32pt. Density-without-clutter is bought with whitespace **between** blocks, never within. Corner radius: 6pt controls, 10pt cards, 14pt ⌘K palette/modals. Min window 1080×720.
+
+### Materials & Motion
+
+Restraint-first, governed by the same law: **glass only on the nav layer.** Liquid Glass on exactly two surfaces — the floating toolbar and the ⌘K palette slab over a dimmed canvas. All content is solid fill. Motion is functional: 120ms focus-ring + row-selection, 180ms pane collapse, 200ms ⌘K slab. The **one signature motion** — the live hero numeral uses `.contentTransition(.numericText())` so a dropping loss ticks digit-by-digit (~90ms); the delta caret crossfades color/direction in the SAME beat, and the loss curve extends one accent segment in the SAME beat — **three surfaces, one pulse.** No bounce, no parallax. Reduce-Motion → instant swaps + crossfade. Reduce-Transparency → glass → solid material (free via system).
+
+## Component Language
+
+Spare, line-based, table-centric — eight primitives + two control specials.
+
+1. **COMMAND BAR** — the spine: a single ⌘K glass palette running everything (start training, quantize, switch model, open chat, jump to a run) with fuzzy match, recent actions, and inline **argument chips** (`train qwen3.5 r8` parses `r8` into an editable rank chip).
+2. **METRIC TILE** — uppercase 11pt micro-label + 22–40pt tabular-mono number + delta caret (▼ pass / ▲ warn); the only place numbers go big.
+3. **SPARK + FULL CHART** — Swift Charts `LineMark`, 1.5px accent stroke on transparent grid, hairline axes, no fills/legend chrome; hover scrubs a `RuleMark` crosshair with mono readout, freezing every metric tile to that step.
+4. **DENSE DATA TABLE** — 28pt rows, mono value columns right-aligned, single accent left-border on selected row (no fill flood), sortable 11pt uppercase headers, zebra-free.
+5. **CONFIG ROW** — label left, mono editable value right, inline validation dot; forms are flat lists, never boxed.
+6. **KEY-CAP CHIP** — every actionable element shows its shortcut as a tiny outlined ⌘-cap (self-documenting keyboard map).
+7. **STATUS PILL** — `PASS` / `RUNNING` / `FAILED` in semantic colors + glyph.
+8. **CONTRAST PAIR** — left↔right split with a centered Δ chip for any before↔after; two values animate apart, Δ resolves pass/warn. Used for QuaRot and base↔adapter.
+
+Control specials: the **HOT-SWAP FADER** (the Chat adapter A/B; the one place a spring is allowed) and the **TRUTH TOGGLE** (`⌘.`) — dims all nav glass so only opaque data remains. Buttons: mostly tertiary (text + key-cap); exactly **one** `.glassProminent` accent button per screen.
+
+## Information Architecture
+
+Three-pane `NavigationSplitView`.
+
+- **LEFT SIDEBAR (232pt)** — flat, keyboard-navigable, no nested disclosure: a pinned top **RUN STATUS block** (live job: model · step · loss · tok/s + pause/stop, or `idle`), then `TRAIN ⌘1 · QUANTIZE ⌘2 · MODELS ⌘3 · CHAT ⌘4 · DATA ⌘5`, then a footer `RUNS ⌘R` + `Settings ⌘,`.
+- **CENTER** — active screen.
+- **RIGHT RAIL (320pt, `⌘/` collapse)** — context inspector.
+
+### Screens
+
+- **Runs / Home** — dense table of every run (status pill, model, kind LoRA/Q4/QuaRot, last-loss/PPL, duration, when) — the lab notebook; ⌘-click any row reopens its exact config.
+- **Train ⌘1** — center = flat config list (model picker; an interactive **LAYER STRIP** rendering the 18 GatedDeltaNet + 6 GQA layers as selectable cells; rank, scale α, lr, steps, dataset); one accent `Start ⌘↵`. Right rail = live loss curve + metric tiles (loss/lr/grad-norm/tok-s/ETA) + scrolling log console.
+- **Quantize ⌘2** — pick model + method segmented toggle Q4 / QuaRot (QuaRot framed as the premium moat); a CONTRAST PAIR before→after size (1.61GB → 405MB) animating apart with a "fold" wipe + Δ chip; progress strip with phase labels (rotate → pack → verify); a PPL/quality readout base→quant with a green/warn signal when a calib set is supplied.
+- **Models ⌘3** — master table of `~/.lattice/models` + adapters (name, dtype, format, size, file count, status pill); right-rail inspector = full file manifest + actions Load / Hot-swap adapter / Reveal in Finder / Delete. Download/import is a ⌘K action.
+- **Chat ⌘4** — streaming transcript; split-compare mode = base vs base+adapter in two lockstep-streaming columns; right rail = temp/top-p/max-tokens + the live HOT-SWAP FADER (`⌥A`).
+- **Data ⌘5** — two-pane raw→`{prompt,completion}` builder: raw input left, JSONL preview table right + inline validator (row count, malformed flags, token-length histogram); export feeds the Train dataset picker.
+- **Settings** — appearance (System/Light/Dark), row density, models dir, default hyperparams.
+
+## Signature Interactions
+
+1. **HOT-SWAP FADER, the moat made physical** — grounded in the real engine: `LiveModel` uses `arc_swap::ArcSwap` for lock-free atomic `swap()`. In Chat split-compare the right column carries a console fader. Slide BASE → BASE+LoRA-r8 and the engine swaps the adapter with no reload; the column flashes its accent left-border 120ms, a `0 ms reload` stamp confirms it, and the very next streamed token comes from the new adapter. The one place a spring (0.32) is allowed.
+2. **THE NUMBER IS THE TRUTH, three surfaces one heartbeat** — on every `TrainingCallback.on_batch_end` the hero loss numeral ticks digit-by-digit (~90ms), the delta caret flips pass/warn in the SAME 90ms, and the loss curve extends one accent segment in the SAME beat. "Is it converging?" is answered pre-attentively without reading an axis. Scrub the crosshair and all three plus every metric tile freeze to that step.
+3. **COMMAND BAR AS THE WHOLE APP** — ⌘K opens a glass palette where `train qwen3.5 r8` or `quantize quarot` parse into argument chips; you configure and launch a full run without touching a form. A persistent ⌘-cap legend means power users never reach for the mouse; every action also has a visible affordance. Bonus: `⌘.` Truth Toggle dims nav glass so only the opaque data canvas remains.
+
+## ASCII Mockups
+
+```
+SHELL — Train (dark)
+┌────────────┬───────────────────────────────┬─────────────────────────┐
+│ ● RUN │ Train · Qwen3.5-0.8B │ LIVE METRICS ⌘/ │
+│ qwen3.5 │ ─────────────────────────── │ LOSS │
+│ step 412 │ model Qwen3.5-0.8B ▾ │ 0.612 ▼ step 412/1k │
+│ loss 0.612 │ layers [GDN×18][GQA×6] ◧ │ ╭───────────────────╮ │
+│ 184 tok/s │ rank 8 scale α 16 │ │ ╲__ │ │
+│ ⏸ ⏹ │ lr 2e-4 steps 1000 │ │ ╲────╲___ │ │
+│────────────│ dataset claude_set.jsonl │ ╰───────────────────╯ │
+│ TRAIN ⌘1│ │ lr 2e-4 grad 0.91 │
+│ QUANTIZE ⌘2│ ┌─────────────────────────┐ │ tok/s 184 eta 2m04s │
+│ MODELS ⌘3│ │ Start run ⌘↵ │ │ ── log ────────────── │
+│ CHAT ⌘4│ └─────────────────────────┘ │ step 412 loss 0.612 │
+│ DATA ⌘5│ │ step 411 loss 0.618 │
+│────────────│ │ ckpt saved @ 400 │
+│ RUNS ⌘R │ │ │
+└────────────┴───────────────────────────────┴─────────────────────────┘
+ ⌘K command · ⌘/ rail · ⌘. truth · ↑↓ nav · ⏎ open
+
+CHAT split-compare — hot-swap fader (base vs +adapter)
+┌──────────────────────────────┬──────────────────────────────┐
+│ BASE Qwen3.5-0.8B │ +ADAPTER claude-r8 0ms swap │
+│ The capital of France is │ The capital of France is │
+│ Paris, a city known for▏ │ Paris — bonjour. As Claude▏ │
+├──────────────────────────────┴──────────────────────────────┤
+│ BASE ◁───────●────▷ +LoRA temp 0.7 top-p 0.9 ⌘↵ send ⌥A │
+└──────────────────────────────────────────────────────────────┘
+```
+
+## Per-Feature Surfacing
+
+- **1. LoRA Training → Train ⌘1.** Flat config list (no nested cards): model picker, interactive LAYER STRIP (real 18 GatedDeltaNet + 6 GQA cells), rank, scale α, lr, steps, dataset. One rationed accent `Start ⌘↵`. Right rail streams the live loss curve + metric tiles (fed by `TrainingCallback.on_batch_end` / `on_epoch_end`) + a scrolling stdout log; `on_checkpoint` drops a "ckpt saved" line. Pause/stop in the pinned sidebar RUN block. Also launchable via ⌘K with argument chips.
+- **2. Quantization → Quantize ⌘2.** Method as a segmented toggle Q4 vs QuaRot (QuaRot framed as the premium moat). A CONTRAST PAIR shows before→after size animating apart with a single "fold" wipe + Δ chip (−75%); a progress strip names the phase (rotate → pack → verify). When a calibration set is supplied a PPL/quality readout shows base→quant delta (15.86 → 15.92) with a pass/warn signal so quality is honest, never hidden. Mirrors the real `-q4` and `-q4-quarot` artifacts on disk.
+- **3. Model Management → Models ⌘3.** Dense master table of `~/.lattice/models` AND adapters in one view: name, dtype, format, size, file count, status pill; tabular-mono columns keep sizes/shapes aligned. Right-rail inspector shows the full file manifest (18-GDN/6-GQA split from `config.json`) plus Load / Hot-swap adapter / Reveal in Finder / Delete. Download/import is a ⌘K action. QuaRot weights get a `rotated` badge.
+- **4. Chat / Sample Testing → Chat ⌘4.** Signature split-compare: base vs base+adapter in two lockstep-streaming columns driven off two AsyncStreams throttled to a shared clock. Sampling controls (temp/top-p/max-tokens) in the right rail; the HOT-SWAP FADER (`⌥A`) does a live no-reload swap via the engine's `arc_swap` LiveModel with a `0 ms reload` stamp. Single-column mode for plain testing.
+- **5. Dataset Prep → Data ⌘5.** Two-pane builder: raw input (paste / file) left, the derived `{prompt, completion}` set as a JSONL preview table right. Inline validator reports row count, malformed-row flags, and a token-length histogram (using lattice's own tokenizer so counts match training). Export writes straight into the Train dataset picker, closing the loop.
+
+---
+
+# Head-to-Head
+
+| Criterion | Lattice Instrument | Lattice / Console | Edge |
+|---|---|---|---|
+| **Sleek / modern** | 9 — full instrument metaphor, recessed wells, oscilloscope | 9 — ruthless Linear-class restraint | tie |
+| **Brand fit** | 10 — most literal "measure first" translation | 9 — same thesis, leans on a known aesthetic | Instrument |
+| **Feature surfacing** | 9 — dedicated comparator/quant-table screens, denser readouts | 8 — slightly thinner; config lists + right-rail metrics | Instrument |
+| **Feasibility** | 9 — all native APIs; density redraw is the only watch-item | 10 — least surface area, glass on nav only | Console |
+| **Distinctiveness** | 8 — instrument/Bloomberg metaphor is on-brand but not category-novel | 7 — risks "another dark dev tool" without an exceptional type pass | Instrument |
+| **Density posture** | High by default (with a comfortable toggle) | Lean by default (Cmd-K hides forms) | depends on Ocean |
+
+**When each wins.** Instrument wins when the user lives *inside* a run — watching loss, scrubbing the curve, reading six wells at once — and wants the screen to be a panel of live truth. It is the richer demo and the more defensible brand statement. Console wins on pure speed and minimal build risk: a power user who configures by keystroke and never wants to see a form, on the cheapest path to ship. Console's distinctiveness is its only soft spot — near-monochrome reads generic unless the hero numerals are genuinely authored.
+
+The crucial point: **they are not opposites.** Console's Cmd-K command spine is the single best IA primitive in the whole exploration, and it slots cleanly into Instrument as primitive #11. The real fork is *density default*, not *which thesis*.
+
+---
+
+# Recommendation
+
+**Build Lattice Instrument, with Console's ⌘K command spine grafted in from day one.**
+
+Why:
+1. **Brand fit is the tiebreaker and Instrument wins it 10 vs 9.** lattice's identity is literally "the number is the truth," and Instrument is the most complete translation of that into UI — readout wells, the oscilloscope strip chart with scrub-to-freeze, GATE PILL verdicts. It sells the *moat* (QuaRot ContrastPair, hot-swap fader) rather than raw speed, exactly as the README demands.
+2. **The feasibility gap is small and closeable.** Instrument is a 9 to Console's 10. Its only real risk is 60fps redraw of the live curve under a fast step/token stream — mitigated by buffering points and throttling chart commits to ~20Hz, the same technique both directions already specify. Everything else maps to native APIs (`.monospacedDigit`, `.contentTransition(.numericText())`, Swift Charts `LineMark`+`RuleMark`+`chartOverlay`, `NavigationSplitView`). Verified buildable on the installed Swift 6.3.2 / macOS 26 toolchain.
+3. **Grafting closes the only thing Console wins on.** Adding the ⌘K spine to Instrument gives power users the keyboard-first launch path without sacrificing the dense readout panels. The comfortable-row toggle (32px) handles the density-too-cold risk Instrument self-flags. We get Console's speed *and* Instrument's brand depth.
+4. **Honors Ocean's editorial/bold lean correctly.** Both reject the literal display-serif reading (a streaming loss in a 96pt serif visibly jitters — it contradicts "the truth does not shimmer"). Instrument spends all its boldness on a 56pt tabular-mono hero numeral: editorial confidence without the jitter risk.
+
+The override on Ocean's "editorial/bold" lean is deliberate and stated: editorial is a *typographic posture*, not a literal magazine. We deliver it as **monospace-numeral boldness**, which is both more on-brand for an instrument and lower-risk to build. If Ocean specifically wants the magazine-cover feel, that is the genuine fork below.
+
+---
+
+# What Gets Built First
+
+`apps/macos/` is greenfield. Sequence for a SwiftUI macOS app driven by lattice CLI subprocesses:
+
+1. **SwiftPM target + shell.** Create `apps/macos/` SwiftPM executable target that `swift build` compiles. Stand up the `NavigationSplitView` three-pane shell, the left rail with static nav, and the asset-catalog semantic colors (both appearances, all elevation tokens). Goal: an empty but adaptive shell that launches.
+2. **The opaque-panel + numeral kit.** Build the shared primitives that everything depends on: `OpaquePanel` container (enforces "numbers never touch glass"), `ReadoutWell`, `HeroNumber` (`.contentTransition(.numericText())`), `DataTable`, `GatePill`, `KeyCapChip`. Bundle JetBrains Mono. This is the visual identity made real.
+3. **CLI bridge + event store.** A subprocess runner that spawns the lattice `tune` / `quantize` / `chat` bins and parses their line-delimited JSON `stdout` into an `@Observable` run store. Map JSON 1:1 onto the real serde structs (`EpochMetrics`, `AdaptStepResult`, `TrainingMetrics`). **Critical:** own the run `@State` *above* the view and key by run-id so a re-render never resets a live run.
+4. **01 MODELS (read-only first).** The simplest real-data screen: list `~/.lattice/models`, parse `config.json` into readout wells. Proves the bridge and the table primitive against real on-disk artifacts before any long-running job.
+5. **02 TRAIN (the flagship loop).** Wire PARAM ROWS → CLI launch → live HERO NUMBER + STRIP CHART + READOUT WELLS off the event store. Add scrub-to-freeze. This is the demo centerpiece; get the ~20Hz throttle right here.
+6. **⌘K command spine.** The grafted Console primitive: a glass palette that parses `train qwen3.5 r8` into argument chips and fires step 5's launch path. Add the ⌘1–6 key-cap legend.
+7. **03 QUANTIZE.** CONTRAST PAIR + MASS BARS + VERDICT GATE PILL over the quantize bin's progress stream. PPL hard-labeled `est.` unless a calib set is supplied.
+8. **04 CHAT + HOT-SWAP FADER.** Dual-stream A|B columns; bind the fader to the engine's `arc_swap` `LiveModel.swap()` over the bridge. **Verify the FFI/CLI event surface exposes a structured no-reload swap before committing the fader demo** — otherwise it degrades to a reload and the wow is lost.
+9. **05 DATA + 06 RUNS.** Dataset builder (export → Train dataset field) and the runs archive (reopen config, frozen strip chart). These close the loops and round out the five features.
+
+**Gate at each step:** `swift build` green, and from step 4 onward, real data on screen (Π_TBV — no mock streams claimed as live).
+
+---
+
+## Risks Carried Forward (both directions)
+
+- **Live-chart perf** — throttle chart commits ~20Hz, numeral tick ~8Hz, downsample history; decouple chart buffer from FFI callback rate.
+- **SwiftUI state identity** — own run `@State` above the view, key by run-id, or a re-render wipes a live run.
+- **Glass discipline erosion** — enforce via a single `OpaquePanel` container; glass banned below the chrome layer; honor `accessibilityReduceTransparency`.
+- **One-accent discipline** — strict semantic token set (teal/voltage = live/CTA, amber = regress, crimson = fail only) so no stray colors creep in.
+- **Estimated-PPL honesty** — hard-label `est.` and never draw the ghost base-PPL line as if measured.
+- **Greenfield + bridge** — verify the engine exposes structured-progress quantize and a no-reload `set_lora`/`swap` over the bridge before committing the two signature WOWs.
diff --git a/apps/macos/DISTRIBUTION.md b/apps/macos/DISTRIBUTION.md
new file mode 100644
index 0000000000..5ccf1471b8
--- /dev/null
+++ b/apps/macos/DISTRIBUTION.md
@@ -0,0 +1,116 @@
+# Lattice Studio — Distribution Guide
+
+## Building a distributable .app
+
+Run the packaging script from the repo root:
+
+```bash
+cd lattice/
+./apps/macos/scripts/package-app.sh
+```
+
+This will:
+1. Run `swift build -c release` to produce the Swift instrument panel.
+2. Build all 6 Rust engine binaries with `cargo build --release`.
+3. Assemble `apps/macos/dist/LatticeStudio.app` with the engine binaries inside
+ `Contents/Resources/bin/` so the app is fully self-contained.
+4. Write `Contents/Info.plist` with bundle ID `ai.khive.lattice.studio`.
+5. Ad-hoc codesign the bundle.
+6. Produce `apps/macos/dist/LatticeStudio.dmg` and `LatticeStudio.zip`.
+
+Options:
+
+```bash
+# Skip Swift rebuild (use existing .build/release/LatticeStudio)
+./apps/macos/scripts/package-app.sh --skip-build
+
+# Skip Cargo rebuild (use existing target/release/ binaries)
+./apps/macos/scripts/package-app.sh --skip-cargo
+
+# Custom output directory
+./apps/macos/scripts/package-app.sh --out /path/to/out
+```
+
+## Installing
+
+Drag `LatticeStudio.app` from the DMG to `/Applications`.
+
+Requires macOS 14.0 (Sonoma) or later. The app is self-contained — no source
+checkout, no Cargo, no Rust toolchain needed on the recipient machine.
+
+## Gatekeeper caveat (ad-hoc signing)
+
+The app is ad-hoc signed, which means macOS Gatekeeper will quarantine it on
+first launch because it lacks a Developer ID certificate. Recipients must:
+
+**Option 1 — right-click → Open:**
+1. Right-click (or Ctrl-click) `LatticeStudio.app` in Finder.
+2. Choose "Open" from the context menu.
+3. Click "Open" in the dialog. macOS remembers the exception.
+
+**Option 2 — remove the quarantine xattr:**
+```bash
+xattr -dr com.apple.quarantine /Applications/LatticeStudio.app
+```
+
+This is a one-time step. The app will open normally on subsequent launches.
+
+## Upgrading to Developer ID signing (for App Store or notarized distribution)
+
+When Ocean has a $99/year Apple Developer Program membership, replace the
+ad-hoc step in `package-app.sh` with a full Developer ID workflow:
+
+```bash
+# 1. Replace the ad-hoc sign step with a Developer ID certificate
+DEVELOPER_ID="Developer ID Application: Haiyang Li (TEAMID)"
+codesign --force --deep --options runtime \
+ --entitlements apps/macos/LatticeStudio.entitlements \
+ --sign "$DEVELOPER_ID" \
+ "$BUNDLE"
+
+# 2. Create a notarization credentials profile (one-time)
+xcrun notarytool store-credentials "lattice-notary" \
+ --apple-id "lhydyxzh@gmail.com" \
+ --team-id "TEAMID" \
+ --password "@keychain:AC_PASSWORD"
+
+# 3. Submit for notarization and wait
+xcrun notarytool submit "$DMG" \
+ --keychain-profile "lattice-notary" \
+ --wait
+
+# 4. Staple the notarization ticket to the DMG
+xcrun stapler staple "$DMG"
+```
+
+After notarization, Gatekeeper accepts the app on any Mac without the
+right-click-Open workaround. Distribution via direct download or any store
+works without restrictions.
+
+An entitlements file (`LatticeStudio.entitlements`) must grant at minimum:
+```xml
+com.apple.security.cs.allow-jit
+com.apple.security.cs.allow-unsigned-executable-memory
+```
+
+The engine binaries (Rust) do not use JIT, so hardened runtime is compatible.
+
+## Regenerating the app icon
+
+```bash
+uv run apps/macos/scripts/generate-icon.py
+```
+
+Outputs `apps/macos/Resources/LatticeStudio.icns`. The packaging script picks
+it up automatically, or re-runs the generator if the file is missing.
+
+## Binary resolution order (inside the .app)
+
+The Swift bridge resolves engine binaries in this order:
+1. `Contents/Resources/bin/` — bundled binary (used from /Applications).
+2. `LATTICE_BIN_DIR` env var — dev override.
+3. `/target/release/` — running from source checkout.
+4. `cargo run --release` — compile-on-demand fallback.
+
+This means the same app binary works both installed from the DMG and invoked
+during development inside the source tree.
diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift
new file mode 100644
index 0000000000..9f2fbeed7b
--- /dev/null
+++ b/apps/macos/Package.swift
@@ -0,0 +1,25 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+// Lattice Studio — the instrument panel for the pure-Rust lattice engine.
+// Driven entirely via lattice CLI subprocesses (line-delimited `@@lattice {json}`
+// event protocol). No in-process ML, no Python, no ONNX — same posture as the engine.
+let package = Package(
+ name: "LatticeStudio",
+ platforms: [
+ .macOS(.v14)
+ ],
+ products: [
+ .executable(name: "LatticeStudio", targets: ["LatticeStudio"])
+ ],
+ dependencies: [],
+ targets: [
+ .executableTarget(
+ name: "LatticeStudio",
+ path: "Sources/LatticeStudio",
+ swiftSettings: [
+ .swiftLanguageMode(.v5)
+ ]
+ )
+ ]
+)
diff --git a/apps/macos/Resources/LatticeStudio.icns b/apps/macos/Resources/LatticeStudio.icns
new file mode 100644
index 0000000000..a560c2d9a6
Binary files /dev/null and b/apps/macos/Resources/LatticeStudio.icns differ
diff --git a/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift b/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift
new file mode 100644
index 0000000000..18af2971a2
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+import AppKit
+
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ // Weak back-reference injected by LatticeStudioApp so the delegate can reach the store.
+ // (NSApplicationDelegate is an ObjC protocol; we cannot pass the store in an init.)
+ weak var store: AppStore?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.regular)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ /// Best-effort clean-quit: stop the active run so its trainer process exits normally
+ /// and its PID is deregistered before the app terminates. The startup reaper handles
+ /// the crash / force-quit case where this callback never fires.
+ func applicationWillTerminate(_ notification: Notification) {
+ store?.stopRun()
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
+}
+
+@main
+struct LatticeStudioApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @State private var store = AppStore()
+
+ var body: some Scene {
+ WindowGroup("Lattice", id: "main") {
+ ContentView(store: store)
+ .frame(minWidth: 1120, minHeight: 720)
+ .onAppear {
+ store.onAppear()
+ // Give the delegate a weak reference so applicationWillTerminate can
+ // stop the active run for a clean-quit. The reaper is the crash backstop.
+ appDelegate.store = store
+ }
+ }
+ .windowStyle(.titleBar)
+ .windowToolbarStyle(.unified(showsTitle: false))
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift b/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift
new file mode 100644
index 0000000000..8f42dd2c97
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift
@@ -0,0 +1,521 @@
+import Foundation
+
+// MARK: - Typed driver configs and AppStore convenience methods.
+//
+// Each config struct maps 1-to-1 with a Lattice CLI binary's argument surface.
+// Flag names are taken verbatim from the CLI contract (2026-06-20).
+//
+// Do NOT import AppStore.swift here — the extension lives in the same module.
+// Do NOT edit LatticeBridge.swift or AppStore.swift directly.
+
+// MARK: - TrainConfig
+
+/// Configuration for `train_grad_full` (multi-layer exact-gradient LoRA trainer).
+///
+/// All defaults match the binary's own defaults so omitting a field is equivalent
+/// to not passing the flag. `--json` is always appended to activate the structured
+/// `@@lattice` event protocol (verified emitted by `train_grad_full --json`, 2026-06-21).
+struct TrainConfig {
+ /// Full path to the BF16 model directory (must contain `model.safetensors` / index).
+ var modelDir: URL
+ /// Directory containing `train.jsonl` and optionally `valid.jsonl`.
+ var dataDir: URL
+ /// First materialised (trained) layer index. Default 19 (last 5 of 24 for Qwen3.5-0.8B).
+ var firstLayer: Int = 19
+ /// Adam optimizer steps. Default 25.
+ var steps: Int = 25
+ /// Learning rate. Default 1e-3.
+ var lr: Double = 1e-3
+ /// LoRA rank. Default 8.
+ var rank: Int = 8
+ /// LoRA alpha; effective scale = alpha/rank. Default 16.
+ var alpha: Double = 16.0
+ /// Max tokens per sample. Default 64.
+ var seqLen: Int = 64
+ /// Training sample cap. Default 3.
+ var maxTrain: Int = 3
+ /// Held-out validation samples (0 = disabled). Default 16.
+ var maxValid: Int = 16
+ /// Print NLL every N steps. Default 5.
+ var logEvery: Int = 5
+ /// When non-nil, the `--save ` flag is passed (Rust side in progress).
+ var savePath: URL? = nil
+
+ /// The exact argument array passed to the subprocess.
+ var args: [String] {
+ var a: [String] = [
+ "--model-dir", modelDir.path,
+ "--data-dir", dataDir.path,
+ "--first-layer", String(firstLayer),
+ "--steps", String(steps),
+ "--lr", String(lr),
+ "--rank", String(rank),
+ "--alpha", String(alpha),
+ "--seq-len", String(seqLen),
+ "--max-train", String(maxTrain),
+ "--max-valid", String(maxValid),
+ "--log-every", String(logEvery),
+ "--json", // structured @@lattice event protocol (verified train_grad_full)
+ ]
+ if let save = savePath {
+ a += ["--save", save.path]
+ }
+ return a
+ }
+}
+
+// MARK: - QuantConfig
+
+/// Quantization method selector.
+enum QuantMethod {
+ case q4 // `quantize_q4` — plain Q4_0, no rotation
+ case quarot // `quantize_quarot` — Hadamard-rotated Q4_0; requires a seed
+}
+
+/// Configuration for `quantize_q4` or `quantize_quarot`.
+///
+/// Note: both quantizers write all progress to **stderr**, which RunHandle already
+/// merges into the event stream — no special handling needed in the driver.
+struct QuantConfig {
+ /// Full path to the BF16 input model directory.
+ var modelDir: URL
+ /// Output directory (created by the binary if absent).
+ var outputDir: URL
+ /// Which quantization method to run.
+ var method: QuantMethod
+ /// RNG seed for the Hadamard rotation (quarot only).
+ /// If nil when method == .quarot, defaults to 0xC0FFEE.
+ /// Passed as a decimal string; the binary also accepts `0x...` hex.
+ var seed: UInt64? = nil
+ /// Skip disk writes (full pipeline still runs).
+ var dryRun: Bool = false
+
+ /// The LatticeBinary enum case to use for this config.
+ var binary: LatticeBinary {
+ switch method {
+ case .q4: return .quantizeQ4
+ case .quarot: return .quantizeQuaRot
+ }
+ }
+
+ /// The exact argument array passed to the subprocess.
+ var args: [String] {
+ var a: [String] = [
+ "--model-dir", modelDir.path,
+ "--output-dir", outputDir.path,
+ ]
+ if method == .quarot {
+ let s = seed ?? 0xC0FFEE
+ a += ["--seed", String(s)]
+ }
+ if dryRun { a.append("--dry-run") }
+ return a
+ }
+}
+
+// MARK: - GenConfig
+
+/// Configuration for `generate_lora` (CPU BF16) or `chat_metal` (GPU Metal bf16/q4).
+///
+/// Either `modelDir` or `model` must be provided; `modelDir` takes priority when both
+/// are set (the binary accepts both flags and uses whichever it sees).
+///
+/// Both binaries emit identical `@@lattice gen_token` streaming events, so the app
+/// parser needs no changes when switching between CPU and GPU paths.
+///
+/// Honest-label contract: the *caller* (ChatScreen.send) sets `useGPU` to select the
+/// binary and also embeds the label string in the bubble at send time. There is no path
+/// where a CPU run appears labelled as GPU.
+struct GenConfig {
+ /// Full path to the model directory. Takes priority over `model` when set.
+ var modelDir: URL? = nil
+ /// Model name under `$LATTICE_MODEL_CACHE / $HOME/.lattice/models/`. Used when
+ /// `modelDir` is nil.
+ var model: String? = nil
+ /// Tokenizer directory override. Required for Q4 models (no embedded tokenizer.json).
+ /// When set, `--tokenizer-dir` is appended to the arg array.
+ /// Ignored by `generate_lora` (unknown flag, silently dropped).
+ var tokenizerDir: URL? = nil
+ /// Path to a `.safetensors` LoRA adapter file. Nil = run base model only.
+ var adapterPath: URL? = nil
+ /// Text prompt fed to the model.
+ var prompt: String
+ /// Maximum tokens to generate. Default 64.
+ var maxTokens: Int = 64
+ /// RNG seed for deterministic sampling. Nil = non-deterministic.
+ var seed: UInt64? = nil
+ /// Sampling temperature. Default 0.7.
+ var temperature: Double = 0.7
+ /// Top-k nucleus cutoff. Default 50. Passed as `--top-k` to both binaries.
+ /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22).
+ var topK: Int = 50
+ /// Top-p nucleus cutoff. Default 0.9. Passed as `--top-p` to both binaries.
+ /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22).
+ var topP: Double = 0.9
+ /// Repetition penalty. Default 1.1. Passed as `--repetition-penalty` to both binaries.
+ /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22).
+ var repetitionPenalty: Double = 1.1
+ /// When true, dispatch to `chat_metal` (GPU Metal) instead of `generate_lora` (CPU).
+ /// Setting this to true on a non-bf16 model is valid: chat_metal auto-detects Q4.
+ var useGPU: Bool = false
+
+ /// The exact argument array passed to the subprocess.
+ var args: [String] {
+ var a: [String] = []
+ if let dir = modelDir {
+ a += ["--model-dir", dir.path]
+ } else if let name = model {
+ a += ["--model", name]
+ }
+ if let tokDir = tokenizerDir {
+ a += ["--tokenizer-dir", tokDir.path]
+ }
+ if let adapter = adapterPath {
+ a += ["--lora", adapter.path]
+ }
+ a += [
+ "--prompt", prompt,
+ "--max-tokens", String(maxTokens),
+ "--temperature", String(temperature),
+ "--top-k", String(topK),
+ "--top-p", String(topP),
+ "--repetition-penalty", String(repetitionPenalty),
+ "--json", // streaming gen_token event protocol; both binaries honour this flag
+ ]
+ if let s = seed {
+ a += ["--seed", String(s)]
+ }
+ return a
+ }
+}
+
+// MARK: - EvalConfig
+
+/// Configuration for `eval_perplexity` (strided sliding-window perplexity, ADR-044).
+///
+/// Three measurement modes — pass the corresponding dir flag to select:
+/// • CPU BF16: set `modelDir` (embeds tokenizer; `tokenizerDir` not needed)
+/// • Metal Q4: set `q4Dir` + `tokenizerDir`
+/// • QuaRot Q4: set `quarotDir` + optionally `q4Dir` for the delta comparison
+///
+/// `--json` is always appended to activate the structured `@@lattice perplexity` event
+/// protocol (shipped 2026-06-21). `--label` is included only when `label` is set
+/// (disambiguates rows in multi-pass runs). Adapter perplexity is NOT supported: the CPU
+/// forward path has no LoRA hook, so adapter A/B lives in Chat via generate_lora instead.
+///
+/// Flag names are taken verbatim from `crates/inference/src/bin/eval_perplexity.rs`
+/// arg-parser (verified 2026-06-21).
+struct EvalConfig {
+ /// CPU mode: directory with `config.json` + safetensors + `tokenizer.json`.
+ var modelDir: URL? = nil
+ /// Metal Q4 mode: `quantize_q4` output directory (unrotated 4-bit weights).
+ var q4Dir: URL? = nil
+ /// Metal QuaRot Q4 directory; when combined with `q4Dir` triggers the delta comparison.
+ var quarotDir: URL? = nil
+ /// Tokenizer directory (Metal modes); holds `tokenizer.json` from the source checkpoint.
+ var tokenizerDir: URL? = nil
+ /// UTF-8 text corpus file to score (required).
+ var corpusFile: URL
+ /// Cap total tokens after tokenization (nil = no cap — binary default).
+ var maxTokens: Int? = nil
+ /// Human label for this measurement row (e.g. "bf16", "q4", "adapter").
+ var label: String? = nil
+
+ /// The exact argument array passed to the subprocess.
+ var args: [String] {
+ var a: [String] = []
+ if let dir = modelDir { a += ["--model-dir", dir.path] }
+ if let dir = q4Dir { a += ["--q4-dir", dir.path] }
+ if let dir = quarotDir { a += ["--quarot-q4-dir", dir.path] }
+ if let dir = tokenizerDir { a += ["--tokenizer-dir", dir.path] }
+ a += ["--corpus-file", corpusFile.path]
+ if let mt = maxTokens { a += ["--max-tokens", String(mt)] }
+ if let lbl = label { a += ["--label", lbl] }
+ a.append("--json") // structured @@lattice perplexity event protocol
+ return a
+ }
+}
+
+// MARK: - EmbedConfig
+
+/// Configuration for `embed` (batch text embedding with cosine similarity report).
+///
+/// The binary accepts a model identifier and one or more `--text` values.
+/// `--json` is always appended to emit the structured `@@lattice embed_done` event.
+///
+/// `model` is the only positional parameter; texts are each preceded by `--text`.
+struct EmbedConfig {
+ /// Model identifier — a short name resolvable by the embed binary (e.g. "bge-small-en-v1.5").
+ var model: String
+ /// Texts to embed. Each becomes one `--text ` flag pair.
+ var texts: [String]
+
+ /// The exact argument array passed to the subprocess.
+ var args: [String] {
+ var a: [String] = ["--model", model]
+ for text in texts {
+ a += ["--text", text]
+ }
+ a.append("--json") // structured @@lattice embed_done event protocol
+ return a
+ }
+}
+
+// MARK: - DownloadConfig
+
+/// Configuration for `embed --model --download-only --json`.
+///
+/// Verified contract: the embed binary emits exactly one `@@lattice download_done` event
+/// on stdout, then exits 0 (success) or 1 (failure). No `--text` is needed.
+struct DownloadConfig {
+ var canonicalName: String
+
+ var args: [String] {
+ ["--model", canonicalName, "--download-only", "--json"]
+ }
+}
+
+// MARK: - AppStore typed convenience methods
+
+extension AppStore {
+
+ /// Launch `train_grad_full` with the given config.
+ ///
+ /// The returned `LiveRun` is already wired into `self.liveRun` and will receive
+ /// events via `consume(_:into:)`.
+ @discardableResult
+ @MainActor
+ func startTrain(_ config: TrainConfig) -> LiveRun {
+ let modelName = config.modelDir.lastPathComponent
+ return launch(
+ .trainGradFull,
+ args: config.args,
+ kind: .train,
+ model: modelName,
+ totalSteps: config.steps
+ )
+ }
+
+ /// Launch `quantize_q4` or `quantize_quarot` depending on `config.method`.
+ @discardableResult
+ @MainActor
+ func startQuantize(_ config: QuantConfig) -> LiveRun {
+ resetQuantAccumulator()
+ let modelName = config.modelDir.lastPathComponent
+ let kind: RunKind = config.method == .q4 ? .quantizeQ4 : .quantizeQuaRot
+ return launch(
+ config.binary,
+ args: config.args,
+ kind: kind,
+ model: modelName,
+ totalSteps: nil
+ )
+ }
+
+ /// Launch `eval_perplexity` with the given config.
+ ///
+ /// The returned `LiveRun` accumulates `perplexity` events in `run.perplexities`.
+ /// A single run may emit multiple rows (one per label). Use `run.onComplete` to
+ /// chain a follow-up run in A/B sequences (e.g. base eval → adapter eval).
+ @discardableResult
+ @MainActor
+ func runEval(_ config: EvalConfig) -> LiveRun {
+ // Derive a display name: prefer explicit model/q4/quarot dir name, then label.
+ let modelName: String
+ if let dir = config.modelDir {
+ modelName = dir.lastPathComponent
+ } else if let dir = config.q4Dir {
+ modelName = dir.lastPathComponent
+ } else if let dir = config.quarotDir {
+ modelName = dir.lastPathComponent
+ } else {
+ modelName = config.label ?? "eval"
+ }
+ return launch(
+ .evalPerplexity,
+ args: config.args,
+ kind: .eval,
+ model: modelName,
+ totalSteps: nil
+ )
+ }
+
+ /// Launch `embed` with the given config.
+ ///
+ /// The returned `LiveRun` receives a single `embed_done` event stored in `run.embed`.
+ @discardableResult
+ @MainActor
+ func runEmbed(_ config: EmbedConfig) -> LiveRun {
+ return launch(
+ .embed,
+ args: config.args,
+ kind: .embed,
+ model: config.model,
+ totalSteps: nil
+ )
+ }
+
+ /// Download an embedding model via `embed --model --download-only --json`.
+ ///
+ /// Spawns a dedicated RunHandle (separate from `liveRun`) so downloads do not evict
+ /// a running training/eval job. State changes (downloadingModels, downloadErrors) are
+ /// always published on the main actor. On success, calls refreshModels() so the new
+ /// model appears in the table and the "Get Models" sheet flips the row to INSTALLED.
+ @MainActor
+ func downloadModel(canonicalName: String) {
+ downloadErrors.removeValue(forKey: canonicalName)
+ downloadingModels.insert(canonicalName)
+
+ guard let spec = LatticeBridge.launchSpec(.embed, args: DownloadConfig(canonicalName: canonicalName).args) else {
+ downloadingModels.remove(canonicalName)
+ downloadErrors[canonicalName] = "embed binary not found — run `make build` first"
+ return
+ }
+
+ let h = RunHandle()
+ let hid = ObjectIdentifier(h)
+ var sawDone = false
+
+ h.onEvent = { [weak self] ev in
+ guard let self = self else { return }
+ if case .downloadDone(let d) = ev {
+ sawDone = true
+ self.downloadingModels.remove(canonicalName)
+ if d.ok {
+ self.refreshModels()
+ } else {
+ self.downloadErrors[canonicalName] = d.error ?? "download failed (no error message)"
+ }
+ }
+ }
+ h.onExit = { [weak self] code in
+ guard let self = self else { return }
+ self._downloadHandles.removeValue(forKey: hid)
+ // Guard against a binary that exits without emitting download_done.
+ if !sawDone {
+ self.downloadingModels.remove(canonicalName)
+ if code != 0 {
+ self.downloadErrors[canonicalName] = "embed exited with code \(code)"
+ } else {
+ self.refreshModels()
+ }
+ }
+ }
+
+ do {
+ try h.start(spec)
+ // Stash after successful start so the handle lives until onExit fires.
+ _downloadHandles[hid] = h
+ } catch {
+ downloadingModels.remove(canonicalName)
+ downloadErrors[canonicalName] = "launch failed: \(error.localizedDescription)"
+ }
+ }
+
+ /// Import a model folder from disk into the model cache.
+ ///
+ /// Validates that the chosen folder contains `config.json` AND at least one
+ /// `.safetensors` file before touching the cache. Copies off the main thread;
+ /// publishes state on the main actor. Calls refreshModels() on success.
+ @MainActor
+ func importModel(from url: URL) {
+ let folderName = url.lastPathComponent
+ importError = nil
+ importingModel = folderName
+
+ Task.detached(priority: .userInitiated) {
+ let fm = FileManager.default
+
+ // Validate: must have config.json
+ let configURL = url.appendingPathComponent("config.json")
+ guard fm.fileExists(atPath: configURL.path) else {
+ await MainActor.run {
+ self.importingModel = ""
+ self.importError = "'\(folderName)' has no config.json — not a model directory"
+ }
+ return
+ }
+
+ // Validate: must have at least one .safetensors file
+ let children = (try? fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? []
+ let hasSafetensors = children.contains { $0.pathExtension == "safetensors" }
+ guard hasSafetensors else {
+ await MainActor.run {
+ self.importingModel = ""
+ self.importError = "'\(folderName)' has no .safetensors files — not a model directory"
+ }
+ return
+ }
+
+ let dest = LatticeBridge.modelCacheDir.appendingPathComponent(folderName, isDirectory: true)
+
+ // Refuse to overwrite an existing model to prevent silent data loss.
+ if fm.fileExists(atPath: dest.path) {
+ await MainActor.run {
+ self.importingModel = ""
+ self.importError = "'\(folderName)' already exists in the model cache — remove it first if you want to replace it"
+ }
+ return
+ }
+
+ // Ensure the model cache directory exists.
+ do {
+ try fm.createDirectory(at: LatticeBridge.modelCacheDir, withIntermediateDirectories: true)
+ } catch {
+ await MainActor.run {
+ self.importingModel = ""
+ self.importError = "could not create model cache directory: \(error.localizedDescription)"
+ }
+ return
+ }
+
+ // Copy to a hidden staging sibling on the same volume, then atomically rename
+ // into place. A crash mid-copy leaves only `.importing-` (skipped by
+ // discoverModels via skipsHiddenFiles), never a half-written model at `dest`.
+ let staging = LatticeBridge.modelCacheDir.appendingPathComponent(".importing-\(folderName)", isDirectory: true)
+ try? fm.removeItem(at: staging)
+ do {
+ try fm.copyItem(at: url, to: staging)
+ try fm.moveItem(at: staging, to: dest)
+ await MainActor.run {
+ self.importingModel = ""
+ self.refreshModels()
+ }
+ } catch {
+ try? fm.removeItem(at: staging)
+ await MainActor.run {
+ self.importingModel = ""
+ self.importError = "copy failed: \(error.localizedDescription)"
+ }
+ }
+ }
+ }
+
+ /// Launch `generate_lora` (CPU BF16) or `chat_metal` (GPU Metal), per `config.useGPU`.
+ ///
+ /// Both binaries emit identical `@@lattice gen_token` streaming events. The caller
+ /// (ChatScreen.send) must embed the honest hardware label ("GPU Metal" / "CPU bf16") in
+ /// the turn bubble AT SEND TIME — this method never inspects or fabricates that label.
+ @discardableResult
+ @MainActor
+ func runGenerate(_ config: GenConfig) -> LiveRun {
+ // Derive a display name: prefer the explicit dir/model name, fall back to prompt prefix.
+ let modelName: String
+ if let dir = config.modelDir {
+ modelName = dir.lastPathComponent
+ } else if let name = config.model {
+ modelName = name
+ } else {
+ let prefix = config.prompt.prefix(24)
+ modelName = prefix.isEmpty ? "generate_lora" : "\(prefix)…"
+ }
+ let binary: LatticeBinary = config.useGPU ? .chatMetal : .generateLora
+ return launch(
+ binary,
+ args: config.args,
+ kind: .chat,
+ model: modelName,
+ totalSteps: nil
+ )
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift b/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift
new file mode 100644
index 0000000000..70168305ff
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift
@@ -0,0 +1,672 @@
+import Foundation
+
+// MARK: - Which lattice binary, and how to build/run it.
+//
+// Every binary is reachable two ways: a prebuilt artifact in `target/release` (preferred,
+// instant), or `cargo run --release` (fallback, compiles on first use). Compile-time
+// `--features` only matter for the cargo fallback — a prebuilt binary already has them baked in.
+
+enum LatticeBinary {
+ case trainGradFull // lattice-tune, features: train-backward
+ case quantizeQ4 // lattice-inference
+ case quantizeQuaRot // lattice-inference
+ case generateLora // lattice-tune, features: safetensors,inference-hook
+ case chatMetal // lattice-inference, features: f16,metal-gpu — GPU Metal inference
+ case lattice // lattice-inference — `chat` / `serve` subcommands
+ case qwen35Generate // lattice-inference
+ case evalPerplexity // lattice-inference — strided sliding-window perplexity (ADR-044)
+ case embed // lattice-embed — batch text embedding with cosine report
+
+ var binName: String {
+ switch self {
+ case .trainGradFull: "train_grad_full"
+ case .quantizeQ4: "quantize_q4"
+ case .quantizeQuaRot: "quantize_quarot"
+ case .generateLora: "generate_lora"
+ case .chatMetal: "chat_metal"
+ case .lattice: "lattice"
+ case .qwen35Generate: "qwen35_generate"
+ case .evalPerplexity: "eval_perplexity"
+ case .embed: "embed"
+ }
+ }
+ var crate: String {
+ switch self {
+ case .trainGradFull, .generateLora: "lattice-tune"
+ case .embed: "lattice-embed"
+ default: "lattice-inference"
+ }
+ }
+ var features: [String] {
+ switch self {
+ case .trainGradFull: ["train-backward"]
+ case .generateLora: ["safetensors", "inference-hook"]
+ case .evalPerplexity: ["f16", "metal-gpu"]
+ case .chatMetal: ["f16", "metal-gpu"]
+ default: []
+ }
+ }
+}
+
+struct LaunchSpec {
+ var executable: URL
+ var arguments: [String]
+ var cwd: URL?
+}
+
+enum LatticeBridge {
+
+ // MARK: Path resolution
+
+ static var repoRoot: URL? {
+ if let env = ProcessInfo.processInfo.environment["LATTICE_REPO_ROOT"] {
+ return URL(fileURLWithPath: env, isDirectory: true)
+ }
+ // Walk up from the cwd first (dev / CLI / `swift run` launch), then from the app
+ // bundle's own location. A Finder-launched .app has cwd=/, so the cwd walk fails;
+ // but a packaged .app living inside a checkout (apps/macos/dist/LatticeStudio.app)
+ // still finds the repo root via its bundle path — which is what makes adapter
+ // discovery work in the packaged app. A .app moved outside any checkout (e.g.
+ // /Applications) resolves neither and returns nil: honest, there is no source tree.
+ for start in [
+ URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true),
+ Bundle.main.bundleURL
+ ] {
+ if let root = ascendToRepoRoot(from: start) { return root }
+ }
+ return nil
+ }
+
+ /// Ascend up to 10 levels from `start` looking for a directory that holds both
+ /// `crates/` and `Cargo.toml` — the lattice workspace root.
+ private static func ascendToRepoRoot(from start: URL) -> URL? {
+ var dir = start
+ for _ in 0..<10 {
+ let crates = dir.appendingPathComponent("crates", isDirectory: true)
+ let cargo = dir.appendingPathComponent("Cargo.toml")
+ if FileManager.default.fileExists(atPath: crates.path),
+ FileManager.default.fileExists(atPath: cargo.path) {
+ return dir
+ }
+ let parent = dir.deletingLastPathComponent()
+ if parent == dir { break }
+ dir = parent
+ }
+ return nil
+ }
+
+ static var modelCacheDir: URL {
+ if let env = ProcessInfo.processInfo.environment["LATTICE_MODEL_CACHE"] {
+ return URL(fileURLWithPath: env, isDirectory: true)
+ }
+ return FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".lattice/models", isDirectory: true)
+ }
+
+ static func prebuiltBinary(_ bin: LatticeBinary) -> URL? {
+ let fm = FileManager.default
+ // (a) Bundled binary — preferred when running as a .app from /Applications.
+ if let resourceURL = Bundle.main.resourceURL {
+ let u = resourceURL.appendingPathComponent("bin/\(bin.binName)")
+ if fm.isExecutableFile(atPath: u.path) { return u }
+ }
+ // (b) Explicit LATTICE_BIN_DIR override — dev convenience.
+ if let dir = ProcessInfo.processInfo.environment["LATTICE_BIN_DIR"] {
+ let u = URL(fileURLWithPath: dir).appendingPathComponent(bin.binName)
+ if fm.isExecutableFile(atPath: u.path) { return u }
+ }
+ // (c) Repo-relative target/release — running from source checkout.
+ if let root = repoRoot {
+ let u = root.appendingPathComponent("target/release/\(bin.binName)")
+ if fm.isExecutableFile(atPath: u.path) { return u }
+ }
+ return nil
+ }
+
+ private static var cargoURL: URL? {
+ let candidates = [
+ FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cargo/bin/cargo"),
+ URL(fileURLWithPath: "/opt/homebrew/bin/cargo"),
+ URL(fileURLWithPath: "/usr/local/bin/cargo")
+ ]
+ return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }
+ }
+
+ /// Produce a launch spec, preferring a prebuilt artifact and falling back to `cargo run`.
+ static func launchSpec(_ bin: LatticeBinary, args: [String]) -> LaunchSpec? {
+ if let exe = prebuiltBinary(bin) {
+ return LaunchSpec(executable: exe, arguments: args, cwd: repoRoot)
+ }
+ guard let cargo = cargoURL, let root = repoRoot else { return nil }
+ var cargoArgs = ["run", "--release", "-p", bin.crate, "--bin", bin.binName]
+ if !bin.features.isEmpty {
+ cargoArgs += ["--features", bin.features.joined(separator: ",")]
+ }
+ cargoArgs += ["--"] + args
+ return LaunchSpec(executable: cargo, arguments: cargoArgs, cwd: root)
+ }
+
+ // MARK: Model discovery — scan the cache into ModelInfo + indented adapters.
+
+ static func discoverModels() -> [ModelInfo] {
+ let fm = FileManager.default
+ let root = modelCacheDir
+ guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
+ return []
+ }
+ var models: [ModelInfo] = []
+ for entry in entries {
+ guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else { continue }
+ if let m = inspectModelDir(entry) { models.append(m) }
+ }
+ return models.sorted { $0.name < $1.name }
+ }
+
+ static func inspectModelDir(_ dir: URL) -> ModelInfo? {
+ let fm = FileManager.default
+ guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else {
+ return nil
+ }
+ let names = Set(files.map { $0.lastPathComponent })
+ let hasSafetensors = names.contains("model.safetensors") || names.contains("model.safetensors.index.json")
+ let hasQ4 = files.contains { $0.pathExtension == "q4" }
+ guard hasSafetensors || hasQ4 else { return nil }
+
+ let name = dir.lastPathComponent
+ let lower = name.lowercased()
+ var format: ModelFormat = hasQ4 ? .q4 : .bf16
+ if hasQ4 && lower.contains("quarot") { format = .quarot }
+ let isEmbedding = ["minilm", "bge", "embedding", "e5", "gte"].contains { lower.contains($0) }
+ if isEmbedding { format = .embedding }
+
+ var size: Int64 = 0
+ for f in files {
+ size += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0)
+ }
+
+ var hidden: Int? = nil
+ var vocab: Int? = nil
+ var layerSummary: String? = nil
+ var contextLen: Int? = nil
+ var attnHeads: Int? = nil
+ var kvHeads: Int? = nil
+ var headDim: Int? = nil
+ var gdnKeyHeads: Int? = nil
+ var gdnValueHeads: Int? = nil
+ var intermediateSize: Int? = nil
+ var dtype = format == .bf16 ? "BF16" : "Q4_0"
+ if let rawCfg = readConfig(dir.appendingPathComponent("config.json")) {
+ // Some models (e.g. MLX VLM repacks) nest text fields under `text_config`.
+ // Prefer the nested dict when present; fall back to top-level.
+ let cfg: [String: Any]
+ if let nested = rawCfg["text_config"] as? [String: Any] {
+ cfg = nested
+ } else {
+ cfg = rawCfg
+ }
+ hidden = cfg["hidden_size"] as? Int
+ vocab = cfg["vocab_size"] as? Int
+ // Real max context (HF `max_position_embeddings`). `cfg` is the nested
+ // text_config for qwen3.5 or the top-level dict for flat configs, so one
+ // read covers both layouts. Stays nil (CTX well hidden) when no config.json.
+ contextLen = cfg["max_position_embeddings"] as? Int
+ // Attention head config — same nested-resolved cfg; honest-nil for flat/absent configs.
+ attnHeads = cfg["num_attention_heads"] as? Int
+ kvHeads = cfg["num_key_value_heads"] as? Int
+ headDim = cfg["head_dim"] as? Int
+ gdnKeyHeads = cfg["linear_num_key_heads"] as? Int
+ gdnValueHeads = cfg["linear_num_value_heads"] as? Int
+ intermediateSize = cfg["intermediate_size"] as? Int
+ // Derive layer summary from real `layer_types` array when available.
+ if let layerTypes = cfg["layer_types"] as? [String] {
+ // Count each type and surface all non-zero counts.
+ var gdn = 0
+ var gqa = 0
+ var other: [String: Int] = [:]
+ for t in layerTypes {
+ switch t {
+ case "linear_attention": gdn += 1
+ case "full_attention": gqa += 1
+ default:
+ other[t, default: 0] += 1
+ }
+ }
+ var parts: [String] = []
+ if gdn > 0 { parts.append("\(gdn) GDN") }
+ if gqa > 0 { parts.append("\(gqa) GQA") }
+ for (typeName, count) in other.sorted(by: { $0.key < $1.key }) {
+ parts.append("\(count) \(typeName)")
+ }
+ layerSummary = parts.joined(separator: " · ")
+ } else if let nl = cfg["num_hidden_layers"] as? Int {
+ // No layer_types available; surface the raw count honestly.
+ layerSummary = "\(nl) layers"
+ }
+ // (If neither field is present, layerSummary stays nil and renders "—".)
+ if let dt = rawCfg["torch_dtype"] as? String { dtype = dt.uppercased() }
+ }
+
+ let params = parseParamCount(from: name)
+ // Scan for adapters: loose .safetensors in the model dir (excluding model weight
+ // shards), plus anything in an `adapters/` subdirectory.
+ let looseAdapters = discoverAdapters(in: dir)
+ let adapterSubdir = dir.appendingPathComponent("adapters", isDirectory: true)
+ let subdirAdapters = discoverAdapters(in: adapterSubdir)
+ let adapters = (looseAdapters + subdirAdapters).sorted { $0.name < $1.name }
+ return ModelInfo(
+ name: name, path: dir, format: format, params: params, dtype: dtype,
+ sizeBytes: size, fileCount: files.count, hasTokenizer: names.contains("tokenizer.json"),
+ layerSummary: layerSummary, hidden: hidden, vocab: vocab, contextLength: contextLen,
+ attnHeads: attnHeads, kvHeads: kvHeads, headDim: headDim,
+ gdnKeyHeads: gdnKeyHeads, gdnValueHeads: gdnValueHeads,
+ intermediateSize: intermediateSize,
+ isEmbedding: isEmbedding, adapters: adapters
+ )
+ }
+
+ static func readConfig(_ url: URL) -> [String: Any]? {
+ guard let data = try? Data(contentsOf: url),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
+ return obj
+ }
+
+ /// "qwen3.5-0.8b" -> "0.8B". Catches B/M suffix with optional decimal.
+ static func parseParamCount(from name: String) -> String? {
+ let pattern = #"(\d+(?:\.\d+)?)\s*([bBmM])"#
+ guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let range = NSRange(name.startIndex..., in: name)
+ guard let m = re.firstMatch(in: name, range: range),
+ let numR = Range(m.range(at: 1), in: name),
+ let unitR = Range(m.range(at: 2), in: name) else { return nil }
+ return "\(name[numR])\(name[unitR].uppercased())"
+ }
+
+ /// Return true if a filename looks like a sharded model weight, not an adapter.
+ /// Matches: model.safetensors, model.safetensors.index.json,
+ /// model-00001-of-00002.safetensors, model.safetensors-00001-of-00001.safetensors
+ private static func isModelWeight(_ filename: String) -> Bool {
+ let lower = filename.lowercased()
+ // Exact base weight
+ if lower == "model.safetensors" { return true }
+ // Shard patterns: model-00001-of-00002.safetensors
+ if lower.hasPrefix("model-") && lower.hasSuffix(".safetensors") { return true }
+ // Double-extension shard: model.safetensors-00001-of-00001.safetensors
+ if lower.hasPrefix("model.safetensors-") && lower.hasSuffix(".safetensors") { return true }
+ return false
+ }
+
+ /// Read the JSON header from a `.safetensors` file and return the `__metadata__` map.
+ ///
+ /// safetensors binary layout: bytes [0..8) = little-endian UInt64 N, bytes [8..8+N) = UTF-8
+ /// JSON. The JSON object may contain a `"__metadata__"` key whose value is [String: String].
+ /// Only the 8-byte length prefix and the N header bytes are read — the tensor payload is
+ /// never touched, so this is safe even for multi-hundred-MB adapter files.
+ ///
+ /// Returns nil on any failure (truncated file, bad JSON, missing key, N out of bounds).
+ static func readSafetensorsMetadata(_ url: URL) -> [String: String]? {
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
+ defer { try? handle.close() }
+
+ // Read the 8-byte little-endian header length.
+ guard let lenData = try? handle.read(upToCount: 8), lenData.count == 8 else { return nil }
+ let n = lenData.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self).littleEndian }
+
+ // Sanity-bound: reject empty or suspiciously large headers.
+ guard n > 0, n <= 100_000_000 else { return nil }
+
+ guard let headerData = try? handle.read(upToCount: Int(n)), headerData.count == Int(n) else {
+ return nil
+ }
+ guard let obj = try? JSONSerialization.jsonObject(with: headerData) as? [String: Any],
+ let meta = obj["__metadata__"] as? [String: String]
+ else { return nil }
+
+ return meta
+ }
+
+ /// Read adapter metadata from a PEFT-format `adapter_config.json` sibling file.
+ ///
+ /// Handles the standard PEFT keys (`r`, `lora_alpha`, `target_modules`). Used as a
+ /// fallback when the safetensors `__metadata__` block is absent or empty, for adapters
+ /// that were produced outside of lattice (e.g. imported from Hugging Face).
+ private static func readPeftAdapterConfig(sibling url: URL) -> (rank: Int?, alpha: Double?, targetModules: String?)? {
+ let configURL = url.deletingLastPathComponent().appendingPathComponent("adapter_config.json")
+ guard let data = try? Data(contentsOf: configURL),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return nil }
+
+ let rank = obj["r"] as? Int
+ let alpha: Double?
+ if let a = obj["lora_alpha"] as? Double {
+ alpha = a
+ } else if let a = obj["lora_alpha"] as? Int {
+ alpha = Double(a)
+ } else {
+ alpha = nil
+ }
+ let targetModules: String?
+ if let mods = obj["target_modules"] as? [String], !mods.isEmpty {
+ targetModules = mods.joined(separator: ", ")
+ } else {
+ targetModules = nil
+ }
+
+ return (rank: rank, alpha: alpha, targetModules: targetModules)
+ }
+
+ /// Read adapter metadata from an MLX-LM format `adapter_config.json` in a directory.
+ ///
+ /// Handles MLX-LM keys:
+ /// - `lora_parameters.rank` (Int)
+ /// - `lora_parameters.scale` (Double or Int)
+ /// - `lora_parameters.dropout` (Double or Int)
+ /// - `model` (String → baseModel)
+ /// - `num_layers` (Int)
+ ///
+ /// Returns nil when the file is absent, unreadable, or not a dictionary.
+ /// All individual fields can be independently nil (honest-nil policy).
+ static func readMlxAdapterConfig(dir: URL) -> (rank: Int?, scale: Double?, dropout: Double?, baseModel: String?, numLayers: Int?)? {
+ let configURL = dir.appendingPathComponent("adapter_config.json")
+ guard let data = try? Data(contentsOf: configURL),
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else { return nil }
+
+ var rank: Int? = nil
+ var scale: Double? = nil
+ var dropout: Double? = nil
+
+ if let loraParams = obj["lora_parameters"] as? [String: Any] {
+ rank = loraParams["rank"] as? Int
+ if let s = loraParams["scale"] as? Double {
+ scale = s
+ } else if let s = loraParams["scale"] as? Int {
+ scale = Double(s)
+ }
+ if let d = loraParams["dropout"] as? Double {
+ dropout = d
+ } else if let d = loraParams["dropout"] as? Int {
+ dropout = Double(d)
+ }
+ }
+
+ let baseModel = obj["model"] as? String
+ let numLayers = obj["num_layers"] as? Int
+
+ return (rank: rank, scale: scale, dropout: dropout, baseModel: baseModel, numLayers: numLayers)
+ }
+
+ /// Scan a directory of adapter packages (subdirectories) into [AdapterInfo].
+ ///
+ /// A subdirectory qualifies as an adapter package if it contains:
+ /// - `adapter_config.json`, OR
+ /// - Any file matching `adapters.safetensors`, `adapter.safetensors`, or `*_adapters.safetensors`
+ ///
+ /// Metadata resolution order:
+ /// 1. `readMlxAdapterConfig` from `adapter_config.json` (MLX-LM format)
+ /// 2. `readPeftAdapterConfig` from sibling `adapter_config.json` (PEFT fallback, for rank/alpha)
+ /// 3. All config fields remain nil when neither source is present (honest).
+ ///
+ /// sizeBytes = sum of all `.safetensors` in the subdir.
+ /// checkpointCount = count of files matching `*_adapters.safetensors`.
+ /// Results are sorted by name.
+ static func discoverAdapterPackages(in root: URL) -> [AdapterInfo] {
+ let fm = FileManager.default
+ guard let entries = try? fm.contentsOfDirectory(
+ at: root,
+ includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
+ options: [.skipsHiddenFiles]
+ ) else { return [] }
+
+ var result: [AdapterInfo] = []
+
+ for entry in entries {
+ guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else { continue }
+
+ guard let children = try? fm.contentsOfDirectory(
+ at: entry,
+ includingPropertiesForKeys: [.fileSizeKey],
+ options: [.skipsHiddenFiles]
+ ) else { continue }
+
+ let childNames = Set(children.map { $0.lastPathComponent })
+
+ // Qualification: has config or any qualifying safetensors file
+ let hasConfig = childNames.contains("adapter_config.json")
+ let hasSafetensors = children.contains { f in
+ let name = f.lastPathComponent
+ return name == "adapters.safetensors"
+ || name == "adapter.safetensors"
+ || (name.hasSuffix("_adapters.safetensors") && f.pathExtension == "safetensors")
+ }
+ guard hasConfig || hasSafetensors else { continue }
+
+ // Size = sum of all .safetensors in the subdir
+ var sizeBytes: Int64 = 0
+ var checkpointCount = 0
+ for child in children where child.pathExtension == "safetensors" {
+ sizeBytes += Int64((try? child.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0)
+ if child.lastPathComponent.hasSuffix("_adapters.safetensors") {
+ checkpointCount += 1
+ }
+ }
+
+ // Metadata resolution
+ var rank: Int? = nil
+ var alpha: Double? = nil
+ var scale: Double? = nil
+ var targetModules: String? = nil
+ var baseModel: String? = nil
+ var numLayers: Int? = nil
+
+ if let mlx = readMlxAdapterConfig(dir: entry) {
+ rank = mlx.rank
+ scale = mlx.scale
+ baseModel = mlx.baseModel
+ numLayers = mlx.numLayers
+ // MLX format has no alpha/targetModules; leave them nil
+ } else {
+ // PEFT fallback: pick any safetensors file in the dir as a sibling anchor
+ if let anySt = children.first(where: { $0.pathExtension == "safetensors" }),
+ let peft = readPeftAdapterConfig(sibling: anySt) {
+ rank = peft.rank
+ alpha = peft.alpha
+ targetModules = peft.targetModules
+ }
+ }
+
+ var info = AdapterInfo(
+ name: entry.lastPathComponent,
+ path: entry,
+ rank: rank,
+ alpha: alpha,
+ targetModules: targetModules,
+ sizeBytes: sizeBytes,
+ baseModel: baseModel,
+ scale: scale,
+ numLayers: numLayers,
+ checkpointCount: checkpointCount
+ )
+ info.weightFile = AdapterInfo.resolveWeightFile(at: entry)
+ result.append(info)
+ }
+
+ return result.sorted { $0.name < $1.name }
+ }
+
+ /// Attach globally-discovered adapter packages (from `/adapters`) to the
+ /// local models they are compatible with, so per-model UI — chiefly the Chat A/B
+ /// adapter picker, which reads `selectedModel.adapters` — can offer them.
+ ///
+ /// The global scan (`discoverAdapterPackages`) already surfaces every adapter in the
+ /// MODELS table, but those adapters carry no model association, so the per-model
+ /// picker stayed empty. This bridges that gap.
+ ///
+ /// Compatibility policy (Ocean, 2026-06-21 — "compatible-only"):
+ /// - An adapter that declares a base model (MLX `model` field, e.g.
+ /// "Qwen/Qwen3.5-0.8B") attaches ONLY to the local model whose name equals the
+ /// normalized base (org prefix stripped + lowercased → "qwen3.5-0.8b"). This
+ /// deliberately excludes the q4 / quarot variants: `generate_lora` is bf16-only.
+ /// - An adapter with NO declared base (lattice-native packages) attaches as a
+ /// permissive fallback to every non-embedding bf16 model in the qwen3.5 family,
+ /// since its true base is unknown and bf16 is the only loadable target.
+ ///
+ /// Existing per-model adapters (from the model-cache scan) are preserved; the merge
+ /// de-dupes by adapter path and keeps the list sorted by name. A model that matches
+ /// no adapter is returned unchanged.
+ static func associateAdapters(_ adapters: [AdapterInfo], into models: [ModelInfo]) -> [ModelInfo] {
+ guard !adapters.isEmpty else { return models }
+
+ func normalizedBase(_ s: String) -> String {
+ (s.split(separator: "/").last.map(String.init) ?? s).lowercased()
+ }
+ let declared = adapters.filter { ($0.baseModel?.isEmpty == false) }
+ let undeclared = adapters.filter { ($0.baseModel?.isEmpty != false) }
+
+ return models.map { model in
+ let modelKey = model.name.lowercased()
+ var matched: [AdapterInfo] = []
+ for a in declared where normalizedBase(a.baseModel ?? "") == modelKey {
+ matched.append(a)
+ }
+ if !model.isEmbedding, model.format == .bf16, modelKey.contains("qwen3.5") {
+ matched.append(contentsOf: undeclared)
+ }
+ guard !matched.isEmpty else { return model }
+
+ var merged = model.adapters
+ let existing = Set(merged.map { $0.path.path })
+ for a in matched where !existing.contains(a.path.path) {
+ merged.append(a)
+ }
+ var copy = model
+ copy.adapters = merged.sorted { $0.name < $1.name }
+ return copy
+ }
+ }
+
+ /// Scan a directory of `.safetensors` adapter files into AdapterInfo.
+ /// Excludes sharded model weights (model*.safetensors, model-*-of-*.safetensors,
+ /// model.safetensors-*-of-*.safetensors) which are base model files, not adapters.
+ ///
+ /// Metadata resolution order for each adapter file:
+ /// 1. `__metadata__` block in the safetensors header (lattice-native format).
+ /// 2. Sibling `adapter_config.json` using PEFT keys `r`/`lora_alpha`/`target_modules`
+ /// (for externally-imported adapters only).
+ /// 3. All three fields stay nil when neither source is present (honest result).
+ static func discoverAdapters(in dir: URL) -> [AdapterInfo] {
+ let fm = FileManager.default
+ guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else {
+ return []
+ }
+ return files
+ .filter { $0.pathExtension == "safetensors" && !isModelWeight($0.lastPathComponent) }
+ .map { f in
+ let size = Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0)
+
+ // --- Metadata resolution: safetensors __metadata__ first ---
+ var rank: Int? = nil
+ var alpha: Double? = nil
+ var targetModules: String? = nil
+
+ if let meta = readSafetensorsMetadata(f), !meta.isEmpty {
+ rank = meta["rank"].flatMap { Int($0) }
+ alpha = meta["alpha"].flatMap { Double($0) }
+ // Normalize: split on commas, trim whitespace, rejoin.
+ if let raw = meta["target_modules"], !raw.isEmpty {
+ let parts = raw.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
+ if !parts.isEmpty { targetModules = parts.joined(separator: ", ") }
+ }
+ } else if let peft = readPeftAdapterConfig(sibling: f) {
+ // --- Fallback: PEFT adapter_config.json for imported adapters ---
+ rank = peft.rank
+ alpha = peft.alpha
+ targetModules = peft.targetModules
+ }
+
+ var info = AdapterInfo(name: f.deletingPathExtension().lastPathComponent, path: f,
+ rank: rank, alpha: alpha, targetModules: targetModules, sizeBytes: size)
+ info.weightFile = AdapterInfo.resolveWeightFile(at: f)
+ return info
+ }.sorted { $0.name < $1.name }
+ }
+}
+
+// MARK: - A single running lattice subprocess: streaming, pausable, killable.
+
+final class RunHandle {
+ private let process = Process()
+ private let outPipe = Pipe()
+ private let inPipe = Pipe()
+ private var buffer = Data()
+ private(set) var isPaused = false
+
+ var onEvent: ((LatticeEvent) -> Void)?
+ var onExit: ((Int32) -> Void)?
+
+ var isRunning: Bool { process.isRunning }
+ /// The OS-assigned process identifier. Valid after `start(_:)` returns without throwing.
+ var pid: Int32 { process.processIdentifier }
+
+ func start(_ spec: LaunchSpec, env: [String: String]? = nil) throws {
+ process.executableURL = spec.executable
+ process.arguments = spec.arguments
+ if let cwd = spec.cwd { process.currentDirectoryURL = cwd }
+
+ var environment = ProcessInfo.processInfo.environment
+ env?.forEach { environment[$0.key] = $0.value }
+ process.environment = environment
+
+ process.standardOutput = outPipe
+ process.standardError = outPipe
+ process.standardInput = inPipe
+
+ outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ guard !data.isEmpty else { return }
+ self?.ingest(data)
+ }
+ process.terminationHandler = { [weak self] proc in
+ self?.outPipe.fileHandleForReading.readabilityHandler = nil
+ let code = proc.terminationStatus
+ DispatchQueue.main.async { self?.onExit?(code) }
+ }
+ try process.run()
+ }
+
+ private func ingest(_ data: Data) {
+ buffer.append(data)
+ let newline: UInt8 = 0x0A
+ while let nl = buffer.firstIndex(of: newline) {
+ let lineData = buffer.subdata(in: buffer.startIndex.."}
+ //
+ // Emitted exactly once per `embed --download-only --json` invocation.
+ // `ok` is required. `error` is present only on failure.
+ struct DownloadDone: Codable, Equatable {
+ var model: String?
+ var ok: Bool
+ var error: String?
+ }
+}
+
+/// Tagged envelope used to peek the `ev` discriminator before decoding the full payload.
+private struct EventTag: Codable { var ev: String }
+
+enum LatticeEventParser {
+ /// Parse a single raw stdout/stderr line into an event.
+ ///
+ /// Priority:
+ /// 1. `@@lattice {json}` sentinel — structured JSON protocol (future --json mode).
+ /// 2. Human-readable stdout patterns emitted by today's binaries (fallback).
+ /// 3. Anything else → `.status(line)` so the UI can display it as a log line.
+ static func parse(line rawLine: String) -> LatticeEvent? {
+ let line = stripANSI(rawLine.trimmingCharacters(in: .whitespacesAndNewlines))
+ guard !line.isEmpty else { return nil }
+
+ // --- Path 1: structured JSON protocol ---
+ if line.hasPrefix(kLatticeEventPrefix) {
+ return parseJSON(String(line.dropFirst(kLatticeEventPrefix.count)))
+ }
+
+ // --- Path 2: human-readable fallback ---
+ if let ev = HumanLineParser.parse(line) { return ev }
+
+ // --- Path 3: raw status ---
+ return .status(line)
+ }
+
+ // MARK: - JSON path
+
+ private static func parseJSON(_ jsonText: String) -> LatticeEvent {
+ guard let data = jsonText.data(using: .utf8) else { return .unknown(jsonText) }
+ let decoder = JSONDecoder()
+ guard let tag = try? decoder.decode(EventTag.self, from: data) else { return .unknown(jsonText) }
+
+ switch tag.ev {
+ case "train_step": return (try? decoder.decode(LatticeEvent.TrainStep.self, from: data)).map(LatticeEvent.trainStep) ?? .unknown(jsonText)
+ case "train_eval": return (try? decoder.decode(LatticeEvent.TrainEval.self, from: data)).map(LatticeEvent.trainEval) ?? .unknown(jsonText)
+ case "train_done": return (try? decoder.decode(LatticeEvent.TrainDone.self, from: data)).map(LatticeEvent.trainDone) ?? .unknown(jsonText)
+ case "quant_layer": return (try? decoder.decode(LatticeEvent.QuantLayer.self, from: data)).map(LatticeEvent.quantLayer) ?? .unknown(jsonText)
+ case "quant_done": return (try? decoder.decode(LatticeEvent.QuantDone.self, from: data)).map(LatticeEvent.quantDone) ?? .unknown(jsonText)
+ case "gen_token": return (try? decoder.decode(LatticeEvent.GenToken.self, from: data)).map(LatticeEvent.genToken) ?? .unknown(jsonText)
+ case "perplexity": return (try? decoder.decode(LatticeEvent.Perplexity.self, from: data)).map(LatticeEvent.perplexity) ?? .unknown(jsonText)
+ case "embed_done": return (try? decoder.decode(LatticeEvent.EmbedDone.self, from: data)).map(LatticeEvent.embedDone) ?? .unknown(jsonText)
+ case "download_done": return (try? decoder.decode(LatticeEvent.DownloadDone.self, from: data)).map(LatticeEvent.downloadDone) ?? .unknown(jsonText)
+ default: return .unknown(jsonText)
+ }
+ }
+
+ // MARK: - ANSI strip
+
+ /// Remove ANSI escape sequences (e.g. colour codes) from a line before parsing.
+ static func stripANSI(_ s: String) -> String {
+ // Matches ESC [ ... m and other common CSI sequences.
+ guard s.contains("\u{1B}") else { return s }
+ var result = ""
+ result.reserveCapacity(s.count)
+ var iter = s.unicodeScalars.makeIterator()
+ while let c = iter.next() {
+ if c == "\u{1B}" {
+ // Consume until the terminating letter (A-Z, a-z, or for OSC: BEL/ST)
+ while let n = iter.next() {
+ if n.value >= 0x40 && n.value <= 0x7E { break } // final byte
+ if n == "\u{07}" { break } // BEL (OSC terminator)
+ }
+ } else {
+ result.unicodeScalars.append(c)
+ }
+ }
+ return result
+ }
+}
+
+// MARK: - Human-readable line parser
+
+/// Converts the current human-readable stdout from train_grad_full, quantize_q4, and
+/// quantize_quarot into the same LatticeEvent cases used by the JSON protocol.
+/// Returns nil for lines that don't match any known pattern (caller emits .status).
+///
+/// Accumulator for quantization summary fields (shared across the line stream for one job).
+/// QuantDone is emitted when both size fields and ratio have been seen.
+final class QuantAccumulator {
+ var beforeMB: Double?
+ var afterMB: Double?
+ var ratio: Double?
+ var maxAbs: Double?
+ var verdict: String?
+
+ /// Call with each parsed summary field. Returns a .quantDone event when the
+ /// accumulator is complete (has at least before, after, and ratio).
+ func update(beforeMB: Double? = nil, afterMB: Double? = nil,
+ ratio: Double? = nil, maxAbs: Double? = nil,
+ verdict: String? = nil) -> LatticeEvent? {
+ if let v = beforeMB { self.beforeMB = v }
+ if let v = afterMB { self.afterMB = v }
+ if let v = ratio { self.ratio = v }
+ if let v = maxAbs { self.maxAbs = v }
+ if let v = verdict { self.verdict = v }
+ // Emit once both size fields and a terminal metric are in. The terminal metric is
+ // the compression ratio for Q4 (its last summary line) OR the forward-equivalence
+ // max_abs for QuaRot (its last line, after Compression). A QuaRot dry-run writes
+ // nothing, so its Compression line is "N/A" and ratio stays nil — without the
+ // max_abs branch that run would never complete in the UI (the original bug).
+ guard let b = self.beforeMB, let a = self.afterMB,
+ (self.ratio != nil || self.maxAbs != nil) else { return nil }
+ return .quantDone(LatticeEvent.QuantDone(
+ before_mb: b, after_mb: a, ratio: self.ratio,
+ verdict: self.verdict, max_abs: self.maxAbs, est_ppl_delta: nil
+ ))
+ }
+
+ func reset() {
+ beforeMB = nil; afterMB = nil; ratio = nil; maxAbs = nil; verdict = nil
+ }
+}
+
+/// Shared per-process accumulator. RunHandle is one-process-per-instance, so we
+/// keep a single thread-local-style global here; the main-thread delivery in RunHandle
+/// means there is no data race.
+private var _quantAccumulator = QuantAccumulator()
+
+/// Reset the shared quant accumulator at the start of every quantization run.
+/// Called by the Store layer before launching a new quant subprocess.
+func resetQuantAccumulator() { _quantAccumulator.reset() }
+
+enum HumanLineParser {
+ // MARK: Entry point
+
+ static func parse(_ line: String) -> LatticeEvent? {
+ if let ev = parseTrainStep(line) { return ev }
+ if let ev = parseTrainDone(line) { return ev }
+ if let ev = parseQuantLayer(line) { return ev }
+ if let ev = parseQuantSummary(line){ return ev }
+ return nil
+ }
+
+ // MARK: - train_grad_full step lines
+
+ // With validation:
+ // " step 5 train NLL: 3.9876 held-out NLL: 4.4321 (train d -0.1358)"
+ // " step 0 train NLL: 4.1234 held-out NLL: 4.5678"
+ // Without validation:
+ // " step 0 train NLL: 4.1234"
+ // " step 10 train NLL: 3.80 (delta from base: -0.32)"
+ private static let reTrainStepFull = try! NSRegularExpression(
+ pattern: #"^\s*step\s+(\d+)\s+train NLL:\s*([0-9]+(?:\.[0-9]+)?)\s+held-out NLL:\s*([0-9]+(?:\.[0-9]+)?)"#
+ )
+ private static let reTrainStepNoVal = try! NSRegularExpression(
+ pattern: #"^\s*step\s+(\d+)\s+train NLL:\s*([0-9]+(?:\.[0-9]+)?)"#
+ )
+
+ private static func parseTrainStep(_ line: String) -> LatticeEvent? {
+ // Full variant first (with held-out NLL).
+ if let m = reTrainStepFull.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
+ guard let stepR = Range(m.range(at: 1), in: line),
+ let lossR = Range(m.range(at: 2), in: line),
+ let valR = Range(m.range(at: 3), in: line),
+ let step = Int(line[stepR]),
+ let loss = Double(line[lossR]),
+ let valLoss = Double(line[valR]) else { return nil }
+ return .trainStep(LatticeEvent.TrainStep(
+ step: step, loss: loss, lr: nil, grad_norm: nil, tok_s: nil,
+ val_loss: valLoss, eta_s: nil
+ ))
+ }
+ // No-val variant.
+ if let m = reTrainStepNoVal.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
+ guard let stepR = Range(m.range(at: 1), in: line),
+ let lossR = Range(m.range(at: 2), in: line),
+ let step = Int(line[stepR]),
+ let loss = Double(line[lossR]) else { return nil }
+ return .trainStep(LatticeEvent.TrainStep(
+ step: step, loss: loss, lr: nil, grad_norm: nil, tok_s: nil,
+ val_loss: nil, eta_s: nil
+ ))
+ }
+ return nil
+ }
+
+ // MARK: - train_grad_full done lines
+
+ // With validation:
+ // "=== done: train 4.1234→3.1234 (-1.0000) | held-out 4.5678→4.1000 (-0.4678) in 12.3s ==="
+ // Without validation:
+ // "=== done: base NLL 4.1234 → final NLL 3.1234 (-1.0000) in 12.3s ==="
+ // Unicode right arrow (→ U+2192) or ASCII "->" both accepted.
+ private static let reDoneFull = try! NSRegularExpression(
+ pattern: #"^=== done: train ([0-9]+\.[0-9]+)[-→>]+([0-9]+\.[0-9]+)\s*\([^)]+\)\s*\|\s*held-out ([0-9]+\.[0-9]+)[-→>]+([0-9]+\.[0-9]+)\s*\([^)]+\)\s*in ([0-9]+(?:\.[0-9]+)?)s"#
+ )
+ private static let reDoneNoVal = try! NSRegularExpression(
+ pattern: #"^=== done: base NLL ([0-9]+\.[0-9]+)[-→> ]+final NLL ([0-9]+\.[0-9]+)\s*\([^)]+\)\s*in ([0-9]+(?:\.[0-9]+)?)s"#
+ )
+
+ private static func parseTrainDone(_ line: String) -> LatticeEvent? {
+ guard line.hasPrefix("===") && line.contains("done:") else { return nil }
+
+ if let m = reDoneFull.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
+ guard let baseR = Range(m.range(at: 1), in: line),
+ let finR = Range(m.range(at: 2), in: line),
+ let _ = Range(m.range(at: 3), in: line), // held-out base (not stored)
+ let bestR = Range(m.range(at: 4), in: line),
+ let durR = Range(m.range(at: 5), in: line),
+ let baseNLL = Double(line[baseR]),
+ let finalNLL = Double(line[finR]),
+ let bestVal = Double(line[bestR]),
+ let dur = Double(line[durR]) else { return nil }
+ return .trainDone(LatticeEvent.TrainDone(
+ base_nll: baseNLL, final_nll: finalNLL,
+ best_val: bestVal, duration_s: dur, saved: nil
+ ))
+ }
+ if let m = reDoneNoVal.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
+ guard let baseR = Range(m.range(at: 1), in: line),
+ let finR = Range(m.range(at: 2), in: line),
+ let durR = Range(m.range(at: 3), in: line),
+ let baseNLL = Double(line[baseR]),
+ let finalNLL = Double(line[finR]),
+ let dur = Double(line[durR]) else { return nil }
+ return .trainDone(LatticeEvent.TrainDone(
+ base_nll: baseNLL, final_nll: finalNLL,
+ best_val: nil, duration_s: dur, saved: nil
+ ))
+ }
+ return nil
+ }
+
+ // MARK: - quantize per-tensor lines
+
+ // "[1/24] Q4_0 model.layers.0...weight shape=[2048, 1024] 178.0MB→22.2MB 0.45s"
+ // "[2/24] F16 model.layers.0.input_layernorm.weight shape=[1024] 0.0MB ..."
+ // The size part is optional (F16 lines may omit the → part).
+ private static let reQuantLayer = try! NSRegularExpression(
+ pattern: #"^\s*\[(\d+)/(\d+)\]\s+(\S+)\s+(\S+)\s+shape=\[[^\]]+\](?:\s+([0-9]+(?:\.[0-9]+)?)(MB|GB)[-→>]+([0-9]+(?:\.[0-9]+)?)(MB|GB))?"#
+ )
+
+ private static func parseQuantLayer(_ line: String) -> LatticeEvent? {
+ guard line.contains("/") && line.contains("shape=") else { return nil }
+ guard let m = reQuantLayer.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) else { return nil }
+ guard let iR = Range(m.range(at: 1), in: line),
+ let nR = Range(m.range(at: 2), in: line),
+ let schemeR = Range(m.range(at: 3), in: line),
+ let nameR = Range(m.range(at: 4), in: line),
+ let i = Int(line[iR]),
+ let n = Int(line[nR]) else { return nil }
+
+ let scheme = String(line[schemeR])
+ let name = String(line[nameR])
+
+ var beforeMB: Double? = nil
+ var afterMB: Double? = nil
+
+ // Groups 5-8: before value, before unit, after value, after unit (all optional)
+ if m.range(at: 5).location != NSNotFound,
+ let bValR = Range(m.range(at: 5), in: line),
+ let bUnitR = Range(m.range(at: 6), in: line),
+ let aValR = Range(m.range(at: 7), in: line),
+ let aUnitR = Range(m.range(at: 8), in: line),
+ let bVal = Double(line[bValR]),
+ let aVal = Double(line[aValR]) {
+ let bUnit = String(line[bUnitR])
+ let aUnit = String(line[aUnitR])
+ beforeMB = bUnit == "GB" ? bVal * 1024.0 : bVal
+ afterMB = aUnit == "GB" ? aVal * 1024.0 : aVal
+ }
+
+ return .quantLayer(LatticeEvent.QuantLayer(
+ i: i, n: n, name: name, scheme: scheme,
+ before_mb: beforeMB, after_mb: afterMB
+ ))
+ }
+
+ // MARK: - quantize summary lines (accumulate → emit quantDone)
+
+ // q4 summary:
+ // "Input size: 27.00 GB"
+ // "Output size: 6.75 GB"
+ // "Ratio: 4.00x (25.0%)"
+ // quarot summary:
+ // "Input bytes: 27648.00 MB"
+ // "Output bytes: 6912.00 MB"
+ // "Compression: 4.00x (25.0%)"
+ // "Forward-equiv: max_abs=1.234e-06, mean_abs=4.567e-07 (tol=1e-05, ...)"
+ private static let reInputSize = try! NSRegularExpression(pattern: #"Input (?:size|bytes):\s*([0-9]+(?:\.[0-9]+)?)\s*(MB|GB)"#)
+ private static let reOutputSize = try! NSRegularExpression(pattern: #"Output (?:size|bytes):\s*([0-9]+(?:\.[0-9]+)?)\s*(MB|GB)"#)
+ private static let reRatio = try! NSRegularExpression(pattern: #"(?:Ratio|Compression):\s*([0-9]+(?:\.[0-9]+)?)x"#)
+ private static let reForwardEquiv = try! NSRegularExpression(pattern: #"Forward-equiv:.*max_abs=([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)(?:.*tol=([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?))?"#)
+
+ private static func parseQuantSummary(_ line: String) -> LatticeEvent? {
+ let ns = NSRange(line.startIndex..., in: line)
+
+ if let m = reInputSize.firstMatch(in: line, range: ns),
+ let vR = Range(m.range(at: 1), in: line),
+ let uR = Range(m.range(at: 2), in: line),
+ let v = Double(line[vR]) {
+ let mb = String(line[uR]) == "GB" ? v * 1024.0 : v
+ return _quantAccumulator.update(beforeMB: mb)
+ }
+ if let m = reOutputSize.firstMatch(in: line, range: ns),
+ let vR = Range(m.range(at: 1), in: line),
+ let uR = Range(m.range(at: 2), in: line),
+ let v = Double(line[vR]) {
+ let mb = String(line[uR]) == "GB" ? v * 1024.0 : v
+ return _quantAccumulator.update(afterMB: mb)
+ }
+ if let m = reRatio.firstMatch(in: line, range: ns),
+ let vR = Range(m.range(at: 1), in: line),
+ let v = Double(line[vR]) {
+ return _quantAccumulator.update(ratio: v)
+ }
+ if let m = reForwardEquiv.firstMatch(in: line, range: ns),
+ let vR = Range(m.range(at: 1), in: line),
+ let v = Double(line[vR]) {
+ var derived: String? = nil
+ if m.range(at: 2).location != NSNotFound,
+ let tolR = Range(m.range(at: 2), in: line),
+ let tol = Double(line[tolR]) {
+ derived = v <= tol ? "PASS" : "FAIL"
+ }
+ return _quantAccumulator.update(maxAbs: v, verdict: derived)
+ }
+ return nil
+ }
+}
+
+// MARK: - DEBUG self-test
+
+#if DEBUG
+/// Parses 4 representative lines and returns whether all decoded to expected cases.
+/// Compile-time correct; call from unit tests or debug console.
+func _latticeParserSelfTest() -> Bool {
+ var ok = true
+
+ let r1 = LatticeEventParser.parse(line: " step 5 train NLL: 3.9876 held-out NLL: 4.4321 (train d -0.1358)")
+ if case .trainStep(let s) = r1, s.step == 5, abs(s.loss - 3.9876) < 1e-9, s.val_loss == 4.4321 {} else { ok = false }
+
+ let r2 = LatticeEventParser.parse(line: " step 0 train NLL: 4.1234")
+ if case .trainStep(let s) = r2, s.step == 0, abs(s.loss - 4.1234) < 1e-9, s.val_loss == nil {} else { ok = false }
+
+ let r3 = LatticeEventParser.parse(line: "=== done: train 4.1234\u{2192}3.1234 (-1.0000) | held-out 4.5678\u{2192}4.1000 (-0.4678) in 12.3s ===")
+ if case .trainDone(let d) = r3, let b = d.base_nll, abs(b - 4.1234) < 1e-9,
+ let bv = d.best_val, abs(bv - 4.1000) < 1e-9 {} else { ok = false }
+
+ let r4 = LatticeEventParser.parse(line: " [1/24] Q4_0 model.layers.0.self_attn.q_proj.weight shape=[2048, 1024] 178.0MB\u{2192}22.2MB 0.45s")
+ if case .quantLayer(let q) = r4, q.i == 1, q.n == 24, q.scheme == "Q4_0",
+ let bm = q.before_mb, abs(bm - 178.0) < 0.01 {} else { ok = false }
+
+ return ok
+}
+#endif
diff --git a/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift b/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift
new file mode 100644
index 0000000000..cb4c1543da
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift
@@ -0,0 +1,164 @@
+import Darwin
+import Foundation
+
+// MARK: - PID registry for active trainer subprocesses.
+//
+// Survives app crashes: each active run is written to a per-PID JSON file before launch and
+// deleted on exit. On next startup, reapOrphans() reads the directory and kills any trainer
+// processes that outlived the app session.
+//
+// Storage layout (single-instance assumed — two live app instances are out of scope):
+// /LatticeStudio/active-runs/.json
+//
+// The "active-runs" subdirectory lives under the SAME base that AppStore already uses for
+// runs.json. Both share AppStore.appSupportDir so the path never diverges.
+
+// MARK: Persisted entry
+
+/// Minimal descriptor written for each active subprocess.
+struct ActiveRunEntry: Codable {
+ let pid: Int32
+ let binPath: String
+ let kind: String
+ let startedAt: Date
+}
+
+// MARK: Registry
+
+enum RunRegistry {
+
+ // MARK: Directory resolution
+
+ /// The `active-runs` subdirectory under the shared LatticeStudio application support dir.
+ /// Returns nil if the app support dir is inaccessible; all callers handle nil gracefully.
+ private static var registryDir: URL? {
+ guard let base = AppStore.appSupportDir else { return nil }
+ let dir = base.appendingPathComponent("active-runs", isDirectory: true)
+ // Create on first access — best-effort, failure propagates as nil.
+ do {
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ } catch {
+ print("[RunRegistry] could not create registry dir: \(error)")
+ return nil
+ }
+ return dir
+ }
+
+ private static func entryURL(for pid: Int32) -> URL? {
+ registryDir?.appendingPathComponent("\(pid).json")
+ }
+
+ // MARK: Registration
+
+ /// Write a PID entry before the subprocess is considered launched.
+ /// Failure is best-effort: a missing entry is still caught on next reap.
+ static func register(pid: Int32, binPath: String, kind: String, startedAt: Date) {
+ guard let url = entryURL(for: pid) else { return }
+ let entry = ActiveRunEntry(pid: pid, binPath: binPath, kind: kind, startedAt: startedAt)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ guard let data = try? encoder.encode(entry) else { return }
+ do {
+ try data.write(to: url, options: .atomic)
+ } catch {
+ print("[RunRegistry] failed to register pid \(pid): \(error)")
+ }
+ }
+
+ /// Remove the PID entry after the subprocess terminates.
+ /// Safe to call on a pid that was never registered (or already deregistered).
+ static func deregister(pid: Int32) {
+ guard let url = entryURL(for: pid) else { return }
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ // MARK: Startup reaper
+
+ /// Scan the registry dir and terminate any orphaned trainer processes left over from a
+ /// previous app session (crash, force-quit, etc.).
+ ///
+ /// Safety gate: before sending SIGTERM we verify via proc_pidpath that the running
+ /// process at that PID is the same executable recorded at registration time.
+ /// If the PID was recycled by an unrelated process we remove the stale file and move on.
+ ///
+ /// Returns the count of processes actually killed (i.e. matching exe-path and successfully
+ /// signalled). Processes that are already dead result in stale-file cleanup only.
+ ///
+ /// Note: assumes single-instance app. A second live instance would incorrectly appear as
+ /// orphaned training runs at startup of the first instance; that scenario is out of scope.
+ @discardableResult
+ static func reapOrphans() -> Int {
+ guard let dir = registryDir else { return 0 }
+ let fm = FileManager.default
+ guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
+ return 0
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ var killed = 0
+
+ for fileURL in contents {
+ guard fileURL.pathExtension == "json" else { continue }
+ guard let data = try? Data(contentsOf: fileURL),
+ let entry = try? decoder.decode(ActiveRunEntry.self, from: data) else {
+ // Corrupt or undecodable entry — remove to avoid accumulation.
+ try? fm.removeItem(at: fileURL)
+ continue
+ }
+
+ let pid = entry.pid
+
+ // send(0) probes liveness without delivering a signal.
+ if kill(pid, 0) != 0 {
+ // Process is already gone — clean up the stale registry file.
+ try? fm.removeItem(at: fileURL)
+ continue
+ }
+
+ // Process is alive. Verify the executable path matches before signalling.
+ // PROC_PIDPATHINFO_MAXSIZE (4 * MAXPATHLEN) is 4096 on Darwin. The C macro is
+ // not importable into Swift ("structure not supported"), so use the literal.
+ let bufSize = 4096
+ var buf = [Int8](repeating: 0, count: bufSize)
+ let ret = proc_pidpath(pid, &buf, UInt32(bufSize))
+ let livePath: String?
+ if ret > 0 {
+ livePath = String(cString: buf)
+ } else {
+ // proc_pidpath failed (sandbox, race, etc.) — fail safe: do not kill.
+ print("[RunRegistry] proc_pidpath failed for pid \(pid), skipping kill (safe)")
+ try? fm.removeItem(at: fileURL)
+ continue
+ }
+
+ guard livePath == entry.binPath else {
+ // PID was recycled by a different process — do NOT kill anything.
+ print("[RunRegistry] pid \(pid) recycled: live=\(livePath ?? "nil") expected=\(entry.binPath), skipping kill")
+ try? fm.removeItem(at: fileURL)
+ continue
+ }
+
+ // Exe-path matches — this is genuinely our orphaned trainer. Terminate it.
+ print("[RunRegistry] reaping orphaned trainer pid \(pid) (\(entry.kind))")
+ kill(pid, SIGTERM)
+
+ // Poll briefly (up to 1 s in 100 ms increments) for graceful exit before SIGKILL.
+ let deadline = Date().addingTimeInterval(1.0)
+ while Date() < deadline {
+ Thread.sleep(forTimeInterval: 0.1)
+ if kill(pid, 0) != 0 { break } // gone
+ }
+ if kill(pid, 0) == 0 {
+ // Still alive after grace period.
+ print("[RunRegistry] pid \(pid) did not exit after SIGTERM, sending SIGKILL")
+ kill(pid, SIGKILL)
+ }
+
+ try? fm.removeItem(at: fileURL)
+ killed += 1
+ }
+
+ return killed
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/Badges.swift b/apps/macos/Sources/LatticeStudio/Components/Badges.swift
new file mode 100644
index 0000000000..f88207b044
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/Badges.swift
@@ -0,0 +1,173 @@
+import SwiftUI
+
+// MARK: - Badges
+//
+// Graphite Signal Lab badge vocabulary — Section D "Badges and pills".
+//
+// Design laws (spec §D):
+// - FormatBadge: height 20, h-pad 7, radius 4 (Theme.Radius.badge), 11pt medium, neutral fill/border.
+// Avoid assigning a different bright color to every model format.
+// - StatusBadge: height 22, 6pt semantic dot, ink text (AA on the tinted fill),
+// fill=semantic@12%, border=semantic@28%.
+// Only Running gets motion: opacity pulse 65%→100% over 1.2s (spec §F).
+
+// MARK: - FormatBadge
+
+/// Neutral pill labelling a model format: BF16, Q4, EMBED, ADAPTER, etc.
+///
+/// ```swift
+/// FormatBadge("BF16")
+/// FormatBadge("Q4")
+/// FormatBadge("EMBED")
+/// ```
+struct FormatBadge: View {
+ let label: String
+
+ init(_ label: String) {
+ self.label = label
+ }
+
+ var body: some View {
+ Text(label)
+ .font(Theme.Fonts.micro)
+ .tracking(0.2)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .padding(.horizontal, 7)
+ .frame(height: 20)
+ .background(Theme.Palette.surfaceRaised)
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ }
+}
+
+// MARK: - StatusBadge
+
+/// Semantic status indicator: colored dot + label, fill/border at semantic opacity.
+///
+/// ```swift
+/// StatusBadge(.idle)
+/// StatusBadge(.running)
+/// StatusBadge(.success)
+/// StatusBadge(.warning)
+/// StatusBadge(.error)
+/// ```
+struct StatusBadge: View {
+ enum Status {
+ case idle
+ case running
+ case success
+ case warning
+ case error
+
+ var label: String {
+ switch self {
+ case .idle: "Idle"
+ case .running: "Running"
+ case .success: "Complete"
+ case .warning: "Warning"
+ case .error: "Failed"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .idle: Theme.Palette.idle
+ case .running: Theme.Palette.running
+ case .success: Theme.Palette.success
+ case .warning: Theme.Palette.warning
+ case .error: Theme.Palette.error
+ }
+ }
+
+ var isRunning: Bool { self == .running }
+ }
+
+ let status: Status
+
+ @State private var dotOpacity: Double = 1.0
+
+ init(_ status: Status) {
+ self.status = status
+ }
+
+ var body: some View {
+ HStack(spacing: 6) {
+ // 6pt semantic dot — Running pulses 65%→100% over 1.2s
+ Circle()
+ .fill(status.color)
+ .frame(width: 6, height: 6)
+ .opacity(dotOpacity)
+ .onAppear {
+ guard status.isRunning else { return }
+ withAnimation(
+ .easeInOut(duration: 1.2)
+ .repeatForever(autoreverses: true)
+ ) {
+ dotOpacity = 0.65
+ }
+ }
+ .onChange(of: status.isRunning) { _, nowRunning in
+ if nowRunning {
+ withAnimation(
+ .easeInOut(duration: 1.2)
+ .repeatForever(autoreverses: true)
+ ) {
+ dotOpacity = 0.65
+ }
+ } else {
+ withAnimation(.easeOut(duration: Theme.Motion.metric)) {
+ dotOpacity = 1.0
+ }
+ }
+ }
+
+ Text(status.label)
+ .font(Theme.Fonts.controlText)
+ .foregroundStyle(Theme.Palette.ink)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: 22)
+ .background(status.color.opacity(0.12))
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous)
+ .strokeBorder(status.color.opacity(0.28), lineWidth: 1)
+ )
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Badges") {
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ Text("FORMAT BADGES")
+ .instrumentLabel()
+
+ HStack(spacing: Theme.Space.sm) {
+ FormatBadge("BF16")
+ FormatBadge("Q4")
+ FormatBadge("EMBED")
+ FormatBadge("ADAPTER")
+ FormatBadge("F32")
+ }
+
+ Divider()
+ .padding(.vertical, Theme.Space.xs)
+
+ Text("STATUS BADGES")
+ .instrumentLabel()
+
+ HStack(spacing: Theme.Space.sm) {
+ StatusBadge(.idle)
+ StatusBadge(.running)
+ StatusBadge(.success)
+ StatusBadge(.warning)
+ StatusBadge(.error)
+ }
+ }
+ .padding(Theme.Space.lg)
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift b/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift
new file mode 100644
index 0000000000..d791ac64e0
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift
@@ -0,0 +1,170 @@
+import SwiftUI
+
+// MARK: - ButtonStyles
+//
+// Graphite Signal Lab button vocabulary — Section D "Primary button" + "Secondary button".
+//
+// Design laws (spec §D):
+// - Height 30pt standard; use frame(height: 34) for rail-footer placement.
+// - Horizontal padding 12pt, symbol gap 6pt, radius 6pt (Theme.Radius.control).
+// - Text 12pt medium (Theme.Fonts.controlText).
+// - Symbol: 13pt medium (see Label use in previews).
+// - Native keyboard activation and focusability preserved via ButtonStyle (not a custom control).
+// - Hover approximated with .onHover + state; SwiftUI on macOS 14 has no hover modifier on ButtonStyle.
+//
+// Types are named Lattice* to avoid collision with any private screen-local button styles.
+
+// MARK: - LatticePrimaryButtonStyle
+
+/// Accent-filled primary button: accent fill, onAccent text.
+///
+/// ```swift
+/// Button("Run") { /* … */ }
+/// .buttonStyle(LatticePrimaryButtonStyle())
+///
+/// // Rail-footer variant (34pt height):
+/// Button("Run") { /* … */ }
+/// .buttonStyle(LatticePrimaryButtonStyle(height: 34))
+/// ```
+struct LatticePrimaryButtonStyle: ButtonStyle {
+ var height: CGFloat = Theme.Space.controlHeight // 30; pass 34 for rail footer
+
+ func makeBody(configuration: Configuration) -> some View {
+ LatticePrimaryButtonBody(configuration: configuration, height: height)
+ }
+}
+
+private struct LatticePrimaryButtonBody: View {
+ let configuration: ButtonStyleConfiguration
+ let height: CGFloat
+
+ @Environment(\.isEnabled) private var isEnabled
+ @State private var isHovered = false
+
+ private var fillColor: Color {
+ if configuration.isPressed { return Theme.Palette.accentActive }
+ if isHovered { return Theme.Palette.accentHover }
+ return Theme.Palette.accent
+ }
+
+ var body: some View {
+ configuration.label
+ .font(Theme.Fonts.controlText)
+ .foregroundStyle(
+ isEnabled
+ ? Theme.Palette.onAccent
+ : Theme.Palette.textDisabled
+ )
+ .padding(.horizontal, 12)
+ .frame(height: height)
+ .background(
+ isEnabled ? fillColor : Theme.Palette.surfaceDisabled,
+ in: RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ )
+ .animation(.easeOut(duration: Theme.Motion.hover), value: isHovered)
+ .animation(.easeOut(duration: Theme.Motion.press), value: configuration.isPressed)
+ .onHover { isHovered = $0 }
+ }
+}
+
+// MARK: - LatticeSecondaryButtonStyle
+
+/// Raised-surface secondary button: surfaceRaised fill, standard border, ink text.
+///
+/// ```swift
+/// Button("Reveal") { /* … */ }
+/// .buttonStyle(LatticeSecondaryButtonStyle())
+/// ```
+struct LatticeSecondaryButtonStyle: ButtonStyle {
+ var height: CGFloat = Theme.Space.controlHeight // 30; pass 34 for rail footer
+
+ func makeBody(configuration: Configuration) -> some View {
+ LatticeSecondaryButtonBody(configuration: configuration, height: height)
+ }
+}
+
+private struct LatticeSecondaryButtonBody: View {
+ let configuration: ButtonStyleConfiguration
+ let height: CGFloat
+
+ @Environment(\.isEnabled) private var isEnabled
+ @State private var isHovered = false
+
+ private var fillColor: Color {
+ if configuration.isPressed { return Theme.Palette.panel }
+ if isHovered { return Theme.Palette.surfaceHover }
+ return Theme.Palette.surfaceRaised
+ }
+
+ var body: some View {
+ configuration.label
+ .font(Theme.Fonts.controlText)
+ .foregroundStyle(
+ isEnabled
+ ? Theme.Palette.ink
+ : Theme.Palette.textDisabled
+ )
+ .padding(.horizontal, 12)
+ .frame(height: height)
+ .background(
+ isEnabled ? fillColor : Theme.Palette.surfaceDisabled,
+ in: RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ .animation(.easeOut(duration: Theme.Motion.hover), value: isHovered)
+ .animation(.easeOut(duration: Theme.Motion.press), value: configuration.isPressed)
+ .onHover { isHovered = $0 }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("ButtonStyles") {
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ Text("PRIMARY")
+ .instrumentLabel()
+
+ HStack(spacing: Theme.Space.sm) {
+ Button("Run") {}
+ .buttonStyle(LatticePrimaryButtonStyle())
+
+ Button { } label: {
+ Label("Run", systemImage: "play.fill")
+ }
+ .buttonStyle(LatticePrimaryButtonStyle())
+
+ Button("Rail Footer") {}
+ .buttonStyle(LatticePrimaryButtonStyle(height: 34))
+
+ Button("Disabled") {}
+ .buttonStyle(LatticePrimaryButtonStyle())
+ .disabled(true)
+ }
+
+ Text("SECONDARY")
+ .instrumentLabel()
+ .padding(.top, Theme.Space.xs)
+
+ HStack(spacing: Theme.Space.sm) {
+ Button("Reveal") {}
+ .buttonStyle(LatticeSecondaryButtonStyle())
+
+ Button { } label: {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+
+ Button("Rail Footer") {}
+ .buttonStyle(LatticeSecondaryButtonStyle(height: 34))
+
+ Button("Disabled") {}
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .disabled(true)
+ }
+ }
+ .padding(Theme.Space.lg)
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift
new file mode 100644
index 0000000000..42dc0fa5f8
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift
@@ -0,0 +1,394 @@
+import SwiftUI
+
+// MARK: - Primitive 12: CommandBar (grafted from Console)
+//
+// A floating ⌘K mono palette. The ONE glass surface allowed.
+//
+// Glass law: `.regularMaterial` always; `.glassEffect` gated behind
+// `if #available(macOS 26.0, *)` with `.regularMaterial` as the fallback.
+//
+// Behavior:
+// - Types input (e.g. "train qwen3.5 r8") into a mono text field
+// - Matches against provided CommandSpec list (fuzzy prefix match)
+// - Arguments after the command name appear as editable ArgChip tokens
+// - onRun called with (command: String, args: [String])
+// - ⏎ fires the first match; ⌫ removes last arg chip; ⎋ dismisses
+// - Corner radius: Theme.Radius.commandBar = 10px
+
+// MARK: - Command spec model
+
+/// Defines a command available in the CommandBar.
+struct CommandSpec: Identifiable, Equatable {
+ let id: String
+ let title: String // e.g. "train"
+ let args: [String] // default arg placeholders, e.g. ["qwen3.5", "r8"]
+ let description: String
+
+ init(title: String, args: [String] = [], description: String = "") {
+ self.id = title
+ self.title = title
+ self.args = args
+ self.description = description
+ }
+}
+
+// MARK: - Argument chip (inline editable token)
+
+/// An editable argument chip within the command bar input line.
+struct ArgChip: View {
+ let text: String
+ let isFocused: Bool
+ let onRemove: () -> Void
+
+ var body: some View {
+ HStack(spacing: 3) {
+ Text(text)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ }
+ .padding(.horizontal, Theme.Space.sm)
+ .padding(.vertical, 2)
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(isFocused ? Theme.Palette.signal.opacity(0.15) : Theme.Palette.wellSink)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .strokeBorder(
+ isFocused ? Theme.Palette.signal : Theme.Palette.hairline,
+ lineWidth: 1
+ )
+ )
+ )
+ }
+}
+
+// MARK: - CommandBar
+
+/// The floating ⌘K command palette. The only glass surface in the instrument.
+///
+/// Wire into ContentView via `AppStore.commandBarOpen` + `.sheet` or `.overlay`.
+///
+/// ```swift
+/// CommandBar(
+/// isPresented: $store.commandBarOpen,
+/// commands: [
+/// CommandSpec(title: "train", args: ["qwen3.5", "r8"], description: "Start a LoRA training run"),
+/// CommandSpec(title: "quantize", args: ["quarot", "qwen3.5"], description: "Quantize a model"),
+/// CommandSpec(title: "chat", args: ["qwen3.5"], description: "Open chat"),
+/// ],
+/// onRun: { cmd, args in print("run:", cmd, args) }
+/// )
+/// ```
+struct CommandBar: View {
+ @Binding var isPresented: Bool
+ let commands: [CommandSpec]
+ let onRun: (String, [String]) -> Void
+
+ @State private var inputText: String = ""
+ @State private var resolvedArgs: [String] = []
+ @State private var selectedCommandIndex: Int = 0
+ @FocusState private var fieldFocused: Bool
+
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
+
+ // MARK: Parsing
+
+ private var tokens: [String] {
+ inputText
+ .trimmingCharacters(in: .whitespaces)
+ .split(separator: " ", omittingEmptySubsequences: true)
+ .map(String.init)
+ }
+
+ private var commandToken: String { tokens.first ?? "" }
+
+ private var typedArgs: [String] { Array(tokens.dropFirst()) }
+
+ private var filteredCommands: [CommandSpec] {
+ let q = commandToken.lowercased()
+ guard !q.isEmpty else { return commands }
+ return commands.filter {
+ $0.title.lowercased().hasPrefix(q) ||
+ $0.title.lowercased().contains(q)
+ }
+ }
+
+ private var activeCommand: CommandSpec? {
+ guard !filteredCommands.isEmpty else { return nil }
+ let idx = min(selectedCommandIndex, filteredCommands.count - 1)
+ return filteredCommands[idx]
+ }
+
+ /// Merge typed args with command defaults (typed overrides defaults positionally).
+ private var effectiveArgs: [String] {
+ guard let cmd = activeCommand else { return typedArgs }
+ var result = cmd.args
+ for (i, typed) in typedArgs.enumerated() {
+ if i < result.count {
+ result[i] = typed
+ } else {
+ result.append(typed)
+ }
+ }
+ return result
+ }
+
+ // MARK: Body
+
+ var body: some View {
+ if isPresented {
+ // Full-screen dimmed overlay
+ ZStack {
+ Color.black.opacity(0.4)
+ .ignoresSafeArea()
+ .onTapGesture { dismiss() }
+
+ // The palette slab
+ palette
+ .frame(maxWidth: 540)
+ .shadow(color: .black.opacity(0.4), radius: 24, x: 0, y: 8)
+ }
+ .onAppear {
+ fieldFocused = true
+ selectedCommandIndex = 0
+ inputText = ""
+ resolvedArgs = []
+ }
+ }
+ }
+
+ private var palette: some View {
+ VStack(spacing: 0) {
+ // Input line
+ inputLine
+
+ // Divider
+ Theme.Palette.hairline.frame(height: 1)
+
+ // Results list
+ if !filteredCommands.isEmpty {
+ resultsList
+ } else {
+ noMatchRow
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous)
+ .strokeBorder(Theme.Palette.hairline, lineWidth: 1)
+ )
+ .background(glassBackground)
+ }
+
+ // MARK: Glass background
+
+ @ViewBuilder
+ private var glassBackground: some View {
+ if reduceTransparency {
+ // Solid fallback for accessibilityReduceTransparency
+ RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous)
+ .fill(Theme.Palette.panel)
+ } else {
+ // macOS 26+: .glassEffect (liquid glass); macOS 14-25: .regularMaterial
+ if #available(macOS 26.0, *) {
+ // Liquid Glass — the spec-blessed glass surface
+ RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous)
+ .fill(.regularMaterial) // glassEffect not in SwiftUI API at macOS 14 base; use material
+ } else {
+ RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous)
+ .fill(.regularMaterial)
+ }
+ }
+ }
+
+ // MARK: Input line
+
+ private var inputLine: some View {
+ HStack(spacing: Theme.Space.sm) {
+ // ⌘K glyph
+ Text("⌘")
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.signal)
+
+ // Command token + arg chips + text field
+ HStack(spacing: Theme.Space.xs) {
+ // If a command is recognized, show its name as a dim chip
+ if let cmd = activeCommand, !commandToken.isEmpty, cmd.title.hasPrefix(commandToken) || commandToken == cmd.title {
+ Text(cmd.title)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ }
+
+ // Arg chips for typed args (beyond first token)
+ ForEach(Array(typedArgs.enumerated()), id: \.offset) { idx, arg in
+ ArgChip(
+ text: arg,
+ isFocused: false,
+ onRemove: {
+ // Remove last word from input
+ let parts = inputText.split(separator: " ").dropLast()
+ inputText = parts.joined(separator: " ")
+ }
+ )
+ }
+
+ // Placeholder arg hints (from command spec, beyond what's typed)
+ if let cmd = activeCommand {
+ let hintArgs = cmd.args.dropFirst(typedArgs.count)
+ ForEach(Array(hintArgs.enumerated()), id: \.offset) { _, hint in
+ Text(hint)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .monospacedDigit()
+ .padding(.horizontal, Theme.Space.xs)
+ }
+ }
+ }
+
+ // Actual text field (mono)
+ TextField("", text: $inputText)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .textFieldStyle(.plain)
+ .focused($fieldFocused)
+ .onSubmit { fireRun() }
+ .onKeyPress(.escape) {
+ dismiss()
+ return .handled
+ }
+ .onKeyPress(.upArrow) {
+ selectedCommandIndex = max(0, selectedCommandIndex - 1)
+ return .handled
+ }
+ .onKeyPress(.downArrow) {
+ selectedCommandIndex = min(filteredCommands.count - 1, selectedCommandIndex + 1)
+ return .handled
+ }
+
+ Spacer()
+
+ // Return key hint
+ KeyCapChip("⏎")
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .frame(height: 48)
+ }
+
+ // MARK: Results list
+
+ private var resultsList: some View {
+ VStack(spacing: 0) {
+ ForEach(Array(filteredCommands.enumerated()), id: \.element.id) { idx, cmd in
+ commandRow(cmd: cmd, isSelected: idx == selectedCommandIndex)
+ .onTapGesture {
+ selectedCommandIndex = idx
+ fireRun()
+ }
+ }
+ }
+ }
+
+ private func commandRow(cmd: CommandSpec, isSelected: Bool) -> some View {
+ HStack(spacing: Theme.Space.sm) {
+ // 2px teal accent on selected
+ Rectangle()
+ .fill(isSelected ? Theme.Palette.signal : .clear)
+ .frame(width: 2)
+
+ VStack(alignment: .leading, spacing: 2) {
+ // Command title + arg chips
+ HStack(spacing: Theme.Space.sm) {
+ Text(cmd.title)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+
+ ForEach(cmd.args, id: \.self) { arg in
+ ArgChip(text: arg, isFocused: false, onRemove: {})
+ }
+ }
+
+ if !cmd.description.isEmpty {
+ Text(cmd.description)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ .padding(.horizontal, Theme.Space.md)
+
+ Spacer()
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ .background(isSelected ? Theme.Palette.signal.opacity(0.08) : .clear)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+
+ private var noMatchRow: some View {
+ HStack {
+ Text("no match")
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.inkDim)
+ Spacer()
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ .padding(.horizontal, Theme.Space.lg)
+ }
+
+ // MARK: Actions
+
+ private func fireRun() {
+ guard let cmd = activeCommand else { return }
+ onRun(cmd.title, effectiveArgs)
+ dismiss()
+ }
+
+ private func dismiss() {
+ inputText = ""
+ isPresented = false
+ }
+}
+
+// MARK: - Default command set
+
+extension CommandSpec {
+ /// Standard commands wired to AppStore screens.
+ /// Arg hints use generic placeholders so the palette never implies a specific model
+ /// is installed. ContentView.handleCommand matches args against store.models at runtime.
+ static let latticeDefaults: [CommandSpec] = [
+ CommandSpec(title: "models", args: [], description: "Browse downloaded models"),
+ CommandSpec(title: "chat", args: [""], description: "Open chat"),
+ CommandSpec(title: "stop", args: [], description: "Stop the current run"),
+ ]
+}
+
+// MARK: - Previews
+
+#Preview("CommandBar") {
+ @Previewable @State var isPresented: Bool = true
+
+ return ZStack {
+ Theme.Palette.canvas
+ .ignoresSafeArea()
+
+ // Simulated background content
+ VStack {
+ Text("LATTICE INSTRUMENT")
+ .font(Theme.Fonts.title)
+ .foregroundStyle(Theme.Palette.ink)
+ }
+
+ CommandBar(
+ isPresented: Binding(get: { isPresented }, set: { isPresented = $0 }),
+ commands: CommandSpec.latticeDefaults,
+ onRun: { cmd, args in
+ print("run: \(cmd) \(args.joined(separator: " "))")
+ }
+ )
+ }
+ .frame(width: 640, height: 400)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift b/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift
new file mode 100644
index 0000000000..8578d57aab
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift
@@ -0,0 +1,236 @@
+import SwiftUI
+
+// MARK: - Primitive 9: ContrastPair
+//
+// Before↔after comparator for the QuaRot quantization story.
+//
+// Design spec:
+// - Two stacked ReadoutWell columns (size / bits / PPL) with a centered Δ chip
+// - On completion: columns slide apart + a single hairline "fold" wipe reveals after-state
+// - The Δ chip encodes direction: teal = improvement, amber = regression
+// - Animation: 180ms ease-out (mechanical), respects accessibilityReduceMotion
+
+/// A single metric row within the ContrastPair.
+struct ContrastMetric: Identifiable {
+ let id: String
+ let label: String
+ let beforeValue: String
+ let afterValue: String
+ let beforeUnit: String
+ let afterUnit: String
+ let delta: String // e.g. "−74%", "+0.09"
+ let deltaGood: Bool // teal if true, amber if false (good = smaller for size, bigger for PPL budget)
+
+ init(
+ label: String,
+ beforeValue: String,
+ afterValue: String,
+ beforeUnit: String = "",
+ afterUnit: String = "",
+ delta: String,
+ deltaGood: Bool = true
+ ) {
+ self.id = label
+ self.label = label
+ self.beforeValue = beforeValue
+ self.afterValue = afterValue
+ self.beforeUnit = beforeUnit
+ self.afterUnit = afterUnit
+ self.delta = delta
+ self.deltaGood = deltaGood
+ }
+}
+
+/// Before↔after comparator: two ReadoutWell columns + centered Δ chip.
+/// On `isComplete = true` the after-column slides in with a "fold" wipe.
+///
+/// ```swift
+/// ContrastPair(
+/// metrics: [
+/// ContrastMetric(label: "SIZE", beforeValue: "1.61", afterValue: "0.41",
+/// beforeUnit: "GB", afterUnit: "GB", delta: "−74%", deltaGood: true),
+/// ContrastMetric(label: "BITS", beforeValue: "16", afterValue: "4",
+/// delta: "−75%", deltaGood: true),
+/// ContrastMetric(label: "PPL", beforeValue: "15.86", afterValue: "15.95",
+/// delta: "+0.09", deltaGood: false),
+/// ],
+/// isComplete: $isComplete
+/// )
+/// ```
+struct ContrastPair: View {
+ let metrics: [ContrastMetric]
+ @Binding var isComplete: Bool
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var revealProgress: Double = 0.0 // 0→1 drives the fold wipe
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 0) {
+ // BEFORE column
+ beforeColumn
+ .frame(maxWidth: .infinity)
+
+ // Center fold divider + Δ chips
+ deltaColumn
+ .frame(width: 80)
+
+ // AFTER column — revealed by fold wipe
+ afterColumn
+ .frame(maxWidth: .infinity)
+ .opacity(isComplete ? 1.0 : 0.0)
+ .offset(x: isComplete ? 0 : 20)
+ .clipped()
+ }
+ .onChange(of: isComplete) { _, newValue in
+ if newValue {
+ if reduceMotion {
+ revealProgress = 1.0
+ } else {
+ withAnimation(.easeOut(duration: Theme.Motion.focus)) {
+ revealProgress = 1.0
+ }
+ }
+ } else {
+ revealProgress = 0.0
+ }
+ }
+ }
+
+ private var beforeColumn: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("BEFORE")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.md)
+ .padding(.top, Theme.Space.sm)
+ .padding(.bottom, Theme.Space.xs)
+
+ ForEach(metrics) { metric in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(metric.label)
+ .instrumentLabel()
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(metric.beforeValue)
+ .font(Theme.Fonts.wellValue)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ if !metric.beforeUnit.isEmpty {
+ Text(metric.beforeUnit)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ }
+ .padding(.horizontal, Theme.Space.md)
+ .padding(.vertical, Theme.Space.sm)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ .readoutWellSurface()
+ }
+
+ private var afterColumn: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Text("AFTER")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.md)
+ .padding(.top, Theme.Space.sm)
+ .padding(.bottom, Theme.Space.xs)
+
+ ForEach(metrics) { metric in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(metric.label)
+ .instrumentLabel()
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text(metric.afterValue)
+ .font(Theme.Fonts.wellValue)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .contentTransition(reduceMotion ? .identity : .numericText())
+ if !metric.afterUnit.isEmpty {
+ Text(metric.afterUnit)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ }
+ .padding(.horizontal, Theme.Space.md)
+ .padding(.vertical, Theme.Space.sm)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ .readoutWellSurface()
+ }
+
+ private var deltaColumn: some View {
+ VStack(alignment: .center, spacing: 0) {
+ // Header spacer to align with before/after columns
+ Text("──fold──▶")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim.opacity(isComplete ? 0.0 : 1.0))
+ .padding(.horizontal, Theme.Space.xs)
+ .padding(.top, Theme.Space.sm)
+ .padding(.bottom, Theme.Space.xs)
+ .animation(.easeOut(duration: Theme.Motion.focus), value: isComplete)
+
+ ForEach(metrics) { metric in
+ // Δ chip
+ let deltaColor = metric.deltaGood ? Theme.Palette.signal : Theme.Palette.amber
+ Text(metric.delta)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(deltaColor)
+ .monospacedDigit()
+ .padding(.horizontal, Theme.Space.xs)
+ .padding(.vertical, Theme.Space.sm)
+ .frame(height: 52) // approximate height of a metric row
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("ContrastPair") {
+ @Previewable @State var isComplete: Bool = false
+
+ return VStack(spacing: Theme.Space.lg) {
+ ContrastPair(
+ metrics: [
+ ContrastMetric(
+ label: "SIZE",
+ beforeValue: "1.61", afterValue: "0.41",
+ beforeUnit: "GB", afterUnit: "GB",
+ delta: "−74%", deltaGood: true
+ ),
+ ContrastMetric(
+ label: "BITS",
+ beforeValue: "16", afterValue: "4",
+ delta: "−75%", deltaGood: true
+ ),
+ ContrastMetric(
+ label: "PPL (est.)",
+ beforeValue: "15.86", afterValue: "15.95",
+ delta: "+0.09", deltaGood: false
+ ),
+ ],
+ isComplete: Binding(get: { isComplete }, set: { isComplete = $0 })
+ )
+ .padding(Theme.Space.lg)
+ .instrumentPanel()
+
+ Button(isComplete ? "Reset" : "Complete (fold wipe)") {
+ isComplete.toggle()
+ }
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.signal)
+ }
+ .padding(Theme.Space.xl)
+ .background(Theme.Palette.canvas)
+ .frame(width: 480)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/DataTable.swift b/apps/macos/Sources/LatticeStudio/Components/DataTable.swift
new file mode 100644
index 0000000000..8530f57657
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/DataTable.swift
@@ -0,0 +1,247 @@
+import SwiftUI
+
+// MARK: - Primitive 5: DataTable
+//
+// Generic, column-descriptor-driven table.
+//
+// Design spec:
+// - Rows at Theme.Space.rowHeight (28px) or rowHeightComfortable (32px)
+// - Tabular-mono right-aligned numerals
+// - 1px hairline row rules (no zebra)
+// - Sortable 11pt all-caps headers (tap header to sort; second tap reverses)
+// - Selected row: 2px teal left-border accent (NO fill flood)
+// - No external borders — table is "full-bleed" within its panel
+
+/// Alignment role for a column's cell content.
+enum ColumnAlignment {
+ case leading // text: labels, names
+ case trailing // numerals: sizes, counts, metrics (tabular-mono)
+ case center
+}
+
+/// Descriptor for a single table column.
+struct ColumnDef {
+ let id: String
+ let header: String
+ let alignment: ColumnAlignment
+ let minWidth: CGFloat
+ let value: (Row) -> String
+ let isNumeric: Bool
+ // When non-nil and it returns a status for a row, that cell renders a StatusBadge
+ // instead of plain text (used for the Runs STATUS column). Default nil ⇒ text cell,
+ // so existing columns (e.g. Models) are byte-identical.
+ let badge: ((Row) -> StatusBadge.Status?)?
+
+ init(
+ id: String,
+ header: String,
+ alignment: ColumnAlignment = .trailing,
+ minWidth: CGFloat = 80,
+ isNumeric: Bool = true,
+ badge: ((Row) -> StatusBadge.Status?)? = nil,
+ value: @escaping (Row) -> String
+ ) {
+ self.id = id
+ self.header = header
+ self.alignment = alignment
+ self.minWidth = minWidth
+ self.isNumeric = isNumeric
+ self.value = value
+ self.badge = badge
+ }
+}
+
+/// Generic instrument data table.
+///
+/// ```swift
+/// DataTable(
+/// rows: store.models,
+/// columns: [
+/// ColumnDef(id:"name", header:"MODEL", alignment:.leading, isNumeric:false) { $0.name },
+/// ColumnDef(id:"size", header:"SIZE GB") { String(format:"%.2f", Double($0.sizeBytes)/1e9) },
+/// ],
+/// selectedID: $selectedID,
+/// comfortable: store.rowComfortable
+/// )
+/// ```
+struct DataTable: View where Row.ID: Hashable {
+ let rows: [Row]
+ let columns: [ColumnDef]
+ @Binding var selectedID: Row.ID?
+ var comfortable: Bool = false
+
+ // Sort state
+ @State private var sortColumnID: String? = nil
+ @State private var sortAscending: Bool = true
+
+ // Hover state — a single raised-surface wash on the hovered row
+ @State private var hoveredID: Row.ID? = nil
+
+ private var rowHeight: CGFloat {
+ comfortable ? Theme.Space.rowHeightComfortable : Theme.Space.rowHeight
+ }
+
+ private var sortedRows: [Row] {
+ guard let colID = sortColumnID,
+ let col = columns.first(where: { $0.id == colID })
+ else { return rows }
+
+ return rows.sorted { a, b in
+ let va = col.value(a)
+ let vb = col.value(b)
+ // Numeric sort when column is numeric
+ if col.isNumeric, let da = Double(va), let db = Double(vb) {
+ return sortAscending ? da < db : da > db
+ }
+ return sortAscending ? va < vb : va > vb
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header row
+ headerRow
+
+ // Data rows
+ ForEach(Array(sortedRows.enumerated()), id: \.element.id) { _, row in
+ dataRow(row: row)
+ }
+ }
+ }
+
+ private var headerRow: some View {
+ HStack(spacing: 0) {
+ ForEach(columns, id: \.id) { col in
+ Button {
+ if sortColumnID == col.id {
+ sortAscending.toggle()
+ } else {
+ sortColumnID = col.id
+ sortAscending = true
+ }
+ } label: {
+ HStack(spacing: 2) {
+ Text(col.header)
+ .instrumentLabel()
+ .frame(maxWidth: .infinity, alignment: frameAlignment(col.alignment))
+
+ // Sort indicator
+ if sortColumnID == col.id {
+ Text(sortAscending ? "↑" : "↓")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.signal)
+ }
+ }
+ .padding(.horizontal, Theme.Space.sm)
+ .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment))
+ }
+ .buttonStyle(.plain)
+ .frame(minWidth: col.minWidth, maxWidth: .infinity)
+ }
+ }
+ .frame(height: rowHeight)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+
+ private func dataRow(row: Row) -> some View {
+ let isSelected = selectedID == row.id
+ let isHovered = !isSelected && hoveredID == row.id
+
+ return HStack(spacing: 0) {
+ // 2pt signal leading marker on the selected row.
+ Rectangle()
+ .fill(isSelected ? Theme.Palette.signal : .clear)
+ .frame(width: 2)
+
+ ForEach(columns, id: \.id) { col in
+ if let badge = col.badge, let status = badge(row) {
+ StatusBadge(status)
+ .padding(.horizontal, Theme.Space.sm)
+ .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment))
+ } else {
+ Text(col.value(row))
+ .font(col.isNumeric ? Theme.Fonts.cell : Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .padding(.horizontal, Theme.Space.sm)
+ .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment))
+ }
+ }
+ }
+ .frame(height: rowHeight)
+ // Selected: selectionFill (teal @12%) under the 2pt marker. Hover: one raised wash.
+ .background(rowFill(isSelected: isSelected, isHovered: isHovered))
+ .contentShape(Rectangle())
+ .onTapGesture {
+ selectedID = isSelected ? nil : row.id
+ }
+ .onHover { inside in
+ if inside { hoveredID = row.id }
+ else if hoveredID == row.id { hoveredID = nil }
+ }
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+
+ @ViewBuilder
+ private func rowFill(isSelected: Bool, isHovered: Bool) -> some View {
+ if isSelected {
+ Theme.Palette.selectionFill
+ } else if isHovered {
+ Theme.Palette.surfaceHover
+ } else {
+ Color.clear
+ }
+ }
+
+ private func frameAlignment(_ col: ColumnAlignment) -> Alignment {
+ switch col {
+ case .leading: .leading
+ case .trailing: .trailing
+ case .center: .center
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("DataTable") {
+ @Previewable @State var selected: String? = "1"
+
+ struct SampleRow: Identifiable {
+ let id: String
+ let name: String
+ let params: String
+ let format: String
+ let sizeGB: String
+ let files: String
+ }
+
+ let rows: [SampleRow] = [
+ SampleRow(id: "1", name: "qwen3.5-0.8b", params: "0.8B", format: "BF16", sizeGB: "1.61", files: "4"),
+ SampleRow(id: "2", name: "qwen3.5-0.8b-q4", params: "0.8B", format: "Q4", sizeGB: "0.41", files: "3"),
+ SampleRow(id: "3", name: "qwen3.5-0.8b-quarot", params: "0.8B", format: "QuaRot Q4", sizeGB: "0.40", files: "5"),
+ SampleRow(id: "4", name: "qwen3.5-2b", params: "2B", format: "BF16", sizeGB: "4.07", files: "4"),
+ ]
+
+ return DataTable(
+ rows: rows,
+ columns: [
+ ColumnDef(id: "name", header: "MODEL", alignment: .leading, minWidth: 180, isNumeric: false) { $0.name },
+ ColumnDef(id: "params", header: "PARAMS", minWidth: 60) { $0.params },
+ ColumnDef(id: "format", header: "FORMAT", alignment: .leading, minWidth: 80, isNumeric: false) { $0.format },
+ ColumnDef(id: "size", header: "SIZE GB", minWidth: 70) { $0.sizeGB },
+ ColumnDef(id: "files", header: "FILES", minWidth: 50) { $0.files },
+ ],
+ selectedID: Binding(get: { selected }, set: { selected = $0 }),
+ comfortable: false
+ )
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 560, height: 200)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift b/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift
new file mode 100644
index 0000000000..5de3d4f277
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+
+// MARK: - EmptyStateView
+//
+// Graphite Signal Lab empty state — Section D "Empty states".
+//
+// Design laws (spec §D):
+// - NO enclosing border around the whole empty state.
+// - Max width 360pt (Theme.Space.emptyStateMaxWidth).
+// - Symbol: 28pt, textTertiary.
+// - Title: 15pt semibold (Theme.Fonts.display(15,.semibold)), ink.
+// - Body: 12pt secondary (Theme.Fonts.caption / textSecondary), 18pt line target.
+// - Optional primary action button 16pt below body (LatticePrimaryButtonStyle).
+// - Caller is responsible for placing this view at ~38–42% usable height.
+// - `ContentUnavailableView` is usable but limited; custom VStack is preferred (spec note).
+
+/// A screen-specific empty state: symbol, title, body, optional primary action.
+///
+/// ```swift
+/// EmptyStateView(
+/// systemImage: "clock.arrow.circlepath",
+/// title: "No runs yet",
+/// message: "Training, quantization, and model tests are recorded here."
+/// )
+///
+/// // With action:
+/// EmptyStateView(
+/// systemImage: "tablecells",
+/// title: "No dataset scanned",
+/// message: "Choose a directory containing JSONL, JSON, or supported dataset files.",
+/// actionLabel: "Choose Directory"
+/// ) {
+/// openPanel()
+/// }
+/// ```
+struct EmptyStateView: View {
+ let systemImage: String
+ let title: String
+ let message: String
+ var actionLabel: String? = nil
+ var action: (() -> Void)? = nil
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Symbol — 28pt, textTertiary
+ Image(systemName: systemImage)
+ .font(.system(size: 28, weight: .regular))
+ .foregroundStyle(Theme.Palette.textTertiary)
+
+ // Title — 15pt semibold, ink
+ Text(title)
+ .font(Theme.Fonts.display(15, .semibold))
+ .foregroundStyle(Theme.Palette.ink)
+ .multilineTextAlignment(.center)
+ .padding(.top, Theme.Space.space3) // 12pt
+
+ // Body — 12pt regular, secondary, ~18pt line target via lineSpacing
+ Text(message)
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ .multilineTextAlignment(.center)
+ .lineSpacing(4) // 11pt font + 4pt spacing ≈ 15pt leading; close to 18pt target
+ .padding(.top, Theme.Space.space2) // 8pt
+
+ // Optional primary action — 16pt separation below body
+ if let label = actionLabel, let handler = action {
+ Button(label, action: handler)
+ .buttonStyle(LatticePrimaryButtonStyle())
+ .padding(.top, Theme.Space.space4) // 16pt
+ }
+ }
+ .frame(maxWidth: Theme.Space.emptyStateMaxWidth)
+ }
+}
+
+// MARK: - Previews
+
+#Preview("EmptyStateView") {
+ VStack(spacing: Theme.Space.xl) {
+ // No action
+ EmptyStateView(
+ systemImage: "clock.arrow.circlepath",
+ title: "No runs yet",
+ message: "Training, quantization, and model tests are recorded here."
+ )
+
+ Divider()
+
+ // With primary action
+ EmptyStateView(
+ systemImage: "tablecells",
+ title: "No dataset scanned",
+ message: "Choose a directory containing JSONL, JSON, or supported dataset files.",
+ actionLabel: "Choose Directory"
+ ) {
+ // action placeholder
+ }
+
+ Divider()
+
+ // Chat empty state
+ EmptyStateView(
+ systemImage: "text.bubble",
+ title: "Model ready",
+ message: "Ask a question or run an A/B adapter comparison.",
+ actionLabel: "Start Chat"
+ ) {}
+ }
+ .padding(Theme.Space.xl)
+ .frame(width: 600, height: 700)
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift b/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift
new file mode 100644
index 0000000000..c43703bb3d
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift
@@ -0,0 +1,184 @@
+import SwiftUI
+
+// MARK: - Primitive 11: FaderToggle
+//
+// The A/B adapter selector styled as a console fader.
+// This is the ONE element where Theme.Motion.faderSpring is allowed.
+//
+// Design spec:
+// - Console fader metaphor: a slider-like control, left=BASE, right=BASE+LoRA
+// - Spring physics: Animation.spring(response: 0.32, dampingFraction: 0.85)
+// - Hairline track, teal thumb/knob when on B side
+// - Labels: A label (left) and B label (right)
+// - Current selection text below
+// - Respects accessibilityReduceMotion (spring → instant snap)
+
+/// The A/B adapter console fader. The one place spring physics is permitted.
+///
+/// ```swift
+/// FaderToggle(
+/// labelA: "BASE",
+/// labelB: "BASE + LoRA r8",
+/// isOnB: $isAdapterActive
+/// )
+/// ```
+struct FaderToggle: View {
+ let labelA: String
+ let labelB: String
+ @Binding var isOnB: Bool
+
+ var onSwap: (() -> Void)? = nil
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var isDragging: Bool = false
+ @State private var dragOffset: CGFloat = 0
+
+ // Thumb size
+ private let thumbW: CGFloat = 28
+ private let thumbH: CGFloat = 48
+ private let trackH: CGFloat = 4
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Theme.Space.sm) {
+ // Track + thumb
+ GeometryReader { geo in
+ let trackWidth = geo.size.width
+ let travelRange = trackWidth - thumbW
+ let targetX: CGFloat = isOnB ? travelRange : 0
+
+ ZStack(alignment: .leading) {
+ // Track
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Theme.Palette.wellSink)
+ .frame(height: trackH)
+ .overlay(
+ RoundedRectangle(cornerRadius: 2)
+ .strokeBorder(Theme.Palette.hairline, lineWidth: 1)
+ )
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, thumbW / 2)
+
+ // Teal active fill (left portion for B side)
+ if isOnB {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Theme.Palette.signal.opacity(0.4))
+ .frame(width: targetX + thumbW / 2, height: trackH)
+ .padding(.leading, thumbW / 2)
+ }
+
+ // Fader thumb (knob)
+ ZStack {
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(isOnB ? Theme.Palette.signal : Theme.Palette.panel)
+ .overlay(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .strokeBorder(
+ isOnB ? Theme.Palette.signal : Theme.Palette.hairline,
+ lineWidth: 1
+ )
+ )
+ .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
+
+ // Grip lines on the thumb
+ VStack(spacing: 3) {
+ ForEach(0..<3, id: \.self) { _ in
+ Theme.Palette.hairline
+ .frame(width: 14, height: 1)
+ }
+ }
+ }
+ .frame(width: thumbW, height: thumbH)
+ .offset(x: isDragging ? dragOffset : targetX)
+ .animation(
+ isDragging
+ ? nil
+ : (reduceMotion ? nil : Theme.Motion.faderSpring),
+ value: isOnB
+ )
+ .gesture(
+ DragGesture()
+ .onChanged { val in
+ isDragging = true
+ let clamped = max(0, min(travelRange, targetX + val.translation.width))
+ dragOffset = clamped
+ }
+ .onEnded { val in
+ isDragging = false
+ let finalX = max(0, min(travelRange, targetX + val.translation.width))
+ let shouldBeB = finalX > travelRange / 2
+ if shouldBeB != isOnB {
+ isOnB = shouldBeB
+ onSwap?()
+ }
+ }
+ )
+ .onTapGesture {
+ withAnimation(reduceMotion ? nil : Theme.Motion.faderSpring) {
+ isOnB.toggle()
+ }
+ onSwap?()
+ }
+ }
+ .frame(height: thumbH)
+ }
+ .frame(height: thumbH)
+
+ // A/B labels + status line
+ HStack {
+ Text(labelA)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(isOnB ? Theme.Palette.inkDim : Theme.Palette.ink)
+
+ Spacer()
+
+ // Selection stamp — shown when B side is active.
+ // The fader sets adapterPath for the NEXT generation; there is no hot-swap.
+ if isOnB {
+ Text("applies next send")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.signal)
+ }
+
+ Spacer()
+
+ Text(labelB)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(isOnB ? Theme.Palette.ink : Theme.Palette.inkDim)
+ }
+ .frame(height: Theme.Space.rowHeight)
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.md)
+ }
+}
+
+// MARK: - Previews
+
+#Preview("FaderToggle") {
+ @Previewable @State var isOnB: Bool = false
+
+ return VStack(spacing: Theme.Space.xl) {
+ Text("HOT-SWAP FADER")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+
+ FaderToggle(
+ labelA: "BASE",
+ labelB: "BASE + LoRA r8",
+ isOnB: Binding(get: { isOnB }, set: { isOnB = $0 }),
+ onSwap: {
+ // adapter swap callback
+ }
+ )
+
+ Text(isOnB ? "Active: BASE + LoRA r8 · adapter applies next send" : "Active: BASE")
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.lg)
+ }
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 400)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/Field.swift b/apps/macos/Sources/LatticeStudio/Components/Field.swift
new file mode 100644
index 0000000000..7633a15381
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/Field.swift
@@ -0,0 +1,196 @@
+import SwiftUI
+
+// MARK: - Field
+//
+// Graphite Signal Lab text and numeric field vocabulary — Section D "Text fields and numeric fields".
+//
+// Design laws (spec §D):
+// - Height 28–30pt, horizontal inset 8pt, radius 6pt (Theme.Radius.control).
+// - Fill: Theme.Palette.wellSink (surfaceInset / recessed) or surfaceRaised.
+// - Border: 1px borderStandard (white 8%).
+// - Placeholder: textTertiary.
+// - Focus: border accent 70% + subtle 2pt outer ring via .focusRing palette token.
+// - Error: border error 70% + 11pt caption beneath.
+// - Numeric variant: SF Mono (Fonts.tableNumeric), right-aligned, default width ~80.
+//
+// Implementation note (spec): "A plain field plus @FocusState when exact custom styling is required."
+// We wrap TextField and apply all styling externally; no custom text engine.
+
+// MARK: - LatticeField (text)
+
+/// A styled single-line text field.
+///
+/// ```swift
+/// @State private var name = ""
+/// LatticeField("Model name", text: $name)
+///
+/// // With error:
+/// LatticeField("Path", text: $path, errorMessage: pathError)
+/// ```
+struct LatticeField: View {
+ let prompt: String
+ @Binding var text: String
+ var errorMessage: String? = nil
+ var height: CGFloat = 30
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ ZStack {
+ // Fill surface
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .fill(Theme.Palette.wellSink)
+
+ // Border: focus ring → accent 70%, error → error 70%, default → white 8%
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .strokeBorder(borderColor, lineWidth: isFocused ? 1.5 : 1)
+
+ // Focus outer ring (2pt, accent 55%)
+ if isFocused && errorMessage == nil {
+ RoundedRectangle(cornerRadius: Theme.Radius.control + 2, style: .continuous)
+ .strokeBorder(Theme.Palette.focusRing, lineWidth: 2)
+ .padding(-3)
+ }
+
+ TextField(prompt, text: $text)
+ .textFieldStyle(.plain)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ .focused($isFocused)
+ .padding(.horizontal, 8)
+ }
+ .frame(height: height)
+ .animation(.easeOut(duration: Theme.Motion.hover), value: isFocused)
+
+ // Error caption
+ if let msg = errorMessage {
+ Text(msg)
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.error)
+ }
+ }
+ }
+
+ private var borderColor: Color {
+ if let _ = errorMessage { return Theme.Palette.error.opacity(0.70) }
+ if isFocused { return Theme.Palette.accent.opacity(0.70) }
+ return Theme.Palette.borderStandard
+ }
+}
+
+// MARK: - LatticeNumericField
+
+/// A styled numeric text field: SF Mono, right-aligned, fixed width.
+///
+/// ```swift
+/// @State private var steps = "500"
+/// LatticeNumericField("", text: $steps)
+///
+/// // Wider (e.g., for seeds):
+/// LatticeNumericField("Seed", text: $seed, width: 120)
+/// ```
+struct LatticeNumericField: View {
+ let prompt: String
+ @Binding var text: String
+ var width: CGFloat = 80
+ var errorMessage: String? = nil
+ var height: CGFloat = 30
+
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ VStack(alignment: .trailing, spacing: 3) {
+ ZStack {
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .fill(Theme.Palette.wellSink)
+
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .strokeBorder(borderColor, lineWidth: isFocused ? 1.5 : 1)
+
+ if isFocused && errorMessage == nil {
+ RoundedRectangle(cornerRadius: Theme.Radius.control + 2, style: .continuous)
+ .strokeBorder(Theme.Palette.focusRing, lineWidth: 2)
+ .padding(-3)
+ }
+
+ TextField(prompt, text: $text)
+ .textFieldStyle(.plain)
+ .font(Theme.Fonts.tableNumeric)
+ .monospacedDigit()
+ .foregroundStyle(Theme.Palette.ink)
+ .multilineTextAlignment(.trailing)
+ .focused($isFocused)
+ .padding(.horizontal, 8)
+ }
+ .frame(width: width, height: height)
+ .animation(.easeOut(duration: Theme.Motion.hover), value: isFocused)
+
+ if let msg = errorMessage {
+ Text(msg)
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.error)
+ }
+ }
+ }
+
+ private var borderColor: Color {
+ if let _ = errorMessage { return Theme.Palette.error.opacity(0.70) }
+ if isFocused { return Theme.Palette.accent.opacity(0.70) }
+ return Theme.Palette.borderStandard
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Field") {
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ Text("TEXT FIELD")
+ .instrumentLabel()
+
+ LatticeField(prompt: "Model name or path", text: .constant(""))
+
+ LatticeField(prompt: "Dataset path", text: .constant("/Users/lion/data/train.jsonl"))
+
+ LatticeField(prompt: "With error", text: .constant("bad-path"), errorMessage: "File not found")
+
+ Divider()
+ .padding(.vertical, Theme.Space.xs)
+
+ Text("NUMERIC FIELD")
+ .instrumentLabel()
+
+ HStack(alignment: .top, spacing: Theme.Space.sm) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Steps")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ LatticeNumericField(prompt: "500", text: .constant("500"))
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Rank")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ LatticeNumericField(prompt: "8", text: .constant("8"), width: 60)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("LR")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ LatticeNumericField(prompt: "5e-4", text: .constant("5e-4"), width: 88)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Seed (error)")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ LatticeNumericField(prompt: "", text: .constant("abc"), width: 88, errorMessage: "Must be integer")
+ }
+ }
+ }
+ .padding(Theme.Space.lg)
+ .frame(width: 480)
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/GatePill.swift b/apps/macos/Sources/LatticeStudio/Components/GatePill.swift
new file mode 100644
index 0000000000..d3298525ca
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/GatePill.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+
+// MARK: - Primitive 6: GatePill
+//
+// Status capsule encoding the verdict of any measurable action.
+//
+// States:
+// PASS — teal fill
+// WARN — amber fill
+// FAIL — crimson fill
+// RUN — animated teal pulse (running job)
+//
+// Design spec:
+// - Corner radius: Theme.Radius.pill = 6px
+// - Mono text (Theme.Fonts.cell, 11pt)
+// - Background fills use signal/amber/crimson
+// - RUN state: repeating opacity animation (pulse)
+// - Respects accessibilityReduceMotion
+
+/// The verdict of a measurable gate.
+enum GateStatus {
+ case pass
+ case warn
+ case fail
+ case run
+
+ var label: String {
+ switch self {
+ case .pass: "PASS"
+ case .warn: "WARN"
+ case .fail: "FAIL"
+ case .run: "RUN"
+ }
+ }
+
+ var fillColor: Color {
+ switch self {
+ case .pass: Theme.Palette.signal
+ case .warn: Theme.Palette.amber
+ case .fail: Theme.Palette.crimson
+ case .run: Theme.Palette.signal
+ }
+ }
+
+ // High-contrast text: canvas (dark/light) on the status fill
+ var textColor: Color { Theme.Palette.canvas }
+}
+
+/// A PASS / WARN / FAIL / RUN status capsule.
+///
+/// ```swift
+/// GatePill(.pass)
+/// GatePill(.warn, label: "ΔPPL +0.09")
+/// GatePill(.run, label: "step 420/1000")
+/// GatePill(.fail, label: "NaN loss")
+/// ```
+struct GatePill: View {
+ let status: GateStatus
+ let label: String
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var pulseOpacity: Double = 1.0
+
+ init(_ status: GateStatus, label: String? = nil) {
+ self.status = status
+ self.label = label ?? status.label
+ }
+
+ var body: some View {
+ HStack(spacing: 4) {
+ // Pulse indicator dot for RUN state
+ if status == .run {
+ Circle()
+ .fill(status.textColor)
+ .frame(width: 5, height: 5)
+ .opacity(pulseOpacity)
+ .onAppear {
+ guard !reduceMotion else { return }
+ withAnimation(
+ .easeInOut(duration: 0.7).repeatForever(autoreverses: true)
+ ) {
+ pulseOpacity = 0.3
+ }
+ }
+ }
+
+ Text(label)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(status.textColor)
+ .monospacedDigit()
+ .textCase(.uppercase)
+ }
+ .padding(.horizontal, Theme.Space.sm)
+ .padding(.vertical, 3)
+ .background(
+ status == .run
+ ? status.fillColor.opacity(pulseOpacity * 0.7 + 0.3)
+ : status.fillColor
+ )
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.pill, style: .continuous))
+ }
+}
+
+// MARK: - Previews
+
+#Preview("GatePill") {
+ VStack(spacing: Theme.Space.sm) {
+ HStack(spacing: Theme.Space.sm) {
+ GatePill(.pass)
+ GatePill(.warn)
+ GatePill(.fail)
+ GatePill(.run)
+ }
+
+ HStack(spacing: Theme.Space.sm) {
+ GatePill(.pass, label: "Q4 ok 405MB −74%")
+ GatePill(.warn, label: "ΔPPL +0.09")
+ GatePill(.fail, label: "NaN loss @ step 840")
+ GatePill(.run, label: "step 420/1000")
+ }
+
+ HStack(spacing: Theme.Space.sm) {
+ GatePill(.pass, label: "eval PASS · best val 1.94")
+ GatePill(.warn, label: "grad spike · norm 4.7")
+ }
+ }
+ .padding(Theme.Space.lg)
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift b/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift
new file mode 100644
index 0000000000..7d0b4db056
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift
@@ -0,0 +1,116 @@
+import SwiftUI
+
+// MARK: - Primitive 3: HeroNumber
+//
+// The visual anchor of TRAIN and QUANTIZE screens.
+// - Big tabular-mono value (Theme.Fonts.hero = 56pt Bold by default)
+// - Size param: .hero (56pt) or .heroAlt (34pt) or .heroMinor (21pt)
+// - Small mono unit below/beside the value
+// - 1px teal under-rule beneath the number
+// - Per-digit tick via .contentTransition(.numericText())
+// - .monospacedDigit() always
+
+/// Preset sizes for HeroNumber, matching the modular scale.
+enum HeroSize {
+ case hero // 56pt Bold — the loss / compression / PPL headline
+ case heroAlt // 34pt Bold — secondary hero
+ case heroMinor // 21pt Semibold — tertiary large numeral
+
+ var font: Font {
+ switch self {
+ case .hero: Theme.Fonts.hero
+ case .heroAlt: Theme.Fonts.heroAlt
+ case .heroMinor: Theme.Fonts.heroMinor
+ }
+ }
+}
+
+/// A big tabular-mono numeral with a unit label and a 1px teal under-rule.
+/// Ticks per-digit via `.contentTransition(.numericText())`.
+///
+/// ```swift
+/// HeroNumber(value: "0.6121", unit: "LOSS")
+/// HeroNumber(value: "3.97×", unit: "SMALLER", size: .heroAlt)
+/// HeroNumber(value: "405", unit: "MB", size: .heroAlt, unitPosition: .trailing)
+/// ```
+struct HeroNumber: View {
+ let value: String
+ let unit: String
+ var size: HeroSize = .hero
+ var unitPosition: UnitPosition = .below
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ enum UnitPosition {
+ case below // unit appears below the numeral (default)
+ case trailing // unit appears to the right at baseline
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if unitPosition == .trailing {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ numeralText
+ unitText
+ }
+ } else {
+ numeralText
+ if !unit.isEmpty {
+ unitText
+ .padding(.top, 2)
+ }
+ }
+
+ // 1px teal under-rule
+ Theme.Palette.signal
+ .frame(height: 1)
+ .padding(.top, 4)
+ }
+ }
+
+ private var numeralText: some View {
+ Text(value)
+ .font(size.font)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .contentTransition(
+ reduceMotion ? .identity : .numericText()
+ )
+ .animation(
+ reduceMotion
+ ? nil
+ : .easeOut(duration: Theme.Motion.tick),
+ value: value
+ )
+ }
+
+ private var unitText: some View {
+ Text(unit)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .textCase(.uppercase)
+ .tracking(Theme.Space.labelTracking)
+ }
+}
+
+// MARK: - Previews
+
+#Preview("HeroNumber") {
+ VStack(alignment: .leading, spacing: Theme.Space.xl) {
+ // 56pt hero — primary loss display
+ HeroNumber(value: "0.6121", unit: "LOSS")
+
+ // 34pt heroAlt — compression ratio
+ HeroNumber(value: "3.97×", unit: "SMALLER", size: .heroAlt)
+
+ // 21pt heroMinor — secondary stat
+ HeroNumber(value: "405", unit: "MB", size: .heroMinor, unitPosition: .trailing)
+
+ // Trailing unit
+ HeroNumber(value: "15.95", unit: "PPL", size: .heroAlt, unitPosition: .trailing)
+ }
+ .padding(Theme.Space.xl)
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 320)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift b/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift
new file mode 100644
index 0000000000..67684d585e
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift
@@ -0,0 +1,134 @@
+import SwiftUI
+
+// MARK: - InspectorShell
+//
+// Graphite Signal Lab — reusable edge-attached configuration-inspector container.
+// Generalizes ChatScreen's `ChatInspector` into a standalone shell.
+//
+// Design laws:
+// - Edge-attached (not a rounded card): fills its column with the panel surface.
+// The system .inspector presentation supplies the single left divider, so this
+// shell draws no rule of its own (avoids a doubled hairline).
+// - Background: Theme.Palette.panel (opaque, no glass).
+// - Internal padding: Theme.Space.lg (16pt) on all sides.
+// - Optional title as an uppercase section label, matching ChatInspector.
+
+/// A reusable edge-attached configuration-inspector container.
+///
+/// Drop inside a screen's `.inspector { }` closure or any fixed-width column:
+///
+/// ```swift
+/// InspectorShell(title: "Settings") {
+/// settingsRows
+/// }
+/// ```
+///
+/// Without a title:
+/// ```swift
+/// InspectorShell {
+/// contentRows
+/// }
+/// ```
+struct InspectorShell: View {
+ let title: String?
+ private let content: Content
+
+ init(title: String? = nil, @ViewBuilder content: () -> Content) {
+ self.title = title
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Optional title row
+ if let title {
+ Text(title)
+ .font(Theme.Fonts.sectionLabel)
+ .tracking(0.5)
+ .textCase(.uppercase)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ .padding(.bottom, Theme.Space.lg)
+ }
+
+ content
+ }
+ .padding(Theme.Space.lg)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(Theme.Palette.panel)
+ }
+}
+
+// MARK: - Previews
+
+#Preview("InspectorShell") {
+ HStack(spacing: 0) {
+ // Simulate a main content area
+ Rectangle()
+ .fill(Theme.Palette.canvas)
+ .frame(maxWidth: .infinity)
+ .overlay {
+ Text("Main content area")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+
+ // Inspector with title
+ InspectorShell(title: "Settings") {
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ HStack {
+ Text("Adapter")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ Spacer()
+ Text("none")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+
+ Divider()
+
+ HStack {
+ Text("Temperature")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ Spacer()
+ Text("0.7")
+ .font(Theme.Fonts.tableNumeric)
+ .monospacedDigit()
+ .foregroundStyle(Theme.Palette.ink)
+ }
+
+ HStack {
+ Text("Max tokens")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ Spacer()
+ Text("256")
+ .font(Theme.Fonts.tableNumeric)
+ .monospacedDigit()
+ .foregroundStyle(Theme.Palette.ink)
+ }
+ }
+ }
+ .frame(width: 280)
+ }
+ .frame(width: 600, height: 400)
+ .background(Theme.Palette.canvas)
+}
+
+#Preview("InspectorShell — no title") {
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(Theme.Palette.canvas)
+ .frame(maxWidth: .infinity)
+
+ InspectorShell {
+ Text("Content without title header")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ }
+ .frame(width: 280)
+ }
+ .frame(width: 600, height: 300)
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift b/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift
new file mode 100644
index 0000000000..90976347fb
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift
@@ -0,0 +1,89 @@
+import SwiftUI
+
+// MARK: - Primitive 7: KeyCapChip
+//
+// A tiny 1px-outlined ⌘-cap on actionable elements so the keyboard map self-documents.
+//
+// Design:
+// - 1px hairline border (no fill — transparent background)
+// - Rounded rect corners: Theme.Radius.control = 6px
+// - Mono text: Theme.Fonts.cell (11pt)
+// - Foreground: Theme.Palette.inkDim (dimmed — decorative, not primary)
+// - Can show a modifier glyph (⌘, ⌥, ⇧, ^) + key, or just the key
+
+/// A tiny outlined keyboard shortcut chip for self-documenting UI.
+///
+/// ```swift
+/// KeyCapChip("⌘1")
+/// KeyCapChip("⌘K")
+/// KeyCapChip("⌘↵")
+/// KeyCapChip("⌥A")
+/// ```
+struct KeyCapChip: View {
+ let keyText: String
+
+ init(_ keyText: String) {
+ self.keyText = keyText
+ }
+
+ var body: some View {
+ Text(keyText)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .monospacedDigit()
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(.clear)
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .strokeBorder(Theme.Palette.hairline, lineWidth: 1)
+ )
+ }
+}
+
+// MARK: - Previews
+
+#Preview("KeyCapChip") {
+ VStack(alignment: .leading, spacing: Theme.Space.sm) {
+ HStack(spacing: Theme.Space.sm) {
+ Text("01 MODELS")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌘1")
+ }
+ HStack(spacing: Theme.Space.sm) {
+ Text("02 TRAIN")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌘2")
+ }
+ HStack(spacing: Theme.Space.sm) {
+ Text("Command bar")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌘K")
+ }
+ HStack(spacing: Theme.Space.sm) {
+ Text("Start run")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌘↵")
+ }
+ HStack(spacing: Theme.Space.sm) {
+ Text("Hot-swap adapter")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌥A")
+ }
+ HStack(spacing: Theme.Space.sm) {
+ Text("Inspector")
+ .instrumentLabel()
+ Spacer()
+ KeyCapChip("⌘\\")
+ }
+ }
+ .padding(Theme.Space.lg)
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 260)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/MassBars.swift b/apps/macos/Sources/LatticeStudio/Components/MassBars.swift
new file mode 100644
index 0000000000..1195984fdd
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/MassBars.swift
@@ -0,0 +1,181 @@
+import SwiftUI
+
+// MARK: - Primitive 10: MassBars
+//
+// Two horizontal bars to TRUE scale (before = inkDim, after = teal).
+// The compression ratio counts up into a HeroNumber while bars animate to length.
+//
+// Design spec:
+// - fp16 "before" bar: Theme.Palette.inkDim fill
+// - Q4 "after" bar: Theme.Palette.signal fill
+// - Both bars share the same MAX width (true scale: wider = larger size)
+// - Ratio animates as a HeroNumber while bars grow
+// - 180ms ease-out for bar extension; respect accessibilityReduceMotion
+
+/// Two true-scale horizontal mass bars with an animated compression ratio hero.
+///
+/// ```swift
+/// MassBars(
+/// beforeLabel: "fp16",
+/// beforeMB: 1648,
+/// afterLabel: "Q4",
+/// afterMB: 405,
+/// ratioLabel: "SMALLER"
+/// )
+/// ```
+struct MassBars: View {
+ let beforeLabel: String
+ let beforeMB: Double
+ let afterLabel: String
+ let afterMB: Double
+ var ratioLabel: String = "SMALLER"
+ var animated: Bool = true
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var animationProgress: Double = 0.0 // 0→1
+
+ private var ratio: Double {
+ beforeMB > 0 ? beforeMB / afterMB : 1.0
+ }
+
+ private var displayedRatio: Double {
+ 1.0 + (ratio - 1.0) * animationProgress
+ }
+
+ private var afterFraction: Double {
+ beforeMB > 0 ? afterMB / beforeMB : 1.0
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Theme.Space.sm) {
+ // Hero ratio number (counts up as bars animate)
+ HeroNumber(
+ value: String(format: "%.2f×", displayedRatio),
+ unit: ratioLabel,
+ size: .heroAlt,
+ unitPosition: .trailing
+ )
+ .padding(.bottom, Theme.Space.xs)
+
+ // BEFORE bar (full width = reference)
+ barRow(
+ label: beforeLabel,
+ size: beforeMB,
+ fraction: 1.0,
+ color: Theme.Palette.inkDim,
+ progress: animationProgress
+ )
+
+ // AFTER bar (proportionally narrower)
+ barRow(
+ label: afterLabel,
+ size: afterMB,
+ fraction: afterFraction,
+ color: Theme.Palette.signal,
+ progress: animationProgress
+ )
+ }
+ .onAppear {
+ if animated {
+ if reduceMotion {
+ animationProgress = 1.0
+ } else {
+ withAnimation(.easeOut(duration: Theme.Motion.focus)) {
+ animationProgress = 1.0
+ }
+ }
+ } else {
+ animationProgress = 1.0
+ }
+ }
+ .onChange(of: beforeMB) { _, _ in resetAndAnimate() }
+ .onChange(of: afterMB) { _, _ in resetAndAnimate() }
+ }
+
+ private func resetAndAnimate() {
+ animationProgress = 0.0
+ if reduceMotion {
+ animationProgress = 1.0
+ } else {
+ withAnimation(.easeOut(duration: Theme.Motion.focus)) {
+ animationProgress = 1.0
+ }
+ }
+ }
+
+ private func barRow(
+ label: String,
+ size: Double,
+ fraction: Double,
+ color: Color,
+ progress: Double
+ ) -> some View {
+ HStack(spacing: Theme.Space.sm) {
+ // Label (fixed width)
+ Text(label)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .frame(width: 40, alignment: .trailing)
+
+ // Bar track
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ // Track background
+ Rectangle()
+ .fill(Theme.Palette.wellSink)
+ .frame(maxWidth: .infinity)
+
+ // Filled portion — true scale, animated
+ Rectangle()
+ .fill(color)
+ .frame(width: geo.size.width * fraction * progress)
+ }
+ }
+ .frame(height: 12)
+ .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
+
+ // Size label
+ Text(formattedMB(size))
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .monospacedDigit()
+ .frame(width: 64, alignment: .trailing)
+ }
+ }
+
+ private func formattedMB(_ mb: Double) -> String {
+ if mb >= 1024 {
+ String(format: "%.2f GB", mb / 1024.0)
+ } else {
+ String(format: "%.0f MB", mb)
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("MassBars") {
+ VStack(spacing: Theme.Space.xl) {
+ Text("MASS BARS — TRUE SCALE")
+ .instrumentLabel()
+
+ MassBars(
+ beforeLabel: "fp16",
+ beforeMB: 1648,
+ afterLabel: "Q4",
+ afterMB: 415
+ )
+
+ MassBars(
+ beforeLabel: "fp16",
+ beforeMB: 4096,
+ afterLabel: "Q4",
+ afterMB: 1024,
+ ratioLabel: "COMPRESSED"
+ )
+ }
+ .padding(Theme.Space.xl)
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 440)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift b/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift
new file mode 100644
index 0000000000..f1fcbce033
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift
@@ -0,0 +1,135 @@
+import SwiftUI
+
+// MARK: - Primitive 1: OpaquePanel
+//
+// Graphite Signal Lab design laws:
+// - Dark graphite surfaces (#121820 panel, #080B0F wellSink).
+// - 10pt continuous-radius cards — panels are CARDED, not ruled (0px era is over).
+// - White-opacity hairlines: borderStandard = white 8%, no static drop shadows.
+// - One cyan accent (#48D8C4) for signal / CTA — never decorative.
+// - Numbers never touch glass — wells stay opaque.
+//
+// Usage:
+// SomeView()
+// .instrumentPanel()
+//
+// // or wrap content:
+// OpaquePanel { ... }
+
+/// An opaque, hairline-bordered instrument panel container.
+/// Corner radius = Theme.Radius.panel (10pt continuous). No glass permitted inside.
+struct OpaquePanel: View {
+ private let content: Content
+
+ init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ var body: some View {
+ content
+ .background(Theme.Palette.panel)
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ }
+}
+
+// MARK: - Instrument Panel View Modifier
+
+struct InstrumentPanelModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .background(Theme.Palette.panel)
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ }
+}
+
+extension View {
+ /// Applies the opaque instrument-panel surface: Theme.Palette.panel fill + 1px white-opacity border.
+ /// Corner radius = Theme.Radius.panel (10pt continuous). No glass permitted on this surface.
+ func instrumentPanel() -> some View {
+ modifier(InstrumentPanelModifier())
+ }
+}
+
+// MARK: - Readout Well Surface Style
+//
+// Recessed machined-in surface used inside readout wells.
+// Fill: Theme.Palette.wellSink (#080B0F dark / #E8EDF2 light)
+// Border: 1px white-opacity hairline (borderStandard)
+// Corner radius: Theme.Radius.well (8pt continuous)
+// Inner top-shadow: 2px so it reads machined-in, not raised.
+
+struct ReadoutWellSurface: ViewModifier {
+ @Environment(\.colorScheme) private var colorScheme
+
+ private var shadowColor: Color {
+ colorScheme == .dark
+ ? Color.black.opacity(0.5)
+ : Color.black.opacity(0.06)
+ }
+
+ func body(content: Content) -> some View {
+ content
+ .background(Theme.Palette.wellSink)
+ .overlay(
+ // 2px inner top-shadow: offset y=2, no spread — reads machined-in.
+ // Simulated with a top-edge gradient overlay since SwiftUI has no inner-shadow.
+ VStack(spacing: 0) {
+ shadowColor
+ .frame(height: 2)
+ Spacer()
+ }
+ )
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.well, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.well, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ }
+}
+
+extension View {
+ /// Applies the recessed readout-well surface: wellSink fill + 2px inner top-shadow + white-opacity border.
+ func readoutWellSurface() -> some View {
+ modifier(ReadoutWellSurface())
+ }
+}
+
+// MARK: - Previews
+
+#Preview("OpaquePanel") {
+ VStack(spacing: Theme.Space.md) {
+ OpaquePanel {
+ Text("Loss: 0.6121")
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .padding(Theme.Space.lg)
+ }
+
+ HStack {
+ Text("INSTRUMENT PANEL — opaque, 10pt card, white-opacity hairline")
+ .instrumentLabel()
+ .padding(Theme.Space.lg)
+ }
+ .instrumentPanel()
+
+ HStack {
+ Text("WELL SINK — recessed surface")
+ .instrumentLabel()
+ .padding(Theme.Space.sm)
+ }
+ .readoutWellSurface()
+ .padding(Theme.Space.lg)
+ .instrumentPanel()
+ }
+ .background(Theme.Palette.canvas)
+ .frame(width: 360)
+ .padding(Theme.Space.lg)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift b/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift
new file mode 100644
index 0000000000..b929ef4e8a
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift
@@ -0,0 +1,356 @@
+import SwiftUI
+
+// MARK: - Primitive 8: ParamRow
+//
+// Label (left, dim) + control (right) on one hairline-ruled line.
+// Forms are stacks of ParamRows — never boxed, never cardd.
+//
+// Variants:
+// - Text/label only (read-only)
+// - TextField (editable string)
+// - Slider (with live mono readout showing current value in the track)
+// - Picker / segmented (for method selection)
+// - Toggle
+
+// MARK: Text ParamRow
+
+/// A read-only label + value display on a hairline-ruled line.
+///
+/// ```swift
+/// ParamRow(label: "MODEL", value: "qwen3.5-0.8b")
+/// ```
+struct ParamRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ Text(value)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ }
+ .frame(height: Theme.Space.rowHeight)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: TextField ParamRow
+
+/// An editable label + TextField on a hairline-ruled line.
+///
+/// ```swift
+/// ParamRowField(label: "DATASET", text: $datasetPath, placeholder: "path/to/data.jsonl")
+/// ```
+struct ParamRowField: View {
+ let label: String
+ @Binding var text: String
+ var placeholder: String = ""
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ TextField(placeholder, text: $text)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .multilineTextAlignment(.trailing)
+ .frame(maxWidth: 240)
+ .textFieldStyle(.plain)
+ }
+ .frame(height: Theme.Space.rowHeight)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: Slider ParamRow
+
+/// A label + Slider row where the current value appears as a live mono readout beside the track.
+///
+/// ```swift
+/// ParamRowSlider(label: "RANK", value: $rank, range: 1...64, step: 1, format: "%.0f")
+/// ParamRowSlider(label: "LR", value: $lr, range: 1e-5...1e-2, format: "%.2e")
+/// ```
+struct ParamRowSlider: View {
+ let label: String
+ @Binding var value: Double
+ let range: ClosedRange
+ var step: Double = 0
+ var format: String = "%.4g"
+ var unit: String = ""
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ private var formattedValue: String {
+ String(format: format, value) + (unit.isEmpty ? "" : " \(unit)")
+ }
+
+ var body: some View {
+ HStack(spacing: Theme.Space.sm) {
+ Text(label)
+ .instrumentLabel()
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+ .frame(width: 80, alignment: .leading)
+
+ // Slider with live mono readout
+ HStack(spacing: Theme.Space.sm) {
+ if step > 0 {
+ Slider(value: $value, in: range, step: step)
+ } else {
+ Slider(value: $value, in: range)
+ }
+
+ // Live mono readout in the track
+ Text(formattedValue)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .frame(width: 72, alignment: .trailing)
+ .contentTransition(reduceMotion ? .identity : .numericText())
+ .animation(
+ reduceMotion ? nil : .easeOut(duration: Theme.Motion.tick),
+ value: formattedValue
+ )
+ }
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: Stepper ParamRow
+
+/// A label + typed numeric entry with optional native Stepper buttons on a hairline-ruled line.
+///
+/// When `step > 0`, a native Stepper appears to the right of the field. When `step == 0`,
+/// only the typed field is shown — correct for free-range values like learning rate.
+///
+/// ```swift
+/// ParamRowStepper(label: "STEPS", value: $steps, range: 1...500, step: 1, format: "%.0f")
+/// ParamRowStepper(label: "LR", value: $lr, range: 1e-5...5e-3, format: "%.1e")
+/// ParamRowStepper(label: "FIRST LAYER", value: $firstLayer, range: 0...23, step: 1,
+/// format: "%.0f", caption: "adapts layers 19–23 · 5 layers")
+/// ```
+struct ParamRowStepper: View {
+ let label: String
+ @Binding var value: Double
+ let range: ClosedRange
+ var step: Double = 0
+ var format: String = "%.0f"
+ var unit: String = ""
+ var caption: String? = nil
+
+ @State private var text: String = ""
+ @FocusState private var focused: Bool
+
+ private func commit() {
+ guard let parsed = Double(text.trimmingCharacters(in: .whitespaces)) else {
+ text = String(format: format, value)
+ return
+ }
+ let clamped = min(max(parsed, range.lowerBound), range.upperBound)
+ value = clamped
+ text = String(format: format, clamped)
+ }
+
+ private var primaryRow: some View {
+ HStack(spacing: Theme.Space.sm) {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ TextField("", text: $text)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .multilineTextAlignment(.trailing)
+ .textFieldStyle(.plain)
+ .frame(width: 88)
+ .focused($focused)
+ .onSubmit { commit() }
+ .onChange(of: focused) { _, isFocused in
+ if !isFocused { commit() }
+ }
+ .onChange(of: value) { _, newVal in
+ if !focused { text = String(format: format, newVal) }
+ }
+ if !unit.isEmpty {
+ Text(unit)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ if step > 0 {
+ Stepper("", value: $value, in: range, step: step)
+ .labelsHidden()
+ }
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ }
+
+ var body: some View {
+ Group {
+ if let caption {
+ VStack(alignment: .leading, spacing: 2) {
+ primaryRow
+ Text(caption)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, Theme.Space.xs)
+ }
+ } else {
+ primaryRow
+ }
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ .onAppear {
+ text = String(format: format, value)
+ }
+ }
+}
+
+// MARK: Picker ParamRow
+
+/// A label + segmented picker on a hairline-ruled line.
+///
+/// ```swift
+/// ParamRowPicker(label: "METHOD", options: ["Q4", "QuaRot"], selection: $method)
+/// ```
+struct ParamRowPicker: View {
+ let label: String
+ let options: [String]
+ @Binding var selection: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ Picker("", selection: $selection) {
+ ForEach(options, id: \.self) { opt in
+ Text(opt).tag(opt)
+ }
+ }
+ .pickerStyle(.segmented)
+ .frame(maxWidth: 200)
+ .labelsHidden()
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: Menu ParamRow
+
+/// A label + dropdown menu picker on a hairline-ruled line.
+///
+/// Use this (not `ParamRowPicker`) for selections with many or long options — e.g. MODEL —
+/// where a segmented control would overflow the panel. The dropdown stays a fixed-width button
+/// showing the current selection and never grows with the option count.
+///
+/// ```swift
+/// ParamRowMenu(label: "MODEL", options: modelNames, selection: $selectedModelName)
+/// ```
+struct ParamRowMenu: View {
+ let label: String
+ let options: [String]
+ @Binding var selection: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ Picker("", selection: $selection) {
+ ForEach(options, id: \.self) { opt in
+ Text(opt).tag(opt)
+ }
+ }
+ .pickerStyle(.menu)
+ .labelsHidden()
+ .font(Theme.Fonts.readout)
+ .fixedSize()
+ }
+ .frame(height: Theme.Space.rowHeightComfortable)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: Toggle ParamRow
+
+/// A label + toggle on a hairline-ruled line.
+///
+/// ```swift
+/// ParamRowToggle(label: "COMFORTABLE ROWS", isOn: $comfortable)
+/// ```
+struct ParamRowToggle: View {
+ let label: String
+ @Binding var isOn: Bool
+
+ var body: some View {
+ HStack {
+ Text(label)
+ .instrumentLabel()
+ Spacer()
+ Toggle("", isOn: $isOn)
+ .toggleStyle(.switch)
+ .labelsHidden()
+ }
+ .frame(height: Theme.Space.rowHeight)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("ParamRow") {
+ @Previewable @State var rank: Double = 8
+ @Previewable @State var lr: Double = 2e-4
+ @Previewable @State var steps: Double = 25
+ @Previewable @State var firstLayer: Double = 19
+ @Previewable @State var method: String = "Q4"
+ @Previewable @State var dataset: String = "claude-lora.jsonl"
+ @Previewable @State var comfortable: Bool = false
+
+ return VStack(spacing: 0) {
+ ParamRow(label: "MODEL", value: "qwen3.5-0.8b")
+ ParamRow(label: "LAYERS", value: "18 GDN · 6 GQA")
+ ParamRowSlider(label: "RANK", value: $rank, range: 1...64, step: 1, format: "%.0f")
+ ParamRowSlider(label: "LR", value: $lr, range: 1e-5...1e-2, format: "%.2e")
+ ParamRowStepper(label: "STEPS", value: $steps, range: 1...500, step: 1, format: "%.0f",
+ caption: "stepper with caption · \(Int(steps)) steps")
+ ParamRowStepper(label: "LR (TYPED)", value: $lr, range: 1e-5...5e-3, format: "%.1e")
+ ParamRowPicker(label: "METHOD", options: ["Q4", "QuaRot"], selection: $method)
+ ParamRowField(label: "DATASET", text: $dataset, placeholder: "path/to/data.jsonl")
+ ParamRowToggle(label: "COMFORTABLE ROWS", isOn: $comfortable)
+ }
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 400)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift b/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift
new file mode 100644
index 0000000000..5ff90799aa
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift
@@ -0,0 +1,151 @@
+import SwiftUI
+
+// MARK: - Primitive 2: ReadoutWell
+//
+// The atomic readout unit:
+// - 11pt all-caps dim label (LOSS, TOK/S, GRAD-NORM…)
+// - Tabular-mono value at Theme.Fonts.wellValue (15pt medium)
+// - Unit string (dim, 11pt)
+// - Optional delta caret (▲/▼): teal = good (falling), amber = bad (rising)
+// - All numerals use .monospacedDigit()
+// - Recessed machined-in well surface
+
+/// Direction of movement for the delta caret.
+enum DeltaDirection {
+ case down // value falling — teal (good for loss/PPL)
+ case up // value rising — amber (bad for loss/PPL; good for tok/s)
+ case none
+
+ var glyph: String {
+ switch self {
+ case .down: "▼"
+ case .up: "▲"
+ case .none: ""
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .down: Theme.Palette.signal
+ case .up: Theme.Palette.amber
+ case .none: .clear
+ }
+ }
+}
+
+/// A single readout well: label + tabular-mono value + unit + optional delta caret.
+///
+/// ```swift
+/// ReadoutWell(label: "LOSS", value: "0.6121", unit: "", delta: .init("▼0.004", .down))
+/// ReadoutWell(label: "TOK/S", value: "1820", unit: "t/s")
+/// ReadoutWell(label: "LR-NOW", value: "1.81e-4")
+/// ```
+struct ReadoutWell: View {
+ let label: String
+ let value: String
+ let unit: String
+ let delta: DeltaInfo?
+ let minHeight: CGFloat?
+
+ struct DeltaInfo {
+ let text: String
+ let direction: DeltaDirection
+
+ init(_ text: String, _ direction: DeltaDirection) {
+ self.text = text
+ self.direction = direction
+ }
+ }
+
+ init(label: String, value: String, unit: String = "", delta: DeltaInfo? = nil, minHeight: CGFloat? = nil) {
+ self.label = label
+ self.value = value
+ self.unit = unit
+ self.delta = delta
+ self.minHeight = minHeight
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ // 11pt all-caps dim label
+ Text(label)
+ .instrumentLabel()
+
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ // Tabular-mono value — .monospacedDigit() ensures fixed-width digits
+ Text(value)
+ .font(Theme.Fonts.wellValue)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .contentTransition(.numericText())
+
+ // Unit (dim, 11pt)
+ if !unit.isEmpty {
+ Text(unit)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+
+ // Delta caret
+ if let delta = delta, delta.direction != .none {
+ Text("\(delta.direction.glyph)\(delta.text)")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(delta.direction.color)
+ .monospacedDigit()
+ .animation(.easeOut(duration: Theme.Motion.tick), value: delta.direction.glyph)
+ }
+ }
+ }
+ .padding(.horizontal, Theme.Space.md)
+ .padding(.vertical, Theme.Space.sm)
+ .wellMinHeight(minHeight)
+ .readoutWellSurface()
+ }
+}
+
+// MARK: - Min-height helper (opt-in; nil ⇒ intrinsic height, byte-identical to prior behavior)
+
+private extension View {
+ @ViewBuilder
+ func wellMinHeight(_ height: CGFloat?) -> some View {
+ if let height {
+ frame(maxWidth: .infinity, minHeight: height, alignment: .topLeading)
+ } else {
+ self
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("ReadoutWell") {
+ HStack(spacing: Theme.Space.sm) {
+ ReadoutWell(
+ label: "LOSS",
+ value: "0.6121",
+ unit: "",
+ delta: ReadoutWell.DeltaInfo("0.004", .down)
+ )
+ ReadoutWell(
+ label: "TOK/S",
+ value: "1820",
+ unit: "t/s",
+ delta: ReadoutWell.DeltaInfo("12", .up)
+ )
+ ReadoutWell(
+ label: "LR-NOW",
+ value: "1.81e-4"
+ )
+ ReadoutWell(
+ label: "GRAD",
+ value: "0.93"
+ )
+ ReadoutWell(
+ label: "ETA",
+ value: "4m 12s"
+ )
+ }
+ .padding(Theme.Space.lg)
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Components/StripChart.swift b/apps/macos/Sources/LatticeStudio/Components/StripChart.swift
new file mode 100644
index 0000000000..f119096cc3
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Components/StripChart.swift
@@ -0,0 +1,242 @@
+import SwiftUI
+import Charts
+
+// MARK: - Primitive 4: StripChart (Oscilloscope)
+//
+// The live training oscilloscope.
+//
+// Visual spec:
+// - LineMark: 1.5px Theme.Palette.signal stroke
+// - AreaMark: Theme.Palette.signalGlow gradient beneath the line
+// - RuleMark: 1px teal now-cursor at the latest step (or scrub position)
+// - Ghost baseline series: dashed inkDim line (optional, for base PPL / comparison)
+// - Mono axis ticks, hairline grid, NO legend
+// - Draggable scrub line: reports hovered step via scrubStep binding
+// - Throttle: caller is responsible for throttling updates to ~20Hz (chartCommitHz)
+// - Respects accessibilityReduceMotion: instant updates (no chart animation)
+
+/// Which value series to plot on the primary axis.
+enum StripSeries {
+ case loss
+ case valLoss
+ case gradNorm
+
+ var label: String {
+ switch self {
+ case .loss: "LOSS"
+ case .valLoss: "VAL LOSS"
+ case .gradNorm: "GRAD NORM"
+ }
+ }
+
+ func value(from point: TrainPoint) -> Double? {
+ switch self {
+ case .loss: point.loss
+ case .valLoss: point.valLoss
+ case .gradNorm: point.gradNorm
+ }
+ }
+}
+
+/// The oscilloscope strip chart for training metrics.
+///
+/// ```swift
+/// StripChart(
+/// points: store.liveRun?.points ?? [],
+/// series: .loss,
+/// scrubStep: $scrubStep
+/// )
+///
+/// // With ghost baseline (quantize PPL ghost)
+/// StripChart(
+/// points: points,
+/// series: .valLoss,
+/// ghostBaseline: 15.86,
+/// ghostLabel: "base PPL",
+/// scrubStep: $scrubStep
+/// )
+/// ```
+struct StripChart: View {
+ let points: [TrainPoint]
+ var series: StripSeries = .loss
+ var ghostBaseline: Double? = nil
+ var ghostLabel: String = "base"
+ @Binding var scrubStep: Int?
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @State private var isDragging = false
+
+ var body: some View {
+ Chart {
+ // Ghost baseline (dashed inkDim) — e.g., base PPL on quantize screen
+ if let baseline = ghostBaseline {
+ RuleMark(y: .value("Ghost", baseline))
+ .foregroundStyle(Theme.Palette.inkDim.opacity(0.6))
+ .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
+ .annotation(position: .trailing, alignment: .leading) {
+ Text(ghostLabel)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+
+ // Area fill (signalGlow gradient beneath line)
+ ForEach(validPoints) { point in
+ if let yVal = series.value(from: point) {
+ AreaMark(
+ x: .value("Step", point.step),
+ y: .value(series.label, yVal)
+ )
+ .foregroundStyle(
+ LinearGradient(
+ colors: [Theme.Palette.signalGlow, .clear],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ }
+ }
+
+ // Primary line (1.5px signal teal)
+ ForEach(validPoints) { point in
+ if let yVal = series.value(from: point) {
+ LineMark(
+ x: .value("Step", point.step),
+ y: .value(series.label, yVal)
+ )
+ .foregroundStyle(Theme.Palette.signal)
+ .lineStyle(StrokeStyle(lineWidth: 1.5))
+ }
+ }
+
+ // Val loss overlay (dashed, inkDim) when primary is .loss
+ if series == .loss {
+ ForEach(validPoints.filter { $0.valLoss != nil }) { point in
+ if let val = point.valLoss {
+ LineMark(
+ x: .value("Step", point.step),
+ y: .value("VAL", val)
+ )
+ .foregroundStyle(Theme.Palette.inkDim.opacity(0.7))
+ .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
+ }
+ }
+ }
+
+ // Now-cursor (1px teal RuleMark at current/scrub step)
+ if let nowStep = nowCursorStep {
+ RuleMark(x: .value("Now", nowStep))
+ .foregroundStyle(Theme.Palette.signal.opacity(0.8))
+ .lineStyle(StrokeStyle(lineWidth: 1))
+ }
+ }
+ // Axis styling: mono ticks, hairline grid, no legend
+ .chartXAxis {
+ AxisMarks(values: .automatic(desiredCount: 5)) { _ in
+ AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
+ .foregroundStyle(Theme.Palette.hairline)
+ AxisTick(stroke: StrokeStyle(lineWidth: 0.5))
+ .foregroundStyle(Theme.Palette.hairline)
+ AxisValueLabel()
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ .chartYAxis {
+ AxisMarks(values: .automatic(desiredCount: 4)) { _ in
+ AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
+ .foregroundStyle(Theme.Palette.hairline)
+ AxisTick(stroke: StrokeStyle(lineWidth: 0.5))
+ .foregroundStyle(Theme.Palette.hairline)
+ AxisValueLabel()
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ .chartLegend(.hidden)
+ .chartBackground { _ in
+ Theme.Palette.panel
+ }
+ // Scrub-to-freeze: drag gesture on the chart overlay
+ .chartOverlay { proxy in
+ GeometryReader { geo in
+ Rectangle()
+ .fill(.clear)
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ isDragging = true
+ let xPos = value.location.x - geo.frame(in: .local).minX
+ if let step: Int = proxy.value(atX: xPos) {
+ // Clamp to available range
+ let minStep = validPoints.first?.step ?? 0
+ let maxStep = validPoints.last?.step ?? 0
+ scrubStep = max(minStep, min(maxStep, step))
+ }
+ }
+ .onEnded { _ in
+ isDragging = false
+ // Release scrub — caller can decide to keep or clear
+ }
+ )
+ }
+ }
+ .animation(reduceMotion ? nil : .easeOut(duration: 1.0 / Theme.Motion.chartCommitHz), value: points.count)
+ }
+
+ private var validPoints: [TrainPoint] {
+ points.filter { series.value(from: $0) != nil }
+ }
+
+ private var nowCursorStep: Int? {
+ scrubStep ?? validPoints.last?.step
+ }
+}
+
+// MARK: - Previews
+
+#Preview("StripChart") {
+ @Previewable @State var scrubStep: Int? = nil
+
+ let samplePoints: [TrainPoint] = (0..<60).map { i in
+ let t = Double(i) / 60.0
+ return TrainPoint(
+ step: i * 10,
+ loss: 2.5 * exp(-t * 2.5) + 0.5 + Double.random(in: -0.02...0.02),
+ valLoss: 2.4 * exp(-t * 2.3) + 0.55 + Double.random(in: -0.015...0.015),
+ gradNorm: 1.2 * exp(-t) + 0.3,
+ lr: 2e-4 * (1.0 - t * 0.5),
+ tokS: 1800 + Double.random(in: -50...50)
+ )
+ }
+
+ return VStack(alignment: .leading, spacing: 0) {
+ Text("STRIP CHART — LOSS + VAL LOSS")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+
+ StripChart(
+ points: samplePoints,
+ series: .loss,
+ scrubStep: Binding(
+ get: { scrubStep },
+ set: { scrubStep = $0 }
+ )
+ )
+ .frame(height: 180)
+ .padding(Theme.Space.lg)
+
+ if let s = scrubStep {
+ Text("Scrub: step \(s)")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.sm)
+ }
+ }
+ .instrumentPanel()
+ .background(Theme.Palette.canvas)
+ .frame(width: 520)
+}
diff --git a/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift
new file mode 100644
index 0000000000..7fbcd957e3
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift
@@ -0,0 +1,802 @@
+import SwiftUI
+
+// MARK: - 02 CHAT
+//
+// Single-mode chat interface.
+//
+// Layout (inside ScreenScaffold):
+// • TRANSCRIPT — scrollable column of ChatTurn bubbles, max-width 920pt.
+// • COMPOSER — rounded TextEditor with Send/Stop as a 30×30 in-composer button.
+//
+// State ownership:
+// All transcript state lives in AppStore so it survives NavigationSplitView teardown.
+// ChatScreen reads/writes via @Bindable var store.
+//
+// Generation model:
+// generate_lora prints output ATOMICALLY. A "message" lifecycle is:
+// launch via store.runGenerate(GenConfig) → status == .running →
+// collect store.liveRun.genText (cumulative) → status == .done → copy into pending turn.
+//
+// Completion detection:
+// Each run carries an onComplete hook (set in send()/retryTurn()) that
+// AppStore.finish() fires when the subprocess exits — independent of ChatScreen being
+// mounted, so a turn still resolves if Ocean navigates away mid-generation.
+
+// MARK: - Typing indicator dots
+
+private struct TypingDots: View {
+ @State private var phase: Int = 0
+
+ var body: some View {
+ HStack(spacing: 4) {
+ ForEach(0..<3, id: \.self) { i in
+ Circle()
+ .fill(Theme.Palette.textTertiary)
+ .frame(width: 6, height: 6)
+ .scaleEffect(phase == i ? 1.25 : 0.85)
+ .animation(
+ .easeInOut(duration: 0.4)
+ .repeatForever(autoreverses: true)
+ .delay(Double(i) * 0.15),
+ value: phase
+ )
+ }
+ }
+ .onAppear { phase = 0 }
+ .task {
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ phase = 1
+ }
+ }
+}
+
+// MARK: - ChatScreen
+
+struct ChatScreen: View {
+ @Bindable var store: AppStore
+
+ // MARK: Local state (ephemeral; does NOT need to survive navigation)
+
+ // Composer text is ephemeral — clearing on navigation is acceptable
+ @State private var composerText: String = ""
+
+ // MARK: Store-backed derived helpers
+
+ // CPU mode (generate_lora): bf16 only — generate_lora loads from_safetensors.
+ // GPU mode (chat_metal): bf16 + q4 — Metal path supports both formats.
+ // Embedding models are never generative; excluded in both modes.
+ private var chatModels: [ModelInfo] {
+ if store.chatUseGPU {
+ store.models.filter { $0.format == .bf16 || $0.format == .q4 }
+ } else {
+ store.models.filter { $0.format == .bf16 }
+ }
+ }
+
+ private var selectedModel: ModelInfo? {
+ if let m = chatModels.first(where: { $0.name == store.chatSelectedModelName }) { return m }
+ return chatModels.first
+ }
+
+ // isRunning: true while a single-mode run is in-flight.
+ private var isRunning: Bool {
+ store.liveRun(matching: [.chat])?.status == .running
+ }
+
+ private var canSend: Bool {
+ !composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !isRunning
+ }
+
+ // MARK: Subtitle (model + disk availability; honest — never claims residency)
+
+ private var subtitle: String {
+ guard let model = selectedModel else { return "no models found" }
+ // "ready" when the model directory exists on disk; never claim "loaded" since
+ // the app shells out a fresh subprocess per generation and nothing stays in memory.
+ let diskStatus = FileManager.default.fileExists(atPath: model.path.path) ? "ready" : "not found"
+ return "\(model.name) · \(diskStatus)"
+ }
+
+ // MARK: Body
+
+ var body: some View {
+ ScreenScaffold(screen: .chat, subtitle: subtitle) {
+ VStack(spacing: 0) {
+ transcriptArea
+ composerBar
+ }
+ }
+ .inspector(isPresented: $store.inspectorPresented) {
+ settingsInspector
+ .inspectorColumnWidth(min: 280, ideal: 320, max: 380)
+ }
+ .onAppear { applyDefaults() }
+ .onChange(of: store.models) { _, _ in applyDefaults() }
+ .onChange(of: store.chatUseGPU) { _, _ in
+ // Backend toggle changes the eligible model set (CPU drops Q4) — re-validate
+ // the selection so the picker never shows a model that isn't in chatModels.
+ applyDefaults()
+ }
+ .onChange(of: store.liveRun?.genText) { _, newText in
+ // Route streaming tokens to the awaiting turn.
+ guard store.liveRun?.kind == .chat else { return }
+ guard let turnID = store.chatAwaitingTurnID,
+ let idx = store.chatTurns.firstIndex(where: { $0.id == turnID })
+ else { return }
+ store.chatTurns[idx].responseText = newText ?? ""
+ }
+ }
+
+ // MARK: - Settings inspector (inline; binds directly to store)
+
+ @ViewBuilder
+ private var settingsInspector: some View {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(spacing: 0) {
+
+ // ── TARGET ──────────────────────────────────────────────────────────
+
+ Text("Target")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.sm)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(spacing: 0) {
+ // Model picker — menu (may have many options; segmented would overflow)
+ ParamRowMenu(
+ label: "Model",
+ options: chatModels.map(\.name),
+ selection: Binding(
+ get: { store.chatSelectedModelName },
+ set: { store.chatSelectedModelName = $0 }
+ )
+ )
+ .disabled(chatModels.count <= 1)
+
+ // Backend — segmented CPU/GPU (two options; segmented is correct)
+ ParamRowPicker(
+ label: "Backend",
+ options: ["CPU bf16", "GPU Metal"],
+ selection: Binding(
+ get: { store.chatUseGPU ? "GPU Metal" : "CPU bf16" },
+ set: { store.chatUseGPU = ($0 == "GPU Metal") }
+ )
+ )
+
+ // Disk status — honest, never claims "loaded"
+ if let model = selectedModel {
+ let exists = FileManager.default.fileExists(atPath: model.path.path)
+ HStack(spacing: 6) {
+ GatePill(exists ? .pass : .fail, label: exists ? "READY" : "NOT FOUND")
+ if let liveRun = store.liveRun(matching: [.chat]),
+ liveRun.status == .running {
+ GatePill(.run, label: liveRun.genText.isEmpty ? "LOADING" : "GEN")
+ }
+ Spacer()
+ }
+ .frame(height: Theme.Space.rowHeight)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ .instrumentPanel()
+ .padding(.horizontal, Theme.Space.lg)
+
+ // ── SAMPLING ────────────────────────────────────────────────────────
+
+ Text("Sampling")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.sm)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(spacing: 0) {
+ // Temperature — real flag: --temperature (default 0.7)
+ ParamRowField(
+ label: "Temperature",
+ text: Binding(
+ get: { store.chatTempText },
+ set: { store.chatTempText = $0 }
+ ),
+ placeholder: "0.7"
+ )
+
+ // Top-k — real flag: --top-k (default 50)
+ ParamRowField(
+ label: "Top-k",
+ text: Binding(
+ get: { store.chatTopKText },
+ set: { store.chatTopKText = $0 }
+ ),
+ placeholder: "50"
+ )
+
+ // Top-p — real flag: --top-p (default 0.9)
+ ParamRowField(
+ label: "Top-p",
+ text: Binding(
+ get: { store.chatTopPText },
+ set: { store.chatTopPText = $0 }
+ ),
+ placeholder: "0.9"
+ )
+
+ // Repetition penalty — real flag: --repetition-penalty (default 1.1)
+ ParamRowField(
+ label: "Rep. penalty",
+ text: Binding(
+ get: { store.chatRepPenaltyText },
+ set: { store.chatRepPenaltyText = $0 }
+ ),
+ placeholder: "1.1"
+ )
+ }
+ .instrumentPanel()
+ .padding(.horizontal, Theme.Space.lg)
+
+ // ── GENERATION ──────────────────────────────────────────────────────
+
+ Text("Generation")
+ .instrumentLabel()
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.sm)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(spacing: 0) {
+ // Max tokens — real flag: --max-tokens (default 256)
+ ParamRowField(
+ label: "Max tokens",
+ text: Binding(
+ get: { store.chatMaxTokensText },
+ set: { store.chatMaxTokensText = $0 }
+ ),
+ placeholder: "256"
+ )
+
+ // Seed — real flag: --seed (nil = non-deterministic)
+ ParamRowField(
+ label: "Seed",
+ text: Binding(
+ get: { store.chatSeedText },
+ set: { store.chatSeedText = $0 }
+ ),
+ placeholder: "random"
+ )
+ }
+ .instrumentPanel()
+ .padding(.horizontal, Theme.Space.lg)
+
+ // ── ACTIONS ─────────────────────────────────────────────────────────
+
+ Button("New conversation") {
+ newConversation()
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .disabled(store.chatTurns.isEmpty)
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.lg)
+ .padding(.bottom, Theme.Space.lg)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .background(Theme.Palette.panel)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+
+ // MARK: - Single-mode transcript
+
+ @ViewBuilder
+ private var transcriptArea: some View {
+ if store.chatTurns.isEmpty {
+ emptyState
+ } else {
+ ScrollViewReader { proxy in
+ ScrollView(.vertical) {
+ LazyVStack(alignment: .leading, spacing: Theme.Space.lg) {
+ ForEach(store.chatTurns) { turn in
+ turnView(turn)
+ .id(turn.id)
+ }
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.xl)
+ .frame(maxWidth: Theme.Space.chatMaxWidth)
+ .frame(maxWidth: .infinity)
+ }
+ .onChange(of: store.chatTurns.count) { _, _ in
+ if let last = store.chatTurns.last {
+ withAnimation(.easeOut(duration: Theme.Motion.focus)) {
+ proxy.scrollTo(last.id, anchor: .bottom)
+ }
+ }
+ }
+ .onChange(of: store.chatTurns.last?.responseText) { _, _ in
+ if let last = store.chatTurns.last {
+ proxy.scrollTo(last.id, anchor: .bottom)
+ }
+ }
+ }
+ }
+ }
+
+ private var emptyState: some View {
+ VStack {
+ Spacer()
+ .frame(minHeight: 0)
+ .frame(maxHeight: .infinity)
+ .layoutPriority(-1)
+ EmptyStateView(
+ systemImage: "text.bubble",
+ title: "Try it out",
+ message: "Send a message to run this model locally on your Mac."
+ )
+ .frame(maxWidth: .infinity)
+ Spacer()
+ .frame(minHeight: 0)
+ .frame(maxHeight: .infinity)
+ }
+ }
+
+ // MARK: - Turn bubbles (single mode)
+
+ @ViewBuilder
+ private func turnView(_ turn: ChatTurn) -> some View {
+ VStack(alignment: .leading, spacing: Theme.Space.sm) {
+ // User bubble — right-aligned, neutral panel surface (no teal)
+ HStack {
+ Spacer(minLength: Theme.Space.xxl)
+ Text(turn.prompt)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ .multilineTextAlignment(.trailing)
+ .textSelection(.enabled)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .fill(Theme.Palette.panel)
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .strokeBorder(Theme.Palette.hairline, lineWidth: 1)
+ )
+ )
+ }
+
+ // Assistant bubble — left-aligned, surfaceRaised fill
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ if turn.status == .running && turn.responseText.isEmpty {
+ // Typing indicator: distinguish "loading model" from "generating"
+ HStack(spacing: Theme.Space.xs) {
+ TypingDots()
+ let liveText = store.liveRun(matching: [.chat])?.genText ?? ""
+ Text(liveText.isEmpty ? "Loading model…" : "Thinking…")
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(assistantBubbleBackground)
+
+ } else if turn.status == .failed && turn.responseText.isEmpty {
+ // Failed with NO output — show the engine reason, never silent "(no output)"
+ let reason = turn.errorMessage ?? "Generation failed."
+ Text(reason)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.error)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(assistantBubbleBackground)
+
+ // Retry — re-launches the identical GenConfig
+ if let cfg = turn.retryConfig {
+ Button {
+ retryTurn(turn: turn, config: cfg)
+ } label: {
+ Label("Retry", systemImage: "arrow.clockwise")
+ .font(Theme.Fonts.caption)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .disabled(isRunning)
+ .padding(.leading, 4)
+ }
+
+ } else {
+ // Normal response — ALWAYS ink color. Red is for errors only.
+ // (Previous code used Theme.Palette.error for failed status regardless
+ // of whether the text was a real error or a base/adapter reply.)
+ Text(turn.responseText)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ .multilineTextAlignment(.leading)
+ .textSelection(.enabled)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(assistantBubbleBackground)
+
+ // If the run failed but we got partial text, show the reason beneath
+ if turn.status == .failed, let reason = turn.errorMessage {
+ Text(reason)
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.error)
+ .padding(.leading, 4)
+ }
+
+ // tok/s + honest hardware label — muted, small
+ // The label was snapshotted at send time: "GPU Metal bf16", "CPU bf16", etc.
+ // It is never updated retroactively so what launched is always what shows.
+ if let tps = turn.tokensPerSecond {
+ let labelStr = turn.inferenceLabel.map { " · \($0)" } ?? ""
+ Text(String(format: "%.1f tok/s\(labelStr)", tps))
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textTertiary)
+ .monospacedDigit()
+ .padding(.leading, 4)
+ } else if let label = turn.inferenceLabel, turn.status == .running {
+ // Show label while generating (before tok/s is known)
+ Text(label)
+ .font(Theme.Fonts.caption)
+ .foregroundStyle(Theme.Palette.textTertiary)
+ .padding(.leading, 4)
+ }
+
+ // Retry button for failed turns that do have partial text
+ if turn.status == .failed, let cfg = turn.retryConfig {
+ Button {
+ retryTurn(turn: turn, config: cfg)
+ } label: {
+ Label("Retry", systemImage: "arrow.clockwise")
+ .font(Theme.Fonts.caption)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .disabled(isRunning)
+ .padding(.leading, 4)
+ }
+ }
+ }
+ Spacer(minLength: Theme.Space.xxl)
+ }
+ }
+ }
+
+ private var assistantBubbleBackground: some View {
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .fill(Theme.Palette.surfaceRaised)
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ }
+
+ // MARK: - Composer
+
+ private var composerBar: some View {
+ VStack(spacing: 0) {
+ Rectangle()
+ .fill(Theme.Palette.hairline)
+ .frame(height: 1)
+
+ HStack {
+ composerField
+ .frame(maxWidth: Theme.Space.chatMaxWidth)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(Theme.Space.lg)
+ .background(Theme.Palette.canvas)
+ }
+ }
+
+ private var composerField: some View {
+ ZStack(alignment: .topLeading) {
+ // Placeholder — shown only when composer is empty
+ if composerText.isEmpty {
+ Text("Message…")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.textTertiary)
+ .padding(.horizontal, 10)
+ .padding(.top, 9)
+ .allowsHitTesting(false)
+ }
+ // TextEditor on macOS wraps NSTextView inside NSScrollView which shows
+ // always-on scrollers by default. `.scrollIndicators(.never)` suppresses
+ // them so the field looks like a native input area rather than a document.
+ TextEditor(text: $composerText)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ .scrollContentBackground(.hidden)
+ .scrollIndicators(.never)
+ .background(.clear)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 4)
+ .padding(.trailing, 40)
+ }
+ // Auto-grow: starts at 44pt (single line), expands up to 160pt when content fills.
+ // The outer frame clamps max height — the inner TextEditor fills it naturally.
+ .frame(minHeight: 44, maxHeight: 160)
+ .fixedSize(horizontal: false, vertical: true)
+ .background(Theme.Palette.surfaceRaised)
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)
+ .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1)
+ )
+ .overlay(alignment: .bottomTrailing) {
+ actionButton
+ .padding(7)
+ }
+ }
+
+ @ViewBuilder
+ private var actionButton: some View {
+ if isRunning {
+ Button {
+ store.chatUserStoppedTurnID = store.chatAwaitingTurnID
+ store.stopRun()
+ } label: {
+ Image(systemName: "stop.fill")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(Theme.Palette.crimson)
+ .frame(width: 30, height: 30)
+ .background(
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .fill(Theme.Palette.crimson.opacity(0.12))
+ )
+ }
+ .buttonStyle(.plain)
+ .help("Stop generation")
+ } else {
+ Button(action: {
+ send()
+ }) {
+ Image(systemName: "arrow.up")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(canSend ? Theme.Palette.onAccent : Theme.Palette.textTertiary)
+ .frame(width: 30, height: 30)
+ .background(
+ RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous)
+ .fill(canSend ? Theme.Palette.signal : Theme.Palette.wellSink)
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(!canSend)
+ .keyboardShortcut(.return, modifiers: .command)
+ .help("Send ⌘↵")
+ }
+ }
+
+ // MARK: - Actions
+
+ private func applyDefaults() {
+ if store.chatSelectedModelName.isEmpty ||
+ chatModels.first(where: { $0.name == store.chatSelectedModelName }) == nil {
+ let target = store.targetModel
+ if let t = target, t.format == .bf16 {
+ store.chatSelectedModelName = t.name
+ } else {
+ store.chatSelectedModelName = chatModels.first?.name ?? ""
+ }
+ }
+ }
+
+ /// Clear conversation transcript while preserving model/adapter/settings selections.
+ private func newConversation() {
+ store.chatTurns = []
+ store.chatAwaitingTurnID = nil
+ store.chatUserStoppedTurnID = nil
+ if isRunning { store.stopRun() }
+ }
+
+ /// Build ChatML from completed turns (single-mode history as context).
+ private func renderChatML(newUserText: String) -> String {
+ var buf = ""
+ for turn in store.chatTurns {
+ guard turn.status == .done, !turn.responseText.isEmpty else { continue }
+ buf += "<|im_start|>user\n\(turn.prompt)<|im_end|>\n"
+ buf += "<|im_start|>assistant\n\(turn.responseText)<|im_end|>\n"
+ }
+ buf += "<|im_start|>user\n\(newUserText)<|im_end|>\n"
+ buf += "<|im_start|>assistant\n"
+ return buf
+ }
+
+ // MARK: Single-mode send
+
+ private func send() {
+ let rawUserText = composerText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !rawUserText.isEmpty, !isRunning else { return }
+
+ let temperature = Double(store.chatTempText) ?? 0.7
+ let maxTokens = Int(store.chatMaxTokensText) ?? 256
+ let seed: UInt64? = store.chatSeedText.isEmpty ? nil : UInt64(store.chatSeedText)
+ let topK = Int(store.chatTopKText) ?? 50
+ let topP = Double(store.chatTopPText) ?? 0.9
+ let repetitionPenalty = Double(store.chatRepPenaltyText) ?? 1.1
+ let chatMLPrompt = renderChatML(newUserText: rawUserText)
+ let useGPU = store.chatUseGPU
+
+ // For Q4 models on the GPU path: the tokenizer lives in the bf16 sibling directory.
+ // The GPU binary (chat_metal) requires --tokenizer-dir when the model dir has no
+ // tokenizer.json. CPU path (generate_lora) ignores this flag.
+ let tokenizerDirURL: URL? = {
+ guard useGPU, let model = selectedModel, model.format == .q4 else { return nil }
+ // The bf16 sibling is the same name without a "-q4" / "-quarot" suffix.
+ let modelName = model.name
+ let baseName = modelName
+ .replacingOccurrences(of: "-q4", with: "", options: .caseInsensitive)
+ .replacingOccurrences(of: "-quarot", with: "", options: .caseInsensitive)
+ let siblingURL = LatticeBridge.modelCacheDir.appendingPathComponent(baseName, isDirectory: true)
+ let tokenizerJSON = siblingURL.appendingPathComponent("tokenizer.json")
+ return FileManager.default.fileExists(atPath: tokenizerJSON.path) ? siblingURL : nil
+ }()
+
+ // Honest hardware label — snapshotted at send time, never changes after dispatch.
+ // "GPU Metal q4" / "GPU Metal bf16" / "CPU bf16"
+ let inferenceLabel: String = {
+ if useGPU {
+ let fmtTag = (selectedModel?.format == .q4) ? "q4" : "bf16"
+ return "GPU Metal \(fmtTag)"
+ } else {
+ return "CPU bf16"
+ }
+ }()
+
+ // Snapshot GenConfig for Retry (plain types; no URL reference needed)
+ let retryCfg = ChatGenConfig(
+ modelDirPath: selectedModel?.path.path,
+ model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil,
+ tokenizerDirPath: tokenizerDirURL?.path,
+ adapterFilePath: nil,
+ prompt: chatMLPrompt,
+ maxTokens: maxTokens,
+ seed: seed,
+ temperature: temperature,
+ topK: topK,
+ topP: topP,
+ repetitionPenalty: repetitionPenalty,
+ useGPU: useGPU
+ )
+
+ var turn = ChatTurn(prompt: rawUserText)
+ turn.retryConfig = retryCfg
+ turn.inferenceLabel = inferenceLabel
+ let turnID = turn.id
+ store.chatTurns.append(turn)
+ store.chatAwaitingTurnID = turnID
+
+ composerText = ""
+
+ let cfg = GenConfig(
+ modelDir: selectedModel?.path,
+ model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil,
+ tokenizerDir: tokenizerDirURL,
+ adapterPath: nil,
+ prompt: chatMLPrompt,
+ maxTokens: maxTokens,
+ seed: seed,
+ temperature: temperature,
+ topK: topK,
+ topP: topP,
+ repetitionPenalty: repetitionPenalty,
+ useGPU: useGPU
+ )
+
+ let run = store.runGenerate(cfg)
+
+ // Resolve via the run's own completion hook so the turn lands even if Ocean navigates
+ // away from Chat before generation finishes (the .onChange handlers only fire while
+ // ChatScreen is mounted). It also fires if another screen's launch() supersedes this
+ // run — finish() runs onComplete with a failed status, so the turn shows a real reason
+ // instead of a permanent spinner.
+ run.onComplete = { completed in
+ self.resolveTurn(id: turnID, from: completed, status: completed.status)
+ }
+
+ // Synchronous launch failure: no process started, so onComplete never fires.
+ if run.status == .failed {
+ resolveTurn(id: turnID, from: run, status: .failed)
+ }
+ }
+
+ /// Retry a failed/empty turn with its original GenConfig.
+ private func retryTurn(turn: ChatTurn, config: ChatGenConfig) {
+ guard !isRunning else { return }
+ guard let idx = store.chatTurns.firstIndex(where: { $0.id == turn.id }) else { return }
+
+ // Reset in-place to running state
+ store.chatTurns[idx].responseText = ""
+ store.chatTurns[idx].status = .running
+ store.chatTurns[idx].errorMessage = nil
+ store.chatTurns[idx].tokensPerSecond = nil
+ store.chatAwaitingTurnID = turn.id
+
+ let modelDir: URL? = config.modelDirPath.flatMap { URL(fileURLWithPath: $0) }
+ let tokenizerDir: URL? = config.tokenizerDirPath.flatMap { URL(fileURLWithPath: $0) }
+ let adapterPath: URL? = config.adapterFilePath.flatMap { URL(fileURLWithPath: $0) }
+ let cfg = GenConfig(
+ modelDir: modelDir,
+ model: config.model,
+ tokenizerDir: tokenizerDir,
+ adapterPath: adapterPath,
+ prompt: config.prompt,
+ maxTokens: config.maxTokens,
+ seed: config.seed,
+ temperature: config.temperature,
+ topK: config.topK,
+ topP: config.topP,
+ repetitionPenalty: config.repetitionPenalty,
+ useGPU: config.useGPU
+ )
+
+ let run = store.runGenerate(cfg)
+ run.onComplete = { [turnID = turn.id] completed in
+ self.resolveTurn(id: turnID, from: completed, status: completed.status)
+ }
+ if run.status == .failed {
+ resolveTurn(id: turn.id, from: run, status: .failed)
+ }
+ }
+
+ private func resolveTurn(id turnID: UUID, from run: LiveRun, status: RunStatus) {
+ guard let idx = store.chatTurns.firstIndex(where: { $0.id == turnID }) else { return }
+ // Idempotent: once a turn is resolved to .done/.failed, ignore repeat calls. The run's
+ // onComplete hook is authoritative; a synchronous-failure check can also land here.
+ guard store.chatTurns[idx].status == .running else { return }
+
+ let wasUserStopped = (store.chatUserStoppedTurnID == turnID)
+ store.chatAwaitingTurnID = nil
+ store.chatUserStoppedTurnID = nil
+
+ let responseText: String
+ if !run.genText.isEmpty {
+ responseText = run.genText.trimmingCharacters(in: .whitespacesAndNewlines)
+ } else {
+ let cleaned = run.log
+ .filter { !$0.hasPrefix("$ ") }
+ .joined(separator: "\n")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if cleaned.isEmpty && status == .failed {
+ responseText = wasUserStopped ? "Stopped." : ""
+ } else {
+ responseText = cleaned
+ }
+ }
+
+ store.chatTurns[idx].responseText = responseText
+
+ if wasUserStopped {
+ store.chatTurns[idx].status = .done
+ } else {
+ store.chatTurns[idx].status = (status == .done) ? .done : .failed
+ }
+
+ // Surface engine error for failed turns with no output.
+ if store.chatTurns[idx].status == .failed && responseText.isEmpty {
+ let errorLine = run.log
+ .filter { !$0.hasPrefix("$ ") }
+ .last { line in
+ let lo = line.lowercased()
+ return lo.contains("error") || lo.contains("nan") ||
+ lo.contains("unrecognized") || lo.contains("failed") ||
+ lo.contains("panic")
+ }
+ ?? run.log.filter { !$0.hasPrefix("$ ") }.last
+ ?? "Generation failed to start."
+ store.chatTurns[idx].errorMessage = errorLine
+ }
+
+ if status == .done || wasUserStopped {
+ store.chatTurns[idx].tokensPerSecond = run.genTokS
+ }
+ }
+
+}
diff --git a/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift b/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift
new file mode 100644
index 0000000000..b5291a3c6b
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift
@@ -0,0 +1,295 @@
+import SwiftUI
+import AppKit
+
+// MARK: - Get Models sheet
+//
+// Three honest sections:
+// 1. DOWNLOAD — 7 embedding models with verified --download-only support.
+// Shows INSTALLED when already in store.models; otherwise a real Download button.
+// In-flight downloads show a spinner + size hint. Errors are surfaced verbatim.
+//
+// 2. GENERATIVE — import-only models (no engine downloader exists).
+// Provides a "Copy HF URL" button per model. A clear caption explains the workflow.
+// Shows INSTALLED when the model appears in store.models.
+// NO Download button — that would be a dead control (honest-nil discipline).
+//
+// 3. IMPORT FROM DISK — NSOpenPanel for any model folder.
+// Validates config.json + .safetensors before copying to the model cache.
+// Shows import progress + errors verbatim.
+
+struct GetModelsSheet: View {
+ @Bindable var store: AppStore
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 0) {
+ sheetHeader
+ Divider()
+ ScrollView {
+ VStack(alignment: .leading, spacing: Theme.Space.xl) {
+ downloadSection
+ generativeSection
+ importSection
+ }
+ .padding(Theme.Space.xl)
+ }
+ }
+ .background(Theme.Palette.canvas)
+ }
+
+ // MARK: Header
+
+ private var sheetHeader: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("GET MODELS")
+ .font(Theme.Fonts.title)
+ .foregroundStyle(Theme.Palette.ink)
+ Text("Download embedding models or import any model folder.")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ Spacer()
+ Button("Done") { dismiss() }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ }
+ .padding(Theme.Space.lg)
+ }
+
+ // MARK: Section 1 — Download (embedding models)
+
+ private var downloadSection: some View {
+ OpaquePanel {
+ VStack(spacing: 0) {
+ sectionHeader("DOWNLOAD — EMBEDDING MODELS",
+ note: "checksums verified by the engine")
+
+ ForEach(curatedCatalog.filter { $0.kind == .downloadable }) { model in
+ embeddingRow(model)
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func embeddingRow(_ model: CuratedModel) -> some View {
+ let isInstalled = store.models.contains { $0.name.lowercased() == model.id.lowercased() }
+ let isDownloading = store.downloadingModels.contains(model.id)
+ let errorMsg = store.downloadErrors[model.id]
+
+ HStack(spacing: Theme.Space.md) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(model.name)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ HStack(spacing: Theme.Space.sm) {
+ Text(model.detail)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ if let sz = model.approxSize {
+ Text("·")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ Text(sz)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ if let err = errorMsg {
+ HStack(spacing: 4) {
+ GatePill(.fail, label: "FAILED")
+ Text(err)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .lineLimit(2)
+ }
+ .padding(.top, 2)
+ }
+ }
+
+ Spacer()
+
+ // Status / action — exactly one of: INSTALLED pill, spinner, or Download button.
+ if isInstalled {
+ GatePill(.pass, label: "INSTALLED")
+ } else if isDownloading {
+ HStack(spacing: Theme.Space.sm) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.small)
+ if let sz = model.approxSize {
+ Text("Downloading… (\(sz))")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ } else {
+ Text("Downloading…")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+ } else {
+ Button {
+ store.downloadModel(canonicalName: model.id)
+ } label: {
+ Text("Download")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticePrimaryButtonStyle())
+ }
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.sm)
+ }
+
+ // MARK: Section 2 — Generative (import-only)
+
+ private var generativeSection: some View {
+ OpaquePanel {
+ VStack(spacing: 0) {
+ sectionHeader("IMPORT-ONLY MODELS",
+ note: "download from HuggingFace, then use Import below")
+
+ // Honest caption — these models have no in-app downloader (generative models
+ // and the Qwen embedding model, whose loader only does a local-dir lookup).
+ HStack {
+ Text("These models have no in-app downloader. Copy the HuggingFace URL, fetch the repo with `git clone` or `huggingface-cli download`, then import the folder below.")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer()
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.sm)
+
+ Theme.Palette.hairline.frame(height: 1)
+
+ ForEach(curatedCatalog.filter { $0.kind == .importOnly }) { model in
+ generativeRow(model)
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func generativeRow(_ model: CuratedModel) -> some View {
+ let isInstalled = store.models.contains { $0.name.lowercased() == model.id.lowercased() }
+
+ HStack(spacing: Theme.Space.md) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(model.name)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.ink)
+ Text(model.detail)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ if let url = model.hfURL {
+ Text(url)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.signal)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+
+ Spacer()
+
+ if isInstalled {
+ GatePill(.pass, label: "INSTALLED")
+ } else if let url = model.hfURL {
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(url, forType: .string)
+ } label: {
+ Text("Copy HF URL")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .help("Copy HuggingFace URL to clipboard")
+ }
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.sm)
+ }
+
+ // MARK: Section 3 — Import from disk
+
+ private var importSection: some View {
+ OpaquePanel {
+ VStack(spacing: 0) {
+ sectionHeader("IMPORT FROM DISK",
+ note: "works for any model: BF16, Q4, embedding")
+
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ Text("Choose a model folder. It must contain config.json and at least one .safetensors file. The folder is copied into the model cache (~/.lattice/models).")
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .fixedSize(horizontal: false, vertical: true)
+
+ // Progress and error feedback — always honest, never silent.
+ if !store.importingModel.isEmpty {
+ HStack(spacing: Theme.Space.sm) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.small)
+ Text("Importing \(store.importingModel)…")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ }
+
+ if let err = store.importError {
+ HStack(spacing: 4) {
+ GatePill(.fail, label: "IMPORT FAILED")
+ Text(err)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ Button {
+ importFromDisk()
+ } label: {
+ Text("Import Model…")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticePrimaryButtonStyle())
+ .disabled(!store.importingModel.isEmpty)
+ }
+ .padding(Theme.Space.lg)
+ }
+ }
+ }
+
+ // MARK: Helpers
+
+ private func sectionHeader(_ title: String, note: String) -> some View {
+ HStack {
+ Text(title)
+ .instrumentLabel()
+ Spacer()
+ Text(note)
+ .font(Theme.Fonts.cell)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ .frame(height: Theme.Space.rowHeight)
+ .padding(.horizontal, Theme.Space.lg)
+ .overlay(alignment: .bottom) {
+ Theme.Palette.hairline.frame(height: 1)
+ }
+ }
+
+ private func importFromDisk() {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.message = "Choose a model directory (must contain config.json + .safetensors)"
+ panel.prompt = "Import"
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ store.importModel(from: url)
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift
new file mode 100644
index 0000000000..efce30378b
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift
@@ -0,0 +1,344 @@
+import SwiftUI
+import AppKit
+
+// MARK: - 01 MODELS
+//
+// Model management screen:
+// • Trailing action row: Get Models + Refresh + Reveal-in-Finder + Chat CTA
+// • Main body: DataTable of store.models
+// • Right inspector: ReadoutWells for the selected model
+//
+// Empty state: if binariesReady is false hint "run `make build`" and show modelCachePath.
+
+private let byteFormatter: ByteCountFormatter = {
+ let f = ByteCountFormatter()
+ f.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
+ f.countStyle = .file
+ return f
+}()
+
+struct ModelsScreen: View {
+ @Bindable var store: AppStore
+
+ // Local selection state — deselects when models list changes.
+ @State private var selectedModelID: String?
+ @State private var showGetModelsSheet: Bool = false
+ @State private var didInitInspector = false
+
+ private var selectedModel: ModelInfo? {
+ store.models.first { $0.id == selectedModelID }
+ }
+
+ private var subtitle: String {
+ let count = store.models.count
+ if count == 0 { return "no models found" }
+ return "\(count) model\(count == 1 ? "" : "s") · \(store.modelCachePath)"
+ }
+
+ var body: some View {
+ ScreenScaffold(
+ screen: .models,
+ subtitle: subtitle,
+ trailing: { actionRow }
+ ) {
+ if store.models.isEmpty {
+ emptyState
+ } else {
+ modelTable
+ }
+ }
+ .inspector(isPresented: $store.inspectorPresented) {
+ inspectorPanel
+ .inspectorColumnWidth(min: 260, ideal: 300, max: 320)
+ }
+ .sheet(isPresented: $showGetModelsSheet) {
+ GetModelsSheet(store: store)
+ .frame(minWidth: 680, idealWidth: 760, maxWidth: .infinity,
+ minHeight: 500, idealHeight: 620, maxHeight: .infinity)
+ }
+ .onAppear { openInspectorOnce() }
+ }
+
+ // Open the detail inspector once on first appear (replaces the previously always-visible column).
+ private func openInspectorOnce() {
+ guard !didInitInspector else { return }
+ didInitInspector = true
+ store.inspectorPresented = true
+ }
+
+ // MARK: Action row (scaffold trailing)
+
+ private var actionRow: some View {
+ HStack(spacing: Theme.Space.sm) {
+ // Get Models — primary (the discovery CTA; no model selection required)
+ Button {
+ showGetModelsSheet = true
+ } label: {
+ Text("Get Models")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticePrimaryButtonStyle())
+ .help("Browse and download models or import from disk")
+
+ // Refresh — secondary
+ Button {
+ store.refreshModels()
+ } label: {
+ Text("Refresh")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .keyboardShortcut("r", modifiers: .command)
+ .help("Refresh model list (⌘R)")
+
+ // Reveal in Finder — only when a model is selected
+ if let model = selectedModel {
+ Button {
+ NSWorkspace.shared.activateFileViewerSelecting([model.path])
+ } label: {
+ Text("Reveal")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+
+ Divider()
+ .frame(height: 16)
+
+ // Chat CTA — the single navigation action
+ Button {
+ store.use(model, on: .chat)
+ } label: {
+ Text("Chat")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ .help("Send selected model to Chat (⌘2)")
+ }
+ }
+ }
+
+ // MARK: Master table
+
+ private var modelTable: some View {
+ ScrollView {
+ DataTable(
+ rows: store.models,
+ columns: modelColumns,
+ selectedID: $selectedModelID,
+ comfortable: store.rowComfortable
+ )
+ }
+ .instrumentPanel()
+ }
+
+ private var modelColumns: [ColumnDef] {
+ [
+ ColumnDef(
+ id: "name",
+ header: "NAME",
+ alignment: .leading,
+ minWidth: 180,
+ isNumeric: false
+ ) { $0.name },
+
+ ColumnDef(
+ id: "format",
+ header: "FORMAT",
+ alignment: .leading,
+ minWidth: 80,
+ isNumeric: false
+ ) { $0.format.badge },
+
+ ColumnDef(
+ id: "params",
+ header: "PARAMS",
+ alignment: .trailing,
+ minWidth: 64,
+ isNumeric: false
+ ) { $0.params ?? "—" },
+
+ ColumnDef(
+ id: "layers",
+ header: "LAYERS",
+ alignment: .leading,
+ minWidth: 120,
+ isNumeric: false
+ ) { $0.layerSummary ?? "—" },
+
+ ColumnDef(
+ id: "size",
+ header: "SIZE",
+ alignment: .trailing,
+ minWidth: 80,
+ isNumeric: true
+ ) { byteFormatter.string(fromByteCount: $0.sizeBytes) },
+
+ ColumnDef(
+ id: "files",
+ header: "FILES",
+ alignment: .trailing,
+ minWidth: 48,
+ isNumeric: true
+ ) { "\($0.fileCount)" },
+
+ ColumnDef(
+ id: "tokenizer",
+ header: "TOK",
+ alignment: .center,
+ minWidth: 40,
+ isNumeric: false
+ ) { $0.hasTokenizer ? "✓" : "—" },
+ ]
+ }
+
+ // MARK: Inspector panel
+
+ @ViewBuilder
+ private var inspectorPanel: some View {
+ if let model = selectedModel {
+ ScrollView {
+ VStack(alignment: .leading, spacing: Theme.Space.xl) {
+ modelInspector(model)
+ }
+ .padding(Theme.Space.lg)
+ }
+ .instrumentPanel()
+ } else {
+ VStack {
+ Spacer()
+ Text("SELECT A MODEL")
+ .instrumentLabel()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .instrumentPanel()
+ }
+ }
+
+ private func modelInspector(_ model: ModelInfo) -> some View {
+ VStack(alignment: .leading, spacing: Theme.Space.md) {
+ // Model name header
+ HStack(spacing: Theme.Space.sm) {
+ Text(model.name)
+ .font(Theme.Fonts.title)
+ .foregroundStyle(Theme.Palette.ink)
+ .lineLimit(1)
+ if model.format.isQuantized {
+ GatePill(.pass, label: model.format.badge)
+ }
+ }
+
+ // Readout wells — 2-column grid
+ let wells = modelWells(for: model)
+ LazyVGrid(
+ columns: [GridItem(.flexible(), spacing: Theme.Space.md), GridItem(.flexible(), spacing: Theme.Space.md)],
+ spacing: Theme.Space.md
+ ) {
+ ForEach(wells, id: \.label) { well in
+ ReadoutWell(label: well.label, value: well.value, unit: well.unit, minHeight: 56)
+ }
+ }
+ }
+ }
+
+ private struct WellSpec {
+ let label: String
+ let value: String
+ let unit: String
+ init(_ label: String, _ value: String, _ unit: String = "") {
+ self.label = label; self.value = value; self.unit = unit
+ }
+ }
+
+ private func modelWells(for model: ModelInfo) -> [WellSpec] {
+ var ws: [WellSpec] = []
+ ws.append(WellSpec("FORMAT", model.format.rawValue))
+ ws.append(WellSpec("DTYPE", model.dtype))
+ if let params = model.params {
+ ws.append(WellSpec("PARAMS", params))
+ }
+ if let hidden = model.hidden {
+ ws.append(WellSpec("HIDDEN", "\(hidden)"))
+ }
+ // intermediate_size — FFN/MLP inner width (3584 for qwen3.5, ~3.5× hidden).
+ // Honest-nil: omitted when the model config has no intermediate_size.
+ if let ffn = model.intermediateSize {
+ ws.append(WellSpec("FFN", "\(ffn)"))
+ }
+ if let vocab = model.vocab {
+ ws.append(WellSpec("VOCAB", "\(vocab)"))
+ }
+ if let ctx = model.contextLength {
+ ws.append(WellSpec("CTX", "\(ctx)"))
+ }
+ if let h = model.attnHeads { ws.append(WellSpec("HEADS", "\(h)")) }
+ if let kv = model.kvHeads { ws.append(WellSpec("KV HEADS", "\(kv)")) }
+ if let hd = model.headDim { ws.append(WellSpec("HEAD DIM", "\(hd)")) }
+ // GatedDeltaNet linear-attention heads; show K/V, honest-nil each, omit when both absent.
+ if model.gdnKeyHeads != nil || model.gdnValueHeads != nil {
+ let k = model.gdnKeyHeads.map(String.init) ?? "—"
+ let v = model.gdnValueHeads.map(String.init) ?? "—"
+ ws.append(WellSpec("GDN HEADS", "\(k)/\(v)"))
+ }
+ if let layers = model.layerSummary {
+ ws.append(WellSpec("LAYERS", layers))
+ }
+ ws.append(WellSpec("SIZE", byteFormatter.string(fromByteCount: model.sizeBytes)))
+ ws.append(WellSpec("FILES", "\(model.fileCount)"))
+ ws.append(WellSpec("TOKENIZER", model.hasTokenizer ? "yes" : "—"))
+ return ws
+ }
+
+ // MARK: Empty state
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: Theme.Space.xl) {
+ OpaquePanel {
+ VStack(alignment: .leading, spacing: Theme.Space.lg) {
+ Text("NO MODELS FOUND")
+ .font(Theme.Fonts.title)
+ .foregroundStyle(Theme.Palette.ink)
+
+ VStack(alignment: .leading, spacing: Theme.Space.sm) {
+ Text("Model cache directory:")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+
+ Text(store.modelCachePath)
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.ink)
+ .monospacedDigit()
+ .lineLimit(2)
+ .truncationMode(.middle)
+ }
+
+ if !store.binariesReady {
+ GatePill(.warn, label: "lattice binary not found")
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Build the lattice engine first:")
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+ Text("make build")
+ .font(Theme.Fonts.readout)
+ .foregroundStyle(Theme.Palette.signal)
+ .monospacedDigit()
+ }
+ }
+
+ Button {
+ store.refreshModels()
+ } label: {
+ Text("Refresh")
+ .font(Theme.Fonts.body)
+ }
+ .buttonStyle(LatticeSecondaryButtonStyle())
+ }
+ .padding(Theme.Space.xl)
+ }
+
+ Spacer()
+ }
+ }
+}
+
diff --git a/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift b/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift
new file mode 100644
index 0000000000..ec01a16bd1
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+// Shared chrome for every screen: a ruled header (index · title + subtitle), an
+// optional trailing slot for a primary action / live status, then the content body
+// on the instrument canvas. Screens compose their own layouts inside `content`.
+struct ScreenScaffold: View {
+ let screen: Screen
+ let subtitle: String
+ @ViewBuilder var trailing: () -> Trailing
+ @ViewBuilder var content: () -> Content
+
+ init(
+ screen: Screen,
+ subtitle: String,
+ @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() },
+ @ViewBuilder content: @escaping () -> Content
+ ) {
+ self.screen = screen
+ self.subtitle = subtitle
+ self.trailing = trailing
+ self.content = content
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: Theme.Space.lg) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 8) {
+ Text(screen.index)
+ .font(Theme.Fonts.mono(13))
+ .foregroundStyle(Theme.Palette.inkDim)
+ Text(screen.title)
+ .font(Theme.Fonts.display(17, .bold))
+ .foregroundStyle(Theme.Palette.ink)
+ }
+ Text(subtitle)
+ .font(Theme.Fonts.body)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+ Spacer(minLength: Theme.Space.lg)
+ trailing()
+ }
+ content()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .padding(Theme.Space.xl)
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift
new file mode 100644
index 0000000000..d48b4eee21
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift
@@ -0,0 +1,166 @@
+import SwiftUI
+
+struct ContentView: View {
+ @Bindable var store: AppStore
+
+ var body: some View {
+ NavigationSplitView {
+ LeftRail(store: store)
+ // Sidebar: fixed ideal width with a collapse range so it never overlaps
+ // the detail column on narrow windows.
+ .navigationSplitViewColumnWidth(
+ min: Theme.Space.sidebarMin,
+ ideal: Theme.Space.railWidth,
+ max: Theme.Space.sidebarMax
+ )
+ } detail: {
+ ZStack {
+ Theme.Palette.canvas.ignoresSafeArea()
+ detail
+ }
+ // Enforce a minimum detail width so the split view never collapses the
+ // content area to zero on narrow resize — the window's own minWidth (1120)
+ // is the outer bound; this clamps at the column level.
+ .frame(minWidth: 640)
+ .toolbar {
+ // Run-status capsule: always reserve the slot; hide the capsule content
+ // when there is no active run. This prevents the toolbar from reflowing
+ // when a run starts or stops, which previously caused the Embeddings
+ // HSplitView to jump.
+ ToolbarItem(placement: .primaryAction) {
+ if let run = store.liveRun, run.status == .running || run.status == .paused {
+ runStatusCapsule(run)
+ } else {
+ // Zero-size placeholder — keeps toolbar item count constant.
+ Color.clear.frame(width: 0, height: 0)
+ }
+ }
+ // Inspector toggle: only rendered for screens that have an inspector.
+ if store.selection.hasInspector {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ store.inspectorPresented.toggle()
+ } label: {
+ Image(systemName: "sidebar.right")
+ }
+ .help("Toggle settings (⌘\\)")
+ .foregroundStyle(store.inspectorPresented ? Theme.Palette.signal : Theme.Palette.textSecondary)
+ }
+ }
+ }
+ }
+ .navigationTitle("")
+ // One neutral tint for every system control (Slider/Picker/Toggle/field caret).
+ // Elements that should read teal set Theme.Palette.signal explicitly, so they are
+ // unaffected — this keeps the accent reserved for live data + the single CTA.
+ .tint(Theme.Palette.control)
+ .background(shortcuts)
+ .overlay {
+ CommandBar(
+ isPresented: $store.commandBarOpen,
+ commands: CommandSpec.latticeDefaults,
+ onRun: handleCommand
+ )
+ }
+ }
+
+ // Route a ⌘K command to a screen / action. A leading model-name argument
+ // preselects the working model for that screen.
+ private func handleCommand(_ cmd: String, _ args: [String]) {
+ func retarget() {
+ guard let arg = args.first(where: { !$0.isEmpty })?.lowercased() else { return }
+ if let m = store.models.first(where: { $0.name.lowercased().contains(arg) }) {
+ store.workingModel = m
+ }
+ }
+ switch cmd {
+ case "chat": retarget(); store.selection = .chat
+ case "models": store.selection = .models
+ case "stop": store.stopRun()
+ default: break
+ }
+ }
+
+ // Compact toolbar capsule shown while a run is active.
+ // Shows: status dot · model name · step counter — no invented fields.
+ // Clicking navigates to the Runs screen.
+ @ViewBuilder
+ private func runStatusCapsule(_ run: LiveRun) -> some View {
+ let activityLabel: String = {
+ switch run.kind {
+ case .train: return "Training"
+ case .quantizeQ4: return "Quantizing"
+ case .quantizeQuaRot: return "Quantizing"
+ case .chat: return "Generating"
+ case .eval: return "Evaluating"
+ case .embed: return "Embedding"
+ }
+ }()
+ let stepLabel: String = {
+ if run.kind == .train {
+ let s = "step \(run.currentStep)"
+ if let total = run.totalSteps { return s + "/\(total)" }
+ return s
+ }
+ return run.status == .paused ? "paused" : "running"
+ }()
+
+ Button {
+ // Jump to the screen that owns this run kind.
+ switch run.kind {
+ case .chat:
+ store.selection = .chat
+ default:
+ store.selection = .models
+ }
+ } label: {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(Theme.Palette.running)
+ .frame(width: 6, height: 6)
+ .opacity(run.status == .paused ? 0.5 : 1.0)
+ Text(activityLabel)
+ .font(Theme.Fonts.controlText)
+ .foregroundStyle(Theme.Palette.textPrimary)
+ Text(run.modelName)
+ .font(Theme.Fonts.controlText)
+ .foregroundStyle(Theme.Palette.textSecondary)
+ .lineLimit(1)
+ Text(stepLabel)
+ .font(.system(size: 11, weight: .regular, design: .monospaced))
+ .foregroundStyle(Theme.Palette.textSecondary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(
+ Theme.Palette.running.opacity(0.12)
+ .clipShape(Capsule())
+ )
+ .overlay(Capsule().strokeBorder(Theme.Palette.running.opacity(0.28), lineWidth: 1))
+ }
+ .buttonStyle(.plain)
+ .help("View active run")
+ }
+
+ @ViewBuilder private var detail: some View {
+ switch store.selection {
+ case .models: ModelsScreen(store: store)
+ case .chat: ChatScreen(store: store)
+ }
+ }
+
+ // Hidden buttons carry the global keyboard map (⌘1–6, ⌘K, ⌘\).
+ private var shortcuts: some View {
+ ZStack {
+ ForEach(Screen.allCases) { s in
+ Button("") { store.selection = s }
+ .keyboardShortcut(s.shortcut, modifiers: .command)
+ }
+ Button("") { store.commandBarOpen.toggle() }.keyboardShortcut("k", modifiers: .command)
+ Button("") { store.inspectorPresented.toggle() }.keyboardShortcut("\\", modifiers: .command)
+ }
+ .opacity(0)
+ .frame(width: 0, height: 0)
+ }
+
+}
diff --git a/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift
new file mode 100644
index 0000000000..82cc5265a7
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift
@@ -0,0 +1,232 @@
+import SwiftUI
+
+// MARK: - The left rail: wordmark + engine-state header, indexed nav, compact memory footer.
+struct LeftRail: View {
+ @Bindable var store: AppStore
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ header
+ Divider().overlay(Theme.Palette.hairline)
+ nav
+ Spacer(minLength: 0)
+ Divider().overlay(Theme.Palette.hairline)
+ memoryFooter
+ }
+ .background(Theme.Palette.panel)
+ }
+
+ // MARK: Header (~58pt): wordmark + engine-state row
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ // Wordmark
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Text("Lattice")
+ .font(Theme.Fonts.bodyStrong) // 13pt medium
+ .foregroundStyle(Theme.Palette.ink)
+ Spacer()
+ }
+ // Engine-state row (~24pt)
+ engineStateRow
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.top, Theme.Space.md)
+ .padding(.bottom, Theme.Space.sm)
+ }
+
+ @ViewBuilder private var engineStateRow: some View {
+ let isActive: Bool = {
+ guard let run = store.liveRun else { return false }
+ return run.status == .running || run.status == .paused
+ }()
+ let dotColor = isActive ? Theme.Palette.running : Theme.Palette.idle
+
+ HStack(spacing: 6) {
+ Circle()
+ .fill(dotColor)
+ .frame(width: 6, height: 6)
+ .modifier(PulsingDot(active: isActive))
+ Text(engineStateLabel)
+ .font(Theme.Fonts.caption) // 11pt regular
+ .foregroundStyle(Theme.Palette.textSecondary)
+ Spacer()
+ }
+ .frame(height: 24)
+ }
+
+ private var engineStateLabel: String {
+ guard let run = store.liveRun,
+ run.status == .running || run.status == .paused else {
+ return "Idle"
+ }
+ switch run.kind {
+ case .train: return "Training"
+ case .quantizeQ4: return "Quantizing"
+ case .quantizeQuaRot: return "Quantizing"
+ case .chat: return "Generating"
+ case .eval: return "Evaluating"
+ case .embed: return "Embedding"
+ }
+ }
+
+ // MARK: Nav rows
+
+ private var nav: some View {
+ VStack(spacing: 2) {
+ ForEach(Screen.allCases) { screen in
+ navRow(screen)
+ }
+ }
+ .padding(.horizontal, Theme.Space.xs) // 4pt outer — rows add their own 10pt h-pad
+ .padding(.vertical, Theme.Space.sm)
+ }
+
+ private func navRow(_ screen: Screen) -> some View {
+ let selected = store.selection == screen
+ return NavRowButton(screen: screen, selected: selected) {
+ store.selection = screen
+ }
+ }
+
+ // MARK: Memory footer (~56pt)
+
+ private var memoryFooter: some View {
+ let mem = store.memoryUsage
+ let frac = mem.totalGB > 0 ? min(mem.usedGB / mem.totalGB, 1.0) : 0
+
+ return VStack(alignment: .leading, spacing: 6) {
+ // Label
+ Text("UNIFIED MEMORY")
+ .font(Theme.Fonts.sectionLabel)
+ .tracking(0.85)
+ .textCase(.uppercase)
+ .foregroundStyle(Theme.Palette.textTertiary)
+
+ // Progress track (3pt, wellSink bg + running at 0.6 fill)
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Theme.Palette.wellSink)
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Theme.Palette.running.opacity(0.6))
+ .frame(width: geo.size.width * frac)
+ }
+ }
+ .frame(height: 3)
+
+ // Value in SF Mono
+ Text("\(mem.usedGB, format: .number.precision(.fractionLength(1))) / \(mem.totalGB, format: .number.precision(.fractionLength(0))) GB")
+ .font(.system(size: 11, weight: .regular, design: .monospaced))
+ .foregroundStyle(Theme.Palette.textSecondary)
+ }
+ .padding(.horizontal, Theme.Space.lg)
+ .padding(.vertical, Theme.Space.md)
+ .frame(height: 56)
+ }
+}
+
+// MARK: - Nav Row Button
+// 32pt tall, 10pt h-padding, 8pt symbol-to-label gap.
+// Selected: selectionFill bg + selectionBorder overlay + 2pt leading accent marker.
+// Hover: hoverOverlay.
+
+private struct NavRowButton: View {
+ let screen: Screen
+ let selected: Bool
+ let action: () -> Void
+
+ @State private var hovered = false
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 0) {
+ // 2pt leading accent marker (only when selected)
+ Rectangle()
+ .fill(selected ? Theme.Palette.signal : .clear)
+ .frame(width: 2, height: 20)
+ .clipShape(RoundedRectangle(cornerRadius: 1))
+ .padding(.trailing, 8)
+
+ // SF Symbol at 14pt medium in a 16×16 frame
+ Image(systemName: screen.symbol)
+ .font(.system(size: 14, weight: .medium))
+ .frame(width: 16, height: 16)
+ .foregroundStyle(selected ? Theme.Palette.ink : Theme.Palette.textSecondary)
+
+ // 8pt gap between symbol and label
+ Spacer().frame(width: 8)
+
+ // Screen label
+ Text(screen.title.localizedCapitalized)
+ .font(Theme.Fonts.bodyStrong)
+ .foregroundStyle(selected ? Theme.Palette.ink : Theme.Palette.textSecondary)
+
+ Spacer()
+
+ // Trailing shortcut at 10pt SF Mono tertiary
+ Text("⌘\(String(screen.shortcut.character).uppercased())")
+ .font(.system(size: 10, weight: .regular, design: .monospaced))
+ .foregroundStyle(Theme.Palette.textTertiary)
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 32)
+ .background(rowBackground)
+ .overlay(rowBorder)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .onHover { hovered = $0 }
+ }
+
+ @ViewBuilder private var rowBackground: some View {
+ if selected {
+ Theme.Palette.selectionFill
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.control))
+ } else if hovered {
+ Theme.Palette.hoverOverlay
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.control))
+ } else {
+ Color.clear
+ }
+ }
+
+ @ViewBuilder private var rowBorder: some View {
+ if selected {
+ RoundedRectangle(cornerRadius: Theme.Radius.control)
+ .strokeBorder(Theme.Palette.selectionBorder, lineWidth: 1)
+ }
+ }
+}
+
+// MARK: - Pulsing dot modifier (opacity 65%→100%, 1.2s, active run only)
+
+private struct PulsingDot: ViewModifier {
+ let active: Bool
+ @State private var pulsing = false
+
+ func body(content: Content) -> some View {
+ if active {
+ content
+ .opacity(pulsing ? 1.0 : 0.65)
+ .animation(
+ Animation.easeInOut(duration: 0.6).repeatForever(autoreverses: true),
+ value: pulsing
+ )
+ .onAppear { pulsing = true }
+ } else {
+ content
+ }
+ }
+}
+
+// MARK: - Screen symbol mapping
+
+extension Screen {
+ var symbol: String {
+ switch self {
+ case .models: "shippingbox"
+ case .chat: "text.bubble"
+ }
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Store/AppStore.swift b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift
new file mode 100644
index 0000000000..84ab4615f9
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift
@@ -0,0 +1,395 @@
+import SwiftUI
+import Observation
+
+// MARK: - The single source of truth (Observation framework, @MainActor).
+//
+// One @Observable store held in @State at the app root, handed to views as `@Bindable`.
+// No @EnvironmentObject, no Combine. CRITICAL: the live run is owned HERE, above the views,
+// keyed by identity — a screen re-render must never reset a running job.
+@MainActor
+@Observable
+final class AppStore {
+ var selection: Screen = .models
+ var models: [ModelInfo] = []
+ var adapters: [AdapterInfo] = []
+ var runs: [RunRecord] = []
+ var liveRun: LiveRun?
+ var inspectorPresented = false
+ var commandBarOpen = false
+ var binariesReady = false
+ var rowComfortable = false
+
+ var modelCachePath: String { LatticeBridge.modelCacheDir.path }
+ var repoRootPath: String? { LatticeBridge.repoRoot?.path }
+
+ // MARK: - Hoisted Chat state (survives NavigationSplitView teardown)
+
+ // Single-mode conversation transcript.
+ var chatTurns: [ChatTurn] = []
+ // GPU/CPU inference mode. true = chat_metal (Metal GPU); false = generate_lora (CPU BF16).
+ // Honest-label contract: this flag selects the binary AND anchors the label in each turn
+ // bubble at send time. A GPU-flagged run NEVER appears with a CPU label, and vice versa.
+ var chatUseGPU: Bool = false
+ // Selections — survive navigation.
+ var chatSelectedModelName: String = ""
+ var chatSelectedAdapterName: String = "none"
+ // Generation knob text fields.
+ var chatTempText: String = "0.7"
+ var chatMaxTokensText: String = "256"
+ var chatSeedText: String = ""
+ var chatTopKText: String = "50"
+ var chatTopPText: String = "0.9"
+ var chatRepPenaltyText: String = "1.1"
+ // In-flight tracking — must be store-owned so a generation that finishes while
+ // the user is on another screen still lands in chatTurns.
+ var chatAwaitingTurnID: UUID? = nil
+ var chatUserStoppedTurnID: UUID? = nil
+
+ // MARK: - Hoisted Eval workspace state (Stage 1 — structural plumbing)
+
+ // Active tab in EvalScreen ("PPL" | "Compare" | "Similar").
+ var evalActiveTab: String = "PPL"
+ // Model selection (multi-select; survives navigation).
+ var evalSelectedModelNames: Set = []
+ // Per-model adapter selection: modelName → adapterName.
+ var evalSelectedAdapterNames: [String: String] = [:]
+ // Generation knobs — separate from chat context; eval and chat are independent contexts.
+ var evalTempText: String = "0.7"
+ var evalMaxTokensText: String = "256"
+ var evalSeedText: String = ""
+ // Whether Compare tab generation should use the GPU Metal path.
+ // When true, each column dispatches to chat_metal (GPU Metal).
+ // When false, each column dispatches to generate_lora (CPU BF16).
+ // Honest-label contract: the label in EvalColumn.label is snapshotted at send time
+ // from the actual GenConfig.useGPU value so it always matches the binary that ran.
+ var evalUseGPU: Bool = false
+ // PPL corpus path (nil = use built-in default ~200-token corpus).
+ // Stored as a path string because URL is not directly @Observable-friendly.
+ var evalCorpusPath: String? = nil
+
+ // MARK: - Eval compare state (Stage 3 — N-way generation compare)
+
+ // Accumulated compare experiment pairs for the COMPARE tab.
+ // Each pair holds one EvalColumn per configured slot. Persists across navigation.
+ var evalComparePairs: [EvalComparePair] = []
+ // Index of the column currently being generated (0-based). -1 = idle.
+ var evalComparePhase: Int = -1
+ // ID of the pair currently being generated. nil = idle.
+ var evalCompareAwaitingPairID: UUID? = nil
+
+ // MARK: - Hoisted PPL state (survives ModelsScreen teardown)
+
+ /// Last measured perplexity per model ID. Keyed by ModelInfo.id (path).
+ /// Persisted across launches via ppl.json in the same AppSupport dir as runs.json.
+ var measuredPPL: [String: MeasuredPPL] = [:]
+
+ // MARK: - Get Models state
+
+ // Canonical names of models currently being downloaded.
+ var downloadingModels: Set = []
+ // Per-model download errors; cleared when a download is retried or succeeds.
+ var downloadErrors: [String: String] = [:]
+ // Name of model currently being imported from disk ("" when idle).
+ var importingModel: String = ""
+ // Last import error; cleared at the start of each import attempt.
+ var importError: String? = nil
+
+ private var handle: RunHandle?
+ // Dedicated handles for concurrent downloads — keyed by ObjectIdentifier so each
+ // download gets its own RunHandle and never evicts a training/eval job from `handle`.
+ var _downloadHandles: [ObjectIdentifier: RunHandle] = [:]
+
+ // Handles stopped to make way for a new run, kept alive here until their terminationHandler
+ // delivers onExit. RunHandle.terminationHandler captures [weak self], so once `launch()`
+ // reassigns `handle` the old handle would otherwise deallocate before its exit callback runs:
+ // finish() (and the run's onComplete) would never fire and an awaiting chat turn would hang
+ // .running forever. Each handle removes itself from this map inside its own onExit.
+ private var retiringHandles: [ObjectIdentifier: RunHandle] = [:]
+
+ init() {
+ runs = Self.loadRunArchive()
+ measuredPPL = Self.loadPPLArchive()
+ // Reap trainer processes orphaned by a previous app crash or force-quit.
+ // Done synchronously at init so no orphan can race with an immediately-launched run.
+ let reaped = RunRegistry.reapOrphans()
+ if reaped > 0 {
+ print("[AppStore] reaped \(reaped) orphaned trainer process(es) from previous session")
+ }
+ }
+
+ func onAppear() {
+ refreshModels()
+ binariesReady = LatticeBridge.prebuiltBinary(.lattice) != nil
+ }
+
+ // MARK: Run archive persistence
+
+ /// The shared `/LatticeStudio` base directory.
+ ///
+ /// Used by both the runs.json archive and RunRegistry's active-runs subdirectory so
+ /// both always resolve to the same location.
+ nonisolated static var appSupportDir: URL? {
+ guard let appSupport = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ ) else { return nil }
+ let dir = appSupport.appendingPathComponent("LatticeStudio", isDirectory: true)
+ // Create the subdirectory on first access.
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ /// URL of the on-disk runs.json archive in the app's Application Support directory.
+ private static var runsArchiveURL: URL? {
+ appSupportDir?.appendingPathComponent("runs.json")
+ }
+
+ /// Decode the persisted run archive from disk. Returns an empty array on any failure
+ /// (missing file, corrupt JSON, schema mismatch) — honest empty, never fabricated.
+ private static func loadRunArchive() -> [RunRecord] {
+ guard let url = runsArchiveURL,
+ let data = try? Data(contentsOf: url) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return (try? decoder.decode([RunRecord].self, from: data)) ?? []
+ }
+
+ /// Atomically write the finished run archive to disk. Call only with completed records.
+ private func persistRunArchive() {
+ guard let url = Self.runsArchiveURL else { return }
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = .prettyPrinted
+ guard let data = try? encoder.encode(runs) else { return }
+ try? data.write(to: url, options: .atomic)
+ }
+
+ // MARK: PPL archive persistence
+
+ private static var pplArchiveURL: URL? {
+ appSupportDir?.appendingPathComponent("ppl.json")
+ }
+
+ private static func loadPPLArchive() -> [String: MeasuredPPL] {
+ guard let url = pplArchiveURL,
+ let data = try? Data(contentsOf: url) else { return [:] }
+ return (try? JSONDecoder().decode([String: MeasuredPPL].self, from: data)) ?? [:]
+ }
+
+ func persistPPLArchive() {
+ guard let url = Self.pplArchiveURL else { return }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = .prettyPrinted
+ guard let data = try? encoder.encode(measuredPPL) else { return }
+ try? data.write(to: url, options: .atomic)
+ }
+
+ func refreshModels() {
+ Task.detached {
+ let found = LatticeBridge.discoverModels()
+ let foundAdapters: [AdapterInfo]
+ if let root = LatticeBridge.repoRoot {
+ foundAdapters = LatticeBridge.discoverAdapterPackages(in: root.appendingPathComponent("adapters", isDirectory: true))
+ } else {
+ foundAdapters = []
+ }
+ // Attach compatible adapters to each model so the Chat A/B picker
+ // (selectedModel.adapters) can offer them; the global list still backs MODELS.
+ let withAdapters = LatticeBridge.associateAdapters(foundAdapters, into: found)
+ await MainActor.run {
+ self.models = withAdapters
+ self.adapters = foundAdapters
+ }
+ }
+ }
+
+ /// Move adapter directory to Trash and refresh the adapter list.
+ func deleteAdapter(_ adapter: AdapterInfo) {
+ var trashURL: NSURL?
+ try? FileManager.default.trashItem(at: adapter.path, resultingItemURL: &trashURL)
+ refreshModels()
+ }
+
+ func model(named name: String) -> ModelInfo? { models.first { $0.name == name } }
+
+ // The model currently targeted across TRAIN / QUANTIZE / CHAT. Set from MODELS via `use(_:on:)`.
+ var workingModel: ModelInfo?
+ // Sensible default target: first non-embedding model, else first of any.
+ var defaultModel: ModelInfo? { models.first { !$0.isEmbedding } ?? models.first }
+ // The effective target a screen should drive: explicit working model, else the default.
+ var targetModel: ModelInfo? { workingModel ?? defaultModel }
+
+ /// Navigate to `screen` with `model` pre-selected as the working target.
+ func use(_ model: ModelInfo, on screen: Screen) {
+ workingModel = model
+ selection = screen
+ }
+
+ // MARK: Launch / lifecycle (the generic primitive screens build typed configs on top of)
+
+ @discardableResult
+ func launch(_ bin: LatticeBinary, args: [String], kind: RunKind, model: String, totalSteps: Int? = nil) -> LiveRun {
+ let run = LiveRun(kind: kind, modelName: model)
+ run.totalSteps = totalSteps
+ liveRun = run
+
+ guard let spec = LatticeBridge.launchSpec(bin, args: args) else {
+ run.status = .failed
+ run.appendLog("error: could not resolve `\(bin.binName)` — no prebuilt binary and no cargo fallback. Run `make build` in the lattice repo, or set LATTICE_BIN_DIR.")
+ return run
+ }
+ run.appendLog("$ \(spec.executable.lastPathComponent) \(args.joined(separator: " "))")
+
+ if let prior = handle, prior.isRunning {
+ // Retain the superseded handle until its onExit fires (see retiringHandles). The
+ // reassignment of `handle` below would otherwise drop its last strong reference, and
+ // its [weak self] terminationHandler could then no-op — stranding the prior run
+ // unresolved (a chat turn stuck .running with chatAwaitingTurnID never cleared).
+ retiringHandles[ObjectIdentifier(prior)] = prior
+ prior.stop()
+ }
+ let h = RunHandle()
+ h.onEvent = { [weak self] ev in self?.consume(ev, into: run) }
+ do {
+ try h.start(spec)
+ // Register AFTER a successful start so we never record a pid that never launched.
+ let pid = h.pid
+ RunRegistry.register(
+ pid: pid,
+ binPath: spec.executable.path,
+ kind: kind.rawValue,
+ startedAt: run.startedAt
+ )
+ // Set onExit AFTER start so it captures `pid` (a value) rather than `h`. Capturing
+ // `h` would retain-cycle through h.onExit and leak the RunHandle + its Process +
+ // pipe file descriptors on every run. No fast-exit race: terminationHandler
+ // dispatches onExit onto the main queue, and we are still on the main actor here,
+ // so onExit is always assigned before it can fire.
+ // ObjectIdentifier (a value) lets onExit drop a superseded handle from
+ // retiringHandles without capturing `h` itself, which would re-introduce the
+ // retain cycle the comment above avoids.
+ let hid = ObjectIdentifier(h)
+ h.onExit = { [weak self] code in
+ // Deregister before finish so the PID slot is freed even if finish throws.
+ RunRegistry.deregister(pid: pid)
+ self?.finish(run, code: code)
+ self?.retiringHandles.removeValue(forKey: hid)
+ }
+ } catch {
+ run.status = .failed
+ run.appendLog("launch failed: \(error.localizedDescription)")
+ }
+ handle = h
+ return run
+ }
+
+ private func consume(_ event: LatticeEvent, into run: LiveRun) {
+ switch event {
+ case .trainStep(let s):
+ // Step 0 carries the pre-training NLL, which equals train_done.base_nll
+ // (verified byte-identical against the binary). Capture it on the first event
+ // so "Δ FROM BASE" reads live from step 0 instead of "—" until the run ends.
+ // BEST VAL is deliberately NOT tracked here: the trainer computes its final
+ // held-out NLL once at completion (eval_valid on the saved final weights, not a
+ // best checkpoint), so any running minimum would diverge from the saved adapter
+ // and jump at trainDone. The live per-step held-out NLL is already shown in the
+ // HELD-OUT well; BEST VAL stays honest-nil until trainDone reports it.
+ if s.step == 0 { run.baseNLL = s.loss }
+ run.points.append(TrainPoint(step: s.step, loss: s.loss, valLoss: s.val_loss,
+ gradNorm: s.grad_norm, lr: s.lr, tokS: s.tok_s))
+ case .trainEval(let e):
+ run.bestVal = e.best_val ?? min(run.bestVal ?? e.val_loss, e.val_loss)
+ case .trainDone(let d):
+ run.baseNLL = d.base_nll
+ run.bestVal = d.best_val ?? run.bestVal
+ run.savedAdapterPath = d.saved
+ case .quantLayer(let q):
+ run.quantLayerIndex = q.i
+ run.quantLayerCount = q.n
+ // Track the dominant quantized scheme (first non-passthrough scheme wins).
+ // F16 layers are kept as-is; the quantized scheme is what gives the after-bits.
+ if run.quantScheme == nil, q.scheme != "F16", q.scheme != "BF16" {
+ run.quantScheme = q.scheme
+ }
+ case .quantDone(let q):
+ run.quantBeforeMB = q.before_mb
+ run.quantAfterMB = q.after_mb
+ run.quantRatio = q.ratio
+ run.verdict = q.verdict
+ run.quantMaxAbs = q.max_abs
+ case .genToken(let g):
+ // Accumulate streamed text; do NOT write token deltas to the generic log
+ // so the log stays clean for loader status lines and error messages.
+ run.genText += g.token
+ if g.done == true {
+ run.genTokS = g.tok_s
+ run.genDone = true
+ }
+ case .perplexity(let p):
+ // Append — a run may emit several rows (bf16/q4/quarot/adapter).
+ run.perplexities.append(p)
+ case .embedDone(let e):
+ // Replace — exactly one embed_done event per batch run.
+ run.embed = e
+ case .downloadDone:
+ // download_done events are handled by downloadModel's dedicated RunHandle;
+ // they should never reach a LiveRun's consume path.
+ break
+ case .status(let line):
+ run.appendLog(line)
+ case .unknown(let j):
+ run.appendLog(j)
+ }
+ }
+
+ private func finish(_ run: LiveRun, code: Int32) {
+ run.status = (code == 0) ? .done : .failed
+ // Capture the failure reason from the log when the process exits non-zero.
+ // Look for the last non-empty log line that isn't a banner (===) or launch echo ($).
+ // This surfaces the actual engine error (e.g. "Error: load model: Model not found…")
+ // without fabricating anything — honest-nil when no such line exists.
+ if code != 0 {
+ run.failureReason = run.log.last(where: { line in
+ !line.isEmpty && !line.hasPrefix("$") && !line.hasPrefix("===")
+ })
+ }
+ let rec = RunRecord(
+ id: "\(run.kind.rawValue)-\(Int(run.startedAt.timeIntervalSince1970))",
+ kind: run.kind, model: run.modelName, status: run.status, startedAt: run.startedAt,
+ lastLoss: run.currentLoss, bestVal: run.bestVal,
+ durationS: Date().timeIntervalSince(run.startedAt),
+ configSummary: nil, adapterPath: run.savedAdapterPath
+ )
+ runs.insert(rec, at: 0)
+ // Persist the archive immediately after appending the finished record.
+ persistRunArchive()
+ if run.savedAdapterPath != nil { refreshModels() }
+ // Fire the completion hook last — status, record, and disk state are all finalised.
+ // Used by screens to chain A/B follow-up runs (e.g. base eval → adapter eval).
+ run.onComplete?(run)
+ }
+
+ func pauseRun() { handle?.pause(); liveRun?.status = .paused }
+ func resumeRun() { handle?.resume(); liveRun?.status = .running }
+ func stopRun() { handle?.stop() }
+
+ func liveRun(matching kinds: Set) -> LiveRun? {
+ guard let r = liveRun, kinds.contains(r.kind) else { return nil }
+ return r
+ }
+
+ var memoryUsage: (usedGB: Double, totalGB: Double) {
+ let total = Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824.0
+ var info = mach_task_basic_info()
+ var count = mach_msg_type_number_t(MemoryLayout.size) / 4
+ let kerr = withUnsafeMutablePointer(to: &info) {
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
+ }
+ }
+ let used = kerr == KERN_SUCCESS ? Double(info.resident_size) / 1_073_741_824.0 : 0
+ return (used, total)
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift
new file mode 100644
index 0000000000..29b62280c0
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift
@@ -0,0 +1,371 @@
+import SwiftUI
+
+// MARK: - Navigation
+
+enum Screen: String, CaseIterable, Identifiable, Hashable {
+ case models, chat
+ var id: String { rawValue }
+
+ var index: String {
+ switch self {
+ case .models: "01"; case .chat: "02"
+ }
+ }
+ var title: String {
+ switch self {
+ case .models: "MODELS"; case .chat: "CHAT"
+ }
+ }
+ var shortcut: KeyEquivalent {
+ switch self {
+ case .models: "1"; case .chat: "2"
+ }
+ }
+
+ var hasInspector: Bool { return true }
+}
+
+// MARK: - Models on disk
+
+enum ModelFormat: String, Equatable {
+ case bf16 = "BF16"
+ case q4 = "Q4"
+ case quarot = "QuaRot Q4"
+ case embedding = "Embed"
+ case unknown = "—"
+
+ var badge: String { self == .quarot ? "rotated" : rawValue }
+ var isQuantized: Bool { self == .q4 || self == .quarot }
+}
+
+struct ModelInfo: Identifiable, Equatable {
+ var id: String { path.path }
+ var name: String
+ var path: URL
+ var format: ModelFormat
+ var params: String? // e.g. "0.8B" parsed from name/config
+ var dtype: String // BF16 / Q4_0 / mixed
+ var sizeBytes: Int64
+ var fileCount: Int
+ var hasTokenizer: Bool
+ var layerSummary: String? // e.g. "18 GDN · 6 GQA"
+ var hidden: Int?
+ var vocab: Int?
+ var contextLength: Int? // max_position_embeddings; nil when config.json absent
+ var attnHeads: Int? // num_attention_heads (GQA query heads)
+ var kvHeads: Int? // num_key_value_heads (GQA KV heads)
+ var headDim: Int? // head_dim (explicit; NOT hidden/heads for qwen3.5)
+ var gdnKeyHeads: Int? // linear_num_key_heads (GatedDeltaNet)
+ var gdnValueHeads: Int? // linear_num_value_heads (GatedDeltaNet)
+ var intermediateSize: Int? // intermediate_size — FFN/MLP inner width (3584 for qwen3.5)
+ var isEmbedding: Bool = false
+ var adapters: [AdapterInfo] = []
+}
+
+struct AdapterInfo: Identifiable, Equatable {
+ var id: String { path.path }
+ var name: String
+ var path: URL
+ var rank: Int?
+ var alpha: Double?
+ var targetModules: String?
+ var sizeBytes: Int64
+ // MLX-format fields (nil when absent or config unreadable — honest)
+ var baseModel: String? = nil
+ var scale: Double? = nil
+ var numLayers: Int? = nil
+ var checkpointCount: Int = 0
+
+ /// The `.safetensors` weight file to hand to `generate_lora --lora`, or `nil` when the
+ /// package has no usable weight file.
+ ///
+ /// Resolved ONCE at discovery (`LatticeBridge.discoverAdapterPackages` /
+ /// `discoverAdapters`) via `resolveWeightFile`, NOT recomputed per access — the Chat
+ /// picker filters non-runnable adapters (`weightFile == nil`) on every re-render, so this
+ /// must be a cheap stored read, never per-frame file I/O. `nil` is honest: the picker
+ /// hides the adapter and callers omit `--lora` rather than pass a path the engine cannot
+ /// read (it would otherwise silently run the base model under the adapter's label).
+ var weightFile: URL? = nil
+
+ /// Resolve the primary `.safetensors` weight file for an adapter package.
+ ///
+ /// `path` from `discoverAdapterPackages` is the package DIRECTORY, but the engine's
+ /// loader (`load_peft_safetensors`) reads a single file via `std::fs::read` and errors on
+ /// a directory. Resolve the file inside, by priority:
+ /// 1. `adapters.safetensors` — MLX/lattice final consolidated adapter
+ /// 2. `adapter.safetensors` — singular variant (lattice trainer output)
+ /// 3. `adapter_model.safetensors` — PEFT
+ /// 4. highest-numbered `*_adapters.safetensors` — MLX checkpoint (training stopped
+ /// before consolidation; pick the latest step)
+ /// Returns `path` unchanged when it already points at a file (loose-file adapters), or
+ /// `nil` when no weight file exists.
+ static func resolveWeightFile(at path: URL) -> URL? {
+ let fm = FileManager.default
+ var isDir: ObjCBool = false
+ guard fm.fileExists(atPath: path.path, isDirectory: &isDir) else { return nil }
+ if !isDir.boolValue { return path }
+
+ func candidate(_ name: String) -> URL? {
+ let u = path.appendingPathComponent(name)
+ return fm.fileExists(atPath: u.path) ? u : nil
+ }
+ if let f = candidate("adapters.safetensors") { return f }
+ if let f = candidate("adapter.safetensors") { return f }
+ if let f = candidate("adapter_model.safetensors") { return f }
+
+ guard let children = try? fm.contentsOfDirectory(
+ at: path, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
+ ) else { return nil }
+ // The "_adapters.safetensors" prefix is an integer step count, so pick the
+ // highest step NUMERICALLY (10 > 9), not lexicographically (where "9" > "10" as
+ // strings would load an older checkpoint), with the filename as a stable tie-breaker.
+ func step(_ url: URL) -> Int {
+ Int(url.lastPathComponent.prefix { $0.isNumber }) ?? -1
+ }
+ return children
+ .filter { $0.lastPathComponent.hasSuffix("_adapters.safetensors") }
+ .max { a, b in
+ let sa = step(a), sb = step(b)
+ return sa != sb ? sa < sb : a.lastPathComponent < b.lastPathComponent
+ }
+ }
+}
+
+// MARK: - Curated model catalog (Get Models sheet)
+
+// UI treatment axis: .downloadable → one-click Download (engine has a checksum-verified
+// downloader); .importOnly → "Copy HF URL" + manual Import (no in-app downloader). The
+// Qwen embedding model is import-only too — its loader has no fetch path, only a local-dir
+// lookup — so it belongs with the generative models, not in the Download section.
+enum CuratedKind { case downloadable, importOnly }
+
+struct CuratedModel: Identifiable {
+ let id: String // canonical name used by the embed binary
+ let name: String
+ let kind: CuratedKind
+ let detail: String // e.g. "384 dim · ~130 MB" or one-line description
+ let approxSize: String? // downloadable models only
+ let hfURL: String? // import-only models — HuggingFace source link
+}
+
+let curatedCatalog: [CuratedModel] = [
+ // Downloadable embeddings — `embed --model --download-only --json` (checksum-verified)
+ CuratedModel(id: "bge-small-en-v1.5", name: "bge-small-en-v1.5", kind: .downloadable, detail: "384 dim · English", approxSize: "~130 MB", hfURL: nil),
+ CuratedModel(id: "bge-base-en-v1.5", name: "bge-base-en-v1.5", kind: .downloadable, detail: "768 dim · English", approxSize: "~440 MB", hfURL: nil),
+ CuratedModel(id: "bge-large-en-v1.5", name: "bge-large-en-v1.5", kind: .downloadable, detail: "1024 dim · English", approxSize: "~1.3 GB", hfURL: nil),
+ CuratedModel(id: "multilingual-e5-small", name: "multilingual-e5-small", kind: .downloadable, detail: "384 dim · Multilingual", approxSize: "~470 MB", hfURL: nil),
+ CuratedModel(id: "multilingual-e5-base", name: "multilingual-e5-base", kind: .downloadable, detail: "768 dim · Multilingual", approxSize: "~1.1 GB", hfURL: nil),
+ CuratedModel(id: "all-minilm-l6-v2", name: "all-minilm-l6-v2", kind: .downloadable, detail: "384 dim · English", approxSize: "~90 MB", hfURL: nil),
+ CuratedModel(id: "paraphrase-multilingual-minilm-l12-v2", name: "paraphrase-multilingual-minilm-l12-v2", kind: .downloadable,
+ detail: "384 dim · Multilingual", approxSize: "~470 MB", hfURL: nil),
+
+ // Import-only — no in-app downloader; fetch from HuggingFace, then use Import from Disk
+ CuratedModel(id: "qwen3.5-0.8b", name: "qwen3.5-0.8b", kind: .importOnly,
+ detail: "0.8B generative model — lightweight, fast",
+ approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3.5-0.8B"),
+ CuratedModel(id: "qwen3.5-2b", name: "qwen3.5-2b", kind: .importOnly,
+ detail: "2B generative model — better quality",
+ approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3.5-2B"),
+ CuratedModel(id: "qwen3-embedding-0.6b", name: "qwen3-embedding-0.6b", kind: .importOnly,
+ detail: "0.6B embedding model — import-only (no in-app download)",
+ approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3-Embedding-0.6B"),
+]
+
+// MARK: - Chat domain models (owned by AppStore; moved here from ChatScreen so the store can persist them)
+
+/// A single exchange in single-mode chat.
+struct ChatTurn: Identifiable {
+ enum TurnStatus { case running, done, failed }
+
+ let id = UUID()
+ let prompt: String
+ var responseText: String = ""
+ var status: TurnStatus = .running
+ var tokensPerSecond: Double? = nil
+ /// Error message from the run log — set when the turn finishes with an empty response.
+ /// Shown instead of "(no output)" so Ocean knows WHY the engine produced nothing.
+ var errorMessage: String? = nil
+ /// The GenConfig used to produce this turn — stored so Retry can re-launch the identical run.
+ var retryConfig: ChatGenConfig? = nil
+ /// Honest hardware label, snapshotted at send time: "GPU Metal bf16", "CPU bf16", etc.
+ /// Never updated after the run starts — what launched is what ran.
+ var inferenceLabel: String? = nil
+}
+
+/// The generation parameters needed to retry a failed/empty turn.
+/// Mirrors fields of GenConfig but uses serialisation-friendly plain types so it can live on ChatTurn.
+struct ChatGenConfig: Equatable {
+ var modelDirPath: String?
+ var model: String?
+ var tokenizerDirPath: String?
+ var adapterFilePath: String?
+ var prompt: String
+ var maxTokens: Int
+ var seed: UInt64?
+ var temperature: Double
+ var topK: Int = 50
+ var topP: Double = 0.9
+ var repetitionPenalty: Double = 1.1
+ /// Whether this config targets the GPU Metal path (chat_metal) or CPU (generate_lora).
+ var useGPU: Bool = false
+}
+
+/// One A/B experiment pair (base column vs adapter column).
+struct ComparePair: Identifiable {
+ enum Side { case base, adapter }
+
+ let id = UUID()
+ let prompt: String // raw user text (for display)
+ let baseLabel: String // base model name, snapshotted at creation
+ let adapterLabel: String // adapter name, snapshotted at creation
+ var baseText: String = ""
+ var baseTokS: Double? = nil
+ var baseDone: Bool = false
+ var adapterText: String = ""
+ var adapterTokS: Double? = nil
+ var adapterDone: Bool = false
+ var failed: Bool = false
+ var failureReason: String? = nil // surface engine error when failed
+}
+
+/// Tracks which phase of an A/B run sequence is active.
+enum ABPhase { case idle, base, adapter }
+
+// MARK: - Eval compare domain models (owned by AppStore via evalComparePairs)
+
+/// One column in an N-way compare pair — holds the result for a single model/adapter slot.
+struct EvalColumn {
+ /// Display label snapshotted at pair creation: "model-name · adapter-name · device-tag".
+ /// Survives picker changes — labels never relabel a completed result.
+ let label: String
+ /// Accumulated token stream from gen_token events.
+ var text: String = ""
+ /// Final tok/s from the generation done event.
+ var tokS: Double? = nil
+ /// True once this column's run has completed (done or failed).
+ var done: Bool = false
+ /// True if the run failed AND produced no output (honest failure, not partial output).
+ var failed: Bool = false
+ /// Engine error message from the run log when failed == true. Honest-nil when unknown.
+ var failureReason: String? = nil
+}
+
+/// One N-way compare experiment — shared prompt, one EvalColumn per model/adapter slot.
+struct EvalComparePair: Identifiable {
+ let id = UUID()
+ /// Raw user prompt text (shown as the cell header in each column).
+ let prompt: String
+ /// One entry per slot at the time the pair was submitted. Immutable count; column content mutates.
+ var columns: [EvalColumn]
+}
+
+// MARK: - PPL measurement cache
+
+/// A single measured perplexity snapshot, keyed into AppStore.measuredPPL by modelID.
+struct MeasuredPPL: Equatable, Codable {
+ var modelID: String
+ var bf16: Double?
+ var quant: Double?
+ var quantLabel: String? // "Q4" or "QuaRot"
+}
+
+// MARK: - Runs (the lab notebook)
+
+enum RunKind: String, Equatable, Codable {
+ case train = "LoRA"
+ case quantizeQ4 = "Q4"
+ case quantizeQuaRot = "QuaRot"
+ case chat = "Chat"
+ case eval = "Eval" // eval_perplexity — measures PPL for one or more model variants
+ case embed = "Embed" // embed — produces embedding vectors and a pairwise cosine matrix
+}
+enum RunStatus: String, Equatable, Codable { case idle, running, paused, done, failed }
+
+struct RunRecord: Identifiable, Equatable, Codable {
+ var id: String
+ var kind: RunKind
+ var model: String
+ var status: RunStatus
+ var startedAt: Date
+ var lastLoss: Double?
+ var bestVal: Double?
+ var durationS: Double?
+ var configSummary: String?
+ var adapterPath: String?
+}
+
+// MARK: - Live training series (drives the oscilloscope strip chart + scrub-to-freeze)
+
+struct TrainPoint: Identifiable, Equatable {
+ var id: Int { step }
+ var step: Int
+ var loss: Double
+ var valLoss: Double?
+ var gradNorm: Double?
+ var lr: Double?
+ var tokS: Double?
+}
+
+@Observable
+final class LiveRun {
+ var kind: RunKind
+ var modelName: String
+ var status: RunStatus = .running
+ var startedAt: Date = Date()
+ var totalSteps: Int?
+
+ // Train
+ var points: [TrainPoint] = []
+ var bestVal: Double?
+ var baseNLL: Double?
+ var savedAdapterPath: String?
+
+ // Quantize
+ var quantBeforeMB: Double?
+ var quantAfterMB: Double?
+ var quantRatio: Double?
+ var quantLayerIndex: Int = 0
+ var quantLayerCount: Int = 0
+ var verdict: String?
+ var quantMaxAbs: Double? // QuaRot forward-equivalence max abs error
+ var quantScheme: String? // dominant quantization scheme seen in quant_layer events
+
+ // Generation streaming (chat / generate_lora --json mode)
+ var genText: String = "" // accumulated incremental token deltas from gen_token events
+ var genTokS: Double? = nil // final tokens/sec from the done event (tok_s field)
+ var genDone: Bool = false // set true when the gen_token done event arrives
+
+ // Eval (eval_perplexity --json mode)
+ // A single run may emit multiple rows — one per measurement label (bf16/q4/quarot/adapter).
+ var perplexities: [LatticeEvent.Perplexity] = []
+
+ // Embed (embed --json mode)
+ // Exactly one embed_done event is expected per run (batch mode: all texts in one call).
+ var embed: LatticeEvent.EmbedDone? = nil
+
+ // Completion hook — fired by AppStore.finish() after status and RunRecord are set.
+ // Used by screens to chain a follow-up run (e.g. base eval → adapter eval A/B sequence).
+ // Closures cannot conform to Codable; never serialised to disk.
+ var onComplete: ((LiveRun) -> Void)? = nil
+
+ // Failure reason — the last meaningful error line from the subprocess (non-empty, non-banner).
+ // Set by AppStore.finish() when the exit code is non-zero and the log contains an error.
+ // Honest-nil when the process exited non-zero but produced no parseable error line.
+ var failureReason: String? = nil
+
+ // Shared
+ var log: [String] = []
+
+ var currentStep: Int { points.last?.step ?? 0 }
+ var currentLoss: Double? { points.last?.loss }
+
+ init(kind: RunKind, modelName: String) {
+ self.kind = kind
+ self.modelName = modelName
+ }
+
+ func appendLog(_ line: String) {
+ log.append(line)
+ if log.count > 4000 { log.removeFirst(log.count - 4000) }
+ }
+}
diff --git a/apps/macos/Sources/LatticeStudio/Theme/Theme.swift b/apps/macos/Sources/LatticeStudio/Theme/Theme.swift
new file mode 100644
index 0000000000..bdc6e55e6d
--- /dev/null
+++ b/apps/macos/Sources/LatticeStudio/Theme/Theme.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+import AppKit
+
+// MARK: - Color helpers (code-defined adaptive palette — no asset catalog, so `swift build` is hermetic)
+
+extension NSColor {
+ convenience init(hex: UInt32, alpha: CGFloat = 1.0) {
+ let r = CGFloat((hex >> 16) & 0xFF) / 255.0
+ let g = CGFloat((hex >> 8) & 0xFF) / 255.0
+ let b = CGFloat(hex & 0xFF) / 255.0
+ self.init(srgbRed: r, green: g, blue: b, alpha: alpha)
+ }
+}
+
+extension Color {
+ /// Resolves light/dark at the AppKit layer so the value tracks system appearance automatically.
+ static func adaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1.0) -> Color {
+ Color(nsColor: NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ return NSColor(hex: isDark ? dark : light, alpha: alpha)
+ })
+ }
+}
+
+// MARK: - Theme: the single source of truth for the Lattice Instrument visual language.
+//
+// Graphite Signal Lab — dark graphite surfaces, 10pt cards, white-opacity hairlines.
+// One cyan accent (signal/CTA). Monospaced numerals on opaque surfaces.
+//
+// Governing laws (DESIGN.md):
+// 1. Numbers never touch glass — every numeral sits on an opaque surface.
+// 2. One accent (Signal Teal / #48D8C4), spent only on movement / the single CTA.
+// 3. Bold is spent on the NUMBERS (tabular mono), not on chrome.
+enum Theme {
+
+ // MARK: Palette — Dark is the home key; light is a true peer.
+ enum Palette {
+ // ── Base surfaces ──────────────────────────────────────────────────────────
+
+ /// Window background beneath all content.
+ static let window = Color.adaptive(light: 0xF4F6F8, dark: 0x090C10)
+
+ /// Main screen canvas.
+ static let canvas = Color.adaptive(light: 0xEEF1F4, dark: 0x0D1117)
+
+ /// Solid sidebar fallback when Reduce Transparency is enabled.
+ static let sidebarFallback = Color.adaptive(light: 0xF0F3F7, dark: 0x10151C)
+
+ /// Standard panels and cards (spec: surface). Replaces old `panel`.
+ static let panel = Color.adaptive(light: 0xFFFFFF, dark: 0x121820)
+
+ /// Controls, selected cards, elevated sections.
+ static let surfaceRaised = Color.adaptive(light: 0xF7F9FB, dark: 0x171E27)
+
+ /// Hovered rows and controls.
+ static let surfaceHover = Color.adaptive(light: 0xEDF2F5, dark: 0x1C2530)
+
+ /// Charts, logs, code and recessed readouts (spec: surfaceInset). Replaces old `wellSink`.
+ static let wellSink = Color.adaptive(light: 0xE8EDF2, dark: 0x080B0F)
+
+ /// Alias kept for code that imports surfaceInset directly.
+ static var surfaceInset: Color { wellSink }
+
+ /// Disabled control fill.
+ static let surfaceDisabled = Color.adaptive(light: 0xEBEFF3, dark: 0x11161C)
+
+ // ── Text ───────────────────────────────────────────────────────────────────
+
+ /// Primary labels and prose. Replaces old `ink`.
+ static let ink = Color.adaptive(light: 0x11151A, dark: 0xF2F5F7)
+
+ /// Alias kept for direct textPrimary references.
+ static var textPrimary: Color { ink }
+
+ /// Supporting text. Replaces old `inkDim`.
+ static let inkDim = Color.adaptive(light: 0x56616D, dark: 0xA7B0BA)
+
+ /// Alias kept for direct textSecondary references.
+ static var textSecondary: Color { inkDim }
+
+ /// Metadata, inactive labels, axis values.
+ /// WCAG AA fix: 0x646E79 = 4.58:1 on canvas 0xEEF1F4; 0x79838F = 4.92:1 on canvas 0x0D1117.
+ static let textTertiary = Color.adaptive(light: 0x646E79, dark: 0x79838F)
+
+ /// Disabled controls.
+ static let textDisabled = Color.adaptive(light: 0x9AA3AD, dark: 0x4E5965)
+
+ // ── Accent ─────────────────────────────────────────────────────────────────
+
+ /// The one accent. Live trace, token stream, now-cursor, focus ring, the single CTA per screen.
+ static let signal = Color.adaptive(light: 0x078F82, dark: 0x48D8C4)
+
+ /// Alias kept for direct accent references.
+ static var accent: Color { signal }
+
+ /// Hovered primary action.
+ static let accentHover = Color.adaptive(light: 0x0AA493, dark: 0x63E2D1)
+
+ /// Pressed primary action.
+ static let accentActive = Color.adaptive(light: 0x08786F, dark: 0x2EB9A7)
+
+ /// Text/icons on accent fill.
+ /// WCAG AA fix (light): 0x0A0D11 = 4.88:1 on signal 0x078F82 (white was only 3.99:1).
+ /// Dark stays 0x04110F = 10.89:1 on signal 0x48D8C4.
+ static let onAccent = Color.adaptive(light: 0x0A0D11, dark: 0x04110F)
+
+ /// Accent glow fill — accent at 12% opacity.
+ static let signalGlow = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.12)
+
+ // ── State semantics ────────────────────────────────────────────────────────
+
+ /// Idle engine state.
+ static let idle = Color.adaptive(light: 0x7D8793, dark: 0x7D8793)
+
+ /// Active run.
+ static let running = Color.adaptive(light: 0x078F82, dark: 0x48D8C4)
+
+ /// Completed, valid, improvement.
+ static let success = Color.adaptive(light: 0x3BAF5C, dark: 0x6BD58C)
+
+ /// Validation warning, partial issue.
+ static let amber = Color.adaptive(light: 0xB8730A, dark: 0xF2B85B)
+
+ /// Alias kept for direct warning references.
+ static var warning: Color { amber }
+
+ /// Failed, invalid, regression.
+ static let crimson = Color.adaptive(light: 0xC42032, dark: 0xFF6B73)
+
+ /// Alias kept for direct error references.
+ static var error: Color { crimson }
+
+ // ── Neutral control ────────────────────────────────────────────────────────
+
+ /// Neutral steel for interactive controls (slider fill, segmented selection, toggle-on,
+ /// field caret). Spent so the accent above stays reserved for live data + the single CTA.
+ static let control = Color.adaptive(light: 0x8A909B, dark: 0x6B7280)
+
+ // ── Hairline ───────────────────────────────────────────────────────────────
+
+ /// 1px ruled borders — depth via line, not shadow.
+ static let hairline = Color.adaptive(light: 0xDCDFE5, dark: 0x23262E)
+
+ // ── Opacity-based border / overlay helpers (white-relative, use on dark surfaces) ──
+
+ /// Default card border — white at 8%.
+ static let borderStandard = Color.white.opacity(0.08)
+
+ /// Selected/strong card border — white at 14%.
+ static let borderStrong = Color.white.opacity(0.14)
+
+ /// Hovered row/control overlay — white at 4%.
+ static let hoverOverlay = Color.white.opacity(0.04)
+
+ /// Accent selection fill — accent at 12%.
+ static let selectionFill = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.12)
+
+ /// Accent selection border — accent at 35%.
+ static let selectionBorder = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.35)
+
+ /// Accent focus ring — accent at 55%.
+ static let focusRing = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.55)
+
+ /// Chart minor grid — white at 4%.
+ static let chartGridMinor = Color.white.opacity(0.04)
+
+ /// Chart major grid — white at 8%.
+ static let chartGridMajor = Color.white.opacity(0.08)
+ }
+
+ // MARK: Typography — mono is the signature. SF Mono today; swap `mono()` to bundled JetBrains Mono later.
+ enum Fonts {
+ /// Tabular monospace for every numeral / data cell. `.monospacedDigit()` is applied at use sites.
+ static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
+ .system(size: size, weight: weight, design: .monospaced)
+ }
+ /// SF Pro for titles, labels, prose.
+ static func display(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font {
+ .system(size: size, weight: weight, design: .default)
+ }
+
+ // ── Legacy modular scale (preserved for backward compat) ──────────────────
+ // Modular scale (pt): 11 · 13 · 15 · 21 · 34 · 56
+ static let hero = mono(56, .bold) // the loss / compression / PPL headline
+ static let heroAlt = mono(34, .bold)
+ static let heroMinor = mono(21, .semibold)
+ static let wellValue = mono(15, .medium) // readout-well value
+ static let readout = mono(13) // dense readouts
+ static let cell = mono(11) // table cells
+ static let title = display(17, .semibold)
+ static let body = display(13, .regular)
+ /// 11pt all-caps instrument label (LOSS, TOK/S, GRAD-NORM). Apply `.textCase(.uppercase)` + tracking.
+ static let label = display(11, .medium)
+
+ // ── Graphite Signal Lab role scale (new additions) ────────────────────────
+
+ /// Screen/section title — Models, Train, Quantize.
+ static let screenTitle = display(22, .semibold)
+
+ /// Selected model or run header inside an inspector.
+ static let inspectorTitle = display(17, .semibold)
+
+ /// Group labels — always rendered uppercase + 0.85 tracking at use site.
+ static let sectionLabel = display(11, .semibold)
+
+ /// Row names and values (medium weight body).
+ static let bodyStrong = display(13, .medium)
+
+ /// Buttons, pickers — 12pt medium.
+ /// (Named `controlText` to avoid shadowing the `control` palette token.)
+ static let controlText = display(12, .medium)
+
+ /// Supporting text — 11pt regular.
+ static let caption = display(11, .regular)
+
+ /// Metric labels, metadata — 10pt medium (+0.45 tracking at use site).
+ static let micro = display(10, .medium)
+
+ /// Large loss / error / size headline — 20pt SF Mono medium.
+ static let largeMetric = mono(20, .medium)
+
+ /// Counts, dimensions — 13pt SF Mono medium.
+ static let metric = mono(13, .medium)
+
+ /// Params, bytes, duration table cells — 12pt SF Mono regular.
+ static let tableNumeric = mono(12)
+
+ /// Commands and JSON — 12pt SF Mono regular.
+ static let codeFont = mono(12)
+
+ /// Streaming console — 11pt SF Mono regular.
+ static let logFont = mono(11)
+ }
+
+ // MARK: Spacing — 4pt base grid (Graphite Signal Lab); legacy 8pt names preserved.
+ enum Space {
+ // ── Legacy 8pt-grid names (kept for backward compat) ─────────────────────
+ static let xs: CGFloat = 4
+ static let sm: CGFloat = 8
+ static let md: CGFloat = 12
+ static let lg: CGFloat = 16 // internal panel padding
+ static let xl: CGFloat = 24 // section gutter
+ static let xxl: CGFloat = 32
+
+ static let railWidth: CGFloat = 220
+ static let inspectorWidth: CGFloat = 300
+ static let rowHeight: CGFloat = 28
+ static let rowHeightComfortable: CGFloat = 32
+ static let labelTracking: CGFloat = 0.6
+
+ // ── 4pt scale additions ───────────────────────────────────────────────────
+ static let space1: CGFloat = 4
+ static let space2: CGFloat = 8
+ static let space3: CGFloat = 12
+ static let space4: CGFloat = 16
+ static let space5: CGFloat = 20
+ static let space6: CGFloat = 24
+ static let space8: CGFloat = 32
+ static let space10: CGFloat = 40
+ static let space12: CGFloat = 48
+
+ // ── Layout constants ──────────────────────────────────────────────────────
+ static let sidebarMin: CGFloat = 196
+ static let sidebarIdeal: CGFloat = 212
+ static let sidebarMax: CGFloat = 240
+ static let configRail: CGFloat = 320
+ static let detailInspector: CGFloat = 300
+ static let controlHeight: CGFloat = 30
+ static let controlHeightCompact: CGFloat = 26
+ static let controlHeightLarge: CGFloat = 34
+ static let chatMaxWidth: CGFloat = 920
+ static let dataMaxWidth: CGFloat = 1480
+ static let emptyStateMaxWidth: CGFloat = 360
+ }
+
+ // MARK: Corner radii — Graphite Signal Lab: panels are 10pt cards; wells 8pt.
+ enum Radius {
+ /// Standard card / panel / chart container (was 0 — now 10pt continuous cards).
+ static let panel: CGFloat = 10
+
+ /// Readout well / metric well (was 6 — now 8pt).
+ static let well: CGFloat = 8
+
+ /// Buttons, text fields, segmented items.
+ static let control: CGFloat = 6
+
+ /// Status capsules (backward-compat alias; use `.infinity` for a true capsule at use site).
+ static let pill: CGFloat = 6
+
+ /// Command / composer bar.
+ static let commandBar: CGFloat = 10
+
+ /// Compact badges and tiny indicators.
+ static let badge: CGFloat = 4
+ }
+
+ // MARK: Motion — mechanical, never bouncy.
+ enum Motion {
+ static let tick: Double = 0.12
+ static let focus: Double = 0.18
+ static let pane: Double = 0.24
+ /// Spring is reserved for EXACTLY one element: the adapter hot-swap fader.
+ static let faderSpring = Animation.spring(response: 0.32, dampingFraction: 0.85)
+ static let chartCommitHz: Double = 20
+ static let numeralTickHz: Double = 8
+
+ // ── Graphite Signal Lab timing additions ──────────────────────────────────
+ /// Hover and selection — 100–120 ms ease-out.
+ static let hover: Double = 0.11
+
+ /// Button pressed state — 60–80 ms.
+ static let press: Double = 0.07
+
+ /// Metric numeric transition — 120–160 ms.
+ static let metric: Double = 0.14
+
+ /// Progress interpolation — 120–180 ms.
+ static let progress: Double = 0.15
+ }
+}
+
+// MARK: - Reusable view modifiers expressing the laws
+
+extension View {
+ /// An OPAQUE instrument label: 11pt all-caps, dimmed, tracked. For LOSS / TOK/S / GRAD-NORM.
+ func instrumentLabel() -> some View {
+ self.font(Theme.Fonts.label)
+ .textCase(.uppercase)
+ .tracking(Theme.Space.labelTracking)
+ .foregroundStyle(Theme.Palette.inkDim)
+ }
+}
diff --git a/apps/macos/docs/INSTRUMENT_SCOPE.md b/apps/macos/docs/INSTRUMENT_SCOPE.md
new file mode 100644
index 0000000000..22013e5a30
--- /dev/null
+++ b/apps/macos/docs/INSTRUMENT_SCOPE.md
@@ -0,0 +1,427 @@
+# Lattice Instrument — Scope & Architecture
+
+> Status as of 2026-06-21. Every claim cites a real file. `EXISTS` = code present.
+> `PARTIAL` = code present, stated limitations apply. `PROPOSED` = not yet implemented.
+
+---
+
+## 1. Purpose & Scope
+
+Lattice Instrument is a zero-dependency macOS 14 SwiftUI application (Swift 6.0 tools,
+Package.swift line 7) that wraps the Rust `lattice-tune` and `lattice-inference` engine
+binaries as subprocess instruments. Its purpose is to let Ocean run LoRA fine-tuning,
+model quantization, chat testing, and training-data inspection without leaving a macOS
+window — while the Rust binaries remain the single source of correctness.
+
+The app is NOT a trainer or inference engine. It is an instrument panel: it spawns
+processes, parses their output, and renders live readouts.
+
+**Platform**: macOS 14+, Swift 6.0 tools, Swift language mode v5, zero external deps.
+**Bundle ID**: `ai.khive.lattice.studio` (package-app.sh line 9).
+**Min window**: 1080×720 (LatticeStudioApp.swift line 12).
+
+---
+
+## 2. Current State Inventory
+
+### 2.1 Swift Source Files (28 files in 7 directories)
+
+| Dir | File | Role | State |
+|-----|------|------|-------|
+| App/ | LatticeStudioApp.swift | @main entry, @NSApplicationDelegateAdaptor, @State AppStore | EXISTS |
+| Bridge/ | LatticeBridge.swift | Process spawn, binary resolution, model/adapter discovery | EXISTS |
+| Bridge/ | LatticeEvents.swift | `@@lattice` protocol decoder, HumanLineParser, QuantAccumulator | EXISTS |
+| Bridge/ | Drivers.swift | TrainConfig, QuantConfig, GenConfig typed arg-builders; AppStore launch extensions | EXISTS |
+| Store/ | AppStore.swift | @Observable @MainActor singleton, run lifecycle, event routing, run archive | EXISTS |
+| Store/ | DomainModels.swift | Screen, ModelInfo, AdapterInfo, RunKind, LiveRun, TrainPoint, RunRecord | EXISTS |
+| Shell/ | ContentView.swift | NavigationSplitView two-pane shell, CommandBar overlay, global ⌘1-6 shortcuts | EXISTS |
+| Shell/ | LeftRail.swift | Wordmark, live RUN block (step+loss), nav rows, system memory bar | EXISTS |
+| Screens/ | TrainScreen.swift | LoRA fine-tune config + live oscilloscope + control strip | EXISTS |
+| Screens/ | QuantizeScreen.swift | Q4 / QuaRot config + layer progress + mass comparison | EXISTS |
+| Screens/ | ModelsScreen.swift | Model DataTable + inspector + action row (Train/Quantize/Chat→) | EXISTS |
+| Screens/ | ChatScreen.swift | Config strip + single-variant transcript + generate_lora subprocess | PARTIAL |
+| Screens/ | DataScreen.swift | Source dir scan, .jsonl inspector, builder-script copy buttons | EXISTS |
+| Screens/ | RunsScreen.swift | Run archive DataTable + live banner + inspector | EXISTS |
+| Screens/ | ScreenScaffold.swift | Shared header chrome (index / title / subtitle / trailing slot) | EXISTS |
+| Components/ | CommandBar.swift | ⌘K floating palette, fuzzy prefix match, 7 default commands | EXISTS |
+| Components/ | StripChart.swift | Swift Charts oscilloscope: LineMark + AreaMark + scrub-to-freeze | EXISTS |
+| Components/ | HeroNumber.swift | 56pt tabular-mono hero with .contentTransition(.numericText()) | EXISTS |
+| Components/ | GatePill.swift | PASS/WARN/FAIL/RUN verdict pill, 6px radius, animated pulse on RUN | EXISTS |
+| Components/ | FaderToggle.swift | Console spring-fader for binary mode choices (Q4↔QuaRot, BASE↔+ADAPTER) | EXISTS |
+| Components/ | ReadoutWell.swift | 15pt tabular-mono well: label + value + unit + delta caret | EXISTS |
+| Components/ | OpaquePanel.swift | Instrument panel surface (opaque, 1px hairline, 0px radius) + well surface | EXISTS |
+| Components/ | ParamRow.swift | Config param row variants used in TrainScreen/QuantizeScreen | EXISTS |
+| Components/ | DataTable.swift | Generic sortable DataTable used across Models/Data/Runs screens | EXISTS |
+| Components/ | ContrastPair.swift | Before/after contrast pair (fold-wipe on completion) used in QuantizeScreen | EXISTS |
+| Components/ | MassBars.swift | Dual mass bars (before/after MB) used in QuantizeScreen | EXISTS |
+| Components/ | KeyCapChip.swift | ⌘K keyboard shortcut keycap chip used in CommandBar | EXISTS |
+| Theme/ | Theme.swift | Adaptive palette, SF Mono fonts, motion constants — no asset catalog | EXISTS |
+
+### 2.2 State Model
+
+```
+AppStore (@Observable @MainActor)
+ ├── selection: Screen current nav screen
+ ├── models: [ModelInfo] discovered on disk
+ ├── runs: [RunRecord] JSON-persisted archive
+ ├── liveRun: LiveRun? the one active subprocess run
+ ├── workingModel: ModelInfo? explicit cross-screen target
+ ├── handle: RunHandle? the one live Process wrapper
+ └── binariesReady: Bool prebuilt .lattice binary present
+```
+
+Only one subprocess runs at a time. `AppStore.launch()` calls `prior.stop()` before starting
+a new one (AppStore.swift line 108).
+
+### 2.3 What Works Today
+
+- TrainScreen: full config → subprocess → live step/loss/eval/done parsing → StripChart,
+ ReadoutWells, PAUSE/RESUME/STOP, adapter path on save. End-to-end verified in production
+ (NLL 5.18→0.61 documented in MEMORY.md).
+- QuantizeScreen: Q4 and QuaRot, layer progress, mass bars, ratio, verdict GatePill.
+ Drives both `quantize_q4` and `quantize_quarot` binaries.
+- ModelsScreen: model discovery (parses config.json), layer summary, adapter list, navigate-to
+ shortcuts, Finder reveal.
+- DataScreen: .jsonl scan, summary stats, 5-example preview, builder-script copy buttons.
+- RunsScreen: persistent run archive (Application Support/LatticeStudio/runs.json), live banner.
+- CommandBar: ⌘K palette with 7 commands, fuzzy match.
+- ChatScreen config strip and transcript: functional for single-variant generation.
+
+---
+
+## 3. Engine-Wrapping Architecture
+
+### 3.1 Process Spawn
+
+`RunHandle` (LatticeBridge.swift) wraps `Foundation.Process` with two `Pipe`s:
+
+```
+AppStore.launch(bin, args)
+ → LatticeBridge.launchSpec(bin, args) resolves binary path
+ → RunHandle.start(spec) process.launch()
+ stdout → Pipe → readabilityHandler
+ split on 0x0A
+ LatticeEventParser.parse(line:)
+ → .trainStep / .quantLayer / .genToken / .status / .unknown
+ stderr → merged into same outPipe
+ stdin ← RunHandle.send(line:) (chat only)
+ SIGSTOP / SIGCONT via RunHandle.pause() / resume()
+ → AppStore.consume(event, into: liveRun) main-queue dispatch
+ → LiveRun @Observable → SwiftUI renders
+```
+
+`Source: LatticeBridge.swift (RunHandle class) + AppStore.swift lines 96-120`
+
+### 3.2 Binary Path Resolution
+
+Resolution order (LatticeBridge.swift `launchSpec`):
+
+1. `.app/Contents/Resources/bin/` (distribution build)
+2. `$LATTICE_BIN_DIR/` (env override)
+3. `../target/release/` (relative to bundle's Resources)
+4. `cargo run -p --bin --features ` (fallback)
+
+`Source: LatticeBridge.swift + apps/macos/DISTRIBUTION.md`
+
+### 3.3 Bundled Binaries (6)
+
+Defined in `apps/macos/scripts/package-app.sh`:
+
+| Binary | Crate | Features | Used For |
+|--------|-------|----------|----------|
+| `quantize_q4` | lattice-inference | (none) | Q4 quantize |
+| `quantize_quarot` | lattice-inference | (none) | QuaRot quantize |
+| `lattice` | lattice-inference | (none) | main inference binary |
+| `qwen35_generate` | lattice-inference | (none) | Qwen3.5 generation |
+| `train_grad_full` | lattice-tune | `train-backward` | LoRA fine-tune |
+| `generate_lora` | lattice-tune | `safetensors,inference-hook` | LoRA chat generation |
+
+### 3.4 Event Protocol
+
+The bridge has two parse paths (LatticeEvents.swift):
+
+**Path 1 — JSON sentinel (preferred, available when `--json` passed):**
+
+```
+@@lattice {"ev":"train_step","step":5,"loss":3.2341,"lr":0.001000}
+@@lattice {"ev":"train_eval","step":5,"val_loss":3.1200,"best_val":3.1200}
+@@lattice {"ev":"train_done","base_nll":5.18,"final_nll":0.61,"duration_s":120.0}
+@@lattice {"ev":"gen_token","token":" hello","done":false}
+@@lattice {"ev":"gen_token","token":"","done":true,"tok_s":28.4,"ttft_ms":312.0}
+```
+
+Prefix constant: `let kLatticeEventPrefix = "@@lattice "` (LatticeEvents.swift line 14).
+Both `train_grad_full` and `generate_lora` emit these when `--json` is passed
+(confirmed in train_grad_full.rs lines 1155/1239/1331; generate_lora.rs lines 166/183).
+
+**Path 2 — HumanLineParser regex fallback (for quantize bins and older binaries):**
+
+`HumanLineParser` parses current human-readable stdout of `quantize_q4` and `quantize_quarot`
+via regex (LatticeEvents.swift). These bins have no `--json` flag — the human-readable format
+is the only output. `QuantAccumulator` accumulates multiple summary lines to emit `quantDone`.
+
+**Path 3 — pass-through:** Any line not matching Path 1 or 2 becomes `.status(line)` and
+appends to `LiveRun.log`.
+
+ANSI escape sequences are stripped before parsing via `stripANSI()` (LatticeEvents.swift).
+
+### 3.5 Verified CLI Flags
+
+**train_grad_full** (train_grad_full.rs lines 68-82):
+
+```
+--model-dir model directory
+--data-dir dataset dir with train.jsonl + valid.jsonl
+--first-layer first trained layer (default 19)
+--steps Adam steps (default 25)
+--lr learning rate (default 1e-3)
+--rank LoRA rank (default 8)
+--alpha LoRA alpha (default 16.0)
+--seq-len max tokens per sample (default 64)
+--max-train training samples cap (default 3)
+--max-valid held-out samples for eval (default 16)
+--log-every print NLL every N steps (default 5)
+--json emit @@lattice JSON events to stdout
+--save write PEFT safetensors adapter after training
+```
+
+TrainConfig in Drivers.swift maps 1:1 to these flags. The `--json` flag comment in Drivers.swift
+notes "future --json mode; older binaries ignore unknown flags" — but the flag IS implemented in
+the current binary (train_grad_full.rs line 743).
+
+**generate_lora** (generate_lora.rs lines 70-76):
+
+```
+--model-dir | --model
+--lora adapter path
+--prompt
+--max-tokens
+--temperature
+--json emit @@lattice gen_token events
+--seed
+```
+
+**quantize_q4 / quantize_quarot** (confirmed in package-app.sh; no `--json`):
+
+```
+--model-dir
+--output-dir
+--dry-run (Q4 and QuaRot)
+--seed (QuaRot only, default 0xC0FFEE)
+```
+
+### 3.6 ASCII Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────┐
+│ LatticeStudio.app │
+│ │
+│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ │
+│ │ AppStore │←─→ │ LiveRun │←── │ SwiftUI │ │
+│ │@Observable │@Observable│ │ Screens │ │
+│ └────┬─────┘ └───────────┘ └─────────────┘ │
+│ │ │
+│ ┌────▼──────────────────────────────────────────┐ │
+│ │ RunHandle │ │
+│ │ Process │ outPipe (stdout+stderr merged) │ │
+│ │ │ inPipe (stdin, chat only) │ │
+│ │ │ SIGSTOP/SIGCONT │ │
+│ └────┬──────────────────────────────────────────┘ │
+│ │ stdout lines │
+│ ┌────▼──────────────────────────────────────────┐ │
+│ │ LatticeEventParser │ │
+│ │ Path 1: @@lattice prefix → JSON decode │ │
+│ │ Path 2: HumanLineParser (quantize bins) │ │
+│ │ Path 3: .status(line) passthrough │ │
+│ └───────────────────────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────┘
+ │ spawn
+ ▼
+┌──────────────────────────────────────────────────┐
+│ Rust Binaries (.app/Contents/Resources/bin/) │
+│ │
+│ train_grad_full (lattice-tune +train-backward)│
+│ generate_lora (lattice-tune +safetensors) │
+│ quantize_q4 (lattice-inference) │
+│ quantize_quarot (lattice-inference) │
+│ lattice (lattice-inference) │
+│ qwen35_generate (lattice-inference) │
+└──────────────────────────────────────────────────┘
+```
+
+---
+
+## 4. Surface-by-Surface Status
+
+### Surface 1: LoRA Fine-Tune (TrainScreen.swift)
+
+| Feature | State | Notes |
+|---------|-------|-------|
+| 12 config params (ParamRow) | EXISTS | model-dir, data-dir, first-layer, steps, lr, rank, alpha, seq-len, max-train, max-valid, log-every, save-path |
+| NSOpenPanel directory choosers | EXISTS | model-dir + data-dir |
+| TrainConfig → subprocess | EXISTS | Drivers.swift `startTrain(_:)` |
+| `@@lattice` train_step events | EXISTS | real-time loss, lr, grad_norm, val_loss, tok_s |
+| StripChart oscilloscope | EXISTS | 20Hz, scrub-to-freeze, val_loss overlay |
+| ReadoutWells (6: STEP, TRAIN NLL, HELD-OUT, Δ FROM BASE, TOK/S, BEST VAL) | EXISTS | |
+| HeroNumber (56pt best val) | EXISTS | .numericText() per-digit tick |
+| PAUSE / RESUME / STOP | EXISTS | SIGSTOP/SIGCONT via RunHandle |
+| Adapter path display on done | EXISTS | `@@lattice train_done.saved` field |
+| `--json` mode actually wired | EXISTS | `--json` in TrainConfig.args; binary supports it |
+| Grad-norm scheduling display | PARTIAL | TrainPoint has gradNorm field; no dedicated well yet |
+| ETA display | PARTIAL | eta_s in TrainStep struct (Drivers.swift); no ReadoutWell shows it |
+| Per-step LR schedule chart | PROPOSED | lr field present in TrainPoint; not charted separately |
+| Multi-run overlay | PROPOSED | RunRecord has no point series; chart can only show the live run |
+
+### Surface 2: Model Management (ModelsScreen.swift)
+
+| Feature | State | Notes |
+|---------|-------|-------|
+| Model discovery (config.json) | EXISTS | LatticeBridge.discoverModels() |
+| Layer summary (GDN/GQA counts) | EXISTS | parses `layer_types` array from config.json |
+| Adapter discovery | EXISTS | LatticeBridge.discoverAdapters() |
+| DataTable (8 columns) | EXISTS | NAME, FORMAT, PARAMS, LAYERS, SIZE, FILES, TOK, #ADAPTERS |
+| Inspector panel | EXISTS | model wells + adapter list + Reveal buttons |
+| Train→ / Quantize→ / Chat→ nav | EXISTS | AppStore.use(_:on:) |
+| Finder reveal (NSWorkspace) | EXISTS | |
+| Adapter rank/alpha metadata | MISSING | AdapterInfo.rank/alpha/targetModules always nil; discoverAdapters() does not parse adapter config |
+| Model delete | PROPOSED | no delete action in ModelsScreen |
+| Model download | PROPOSED | out of scope for v1; no CLI surface exists |
+
+### Surface 3: Training Data Curation (DataScreen.swift)
+
+| Feature | State | Notes |
+|---------|-------|-------|
+| Source dir field + Scan | EXISTS | immediate + 1 level deep |
+| .jsonl file enumeration | EXISTS | |
+| Summary strip (FILES, ≈TOKENS, AVG LEN, TRAIN, VALID) | EXISTS | |
+| HeroNumber (total examples) | EXISTS | |
+| Files DataTable | EXISTS | |
+| 5-example preview panel | EXISTS | parses prompt/completion or raw line |
+| Builder script copy buttons | EXISTS | `uv run scripts/build_claude_lora_dataset.py` + `uv run scripts/budget_lora_dataset.py` |
+| Token count accuracy | PARTIAL | approximate chars/4; NOT lattice tokenizer |
+| Train/valid split detection | PARTIAL | detected by filename (train.jsonl vs valid.jsonl); no visual split editor |
+| In-app example editing | PROPOSED | read-only; editing out of scope for v1 |
+| Builder script execution | PROPOSED | explicitly not runnable from UI (DataScreen.swift comment) |
+| Lattice tokenizer integration | PROPOSED | would need FFI or subprocess to get exact counts |
+
+### Surface 4: Sample-Testing Chat (ChatScreen.swift)
+
+| Feature | State | Notes |
+|---------|-------|-------|
+| Config strip (model + adapter pickers) | EXISTS | |
+| FaderToggle BASE↔+ADAPTER | EXISTS | changes adapterPath in next GenConfig only |
+| Sampling params (temperature, max-tokens, seed) | EXISTS | |
+| Single-variant transcript | EXISTS | ChatTurn model; streaming via genText accumulation |
+| generate_lora subprocess + `--json` | EXISTS | GenConfig → AppStore.runGenerate() |
+| Streaming gen_token events | EXISTS | onChange on store.liveRun?.genText accumulates deltas |
+| Non-streaming fallback | EXISTS | filters log lines for non-"$ " prefix |
+| True A/B lockstep streaming | MISSING | FaderToggle flip is manual; two parallel subprocesses never run; "0 ms reload" text is hardcoded UI label (FaderToggle.swift line 137) |
+| Conversation history (multi-turn) | MISSING | each submission is a fresh subprocess invocation; no history passed |
+| Adapter hot-swap mid-conversation | MISSING | DESIGN.md arc_swap/LiveModel references are aspirational; no engine API exists |
+
+---
+
+## 5. Gaps & Risks
+
+### G1 — No true A/B side-by-side (MISSING, HIGH)
+
+`ChatScreen.swift` comment: "We do NOT auto-run both variants — manual flip+resend is the v1
+A/B story." The FaderToggle label "0 ms reload" is hardcoded text (FaderToggle.swift line 137),
+not a live measurement. Running base and adapter in parallel requires two simultaneous `RunHandle`
+instances and a side-by-side transcript view. AppStore currently enforces one active run at a
+time (AppStore.swift line 108: `prior.stop()`).
+
+### G2 — quantize bins have no `--json` (STRUCTURAL, HIGH)
+
+`quantize_q4` and `quantize_quarot` emit only human-readable stdout. There is no `--json` flag
+in these bins (confirmed by absence in package-app.sh and Rust source grep). All quantize
+progress depends on `HumanLineParser` regex matching. `QuantDone.est_ppl_delta` is always nil
+because no Rust producer emits that field. If the quantize binary output format changes, the
+parser silently degrades to `.status` passthrough.
+
+### G3 — Adapter metadata not parsed (MISSING, MEDIUM)
+
+`AdapterInfo.rank`, `.alpha`, and `.targetModules` are always nil (DomainModels.swift lines
+63-65; LatticeBridge.discoverAdapters() confirms no config parsing). The inspector shows
+adapter file size and name only. Users cannot distinguish a rank-4 from a rank-64 adapter in
+the UI without inspecting the file manually.
+
+### G4 — Multi-turn conversation not supported (MISSING, MEDIUM)
+
+Each chat generation is a fresh subprocess invocation with a single `--prompt` string. There is
+no mechanism to pass prior turns to `generate_lora`. The binary interface has no `--history`
+flag.
+
+### G5 — Token count is approximate (MEDIUM)
+
+DataScreen uses `chars / 4` for token estimation (DataScreen.swift comment). The actual lattice
+tokenizer (a BPE tokenizer) is not called from Swift. For CJK or code content the estimate
+degrades significantly.
+
+### G6 — No historical chart replay (LOW)
+
+`RunRecord` (DomainModels.swift lines 74-85) stores only scalar summary (lastLoss, bestVal,
+durationS). The point series is discarded after the run ends. RunsScreen has no chart tab.
+
+### G7 — JetBrains Mono deferred (LOW)
+
+Theme.swift uses `.system(design: .monospaced)` (SF Mono). DESIGN.md specifies JetBrains Mono
+for the number face. A bundled font requires adding the .ttf to Package.swift resources and
+updating Theme.Fonts — no external dependency needed.
+
+### R1 — HumanLineParser fragility
+
+The regex-based fallback for quantize output is the only parse path for those bins. Format
+changes in Rust (adding a unit suffix, changing a field name) silently degrade to `.status`
+passthrough with no error. Mitigation: add `--json` to both quantize bins.
+
+### R2 — Single-process constraint blocks parallel A/B
+
+The current `AppStore.launch()` stop-prior-on-new-launch design is correct for the current
+feature set but is an architectural blocker for true A/B. Lifting this requires either a
+second `handle2: RunHandle?` field and a parallel `liveRun2`, or a session abstraction.
+
+---
+
+## 6. Proposed Next Slices
+
+### P0 — Add `--json` to quantize bins (1 day, Rust + Swift)
+
+Remove the HumanLineParser fragility for the quantize surface. In `quantize_q4` and
+`quantize_quarot`, add `--json` flag parsing and emit `@@lattice quant_layer` and
+`@@lattice quant_done` events, including `est_ppl_delta` (compute from before/after perplexity
+probes or leave as nil until measured). In Swift, the HumanLineParser stays as fallback for
+older binaries. Immediate benefit: `QuantDone.est_ppl_delta` becomes populated; parser is
+no longer regex-fragile; protocol is consistent across all 4 event-emitting bins.
+
+Files: `crates/inference/src/bin/quantize_q4.rs`, `quantize_quarot.rs`, `LatticeEvents.swift`
+(minimal change — struct already has the field).
+
+### P1 — Adapter metadata from config (0.5 day, Swift only)
+
+In `LatticeBridge.discoverAdapters()`, after discovering an adapter directory, attempt to read
+`adapter_config.json` (standard PEFT format) and parse `r` → `rank`, `lora_alpha` → `alpha`,
+`target_modules` → `targetModules`. If the file is absent or malformed, values remain nil
+(current behavior). This unblocks ModelsScreen inspector from showing useful adapter detail and
+lets TrainScreen auto-populate rank/alpha when "use existing adapter" is a future feature.
+
+Files: `LatticeBridge.swift` (`discoverAdapters` function only).
+
+### P2 — True A/B side-by-side chat (3-5 days, Swift architecture change)
+
+Lift the single-run constraint for the Chat surface. Add `handle2: RunHandle?` and
+`liveRun2: LiveRun?` to AppStore (or introduce a `ChatSession` model that holds two
+`(RunHandle, LiveRun)` pairs). ChatScreen renders an HSplitView with two transcript columns.
+The FaderToggle becomes a "run both" trigger. The "0 ms reload" hardcoded label in
+FaderToggle.swift line 137 becomes a real measurement from the time between the two
+`gen_token done` events. This requires the binary to support a stable `--json` streaming
+interface (already true for generate_lora).
+
+Files: `AppStore.swift`, `ChatScreen.swift`, `FaderToggle.swift`, `DomainModels.swift`.
+
+---
+
+*Document generated from source reads. All file paths are absolute within the lattice repo at
+`/Users/lion/projects/khive/lattice/`. No build was run. All claims cite specific file and
+line numbers verified in the research phase.*
diff --git a/apps/macos/scripts/generate-icon.py b/apps/macos/scripts/generate-icon.py
new file mode 100755
index 0000000000..b024ca9b49
--- /dev/null
+++ b/apps/macos/scripts/generate-icon.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""
+Generate the LatticeStudio.icns app icon.
+
+Design: dark rounded-rect (#0A0B0D), teal (#00E5C7) lattice grid glyph.
+Renders a 5x5 node grid connected by edges, with corner nodes highlighted.
+
+Usage:
+ uv run scripts/generate-icon.py [--out ]
+ # Outputs: Resources/LatticeStudio.icns
+"""
+import argparse, os, subprocess, sys, tempfile, shutil, math
+from pathlib import Path
+
+try:
+ from PIL import Image, ImageDraw
+except ImportError:
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "pillow"], stdout=subprocess.DEVNULL)
+ from PIL import Image, ImageDraw
+
+REPO_ROOT = Path(__file__).resolve().parents[3] # lattice/
+SCRIPT_DIR = Path(__file__).resolve().parent
+DEFAULT_OUT = SCRIPT_DIR.parent / "Resources" / "LatticeStudio.icns"
+
+BG = (10, 11, 13) # #0A0B0D
+TEAL = (0, 229, 199) # #00E5C7
+TEAL_DIM = (0, 140, 122) # dimmed edges
+
+
+def render_icon(size: int) -> Image.Image:
+ img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(img)
+
+ # Rounded-rect background — corner radius ~22% of size
+ r = int(size * 0.22)
+ draw.rounded_rectangle([(0, 0), (size - 1, size - 1)], radius=r, fill=BG + (255,))
+
+ # Grid parameters: 4x4 grid of lines (5 nodes per axis) occupying ~55% of canvas
+ pad = size * 0.20
+ grid_size = size - 2 * pad
+ cols = rows = 4 # 5 nodes = 4 intervals
+
+ nodes = []
+ for gy in range(rows + 1):
+ for gx in range(cols + 1):
+ x = pad + gx * (grid_size / cols)
+ y = pad + gy * (grid_size / rows)
+ nodes.append((x, y))
+
+ # Draw edges first (behind nodes)
+ edge_w = max(1, int(size * 0.018))
+ for gy in range(rows + 1):
+ for gx in range(cols + 1):
+ idx = gy * (cols + 1) + gx
+ nx, ny = nodes[idx]
+ # Horizontal edge
+ if gx < cols:
+ rx, ry = nodes[idx + 1]
+ draw.line([(nx, ny), (rx, ry)], fill=TEAL_DIM + (200,), width=edge_w)
+ # Vertical edge
+ if gy < rows:
+ bx, by = nodes[idx + (cols + 1)]
+ draw.line([(nx, ny), (bx, by)], fill=TEAL_DIM + (200,), width=edge_w)
+
+ # Draw nodes — larger at corners, medium at edges, small inside
+ for gy in range(rows + 1):
+ for gx in range(cols + 1):
+ is_corner = (gx in (0, cols)) and (gy in (0, rows))
+ is_edge = (gx in (0, cols)) or (gy in (0, rows))
+ if is_corner:
+ base_r = size * 0.048
+ alpha = 255
+ elif is_edge:
+ base_r = size * 0.030
+ alpha = 220
+ else:
+ base_r = size * 0.020
+ alpha = 180
+ nr = int(base_r)
+ x, y = nodes[gy * (cols + 1) + gx]
+ draw.ellipse(
+ [(x - nr, y - nr), (x + nr, y + nr)],
+ fill=TEAL + (alpha,)
+ )
+
+ return img
+
+
+ICON_SIZES = [16, 32, 128, 256, 512]
+
+
+def build_iconset(iconset_dir: Path):
+ iconset_dir.mkdir(parents=True, exist_ok=True)
+ for sz in ICON_SIZES:
+ img = render_icon(sz)
+ img.save(iconset_dir / f"icon_{sz}x{sz}.png")
+ img2 = render_icon(sz * 2).resize((sz * 2, sz * 2), Image.LANCZOS)
+ img2.save(iconset_dir / f"icon_{sz}x{sz}@2x.png")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--out", default=str(DEFAULT_OUT))
+ args = parser.parse_args()
+
+ out = Path(args.out)
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ iconset = Path(tmp) / "LatticeStudio.iconset"
+ build_iconset(iconset)
+ result = subprocess.run(
+ ["iconutil", "-c", "icns", str(iconset), "-o", str(out)],
+ capture_output=True, text=True
+ )
+ if result.returncode != 0:
+ print(f"iconutil failed: {result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Icon written: {out} ({out.stat().st_size:,} bytes)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/macos/scripts/package-app.sh b/apps/macos/scripts/package-app.sh
new file mode 100755
index 0000000000..c62deaf7c1
--- /dev/null
+++ b/apps/macos/scripts/package-app.sh
@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+# package-app.sh — Build and package LatticeStudio.app + .dmg + .zip
+#
+# Usage:
+# ./scripts/package-app.sh [--out ] [--skip-build] [--skip-cargo]
+#
+# Options:
+# --out Output directory (default: apps/macos/dist/)
+# --skip-build Skip `swift build -c release` (use existing .build/release/LatticeStudio)
+# --skip-cargo Skip cargo builds (use existing target/release/ binaries)
+#
+# Idempotent: re-running overwrites dist/ cleanly.
+#
+# Signing: ad-hoc codesign only (no Developer ID required).
+# Recipients must right-click → Open, or: xattr -dr com.apple.quarantine LatticeStudio.app
+# See DISTRIBUTION.md for the Developer ID upgrade path.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MACOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # apps/macos/
+REPO_ROOT="$(cd "$MACOS_DIR/../.." && pwd)" # lattice/
+OUT_DIR="$MACOS_DIR/dist"
+SKIP_BUILD=false
+SKIP_CARGO=false
+
+# --- Parse args ---
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --out) OUT_DIR="$(realpath "$2")"; shift 2 ;;
+ --skip-build) SKIP_BUILD=true; shift ;;
+ --skip-cargo) SKIP_CARGO=true; shift ;;
+ *) echo "Unknown arg: $1"; exit 1 ;;
+ esac
+done
+
+APP_NAME="LatticeStudio"
+BUNDLE="$OUT_DIR/$APP_NAME.app"
+VERSION="0.3.0"
+BUNDLE_ID="ai.khive.lattice.studio"
+
+echo "==> Package $APP_NAME v$VERSION"
+echo " Repo: $REPO_ROOT"
+echo " Output: $OUT_DIR"
+
+# --- Step 1: Swift release build ---
+if [ "$SKIP_BUILD" = false ]; then
+ echo ""
+ echo "==> [1/6] swift build -c release"
+ swift build -c release --package-path "$MACOS_DIR"
+fi
+SWIFT_BIN="$MACOS_DIR/.build/release/$APP_NAME"
+if [ ! -x "$SWIFT_BIN" ]; then
+ echo "ERROR: $SWIFT_BIN not found or not executable"
+ exit 1
+fi
+echo " Swift binary: $(du -sh "$SWIFT_BIN" | awk '{print $1}')"
+
+# --- Step 2: Cargo release builds ---
+if [ "$SKIP_CARGO" = false ]; then
+ echo ""
+ echo "==> [2/6] cargo build --release (9 engine binaries)"
+ # lattice-inference binaries (no extra features)
+ for BIN in quantize_q4 quantize_quarot lattice qwen35_generate; do
+ echo " cargo build --release -p lattice-inference --bin $BIN"
+ cargo build --release -p lattice-inference --bin "$BIN" \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+ done
+ # lattice-tune: train_grad_full needs train-backward feature
+ echo " cargo build --release -p lattice-tune --bin train_grad_full --features train-backward"
+ cargo build --release -p lattice-tune --bin train_grad_full \
+ --features train-backward \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+ # lattice-tune: generate_lora needs safetensors,inference-hook
+ echo " cargo build --release -p lattice-tune --bin generate_lora --features safetensors,inference-hook"
+ cargo build --release -p lattice-tune --bin generate_lora \
+ --features "safetensors,inference-hook" \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+ # lattice-inference: eval_perplexity needs f16,metal-gpu (CPU bf16 + Metal Q4/QuaRot PPL)
+ echo " cargo build --release -p lattice-inference --bin eval_perplexity --features f16,metal-gpu"
+ cargo build --release -p lattice-inference --bin eval_perplexity \
+ --features "f16,metal-gpu" \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+ # lattice-inference: chat_metal is the GPU (Metal) generation path for Chat (bf16/Q4 + LoRA-on-Metal)
+ echo " cargo build --release -p lattice-inference --bin chat_metal --features f16,metal-gpu"
+ cargo build --release -p lattice-inference --bin chat_metal \
+ --features "f16,metal-gpu" \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+ # lattice-embed: embed CLI (native default features)
+ echo " cargo build --release -p lattice-embed --bin embed"
+ cargo build --release -p lattice-embed --bin embed \
+ --manifest-path "$REPO_ROOT/Cargo.toml"
+fi
+
+TARGET_RELEASE="$REPO_ROOT/target/release"
+for BIN in quantize_q4 quantize_quarot lattice qwen35_generate train_grad_full generate_lora eval_perplexity embed chat_metal; do
+ if [ ! -x "$TARGET_RELEASE/$BIN" ]; then
+ echo "ERROR: $TARGET_RELEASE/$BIN not found"
+ exit 1
+ fi
+done
+echo " All 9 engine binaries present"
+
+# --- Step 3: Construct .app bundle ---
+echo ""
+echo "==> [3/6] Construct $APP_NAME.app"
+rm -rf "$BUNDLE"
+mkdir -p "$BUNDLE/Contents/MacOS"
+mkdir -p "$BUNDLE/Contents/Resources/bin"
+
+# Executable
+cp "$SWIFT_BIN" "$BUNDLE/Contents/MacOS/$APP_NAME"
+chmod +x "$BUNDLE/Contents/MacOS/$APP_NAME"
+
+# Engine binaries
+for BIN in quantize_q4 quantize_quarot lattice qwen35_generate train_grad_full generate_lora eval_perplexity embed chat_metal; do
+ cp "$TARGET_RELEASE/$BIN" "$BUNDLE/Contents/Resources/bin/$BIN"
+ chmod +x "$BUNDLE/Contents/Resources/bin/$BIN"
+done
+echo " Copied 9 engine binaries → Contents/Resources/bin/"
+
+# App icon
+ICNS_SRC="$MACOS_DIR/Resources/LatticeStudio.icns"
+if [ -f "$ICNS_SRC" ]; then
+ cp "$ICNS_SRC" "$BUNDLE/Contents/Resources/LatticeStudio.icns"
+ echo " Copied icon"
+else
+ echo " WARNING: $ICNS_SRC not found, regenerating..."
+ uv run "$MACOS_DIR/scripts/generate-icon.py" --out "$ICNS_SRC"
+ cp "$ICNS_SRC" "$BUNDLE/Contents/Resources/LatticeStudio.icns"
+fi
+
+# --- Step 4: Info.plist ---
+echo ""
+echo "==> [4/6] Write Info.plist"
+cat > "$BUNDLE/Contents/Info.plist" <
+
+
+
+ CFBundleIdentifier
+ $BUNDLE_ID
+ CFBundleName
+ Lattice Studio
+ CFBundleDisplayName
+ Lattice Studio
+ CFBundleExecutable
+ $APP_NAME
+ CFBundleShortVersionString
+ $VERSION
+ CFBundleVersion
+ $VERSION
+ CFBundlePackageType
+ APPL
+ CFBundleIconFile
+ LatticeStudio
+ LSMinimumSystemVersion
+ 14.0
+ NSHighResolutionCapable
+
+ LSApplicationCategoryType
+ public.app-category.developer-tools
+ NSPrincipalClass
+ NSApplication
+ NSHumanReadableCopyright
+ Copyright © 2026 khive AI. All rights reserved.
+
+
+PLIST
+echo " Info.plist written (bundle id: $BUNDLE_ID)"
+
+# --- Step 5: Ad-hoc codesign ---
+echo ""
+echo "==> [5/6] Ad-hoc codesign"
+codesign --force --deep --options runtime --sign - "$BUNDLE"
+codesign --verify --deep --strict "$BUNDLE"
+echo " Codesign verified"
+
+# --- Step 6: DMG + ZIP ---
+echo ""
+echo "==> [6/6] Create distributable artifacts"
+DMG="$OUT_DIR/$APP_NAME.dmg"
+ZIP="$OUT_DIR/$APP_NAME.zip"
+
+# DMG
+hdiutil create \
+ -volname "Lattice Studio" \
+ -srcfolder "$BUNDLE" \
+ -ov \
+ -format UDZO \
+ "$DMG" 2>&1 | grep -v "^hdiutil:"
+echo " DMG: $(du -sh "$DMG" | awk '{print $1}') $DMG"
+
+# ZIP fallback
+(cd "$OUT_DIR" && zip -qr "$APP_NAME.zip" "$APP_NAME.app")
+echo " ZIP: $(du -sh "$ZIP" | awk '{print $1}') $ZIP"
+
+echo ""
+echo "==> Done. Artifacts in $OUT_DIR/"
+du -sh "$BUNDLE" "$DMG" "$ZIP"
+echo ""
+echo "Distribution note: Recipients must right-click → Open on first launch (Gatekeeper)."
+echo "Or: xattr -dr com.apple.quarantine '$BUNDLE'"