From 6d9762a013dacffcaa78aee3d2faf1eac7f4f4c5 Mon Sep 17 00:00:00 2001 From: brxs Date: Fri, 24 Jul 2026 20:40:50 -0700 Subject: [PATCH] Move LoRA management into generation panels --- .../issue-66-lora-generation-ui-redesign.md | 250 +++++++++++ docs/issue-66-checklist.md | 74 ++-- frontend/src/App.tsx | 11 +- frontend/src/deck/DeckColumn.test.tsx | 13 +- frontend/src/deck/DeckColumn.tsx | 27 +- frontend/src/i18n/en.json | 43 +- frontend/src/media/MediaExplorer.test.tsx | 24 +- frontend/src/media/MediaExplorer.tsx | 32 +- frontend/src/models/LoraLibrary.test.tsx | 202 --------- frontend/src/models/LoraLibrary.tsx | 223 ---------- frontend/src/models/LoraProvider.test.tsx | 185 ++++++++ frontend/src/models/LoraProvider.tsx | 160 +++++++ frontend/src/models/loraContext.ts | 55 +++ frontend/src/models/useLoras.test.ts | 74 ++++ frontend/src/models/useLoras.ts | 49 +- frontend/src/ui/AnchoredPanel.tsx | 113 +++++ frontend/src/ui/LoraControl.test.tsx | 282 ++++++++++++ frontend/src/ui/LoraControl.tsx | 419 ++++++++++++++++++ frontend/src/ui/LoraRack.test.tsx | 110 ----- frontend/src/ui/LoraRack.tsx | 108 ----- frontend/src/ui/ui.css | 351 ++++++++++++--- src-tauri/src/loras.rs | 8 +- 22 files changed, 1982 insertions(+), 831 deletions(-) create mode 100644 .plans/issue-66-lora-generation-ui-redesign.md delete mode 100644 frontend/src/models/LoraLibrary.test.tsx delete mode 100644 frontend/src/models/LoraLibrary.tsx create mode 100644 frontend/src/models/LoraProvider.test.tsx create mode 100644 frontend/src/models/LoraProvider.tsx create mode 100644 frontend/src/models/loraContext.ts create mode 100644 frontend/src/models/useLoras.test.ts create mode 100644 frontend/src/ui/AnchoredPanel.tsx create mode 100644 frontend/src/ui/LoraControl.test.tsx create mode 100644 frontend/src/ui/LoraControl.tsx delete mode 100644 frontend/src/ui/LoraRack.test.tsx delete mode 100644 frontend/src/ui/LoraRack.tsx diff --git a/.plans/issue-66-lora-generation-ui-redesign.md b/.plans/issue-66-lora-generation-ui-redesign.md new file mode 100644 index 0000000..e8d86a7 --- /dev/null +++ b/.plans/issue-66-lora-generation-ui-redesign.md @@ -0,0 +1,250 @@ +--- +issue: 66 +url: https://github.com/protocol-works/lsdj/issues/66 +pr: https://github.com/protocol-works/lsdj/pull/91 +title: "Move LoRA management into generation and clarify steering state" +date: 2026-07-24 +baseline: 9e7ad0c +status: "implemented; native listening verification pending" +--- + +# Plan: LoRA generation UI redesign (#66 / PR #91) + +## Progress + +- [x] Inspect PR #91, issue #66, the LoRA registry/management flow, all + generation call sites, steering state, styling, and tests. +- [x] Agree on the product direction: progressive disclosure from the generation + surface, with explicit off/on/bypassed states. +- [x] Finalise the compact trigger, expanded-panel wireframe, and UI copy. +- [x] Phase 1: centralise LoRA library state and lifecycle actions. +- [x] Phase 2: build the accessible anchored-panel primitive and compact control. +- [x] Phase 3: implement selection, steering, compatibility, and management in + the contextual panel. +- [x] Phase 4: replace every rack, remove LoRA management from Settings, and + preserve generation payload behaviour. +- [x] Phase 5: complete automated/accessibility verification and prepare the + visual-density/manual UX checklist (native hardware checks remain manual). +- [x] Run `just check`. +- [ ] Complete the updated issue-66 hardware/audio UX checklist on a machine + with the SA3 runtime and test adapters installed. + +Automated verification completed 2026-07-24: `just check` passes (209 backend +tests, 593 frontend tests, 333 Rust tests passed with 1 timing diagnostic +ignored). The native listening, bit-exact generated-audio, and real install +flows remain deliberately unchecked in `docs/issue-66-checklist.md`. + +## Problem + +PR #91 delivers the complete LoRA lifecycle and generation contract, but the +current frontend splits one workflow across two places: + +- adapter installation, listing, deletion, and progress live in a top-level + Settings drawer section; and +- adapter selection and strength live in an always-visible `LoraRack` repeated + across Generate, Samples, and both deck generation panels. + +The rack renders every compatible installed adapter, so its footprint grows with +the library instead of with the current generation choice. Its visual state is +also ambiguous: an applied adapter differs mainly through accent-coloured text +and border, while strength `0` remains selected but is only dimmed. When no +compatible adapters exist, the rack renders nothing, leaving no generation-side +way to discover or install the feature. + +## Chosen direction + +Replace the always-visible rack with one compact `LoRA` status/disclosure control +in every generation surface. Opening it shows a contextual, anchored panel for +selection, strength, compatibility, installation, and management. + +The collapsed generation UI must stay one stable control high regardless of the +number of installed adapters. Example summaries: + +```text +LoRA [ Off ▾ ] +LoRA [ ● Maqam ×1 · +1 active ▾ ] +LoRA [ 1 active · 1 bypassed ▾ ] +``` + +The expanded panel has three progressively disclosed sections: + +1. **Applied to this generation** — selected adapters, explicit state, strength, + bypass, and removal. +2. **Available adapters** — compatible adapters with an explicit Apply action, + plus a collapsed explanation of incompatible adapters. +3. **Install and manage** — an `Install adapter…` disclosure, per-adapter + management actions, and Open Folder. + +Do not automatically apply a newly installed adapter. Keep the panel open, +highlight the new compatible row, and offer an explicit Apply action so an +installation never silently changes the next generation. + +## UX state contract + +The visible and accessible UI must use text/state attributes as well as colour. + +| Effective state | Collapsed summary | Expanded row | +| --- | --- | --- | +| No adapters installed | `LoRA · None installed` | Installation entry point | +| Compatible adapters, none selected | `LoRA · Off` | Neutral available rows | +| Selected at strength greater than 0 | Adapter/value or `N active` | Lit state, explicit `On`, labelled strength | +| Selected at strength 0 | `N bypassed` where relevant | Explicit `Bypassed`; never described as active | +| Adapter incompatible with current kind/base | Excluded from active count | Listed under `Incompatible`, with required engine/base | +| Magenta generation | `LoRA · Unavailable for Magenta` | Management remains reachable; application is disabled | +| Four effective stack entries | Current active summary | Remaining Apply actions disabled with the cap explained | + +Definitions: + +- **Selected** means the adapter remains in the local stack state. +- **Active** means selected, compatible with the current generation, and strength + is greater than `0`. +- **Bypassed** means selected and compatible, but strength is exactly `0`; the + request may retain the zero-strength entry for the existing bit-exact bypass + contract. +- **Off** means no compatible adapter will affect the next generation. + +## Implementation plan + +### Phase 1 — shared library state + +- [x] Extract registry loading, `models://changed` subscription, install + progress, errors, and lifecycle actions from `LoraLibrary`/`useLoras` into one + shared `LoraProvider` or equivalent external store. +- [x] Mount the provider once above `MediaExplorer` and both `DeckColumn` + instances so the app does not create independent `modelStatus()` reads and + subscriptions for every surface. +- [x] Expose installed adapters, current install state, install/cancel/delete, + and Open Folder through a small stable hook API. +- [x] Keep `useLoraStack()` local to each generation form. Track, Samples, deck + A, and deck B must continue to have independent pending choices and remembered + strengths. +- [x] Preserve stale-choice filtering when an adapter is deleted or the engine + switches to a different base. + +### Phase 2 — compact trigger and anchored panel + +- [x] Build a reusable `LoraControl` that accepts generation kind/base, local + stack state/actions, and deck/master accent. +- [x] Always render the control for generation surfaces, including empty-library + and unsupported-engine states, so LoRA discovery and management remain + reachable. +- [x] Make the trigger expose `aria-expanded`, a suitable popup relationship, + explicit summary copy, and a lit treatment only when at least one adapter is + effectively active. +- [x] Add an accessible anchored-panel primitive rendered through a portal. + Media and deck containers scroll/clip, so an in-flow or locally absolute panel + is not sufficient. +- [x] Support outside-click dismissal, Escape, focus entry, focus containment or + documented non-modal behaviour, focus restoration, viewport-edge flipping, + and a narrow-window width cap. + +### Phase 3 — steering, compatibility, and management + +- [x] Render only selected adapters in **Applied to this generation**. +- [x] Give every applied row an explicit `On` or `Bypassed` label and a visible + `Strength ×N` label; do not rely on the knob readout or accent alone. +- [x] Keep the curated strength range and reset behaviour unless the final + wireframe selects a clearer horizontal range control. In either case, retain + quarter-step input and the `×1` reset. +- [x] Make removal from the stack a separate action from strength adjustment. +- [x] Show base-compatible adapters in **Available adapters** with Apply actions. +- [x] Show incompatible installed adapters in a collapsed group with concrete + guidance such as `Medium DiT — select Track` instead of silently filtering + them out. +- [x] Move the HF repo/link field and local `.safetensors` import into a collapsed + `Install adapter…` section. +- [x] Keep base detection as the default and move the small/medium override under + an Advanced disclosure for rank-only adapters. +- [x] Surface live install/cancel/error progress inside the open LoRA panel and + preserve progress when the panel is closed and reopened. +- [x] Put Delete and Open Folder behind clear management actions. Confirm a + delete when the adapter is selected in a visible generation stack, and ensure + its stale choices disappear safely after deletion. + +### Phase 4 — integrate all generation surfaces + +- [x] Replace the track rack in Media Explorer → Generate with `LoraControl`. +- [x] Replace the small-DiT rack in Media Explorer → Samples. +- [x] Replace the rack in deck A's generated-pad panel. +- [x] Replace the rack in deck B's generated-pad panel. +- [x] Remove the top-level LoRA Library section and import from `App.tsx`. +- [x] Remove or repurpose `LoraLibrary` and `LoraRack` once all behaviour is + covered by the shared provider/control/panel components. +- [x] Keep `/api/generate`'s `loras: [{ name, strength }]` shape, backend + validation, Rust registry/importer, and SA3 argv wiring unchanged. +- [x] Verify that engine switches report the effective current state honestly + while remembered per-adapter strengths remain available when switching back. + +### Phase 5 — verification and polish + +- [x] Unit-test the collapsed summary for none installed, off, one active, + multiple active, bypassed, stack-full, incompatible, and Magenta states. +- [x] Unit-test Apply, Remove, strength, bypass, reset, remembered strength, + install, cancel, error, delete, and live registry updates. +- [x] Test keyboard opening/dismissal, focus restoration, accessible names and + state attributes, and ensure no meaningful state is colour-only. +- [x] Retain integration tests proving the exact LoRA stack and trims sent by + Generate, Samples, and both deck call sites. +- [x] Test engine/base switches, deletion of a selected adapter, and the + four-adapter cap. +- [ ] Test long adapter names, many installed adapters, small deck widths, media + tray scrolling, and panel viewport-edge placement. +- [ ] Confirm the collapsed generation layout does not grow as adapters are + installed and does not reduce the media list's usable height. +- [x] Update `docs/issue-66-checklist.md` to replace Settings/rack instructions + with the contextual-panel flow and the explicit state terminology. +- [x] Run frontend unit tests while iterating, then run `just check` before + completion. + +## Likely affected frontend areas + +- `frontend/src/App.tsx` +- `frontend/src/media/MediaExplorer.tsx` +- `frontend/src/deck/DeckColumn.tsx` +- `frontend/src/models/useLoras.ts` +- `frontend/src/models/LoraLibrary.tsx` +- `frontend/src/ui/LoraRack.tsx` +- `frontend/src/ui/Knob.tsx` if the visible strength labelling changes +- `frontend/src/ui/ui.css` +- `frontend/src/media/media.css` and/or `frontend/src/deck/deck.css` for trigger + placement only +- `frontend/src/i18n/en.json` +- corresponding frontend tests +- `docs/issue-66-checklist.md` + +Backend Python, Rust registry/import validation, SA3 pinning, and the HTTP +contract are deliberately outside this redesign unless implementation uncovers +a contract defect. + +## Risks and mitigations + +- **Overlay clipping or incorrect placement:** render through a portal, calculate + placement from the trigger, and test inside both scrolling surfaces. +- **A panel duplicated four times creates duplicated lifecycle state:** keep the + open panel local but registry/install state singleton through the provider. +- **Colour remains the only active signal:** require visible status copy and + semantic state attributes in the component contract and tests. +- **Strength zero is confused with active:** calculate active and bypassed counts + separately and display `Bypassed` explicitly. +- **Incompatible adapters appear to vanish:** show them in an explanatory group + rather than silently filtering the management view. +- **Management overwhelms steering:** collapse installation and advanced base + override by default; keep applied adapters first. +- **Delete unexpectedly changes a pending generation:** confirm selected-adapter + deletion and rely on the existing stale-choice filtering as the final safety + boundary. + +## Definition of done + +- LoRA management is reachable from every generation context and no longer + occupies a top-level Settings section. +- The closed generation surface uses one stable-height control regardless of + installed-adapter count. +- Users can distinguish Off, On, Bypassed, incompatible, unavailable, and + stack-full states without interpreting colour alone. +- Users can install from HF, import a local safetensors adapter, observe/cancel + progress, delete, and open the registry folder from the contextual panel. +- Track, Samples, and both decks retain independent stacks and send the same + validated request contract as PR #91. +- Automated checks pass and the updated issue-66 hardware/UX checklist is + completed on a machine with the SA3 runtime available. diff --git a/docs/issue-66-checklist.md b/docs/issue-66-checklist.md index dc30d7b..f999c2c 100644 --- a/docs/issue-66-checklist.md +++ b/docs/issue-66-checklist.md @@ -4,13 +4,13 @@ Issue #66 builds the production importer for Stable Audio 3 LoRA finetunes on top of the spike's merge-at-load runtime (ADR-0028, `docs/spike-sa3-lora.md`): a `sa3-loras///` registry in the app data dir owned by the Rust shell, a `lora` field on `/api/generate` that rides `--lora`/`--lora-strength` -into the pinned CLI, adapter pickers in the generate surfaces, and a -manager section with import (HuggingFace repo id or local `.safetensors`) and -in-app delete. +into the pinned CLI, plus one contextual LoRA control in every generation +surface for steering and lifecycle management (HuggingFace repo id or local +`.safetensors`, progress, list, and delete). Unit tests cover name/path trust boundaries, the safetensors-header validation (pickle refusal, convention detection, base inference), the exact -argv, the `/api/generate` contract, and the picker/manager UI. What follows +argv, the `/api/generate` contract, and the contextual generation UI. What follows needs a real machine with the SA3 checkout warmed — the sandbox cannot run the shell or MLX. The public PEFT adapter the spike used (`motiftechnologies/stable-audio-3-maqam-lora`, medium base) is the reference @@ -18,12 +18,18 @@ adapter throughout. ## Import -- [ ] Settings drawer → Model library shows the **LoRA adapters** section - with "No adapters installed" and an **Open folder** that reveals +- [ ] Media Explorer → Generate shows the stable-height **LoRA · None + installed** control even with an empty registry. Open it: the contextual + panel says "No adapters installed", offers **Install adapter…**, and has + an **Open folder** action that reveals `~/Library/Application Support/LSDJai/sa3-loras`. -- [ ] Enter `motiftechnologies/stable-audio-3-maqam-lora` and Install: fetch / - download / install progress shows, then the adapter lists as +- [ ] Expand **Install adapter…**, enter + `motiftechnologies/stable-audio-3-maqam-lora`, and Install: fetch / + download / install progress shows in the same panel, then the adapter + lists as **New** under Available adapters: `stable-audio-3-maqam-lora` — **Medium DiT (tracks)**, ~200 MB. +- [ ] The new adapter remains off until **Apply** is pressed; installation alone + does not silently alter the next generation. - [ ] Cancel works mid-download and surfaces as a clean stop, not an error. - [ ] Import the same adapter's `adapter_model.safetensors` via **Import file…** (download it separately first): refused as already installed @@ -38,8 +44,13 @@ adapter throughout. ## Generate - [ ] Media Explorer → Generate, engine **Track (SA3 medium)**: the LoRA - picker offers the Maqam adapter; the pad engines do NOT offer it (wrong - base), and the deck pad panels don't either. + control reads **Off**. Its panel offers the Maqam adapter under Available; + Apply moves it to **Applied to this generation**, labels it **On**, and + lights the collapsed control with `maqam ×1`. +- [ ] On the SFX/Music pad engines, Maqam appears under **Incompatible + adapters** with "Medium DiT — select Track to apply" instead of silently + disappearing. Magenta reads **Unavailable for Magenta** but still opens + the panel for installation and management. - [ ] Compose two tracks from the same prompt + fixed conditions, adapter None vs Maqam at ×1: audibly different in character (the spike measured a difference as large as the signal itself). @@ -52,7 +63,8 @@ adapter throughout. ## Bypass (ADR-0028's bit-exact claim) - [ ] Two tracks with the same prompt and `seed`, adapter **None** vs Maqam at - **×0**: byte-identical WAVs (compare SHA-256). Seed rides via the + **×0 / Bypassed**: byte-identical WAVs (compare SHA-256). The panel and + collapsed summary explicitly say **Bypassed**, not On. Seed rides via the `/api/generate` `seed` field (issue #54) — use `scripts/verify_sa3_surface.py`-style direct calls if the UI has no seed control. @@ -61,8 +73,9 @@ adapter throughout. - [ ] Quit and relaunch: the adapter is still listed (the registry is the directory layout — nothing else to persist). -- [ ] Delete from the manager: the row disappears, the folder is gone, and an - in-flight picker choice falls back to **None** on the next generate. +- [ ] Delete from the contextual panel: an applied adapter asks for + confirmation; after confirmation the row disappears, the folder is gone, + and the stale local choice is omitted from the next generate. - [ ] Drop a valid adapter folder in by hand (`sa3-loras/medium//` with its `.safetensors`): the watcher lists it live, and it generates. @@ -74,11 +87,11 @@ adapter throughout. ## LoRA stack (multi-adapter follow-up) -The generate surfaces replaced the adapter/strength pickers with the -**LoRA rack**: every base-matched adapter is a toggle chip; a chip clicked -into the stack grows a trim knob (double-click parks it at ×1, ×0 dims the -slot — bit-exact bypass). `/api/generate` now takes `loras` (a list of up to -4 `{name, strength}` entries) instead of the single `lora` object. +The generate surfaces expose one stable-height **LoRA control**. Opening its +portalled contextual panel separates Applied, Available, and Incompatible +adapters; each applied adapter has explicit On/Bypassed state, a labelled +strength trim, Bypass/Enable, and Remove. `/api/generate` takes `loras` (a list +of up to 4 `{name, strength}` entries). **Prerequisite (before anything below):** the pin is back on upstream `Stability-AI/stable-audio-3` (our LoRA support landed as PR #57; PR #65 @@ -88,16 +101,19 @@ run the update so the installed checkout matches the pin. On the old fork checkout, a multi-adapter generation fails in argparse (`unrecognized arguments`); a hand-patched checkout is replaced cleanly. -- [ ] With two medium adapters installed: chip both into the Generate tab's - rack, distinct trims (e.g. ×1 and ×0.5) — the backend log shows +- [ ] With two medium adapters installed: Apply both from the Generate panel + with distinct trims (e.g. ×1 and ×0.5) — the collapsed control reads + **2 active**, the backend log shows `merged … from 2 adapter(s)` and the take audibly carries both. -- [ ] Trim one slot to ×0: its slot dims; same prompt + seed with that - adapter chipped out entirely is byte-identical (per-slot bypass). -- [ ] Chip a 5th adapter with 4 stacked: the chip is disabled with the - "Stack full" hint (and a direct POST with 5 entries returns 422, as +- [ ] Bypass one applied adapter: its row and collapsed summary explicitly say + **Bypassed**; Enable restores its last non-zero strength. Same prompt + + seed with that adapter removed entirely is byte-identical. +- [ ] With 4 applied, a 5th Apply action is disabled with the "Stack full" + hint (and a direct POST with 5 entries returns 422, as does a duplicated name). -- [ ] Deck rack takes the deck accent (A lime / B violet by default); - the Media Explorer racks take the master accent. -- [ ] Toggle a chip out and back in: it remembers its trim for the session. -- [ ] Delete a stacked adapter from the LoRA Library: the chip vanishes - from the racks and the next generate simply rides without it. +- [ ] Deck controls/panels take the deck accent (A lime / B violet by default); + Media Explorer controls/panels take the master accent. +- [ ] Remove and re-Apply an adapter: it remembers its trim for the session. +- [ ] Install enough adapters to overflow the old one-line rack: the collapsed + generation control remains one fixed-height control and the adapter list + scrolls inside its panel without taking height from the media library. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85b0b53..f52c01b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,7 +56,7 @@ import { import { Logo } from './ui/Logo' import { Drawer } from './ui/Drawer' import { Button } from './ui/Button' -import { LoraLibrary } from './models/LoraLibrary' +import { LoraProvider } from './models/LoraProvider' import { ModelManager } from './models/ModelManager' import type { StylePreset } from './presets' import { combinedRamWarning } from './ramWarning' @@ -750,6 +750,7 @@ function App() { } return ( +
{/* The frameless title-bar strip behind the macOS traffic lights. With titleBarStyle Overlay the webview covers the native title bar, so that @@ -883,13 +884,6 @@ function App() {

{t('settings.modelLibrary')}

- {/* The LoRA library (issue #66): import / manage Stable Audio 3 LoRA - finetunes — its own top-level section so custom sound worlds don't - hide inside the weights installer. */} -
-

