From 9ce38e80fab8e2a6578c01defafed6206e74fcb1 Mon Sep 17 00:00:00 2001 From: provos Date: Fri, 25 Sep 2026 23:17:22 -0700 Subject: [PATCH 1/2] feat(ui): redesign phase 1 - tokens, app shell, header stepper, status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the Claude Design handoff (docs/redesign/HANDOFF.md §12): - New design tokens (dark-first, light via data-theme on ) with IBM Plex bundled locally and Lucide icons; the dashed Dash-era panels are gone and buttons follow the primary/secondary/ghost/selected system. - AppShell: header, canvas column, Inspector, log drawer, status bar. - AppHeader with the 7-step WorkflowStepper (done/current/todo), undo/redo for the selected slice, theme, Settings and the primary Export action. - Inspector hosts the pre-redesign tab bodies per step until each step gets its own panel; Settings shows the Configuration panel. - StatusBar: running job + progress, latest message, image size, log toggle. LogDrawer holds the whole log. - New projects take on the theme on screen; after an upload's depth map is ready the workflow moves to Slices. - e2e driver maps the former tabs onto steps/Settings; image waits no longer require the image to be on screen. Co-Authored-By: Claude Opus 5.5 --- docs/redesign/HANDOFF.md | 190 +++ docs/redesign/prototype/Prototype.dc.html | 1221 +++++++++++++++++ docs/redesign/tokens.css | 125 ++ e2e/drivers/svelte.ts | 64 +- e2e/helpers/image.ts | 7 +- frontend/package-lock.json | 51 +- frontend/package.json | 5 +- frontend/src/App.svelte | 89 +- frontend/src/App.test.ts | 29 +- frontend/src/app.css | 465 ++++--- .../components/shell/ActivityIndicator.svelte | 52 +- .../src/lib/components/shell/AppHeader.svelte | 152 ++ .../src/lib/components/shell/AppShell.svelte | 76 + .../src/lib/components/shell/Footer.svelte | 9 - .../src/lib/components/shell/Header.svelte | 90 -- .../src/lib/components/shell/Inspector.svelte | 75 + .../src/lib/components/shell/LogDrawer.svelte | 77 ++ .../src/lib/components/shell/LogPanel.svelte | 31 - .../src/lib/components/shell/MainTabs.svelte | 78 -- .../src/lib/components/shell/Shell.test.ts | 207 +++ .../src/lib/components/shell/StatusBar.svelte | 83 ++ .../components/shell/WorkflowStepper.svelte | 131 ++ frontend/src/lib/state/logs.svelte.ts | 5 +- frontend/src/lib/state/ui.svelte.ts | 80 +- frontend/src/lib/workflow.test.ts | 7 + frontend/src/lib/workflow.ts | 11 +- 26 files changed, 2840 insertions(+), 570 deletions(-) create mode 100644 docs/redesign/HANDOFF.md create mode 100644 docs/redesign/prototype/Prototype.dc.html create mode 100644 docs/redesign/tokens.css create mode 100644 frontend/src/lib/components/shell/AppHeader.svelte create mode 100644 frontend/src/lib/components/shell/AppShell.svelte delete mode 100644 frontend/src/lib/components/shell/Footer.svelte delete mode 100644 frontend/src/lib/components/shell/Header.svelte create mode 100644 frontend/src/lib/components/shell/Inspector.svelte create mode 100644 frontend/src/lib/components/shell/LogDrawer.svelte delete mode 100644 frontend/src/lib/components/shell/LogPanel.svelte delete mode 100644 frontend/src/lib/components/shell/MainTabs.svelte create mode 100644 frontend/src/lib/components/shell/Shell.test.ts create mode 100644 frontend/src/lib/components/shell/StatusBar.svelte create mode 100644 frontend/src/lib/components/shell/WorkflowStepper.svelte diff --git a/docs/redesign/HANDOFF.md b/docs/redesign/HANDOFF.md new file mode 100644 index 0000000..32cb0f7 --- /dev/null +++ b/docs/redesign/HANDOFF.md @@ -0,0 +1,190 @@ +# Parallax Maker UI redesign — implementation handoff + +This folder is the handoff for rebuilding the Svelte 5 frontend (`frontend/`) around a new layout. The backend API and `ProjectView` do not change unless a section below says otherwise. + +- **Interactive prototype:** `prototype/Prototype.dc.html` is its source. It is a design-canvas file, not Svelte: the markup is HTML, and the behavior sits in the `class Component` script at the bottom. Read it for layout, states and copy. Do not port it line for line. Its images point at `/_blob/...` URLs that only load on the design canvas. Every image, mask and "AI" result in it is a placeholder. +- **Design tokens:** `tokens.css` replaces the `:root` / `:root.dark` blocks in `frontend/src/app.css`. +- **Live canvas (owner-only link):** https://claude.ai/artifact/AbtMWr5H9m2BUyPft25Leo — the board "Interactive prototype · press Play". Its "Prototype states ▾" menu in the status bar jumps to each of the 7 required states. That menu is a demo aid, not a product feature. + +## 1. Goals (from the brief) + +1. The main loop (select slice → fix mask → inpaint → preview) needs no tab switching. The layer list is always visible, and the selected slice drives a context-sensitive Inspector. +2. The image canvas dominates. View modes: Input, Depth, Slice, Composite, Parallax 2D, 3D. Canvas tools show only when they apply. +3. A workflow stepper shows progress and next steps for new users. Keyboard shortcuts serve experienced users. +4. Rare settings live in a Settings dialog. Export lives in its own dialog. +5. Inline job progress and toasts replace the log as primary feedback. The log becomes an optional drawer. +6. Dark-first, with a light mode. Primary and secondary actions are obvious: amber for the one main action, blue for selection. +7. **Segment Anything (SAM) is the main way to create slices.** Depth-threshold splitting stays as a secondary path. +8. **Leave room for outpainting** (growing a slice beyond the image edges). It is not built yet; section 7 describes the slot the UI reserves for it. + +Constraints: no feature may be lost, only moved. The app fills the viewport with no page scroll, and panels scroll internally. It must work from 1280×800 up. + +## 2. Layout + +``` +┌ AppHeader 48px ── logo · project name │ WorkflowStepper (1–7) │ undo redo theme settings [Export…] ┐ +├ LayerPanel 300px ─┬ CanvasArea (flex) ─────────────────────────┬ Inspector 328px ────────────────┤ +│ header + actions │ ViewModeBar 40px (view tabs · zoom · checker) │ SelectedSliceHeader │ +│ DepthRuler + list │ ToolOptionsBar 44px (options for active tool) │ step section(s), scrolls │ +│ │ Canvas (CanvasToolbar floats at left) │ │ +│ Input / Depth rows│ SelectionBar / JobCard / Toasts float over │ NextStepCard (pinned bottom) │ +├───────────────────┴─────────────────────────────────────────────┴─────────────────────────────────┤ +│ LogDrawer 180px (optional, toggled) │ +├ StatusBar 28px ── active job + progress + Cancel │ last message │ Log · N │ Shortcuts ? ┤ +└───────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +At 1280px wide the canvas column gets 652px. That is enough; do not collapse panels above 1280. + +## 3. Component map (Svelte names) + +Put new components under `frontend/src/lib/components//`. "Replaces" names the component being retired or absorbed. + +| Component | Responsibility | Replaces / absorbs | +|---|---|---| +| `AppShell` | CSS grid above; owns `data-theme` on `` | `App.svelte` layout, `MainTabs` | +| `AppHeader` | Logo, project name, `WorkflowStepper`, undo/redo (selected slice), theme toggle, Settings, Export (primary) | `shell/Header` | +| `WorkflowStepper` | Steps 1–7: Image, Depth, Slices, Inpaint, Ground, Preview, Export. Each is done, current or todo. Clicking a step sets `uiStore.step` plus that step's default view and tool (§4). Export opens `ExportDialog` | `MainTabs` | +| `LayerPanel` | Header (count, Copy, Paste, Delete), `DepthRuler`, `LayerList`, pinned "Input image" and "Depth map" rows that switch the view | slice grid in `SegmentationTab` | +| `DepthRuler` | Vertical 0–255 axis, one draggable handle per slice at its depth, a curve linking each handle to its row. The ground slice's handle is outlined green and the selected one is blue. Drag calls `setSliceDepth` on mouseup | slice depth editing | +| `LayerList` / `LayerRow` | Sorted nearest first. Thumbnail, file name (`image_slice_N`), depth chip, badges (GROUND, inpainted). Click selects the slice | slice thumbnails | +| `ViewModeBar` | View tabs, checkerboard toggle, zoom −/%/+/Fit | `ViewerTabs`, zoom buttons in `InputImagePanel` | +| `CanvasToolbar` | Tools: Pan (H), Segment (S), Inpaint brush (B), Extend (O, planned), Horizon (G). Hidden in Parallax 2D and 3D | tool buttons under the image | +| `ToolOptionsBar` | Options for the active tool only (§5) | `MaskToolbar`, Invert/Feather/Multi row, camera row | +| `Canvas` | Renders the current view and overlays: SAM mask and points, brush strokes and cursor, horizon line, extend frame, candidate hover preview | `InputImagePanel`, `MaskCanvas`, `HorizonOverlay`, `PreviewOverlay`, `Model3DViewer` (3D view) | +| `SelectionBar` | Floats under the image while SAM has a mask: summary, Invert, Feather, "Segment N points" (multi-point), Add to / Remove from selected slice, **New slice** (primary, Enter) | Create/Add/Remove, Invert, Feather, Commit | +| `Inspector` | `SelectedSliceHeader` plus the section(s) for `uiStore.step` (§4), then `NextStepCard` | the five tab bodies | +| `InpaintPanel` | Mode switch Fill holes / Extend edges (planned), numbered sub-steps, prompts, sliders, Generate (primary until candidates exist), Fill/Enhance/Erase, `JobCard`, `CandidateGrid`, Apply (primary once one is picked) | `InpaintingTab` | +| `SegmentPanel` | SAM instructions with 3 sub-steps, selection summary, New slice; collapsible "Split by depth" (number of slices, Split, Balance); "Selected slice" (depth, ground toggle, replace image) | `SegmentationTab`, `ModeTab` mode selector | +| `GroundPanel` | Ground slice radio list (+ "No ground plane"), Fit ground, horizon row and pitch readout, ground distance, `SceneSideView` | Ground/Fit ground buttons, horizon readout, ground section of `ExportTab` | +| `PreviewPanel` | 2D/3D switch, camera distance, max distance, focal length | camera sliders in `ExportTab` | +| `ExportDialog` | Two sections: 3D scene (camera settings mirrored, mesh displacement, depth of field, ground summary with Edit, Upscale textures, Create → Download glTF) and Animation (number of frames, Render). Inline progress | `ExportTab` | +| `SettingsDialog` | Sections with left nav: Inpainting (model, server address + Test, ComfyUI workflow upload, API key + Test), Depth (model), Masks (padding, blur), Slicing (default number of slices), Project (Load/Save state), Appearance (theme) | `ConfigurationTab`, depth model select | +| `EmptyState` | Drop zone, "Choose image…", "try the example image", "load a saved project…"; the Inspector shows the 7-step Getting started list | empty `InputImagePanel` | +| `JobCard` | Label, %, detail line, Cancel. Rendered inline next to the control that started the job, and mirrored in `StatusBar` | `ActivityIndicator`, per-tab progress bars | +| `ToastStack` | Top-right of the canvas. Success and info dismiss after 3s; errors persist and carry actions (Retry, Open settings, View log) | log-as-feedback | +| `LogDrawer` | The existing log, toggled from the status bar or the ` key | `LogPanel` | +| `ShortcutsDialog` | Opened with `?`; lists §6 | `HelpTooltip` (fold its help texts into step sections and hint lines) | + +## 4. UI state and step → defaults + +New UI-only state in `uiStore` (nothing goes to the backend): `step`, `tool`, `view`, `inpaintMode: 'holes' | 'extend'`, `segmentBy: 'object' | 'depth'`, `logOpen`, `exportOpen`, `settingsOpen`, `shortcutsOpen`, `toasts[]`, `hoverCandidate`, `zoom`. + +Choosing a step sets defaults. Users can change view and tool freely afterwards. + +| Step | View | Tool | Inspector section | +|---|---|---|---| +| Image | Input | Pan | Input image (replace), Project (load/save state) | +| Depth | Depth | Pan | Depth model select, Regenerate depth map | +| Slices | Input | Segment | `SegmentPanel` | +| Inpaint | Slice | Brush (or Extend) | `InpaintPanel`; selects the farthest slice if none is selected | +| Ground | Composite | Horizon | `GroundPanel` | +| Preview | Parallax 2D | — | `PreviewPanel` | +| Export | (unchanged) | — | opens `ExportDialog` | + +Choosing a tool also moves the step: Segment → Slices, Brush/Extend → Inpaint, Horizon → Ground. Views Parallax 2D and 3D → Preview. + +Stepper "done" rules: Image = image loaded; Depth = depth map exists; Slices = at least one slice; Inpaint = any slice has had a candidate applied (track client-side if `ProjectView` lacks a flag); Ground = a ground slice exists; Preview = user opened Parallax or 3D this session; Export = an export finished this session. + +After a load, the app auto-advances: when depth finishes it goes to **Slices** with the Segment tool active, and a toast says "Depth map ready — click objects to cut them into layers, or split by depth." + +## 5. Canvas tools and their options bar + +- **Segment (S).** The options bar holds **Select by: Object | Depth band** (default Object), Multi-point, and Clear points (Esc). + - The backend's `clickSegmentation(x, y, mode, shiftKey, ctrlKey)` keeps its signature: Object maps to `mode='instance'`, Depth band to `'depth'` (this replaces the Mode Selector). + - Shift-click adds to the mask; Ctrl/Cmd-click subtracts. The prototype used Alt; implement Ctrl/Cmd to match the backend and today's help text, and accept Alt as an alias. + - In multi-point mode, clicks queue points (`setMultiPointMode`, queued markers) and "Segment N points" calls `commitMultiPoint`. + - While a click job runs, show a small "Segmenting…" chip over the canvas. + - The `SelectionBar` actions map to: New slice → `createSlice`, Add to → `addMaskToSlice`, Remove from → `removeMaskFromSlice`, Invert → `invertMask`, Feather → `featherMask`. +- **Brush (B).** The options bar holds Paint/Erase (X toggles), Size, Load saved mask, Save mask, Clear. It keeps `MaskCanvas` behavior and the ROI box. The cursor becomes a brush-size ring. +- **Extend (O, planned).** The options bar holds a "±96 px sides" preset and Reset. The canvas draws a dashed frame for the requested margins. See §7. +- **Horizon (G).** Only on Composite, Input or Depth. Drag the line to call `setHorizonRow`. The options bar shows row and pitch, plus Fit ground. +- **Parallax 2D.** No toolbar. The options bar is the camera pad (← ↑ ↓ →, forward, back, Reset) via `navigateCamera`. Moving the pointer over the image may drive a *client-side* preview offset; that is optional polish, and the real camera still moves through `navigateCamera`. +- **3D.** `Model3DViewer`. The options bar shows the camera pad and an orbit hint. +- **Pan (H)** and zoom: existing zoom/pan/reset, moved to `ViewModeBar`. + +## 6. Keyboard shortcuts + +Register them on the app root, not `window`. Ignore them while focus is in a text field, except Ctrl+Enter in the prompt fields. + +| Key | Action | Key | Action | +|---|---|---|---| +| S / B / X / O / G / H | Segment / Brush / toggle erase / Extend / Horizon / Pan | Enter | New slice from selection | +| Shift+Enter / Alt+Enter | Add to / Remove from selected slice | [ / ] | Previous / next layer (by depth) | +| Ctrl+Enter | Generate candidates | 1 2 3 | Pick candidate | +| A | Apply picked candidate | I M L C P, Shift+P | Views: Input, depth Map, sLice, Composite, Parallax, 3D | +| Ctrl+Z / Ctrl+Shift+Z | Undo / redo on selected slice (`undoSlice` / `redoSlice`) | Delete | Delete selected slice | +| Ctrl+E / Ctrl+, | Export / Settings | ` / ? / Esc | Log / Shortcuts / clear points or close dialog | + +## 7. Outpainting slot (planned, needs backend) + +The UI reserves space so outpainting slots in without a relayout: + +- `InpaintPanel` has a mode switch, **Fill holes | Extend edges** (the latter badged PLANNED). Both modes share the prompt, negative prompt, strength, guidance, Generate, the 3-candidate grid and Apply. +- Extend edges swaps sub-step 1 ("Paint the holes") for four margin sliders (left, right, top, bottom; 0–192 px, step 8). The canvas shows the grown frame. Candidates preview in that frame on hover. +- Needed from the backend: an endpoint such as `POST /api/v1/projects/{id}/slices/{index}/outpaint` with margins and the same prompt fields, returning candidates the way inpainting does. Slices also need a stored size or offset, so composite, glTF export and animation handle slices larger than the input. Until that exists, render Extend edges disabled with a "Coming soon" tooltip, or behind a feature flag. + +## 8. Feature parity checklist + +Every current control and where it goes. Keep existing `data-testid`s on the moved controls; the e2e driver depends on them. + +| Today (tab / place) | New home | +|---|---| +| Mode: depth image, Depth Module Algorithm, Regenerate Depth Map | Depth step (view Depth + Inspector); model also in Settings › Depth | +| Mode: Mode Selector (depth / instance) | Segment tool option **Select by: Object / Depth band** | +| Segmentation: threshold sliders, Generate, Balance | Slices › Split by depth (collapsible). Keep threshold handles: show them on a thin bar in that section, or as amber ticks on the DepthRuler | +| Segmentation: Create, Delete, Add, Remove, Copy, Paste | SelectionBar (Create/Add/Remove), LayerPanel header (Copy/Paste/Delete) | +| Segmentation: Ground, Fit ground | Ground step (`GroundPanel`); ground toggle also in Slices › Selected slice | +| Segmentation: slice thumbnails, depth edit, undo/redo, download | LayerList + DepthRuler; header undo/redo; Inspector header download; depth input in Selected slice | +| Slice image upload | Slices › Selected slice › Replace image… | +| Under image: zoom −/reset/+, level | ViewModeBar | +| Under image: camera pad (7 buttons) | ToolOptionsBar in Parallax 2D / 3D | +| Under image: Horizon toggle + drag | Horizon tool (G) | +| Under image: checkerboard | ViewModeBar | +| Under image: Invert, Feather, Multi, Commit | SelectionBar / Segment options | +| Under image: Clear, Erase, Load, Brush size | Brush options (+ new Save mask → `saveMask`) | +| Inpainting: prompts, strength, guidance, crop to ROI, Generate, Fill, Enhance, Erase, candidates, Apply | `InpaintPanel` | +| Export: Create glTF, Export glTF, Upscale Textures, DoF, mesh displacement | ExportDialog › 3D scene | +| Export: camera distance, max distance, focal length | PreviewPanel (mirrored in ExportDialog) | +| Export: Ground distance, horizon readout, side view | GroundPanel | +| Export: Export Animation, Number of Frames | ExportDialog › Animation | +| Configuration: number of slices | Slices › Split by depth (+ Settings › Slicing default) | +| Configuration: inpainting model, server address + Test, ComfyUI workflow, API key + Test, mask padding/blur | SettingsDialog | +| Configuration: Load State / Save State | Image step › Project; SettingsDialog › Project; EmptyState link | +| 2D / 3D viewer tabs | View modes Parallax 2D / 3D | +| Header theme toggle | AppHeader + Settings › Appearance | +| Help "?" tooltips | Numbered sub-step hints + ShortcutsDialog | +| Log panel | LogDrawer + StatusBar last message | + +## 9. Feedback rules + +- Every long job (depth, segmentation click, split, inpainting, fill/enhance/erase, glTF, upscale, animation) shows a `JobCard` next to the control that started it. The job is mirrored in the StatusBar with % and Cancel. +- The first depth run shows "Downloading model weights (first run only)" when the job reports it. +- Only one job runs at a time (single worker). While one runs, disable buttons that would start another. +- Errors: a persistent toast with actions, *and* an inline error card where the job was started (e.g. in `InpaintPanel`: "Generation failed · Could not reach ComfyUI at … · Retry · Open settings · View log"). The StatusBar message turns red. +- Success: short toast plus a log line. No modal confirmations. + +## 10. Visual system + +- Tokens: `tokens.css` (dark default via `[data-theme="dark"]`, light via `[data-theme="light"]`). Remove the dashed `.panel` border style entirely. +- Type: IBM Plex Sans (UI), IBM Plex Mono (numbers, file names, depth values). 13px base, 11px uppercase section labels. +- Buttons: 32px default, 26px small. **Primary** (amber fill, dark text): exactly one per context (Export…, New slice, Generate, then Apply once candidates exist). **Secondary** (neutral fill); **ghost** (text only) for tertiary actions; **selected** state is blue. +- Selection is always blue (layer row, candidate, active tool, SAM mask tint). Ground is green. The horizon is orange. The inpaint mask is translucent red-orange. +- Icons: 16px stroke icons, 1.6 stroke width (Lucide fits if you want a library). + +## 11. Backend questions and gaps + +1. **Outpainting:** new endpoint plus slice size/offset (§7). +2. **Rest-of-image slice:** when the first SAM slice is created, the prototype also adds an `image_slice_N` holding everything not yet segmented, so a background exists. Either add this to `createSlice` (flag) or keep requiring Split by depth first. Decide before building `SegmentPanel`. +3. **Cancel:** Cancel buttons assume `DELETE /api/v1/jobs/{id}` or similar. If jobs can't be cancelled, hide Cancel. +4. **Inpainted flag per slice:** optional, for the stepper and the badge. Client-side tracking is fine at first. + +## 12. Suggested order of work + +1. Tokens + `AppShell` + header/stepper + status bar; keep the old tab bodies temporarily inside the Inspector so everything still works. +2. `LayerPanel` with `DepthRuler`; delete the slice grid from the Segmentation tab. +3. `Canvas`, `ViewModeBar`, `CanvasToolbar`, `ToolOptionsBar`; move the under-image controls. +4. `SegmentPanel` + `SelectionBar` (SAM-first); Split by depth secondary. +5. `InpaintPanel` (holes mode; Extend stubbed), `GroundPanel`, `PreviewPanel`. +6. `ExportDialog`, `SettingsDialog`, toasts, `JobCard`, `LogDrawer`, shortcuts. +7. Update `e2e/drivers/svelte.ts`. `openTab(MainTab)` must map to the new steps/dialogs (Mode → Depth step, Segmentation → Slices, Inpainting → Inpaint, Export → ExportDialog, Configuration → SettingsDialog), and `withModeTabVisible` (Mode Selector) → the Segment tool's Select-by switch. Run `npm run check:frontend`, `npm run test:frontend` and `npm run test:e2e`. diff --git a/docs/redesign/prototype/Prototype.dc.html b/docs/redesign/prototype/Prototype.dc.html new file mode 100644 index 0000000..a0a7b65 --- /dev/null +++ b/docs/redesign/prototype/Prototype.dc.html @@ -0,0 +1,1221 @@ + + + + +Parallax Maker · interactive prototype + + + + + + + + + +
+ +
+
+ +Parallax Maker +/ crystals.png +
+ +
+ + + + + +
+
+ +
+ + + +
+
+
+ +
+
+ + +{{zoomPct}}% + + +
+
+ +
+{{opt.noneText}} + +Segment +Click an object · Shift add point · Alt remove point + + + + + +Inpaint mask +
+ + + + + +
+ +Extend canvasPLANNED +Set how far to grow this slice on each side, then generate. + + + + + +Horizon +Drag the line to the horizon · row {{horizon}} · camera pitch {{pitch}}° + + + + +Camera +{{camHint}} + + + + + +
+ +
+ + + + + + +
+ +
or ·
+
+
+ + +
+ +
+ +Input image +Depth map, brighter is nearer + + + +
+Outpainting preview +
+ +Candidate preview + + +
+ + +
+
+ +
+ +
+
+
+
+
+ +
+ + +{{p.sym}} + + + +
+
+
+Horizon · row {{horizon}} · drag +
+ + + + +
+
+
+ + +
Segmenting with SAM…
+
+ +
No distinct object at that point. Click inside an object, or use Split by depth for the background.
+
+ + + + + + +
+
{{job.label}}{{job.pct}}%
+
+
{{job.detail}}
+
+
+ +
Drag to orbit · layers are placed at their depth
+ +
+ +
+ +
+
{{t.title}}
+
{{t.body}}
+
+ +
+
+ +
+
+
+
+
+
+ + +
+ + +
+
Log
+
+
{{l.t}}{{l.msg}}
+
+
+
+ +
+ +{{job.label}}
{{job.pct}}%
+
+{{status.text}} + + +480 × 856 + + +
+ + +
+
Jump to a state (prototype only)
+ +
+
+ + + + + + + + + + + + +
+
+ + + diff --git a/docs/redesign/tokens.css b/docs/redesign/tokens.css new file mode 100644 index 0000000..d1b277a --- /dev/null +++ b/docs/redesign/tokens.css @@ -0,0 +1,125 @@ +/* + * Parallax Maker redesign — design tokens. + * Replaces the :root / :root.dark blocks in frontend/src/app.css. + * Dark is the default. Set data-theme="light" on for light mode. + * Fonts: IBM Plex Sans (400/500/600) and IBM Plex Mono (400/500). + */ + +:root, +[data-theme='dark'] { + color-scheme: dark; + + /* Surfaces */ + --color-bg: #111214; /* app background, canvas surround */ + --color-surface: #18191c; /* panels: header, layers, inspector, status bar */ + --color-surface-raised: #1f2124;/* inputs, cards, segmented-control track */ + --color-surface-hover: #27292e; /* hover rows, secondary buttons */ + --color-float: #1c1d21; /* floating toolbars, toasts, dialogs */ + --color-canvas: #111214; + --color-checker-1: #26282c; + --color-checker-2: #2f3136; + --color-scrim: rgb(0 0 0 / 55%); + + /* Lines */ + --color-border: #2a2d32; + --color-border-strong: #383b41; + + /* Text */ + --color-text: #e8e9ec; + --color-text-secondary: #b0b4bb; + --color-text-muted: #8d929b; /* 5.5:1 on --color-surface */ + + /* Primary action (one per context) */ + --color-primary: #f0a13a; + --color-primary-text: #1b1206; + --color-primary-soft: #2b2419; /* current step background */ + --color-primary-soft-text: #f5b865; + + /* Selection (layer row, candidate, active tool, SAM mask) */ + --color-selection: #4c9dff; + --color-selection-text: #cfe3ff; + --color-selection-soft: #1e2a3b; + --color-selection-tint: rgb(76 157 255 / 50%); + + /* Status */ + --color-success: #8fd1a8; /* done steps, ground badge */ + --color-success-soft: #1f3a2a; + --color-success-line: #2f5a41; + --color-danger: #ff8a80; + --color-danger-soft: #3a1d1f; + --color-danger-line: #6b2c2c; + + /* Canvas overlays */ + --color-horizon: #ff8a3d; + --color-inpaint-mask: rgb(255 96 64 / 50%); + --color-point-positive: #2f7de1; + --color-point-negative: #e8742a; +} + +[data-theme='light'] { + color-scheme: light; + --color-bg: #eceef1; + --color-surface: #f8f9fa; + --color-surface-raised: #ffffff; + --color-surface-hover: #eceef1; + --color-float: #ffffff; + --color-canvas: #e1e3e7; + --color-checker-1: #f4f5f7; + --color-checker-2: #e3e5e9; + --color-scrim: rgb(20 22 26 / 35%); + --color-border: #dcdfe4; + --color-border-strong: #c9cdd3; + --color-text: #1b1d21; + --color-text-secondary: #474c54; + --color-text-muted: #5d636d; + --color-primary: #e8922a; + --color-primary-text: #1b1206; + --color-primary-soft: #fdf0dd; + --color-primary-soft-text: #8a4f00; + --color-selection: #2f7de1; + --color-selection-text: #174a8c; + --color-selection-soft: #e2edfd; + --color-selection-tint: rgb(47 125 225 / 45%); + --color-success: #1c7240; + --color-success-soft: #e3f5ea; + --color-success-line: #9fd3b3; + --color-danger: #b42727; + --color-danger-soft: #fdeaea; + --color-danger-line: #f0b4b4; + --color-horizon: #d9601a; + --color-inpaint-mask: rgb(230 80 50 / 45%); + --color-point-positive: #2f7de1; + --color-point-negative: #d9601a; +} + +:root { + --font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + --text-base: 13px; + --text-small: 11.5px; + --text-label: 11px; /* uppercase, 600, letter-spacing .06em */ + --text-title: 16px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + + --radius-sm: 4px; /* chips, kbd, thumbnails */ + --radius-md: 6px; /* buttons, inputs, rows */ + --radius-lg: 8px; /* floating toolbars, cards, candidates */ + --radius-xl: 10px; /* dialogs */ + + --control-h: 32px; + --control-h-sm: 26px; + --shadow-float: 0 8px 24px rgb(0 0 0 / 30%); + + /* Layout */ + --header-h: 48px; + --statusbar-h: 28px; + --viewbar-h: 40px; + --toolopts-h: 44px; + --layers-w: 300px; + --inspector-w: 328px; +} diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index f10ab53..08e3618 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -23,6 +23,14 @@ import type { * implemented (see docs/svelte-migration/PARITY.md); `supports()` always * returns `true`. */ +/** The workflow step whose Inspector panel is each former main tab. */ +const STEP_FOR_TAB: Record, string> = { + Mode: 'depth', + Segmentation: 'slices', + Inpainting: 'inpaint', + Export: 'export', +}; + export class SvelteDriver implements UiDriver { readonly target: UiTarget = 'svelte'; @@ -39,11 +47,20 @@ export class SvelteDriver implements UiDriver { await expect(this.page.getByRole('heading', { name: 'Parallax Maker' })).toBeVisible(); } + /** + * The redesigned UI has no workflow tabs: each former tab is a workflow + * step's Inspector panel, and Configuration is the Settings panel. + */ async openTab(tab: MainTab): Promise { - const button = this.page.getByRole('tab', { name: tab, exact: true }); - await expect(button).toHaveCount(1); - await button.click(); - await expect(button).toHaveAttribute('aria-selected', 'true'); + const inspector = this.page.getByTestId('inspector'); + if (tab === 'Configuration') { + if ((await inspector.getAttribute('data-panel')) !== 'Configuration') { + await this.page.getByTestId('open-settings').click(); + } + } else { + await this.page.getByTestId(`step-${STEP_FOR_TAB[tab]}`).click(); + } + await expect(inspector).toHaveAttribute('data-panel', tab); } // Observable elements @@ -136,43 +153,24 @@ export class SvelteDriver implements UiDriver { } private async activeMainTab(): Promise { - // Scoped to the workflow tablist specifically: the viewer (2D/3D) tabs - // also use role="tab" and are also selected by default, so a - // page-wide `getByRole('tab', { selected: true })` would be ambiguous. - const active = this.page.locator('[role="tablist"][aria-label="Workflow"] [role="tab"][aria-selected="true"]'); - const count = await active.count(); - if (count === 0) return null; - const name = await active.first().textContent(); - const trimmed = name?.trim(); + const panel = await this.page.getByTestId('inspector').getAttribute('data-panel'); const known: MainTab[] = ['Mode', 'Segmentation', 'Inpainting', 'Export', 'Configuration']; - return (known.find((tab) => tab === trimmed) as MainTab | undefined) ?? null; + return (known.find((tab) => tab === panel) as MainTab | undefined) ?? null; } /** - * The Mode Selector lives in the Svelte-only "Mode" workflow tab (not - * part of the shared `MainTab` type -- Dash keeps its Mode Selector - * inline, outside any tab). Runs `fn` with that tab visible, using real - * clicks (no forced actions on a hidden ``), then restores + * whichever panel was showing before. */ private async withModeTabVisible(fn: () => Promise): Promise { - const tablist = '[role="tablist"][aria-label="Workflow"] [role="tab"]'; - const active = this.page.locator(`${tablist}[aria-selected="true"]`); - const previousTab = (await active.count()) > 0 ? (await active.first().textContent())?.trim() : null; - - if (previousTab !== 'Mode') { - const modeButton = this.page.getByRole('tab', { name: 'Mode', exact: true }); - await modeButton.click(); - await expect(modeButton).toHaveAttribute('aria-selected', 'true'); - } + const previousTab = await this.activeMainTab(); + if (previousTab !== 'Mode') await this.openTab('Mode'); try { return await fn(); } finally { - if (previousTab && previousTab !== 'Mode') { - const button = this.page.getByRole('tab', { name: previousTab, exact: true }); - await button.click(); - await expect(button).toHaveAttribute('aria-selected', 'true'); - } + if (previousTab && previousTab !== 'Mode') await this.openTab(previousTab); } } @@ -467,7 +465,7 @@ export class SvelteDriver implements UiDriver { // Project / configuration async expectDarkTheme(): Promise { - await expect(this.page.locator('#app-container')).toHaveClass(/\bdark\b/); + await expect(this.page.locator('html')).toHaveAttribute('data-theme', 'dark'); } async expectSliderValue(name: SliderName, value: number): Promise { diff --git a/e2e/helpers/image.ts b/e2e/helpers/image.ts index 0252e2a..5ba8e4a 100644 --- a/e2e/helpers/image.ts +++ b/e2e/helpers/image.ts @@ -13,8 +13,13 @@ export type RGB = readonly [number, number, number]; export type RGBA = readonly [number, number, number, number]; export type ImageMetadata = { width: number; height: number; hash: string }; +/** + * Waits until the image element has a fully decoded image. It need not be + * on screen: a UI may keep it in a view or panel that isn't showing (the + * redesigned UI shows the depth map only in its Depth view). + */ export async function waitForImage(image: Locator): Promise { - await expect(image).toBeVisible(); + await expect(image).toBeAttached(); await expect .poll(() => image.evaluate((element: HTMLImageElement) => ({ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a41c0a0..b23bd42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,10 @@ "name": "parallax-maker-frontend", "version": "0.1.0", "dependencies": { - "@google/model-viewer": "^4.3.1" + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", + "@google/model-viewer": "^4.3.1", + "@lucide/svelte": "^1.48.0" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.3.0", @@ -314,6 +317,24 @@ } } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@google/model-viewer": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.3.1.tgz", @@ -334,7 +355,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -345,7 +365,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -356,7 +375,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -366,14 +384,12 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -402,6 +418,15 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, + "node_modules/@lucide/svelte": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", + "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@monogrid/gainmap-js": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", @@ -708,7 +733,6 @@ "version": "1.0.13", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1170,7 +1194,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1245,7 +1268,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1308,7 +1330,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1354,7 +1375,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1451,7 +1471,6 @@ "version": "5.9.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -1499,14 +1518,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.9.tgz", "integrity": "sha512-wshgAMVu4xdUeXi07YDnzim4ddkg8rx2yDTW3WyvskWkDdCYa4+jUMLaHXEO1e1+dmrqhrwbGsgRjQqzGJkqJQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1626,7 +1643,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -2052,7 +2068,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -2428,7 +2443,6 @@ "version": "5.57.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -2480,7 +2494,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2490,7 +2503,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -3178,7 +3190,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 0bf8481..c899931 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,9 @@ "vitest": "^5.0.1" }, "dependencies": { - "@google/model-viewer": "^4.3.1" + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", + "@google/model-viewer": "^4.3.1", + "@lucide/svelte": "^1.48.0" } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 125b702..53b2f0f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,94 +1,21 @@ -
-
- -
- - -
- - -
-
- - + diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 1af6ccf..607778f 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -58,7 +58,7 @@ describe('App', () => { uiStore.reset(); }); - it('renders the header, viewer and workflow tabs, and footer', () => { + it('renders the header, canvas, inspector, log and status bar', () => { render(App); expect(screen.getByTestId('app-title')).toHaveTextContent('Parallax Maker'); @@ -70,16 +70,16 @@ describe('App', () => { expect(screen.getByTestId('app-footer')).toHaveTextContent('2024 Niels Provos'); }); - it('the theme toggle switches the root dark class used by the theme tokens', async () => { + it('starts dark, and the theme toggle switches the data-theme the tokens key off', async () => { render(App); const root = document.documentElement; - const initiallyDark = root.classList.contains('dark'); + await waitFor(() => expect(root.dataset.theme).toBe('dark')); await fireEvent.click(screen.getByTestId('theme-toggle')); - await waitFor(() => expect(root.classList.contains('dark')).toBe(!initiallyDark)); + await waitFor(() => expect(root.dataset.theme).toBe('light')); await fireEvent.click(screen.getByTestId('theme-toggle')); - await waitFor(() => expect(root.classList.contains('dark')).toBe(initiallyDark)); + await waitFor(() => expect(root.dataset.theme).toBe('dark')); }); it('uploading an image creates a project, starts a depth job, and renders both images', async () => { @@ -98,6 +98,17 @@ describe('App', () => { if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { return jsonResponse(200, { entries: [], next: 0 }); } + if (url === `/api/v1/projects/${projectId}/settings` && method === 'PUT') { + // The new project takes on the (default dark) theme on screen. + expect(JSON.parse(init!.body as string)).toEqual({ darkMode: true }); + return jsonResponse(200, { + ...makeView({ + assets: { input: { url: '/api/v1/projects/appstate-e2e-test/assets/input' }, depth: null }, + settings: { ...makeView().settings, darkMode: true }, + }), + changed: true, + }); + } if (url === `/api/v1/projects/${projectId}/depth` && method === 'POST') { return jsonResponse(202, { job: { id: 'job-1', kind: 'depth', status: 'queued', progress: 0 } }); } @@ -138,6 +149,9 @@ describe('App', () => { '/api/v1/projects/appstate-e2e-test/assets/depth', ), ); + // With the depth map ready, the workflow moves on to cutting slices. + await waitFor(() => expect(screen.getByTestId('step-slices')).toHaveAttribute('aria-current', 'step')); + expect(screen.getByTestId('inspector')).toHaveAttribute('data-panel', 'Segmentation'); expect(fetchMock).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ method: 'POST' })); expect(fetchMock).toHaveBeenCalledWith( @@ -151,8 +165,7 @@ describe('App', () => { render(App); const root = document.documentElement; - await waitFor(() => expect(root.classList.contains('dark')).toBe(true)); - expect(screen.getByTestId('app-title').closest('.app-root')).toHaveClass('dark'); + await waitFor(() => expect(root.dataset.theme).toBe('dark')); }); it('toggling the theme persists it via PUT settings once a project is loaded', async () => { @@ -186,6 +199,6 @@ describe('App', () => { ); const call = fetchMock.mock.calls.find(([reqUrl]) => String(reqUrl) === `/api/v1/projects/${projectId}/settings`); expect(JSON.parse(call![1]!.body as string)).toEqual({ darkMode: true }); - await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true)); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark')); }); }); diff --git a/frontend/src/app.css b/frontend/src/app.css index 0f75c3d..bd2cafc 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,102 +1,145 @@ @import 'tailwindcss'; +/* IBM Plex, bundled with the app (no requests to font CDNs). Latin only. */ +@import '@fontsource/ibm-plex-sans/latin-400.css'; +@import '@fontsource/ibm-plex-sans/latin-500.css'; +@import '@fontsource/ibm-plex-sans/latin-600.css'; +@import '@fontsource/ibm-plex-mono/latin-400.css'; +@import '@fontsource/ibm-plex-mono/latin-500.css'; + /* - * Theme tokens for the Svelte frontend. - * - * These intentionally mirror the current (frozen) Dash UI's palette -- - * see ../../tailwind.css and ../../parallax_maker/assets/css/features.css -- - * so the Svelte shell looks the same as Dash while both UIs exist. A later - * redesign should only need to change the values below, not component code. - * - * Dash toggles dark mode with a `dark` class on #app-container (Tailwind - * `darkMode: 'selector'`). We mirror that here: put `.dark` on the root - * element (see App.svelte) and these custom properties flip with it. + * Design tokens (docs/redesign/tokens.css). Dark is the default; App.svelte + * sets data-theme="dark" or "light" on . */ -:root { - --color-bg: #ffffff; - --color-bg-surface: #ffffff; - --color-text: #1f2937; /* gray-800 */ - --color-text-muted: #6b7280; /* gray-500 */ - - --color-header-bg: #1e40af; /* blue-800 */ - --color-header-text: #ffffff; - - --color-accent: #3b82f6; /* blue-500 */ - --color-accent-hover: #2563eb; /* blue-600 */ - --color-accent-text: #ffffff; - - --color-toggle-bg: #2563eb; /* blue-600 */ - --color-toggle-bg-hover: #1d4ed8; /* blue-700 */ - - --color-success: #22c55e; /* green-500 */ - --color-success-text: #ffffff; - - --color-danger: #fca5a5; /* red-300 */ - --color-danger-strong: #ef4444; /* red-500 */ - --color-danger-text: #ffffff; - - --color-border: #d1d5db; /* gray-300 */ - --color-border-strong: #3b82f6; /* blue-500 */ - - --color-surface-muted: #e5e7eb; /* gray-200 */ - --color-disabled-bg: #d1d5db; /* gray-300 */ - --color-disabled-text: #6b7280; /* gray-500 */ - - --color-help-bg: #fef9c3; /* yellow-100 */ - --color-help-border: #000000; - --color-help-text: #1f2937; - - --font-sans: - ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-6: 1.5rem; - --space-8: 2rem; - - --radius-md: 0.375rem; - --radius-lg: 0.5rem; - - --color-depth-number: rgb(146 64 14 / 50%); /* amber-800 @ 50% */ +:root, +[data-theme='dark'] { + color-scheme: dark; + + /* Surfaces */ + --color-bg: #111214; /* app background, canvas surround */ + --color-surface: #18191c; /* panels: header, layers, inspector, status bar */ + --color-surface-raised: #1f2124; /* inputs, cards, segmented-control track */ + --color-surface-hover: #27292e; /* hover rows, secondary buttons */ + --color-float: #1c1d21; /* floating toolbars, toasts, dialogs */ + --color-canvas: #111214; + --color-checker-1: #26282c; + --color-checker-2: #2f3136; + --color-scrim: rgb(0 0 0 / 55%); + + /* Lines */ + --color-border: #2a2d32; + --color-border-strong: #383b41; + + /* Text */ + --color-text: #e8e9ec; + --color-text-secondary: #b0b4bb; + --color-text-muted: #8d929b; /* 5.5:1 on --color-surface */ + + /* Primary action (one per context) */ + --color-primary: #f0a13a; + --color-primary-text: #1b1206; + --color-primary-soft: #2b2419; /* current step background */ + --color-primary-soft-text: #f5b865; + + /* Selection (layer row, candidate, active tool, SAM mask) */ + --color-selection: #4c9dff; + --color-selection-text: #cfe3ff; + --color-selection-soft: #1e2a3b; + --color-selection-tint: rgb(76 157 255 / 50%); + + /* Status */ + --color-success: #8fd1a8; /* done steps, ground badge */ + --color-success-soft: #1f3a2a; + --color-success-line: #2f5a41; + --color-danger: #ff8a80; + --color-danger-soft: #3a1d1f; + --color-danger-line: #6b2c2c; + + /* Canvas overlays */ + --color-horizon: #ff8a3d; + --color-inpaint-mask: rgb(255 96 64 / 50%); + --color-point-positive: #2f7de1; + --color-point-negative: #e8742a; } -:root.dark { - --color-bg: #18181b; /* zinc-900 */ - --color-bg-surface: #27272a; /* zinc-800 */ - --color-text: #cbd5e1; /* slate-300 */ - --color-text-muted: #94a3b8; /* slate-400 */ - - --color-header-bg: #60a5fa; /* blue-400 */ - --color-header-text: #000000; - - --color-accent: #60a5fa; /* blue-400 */ - --color-accent-hover: #3b82f6; /* blue-500 */ - --color-accent-text: #e2e8f0; /* slate-200 */ - - --color-toggle-bg: #d97706; /* amber-600 */ - --color-toggle-bg-hover: #f59e0b; /* amber-500 */ - - --color-success: #4ade80; /* green-400 */ - --color-success-text: #e2e8f0; /* slate-200 */ - - --color-danger: #ef4444; /* red-500 */ - --color-danger-strong: #ef4444; - --color-danger-text: #e2e8f0; - - --color-border: #374151; /* gray-700 */ - --color-border-strong: #60a5fa; /* blue-400 */ - - --color-surface-muted: #27272a; /* zinc-800 */ - --color-disabled-bg: #52525b; /* zinc-600 */ - --color-disabled-text: #cbd5e1; /* slate-300 */ - - --color-help-bg: #fef08a; /* yellow-200 */ - --color-help-border: #374151; /* gray-700 */ - --color-help-text: #334155; /* slate-700 */ +[data-theme='light'] { + color-scheme: light; + --color-bg: #eceef1; + --color-surface: #f8f9fa; + --color-surface-raised: #ffffff; + --color-surface-hover: #eceef1; + --color-float: #ffffff; + --color-canvas: #e1e3e7; + --color-checker-1: #f4f5f7; + --color-checker-2: #e3e5e9; + --color-scrim: rgb(20 22 26 / 35%); + --color-border: #dcdfe4; + --color-border-strong: #c9cdd3; + --color-text: #1b1d21; + --color-text-secondary: #474c54; + --color-text-muted: #5d636d; + --color-primary: #e8922a; + --color-primary-text: #1b1206; + --color-primary-soft: #fdf0dd; + --color-primary-soft-text: #8a4f00; + --color-selection: #2f7de1; + --color-selection-text: #174a8c; + --color-selection-soft: #e2edfd; + --color-selection-tint: rgb(47 125 225 / 45%); + --color-success: #1c7240; + --color-success-soft: #e3f5ea; + --color-success-line: #9fd3b3; + --color-danger: #b42727; + --color-danger-soft: #fdeaea; + --color-danger-line: #f0b4b4; + --color-horizon: #d9601a; + --color-inpaint-mask: rgb(230 80 50 / 45%); + --color-point-positive: #2f7de1; + --color-point-negative: #d9601a; +} - --color-depth-number: rgb(252 211 77 / 50%); /* amber-300 @ 50% */ +:root { + --font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + --text-base: 13px; + --text-small: 11.5px; + --text-label: 11px; /* uppercase, 600, letter-spacing .06em */ + --text-title: 16px; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + + --radius-sm: 4px; /* chips, kbd, thumbnails */ + --radius-md: 6px; /* buttons, inputs, rows */ + --radius-lg: 8px; /* floating toolbars, cards, candidates */ + --radius-xl: 10px; /* dialogs */ + + --control-h: 32px; + --control-h-sm: 26px; + --shadow-float: 0 8px 24px rgb(0 0 0 / 30%); + + /* Layout */ + --header-h: 48px; + --statusbar-h: 28px; + --viewbar-h: 40px; + --toolopts-h: 44px; + --layers-w: 300px; + --inspector-w: 328px; + + /* + * Legacy names still used by components that predate the redesign. Each + * goes away when its last user is rebuilt (docs/redesign/HANDOFF.md §12). + */ + --color-accent: var(--color-selection); + --color-accent-text: var(--color-selection-text); + --color-success-text: var(--color-primary-text); + --color-danger-strong: var(--color-danger); + --color-disabled-text: var(--color-text-muted); + --color-surface-muted: var(--color-surface-raised); + --color-depth-number: color-mix(in srgb, var(--color-primary) 55%, transparent); } html, @@ -109,148 +152,204 @@ body { background-color: var(--color-bg); color: var(--color-text); font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.4; } -/* - * Shared, framework-agnostic look-alike classes for the Dash "general-*" - * utilities (see ../../tailwind.css). Kept here (rather than duplicated in - * every component's diff --git a/frontend/src/lib/components/shell/AppShell.svelte b/frontend/src/lib/components/shell/AppShell.svelte new file mode 100644 index 0000000..81a39ed --- /dev/null +++ b/frontend/src/lib/components/shell/AppShell.svelte @@ -0,0 +1,76 @@ + + +
+ +
+
+ +
+ +
+ + +
+ + diff --git a/frontend/src/lib/components/shell/Footer.svelte b/frontend/src/lib/components/shell/Footer.svelte deleted file mode 100644 index 859ed98..0000000 --- a/frontend/src/lib/components/shell/Footer.svelte +++ /dev/null @@ -1,9 +0,0 @@ -
© 2024 Niels Provos
- - diff --git a/frontend/src/lib/components/shell/Header.svelte b/frontend/src/lib/components/shell/Header.svelte deleted file mode 100644 index 51ba791..0000000 --- a/frontend/src/lib/components/shell/Header.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - -
-
- -
-

Parallax Maker

-
- -
-
- - diff --git a/frontend/src/lib/components/shell/Inspector.svelte b/frontend/src/lib/components/shell/Inspector.svelte new file mode 100644 index 0000000..3c35602 --- /dev/null +++ b/frontend/src/lib/components/shell/Inspector.svelte @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/lib/components/shell/LogDrawer.svelte b/frontend/src/lib/components/shell/LogDrawer.svelte new file mode 100644 index 0000000..0e2c1ab --- /dev/null +++ b/frontend/src/lib/components/shell/LogDrawer.svelte @@ -0,0 +1,77 @@ + + +
+
+ Log + +
+
+ {#each logStore.entries as entry (entry.seq)} +
{entry.message}
+ {/each} +
+
+ + diff --git a/frontend/src/lib/components/shell/LogPanel.svelte b/frontend/src/lib/components/shell/LogPanel.svelte deleted file mode 100644 index b9ffaba..0000000 --- a/frontend/src/lib/components/shell/LogPanel.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -
-
- {#each logStore.last3 as entry (entry.seq)} -
{entry.message}
- {/each} -
-
- - diff --git a/frontend/src/lib/components/shell/MainTabs.svelte b/frontend/src/lib/components/shell/MainTabs.svelte deleted file mode 100644 index 9979d64..0000000 --- a/frontend/src/lib/components/shell/MainTabs.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -
- -
uiStore.setMainTab(MAIN_TABS[index])} - > - {#each MAIN_TABS as tab (tab)} - - {/each} -
- - -
- -
-
- -
-
- -
-
- -
-
- -
-
- - diff --git a/frontend/src/lib/components/shell/Shell.test.ts b/frontend/src/lib/components/shell/Shell.test.ts new file mode 100644 index 0000000..7cd3dc3 --- /dev/null +++ b/frontend/src/lib/components/shell/Shell.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import AppShell from './AppShell.svelte'; +import { projectStore } from '../../state/project.svelte'; +import { jobStore } from '../../state/jobs.svelte'; +import { logStore } from '../../state/logs.svelte'; +import { uiStore } from '../../state/ui.svelte'; +import type { ProjectView, SliceView } from '../../api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function slice(index: number, overrides: Partial = {}): SliceView { + return { + index, + depth: index * 50, + version: 0, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + isGround: false, + image: { url: `/slice-${index}` }, + thumbnail: { url: `/thumb-${index}` }, + mask: null, + ...overrides, + } as SliceView; +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-shell', + revision: 1, + image: { width: 640, height: 480 }, + assets: { input: { url: '/input' }, depth: null }, + mainImage: { url: '/main' }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [], + slices: [], + selectedSlice: null, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, + inpainting: { + model: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', + strength: 0.8, + guidanceScale: 7.5, + padding: 50, + blur: 50, + externalServer: 'localhost:7860', + hasWorkflow: false, + candidates: null, + selectedCandidate: null, + }, + busy: null, + settings: { + darkMode: true, + camera: { distance: 100, focalLength: 100, maxDistance: 200 }, + meshDisplacement: 0, + depthModel: 'dinov2', + }, + exports: { gltf: null, upscaled: false }, + ...overrides, + } as ProjectView; +} + +describe('app shell', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('workflow stepper', () => { + it('starts on Image with nothing done and the Mode panel showing', () => { + render(AppShell); + expect(screen.getByTestId('step-image')).toHaveAttribute('aria-current', 'step'); + expect(screen.getByTestId('step-depth')).toHaveAttribute('data-state', 'todo'); + expect(screen.getByTestId('inspector')).toHaveAttribute('data-panel', 'Mode'); + }); + + it('marks steps done from the project and from this session', () => { + projectStore.applyView( + makeView({ + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + slices: [slice(0), slice(1, { isGround: true })], + }), + ); + render(AppShell); + expect(screen.getByTestId('step-depth')).toHaveAttribute('data-state', 'done'); + expect(screen.getByTestId('step-slices')).toHaveAttribute('data-state', 'done'); + expect(screen.getByTestId('step-ground')).toHaveAttribute('data-state', 'done'); + expect(screen.getByTestId('step-inpaint')).toHaveAttribute('data-state', 'todo'); + expect(screen.getByTestId('step-export')).toHaveAttribute('data-state', 'todo'); + }); + + it.each([ + ['step-depth', 'Mode'], + ['step-slices', 'Segmentation'], + ['step-inpaint', 'Inpainting'], + ['step-ground', 'Segmentation'], + ['step-preview', 'Export'], + ['step-export', 'Export'], + ])('%s shows the %s panel', async (testId, panel) => { + render(AppShell); + await fireEvent.click(screen.getByTestId(testId)); + expect(screen.getByTestId(testId)).toHaveAttribute('aria-current', 'step'); + expect(screen.getByTestId('inspector')).toHaveAttribute('data-panel', panel); + }); + + it('visiting Preview counts as done once another step is current', async () => { + render(AppShell); + await fireEvent.click(screen.getByTestId('step-preview')); + await fireEvent.click(screen.getByTestId('step-image')); + expect(screen.getByTestId('step-preview')).toHaveAttribute('data-state', 'done'); + }); + }); + + describe('header', () => { + it('shows the project name once a project is loaded', () => { + projectStore.applyView(makeView()); + render(AppShell); + expect(screen.getByTestId('project-name')).toHaveTextContent('appstate-shell'); + }); + + it('Settings toggles the Settings panel and back to the current step', async () => { + render(AppShell); + await fireEvent.click(screen.getByTestId('step-inpaint')); + await fireEvent.click(screen.getByTestId('open-settings')); + expect(screen.getByTestId('inspector')).toHaveAttribute('data-panel', 'Configuration'); + expect(screen.getByTestId('open-settings')).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(screen.getByTestId('open-settings')); + expect(screen.getByTestId('inspector')).toHaveAttribute('data-panel', 'Inpainting'); + }); + + it('Export is disabled until there are slices, then opens the Export step', async () => { + projectStore.applyView(makeView()); + const { unmount } = render(AppShell); + expect(screen.getByTestId('open-export')).toBeDisabled(); + unmount(); + + projectStore.applyView(makeView({ slices: [slice(0)] })); + render(AppShell); + await fireEvent.click(screen.getByTestId('open-export')); + expect(screen.getByTestId('step-export')).toHaveAttribute('aria-current', 'step'); + }); + + it('undo and redo act on the selected slice and follow its history', async () => { + projectStore.applyView( + makeView({ slices: [slice(0), slice(1, { canUndo: true })], selectedSlice: 1 }), + ); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url === '/api/v1/projects/appstate-shell/slices/1/undo' && init?.method === 'POST') { + return jsonResponse(200, { ...makeView({ slices: [slice(0), slice(1)] }), changed: true }); + } + if (url.startsWith('/api/v1/projects/appstate-shell/logs')) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + render(AppShell); + + expect(screen.getByTestId('header-redo')).toBeDisabled(); + await fireEvent.click(screen.getByTestId('header-undo')); + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-shell/slices/1/undo', + expect.objectContaining({ method: 'POST' }), + ); + }); + }); + + describe('status bar and log', () => { + it('shows the latest message, the image size and the log count', () => { + projectStore.applyView(makeView()); + logStore.pushClient('first', 'info'); + logStore.pushClient('Generation failed'); + render(AppShell); + expect(screen.getByTestId('status-message')).toHaveTextContent('Generation failed'); + expect(screen.getByTestId('status-message')).toHaveClass('error'); + expect(screen.getByTestId('image-size')).toHaveTextContent('640 × 480'); + expect(screen.getByTestId('log-toggle')).toHaveTextContent('Log · 2'); + }); + + it('the log drawer holds every entry and opens from the status bar', async () => { + logStore.pushClient('one', 'info'); + logStore.pushClient('two', 'info'); + logStore.pushClient('three', 'info'); + logStore.pushClient('four', 'info'); + render(AppShell); + expect(screen.getByTestId('log')).toHaveTextContent(/one.*two.*three.*four/); + expect(screen.getByTestId('log-drawer')).toHaveClass('hidden'); + + await fireEvent.click(screen.getByTestId('log-toggle')); + expect(screen.getByTestId('log-drawer')).not.toHaveClass('hidden'); + expect(screen.getByTestId('log-toggle')).toHaveAttribute('aria-pressed', 'true'); + }); + }); +}); diff --git a/frontend/src/lib/components/shell/StatusBar.svelte b/frontend/src/lib/components/shell/StatusBar.svelte new file mode 100644 index 0000000..f557e88 --- /dev/null +++ b/frontend/src/lib/components/shell/StatusBar.svelte @@ -0,0 +1,83 @@ + + +
+ + {#if last} + {last.message} + {/if} + + {#if size} + {size.width} × {size.height} + {/if} + + © 2024 Niels Provos +
+ + diff --git a/frontend/src/lib/components/shell/WorkflowStepper.svelte b/frontend/src/lib/components/shell/WorkflowStepper.svelte new file mode 100644 index 0000000..c181657 --- /dev/null +++ b/frontend/src/lib/components/shell/WorkflowStepper.svelte @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/lib/state/logs.svelte.ts b/frontend/src/lib/state/logs.svelte.ts index ee33438..7354ef6 100644 --- a/frontend/src/lib/state/logs.svelte.ts +++ b/frontend/src/lib/state/logs.svelte.ts @@ -24,7 +24,7 @@ function createLogStore() { // `refresh()` calls can genuinely overlap. Two overlapping calls that both // read the same `after` before either updates it would otherwise both // fetch (and append) the very same server entries, producing duplicate - // `seq` values and breaking `LogPanel.svelte`'s `{#each ... (entry.seq)}` + // `seq` values and breaking `LogDrawer.svelte`'s `{#each ... (entry.seq)}` // key (`each_key_duplicate`). Serialize instead: a call that arrives while // one is already in flight just flags a follow-up fetch, which the // in-flight call runs itself before returning - no overlapping requests, @@ -72,9 +72,6 @@ function createLogStore() { get entries(): DisplayLogEntry[] { return entries; }, - get last3(): DisplayLogEntry[] { - return entries.slice(-3); - }, pushClient, refresh, reset, diff --git a/frontend/src/lib/state/ui.svelte.ts b/frontend/src/lib/state/ui.svelte.ts index 51fea5f..aca3f94 100644 --- a/frontend/src/lib/state/ui.svelte.ts +++ b/frontend/src/lib/state/ui.svelte.ts @@ -7,6 +7,8 @@ export type ViewerTab = '2D' | '3D'; export type MainTab = 'Mode' | 'Segmentation' | 'Inpainting' | 'Export' | 'Configuration'; +/** The workflow stepper's steps (docs/redesign/HANDOFF.md §4). */ +export type WorkflowStep = 'image' | 'depth' | 'slices' | 'inpaint' | 'ground' | 'preview' | 'export'; export type SegmentationMode = 'depth' | 'segment'; export type Theme = 'light' | 'dark'; /** Highlight state of a configuration probe (Test Connection / Validate API Key). */ @@ -14,6 +16,30 @@ export type ProbeStatus = 'success' | 'failure' | 'none'; export const MAIN_TABS: MainTab[] = ['Mode', 'Segmentation', 'Inpainting', 'Export', 'Configuration']; +export const WORKFLOW_STEPS: { step: WorkflowStep; label: string }[] = [ + { step: 'image', label: 'Image' }, + { step: 'depth', label: 'Depth' }, + { step: 'slices', label: 'Slices' }, + { step: 'inpaint', label: 'Inpaint' }, + { step: 'ground', label: 'Ground' }, + { step: 'preview', label: 'Preview' }, + { step: 'export', label: 'Export' }, +]; + +/** + * Which of the pre-redesign tab bodies the Inspector shows for a step, + * until each step gets its own panel (HANDOFF.md §12). + */ +const STEP_PANELS: Record = { + image: 'Mode', + depth: 'Mode', + slices: 'Segmentation', + inpaint: 'Inpainting', + ground: 'Segmentation', + preview: 'Export', + export: 'Export', +}; + /** Matches components.py's DROPDOWN_DEPTH_MODEL default. */ const DEFAULT_DEPTH_MODEL = 'dinov2'; /** Matches components.py's SLIDER_NUM_SLICES default. */ @@ -22,7 +48,14 @@ const DEFAULT_NUM_SLICES = 3; function createUiStore() { let viewerTab = $state('2D'); let mainTab = $state('Mode'); - let theme = $state('light'); + let step = $state('image'); + let theme = $state('dark'); + let logOpen = $state(false); + // Progress the stepper can't read from ProjectView (HANDOFF.md §4 "done" + // rules): tracked for this browser session only. + let inpainted = $state(false); + let previewed = $state(false); + let exported = $state(false); let segmentationMode = $state('depth'); let depthModel = $state(DEFAULT_DEPTH_MODEL); let pendingNumSlices = $state(DEFAULT_NUM_SLICES); @@ -46,8 +79,10 @@ function createUiStore() { }, setViewerTab(tab: ViewerTab): void { viewerTab = tab; + if (tab === '3D') previewed = true; }, + /** The pre-redesign tab body the Inspector shows (see STEP_PANELS). */ get mainTab(): MainTab { return mainTab; }, @@ -55,6 +90,42 @@ function createUiStore() { mainTab = tab; }, + get step(): WorkflowStep { + return step; + }, + /** Moves to a workflow step and shows that step's Inspector panel. */ + setStep(next: WorkflowStep): void { + step = next; + mainTab = STEP_PANELS[next]; + if (next === 'preview') previewed = true; + }, + + get logOpen(): boolean { + return logOpen; + }, + toggleLog(): void { + logOpen = !logOpen; + }, + + get inpainted(): boolean { + return inpainted; + }, + markInpainted(): void { + inpainted = true; + }, + get previewed(): boolean { + return previewed; + }, + markPreviewed(): void { + previewed = true; + }, + get exported(): boolean { + return exported; + }, + markExported(): void { + exported = true; + }, + get theme(): Theme { return theme; }, @@ -111,7 +182,12 @@ function createUiStore() { reset(): void { viewerTab = '2D'; mainTab = 'Mode'; - theme = 'light'; + step = 'image'; + theme = 'dark'; + logOpen = false; + inpainted = false; + previewed = false; + exported = false; segmentationMode = 'depth'; depthModel = DEFAULT_DEPTH_MODEL; pendingNumSlices = DEFAULT_NUM_SLICES; diff --git a/frontend/src/lib/workflow.test.ts b/frontend/src/lib/workflow.test.ts index 7b31c7c..437b079 100644 --- a/frontend/src/lib/workflow.test.ts +++ b/frontend/src/lib/workflow.test.ts @@ -3,6 +3,7 @@ import * as workflow from './workflow'; import { projectStore } from './state/project.svelte'; import { jobStore } from './state/jobs.svelte'; import { logStore } from './state/logs.svelte'; +import { uiStore } from './state/ui.svelte'; import type { ProjectView } from './api/types'; function jsonResponse(status: number, body: unknown): Response { @@ -52,6 +53,7 @@ describe('workflow', () => { projectStore.reset(); jobStore.end(); logStore.reset(); + uiStore.reset(); }); afterEach(() => { @@ -69,6 +71,10 @@ describe('workflow', () => { if (url.startsWith('/api/v1/projects/appstate-test/logs')) { return jsonResponse(200, { entries: [], next: 0 }); } + if (url === '/api/v1/projects/appstate-test/settings' && method === 'PUT') { + expect(JSON.parse(init!.body as string)).toEqual({ darkMode: true }); + return jsonResponse(200, { ...makeView({ settings: { ...makeView().settings, darkMode: true } }), changed: true }); + } if (url === '/api/v1/projects/appstate-test/depth' && method === 'POST') { expect(JSON.parse(init!.body as string)).toEqual({ model: 'midas' }); return jsonResponse(202, { job: { id: 'job-1', kind: 'depth', status: 'queued', progress: 0 } }); @@ -95,6 +101,7 @@ describe('workflow', () => { expect(jobStore.active).toBeNull(); expect(projectStore.view?.assets.depth?.url).toBe('/depth'); expect(projectStore.view?.thresholds).toEqual([0, 85, 170, 255]); + expect(uiStore.step).toBe('slices'); }); it('records an ApiError message in the log store when a mutation fails', async () => { diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts index 845275a..5f185e3 100644 --- a/frontend/src/lib/workflow.ts +++ b/frontend/src/lib/workflow.ts @@ -40,8 +40,11 @@ export async function uploadImage(file: File, depthModel: string): Promise jobStore.begin('upload'); let projectId: string | null = null; try { - const view = await api.createProject(file); + let view = await api.createProject(file); projectId = view.id; + // A new project keeps the theme the user is already looking at. + const dark = uiStore.theme === 'dark'; + if (view.settings.darkMode !== dark) view = await api.updateSettings(view.id, { darkMode: dark }); projectStore.applyView(view); } catch (err) { logStore.pushClient(errorMessage(err)); @@ -51,6 +54,8 @@ export async function uploadImage(file: File, depthModel: string): Promise jobStore.end(); if (projectId) await refreshLogs(projectId); await startDepth(depthModel); + // The depth map is ready: the next thing to do is cut the image into slices. + if (projectStore.view?.assets.depth) uiStore.setStep('slices'); } /** Starts (or restarts) the depth job for the current project and polls it to completion. */ @@ -285,6 +290,7 @@ async function runSliceMutation( * parallax view (Dash's `navigate_image` buttons). Deselects any slice. */ export async function navigateCamera(direction: api.CameraDirection): Promise { + uiStore.markPreviewed(); await runSliceMutation('navigate', (id) => api.navigateCamera(id, direction)); } @@ -633,6 +639,7 @@ export async function applyInpaintingCandidate(): Promise { try { const result = await api.applyInpaintingCandidate(view.id, index, candidates.generationId); projectStore.applyView(result); + uiStore.markInpainted(); } catch (err) { logStore.pushClient(errorMessage(err)); } finally { @@ -742,6 +749,7 @@ export async function startGltfExport(dof: boolean): Promise { onProgress: (j) => jobStore.setProgress(j.progress), }); if (finished.project) projectStore.applyView(finished.project); + uiStore.markExported(); } catch (err) { logStore.pushClient(errorMessage(err)); } finally { @@ -785,6 +793,7 @@ export async function startAnimationExport(frames: number): Promise { onProgress: (j) => jobStore.setProgress(j.progress), }); if (finished.project) projectStore.applyView(finished.project); + uiStore.markExported(); } catch (err) { logStore.pushClient(errorMessage(err)); } finally { From e93e1f6b8f69f39cbaadfe240d114e3680f276af Mon Sep 17 00:00:00 2001 From: provos Date: Fri, 25 Sep 2026 23:25:00 -0700 Subject: [PATCH 2/2] fix(ui): reset stepper progress for a new project; Preview done only on a real move - A new upload clears this session's Inpaint/Preview/Export progress and starts over at the Image step; restoring a project clears the progress. - navigateCamera marks Preview done only after a successful camera move (runSliceMutation now reports success). Co-Authored-By: Claude Opus 5.5 --- frontend/src/lib/state/ui.svelte.ts | 15 +++++++++ frontend/src/lib/workflow.test.ts | 51 +++++++++++++++++++++++++++++ frontend/src/lib/workflow.ts | 13 +++++--- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/state/ui.svelte.ts b/frontend/src/lib/state/ui.svelte.ts index aca3f94..a9c0db9 100644 --- a/frontend/src/lib/state/ui.svelte.ts +++ b/frontend/src/lib/state/ui.svelte.ts @@ -107,6 +107,21 @@ function createUiStore() { logOpen = !logOpen; }, + /** Forgets this session's progress (a different project is now loaded). */ + resetProgress(): void { + inpainted = false; + previewed = false; + exported = false; + }, + /** A new project: forget progress and start over at the first step. */ + resetSession(): void { + inpainted = false; + previewed = false; + exported = false; + step = 'image'; + mainTab = STEP_PANELS.image; + }, + get inpainted(): boolean { return inpainted; }, diff --git a/frontend/src/lib/workflow.test.ts b/frontend/src/lib/workflow.test.ts index 437b079..1b36417 100644 --- a/frontend/src/lib/workflow.test.ts +++ b/frontend/src/lib/workflow.test.ts @@ -104,6 +104,57 @@ describe('workflow', () => { expect(uiStore.step).toBe('slices'); }); + it('a new upload starts over: session progress is cleared', async () => { + uiStore.markPreviewed(); + uiStore.markExported(); + uiStore.setStep('export'); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url === '/api/v1/projects' && init?.method === 'POST') { + return jsonResponse(201, makeView({ settings: { ...makeView().settings, darkMode: true } })); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + // Leave the depth job failing: only the reset at creation matters here. + return jsonResponse(500, { error: { code: 'internal', message: 'no depth in this test' } }); + }), + ); + + await workflow.uploadImage(new File(['bytes'], 'input.png', { type: 'image/png' }), 'midas'); + + expect(uiStore.previewed).toBe(false); + expect(uiStore.exported).toBe(false); + expect(uiStore.step).toBe('image'); + }); + + it('navigating the camera marks Preview done only when the move succeeds', async () => { + await workflow.navigateCamera('left'); + expect(uiStore.previewed).toBe(false); // no project + + projectStore.applyView(makeView()); + let fail = true; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL): Promise => { + const url = String(input); + if (url === '/api/v1/projects/appstate-test/camera/navigate') { + return fail + ? jsonResponse(409, { error: { code: 'not_ready', message: 'no slices' } }) + : jsonResponse(200, { ...makeView(), changed: true }); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + await workflow.navigateCamera('left'); + expect(uiStore.previewed).toBe(false); + + fail = false; + await workflow.navigateCamera('left'); + expect(uiStore.previewed).toBe(true); + }); + it('records an ApiError message in the log store when a mutation fails', async () => { projectStore.applyView(makeView()); diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts index 5f185e3..748ca30 100644 --- a/frontend/src/lib/workflow.ts +++ b/frontend/src/lib/workflow.ts @@ -42,6 +42,7 @@ export async function uploadImage(file: File, depthModel: string): Promise try { let view = await api.createProject(file); projectId = view.id; + uiStore.resetSession(); // A new project keeps the theme the user is already looking at. const dark = uiStore.theme === 'dark'; if (view.settings.darkMode !== dark) view = await api.updateSettings(view.id, { darkMode: dark }); @@ -269,16 +270,18 @@ export async function setMultiPointMode(enabled: boolean): Promise { async function runSliceMutation( kind: Parameters[0], call: (id: string) => Promise>>, -): Promise { +): Promise { const view = projectStore.view; - if (!view) return; + if (!view) return false; jobStore.begin(kind); try { const result = await call(view.id); projectStore.applyView(result); + return true; } catch (err) { logStore.pushClient(errorMessage(err)); + return false; } finally { jobStore.end(); await refreshLogs(view.id); @@ -290,8 +293,9 @@ async function runSliceMutation( * parallax view (Dash's `navigate_image` buttons). Deselects any slice. */ export async function navigateCamera(direction: api.CameraDirection): Promise { - uiStore.markPreviewed(); - await runSliceMutation('navigate', (id) => api.navigateCamera(id, direction)); + if (await runSliceMutation('navigate', (id) => api.navigateCamera(id, direction))) { + uiStore.markPreviewed(); + } } /** @@ -457,6 +461,7 @@ export async function restoreProject(file: File): Promise { try { const view = await api.restoreProject(file); logStore.reset(); + uiStore.resetProgress(); projectStore.applyView(view); await refreshLogs(view.id); } catch (err) {