diff --git a/docs/svelte-migration/ARCHITECTURE.md b/docs/svelte-migration/ARCHITECTURE.md index 8f3c3fe..83118b8 100644 --- a/docs/svelte-migration/ARCHITECTURE.md +++ b/docs/svelte-migration/ARCHITECTURE.md @@ -64,6 +64,7 @@ All JSON is camelCase. Errors: `{"error": {"code": str, "message": str}}`. | `WorkflowNotReady` / service validation errors | 409 | `not_ready` | | Project busy (another mutation or job running) | 409 | `busy` | | Stale `baseRevision` | 409 | `stale_revision` | +| `DELETE /jobs/{id}` on a job that can't be cancelled right now | 409 | `not_cancellable` | | Provider/model failure | 502 | `provider_error` | | Unexpected | 500 | `internal` (details logged server-side only) | @@ -138,7 +139,8 @@ Never serialize `AppState`, PIL/NumPy objects, model objects or credentials. | `PUT /api/v1/projects/{id}/slice-count` | `{numSlices}` | `ProjectView` | | `PUT /api/v1/projects/{id}/thresholds` | `{values, baseRevision}` | `ProjectView` | | `POST /api/v1/projects/{id}/slices` | `{}` | `202 {job}` | -| `GET /api/v1/jobs/{jobId}` | – | `{id, kind, status: queued/running/succeeded/failed, progress: 0..1, error?, project?: ProjectView}` | +| `GET /api/v1/jobs/{jobId}` | – | `{id, kind, status: queued/running/succeeded/failed/cancelled, progress: 0..1, error?, detail?: string, cancellable: bool, project?: ProjectView}` (`project` is populated for every terminal status, including `cancelled`) | +| `DELETE /api/v1/jobs/{jobId}` | – | `200` with the same body as `GET` above; requests cancellation. Accepted (and returns the now-cancelled-or-cancelling job) for any `queued` job, or a `running` job whose kind opted into cancellation (currently only `inpainting` generation); `404 not_found` for an unknown id; `409 not_cancellable` for a `running` job that didn't opt in or a job that already reached a terminal status. A queued job's cancellation is only observed the next time `_begin_job`'s wrapper runs it, at which point it releases the project's busy slot without doing any work. A running cancellable job only notices the request the next time it reports progress (`Job.set_progress` raises `cancellation.OperationCancelled`, which `JobManager` turns into `cancelled` instead of `failed`) - `inpainting`'s per-diffusion-step callback is what makes that check frequent enough to feel responsive. `detail` is an optional short status line (e.g. depth generation reports "Loading the depth model (the first run downloads its weights)" while the model is loading) that is cleared on every terminal transition. | | `GET /api/v1/projects/{id}/assets/{assetId}` | – | image bytes, `Cache-Control: no-cache`, strong ETag; `assetId="main"` serves the current display image (composed/encoded on request, bytes cached by revision), falling back to the input image when nothing has set a preview | | `GET /api/v1/projects/{id}/logs?after={seq}` | – | `{entries: [{seq, level, message}], next}`; per-project ring buffer (replaces the Dash log pane) | | `GET /api/v1/health` | – | `{ok: true, version}` | @@ -236,7 +238,7 @@ mismatch is `409 not_ready`. | `PUT /api/v1/projects/{id}/slices/{index}/prompts` | `{positivePrompt, negativePrompt}` | `200 ProjectView & {changed}` (sync); `update_prompts`; `changed: false` on `InpaintingUnchanged`. | | `PUT /api/v1/projects/{id}/inpainting/settings` | `{model?, strength?, guidanceScale?, padding?, blur?, externalServer?, apiKey?}` | `200 ProjectView & {changed}` (sync); only fields present in the body are applied. `model` calls `update_model` (and drops any stored candidate set on an actual change, mirroring `remember_inpaint_model`); `externalServer`/`apiKey` are additionally written onto `AppState` (JSON-only save) like Dash's own settings panel. The rest become project-level defaults consumed by the next `generate` call. `apiKey` is write-only - never echoed back by any response. | | `PUT /api/v1/projects/{id}/inpainting/workflow` | multipart `workflow` (ComfyUI JSON) | `200 ProjectView & {changed: true}` (sync); stored in memory and supplied to `generate_candidates` when `model == "comfyui"`. | -| `POST /api/v1/projects/{id}/slices/{index}/inpainting/generate` | `{mode: "paint" \| "fill" \| "enhance", positivePrompt, negativePrompt}` | `202 {job}` (kind `inpainting`); `generate_candidates` using the project's current settings (strength/guidanceScale/padding/blur/model/workflow). See "Candidates" below. | +| `POST /api/v1/projects/{id}/slices/{index}/inpainting/generate` | `{mode: "paint" \| "fill" \| "enhance", positivePrompt, negativePrompt}` | `202 {job}` (kind `inpainting`); `generate_candidates` using the project's current settings (strength/guidanceScale/padding/blur/model/workflow). See "Candidates" below. This is currently the only job kind whose job opts into `DELETE /jobs/{id}` cancellation (see the jobs table above); a cancelled generation leaves the previously stored candidate set and selection untouched, since the record is only replaced after `generate_candidates` returns successfully. | | `PUT /api/v1/projects/{id}/inpainting/selection` | `{generationId, candidate: number \| null}` | `200 ProjectView & {changed}` (sync); `candidate: null` → `clear_selection`; otherwise `select_candidate` (selecting the same index again toggles it off). A `generationId` that doesn't match the project's current candidate set is `409 stale_revision`. | | `POST /api/v1/projects/{id}/slices/{index}/inpainting/apply` | `{generationId}` | `200 ProjectView & {changed: true}` (sync); requires the current candidate set (`generationId`, slice index, and slice version must all still match); `apply_candidate` (new slice version, JSON-only save), re-composes the `main` asset, and drops the stored candidate set - mirrors Dash's `apply_inpainting` re-triggering `react_selected_slice_change`. | | `POST /api/v1/projects/{id}/slices/{index}/inpainting/erase` | – | `200 ProjectView & {changed: true}` (sync); `erase` (new slice version, JSON-only save), re-composes the `main` asset. Unlike apply, any stored candidate set is left alone (Dash's `erase_inpainting` doesn't clear `CTR_INPAINTING_DISPLAY` either). | diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index e1549f2..d184356 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -269,12 +269,11 @@ export class SvelteDriver implements UiDriver { async clickImagePixel(x: number, y: number, modifiers: Modifier[] = []): Promise { await this.closeDialogs(); - // Same position computation as DashDriver.clickImagePixel: the main - // image renders at `width: 100%; height: auto` (see - // InputImagePanel.svelte), so this scale is the exact inverse of the - // backend's `find_pixel_from_click` ratio, and real click coordinates' - // sub-pixel rounding truncates the resulting pixel the same way on both - // UIs (see lib/geometry.ts's `findPixelFromClick`). + // The image is fitted into the stage, so this scale is the inverse of + // lib/geometry.ts's `findPixelFromClick` ratio. Aim at the pixel's + // center, not its top-left corner: a corner click can round into the + // neighbouring pixel depending on the fitted scale (i.e. the window and + // column widths). // Clicks select with the Segment tool, on the Input view. await this.ensureTool('segment'); const image = this.mainImage(); @@ -282,7 +281,7 @@ export class SvelteDriver implements UiDriver { (element: HTMLImageElement, point) => { const rect = element.getBoundingClientRect(); const scale = Math.min(rect.width / element.naturalWidth, rect.height / element.naturalHeight); - return { x: point.x * scale, y: point.y * scale }; + return { x: (point.x + 0.5) * scale, y: (point.y + 0.5) * scale }; }, { x, y }, ); diff --git a/e2e/parallax-maker.spec.ts b/e2e/parallax-maker.spec.ts index 6b1b013..5cd0a2a 100644 --- a/e2e/parallax-maker.spec.ts +++ b/e2e/parallax-maker.spec.ts @@ -137,11 +137,10 @@ test('point segmentation honors positive and negative points through the UI', as .toEqual({ enabled: true, points: [] }); await ui.clickImagePixel(90, 96); - // The current browser-to-image transform scales the rendered click and then - // truncates it, so these requested positions arrive one pixel lower on each axis. + // Clicks land on the requested pixel whatever the fitted scale. await expect .poll(async () => (await readE2EState(page, projectId)).points_selected) - .toEqual([{ point: [89, 95], negative: false }]); + .toEqual([{ point: [90, 96], negative: false }]); expect((await readE2EState(page, projectId)).slice_mask).toEqual(positiveMask); expect(await imageHash(image)).toBe(committedImageHash); @@ -149,8 +148,8 @@ test('point segmentation honors positive and negative points through the UI', as await expect .poll(async () => (await readE2EState(page, projectId)).points_selected) .toEqual([ - { point: [89, 95], negative: false }, - { point: [127, 95], negative: true }, + { point: [90, 96], negative: false }, + { point: [128, 96], negative: true }, ]); expect((await readE2EState(page, projectId)).slice_mask).toEqual(positiveMask); expect(await imageHash(image)).toBe(committedImageHash); @@ -190,12 +189,11 @@ test('a depth-band click records its pixel, depth, log, and mask', async ({ page .poll(async () => (await readE2EState(page, projectId)).slice_pixel) .not.toBeNull(); const state = await readE2EState(page, projectId); - // Lock in the same rendered-coordinate truncation exercised by real clicks. - expect(state.slice_pixel).toEqual([15, 15]); - expect(state.slice_pixel_depth).toBe(1); + expect(state.slice_pixel).toEqual([16, 16]); + expect(state.slice_pixel_depth).toBe(2); expect(state.slice_mask.samples).toMatchObject({ '16,16': 255, '160,96': 0 }); await expect(ui.log()).toContainText( - 'Click event at pixel coordinates (15, 15) at depth 1', + 'Click event at pixel coordinates (16, 16) at depth 2', ); }); diff --git a/e2e/ux-parity.spec.ts b/e2e/ux-parity.spec.ts index 8210a1a..ad6cb6b 100644 --- a/e2e/ux-parity.spec.ts +++ b/e2e/ux-parity.spec.ts @@ -64,11 +64,7 @@ test('after zooming in and panning, a depth-mode click at a known source pixel l // of whether the pan above actually took effect - proving the pixel // mapping itself (lib/geometry.ts's `findPixelFromClick`) stays exact // under zoom, independent of the pan gap just pinned above. The `<= 1` - // tolerance (not exact equality) is the same real sub-pixel truncation - // quirk `e2e/parallax-maker.spec.ts`'s own "depth-band click" scenario - // pins for a *plain, unzoomed* click (a requested (16, 16) truncates to - // (15, 15)) - a fractional zoom scale only makes that rounding harder to - // predict exactly by hand, not any less exact in what the app itself does. + // tolerance allows for the fractional zoom scale's sub-pixel rounding. await ui.clickImagePixel(160, 120); await expect .poll(async () => (await readE2EState(page, projectId)).slice_pixel) diff --git a/frontend/src/app.css b/frontend/src/app.css index bd2cafc..344e931 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -128,18 +128,6 @@ --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, diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index ff41b24..df2d0b2 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ApiError, JobFailedError, getJob, health, pollJob } from './client'; +import { ApiError, JobCancelledError, JobFailedError, cancelJob, getJob, health, pollJob } from './client'; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -119,4 +119,37 @@ describe('pollJob', () => { await getJob('job-3'); expect(fetchMock).toHaveBeenCalledWith('/api/v1/jobs/job-3', expect.objectContaining({ method: 'GET' })); }); + + it('rejects with a JobCancelledError when the job is cancelled', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse(200, { id: 'job-4', kind: 'depth', status: 'cancelled', progress: 0.3 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const cancelled = pollJob('job-4', { intervalMs: 0 }); + await expect(cancelled).rejects.toBeInstanceOf(JobCancelledError); + await expect(cancelled).rejects.toMatchObject({ + job: { id: 'job-4', status: 'cancelled' }, + }); + }); +}); + +describe('cancelJob', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('issues a DELETE to /api/v1/jobs/{id}', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, { id: 'job-5', kind: 'depth', status: 'cancelled', progress: 0.2 })); + vi.stubGlobal('fetch', fetchMock); + + const job = await cancelJob('job-5'); + + expect(fetchMock).toHaveBeenCalledWith('/api/v1/jobs/job-5', expect.objectContaining({ method: 'DELETE' })); + expect(job.status).toBe('cancelled'); + }); }); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d2081bc..963659c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -34,6 +34,17 @@ export class ApiError extends Error { } } +/** A polled job that ended because it was cancelled (`DELETE /jobs/{id}`). */ +export class JobCancelledError extends Error { + readonly job: Job; + + constructor(job: Job) { + super('Cancelled'); + this.name = 'JobCancelledError'; + this.job = job; + } +} + /** A background job reached status `failed`; carries the terminal job record. */ export class JobFailedError extends Error { readonly job: Job; @@ -191,6 +202,14 @@ export function getJob(jobId: string, signal?: AbortSignal): Promise { return request(`/jobs/${encodeURIComponent(jobId)}`, { signal }); } +/** + * DELETE /api/v1/jobs/{id}: stops a queued job, or a running one that can + * stop safely (`Job.cancellable`); 409 otherwise. + */ +export function cancelJob(jobId: string, signal?: AbortSignal): Promise { + return request(`/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE', signal }); +} + /** GET /api/v1/projects/{id}/logs?after={seq} */ export function getLogs( id: string, @@ -807,6 +826,11 @@ export function pollJob(jobId: string, options: PollJobOptions = {}): Promise | undefined = $state(); @@ -57,6 +58,7 @@ {/if} {/if} + diff --git a/frontend/src/lib/components/depth/ModeTab.svelte b/frontend/src/lib/components/depth/ModeTab.svelte index 81bd14e..fb7e2b4 100644 --- a/frontend/src/lib/components/depth/ModeTab.svelte +++ b/frontend/src/lib/components/depth/ModeTab.svelte @@ -1,9 +1,9 @@
@@ -77,9 +74,7 @@
-
-
-
+ diff --git a/frontend/src/lib/components/dialogs/ExportDialog.svelte b/frontend/src/lib/components/dialogs/ExportDialog.svelte index 6c853fb..a9a9936 100644 --- a/frontend/src/lib/components/dialogs/ExportDialog.svelte +++ b/frontend/src/lib/components/dialogs/ExportDialog.svelte @@ -6,7 +6,6 @@ * texture upscaling, Create / Download glTF) and the animation. */ import { projectStore } from '../../state/project.svelte'; - import { jobStore } from '../../state/jobs.svelte'; import { isBusy } from '../../state/busy.svelte'; import * as workflow from '../../workflow'; import { triggerDownload } from '../../download'; @@ -14,6 +13,7 @@ import { uiStore } from '../../state/ui.svelte'; import CameraSlider from '../shared/CameraSlider.svelte'; import Dialog from '../shared/Dialog.svelte'; + import JobCard from '../feedback/JobCard.svelte'; import Box from '@lucide/svelte/icons/box'; import Clapperboard from '@lucide/svelte/icons/clapperboard'; import Check from '@lucide/svelte/icons/check'; @@ -36,7 +36,6 @@ // Model3DViewer.svelte), same as Dash's own // `gltf_create` updating the `#model-viewer` iframe - it does not, by // itself, switch the active viewer tab either. - const creatingGltf = $derived(jobStore.active === 'export-gltf'); function createGltfScene(): void { if (!view || isBusy()) return; @@ -54,7 +53,6 @@ } // -- Upscale Textures (WEB-27). - const upscaling = $derived(jobStore.active === 'upscale'); function upscaleTextures(): void { if (!view || isBusy()) return; @@ -67,7 +65,6 @@ // setting (components.py's SLIDER_NUM_FRAMES has no "remember" callback). let numFrames = $state(100); const seconds = $derived((numFrames / 30).toFixed(1)); - const animating = $derived(jobStore.active === 'animation'); function exportAnimation(): void { if (!view || isBusy() || numFrames <= 0) return; @@ -149,12 +146,7 @@ {#if view?.exports.upscaled}Textures upscaled{/if} -
-
-
+
{#if gltfReady} Scene ready @@ -204,9 +196,7 @@ about {seconds} s.

-
-
-
+
+ {/if} +
+
+ {#if jobStore.detail} + {jobStore.detail} + {/if} + {:else if error} +
+ {error.title} + +
+ {error.message} +
+ {#each error.actions as action (action.label)} + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/feedback/JobCard.test.ts b/frontend/src/lib/components/feedback/JobCard.test.ts new file mode 100644 index 0000000..f09bf92 --- /dev/null +++ b/frontend/src/lib/components/feedback/JobCard.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import JobCard from './JobCard.svelte'; +import { jobStore } from '../../state/jobs.svelte'; +import * as api from '../../api/client'; + +describe('JobCard', () => { + afterEach(() => { + vi.restoreAllMocks(); + jobStore.end(); + jobStore.clearError(); + }); + + it('is hidden (idle) when no job of its kinds is running and there is no matching error', () => { + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + expect(screen.getByTestId('depth-progress')).toHaveClass('idle'); + expect(screen.queryByTestId('job-label')).not.toBeInTheDocument(); + }); + + it('stays hidden for a running job whose kind is not in `kinds`', () => { + jobStore.begin('inpainting'); + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + expect(screen.getByTestId('depth-progress')).toHaveClass('idle'); + }); + + it('shows label, percent and progress bar for a running job in `kinds`', () => { + jobStore.begin('depth'); + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + + expect(screen.getByTestId('depth-progress')).not.toHaveClass('idle'); + expect(screen.getByTestId('job-label')).toHaveTextContent('Generating depth map'); + expect(screen.getByText('0%')).toBeInTheDocument(); + + jobStore.setProgress(0.5); + flushSync(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('shows the detail line only when jobStore.detail is set', () => { + jobStore.begin('depth'); + render(JobCard, { props: { kinds: ['depth'] } }); + expect(screen.queryByTestId('job-detail')).not.toBeInTheDocument(); + + jobStore.track({ id: 'job-1', kind: 'depth', status: 'running', progress: 0.1, detail: 'Loading the depth model' }); + flushSync(); + expect(screen.getByTestId('job-detail')).toHaveTextContent('Loading the depth model'); + }); + + it('shows Cancel only when the job is cancellable, and clicking it calls jobStore.cancel (a DELETE)', async () => { + const cancelJobSpy = vi.spyOn(api, 'cancelJob').mockResolvedValue({ + id: 'job-1', + kind: 'depth', + status: 'cancelled', + progress: 0.4, + }); + jobStore.begin('depth'); + render(JobCard, { props: { kinds: ['depth'] } }); + expect(screen.queryByTestId('job-cancel')).not.toBeInTheDocument(); + + jobStore.track({ id: 'job-1', kind: 'depth', status: 'running', progress: 0.4, cancellable: true }); + flushSync(); + const cancelButton = screen.getByTestId('job-cancel'); + expect(cancelButton).toHaveTextContent('Cancel'); + + await fireEvent.click(cancelButton); + expect(cancelJobSpy).toHaveBeenCalledWith('job-1'); + }); + + it('shows "Cancelling…" and disables the button while a cancel is pending', async () => { + vi.spyOn(api, 'cancelJob').mockReturnValue(new Promise(() => {})); + jobStore.begin('depth'); + jobStore.track({ id: 'job-1', kind: 'depth', status: 'running', progress: 0.4, cancellable: true }); + render(JobCard, { props: { kinds: ['depth'] } }); + + await fireEvent.click(screen.getByTestId('job-cancel')); + + const cancelButton = screen.getByTestId('job-cancel'); + expect(cancelButton).toHaveTextContent('Cancelling…'); + expect(cancelButton).toBeDisabled(); + }); + + it('shows an error card for a matching kind when not running, with actions and dismiss', async () => { + const run = vi.fn(); + jobStore.fail({ kind: 'depth', title: 'Depth map failed', message: 'model exploded', actions: [{ label: 'Retry', run }] }); + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + + expect(screen.getByTestId('depth-progress')).toHaveClass('failed'); + expect(screen.getByTestId('job-error')).toHaveTextContent('Depth map failed'); + expect(screen.getByText('model exploded')).toBeInTheDocument(); + + const actionButton = screen.getByTestId('job-error-action'); + expect(actionButton).toHaveTextContent('Retry'); + await fireEvent.click(actionButton); + expect(run).toHaveBeenCalledOnce(); + + await fireEvent.click(screen.getByTestId('job-error-dismiss')); + expect(screen.queryByTestId('job-error')).not.toBeInTheDocument(); + }); + + it('does not show an error card for a kind not in `kinds`', () => { + jobStore.fail({ kind: 'inpainting', title: 'Generation failed', message: 'boom', actions: [] }); + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + expect(screen.getByTestId('depth-progress')).toHaveClass('idle'); + expect(screen.queryByTestId('job-error')).not.toBeInTheDocument(); + }); + + it('prefers the running view over a stale error for the same kinds', () => { + jobStore.begin('depth'); + // Not a state workflow.ts ever produces (begin() clears lastError), but + // pins down JobCard's own `!running` guard on the error branch. + jobStore.fail({ kind: 'depth', title: 'Depth map failed', message: 'boom', actions: [] }); + render(JobCard, { props: { kinds: ['depth'], testId: 'depth-progress' } }); + expect(screen.getByTestId('job-label')).toBeInTheDocument(); + expect(screen.queryByTestId('job-error')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/feedback/ToastStack.svelte b/frontend/src/lib/components/feedback/ToastStack.svelte new file mode 100644 index 0000000..7518c28 --- /dev/null +++ b/frontend/src/lib/components/feedback/ToastStack.svelte @@ -0,0 +1,134 @@ + + +
+ {#each toastStore.toasts as toast (toast.id)} +
+ +
+ {toast.title} + {#if toast.message}{toast.message}{/if} + {#if toast.actions.length > 0} +
+ {#each toast.actions as action (action.label)} + + {/each} +
+ {/if} +
+ +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/feedback/ToastStack.test.ts b/frontend/src/lib/components/feedback/ToastStack.test.ts new file mode 100644 index 0000000..19c0536 --- /dev/null +++ b/frontend/src/lib/components/feedback/ToastStack.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ToastStack from './ToastStack.svelte'; +import { toastStore } from '../../state/toasts.svelte'; + +describe('ToastStack', () => { + afterEach(() => { + toastStore.reset(); + }); + + it('renders nothing when there are no toasts', () => { + render(ToastStack); + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + + it('renders one toast per store entry with its kind as data-kind', () => { + toastStore.success('Depth map ready'); + toastStore.error('Generation failed', 'boom'); + render(ToastStack); + + const toasts = screen.getAllByTestId('toast'); + expect(toasts).toHaveLength(2); + expect(toasts[0]).toHaveAttribute('data-kind', 'success'); + expect(toasts[0]).toHaveTextContent('Depth map ready'); + expect(toasts[1]).toHaveAttribute('data-kind', 'error'); + expect(toasts[1]).toHaveTextContent('Generation failed'); + expect(toasts[1]).toHaveTextContent('boom'); + }); + + it('clicking a toast-action dismisses the toast and runs the action', async () => { + const run = vi.fn(); + toastStore.error('Generation failed', 'boom', [{ label: 'Retry', run }]); + render(ToastStack); + + const actionButton = screen.getByTestId('toast-action'); + expect(actionButton).toHaveTextContent('Retry'); + await fireEvent.click(actionButton); + + expect(run).toHaveBeenCalledOnce(); + expect(screen.queryByTestId('toast')).not.toBeInTheDocument(); + }); + + it('clicking toast-dismiss removes only that toast', async () => { + toastStore.success('First'); + toastStore.success('Second'); + render(ToastStack); + + const dismissButtons = screen.getAllByTestId('toast-dismiss'); + await fireEvent.click(dismissButtons[0]); + + const remaining = screen.getAllByTestId('toast'); + expect(remaining).toHaveLength(1); + expect(remaining[0]).toHaveTextContent('Second'); + }); +}); diff --git a/frontend/src/lib/components/ground/GroundPanel.svelte b/frontend/src/lib/components/ground/GroundPanel.svelte index c60719c..544b60b 100644 --- a/frontend/src/lib/components/ground/GroundPanel.svelte +++ b/frontend/src/lib/components/ground/GroundPanel.svelte @@ -9,7 +9,7 @@ import { cameraDraftStore } from '../../state/cameraDraft.svelte'; import * as workflow from '../../workflow'; import CameraSlider from '../shared/CameraSlider.svelte'; - import SceneSideView from '../export/SceneSideView.svelte'; + import SceneSideView from './SceneSideView.svelte'; const view = $derived(projectStore.view); // Nearest first, like the layer panel. diff --git a/frontend/src/lib/components/export/SceneSideView.svelte b/frontend/src/lib/components/ground/SceneSideView.svelte similarity index 96% rename from frontend/src/lib/components/export/SceneSideView.svelte rename to frontend/src/lib/components/ground/SceneSideView.svelte index fd96dea..3e71a5e 100644 --- a/frontend/src/lib/components/export/SceneSideView.svelte +++ b/frontend/src/lib/components/ground/SceneSideView.svelte @@ -101,12 +101,12 @@ display: block; width: 100%; height: 9rem; - background-color: var(--color-surface-muted); + background-color: var(--color-surface-raised); border-radius: var(--radius-md); } .frustum { - fill: var(--color-accent); + fill: var(--color-selection); opacity: 0.12; } @@ -127,6 +127,6 @@ } .camera { - fill: var(--color-accent); + fill: var(--color-selection); } diff --git a/frontend/src/lib/components/inpainting/InpaintPanel.svelte b/frontend/src/lib/components/inpainting/InpaintPanel.svelte index da3c84a..281ad0a 100644 --- a/frontend/src/lib/components/inpainting/InpaintPanel.svelte +++ b/frontend/src/lib/components/inpainting/InpaintPanel.svelte @@ -7,12 +7,12 @@ */ import { untrack } from 'svelte'; import { projectStore } from '../../state/project.svelte'; - import { jobStore } from '../../state/jobs.svelte'; import { isBusy } from '../../state/busy.svelte'; import { canvasSaveStore } from '../../state/canvas.svelte'; import { uiStore } from '../../state/ui.svelte'; import * as workflow from '../../workflow'; import { registerInpaintActions } from '../../shortcuts'; + import JobCard from '../feedback/JobCard.svelte'; const view = $derived(projectStore.view); const selectedSlice = $derived(view?.selectedSlice ?? null); @@ -72,8 +72,6 @@ // -- Generate / Fill / Enhance / Erase. const canGenerate = $derived(hasSlice && !isBusy()); - const generating = $derived(jobStore.active === 'inpainting'); - const progressPercent = $derived(Math.round((generating ? jobStore.progress : 0) * 100)); function generate(): void { if (!canGenerate) return; @@ -275,9 +273,7 @@ -
-
-
+
@@ -475,9 +471,6 @@ flex: 1 1 0; } - .progress-bar.idle { - visibility: hidden; - } .candidates { display: grid; diff --git a/frontend/src/lib/components/segmentation/SegmentPanel.svelte b/frontend/src/lib/components/segmentation/SegmentPanel.svelte index 0863859..2614a0c 100644 --- a/frontend/src/lib/components/segmentation/SegmentPanel.svelte +++ b/frontend/src/lib/components/segmentation/SegmentPanel.svelte @@ -11,6 +11,7 @@ import { uiStore } from '../../state/ui.svelte'; import { isBusy } from '../../state/busy.svelte'; import * as workflow from '../../workflow'; + import JobCard from '../feedback/JobCard.svelte'; const view = $derived(projectStore.view); const segmentation = $derived(view?.segmentation); @@ -133,6 +134,7 @@ Remove from selected +
@@ -199,6 +201,7 @@ Balance +
diff --git a/frontend/src/lib/components/shell/ActivityIndicator.svelte b/frontend/src/lib/components/shell/ActivityIndicator.svelte index 45b16a3..fa07067 100644 --- a/frontend/src/lib/components/shell/ActivityIndicator.svelte +++ b/frontend/src/lib/components/shell/ActivityIndicator.svelte @@ -1,30 +1,8 @@