{t('settings.loraLibrary')}

- -
{/* The native MCP server (ADR-0020 Phase 2): last in the list so the copy-paste connection snippets don't push the everyday controls down. */}
@@ -1019,6 +1013,7 @@ function App() { onResize={handleMediaResize} />
+
) } diff --git a/frontend/src/deck/DeckColumn.test.tsx b/frontend/src/deck/DeckColumn.test.tsx index 3742868..c9d401a 100644 --- a/frontend/src/deck/DeckColumn.test.tsx +++ b/frontend/src/deck/DeckColumn.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import { StrictMode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -1428,9 +1428,14 @@ describe('DeckColumn', () => { const onGenerateToPad = vi.fn() generateRow({ onGenerateToPad: onGenerateToPad as () => void }) - expect(screen.queryByText('tape')).toBeNull() - fireEvent.click(screen.getByText('maqam')) - fireEvent.click(screen.getByText('crackle')) + fireEvent.click(screen.getByRole('button', { name: 'LoRA: Off' })) + expect(screen.getByText('Incompatible adapters (1)')).toBeInTheDocument() + const maqamRow = screen.getByText('maqam').closest('.ui-lora-panel__adapter') + expect(maqamRow).not.toBeNull() + fireEvent.click(within(maqamRow as HTMLElement).getByRole('button', { name: 'Apply' })) + const crackleRow = screen.getByText('crackle').closest('.ui-lora-panel__adapter') + expect(crackleRow).not.toBeNull() + fireEvent.click(within(crackleRow as HTMLElement).getByRole('button', { name: 'Apply' })) fireEvent.change(screen.getByLabelText('maqam strength'), { target: { value: '0.75' }, }) diff --git a/frontend/src/deck/DeckColumn.tsx b/frontend/src/deck/DeckColumn.tsx index bb010cd..bf612da 100644 --- a/frontend/src/deck/DeckColumn.tsx +++ b/frontend/src/deck/DeckColumn.tsx @@ -46,14 +46,12 @@ import { type TrackState, } from './useDeck' import { - adaptersForKind, - MAX_LORA_STACK, stackForKind, useLoras, useLoraStack, type LoraChoice, } from '../models/useLoras' -import { LoraRack } from '../ui/LoraRack' +import { LoraControl } from '../ui/LoraControl' import './deck.css' // The worker holds ~3s of lead (see backend worker pacing); the meter shows @@ -316,7 +314,6 @@ export function DeckColumn({ loop.slots.some((slot) => slot.state === 'empty') // Pads only ever ride the small DiTs; a stale slot (deleted adapter) // drops from the request, never blocks it. - const padAdapters = adaptersForKind(loras, 'sfx') const fireGenerate = () => { if (!canGenerate) return const stacked = @@ -939,19 +936,15 @@ export function DeckColumn({ onChange={(value) => setGenerateOneShot(value === 'oneshot')} /> - {generateEngine !== 'magenta' && ( - ({ - name: adapter.name, - label: adapter.slug, - }))} - value={padStack.stack} - onToggle={padStack.toggle} - onStrength={padStack.setStrength} - max={MAX_LORA_STACK} - /> - )} +
{ const fetchMock = stubFetch() renderExplorer() fireEvent.click(screen.getByRole('tab', { name: 'Generate' })) - expect(screen.queryByText('crackle')).toBeNull() - // Chip both medium adapters into the stack; trim only maqam. - fireEvent.click(screen.getByText('maqam')) - fireEvent.click(screen.getByText('breaks')) + fireEvent.click(screen.getByRole('button', { name: 'LoRA: Off' })) + expect(screen.getByText('Incompatible adapters (1)')).toBeInTheDocument() + // Apply both medium adapters from the contextual panel; trim only maqam. + const maqamRow = screen.getByText('maqam').closest('.ui-lora-panel__adapter') + expect(maqamRow).not.toBeNull() + fireEvent.click(within(maqamRow as HTMLElement).getByRole('button', { name: 'Apply' })) + const breaksRow = screen.getByText('breaks').closest('.ui-lora-panel__adapter') + expect(breaksRow).not.toBeNull() + fireEvent.click(within(breaksRow as HTMLElement).getByRole('button', { name: 'Apply' })) fireEvent.change(screen.getByLabelText('maqam strength'), { target: { value: '1.5' }, }) @@ -288,8 +293,13 @@ describe('MediaExplorer', () => { const fetchMock = stubFetch() renderExplorer() fireEvent.click(screen.getByRole('tab', { name: 'Generate' })) - fireEvent.click(screen.getByText('maqam')) // in… - fireEvent.click(screen.getByText('maqam')) // …and back out + fireEvent.click(screen.getByRole('button', { name: 'LoRA: Off' })) + const availableRow = screen.getByText('maqam').closest('.ui-lora-panel__adapter') + expect(availableRow).not.toBeNull() + fireEvent.click(within(availableRow as HTMLElement).getByRole('button', { name: 'Apply' })) + const appliedRow = screen.getByText('maqam').closest('.ui-lora-panel__adapter') + expect(appliedRow).not.toBeNull() + fireEvent.click(within(appliedRow as HTMLElement).getByRole('button', { name: 'Remove' })) await composeTrack('clean take') expect(fetchMock).toHaveBeenCalledWith( '/api/generate', diff --git a/frontend/src/media/MediaExplorer.tsx b/frontend/src/media/MediaExplorer.tsx index b2da1f1..d7e915a 100644 --- a/frontend/src/media/MediaExplorer.tsx +++ b/frontend/src/media/MediaExplorer.tsx @@ -18,14 +18,8 @@ import { import { useInterfaceStore } from '../audio/interfaceStore' import { useControlBus } from '../control/busContext' import { CrateBrowser } from '../crates/CrateBrowser' -import { - adaptersForKind, - MAX_LORA_STACK, - stackForKind, - useLoras, - useLoraStack, -} from '../models/useLoras' -import { LoraRack } from '../ui/LoraRack' +import { stackForKind, useLoras, useLoraStack } from '../models/useLoras' +import { LoraControl } from '../ui/LoraControl' import type { StylePreset } from '../presets' import { Button } from '../ui/Button' import { Panel } from '../ui/Panel' @@ -899,8 +893,6 @@ export function MediaExplorer({ const lengths = ENGINE_LENGTHS[engine] // The racks offer only base-matched adapters; Magenta hides the rack // entirely (no adapter path). - const trackAdapters = engine === 'magenta' ? [] : adaptersForKind(loras, engine) - const sampleAdapters = adaptersForKind(loras, sampleEngine) function loadButtons(onLoad: (deck: DeckId) => void, name: string) { return (['a', 'b'] as const).map((deck) => ( @@ -1112,15 +1104,13 @@ export function MediaExplorer({ {t('media.generate.action')}
- ({ - name: adapter.name, - label: adapter.slug, - }))} + {tracks.length === 0 ? (

{t('media.generate.empty')}

@@ -1279,15 +1269,13 @@ export function MediaExplorer({ {t('media.generate.action')}
- ({ - name: adapter.name, - label: adapter.slug, - }))} + {samples.length === 0 ? (

{t('media.samples.empty')}

diff --git a/frontend/src/models/LoraLibrary.test.tsx b/frontend/src/models/LoraLibrary.test.tsx deleted file mode 100644 index d61d467..0000000 --- a/frontend/src/models/LoraLibrary.test.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { ModelProgress, ModelStatus } from '../audio/nativeEngine' - -// Capture the subscriber callbacks so tests can fire watcher / progress events. -let changedCb: (() => void) | null = null -let progressCb: ((event: ModelProgress) => void) | null = null -const modelStatus = vi.fn<() => Promise>() -const installLora = vi.fn<(source: object, base?: string) => Promise>(async () => {}) -const deleteLora = vi.fn<(name: string) => Promise>(async () => {}) -const cancelInstall = vi.fn(async () => {}) -const openModelFolder = vi.fn<(family: string) => Promise>(async () => {}) -const invoke = vi.fn<(cmd: string, args?: object) => Promise>(async () => null) - -vi.mock('../audio/nativeEngine', () => ({ - modelStatus: () => modelStatus(), - installLora: (source: object, base?: string) => installLora(source, base), - deleteLora: (name: string) => deleteLora(name), - cancelInstall: () => cancelInstall(), - openModelFolder: (family: string) => openModelFolder(family), - invoke: (cmd: string, args?: object) => invoke(cmd, args), - subscribeModelsChanged: (cb: () => void) => { - changedCb = cb - return () => {} - }, - subscribeModelProgress: (cb: (event: ModelProgress) => void) => { - progressCb = cb - return () => {} - }, -})) - -import { LoraLibrary } from './LoraLibrary' - -function status(overrides: Partial = {}): ModelStatus { - return { - magenta: { - modelsDir: '/models', - resourcesPresent: true, - installable: ['mrt2_small', 'mrt2_base'], - installed: [], - }, - sa3: { - state: 'ready', - sizeBytes: 5_000_000_000, - checkout: '/sa3', - installedSource: null, - pinnedSource: { repo: 'https://github.com/brxs/stable-audio-3', commit: 'pinned1' }, - updateAvailable: false, - }, - loras: [], - installing: null, - ...overrides, - } -} - -const maqam = { - name: 'medium/maqam', - base: 'medium' as const, - slug: 'maqam', - sizeBytes: 200_000_000, - source: 'motiftechnologies/stable-audio-3-maqam-lora', - adapterType: 'lora', - rank: 64, -} - -beforeEach(() => { - vi.clearAllMocks() - changedCb = null - progressCb = null -}) - -describe('LoraLibrary', () => { - it('lists installed adapters with base and size, and deletes one', async () => { - modelStatus.mockResolvedValue(status({ loras: [maqam] })) - render() - expect(await screen.findByText('maqam')).toBeInTheDocument() - expect(screen.getByText('Medium DiT (tracks) · 200 MB')).toBeInTheDocument() - fireEvent.click(screen.getByLabelText('Delete adapter maqam')) - expect(deleteLora).toHaveBeenCalledWith('medium/maqam') - }) - - it('reveals the registry folder for native inspection', async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - fireEvent.click(screen.getByText('Open folder')) - expect(openModelFolder).toHaveBeenCalledWith('lora') - }) - - it('imports an adapter from a HuggingFace repo id', async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - fireEvent.change(screen.getByLabelText('HuggingFace repo'), { - target: { value: 'owner/my-lora' }, - }) - fireEvent.click(screen.getByText('Install')) - expect(installLora).toHaveBeenCalledWith({ hfRepo: 'owner/my-lora' }, undefined) - }) - - it('accepts a pasted huggingface.co URL and installs the canonical repo id', async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - fireEvent.change(screen.getByLabelText('HuggingFace repo'), { - target: { - value: 'https://huggingface.co/motiftechnologies/stable-audio-3-maqam-lora', - }, - }) - fireEvent.click(screen.getByText('Install')) - expect(installLora).toHaveBeenCalledWith( - { hfRepo: 'motiftechnologies/stable-audio-3-maqam-lora' }, - undefined, - ) - }) - - it('passes an explicit base override to a repo import', async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - fireEvent.change(screen.getByLabelText('HuggingFace repo'), { - target: { value: 'owner/xs-lora' }, - }) - fireEvent.change(screen.getByLabelText('Base'), { target: { value: 'medium' } }) - fireEvent.click(screen.getByText('Install')) - expect(installLora).toHaveBeenCalledWith({ hfRepo: 'owner/xs-lora' }, 'medium') - }) - - it('imports an adapter file through the native picker', async () => { - modelStatus.mockResolvedValue(status()) - invoke.mockResolvedValue('/downloads/maqam.safetensors') - render() - await screen.findByText('No adapters installed') - fireEvent.click(screen.getByText('Import file…')) - await waitFor(() => - expect(installLora).toHaveBeenCalledWith( - { path: '/downloads/maqam.safetensors' }, - undefined, - ), - ) - expect(invoke).toHaveBeenCalledWith( - 'plugin:dialog|open', - expect.objectContaining({ options: expect.anything() }), - ) - }) - - it('shows live import progress, offers cancel, and re-fetches on change', async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - act(() => - progressCb?.({ - family: 'lora', - name: 'owner/my-lora', - stage: 'download', - message: null, - file: 'adapter_model.safetensors', - }), - ) - expect(screen.getByText(/adapter_model\.safetensors/)).toBeInTheDocument() - fireEvent.click(screen.getByText('Cancel')) - expect(cancelInstall).toHaveBeenCalledTimes(1) - act(() => - progressCb?.({ family: 'lora', name: '', stage: 'cancelled', message: null, file: null }), - ) - act(() => changedCb?.()) - await waitFor(() => expect(modelStatus).toHaveBeenCalledTimes(2)) - // A cancel is a clean stop, never an error banner. - expect(screen.queryByRole('alert')).toBeNull() - }) - - it("another family's install gates the import buttons without claiming the label", async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - fireEvent.change(screen.getByLabelText('HuggingFace repo'), { - target: { value: 'owner/my-lora' }, - }) - act(() => - progressCb?.({ family: 'sa3', name: '', stage: 'install', message: null, file: null }), - ) - // One install at a time shell-side: both actions disabled, no lora label. - expect(screen.getByText('Install')).toBeDisabled() - expect(screen.getByText('Import file…')).toBeDisabled() - expect(screen.queryByText('Cancel')).toBeNull() - }) - - it("surfaces a lora import error but not another family's", async () => { - modelStatus.mockResolvedValue(status()) - render() - await screen.findByText('No adapters installed') - act(() => - progressCb?.({ family: 'magenta', name: 'mrt2_base', stage: 'error', message: 'no weights', file: null }), - ) - expect(screen.queryByRole('alert')).toBeNull() - act(() => - progressCb?.({ family: 'lora', name: '', stage: 'error', message: 'not a recognised SA3 LoRA', file: null }), - ) - expect(screen.getByRole('alert')).toHaveTextContent('not a recognised SA3 LoRA') - }) -}) diff --git a/frontend/src/models/LoraLibrary.tsx b/frontend/src/models/LoraLibrary.tsx deleted file mode 100644 index e051945..0000000 --- a/frontend/src/models/LoraLibrary.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' - -import { Button } from '../ui/Button' -import { Select } from '../ui/Select' -import { TextField } from '../ui/TextField' -import { - cancelInstall, - deleteLora, - installLora, - invoke, - modelStatus, - openModelFolder, - subscribeModelProgress, - subscribeModelsChanged, - type LoraBase, - type ModelProgress, - type ModelStatus, -} from '../audio/nativeEngine' -import { formatBytes } from './formatBytes' - -/** Accept what people actually paste as a repo: a bare `owner/name` id or a - * full huggingface.co URL (scheme/host, a /tree/… suffix, query, fragment). - * Best-effort — anything without a recognisable id inside passes through - * unchanged and the shell's validation names the problem. Mirrors the Rust - * `loras::normalize_hf_repo`. */ -function normalizeHfRepo(input: string): string { - const rest = input - .trim() - .replace(/^https?:\/\//, '') - .replace(/^(www\.)?(huggingface\.co|hf\.co)\//, '') - .split(/[?#]/)[0] - const segments = rest.split('/').filter(Boolean) - return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : input.trim() -} - -/** The LoRA library (issue #66): list / import / delete Stable Audio 3 LoRA - * adapters, its own settings section beside the model manager. Same shape as - * the manager — Rust owns the lifecycle, this reads `model_status` and drives - * the import commands, and the in-flight install survives a drawer - * close/reopen via the status snapshot. */ -export function LoraLibrary() { - const { t } = useTranslation() - const [status, setStatus] = useState(null) - // The in-flight install across ALL families — the shell runs one install at - // a time, so a Magenta/SA3 download gates the import buttons here too; only - // lora events surface as this section's label/error. - const [inflight, setInflight] = useState(null) - const [error, setError] = useState(null) - // The import controls: a HuggingFace repo/link draft and the base override - // ('auto' = infer from the adapter's shapes, the normal case). - const [repo, setRepo] = useState('') - const [base, setBase] = useState<'auto' | LoraBase>('auto') - - const refresh = useCallback(() => { - modelStatus().then(setStatus).catch(() => {}) - }, []) - - useEffect(() => { - refresh() - const unsubChanged = subscribeModelsChanged(refresh) - const unsubProgress = subscribeModelProgress((event) => { - // `done` (success) and `cancelled` (user stop) both just clear the - // in-flight UI; the follow-up `models://changed` reflects the new state. - if (event.stage === 'done' || event.stage === 'cancelled') { - setInflight(null) - setError(null) - } else if (event.stage === 'error') { - setInflight(null) - // Another family's failure is the model manager's story to tell. - if (event.family === 'lora') { - setError(event.message ?? t('modelManager.stage.error')) - } - } else { - setInflight(event) - if (event.family === 'lora') setError(null) - } - }) - return () => { - unsubChanged() - unsubProgress() - } - }, [refresh, t]) - - const onInstall = useCallback( - (source: { hfRepo: string } | { path: string }, name: string) => { - setError(null) - // The display name mirrors the Rust spec's display_name (the progress - // event key), so the pending label shows before the first event lands. - setInflight({ family: 'lora', name, stage: 'fetch', message: null, file: null }) - installLora(source, base === 'auto' ? undefined : base).catch((e: unknown) => { - setInflight(null) - setError(String(e)) - }) - }, - [base], - ) - - const onImportFile = useCallback(() => { - void (async () => { - // The native file picker (dialog plugin) — WKWebView has no File System - // Access API. Only .safetensors is offered (ADR-0028's trust boundary; - // the shell refuses anything else anyway). - const path = await invoke('plugin:dialog|open', { - options: { - multiple: false, - filters: [{ name: t('modelManager.loraFileFilter'), extensions: ['safetensors'] }], - }, - }).catch(() => null) - if (!path) return // the user dismissed the picker - const name = path.replace(/\/+$/, '').split('/').pop() || path - onInstall({ path }, name) - })() - }, [onInstall, t]) - - const onDelete = useCallback((name: string) => { - setError(null) - // The follow-up `models://changed` refreshes the list. - deleteLora(name).catch((e: unknown) => setError(String(e))) - }, []) - - if (!status) { - return

{t('modelManager.loading')}

- } - - const snapshot = status.installing - const isInstalling = inflight !== null || snapshot !== null - // The in-flight import's label — live event (detailed) or status snapshot. - const label = - inflight?.family === 'lora' - ? (() => { - const stage = t(`modelManager.stage.${inflight.stage}`, { - defaultValue: inflight.stage, - }) - // The keyed stage carries the wording; only the file (data) rides along. - return inflight.file ? `${stage} ${inflight.file}` : stage - })() - : snapshot?.family === 'lora' - ? t('modelManager.installing') - : null - const repoDraft = normalizeHfRepo(repo) - - return ( -
- {error && ( -

- {t('modelManager.errorPrefix', { message: error })} -

- )} - -
-
-

{t('modelManager.loras')}

- -
- {status.loras.length === 0 && ( -

{t('modelManager.loraNone')}

- )} - {status.loras.map((adapter) => ( -
-
-
{adapter.slug}
-
- {t(`modelManager.loraBase.${adapter.base}`)} - {` · ${formatBytes(adapter.sizeBytes)}`} -
-
-
- -
-
- ))} -
-
- setRepo(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && repoDraft && !isInstalling) { - onInstall({ hfRepo: repoDraft }, repoDraft) - } - }} - /> -
- setBase(next as 'auto' | LoraBase)} + /> + +
+ {progressLabel ? ( + + ) : ( + <> + + + + )} +
+ {progressLabel ?

{progressLabel}

: null} + {library.isInstalling && !progressLabel ? ( +

{t('lora.anotherInstall')}

+ ) : null} +
+ +
+ + + ) +} diff --git a/frontend/src/ui/LoraRack.test.tsx b/frontend/src/ui/LoraRack.test.tsx deleted file mode 100644 index afc0fe1..0000000 --- a/frontend/src/ui/LoraRack.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' - -import { LoraRack } from './LoraRack' - -const ADAPTERS = [ - { name: 'small/gamelan', label: 'gamelan' }, - { name: 'small/tape-drums', label: 'tape-drums' }, - { name: 'small/vinyl-noise', label: 'vinyl-noise' }, -] - -describe('LoraRack', () => { - it('renders nothing at all without adapters', () => { - const { container } = render( - {}} onStrength={() => {}} />, - ) - expect(container).toBeEmptyDOMElement() - }) - - it('renders every adapter as a chip and marks the stacked ones', () => { - render( - {}} - onStrength={() => {}} - />, - ) - expect(screen.getByText('gamelan')).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByText('tape-drums')).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByText('vinyl-noise')).toHaveAttribute('aria-pressed', 'false') - }) - - it('signals a toggle for chips in and out of the stack', () => { - const onToggle = vi.fn() - render( - {}} - />, - ) - fireEvent.click(screen.getByText('tape-drums')) // into the stack - fireEvent.click(screen.getByText('gamelan')) // out of it - expect(onToggle.mock.calls).toEqual([['small/tape-drums'], ['small/gamelan']]) - }) - - it('shows a trim knob only for stacked adapters and reports turns', () => { - const onStrength = vi.fn() - render( - {}} - onStrength={onStrength} - />, - ) - expect(screen.queryByLabelText('tape-drums strength')).toBeNull() - const knob = screen.getByLabelText('gamelan strength') - fireEvent.change(knob, { target: { value: '0.5' } }) - expect(onStrength).toHaveBeenCalledWith('small/gamelan', 0.5) - }) - - it('parks the trim back at ×1 on double-click', () => { - const onStrength = vi.fn() - render( - {}} - onStrength={onStrength} - />, - ) - fireEvent.doubleClick(screen.getByLabelText('gamelan strength')) - expect(onStrength).toHaveBeenCalledWith('small/gamelan', 1) - }) - - it('dims a slot at ×0 — in the stack, bit-exact silent', () => { - render( - {}} - onStrength={() => {}} - />, - ) - const slot = screen.getByText('gamelan').closest('.ui-lorarack__slot') - expect(slot).toHaveClass('ui-lorarack__slot--bypass') - expect(screen.getByText('×0')).toBeInTheDocument() - }) - - it('disables the remaining chips once the stack hits the cap', () => { - render( - {}} - onStrength={() => {}} - max={2} - />, - ) - expect(screen.getByText('vinyl-noise')).toBeDisabled() - // Stacked chips stay clickable — dropping out must always be possible. - expect(screen.getByText('gamelan')).toBeEnabled() - }) -}) diff --git a/frontend/src/ui/LoraRack.tsx b/frontend/src/ui/LoraRack.tsx deleted file mode 100644 index 7c8b919..0000000 --- a/frontend/src/ui/LoraRack.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useTranslation } from 'react-i18next' - -import { Knob, type KnobAccent } from './Knob' - -/** One selectable adapter: `name` is the registry identity sent in requests, - * `label` the display slug. */ -export type LoraRackAdapter = { name: string; label: string } -/** One slot of the stack: an adapter in the mix at a merge strength. */ -export type LoraRackChoice = { name: string; strength: number } - -// The trim knob's range: the current stops (0-2 in quarter steps) become -// detents. 0 is the bit-exact bypass (ADR-0028); the backend bound is wider -// (0-4), this is the curated UX range. -const STRENGTH_MIN = 0 -const STRENGTH_MAX = 2 -const STRENGTH_STEP = 0.25 -const STRENGTH_REST = 1 - -type LoraRackProps = { - /** Base-matched adapters, library order — one slot each. */ - adapters: LoraRackAdapter[] - /** The stack: which adapters ride the generation, at what strength. */ - value: LoraRackChoice[] - onToggle: (name: string) => void - onStrength: (name: string, strength: number) => void - accent?: KnobAccent - /** Stack cap (mirrors the backend's MAX_LORA_STACK); excess chips disable. */ - max?: number -} - -/** The LoRA stack as a one-line FX rack strip (issue #66 follow-up): every - * adapter is a toggle chip; clicking one into the stack grows a trim knob - * beside it (double-click parks it at ×1). At ×0 the slot dims — in the - * stack, bit-exact silent. Order never matters (the merge is - * order-independent, ADR-0028), so slots stay in library order. Renders - * nothing without adapters — the rack owns its empty state, callers don't - * guard. */ -export function LoraRack({ - adapters, - value, - onToggle, - onStrength, - accent = 'master', - max = 4, -}: LoraRackProps) { - const { t } = useTranslation() - // The cap counts only slots the rack can show — a stale entry (adapter - // deleted mid-session) must not hold a slot hostage. - const present = value.filter((entry) => - adapters.some((adapter) => adapter.name === entry.name), - ) - const full = present.length >= max - - if (adapters.length === 0) return null - - return ( -
- {t('lora.rack')} -
- {adapters.map((adapter) => { - const choice = value.find((entry) => entry.name === adapter.name) - const active = choice !== undefined - const blocked = !active && full - const slotClass = [ - 'ui-lorarack__slot', - active ? 'ui-lorarack__slot--on' : '', - choice?.strength === 0 ? 'ui-lorarack__slot--bypass' : '', - ] - .filter(Boolean) - .join(' ') - return ( -
- - {active && ( -
- onStrength(adapter.name, strength)} - /> -
- )} -
- ) - })} -
-
- ) -} diff --git a/frontend/src/ui/ui.css b/frontend/src/ui/ui.css index 9ed90be..6fc235f 100644 --- a/frontend/src/ui/ui.css +++ b/frontend/src/ui/ui.css @@ -529,7 +529,7 @@ opacity: 0.4; } -/* Small dial ('s'): the in-row trim (LoRA rack). The viewBox is 28 so the +/* Small dial ('s'): the in-row trim (for example the LoRA panel). The viewBox is 28 so the stroke scales down a step to keep the arc weight proportional. */ .ui-knob--s .ui-knob__dial { width: 1.75rem; @@ -541,115 +541,343 @@ stroke-width: 2.6; } -/* ---------- LoRA rack (issue #66 follow-up) ---------- */ +/* ---------- contextual LoRA control (issue #66 proposal) ---------- */ -/* The adapter stack as an FX rack strip: one slot per installed adapter, - and — once toggled into the stack — the slot grows a trim knob BESIDE the - chip behind a hairline divider, keeping the whole rack one control line - (tray height belongs to the library list). Accent follows the host - (deck a/b or master), like the knob. */ -.ui-lorarack { - --rack-accent: var(--color-accent); +.ui-lora-control { + --lora-accent: var(--color-accent); display: flex; align-items: center; gap: var(--space-3); + min-width: 0; } -.ui-lorarack--a { - --rack-accent: var(--color-deck-a); +.ui-lora-control--a, +.ui-lora-panel--a { + --lora-accent: var(--color-deck-a); } -.ui-lorarack--b { - --rack-accent: var(--color-deck-b); +.ui-lora-control--b, +.ui-lora-panel--b { + --lora-accent: var(--color-deck-b); } -.ui-lorarack__label { +.ui-lora-control__label { + flex: none; font: var(--silkscreen); letter-spacing: var(--silkscreen-spacing); text-transform: uppercase; color: var(--color-text-silkscreen); - flex: none; } -.ui-lorarack__slots { +.ui-lora-control__trigger { + min-width: 0; + max-width: 24rem; + height: var(--control-height); display: flex; - flex-wrap: wrap; + align-items: center; gap: var(--space-2); - align-items: stretch; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-s); + background: var(--color-surface-raised); + color: var(--color-text-muted); + font: 600 var(--text-s) var(--font-ui); + letter-spacing: var(--silkscreen-spacing); + text-transform: uppercase; + cursor: pointer; +} + +.ui-lora-control__trigger:hover, +.ui-lora-control__trigger[aria-expanded='true'] { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +.ui-lora-control__trigger:focus-visible { + outline: 1px solid var(--lora-accent); + outline-offset: 2px; } -.ui-lorarack__slot { +.ui-lora-control__led { + width: 0.45rem; + height: 0.45rem; + flex: none; + border-radius: 50%; + background: var(--color-text-silkscreen); + box-shadow: 0 0 0 1px var(--color-surface-inset); +} + +.ui-lora-control__trigger--on { + color: var(--lora-accent); + border-color: var(--lora-accent); +} + +.ui-lora-control__trigger--on .ui-lora-control__led { + background: var(--lora-accent); + box-shadow: 0 0 0.35rem color-mix(in srgb, var(--lora-accent) 55%, transparent); +} + +.ui-lora-control__summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ui-lora-control__chevron { + flex: none; + margin-left: auto; +} + +/* Portalled above the tray/deck overflow regions, but below the modal Settings + drawer. The panel is non-modal: outside click and Escape dismiss it. */ +.ui-anchored-panel { + position: fixed; + z-index: 90; +} + +.ui-lora-panel { + --lora-accent: var(--color-accent); + width: min(35rem, calc(100vw - 1rem)); + max-height: min(42rem, calc(100vh - 1rem)); display: flex; - align-items: stretch; + flex-direction: column; border: 1px solid var(--color-border-strong); - background: var(--color-surface-raised); + background: var(--color-surface); + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.48); } -.ui-lorarack__chip { - font: 600 var(--text-s) var(--font-ui); - line-height: var(--control-line); +.ui-lora-panel:focus { + outline: none; +} + +.ui-lora-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.ui-lora-panel__title, +.ui-lora-panel__context, +.ui-lora-panel__heading, +.ui-lora-panel__empty, +.ui-lora-panel__hint, +.ui-lora-panel__error, +.ui-lora-panel__progress { + margin: 0; +} + +.ui-lora-panel__title, +.ui-lora-panel__heading { + font: var(--silkscreen); letter-spacing: var(--silkscreen-spacing); text-transform: uppercase; - padding: var(--space-2) var(--space-3); +} + +.ui-lora-panel__title { + color: var(--color-text); +} + +.ui-lora-panel__context { + margin-top: var(--space-1); + color: var(--color-text-muted); + font-size: var(--text-xs); +} + +.ui-lora-panel__header-actions, +.ui-lora-panel__actions, +.ui-lora-panel__install-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +.ui-lora-panel__close { border: none; background: transparent; color: var(--color-text-muted); + font-size: var(--text-l); + line-height: 1; cursor: pointer; } -.ui-lorarack__chip:not(:disabled):hover { +.ui-lora-panel__close:hover { color: var(--color-text); } -/* Stack full: the freeze-pad disabled convention. */ -.ui-lorarack__chip:disabled { - opacity: 0.4; - cursor: default; +.ui-lora-panel__close:focus-visible { + outline: 1px solid var(--lora-accent); + outline-offset: 2px; } -.ui-lorarack__chip:focus-visible { - outline: 1px solid var(--color-accent); - outline-offset: -1px; +.ui-lora-panel__body { + overflow-y: auto; + padding: var(--space-4); } -/* In the stack: accent-lit, the icon-picker active convention. */ -.ui-lorarack__slot--on { - border-color: var(--rack-accent); +.ui-lora-panel__section + .ui-lora-panel__section, +.ui-lora-panel__section + .ui-lora-panel__disclosure, +.ui-lora-panel__disclosure + .ui-lora-panel__disclosure { + margin-top: var(--space-4); } -.ui-lorarack__slot--on .ui-lorarack__chip { - color: var(--rack-accent); +.ui-lora-panel__heading { + color: var(--color-text-silkscreen); + margin-bottom: var(--space-2); } -.ui-lorarack__trim { +.ui-lora-panel__empty, +.ui-lora-panel__hint { + color: var(--color-text-muted); + font-size: var(--text-s); +} + +.ui-lora-panel__hint { + margin-top: var(--space-2); +} + +.ui-lora-panel__error { + color: var(--color-led-danger); + font-size: var(--text-s); + margin-bottom: var(--space-3); +} + +.ui-lora-panel__list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.ui-lora-panel__adapter { display: flex; align-items: center; - padding: 0 var(--space-2); - border-left: 1px solid var(--color-border); + gap: var(--space-3); + min-width: 0; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + background: var(--color-surface-raised); +} + +.ui-lora-panel__adapter--on { + border-left: 3px solid var(--lora-accent); +} + +.ui-lora-panel__adapter--bypassed, +.ui-lora-panel__adapter--incompatible { background: var(--color-surface-inset); + color: var(--color-text-muted); +} + +.ui-lora-panel__adapter--bypassed { + border-left: 3px solid var(--color-text-silkscreen); } -/* The knob lies on its side in the strip: dial then ×value, one line. The - readout gets a floor width so the slot doesn't resize under a drag as - "×1" becomes "×0.25". */ -.ui-lorarack__trim .ui-knob { - flex-direction: row; +.ui-lora-panel__adapter--new { + border-color: var(--lora-accent); +} + +.ui-lora-panel__adapter-main { + flex: 1 1 11rem; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.ui-lora-panel__adapter-title { + display: flex; + align-items: center; gap: var(--space-2); } -.ui-lorarack__trim .ui-knob__label { - min-width: 1.9rem; - text-align: left; +.ui-lora-panel__adapter-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text); + font-size: var(--text-m); } -/* ×0 = the bit-exact bypass: in the stack but silent, so the slot dims - toward the surface instead of holding the full accent. */ -.ui-lorarack__slot--bypass { - border-color: color-mix(in srgb, var(--rack-accent) 45%, var(--color-surface)); +.ui-lora-panel__meta { + color: var(--color-text-muted); + font-size: var(--text-xs); } -.ui-lorarack__slot--bypass .ui-lorarack__chip { - color: color-mix(in srgb, var(--rack-accent) 55%, var(--color-surface)); +.ui-lora-panel__state, +.ui-lora-panel__new { + flex: none; + padding: 0 var(--space-1); + border: 1px solid currentColor; + color: var(--lora-accent); + font: var(--silkscreen); + letter-spacing: var(--silkscreen-spacing); + text-transform: uppercase; +} + +.ui-lora-panel__adapter--bypassed .ui-lora-panel__state { + color: var(--color-text-muted); +} + +.ui-lora-panel__adapter .ui-knob { + flex: none; +} + +.ui-lora-panel__adapter .ui-knob__label { + min-width: 4.9rem; + white-space: nowrap; +} + +.ui-lora-panel__disclosure { + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +.ui-lora-panel__disclosure > summary, +.ui-lora-panel__advanced > summary { + color: var(--color-text-muted); + font: 600 var(--text-s) var(--font-ui); + text-transform: uppercase; + letter-spacing: var(--silkscreen-spacing); + cursor: pointer; +} + +.ui-lora-panel__disclosure[open] > summary { + color: var(--color-text); + margin-bottom: var(--space-3); +} + +.ui-lora-panel__install-body { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.ui-lora-panel__advanced { + padding-left: var(--space-2); +} + +.ui-lora-panel__advanced[open] > summary { + margin-bottom: var(--space-2); +} + +.ui-lora-panel__progress { + color: var(--lora-accent); + font-size: var(--text-xs); +} + +@media (max-width: 620px) { + .ui-lora-panel__adapter { + flex-wrap: wrap; + } + + .ui-lora-panel__actions { + margin-left: auto; + } } /* ---------- phase meter (M20) ---------- */ @@ -1358,19 +1586,6 @@ font-size: var(--text-xs); margin-top: var(--space-1); } -/* The LoRA import controls (issue #66): the repo field takes a full line of - the drawer (pasted HF links are long), with base + actions wrapping below. */ -.modelmgr__import { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: var(--space-3); - margin-top: var(--space-3); -} -.modelmgr__import-repo { - flex: 1 1 100%; - min-width: 0; -} .modelmgr__empty { color: var(--color-text-muted); font-size: var(--text-s); diff --git a/src-tauri/src/loras.rs b/src-tauri/src/loras.rs index 1767cab..3b0f5df 100644 --- a/src-tauri/src/loras.rs +++ b/src-tauri/src/loras.rs @@ -206,9 +206,9 @@ fn is_pickle(path: &Path) -> bool { /// The parsed safetensors header: tensor name → shape, plus `__metadata__`. /// Reads ONLY the 8-byte length + JSON header — never tensor data, never a /// pickle (`mx.load`-equivalent trust posture without loading anything). -fn read_safetensors_header( - path: &Path, -) -> Result<(BTreeMap>, BTreeMap), String> { +type SafetensorsHeader = (BTreeMap>, BTreeMap); + +fn read_safetensors_header(path: &Path) -> Result { let file_name = path.file_name().unwrap_or_default().to_string_lossy(); if is_pickle(path) { return Err(format!( @@ -217,7 +217,7 @@ fn read_safetensors_header( execute arbitrary code)" )); } - if !path.extension().is_some_and(|ext| ext == "safetensors") { + if path.extension().is_none_or(|ext| ext != "safetensors") { return Err(format!("'{file_name}' is not a .safetensors adapter")); } let mut file = std::fs::File::open(path)