Skip to content

feat: unified layout loading pipeline with LoadDialog (#1348)#1351

Merged
ggfevans merged 6 commits intomainfrom
issue-1348
Feb 28, 2026
Merged

feat: unified layout loading pipeline with LoadDialog (#1348)#1351
ggfevans merged 6 commits intomainfrom
issue-1348

Conversation

@ggfevans
Copy link
Collaborator

@ggfevans ggfevans commented Feb 27, 2026

User description

This PR unifies the layout loading flow between server-persisted (Persist API) and local file (.zip) imports.

Key Changes:

  • Unified Load Pipeline: Logic moved to load-pipeline.ts for consistent image restoration and state management.
  • New LoadDialog: Replaces simple file picker when Persist API is available, offering a choice between saved layouts and local files.
  • UX Improvements: Added a confirmation step for deleting layouts from the server.
  • Security Guardrails:
    • Added Zod runtime schema validation for API layout lists.
    • Implemented archive extraction limits (ZIP size, uncompressed size, entry count, and compression ratio) to prevent ZIP bombs and DoS.
    • Added zero-byte file protection.
  • Engineering:
    • Full test coverage for the new dialog and pipeline.
    • Adherence to design tokens for the loading spinner.
    • Fixed linting and test regressions in App.svelte and StartScreen.svelte.

Closes #1348


CodeAnt-AI Description

Unify layout loading with a Load dialog and secure ZIP import

What Changed

  • Added a Load Layout dialog that lets users pick layouts saved on the server or import a local .zip; Start screen and app open this dialog when the persistence API is available
  • Centralized loading behavior so API and file imports now consistently: reset autosave/session, clear and reload bundled images, overlay custom images, clear selection, center the view, and show a success or warning toast
  • Block and surface clear errors for unsafe or malformed archives: zero-byte files, files >50MB, archives with >500 entries, excessive uncompressed totals, suspicious compression ratios, and layout YAMLs >5MB
  • Validate Persist API list responses and show an explicit error when the server returns an invalid layout list
  • Replace scattered file-load logic with a single load pipeline and add tests covering the dialog, pipeline, and archive guardrails; updated StartScreen and App to use the unified flow

Impact

✅ Clearer archive rejection errors
✅ Fewer silent API failures when listing saved layouts
✅ Consistent layout restore behavior and messaging across import sources

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

- Unified API and File loading logic into load-pipeline.ts
- Created LoadDialog component for choosing between server and local layouts
- Added Zod runtime schema validation for Persist API layout lists
- Implemented archive extraction guardrails (size, entry count, ratio) for security
- Updated App and StartScreen to use the new unified loading flow
@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 27, 2026

CodeAnt AI is reviewing your PR.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new LoadDialog UI and a unified load pipeline; switches App and StartScreen to open the dialog (or fallback to file import); adds archive guardrails, Zod validation for persistence responses, a "load" dialog id, and tests for dialog, pipeline, and archive guardrails.

Changes

Cohort / File(s) Summary
UI: App & Load dialog
src/App.svelte, src/lib/components/LoadDialog.svelte, src/lib/components/StartScreen.svelte
Introduce LoadDialog component and render it from App/StartScreen; replace direct file-picker/archive logic with dialog-driven flows and pipeline fallbacks; update imports and markup.
Load pipeline
src/lib/utils/load-pipeline.ts, src/lib/utils/*file
Add unified load pipeline: finalizeLayoutLoad, loadFromApi, loadFromFile; centralize layout/image application, toast/analytics, and fallback file import handling.
Archive guardrails
src/lib/utils/archive.ts
Add ZIP extraction guardrails: max ZIP size, max entries, total uncompressed size, max YAML bytes, compression-ratio checks and pre-decompression validations.
Persistence API validation
src/lib/utils/persistence-api.ts
Add Zod schemas to validate saved-layout listing responses and throw PersistenceError on invalid API responses.
Dialog state
src/lib/stores/dialogs.svelte.ts
Extend DialogId union to include "load" to support opening the new LoadDialog via dialog store.
Tests
src/tests/LoadDialog.test.ts, src/tests/load-pipeline.test.ts, src/tests/archive-guardrails.test.ts, src/tests/App.startScreen.test.ts, src/tests/App.cleanupPrompt.test.ts
Add/adjust tests: new LoadDialog unit tests, load-pipeline tests, archive guardrails tests, and update App tests to mock the new pipeline module.
Public surface
src/lib/components/LoadDialog.svelte
New exported Svelte component LoadDialog (adds public component file).

Sequence Diagram

sequenceDiagram
    participant User
    participant App
    participant LoadDialog
    participant PersistenceAPI
    participant FileSystem as File System
    participant Archive
    participant LoadPipeline
    participant LayoutStore

    User->>App: Trigger "Load" (menu / UI)
    App->>LoadDialog: Open dialog (if API available) / or call LoadPipeline.loadFromFile() fallback

    alt Load from API (via dialog)
        User->>LoadDialog: Request list / select saved layout
        LoadDialog->>PersistenceAPI: listSavedLayouts / loadSavedLayout(uuid)
        PersistenceAPI-->>LoadDialog: layout + images
        LoadDialog->>LoadPipeline: loadFromApi(uuid)
        LoadPipeline->>LayoutStore: finalizeLayoutLoad(layout, images)
    else Import from File (via dialog)
        User->>LoadDialog: Click "Import from file"
        LoadDialog->>FileSystem: Open file picker
        FileSystem-->>LoadDialog: .zip file
        LoadDialog->>Archive: extractFolderArchive(file) (guardrails)
        Archive-->>LoadDialog: layout + images
        LoadDialog->>LoadPipeline: loadFromFile(file)
        LoadPipeline->>LayoutStore: finalizeLayoutLoad(layout, images)
    else Fallback (API not available)
        App->>LoadPipeline: loadFromFile()
        LoadPipeline->>FileSystem: open file picker
        FileSystem-->>LoadPipeline: .zip file (or cancel)
        LoadPipeline->>Archive: extractFolderArchive(file)
        Archive-->>LoadPipeline: layout + images
        LoadPipeline->>LayoutStore: finalizeLayoutLoad(layout, images)
    end

    LoadDialog->>App: Close dialog on success
    App-->>User: Layout rendered
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped to the Load button, bright and bold,
Dialogs, server lists, and ZIPs in tidy fold.
Guardrails watch the zip, images find their spot,
Toasts sing softly for each successful plot.
Hooray — the racks wake up, neat as gold!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: introducing a unified layout loading pipeline with a new LoadDialog component, matching the primary objective of the PR.
Description check ✅ Passed The description is directly related to the changeset, detailing the unified load pipeline, new LoadDialog, security guardrails, and test coverage that align with the code changes.
Linked Issues check ✅ Passed The PR fully addresses issue #1348: it enables Load flow to support API-backed layouts from the file menu via the new LoadDialog, provides explicit UI choice between local file and API sources with clear labeling, ensures consistent behavior with initial load, and includes comprehensive test coverage for the dialog, pipeline, and archive guardrails.
Out of Scope Changes check ✅ Passed All code changes are directly aligned with the PR objectives: unified loading pipeline, LoadDialog implementation, security guardrails (Zod validation, archive limits), and comprehensive testing. No unrelated or extraneous changes are present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-1348

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeant-ai codeant-ai bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Feb 27, 2026
@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 27, 2026

Nitpicks 🔍

🔒 No security issues identified
⚡ Recommended areas for review

  • YAML size check
    The YAML file size guard uses the same internal _data.uncompressedSize field which may be unavailable. If _data is missing the check falls back to 0 and large YAML files could slip through. Consider ensuring actual uncompressed size is measured for critical files.

  • Fragile guardrail
    The total uncompressed size calculation relies on the internal JSZip property _data.uncompressedSize. That internal field may be absent or zero until the file has been fully read, which can make the uncompressed-size and compression-ratio checks unreliable and potentially bypassable.

  • Unhandled errors
    handleOpenLayout calls loadFromApi(item.id) but assumes it returns a boolean success flag and does not catch exceptions. If loadFromApi throws, the dialog won't show an error toast or close gracefully, leaving the user unsure what happened.

  • Availability race
    handleLoad uses isApiAvailable() to decide whether to open the new Load dialog or call loadFromFile(). If isApiAvailable() is a snapshot that can be stale (or async to initialize), this check may misroute the flow (opening dialog when API is actually offline or skipping dialog when API becomes available shortly after). Consider using a stable store/subscription or disabling the load button until availability settles.

  • Strict response validation
    The Zod schema for layout list parsing enforces strict fields (e.g., updatedAt uses .datetime()). If the API returns slight variants or extra fields this will throw a validation error; that's surfaced as "Invalid response" which may hide server-side debugging details. Consider using safeParse and including validation errors or loosening/transforming fields where appropriate.

Comment on lines 38 to 44
onMount(async () => {
if (apiActive) {
await loadLayouts();
} else {
loading = false;
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The saved layouts list in the Load dialog is only fetched once in onMount using the initial apiActive value; if the persistence API becomes available after app startup (the normal case, since availability is determined asynchronously in initializePersistence), apiActive flips to true but loadLayouts() is never called again, so users see an empty "No layouts saved on server yet." state even when layouts exist until they manually hit Retry. [logic error]

Severity Level: Major ⚠️
- ⚠️ Load dialog shows empty server layouts list initially.
- ⚠️ Users must click Retry before seeing saved layouts.
- ⚠️ Confusing UX when switching layouts via toolbar Load.
Suggested change
onMount(async () => {
if (apiActive) {
await loadLayouts();
} else {
loading = false;
}
});
onMount(async () => {
if (apiActive) {
await loadLayouts();
} else {
loading = false;
}
});
$effect(() => {
// When API becomes available after mount and we haven't loaded yet,
// automatically fetch the layouts list once.
if (apiActive && !loading && layouts.length === 0 && !error) {
loading = true;
loadLayouts();
}
});
Steps of Reproduction ✅
1. Start the app so `App.svelte` mounts and runs its first `onMount` block
(`src/App.svelte:264-389`), which calls `initializePersistence()`
(`src/lib/stores/persistence.svelte.ts:74-106`) asynchronously; at this point
`apiAvailable` is `null` (`line 41`), so `isApiAvailable()` (`line 50`) returns `false`.

2. During the same initial mount, `LoadDialog.svelte` is also mounted via `<LoadDialog />`
in `App.svelte` (`src/App.svelte:1766`), and its `onMount` handler
(`src/lib/components/LoadDialog.svelte:38-44`) runs while `isApiAvailable()` is still
`false`, so `apiActive` is `false` and the code sets `loading = false` without calling
`loadLayouts()`.

3. After the persistence health check finishes, `initializePersistence()` sets
`apiAvailable` to `true` (`src/lib/stores/persistence.svelte.ts:90-99`), so
`isApiAvailable()` now returns `true`; later, when the user clicks "Load" in the toolbar,
`handleLoad()` in `App.svelte` (`src/App.svelte:704-710`) sees `isApiAvailable()` as
`true` and opens the Load dialog via `dialogStore.open("load")`.

4. When the Load dialog becomes visible, its internal state is `apiActive === true`,
`loading === false`, `layouts === []`, `error === null` (because `loadLayouts()` was never
called after API became available), so the `{#if}` chain in `LoadDialog.svelte`
(`src/lib/components/LoadDialog.svelte:148-161`) takes the `{:else if !layouts ||
layouts.length === 0}` branch and renders "No layouts saved on server yet." even though
`listSavedLayouts()` (used by `loadLayouts()`) would return actual saved layouts until the
user manually clicks the "Retry" button to trigger `loadLayouts()` once.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/components/LoadDialog.svelte
**Line:** 38:44
**Comment:**
	*Logic Error: The saved layouts list in the Load dialog is only fetched once in `onMount` using the initial `apiActive` value; if the persistence API becomes available after app startup (the normal case, since availability is determined asynchronously in `initializePersistence`), `apiActive` flips to true but `loadLayouts()` is never called again, so users see an empty "No layouts saved on server yet." state even when layouts exist until they manually hit Retry.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

Comment on lines 27 to 47
const layoutStore = getLayoutStore();
const imageStore = getImageStore();
const toastStore = getToastStore();
const selectionStore = getSelectionStore();
const canvasStore = getCanvasStore();

// Clear and restore images if provided
if (images) {
imageStore.clearAllImages();
for (const [deviceSlug, deviceImages] of images) {
if (deviceImages.front) {
imageStore.setDeviceImage(deviceSlug, "front", deviceImages.front);
}
if (deviceImages.rear) {
imageStore.setDeviceImage(deviceSlug, "rear", deviceImages.rear);
}
}
}

// Always ensure bundled images are loaded
imageStore.loadBundledImages();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When loading a layout from a ZIP archive, bundled device images are loaded after custom images and overwrite them, so user-uploaded overrides (including placement-level overrides for built‑in device types) will be silently lost on load; bundled images should be the fallback base, with custom images applied last to take precedence. [logic error]

Severity Level: Critical 🚨
- ❌ Importing ZIP loses custom images for built-in device types.
- ⚠️ Start screen "Import File" fails to restore overrides.
- ⚠️ Toolbar and keyboard load shortcuts drop customized visuals.
Suggested change
const layoutStore = getLayoutStore();
const imageStore = getImageStore();
const toastStore = getToastStore();
const selectionStore = getSelectionStore();
const canvasStore = getCanvasStore();
// Clear and restore images if provided
if (images) {
imageStore.clearAllImages();
for (const [deviceSlug, deviceImages] of images) {
if (deviceImages.front) {
imageStore.setDeviceImage(deviceSlug, "front", deviceImages.front);
}
if (deviceImages.rear) {
imageStore.setDeviceImage(deviceSlug, "rear", deviceImages.rear);
}
}
}
// Always ensure bundled images are loaded
imageStore.loadBundledImages();
const layoutStore = getLayoutStore();
const imageStore = getImageStore();
const toastStore = getToastStore();
const selectionStore = getSelectionStore();
const canvasStore = getCanvasStore();
// Clear and restore images if provided
if (images) {
imageStore.clearAllImages();
// Load bundled images first as defaults
imageStore.loadBundledImages();
// Then overlay any custom images from the archive so they take precedence
for (const [deviceSlug, deviceImages] of images) {
if (deviceImages.front) {
imageStore.setDeviceImage(deviceSlug, "front", deviceImages.front);
}
if (deviceImages.rear) {
imageStore.setDeviceImage(deviceSlug, "rear", deviceImages.rear);
}
}
} else {
// Ensure bundled images are loaded when no custom images are provided
imageStore.loadBundledImages();
}
Steps of Reproduction ✅
1. In a test or runtime session, obtain the image store via `getImageStore()` from
`src/lib/stores/images.svelte.ts:206-211`. Note that bundled device slugs come from
`getBundledImageSlugs()` used inside `loadBundledImages()` at
`src/lib/stores/images.svelte.ts:108-131`.

2. Create a layout that uses at least one built-in device type (a slug returned by
`getBundledImageSlugs()`), then set a custom image for that slug using
`imageStore.setDeviceImage(bundledSlug, "front", customImage)` as defined in
`src/lib/stores/images.svelte.ts:34-40`. This mimics a user overriding the default image
for a built-in device type.

3. Save the layout as a folder-based archive using the existing save flow in
`src/App.svelte:671-681`, which calls `imageStore.getUserImages()` and
`downloadArchive()`. `downloadArchive()` delegates to `createFolderArchive()` in
`src/lib/utils/archive.ts:222-314`, which writes those custom images into the ZIP (see
device-type branch at `archive.ts:263-309`).

4. Import the saved archive through any load entrypoint that calls `loadFromFile()` in
`src/lib/utils/load-pipeline.ts:97-107` (e.g., toolbar "Load" handler `handleLoad()` at
`src/App.svelte:704-709`, StartScreen `handleImportFile()` at
`src/lib/components/StartScreen.svelte:128-133`, or LoadDialog `handleImportFile()` at
`src/lib/components/LoadDialog.svelte:101-106`). `loadFromFile()` calls
`extractFolderArchive()` (which rebuilds the `images: ImageStoreMap`) and then
`finalizeLayoutLoad(layout, images, failedImages.length)` at
`src/lib/utils/load-pipeline.ts:104-106`. Inside `finalizeLayoutLoad()`
(`load-pipeline.ts:22-74`), the current implementation first applies the custom images
(`setDeviceImage()` loop at lines 34-43) and then calls `imageStore.loadBundledImages()`
at line 47. Because `loadBundledImages()` overwrites entries for bundled slugs, the
bundled defaults replace the just-restored custom images for those same slugs, so after
import the layout shows default graphics instead of the user's saved overrides.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/utils/load-pipeline.ts
**Line:** 27:47
**Comment:**
	*Logic Error: When loading a layout from a ZIP archive, bundled device images are loaded after custom images and overwrite them, so user-uploaded overrides (including placement-level overrides for built‑in device types) will be silently lost on load; bundled images should be the fallback base, with custom images applied last to take precedence.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 27, 2026

CodeAnt AI finished reviewing your PR.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/components/LoadDialog.svelte`:
- Line 55: Replace the direct console.error call in LoadDialog.svelte with the
namespaced debug logger: import the layoutDebug from '$lib/utils/debug' (using
the prescribed import pattern) and call layoutDebug with a descriptive message
and the error object (e.g., include the error via formatting/placeholders)
instead of console.error("Failed to load layouts:", e); update the code path
that catches layout-loading errors to use layoutDebug so logs follow the
project's debug namespace conventions.
- Around line 38-44: The current onMount block only calls loadLayouts once and
misses later changes to apiActive or dialog reopen; update LoadDialog.svelte to
also react to changes in apiActive and the dialog-open state (e.g., a
prop/variable like open or show) by invoking loadLayouts whenever apiActive
becomes true or the dialog is reopened, and ensure loading is set false on
failures; specifically, add a reactive watcher/statement that watches apiActive
and the dialog-open flag and calls loadLayouts() (and sets loading
appropriately) when apiActive transitions to true or when the dialog is shown
again so the list is refreshed.

In `@src/lib/utils/archive.ts`:
- Around line 331-337: The empty-blob guard currently runs after calling
JSZip.loadAsync, which can throw on zero-byte uploads; move the check for
blob.size === 0 to run before calling getJSZip()/JSZip.loadAsync so you throw
the "Archive file is empty (0 bytes)." error deterministically; specifically, in
the function where getJSZip() and JSZip.loadAsync(blob) are used, check
blob.size first and throw if zero, then call getJSZip() and
JSZip.loadAsync(blob) only when blob is non-empty.
- Around line 347-357: The code is incorrectly reading JSZip internals via
file._data.uncompressedSize which is not part of the public API; update
extractFolderArchive, extractNewFormatZip, and extractOldFormatZip to
asynchronously obtain each ZipObject's uncompressed bytes via the public
file.async(...) API (e.g., "uint8array" or "nodebuffer") and use the resulting
byte length to compute totalUncompressedSize, compression ratios, and
per-file/YAML size checks before parsing; replace all usages of
file._data?.uncompressedSize with await file.async(...) size measurements (and
reuse the loaded bytes where possible to avoid double-decompression) to ensure
guardrails are reliable.

In `@src/lib/utils/load-pipeline.ts`:
- Around line 33-44: Always clear previously set custom images before finalizing
any layout load: call imageStore.clearAllImages() unconditionally before
invoking finalizeLayoutLoad(layout), then only apply new images when the images
parameter is present by iterating and calling
imageStore.setDeviceImage(deviceSlug, "front"|"rear", ...). Update the block
that currently guards clearAllImages() behind if (images) and the analogous code
path used before finalizeLayoutLoad(layout) so that previous overrides cannot
leak into API-loaded layouts.
- Line 109: Replace the console.error call in load-pipeline.ts with the project
namespaced debug logger: add the import `import { layoutDebug } from
'$lib/utils/debug'` if missing, then call layoutDebug with the same message and
the error (e.g., include the error object as a structured parameter) instead of
console.error so the failure to load layout is logged via the debug namespace
and can be filtered at runtime.

In `@src/lib/utils/persistence-api.ts`:
- Around line 205-214: The JSON parsing (await response.json()) is outside the
try/catch so a SyntaxError can escape; move the response.json() call into the
same try block used for validation in listSavedLayouts (i.e., call const rawData
= await response.json() inside the try, then run
LayoutListResponseSchema.parse(rawData)), and in the catch catch any error and
rethrow a PersistenceError("Invalid response from API server") after logging
(log("listSavedLayouts: validation failed %O", error)) so all parse/validation
failures are handled consistently.

In `@src/tests/archive-guardrails.test.ts`:
- Around line 5-58: Add a regression test that validates extractFolderArchive
rejects a zero-byte archive: create an empty Blob (size 0) or a JSZip-generated
empty blob with its size mocked to 0 and add an it(...) in the "Archive
Guardrails" suite that awaits
expect(extractFolderArchive(zeroByteBlob)).rejects.toThrow(...) checking the
same guardrail error path used for invalid archives (e.g., "Archive too
large"/generic "invalid archive" message used by extractFolderArchive) so the
zero-length edge case is covered alongside the existing
size/entry/compression/YAML tests.

In `@src/tests/load-pipeline.test.ts`:
- Around line 42-45: The requestAnimationFrame stub created with
vi.stubGlobal("requestAnimationFrame", ...) is not undone and can leak into
other tests; fix it by adding a teardown that restores the global after each
test (e.g. add an afterEach or afterAll hook that calls
vi.unstubGlobal("requestAnimationFrame") or vi.unstubAllGlobals()) so the
stubbed "requestAnimationFrame" is removed after tests run.

In `@src/tests/LoadDialog.test.ts`:
- Around line 33-137: Add a new test that simulates API initially unavailable
then becoming available and verifies listSavedLayouts is fetched: mock
persistenceStore.isApiAvailable to return false, open the dialog via
dialogStore.open("load") and render LoadDialog, assert
persistenceApi.listSavedLayouts has not been called, then change the mock
(persistenceStore.isApiAvailable) to return true, trigger the dialog refresh by
reopening or calling the same open/refresh path used in the component (e.g.,
dialogStore.open("load") or whatever LoadDialog listens to), await the component
update, and assert persistenceApi.listSavedLayouts was called and the layout UI
appears; reference persistenceStore.isApiAvailable, dialogStore.open,
persistenceApi.listSavedLayouts, and LoadDialog to locate relevant code.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bcfc3bb and e3f5a6e.

📒 Files selected for processing (12)
  • src/App.svelte
  • src/lib/components/LoadDialog.svelte
  • src/lib/components/StartScreen.svelte
  • src/lib/stores/dialogs.svelte.ts
  • src/lib/utils/archive.ts
  • src/lib/utils/load-pipeline.ts
  • src/lib/utils/persistence-api.ts
  • src/tests/App.cleanupPrompt.test.ts
  • src/tests/App.startScreen.test.ts
  • src/tests/LoadDialog.test.ts
  • src/tests/archive-guardrails.test.ts
  • src/tests/load-pipeline.test.ts

- Replace private JSZip _data.uncompressedSize with public async API
- Move empty blob check before JSZip.loadAsync to avoid confusing errors
- Fix image load ordering: always clear → bundled → custom overlay
- Replace onMount with $effect for reactive API fetch in LoadDialog
- Move response.json() inside try/catch in persistence-api.ts
- Replace console.error/warn with debug loggers
- Fix requestAnimationFrame stub leak in load-pipeline tests
- Add zero-byte archive guardrail test
- Add delayed API availability test for LoadDialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ggfevans
Copy link
Collaborator Author

@CodeAnt-AI review

@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 27, 2026

CodeAnt AI is running the review.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@ggfevans
Copy link
Collaborator Author

@CodeRabbit-ai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

@ggfevans: I'll review the changes in this PR right away.


🧠 Learnings used
Learnt from: CR
Repo: RackulaLives/Rackula PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-01T03:29:11.563Z
Learning: CodeRabbit provides AI code review on every PR. Claude Code must wait for CodeRabbit approval before merging. Run local review before pushing with `coderabbit --prompt-only --type uncommitted` or `coderabbit --prompt-only --type committed` to catch issues early. Always use `--prompt-only` for token-efficient output. Never use `gh pr merge` until CodeRabbit has approved the PR.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@codeant-ai codeant-ai bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels Feb 27, 2026
Comment on lines 197 to 208
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
handleDeleteLayout(item);
}}
disabled={deletingId === item.id}
aria-label={`Delete layout ${item.name}`}
title="Delete layout"
>
<IconTrash size={16} />
</button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When the delete button has keyboard focus, pressing Enter or Space will both trigger the delete action (via the button's default click behavior) and bubble a keydown event up to the parent .layout-item, which interprets Enter/Space as a request to open the layout; this means keyboard users will unintentionally open a layout while trying to delete it. Add a keydown handler on the delete button that prevents propagation and handles Enter/Space itself, matching the behavior already used on the StartScreen delete button. [logic error]

Severity Level: Major ⚠️
- ⚠️ Keyboard users cannot delete from Load dialog reliably.
- ⚠️ Delete button activates wrong action (opens layout instead).
- ⚠️ Inconsistent behavior vs StartScreen delete interaction.
Suggested change
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
handleDeleteLayout(item);
}}
disabled={deletingId === item.id}
aria-label={`Delete layout ${item.name}`}
title="Delete layout"
>
<IconTrash size={16} />
</button>
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
handleDeleteLayout(item);
}}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleDeleteLayout(item);
}
}}
disabled={deletingId === item.id}
aria-label={`Delete layout ${item.name}`}
title="Delete layout"
>
<IconTrash size={16} />
</button>
Steps of Reproduction ✅
1. Open the main application so that the `LoadDialog` component (imported in
`/workspace/Rackula/src/App.svelte:39` and rendered at
`/workspace/Rackula/src/App.svelte:1766` as `<LoadDialog />`) can be shown via the dialogs
store.

2. Trigger the "Load Layout" dialog so the saved-layout list from
`src/lib/components/LoadDialog.svelte` is visible; each layout row is rendered by the
`.layout-item` container with `onkeydown={(e) => { if (e.key === "Enter" || e.key === " ")
{ e.preventDefault(); handleOpenLayout(item); } }}` (see lines 171–179 of the new hunk),
which opens the layout when Enter/Space is pressed anywhere within the row.

3. Tab keyboard focus onto a layout's delete button inside that row, which is rendered as
`<button class="delete-btn" ...>` with only an `onclick` handler that stops click
propagation and calls `handleDeleteLayout(item)` but has no `onkeydown` (lines 197–208 in
`src/lib/components/LoadDialog.svelte`).

4. Press Enter or Space: the `keydown` event originates on the delete button, bubbles to
the parent `.layout-item` `onkeydown` handler (because nothing on the button stops
propagation), `handleOpenLayout(item)` executes and opens the layout, while the delete
action is not triggered via keyboard; this reproduces the bug where keyboard activation of
the delete button opens the layout instead of deleting it. This differs from
`src/lib/components/StartScreen.svelte:240–252`, where the delete button includes its own
`onkeydown` handler that prevents propagation and calls `handleDeleteLayout(item)`,
confirming the intended pattern.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/components/LoadDialog.svelte
**Line:** 197:208
**Comment:**
	*Logic Error: When the delete button has keyboard focus, pressing Enter or Space will both trigger the delete action (via the button's default click behavior) and bubble a keydown event up to the parent `.layout-item`, which interprets Enter/Space as a request to open the layout; this means keyboard users will unintentionally open a layout while trying to delete it. Add a keydown handler on the delete button that prevents propagation and handles Enter/Space itself, matching the behavior already used on the StartScreen delete button.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 27, 2026

CodeAnt AI finished running the review.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/components/LoadDialog.svelte`:
- Around line 166-209: The outer interactive container currently uses
role="button" and nests a real <button>, which breaks accessibility; change the
outer clickable "layout" element into a real button element (e.g., use a <button
type="button"> with the existing classes, aria-disabled bound to !item.valid,
and replace the custom onkeydown Enter/Space handling with native button
behavior) and move the delete control out to be a sibling (keep the delete
button using handleDeleteLayout and e.stopPropagation() so it doesn't trigger
handleOpenLayout); preserve the deletingId, handleOpenLayout,
handleDeleteLayout, item.valid, item.id, and the existing aria-label/title and
disabled bindings on the delete button.

In `@src/lib/utils/archive.ts`:
- Around line 341-353: The loop that sums uncompressed sizes (using entries,
zip.files and file.async) should short-circuit as soon as totalUncompressedSize
exceeds LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES to avoid decompressing remaining
files; modify the loop in archive.ts where totalUncompressedSize is accumulated
to check after each bytes.byteLength addition (or before continuing) and
immediately throw the existing Error when the cap is crossed, so you stop
calling file.async on further entries and exit early.

In `@src/tests/load-pipeline.test.ts`:
- Around line 90-134: Replace the invalid TypeScript casts "as vi.Mock" with the
official Mock type from Vitest: import { Mock } from 'vitest' at the top of the
test file and change the casts to "as Mock" for the mocked calls (e.g.,
persistenceApi.loadSavedLayout, fileUtils.openFilePicker,
archive.extractFolderArchive and the other mocked references in the file);
update all five occurrences so the mocks compile under strict TypeScript.

In `@src/tests/LoadDialog.test.ts`:
- Around line 38-163: Replace the non-idiomatic type assertions "as vi.Mock"
with the exported Vitest type "as Mock" throughout the test file (affecting
usages like persistenceStore.isApiAvailable, persistenceApi.listSavedLayouts,
loadPipeline.loadFromApi, loadPipeline.loadFromFile, etc.), and add a top-level
import { Mock } from "vitest" so the type is available; update all ~10
occurrences to use "as Mock" to match Vitest 4.x typings.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e3f5a6e and eadcdec.

📒 Files selected for processing (7)
  • src/lib/components/LoadDialog.svelte
  • src/lib/utils/archive.ts
  • src/lib/utils/load-pipeline.ts
  • src/lib/utils/persistence-api.ts
  • src/tests/LoadDialog.test.ts
  • src/tests/archive-guardrails.test.ts
  • src/tests/load-pipeline.test.ts

Comment on lines 166 to 209
<div
class="layout-item"
class:invalid={!item.valid}
class:deleting={deletingId === item.id}
role="button"
tabindex={item.valid ? 0 : -1}
aria-disabled={!item.valid}
onclick={() => handleOpenLayout(item)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOpenLayout(item);
}
}}
>
<div class="layout-info">
<span class="layout-name">
{item.name}
{#if !item.valid}
<span class="error-badge" title="Corrupted file">!</span>
{/if}
</span>
<span class="layout-meta">
{#if item.valid}
{formatCounts(item)} - {formatDate(item.updatedAt)}
{:else}
<span class="error-text">File corrupted</span> -
{formatDate(item.updatedAt)}
{/if}
</span>
</div>
<button
class="delete-btn"
onclick={(e) => {
e.stopPropagation();
handleDeleteLayout(item);
}}
disabled={deletingId === item.id}
aria-label={`Delete layout ${item.name}`}
title="Delete layout"
>
<IconTrash size={16} />
</button>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

nesting button element inside role="button" WCAG WAI-ARIA accessibility

💡 Result:

Nesting a native interactive control (like a <button>) inside an element with role="button" is not conforming and is a common accessibility/UX failure.

  • ARIA-in-HTML rule (allowed descendants): An element with role="button" must follow the same descendant restrictions as a native <button>it must not contain interactive descendants (or descendants with tabindex). The spec gives <div role="button"><button>…</button></div> as an example of non-conforming descendants. [1]
  • HTML content model: A native <button>’s permitted content is “phrasing content” and must not include interactive content, reinforcing the “no interactive-inside-interactive” rule. [2]
  • Best practice: Prefer a single native control (usually a real <button>) instead of adding role="button" to a non-button element. [3]

What to do instead

  1. Make only one element the actual control (e.g., one <button> for the whole clickable area), and keep inner elements non-interactive; or
  2. If you need multiple actions inside a “card,” make the outer container non-interactive and keep separate inner buttons/links (don’t make the container role="button" / tabindex="0").

Sources: ARIA in HTML (W3C) [1], MDN <button> element reference [2], W3C “Notes on ARIA Use in HTML” [3].


🌐 Web query:

interactive element inside role="button" WAI-ARIA anti-pattern

💡 Result:

Putting an interactive element (e.g., <a>, <button>, <input>, anything focusable/clickable) inside an element with role="button" is a WAI‑ARIA/HTML anti‑pattern because:

  • role="button" has “presentational children” behavior: browsers/AT effectively treat descendants as role="presentation", so the nested control’s semantics (role/name) may be suppressed in the accessibility tree. The inner control may no longer be exposed/announced as a real link/button/etc. [1] [2]
  • If a descendant remains focusable (e.g., it has tabindex="0" or is inherently focusable like <a href>), focus can land on something that has no corresponding accessible node, breaking “Name, Role, Value” expectations and leading to confusing/incorrect SR output. [3]
  • For native <button>, HTML conformance rules also prohibit interactive content descendants (and descendants with tabindex) for similar reasons (nested controls lead to unpredictable behavior). [4]

Preferred fix: don’t nest interactive controls. Make one element the button, and keep any other controls as separate sibling elements (or redesign so only one action is interactive). [3] [4]

Sources:
[1] MDN – ARIA button role (“All descendants are presentational”) https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/button_role
[2] W3C WAI‑ARIA Authoring Practices 1.1 – roles whose descendants are automatically presentational (includes button) https://www.w3.org/TR/2017/NOTE-wai-aria-practices-1.1-20171214/
[3] W3C ACT rule – presentational-children roles + focusable descendants cause missing name/role in accessibility tree https://www.w3.org/WAI/standards-guidelines/act/rules/307n5z/
[4] W3C HTML reference – nesting constraints for button (e.g., button not descendant of button; interactive nesting constraints) https://w3c.github.io/html-reference/spec.html


🌐 Web query:

WCAG guidelines multiple interactive controls same row layout

💡 Result:

WCAG doesn’t prohibit placing multiple interactive controls in the same row (e.g., a horizontal button group/toolbar). What matters is that the row still meets the relevant success criteria:

  • Adequate hit area / spacing between adjacent controls (especially in dense rows): Meet SC 2.5.8 Target Size (Minimum) (AA, WCAG 2.2) by making each target at least 24×24 CSS px, or ensuring sufficient spacing so small targets aren’t easy to tap/click by mistake. [1]
  • Keyboard tab order matches a logical/expected sequence: Ensure SC 2.4.3 Focus Order (A)—focus should move through the controls in an order that preserves meaning and usability (typically left-to-right, top-to-bottom to match the UI). [2]
  • Programmatic order/reading order remains meaningful (don’t “fake” the row with CSS reordering): If sequence affects meaning, meet SC 1.3.2 Meaningful Sequence (A) by keeping DOM order meaningful (be careful with CSS order, reversing flex rows, absolute positioning, etc.). [3]
  • Clear labels/names for each control (especially icon-only buttons in a row): Meet SC 4.1.2 Name, Role, Value (A) (use native controls where possible; ensure each control has a programmatic name via text/aria-label/aria-labelledby). [4]
  • If the row is a related set of form controls, group them semantically (not just visually): Use fieldset/legend to convey relationships for grouped options (technique H71, supports SC 1.3.1 Info and Relationships (A)). [5]

Rule of thumb: A “same-row” layout is fine if (1) targets aren’t too small/close together, (2) focus/reading order is predictable, and (3) relationships and labels are conveyed in code—not only by visual proximity.

Sources: [1] [2] [3] [4] [5]


Avoid nesting interactive controls inside role="button".

A role="button" container with a nested <button> violates WCAG and WAI-ARIA conformance: the inner button's semantics may be suppressed in the accessibility tree, and nested focusable elements can break keyboard navigation and screen reader announcements.

Refactor into separate sibling controls: convert the outer container to a clickable button (or remove role="button" entirely), and keep the delete button as an independent sibling. This maintains accessibility and keyboard behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/components/LoadDialog.svelte` around lines 166 - 209, The outer
interactive container currently uses role="button" and nests a real <button>,
which breaks accessibility; change the outer clickable "layout" element into a
real button element (e.g., use a <button type="button"> with the existing
classes, aria-disabled bound to !item.valid, and replace the custom onkeydown
Enter/Space handling with native button behavior) and move the delete control
out to be a sibling (keep the delete button using handleDeleteLayout and
e.stopPropagation() so it doesn't trigger handleOpenLayout); preserve the
deletingId, handleOpenLayout, handleDeleteLayout, item.valid, item.id, and the
existing aria-label/title and disabled bindings on the delete button.

Comment on lines 38 to 163
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
});

afterEach(() => {
dialogStore.close();
});

it("shows loading state while fetching layouts", async () => {
let resolveLayouts: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolveLayouts = resolve;
});
(persistenceApi.listSavedLayouts as vi.Mock).mockReturnValue(pendingPromise);

dialogStore.open("load");
render(LoadDialog);
await tick();

// Initial state should show loading
expect(screen.getByText(/Loading saved layouts/i)).toBeInTheDocument();
expect(screen.getByTestId("spinner-loader")).toBeInTheDocument();

// Resolve promise and loading should disappear
resolveLayouts!([]);
await waitFor(() => {
expect(screen.queryByText(/Loading saved layouts/i)).not.toBeInTheDocument();
});
});

it("lists saved layouts from the API when available", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);

dialogStore.open("load");
render(LoadDialog);

expect(await screen.findByText("Test Layout 1")).toBeInTheDocument();
expect(screen.getByText(/1 rack, 5 devices/i)).toBeInTheDocument();
});

it("calls loadFromApi and closes when a layout is clicked", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);
(loadPipeline.loadFromApi as vi.Mock).mockResolvedValue(true);

dialogStore.open("load");
render(LoadDialog);

const layoutItem = await screen.findByText("Test Layout 1");
await fireEvent.click(layoutItem);

expect(loadPipeline.loadFromApi).toHaveBeenCalledWith("uuid-1");
expect(dialogStore.isOpen("load")).toBe(false);
});

it("calls loadFromFile and closes when import button is clicked", async () => {
(loadPipeline.loadFromFile as vi.Mock).mockResolvedValue(true);

dialogStore.open("load");
render(LoadDialog);

const importBtn = screen.getByText(/Import from local file/i);
await fireEvent.click(importBtn);

expect(loadPipeline.loadFromFile).toHaveBeenCalled();
expect(dialogStore.isOpen("load")).toBe(false);
});

it("fetches layouts when API becomes available after initial render", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-delayed",
name: "Delayed Layout",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 2,
deviceCount: 10,
valid: true,
},
];
// Start with API unavailable
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(false);
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);

dialogStore.open("load");
render(LoadDialog);
await tick();

// API not available yet — listSavedLayouts should not be called
expect(persistenceApi.listSavedLayouts).not.toHaveBeenCalled();

// Simulate API becoming available
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
// Trigger reactivity by re-rendering (the $effect watches apiActive which is $derived)
// In real app, the persistence store would trigger this. In test, we need to
// re-render since mocked isApiAvailable isn't reactive.
dialogStore.close();
dialogStore.open("load");
render(LoadDialog);

expect(await screen.findByText("Delayed Layout")).toBeInTheDocument();
});

it("shows error state when API fails to list layouts", async () => {
(persistenceApi.listSavedLayouts as vi.Mock).mockRejectedValue(
new persistenceApi.PersistenceError("Server error"),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Vitest 4.x Mock type TypeScript strict mode vi.Mock vs Mock imported

💡 Result:

In Vitest 4.x, the “mock function” type you want in TypeScript strict mode is the exported type from vitest (e.g. Mock, MockInstance, etc.), not something like vi.Mock.

1) vi is a runtime helper; the mock types are exported from vitest

vi.fn() returns a typed mock function, and Vitest’s API docs show the mock methods returning Mock<T> (a type). For example, mockImplementation(fn: T): Mock<T>. (vitest.dev)

So for typing, do this:

import { vi, type Mock } from 'vitest'

type FetchUser = (id: string) => Promise<{ id: string }>

const fetchUser: Mock<FetchUser> = vi.fn()

2) Prefer vi.mocked(...) to “convert” an imported function to a typed mock

When you mock/spy module exports, the import’s static type often stays as the original function type. Vitest’s guide shows using vi.mocked(exampleObject.answer) to get a properly-typed mocked handle you can call mock methods on. (vitest.dev)

import * as api from './api'
import { vi } from 'vitest'

vi.mock('./api', { spy: true })

vi.mocked(api.getUser).mockResolvedValue({ id: '1' })

3) vi.Mock vs Mock (imported)

If you see examples using vi.Mock, that’s either:

  • mixing up value namespace (vi) with type exports, or
  • relying on global/namespace typing that’s brittle under strict TS settings.

In Vitest 4.x, the stable/idiomatic approach is: import mock-related types from vitest (like Mock) and use vi.mocked() to get strict, correct typing. (vitest.dev)

Citations:


🏁 Script executed:

# Check if the test file exists and examine its imports and usage
fd LoadDialog.test.ts --type f

Repository: RackulaLives/Rackula

Length of output: 92


🏁 Script executed:

# Read the test file to see imports and current vi.Mock usage
head -40 src/tests/LoadDialog.test.ts

Repository: RackulaLives/Rackula

Length of output: 1342


🏁 Script executed:

# Check for tsconfig.json to confirm strict mode
find . -name "tsconfig.json" -o -name "tsconfig.test.json" | head -5

Repository: RackulaLives/Rackula

Length of output: 99


🏁 Script executed:

# Check tsconfig.json for strict mode
cat tsconfig.json | head -20

Repository: RackulaLives/Rackula

Length of output: 572


🏁 Script executed:

# Count all vi.Mock usages in LoadDialog.test.ts
rg "as vi\.Mock" src/tests/LoadDialog.test.ts -n

Repository: RackulaLives/Rackula

Length of output: 845


🏁 Script executed:

# Get full line count to verify scope
wc -l src/tests/LoadDialog.test.ts

Repository: RackulaLives/Rackula

Length of output: 96


Use Vitest's exported Mock type instead of vi.Mock.

Vitest 4.x exports Mock and MockInstance types directly from the vitest package. Using as vi.Mock is not the idiomatic pattern and doesn't align with Vitest's official API. There are 10 instances of this pattern in the file.

💡 Proposed fix
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
...
-    (persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
+    (persistenceStore.isApiAvailable as Mock).mockReturnValue(true);
-    (persistenceApi.listSavedLayouts as vi.Mock).mockReturnValue(pendingPromise);
+    (persistenceApi.listSavedLayouts as Mock).mockReturnValue(pendingPromise);

Replace all 10 instances of as vi.Mock with as Mock throughout the file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
});
afterEach(() => {
dialogStore.close();
});
it("shows loading state while fetching layouts", async () => {
let resolveLayouts: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolveLayouts = resolve;
});
(persistenceApi.listSavedLayouts as vi.Mock).mockReturnValue(pendingPromise);
dialogStore.open("load");
render(LoadDialog);
await tick();
// Initial state should show loading
expect(screen.getByText(/Loading saved layouts/i)).toBeInTheDocument();
expect(screen.getByTestId("spinner-loader")).toBeInTheDocument();
// Resolve promise and loading should disappear
resolveLayouts!([]);
await waitFor(() => {
expect(screen.queryByText(/Loading saved layouts/i)).not.toBeInTheDocument();
});
});
it("lists saved layouts from the API when available", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);
dialogStore.open("load");
render(LoadDialog);
expect(await screen.findByText("Test Layout 1")).toBeInTheDocument();
expect(screen.getByText(/1 rack, 5 devices/i)).toBeInTheDocument();
});
it("calls loadFromApi and closes when a layout is clicked", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);
(loadPipeline.loadFromApi as vi.Mock).mockResolvedValue(true);
dialogStore.open("load");
render(LoadDialog);
const layoutItem = await screen.findByText("Test Layout 1");
await fireEvent.click(layoutItem);
expect(loadPipeline.loadFromApi).toHaveBeenCalledWith("uuid-1");
expect(dialogStore.isOpen("load")).toBe(false);
});
it("calls loadFromFile and closes when import button is clicked", async () => {
(loadPipeline.loadFromFile as vi.Mock).mockResolvedValue(true);
dialogStore.open("load");
render(LoadDialog);
const importBtn = screen.getByText(/Import from local file/i);
await fireEvent.click(importBtn);
expect(loadPipeline.loadFromFile).toHaveBeenCalled();
expect(dialogStore.isOpen("load")).toBe(false);
});
it("fetches layouts when API becomes available after initial render", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-delayed",
name: "Delayed Layout",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 2,
deviceCount: 10,
valid: true,
},
];
// Start with API unavailable
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(false);
(persistenceApi.listSavedLayouts as vi.Mock).mockResolvedValue(mockLayouts);
dialogStore.open("load");
render(LoadDialog);
await tick();
// API not available yet — listSavedLayouts should not be called
expect(persistenceApi.listSavedLayouts).not.toHaveBeenCalled();
// Simulate API becoming available
(persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
// Trigger reactivity by re-rendering (the $effect watches apiActive which is $derived)
// In real app, the persistence store would trigger this. In test, we need to
// re-render since mocked isApiAvailable isn't reactive.
dialogStore.close();
dialogStore.open("load");
render(LoadDialog);
expect(await screen.findByText("Delayed Layout")).toBeInTheDocument();
});
it("shows error state when API fails to list layouts", async () => {
(persistenceApi.listSavedLayouts as vi.Mock).mockRejectedValue(
new persistenceApi.PersistenceError("Server error"),
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import LoadDialog from "$lib/components/dialogs/LoadDialog.svelte";
import * as persistenceApi from "$lib/api/persistence";
import * as loadPipeline from "$lib/pipeline/loadPipeline";
import { dialogStore } from "$lib/stores/dialog";
import { persistenceStore } from "$lib/stores/persistence";
// ... other test setup code ...
beforeEach(() => {
vi.clearAllMocks();
(persistenceStore.isApiAvailable as Mock).mockReturnValue(true);
});
afterEach(() => {
dialogStore.close();
});
it("shows loading state while fetching layouts", async () => {
let resolveLayouts: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolveLayouts = resolve;
});
(persistenceApi.listSavedLayouts as Mock).mockReturnValue(pendingPromise);
dialogStore.open("load");
render(LoadDialog);
await tick();
// Initial state should show loading
expect(screen.getByText(/Loading saved layouts/i)).toBeInTheDocument();
expect(screen.getByTestId("spinner-loader")).toBeInTheDocument();
// Resolve promise and loading should disappear
resolveLayouts!([]);
await waitFor(() => {
expect(screen.queryByText(/Loading saved layouts/i)).not.toBeInTheDocument();
});
});
it("lists saved layouts from the API when available", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as Mock).mockResolvedValue(mockLayouts);
dialogStore.open("load");
render(LoadDialog);
expect(await screen.findByText("Test Layout 1")).toBeInTheDocument();
expect(screen.getByText(/1 rack, 5 devices/i)).toBeInTheDocument();
});
it("calls loadFromApi and closes when a layout is clicked", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-1",
name: "Test Layout 1",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 1,
deviceCount: 5,
valid: true,
},
];
(persistenceApi.listSavedLayouts as Mock).mockResolvedValue(mockLayouts);
(loadPipeline.loadFromApi as Mock).mockResolvedValue(true);
dialogStore.open("load");
render(LoadDialog);
const layoutItem = await screen.findByText("Test Layout 1");
await fireEvent.click(layoutItem);
expect(loadPipeline.loadFromApi).toHaveBeenCalledWith("uuid-1");
expect(dialogStore.isOpen("load")).toBe(false);
});
it("calls loadFromFile and closes when import button is clicked", async () => {
(loadPipeline.loadFromFile as Mock).mockResolvedValue(true);
dialogStore.open("load");
render(LoadDialog);
const importBtn = screen.getByText(/Import from local file/i);
await fireEvent.click(importBtn);
expect(loadPipeline.loadFromFile).toHaveBeenCalled();
expect(dialogStore.isOpen("load")).toBe(false);
});
it("fetches layouts when API becomes available after initial render", async () => {
const mockLayouts: persistenceApi.SavedLayoutItem[] = [
{
id: "uuid-delayed",
name: "Delayed Layout",
version: "1.0",
updatedAt: new Date().toISOString(),
rackCount: 2,
deviceCount: 10,
valid: true,
},
];
// Start with API unavailable
(persistenceStore.isApiAvailable as Mock).mockReturnValue(false);
(persistenceApi.listSavedLayouts as Mock).mockResolvedValue(mockLayouts);
dialogStore.open("load");
render(LoadDialog);
await tick();
// API not available yet — listSavedLayouts should not be called
expect(persistenceApi.listSavedLayouts).not.toHaveBeenCalled();
// Simulate API becoming available
(persistenceStore.isApiAvailable as Mock).mockReturnValue(true);
// Trigger reactivity by re-rendering (the $effect watches apiActive which is $derived)
// In real app, the persistence store would trigger this. In test, we need to
// re-render since mocked isApiAvailable isn't reactive.
dialogStore.close();
dialogStore.open("load");
render(LoadDialog);
expect(await screen.findByText("Delayed Layout")).toBeInTheDocument();
});
it("shows error state when API fails to list layouts", async () => {
(persistenceApi.listSavedLayouts as Mock).mockRejectedValue(
new persistenceApi.PersistenceError("Server error"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/LoadDialog.test.ts` around lines 38 - 163, Replace the
non-idiomatic type assertions "as vi.Mock" with the exported Vitest type "as
Mock" throughout the test file (affecting usages like
persistenceStore.isApiAvailable, persistenceApi.listSavedLayouts,
loadPipeline.loadFromApi, loadPipeline.loadFromFile, etc.), and add a top-level
import { Mock } from "vitest" so the type is available; update all ~10
occurrences to use "as Mock" to match Vitest 4.x typings.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
src/tests/LoadDialog.test.ts (1)

38-50: ⚠️ Potential issue | 🟠 Major

Replace vi.Mock casts with Vitest’s typed mock APIs.

Line 38 and related lines rely on as vi.Mock; this is brittle under strict typing and should be replaced with type Mock (or vi.mocked(...)) across the file.

💡 Proposed fix
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
...
-    (persistenceStore.isApiAvailable as vi.Mock).mockReturnValue(true);
+    (persistenceStore.isApiAvailable as Mock).mockReturnValue(true);
...
-    (persistenceApi.listSavedLayouts as vi.Mock).mockReturnValue(pendingPromise);
+    (persistenceApi.listSavedLayouts as Mock).mockReturnValue(pendingPromise);

Apply the same replacement for all as vi.Mock occurrences in this file.

#!/bin/bash
# Verify all remaining non-idiomatic Vitest casts in this file
rg -n 'as vi\.Mock' src/tests/LoadDialog.test.ts

Also applies to: 79-102, 139-140, 150-150, 162-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/LoadDialog.test.ts` around lines 38 - 50, Replace all brittle "as
vi.Mock" casts in this test file with Vitest's typed mock helpers: use
vi.mocked(...) or the Mock<T> type. For each mocked function such as
persistenceStore.isApiAvailable and persistenceApi.listSavedLayouts (and other
occurrences around lines mentioned), swap (fn as vi.Mock) to either
vi.mocked(fn) when asserting call behavior or declare the mock with Mock<typeof
moduleFn> / Mock<FnSignature> and use that typed mock for mockReturnValue /
mockResolvedValue. Make sure to update every occurrence (including the other
blocks noted) so all mockReturnValue / mockResolvedValue calls use the typed
mock helper instead of "as vi.Mock".
src/lib/utils/archive.ts (1)

341-353: ⚠️ Potential issue | 🟠 Major

Short-circuit the uncompressed-size loop once the cap is exceeded.

At Line 341 onward, the code keeps decompressing entries even after crossing MAX_TOTAL_UNCOMPRESSED_BYTES, which increases CPU work on hostile archives.

💡 Proposed fix
   let totalUncompressedSize = 0;
   for (const name of entries) {
     const file = zip.files[name];
     if (!file || file.dir) continue;
     const bytes = await file.async("uint8array");
     totalUncompressedSize += bytes.byteLength;
+    if (totalUncompressedSize > LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES) {
+      throw new Error(
+        `Archive uncompressed size is too large (${Math.round(totalUncompressedSize / 1024 / 1024)}MB).`,
+      );
+    }
   }
-
-  if (totalUncompressedSize > LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES) {
-    throw new Error(
-      `Archive uncompressed size is too large (${Math.round(totalUncompressedSize / 1024 / 1024)}MB).`,
-    );
-  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/utils/archive.ts` around lines 341 - 353, The loop that sums
totalUncompressedSize is decompressing every entry even after exceeding
LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES; change the loop that iterates over entries
(using zip.files[name] and file.async(...)) to check after adding each file's
byteLength whether totalUncompressedSize > LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES
and if so immediately stop further processing (break out and throw the same
error) to short-circuit work and prevent decompressing remaining entries.
src/tests/load-pipeline.test.ts (1)

90-90: ⚠️ Potential issue | 🟠 Major

Use Vitest Mock typing instead of vi.Mock casts.

The as vi.Mock assertions at Line 90 and related lines should be migrated to type Mock (or vi.mocked) to keep strict TypeScript compatibility.

💡 Proposed fix
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
...
-      (persistenceApi.loadSavedLayout as vi.Mock).mockResolvedValue(layout);
+      (persistenceApi.loadSavedLayout as Mock).mockResolvedValue(layout);

Repeat for every as vi.Mock in this file.

#!/bin/bash
# Verify all remaining non-idiomatic Vitest casts in this file
rg -n 'as vi\.Mock' src/tests/load-pipeline.test.ts

Also applies to: 100-100, 118-119, 134-134

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests/load-pipeline.test.ts` at line 90, Replace the non-idiomatic
TypeScript casts using "as vi.Mock" in this test file with proper Vitest
typings: use the Mock generic type or vi.mocked helpers (for example replace
"(persistenceApi.loadSavedLayout as vi.Mock).mockResolvedValue(layout)" by
typing the mocked function via Mock or using
vi.mocked(persistenceApi.loadSavedLayout).mockResolvedValue) and apply the same
change to all occurrences (e.g., persistenceApi.loadSavedLayout,
persistenceApi.deleteLayout, persistenceApi.saveLayout, and other mocks
referenced around lines 100, 118–119, 134). Ensure imports/types for Mock or
vi.mocked are available and update each mock invocation to use the typed mock
methods instead of the "as vi.Mock" cast.
src/lib/components/LoadDialog.svelte (1)

166-209: ⚠️ Potential issue | 🟠 Major

Remove nested interactive controls in layout rows.

Line 170 (role="button" container) wrapping Line 197 (<button>) creates an interactive-in-interactive accessibility conflict and can produce incorrect keyboard behavior.

💡 Proposed fix (structure)
-<div class="layout-item" role="button" tabindex={...} onclick={...} onkeydown={...}>
-  <div class="layout-info">...</div>
-  <button class="delete-btn" ...>...</button>
-</div>
+<div class="layout-item-row">
+  <button
+    type="button"
+    class="layout-item"
+    class:invalid={!item.valid}
+    class:deleting={deletingId === item.id}
+    aria-disabled={!item.valid}
+    onclick={() => handleOpenLayout(item)}
+  >
+    <div class="layout-info">...</div>
+  </button>
+  <button class="delete-btn" ...>...</button>
+</div>

Keep the delete action as a sibling control (not a descendant of the open-layout control).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/components/LoadDialog.svelte` around lines 166 - 209, The layout row
in LoadDialog.svelte currently nests the delete <button> inside the div that has
role="button" and onclick calling handleOpenLayout(item), causing an
interactive-in-interactive accessibility conflict; fix this by moving the delete
control out of the role="button" container so the delete button becomes a
sibling element (preserving class="delete-btn", disabled={deletingId ===
item.id}, aria-label and title) and keep its onclick handler that calls
e.stopPropagation() and handleDeleteLayout(item); ensure the clickable row still
uses role="button", tabindex logic and the onkeydown handler that calls
handleOpenLayout(item) remain on the row container so opening and deletion are
distinct accessible controls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/lib/components/LoadDialog.svelte`:
- Around line 166-209: The layout row in LoadDialog.svelte currently nests the
delete <button> inside the div that has role="button" and onclick calling
handleOpenLayout(item), causing an interactive-in-interactive accessibility
conflict; fix this by moving the delete control out of the role="button"
container so the delete button becomes a sibling element (preserving
class="delete-btn", disabled={deletingId === item.id}, aria-label and title) and
keep its onclick handler that calls e.stopPropagation() and
handleDeleteLayout(item); ensure the clickable row still uses role="button",
tabindex logic and the onkeydown handler that calls handleOpenLayout(item)
remain on the row container so opening and deletion are distinct accessible
controls.

In `@src/lib/utils/archive.ts`:
- Around line 341-353: The loop that sums totalUncompressedSize is decompressing
every entry even after exceeding LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES; change the
loop that iterates over entries (using zip.files[name] and file.async(...)) to
check after adding each file's byteLength whether totalUncompressedSize >
LIMITS.MAX_TOTAL_UNCOMPRESSED_BYTES and if so immediately stop further
processing (break out and throw the same error) to short-circuit work and
prevent decompressing remaining entries.

In `@src/tests/load-pipeline.test.ts`:
- Line 90: Replace the non-idiomatic TypeScript casts using "as vi.Mock" in this
test file with proper Vitest typings: use the Mock generic type or vi.mocked
helpers (for example replace "(persistenceApi.loadSavedLayout as
vi.Mock).mockResolvedValue(layout)" by typing the mocked function via Mock or
using vi.mocked(persistenceApi.loadSavedLayout).mockResolvedValue) and apply the
same change to all occurrences (e.g., persistenceApi.loadSavedLayout,
persistenceApi.deleteLayout, persistenceApi.saveLayout, and other mocks
referenced around lines 100, 118–119, 134). Ensure imports/types for Mock or
vi.mocked are available and update each mock invocation to use the typed mock
methods instead of the "as vi.Mock" cast.

In `@src/tests/LoadDialog.test.ts`:
- Around line 38-50: Replace all brittle "as vi.Mock" casts in this test file
with Vitest's typed mock helpers: use vi.mocked(...) or the Mock<T> type. For
each mocked function such as persistenceStore.isApiAvailable and
persistenceApi.listSavedLayouts (and other occurrences around lines mentioned),
swap (fn as vi.Mock) to either vi.mocked(fn) when asserting call behavior or
declare the mock with Mock<typeof moduleFn> / Mock<FnSignature> and use that
typed mock for mockReturnValue / mockResolvedValue. Make sure to update every
occurrence (including the other blocks noted) so all mockReturnValue /
mockResolvedValue calls use the typed mock helper instead of "as vi.Mock".

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e3f5a6e and eadcdec.

📒 Files selected for processing (7)
  • src/lib/components/LoadDialog.svelte
  • src/lib/utils/archive.ts
  • src/lib/utils/load-pipeline.ts
  • src/lib/utils/persistence-api.ts
  • src/tests/LoadDialog.test.ts
  • src/tests/archive-guardrails.test.ts
  • src/tests/load-pipeline.test.ts

- Restructure LoadDialog layout rows to fix interactive-in-interactive
  a11y violation (button nested inside role="button")
- Early break in archive decompression loop when size limit exceeded
- Replace non-idiomatic `as vi.Mock` casts with `vi.mocked()` in
  load-pipeline and LoadDialog test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 28, 2026

CodeAnt AI is running Incremental review


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels Feb 28, 2026
@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 28, 2026

Sequence Diagram

Shows the unified success-path when a user chooses to load a layout from the server or import a local .zip. The LoadDialog delegates to the centralized load-pipeline which obtains layout+images (from API or archive), updates stores (images + layout) and shows the final toast.

sequenceDiagram
    participant User
    participant LoadDialog
    participant LoadPipeline
    participant BackendOrArchive as "Persist API / Archive"
    participant Stores as "Layout & Image Stores"
    participant Toast

    User->>LoadDialog: Open dialog & choose "Saved" or "Import"
    alt Saved on server
        LoadDialog->>LoadPipeline: loadFromApi(uuid)
    else Local file
        LoadDialog->>LoadPipeline: loadFromFile(file?)
    end
    LoadPipeline->>BackendOrArchive: Fetch layout (+ images) (API or extract ZIP)
    BackendOrArchive-->>LoadPipeline: layout + images (or error)
    LoadPipeline->>Stores: Clear/reload bundled images → overlay custom images → load layout
    LoadPipeline->>Toast: Show success or warning (failed images)
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Contributor

codeant-ai bot commented Feb 28, 2026

CodeAnt AI Incremental review completed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/components/LoadDialog.svelte`:
- Around line 109-118: The formatDate function should be made locale-aware for
time format preference; change formatDate to use toLocaleString (or
Intl.DateTimeFormat) and explicitly pass the user's hour12 preference obtained
from Intl.DateTimeFormat().resolvedOptions().hour12 so the formatted string
respects 12/24-hour settings across browsers—update the function formatDate to
compute const hour12 = new Intl.DateTimeFormat().resolvedOptions().hour12 and
include hour12 in the options passed to toLocaleString/Intl.DateTimeFormat while
keeping the existing year/month/day/hour/minute fields.

In `@src/tests/load-pipeline.test.ts`:
- Around line 23-31: The mock PersistenceError in the vi.mock block lacks the
optional statusCode parameter present in the real class; update the mock
PersistenceError constructor to accept an optional statusCode?: number and
assign it to this.statusCode (while still calling super(message) and setting
this.name = "PersistenceError") so the mock API matches the real
PersistenceError used elsewhere (also verify the loadSavedLayout mock remains
unchanged).

In `@src/tests/LoadDialog.test.ts`:
- Around line 126-159: The test currently calls render(LoadDialog) twice which
can leave multiple component instances in the DOM; change the second render to
use the original render result (e.g., const { rerender, unmount } =
render(LoadDialog)) and either call rerender(LoadDialog) after toggling
dialogStore or call unmount() before calling render again, while keeping the
same mocked persistenceStore.isApiAvailable toggle and awaiting tick() as
needed; update references in this test to use rerender/unmount instead of a
second render and keep assertions against persistenceApi.listSavedLayouts and
screen.findByText("Delayed Layout").

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eadcdec and fc28ce1.

📒 Files selected for processing (4)
  • src/lib/components/LoadDialog.svelte
  • src/lib/utils/archive.ts
  • src/tests/LoadDialog.test.ts
  • src/tests/load-pipeline.test.ts

- Make formatDate locale-aware by passing hour12 from Intl.DateTimeFormat
- Add missing statusCode parameter to mock PersistenceError in both test files
- Fix double render in LoadDialog delayed API test by unmounting first instance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/components/LoadDialog.svelte`:
- Around line 408-410: The CSS rule :global(.spinner) is unused in
LoadDialog.svelte because the component uses the .spinner-loader class; remove
the dead :global(.spinner) block or replace it with :global(.spinner-loader) so
the animation applies to the actual element, updating the style rule to match
the .spinner-loader class used in the component.

In `@src/tests/load-pipeline.test.ts`:
- Around line 115-143: Add a failing-extraction test for loadFromFile: mock
fileUtils.openFilePicker to resolve a File, mock archive.extractFolderArchive to
reject with an Error (e.g., "Invalid archive format"), call loadFromFile, assert
it returns false, and assert toastStore.toasts includes an error toast (use
expect.objectContaining({ type: "error" })); reference loadFromFile,
fileUtils.openFilePicker, archive.extractFolderArchive, and toastStore.toasts
when locating where to add the new test.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fc28ce1 and 9866f32.

📒 Files selected for processing (3)
  • src/lib/components/LoadDialog.svelte
  • src/tests/LoadDialog.test.ts
  • src/tests/load-pipeline.test.ts

- Remove dead :global(.spinner) CSS rule (component uses .spinner-loader)
- Add missing test for loadFromFile extraction failure error path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/components/LoadDialog.svelte`:
- Around line 16-56: The debug call in loadLayouts uses persistenceDebug.api but
the project standard requires the layout-specific debugger; update the import at
the top of LoadDialog.svelte to import { layoutDebug } from "$lib/utils/debug"
instead of persistenceDebug, and replace the usage in loadLayouts (and any other
occurrences in this file) from persistenceDebug.api(...) to layoutDebug.api(...)
so the logging follows the established namespace pattern.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9866f32 and ec3e704.

📒 Files selected for processing (2)
  • src/lib/components/LoadDialog.svelte
  • src/tests/load-pipeline.test.ts

@ggfevans ggfevans merged commit 53a7f7e into main Feb 28, 2026
10 checks passed
@ggfevans ggfevans deleted the issue-1348 branch February 28, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: file menu Load cannot access Persist API layouts after initial load

1 participant