Dashboard: missing async feedback and wrong empty-state copy #38

Closed
opened 2026-04-29 11:15:29 +00:00 by salmaelsoly · 3 comments
Member

Summary

A cluster of small dashboard UX issues, all about missing async feedback or wrong empty-state copy. None of them block functionality, but each one makes the dashboard feel like it's not responding.

Issues

1. Create Deck OK button has no loading state

The OK button awaits rpc('deck.create', ...) (dashboard.js:4208) for ~2-3s with no spinner or disabled state. Mouse-clicking feels non-responsive; pressing Enter feels fine because there's no expectation of immediate visual feedback.

Fix: disable the button and replace its label with a spinner before the await; restore on completion or error.

2. Slides tab shows "Select a deck to view slides" when a deck IS selected and just has 0 slides

templates/index.html:152 hardcodes a single empty-state message. The render condition at dashboard.js:2030-2033 only branches on slides.length === 0 and never checks whether a deck is actually selected.

Fix: branch on selectedDeckPath:

  • no deck selected → "Select a deck from the list to view its slides"
  • deck selected, empty → "No slides yet — click Create Slide to add one"

3. Lightbox shows black screen ~2 seconds on first open

dashboard.js:3107 sets <img>.src after the modal is already visible. The browser then does its own image load while the user stares at black.

Fix: show a skeleton/spinner placeholder until img.complete (or onload) fires. Optionally, preload thumbnails by setting loading="eager" on the lightbox-target.

4. Thumbnail flash to black after Create Slide

After modal close, dashboard.js:1932 calls loadSlidesForDeck(), which round-trips rpc('deck.get', ...) and rerenders the entire slides grid via renderSlides() (:506-530). All existing thumbnails are torn down and re-created, which causes a ~3-second flash to black.

Fix: append the new slide card to the DOM directly instead of rerendering the grid. Or: keep the rerender but cache thumbnail src so the browser doesn't refetch.

Severity

Medium individually, low collectively. None of these break a workflow, but they degrade the perceived responsiveness of the dashboard.

## Summary A cluster of small dashboard UX issues, all about missing async feedback or wrong empty-state copy. None of them block functionality, but each one makes the dashboard feel like it's not responding. ## Issues ### 1. Create Deck OK button has no loading state The OK button awaits `rpc('deck.create', ...)` ([dashboard.js:4208](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/static/js/dashboard.js#L4208)) for ~2-3s with no spinner or disabled state. Mouse-clicking feels non-responsive; pressing Enter feels fine because there's no expectation of immediate visual feedback. **Fix:** disable the button and replace its label with a spinner before the await; restore on completion or error. ### 2. Slides tab shows "Select a deck to view slides" when a deck IS selected and just has 0 slides [templates/index.html:152](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/templates/index.html#L152) hardcodes a single empty-state message. The render condition at [dashboard.js:2030-2033](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/static/js/dashboard.js#L2030-L2033) only branches on `slides.length === 0` and never checks whether a deck is actually selected. **Fix:** branch on `selectedDeckPath`: - no deck selected → "Select a deck from the list to view its slides" - deck selected, empty → "No slides yet — click Create Slide to add one" ### 3. Lightbox shows black screen ~2 seconds on first open [dashboard.js:3107](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/static/js/dashboard.js#L3107) sets `<img>.src` after the modal is already visible. The browser then does its own image load while the user stares at black. **Fix:** show a skeleton/spinner placeholder until `img.complete` (or `onload`) fires. Optionally, preload thumbnails by setting `loading="eager"` on the lightbox-target. ### 4. Thumbnail flash to black after Create Slide After modal close, [dashboard.js:1932](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/static/js/dashboard.js#L1932) calls `loadSlidesForDeck()`, which round-trips `rpc('deck.get', ...)` and rerenders the entire slides grid via `renderSlides()` ([:506-530](https://forge.ourworld.tf/lhumina_code/hero_slides/src/branch/development/crates/hero_slides_ui/static/js/dashboard.js#L506-L530)). All existing thumbnails are torn down and re-created, which causes a ~3-second flash to black. **Fix:** append the new slide card to the DOM directly instead of rerendering the grid. Or: keep the rerender but cache thumbnail `src` so the browser doesn't refetch. ## Severity Medium individually, low collectively. None of these break a workflow, but they degrade the perceived responsiveness of the dashboard.
Author
Member

Implementation Spec for Issue #38

Objective

Fix four small dashboard UX defects: missing async feedback on Create Deck, wrong empty-state copy when a deck is selected with zero slides, black flash when opening the lightbox the first time, and a thumbnail flash to black after Create Slide. All changes are confined to dashboard.js, index.html, and dashboard.css — no Rust/server work, no new dependencies.

Scope decisions

  • In scope: the four issues exactly as filed.
  • Out of scope (intentional tradeoffs):
    • Spinner-on-OK is added only inside showPrompt (the shared prompt-modal flow). Issue #38 calls out Create Deck specifically, but showPrompt is also used by Create Folder, New Subfolder, Insert Slide, Move Slide, Rename Deck, and Duplicate Deck. Fixing it once in showPrompt gives Create Deck the spinner the issue asks for and incidentally fixes the same UX for the six other prompts at zero extra risk. The dedicated create-slide-modal already has its own loading state in doCreateSlide (lines 1899, 1946) — leave it alone.
    • The lightbox spinner overlay relies on <img>.complete/onload/onerror; no preloading, no skeleton box, no image-cache layer. Minimal change.
    • For the post-Create-Slide flash, only the Create-Slide path (doCreateSlide at dashboard.js:1932) is changed. The other ~25 callers of loadSlidesForDeck (delete, move, hide/unhide, drag/drop, deck switch, route navigation, generate completion, etc.) keep the full reload — for those a refresh is the correct behaviour (server-side state genuinely changed and renumbering may apply).
    • The Create-Slide append path is only safe when position is at the end of the deck (the current code path always appends — position = (currentSlides?.length || 0) + 1 at dashboard.js:1886). If position ever changes to mid-deck, fall back to loadSlidesForDeck() because renumbering is needed.

Files to Modify/Create

  • crates/hero_slides_ui/static/js/dashboard.js — wrap showPrompt OK click in async loading state; split slides-empty render into two messages; add onload/onerror toggle on lightbox image; replace await loadSlidesForDeck() after slide.insert with a direct DOM append.
  • crates/hero_slides_ui/templates/index.html — split the #slides-empty block into #slides-empty-no-deck and #slides-empty-no-slides; add a spinner element (#preview-spinner) inside .preview-body.
  • crates/hero_slides_ui/static/css/dashboard.css — add .preview-spinner rules (centered overlay inside .preview-body, hidden by default, visible while image loads); add .preview-img.loading { visibility: hidden; } so the previous slide does not bleed through during navigation.

Implementation Plan

Step 1: Add loading state to the shared prompt modal OK button

Files: crates/hero_slides_ui/static/js/dashboard.js (around lines 120–138)
Dependencies: none
Tasks:

  • In showPrompt, change the single-shot click handler so it:
    1. Captures the trimmed input value (existing behaviour) and bails if empty.
    2. Disables the cloned OK button, stores the original innerHTML, and replaces it with a Bootstrap spinner: <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Working....
    3. Disables the Cancel button and the input field, and disables the data-bs-dismiss close button (so users can't dismiss while RPC is in flight).
    4. awaits onConfirm(val) inside a try/finally. The await works whether or not the caller returns a promise, so existing non-async onConfirm callbacks still function.
    5. In finally, restores the OK button's innerHTML, re-enables the controls, and only then calls modal.hide(). Move the existing modal.hide() from before onConfirm() (line 130) to after await onConfirm(val). This is the load-bearing change for issue #1: the modal stays open and shows the spinner for the ~2-3s of the RPC, then closes.
    6. On exception from onConfirm, swallow nothing — the existing try/catch in each caller (e.g. createDeck at lines 4231–4249) already toasts errors. Keep the call-site error handling intact.
  • Audit each showPrompt caller (dashboard.js:1116, 1148, 2560, 2592, 2920, 2935, 4228) to confirm the callback is async or returns a promise from an awaited RPC. Spot-check shows all seven are already async (...) => { ... }. No call-site changes needed.

Step 2: Split the slides-tab empty state into two distinct messages

Files: crates/hero_slides_ui/templates/index.html (around lines 150–153), crates/hero_slides_ui/static/js/dashboard.js (lines 522–525, 2030–2033)
Dependencies: none
Tasks:

  • In index.html, replace the single <div class="empty-state" id="slides-empty"> block at lines 150–153 with two siblings:
    • <div class="empty-state" id="slides-empty-no-deck"> containing the existing icon and <p>Select a deck to view slides.</p>.
    • <div class="empty-state" id="slides-empty-no-slides" style="display:none"> containing the same bi-card-image icon and <p>No slides yet — click Create Slide to add one.</p>.
  • In dashboard.js, introduce a small helper (inline, not extracted) used in two places to set visibility:
    • In loadSlidesForDeck (lines 522–525), when !path: show #slides-empty-no-deck, hide #slides-empty-no-slides, clear the grid.
    • In renderSlides (lines 2030–2033), when slides.length === 0: show #slides-empty-no-slides, hide #slides-empty-no-deck, clear the grid. (At this point selectedDeckPath is necessarily non-empty because renderSlides is only called from loadSlidesForDeck after the path guard.)
    • In renderSlides (line 2035), when slides are present: hide both empty-state divs.
  • Remove every reference to the old #slides-empty id. Confirm with grep: grep -n "slides-empty" dashboard.js index.html should show only slides-empty-no-deck / slides-empty-no-slides afterwards.

Step 3: Add a spinner to the lightbox until the image loads

Files: crates/hero_slides_ui/templates/index.html (around lines 522–530), crates/hero_slides_ui/static/css/dashboard.css (in the .preview-* block at lines 268–284), crates/hero_slides_ui/static/js/dashboard.js (showPreviewSlide at lines 3127–3148, closePreview at lines 3120–3125)
Dependencies: none
Tasks:

  • In index.html, inside .preview-body (line 522) add a sibling spinner before the <img>: <div class="preview-spinner" id="preview-spinner" style="display:none"><div class="spinner-border" role="status"></div></div>.
  • In dashboard.css, add:
    • .preview-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 5; pointer-events: none; }
    • .preview-spinner .spinner-border { color: #fff; width: 3rem; height: 3rem; }
    • .preview-spinner.visible { display: flex !important; } (or just toggle display).
    • .preview-img.loading { visibility: hidden; } so navigating to the next slide hides the previous frame instead of showing it stretched/stale until the new src decodes.
  • In dashboard.js showPreviewSlide (line 3127): instead of a bare assignment, do:
    const img = document.getElementById('preview-img');
    const spinner = document.getElementById('preview-spinner');
    img.classList.add('loading');
    spinner.style.display = 'flex';
    img.onload = () => { img.classList.remove('loading'); spinner.style.display = 'none'; };
    img.onerror = () => { img.classList.remove('loading'); spinner.style.display = 'none'; };
    img.src = slide.src;
    if (img.complete && img.naturalWidth > 0) {
        // Already cached — onload may not fire; clear immediately.
        img.classList.remove('loading');
        spinner.style.display = 'none';
    }
    
  • In closePreview (line 3120): also hide the spinner and remove the loading class so the next open starts from a clean state. Optionally clear img.src = '' but this would re-trigger a load on every open; better to leave the cached image and let the cache-hit branch above hide the spinner immediately on subsequent opens.

Step 4: Skip the full deck reload after Create Slide

Files: crates/hero_slides_ui/static/js/dashboard.js (doCreateSlide lines 1920–1939, renderSlides lines 2110–2146)
Dependencies: Step 2 (the empty-state hide) and Step 3 should land first/independently — Step 4 is the largest behavioural change.
Tasks:

  • After the await rpc('slide.insert', ...) call returns at line 1921, capture the response (currently discarded): const inserted = await rpc('slide.insert', ...) — the server returns SlideFile { name, path, hidden, fell_back_to_text_only } (verified in crates/hero_slides_lib/src/deck.rs:1037).
  • Remove the await loadSlidesForDeck() call at line 1932.
  • In its place, call a new helper appendSlideCard(inserted, slug, intent) defined alongside renderSlides. The helper:
    1. Builds a slide object shaped for renderSlides/slideBadge: { name: inserted.new_name || inserted.name, hidden: false, has_png: false, needs_generation: true, fell_back_to_text_only: false, modified_at: Date.now() }. (Confirm the JS field name — server returns name; check the JSON casing in crates/hero_slides_server/src/rpc.rs if uncertain.)
    2. Pushes it onto currentSlides so subsequent currentSlides.findIndex(...) lookups in insertSlide, moveSlide, deleteSlide work without a refetch.
    3. Hides both #slides-empty-no-deck and #slides-empty-no-slides (was previously empty case).
    4. Builds the card HTML using the same template literal already in renderSlides lines 2112–2142 (extract that template into a small renderSlideCardHtml(s, deck) function so both call sites share it — keeps the change minimal and avoids divergence).
    5. Appends the new card element to #slides-grid.
    6. Calls attachSlideContextMenus(), attachSlideDragDrop(), attachSlideCardClickHandlers() so the new card has working interactions. (These functions already re-bind everything; safe to re-run.)
    7. Updates the three counter elements at lines 2025–2028 (badge-slides, slides-stat-total, slides-stat-generated, slides-stat-pending) to reflect the new count.
  • The Stage 2 image generation (generateSlide(slug, false) at line 1938) already updates the new card via the _generatingSlides set + spinner overlay logic — no changes needed there.
  • Guard rail: if inserted.new_name is undefined for any reason, fall back to await loadSlidesForDeck() so the user is never left without a card.

Step 5: Smoke test

Files: none
Dependencies: Steps 1–4
Tasks:

  • Manually exercise: Create Deck (spinner visible during ~2-3s, modal closes after), Slides tab on a fresh deck (empty-state copy reads "No slides yet — click Create Slide to add one"), open lightbox (no black flash, spinner appears briefly then image), navigate next/prev in lightbox (no stale frame), Create Slide on a deck with 2+ existing slides (existing thumbnails remain stable, new card appears with Pending badge, then transitions to Generating then Generated when the async generation finishes).
  • Sanity check that other showPrompt flows (Insert Slide, Move Slide, Rename Deck) still work and now also show the OK spinner.

Acceptance Criteria

  • Create Deck OK button shows a spinner and is disabled during the RPC; restores on completion.
  • Slides tab shows "Select a deck to view slides." when no deck is selected, and "No slides yet — click Create Slide to add one." when a deck is selected but empty.
  • Lightbox shows a spinner/skeleton until the image has loaded; no black flash on first open.
  • After Create Slide, existing thumbnails do not flash to black; the new slide card appears next to them.
  • No regression: delete, move, hide/unhide, drag-drop reorder, and deck switching still refresh the slides grid via loadSlidesForDeck as before.
  • No regression: the seven other showPrompt callers (Folder, Subfolder, Insert Slide, Move Slide, Rename Deck, Duplicate Deck, Create Deck) all complete successfully and the modal closes after their RPC resolves.

Risks / Notes

  • Modal-open-during-RPC change of contract. Currently showPrompt hides the modal before invoking onConfirm (line 130). After Step 1 it hides after. If any caller relies on the modal being closed mid-RPC (e.g. opens a second modal, focuses a different element, reads document.activeElement), behaviour will shift. Spot-check of all seven callers shows none of them open another modal during the RPC; createDeck calls openInstructionsEditor afterwards (a full-screen overlay, not a Bootstrap modal — should be fine).
  • Stale currentSlides after append. The new helper mutates currentSlides directly. If the server-side numbering produced a new_name whose prefix differs from slug (server prepends NN_), the JS must use inserted.new_name (or whatever the JSON field is named — verify) not slug for the card id. Get the casing right or context menus will target the wrong card.
  • Image cache behaviour for the lightbox spinner. Browsers fire onload synchronously for images already in cache, but only if src is set after onload is attached and the image was previously decoded. The img.complete && img.naturalWidth > 0 guard handles the rare case where the same <img> already has the same src (no load event re-fires).
  • onload vs onerror reset. Reassigning img.onload/onerror on each showPreviewSlide call replaces the previous handler — no leak. The spinner div is shared and toggled inline; safe across rapid next/prev navigation.
  • Stage 1 (AI content generation) loading state is unchanged. The create-slide-modal already disables and re-labels its own Create button during the AI step; no regression there.
  • The id collision check. renderSlideCardHtml produces id="slide-card-${name}". After append, if the user immediately presses Create Slide again with the same slug, server-side rejection is the safety net (the file already exists). No client-side dedup needed.
## Implementation Spec for Issue #38 ### Objective Fix four small dashboard UX defects: missing async feedback on Create Deck, wrong empty-state copy when a deck is selected with zero slides, black flash when opening the lightbox the first time, and a thumbnail flash to black after Create Slide. All changes are confined to `dashboard.js`, `index.html`, and `dashboard.css` — no Rust/server work, no new dependencies. ### Scope decisions - **In scope:** the four issues exactly as filed. - **Out of scope (intentional tradeoffs):** - Spinner-on-OK is added only inside `showPrompt` (the shared prompt-modal flow). Issue #38 calls out Create Deck specifically, but `showPrompt` is also used by Create Folder, New Subfolder, Insert Slide, Move Slide, Rename Deck, and Duplicate Deck. Fixing it once in `showPrompt` gives Create Deck the spinner the issue asks for and incidentally fixes the same UX for the six other prompts at zero extra risk. The dedicated `create-slide-modal` already has its own loading state in `doCreateSlide` (lines 1899, 1946) — leave it alone. - The lightbox spinner overlay relies on `<img>.complete`/`onload`/`onerror`; no preloading, no skeleton box, no image-cache layer. Minimal change. - For the post-Create-Slide flash, only the Create-Slide path (`doCreateSlide` at `dashboard.js:1932`) is changed. The other ~25 callers of `loadSlidesForDeck` (delete, move, hide/unhide, drag/drop, deck switch, route navigation, generate completion, etc.) keep the full reload — for those a refresh is the correct behaviour (server-side state genuinely changed and renumbering may apply). - The Create-Slide append path is only safe when `position` is at the end of the deck (the current code path always appends — `position = (currentSlides?.length || 0) + 1` at `dashboard.js:1886`). If `position` ever changes to mid-deck, fall back to `loadSlidesForDeck()` because renumbering is needed. ### Files to Modify/Create - `crates/hero_slides_ui/static/js/dashboard.js` — wrap `showPrompt` OK click in async loading state; split slides-empty render into two messages; add `onload`/`onerror` toggle on lightbox image; replace `await loadSlidesForDeck()` after `slide.insert` with a direct DOM append. - `crates/hero_slides_ui/templates/index.html` — split the `#slides-empty` block into `#slides-empty-no-deck` and `#slides-empty-no-slides`; add a spinner element (`#preview-spinner`) inside `.preview-body`. - `crates/hero_slides_ui/static/css/dashboard.css` — add `.preview-spinner` rules (centered overlay inside `.preview-body`, hidden by default, visible while image loads); add `.preview-img.loading { visibility: hidden; }` so the previous slide does not bleed through during navigation. ### Implementation Plan #### Step 1: Add loading state to the shared prompt modal OK button Files: `crates/hero_slides_ui/static/js/dashboard.js` (around lines 120–138) Dependencies: none Tasks: - In `showPrompt`, change the single-shot click handler so it: 1. Captures the trimmed input value (existing behaviour) and bails if empty. 2. Disables the cloned OK button, stores the original `innerHTML`, and replaces it with a Bootstrap spinner: `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Working...`. 3. Disables the Cancel button and the input field, and disables the `data-bs-dismiss` close button (so users can't dismiss while RPC is in flight). 4. `await`s `onConfirm(val)` inside a try/finally. The `await` works whether or not the caller returns a promise, so existing non-async `onConfirm` callbacks still function. 5. In `finally`, restores the OK button's `innerHTML`, re-enables the controls, and only then calls `modal.hide()`. Move the existing `modal.hide()` from before `onConfirm()` (line 130) to after `await onConfirm(val)`. This is the load-bearing change for issue #1: the modal stays open and shows the spinner for the ~2-3s of the RPC, then closes. 6. On exception from `onConfirm`, swallow nothing — the existing `try/catch` in each caller (e.g. `createDeck` at lines 4231–4249) already toasts errors. Keep the call-site error handling intact. - Audit each `showPrompt` caller (`dashboard.js:1116, 1148, 2560, 2592, 2920, 2935, 4228`) to confirm the callback is `async` or returns a promise from an awaited RPC. Spot-check shows all seven are already `async (...) => { ... }`. No call-site changes needed. #### Step 2: Split the slides-tab empty state into two distinct messages Files: `crates/hero_slides_ui/templates/index.html` (around lines 150–153), `crates/hero_slides_ui/static/js/dashboard.js` (lines 522–525, 2030–2033) Dependencies: none Tasks: - In `index.html`, replace the single `<div class="empty-state" id="slides-empty">` block at lines 150–153 with two siblings: - `<div class="empty-state" id="slides-empty-no-deck">` containing the existing icon and `<p>Select a deck to view slides.</p>`. - `<div class="empty-state" id="slides-empty-no-slides" style="display:none">` containing the same `bi-card-image` icon and `<p>No slides yet — click Create Slide to add one.</p>`. - In `dashboard.js`, introduce a small helper (inline, not extracted) used in two places to set visibility: - In `loadSlidesForDeck` (lines 522–525), when `!path`: show `#slides-empty-no-deck`, hide `#slides-empty-no-slides`, clear the grid. - In `renderSlides` (lines 2030–2033), when `slides.length === 0`: show `#slides-empty-no-slides`, hide `#slides-empty-no-deck`, clear the grid. (At this point `selectedDeckPath` is necessarily non-empty because `renderSlides` is only called from `loadSlidesForDeck` after the `path` guard.) - In `renderSlides` (line 2035), when slides are present: hide both empty-state divs. - Remove every reference to the old `#slides-empty` id. Confirm with grep: `grep -n "slides-empty" dashboard.js index.html` should show only `slides-empty-no-deck` / `slides-empty-no-slides` afterwards. #### Step 3: Add a spinner to the lightbox until the image loads Files: `crates/hero_slides_ui/templates/index.html` (around lines 522–530), `crates/hero_slides_ui/static/css/dashboard.css` (in the `.preview-*` block at lines 268–284), `crates/hero_slides_ui/static/js/dashboard.js` (`showPreviewSlide` at lines 3127–3148, `closePreview` at lines 3120–3125) Dependencies: none Tasks: - In `index.html`, inside `.preview-body` (line 522) add a sibling spinner before the `<img>`: `<div class="preview-spinner" id="preview-spinner" style="display:none"><div class="spinner-border" role="status"></div></div>`. - In `dashboard.css`, add: - `.preview-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 5; pointer-events: none; }` - `.preview-spinner .spinner-border { color: #fff; width: 3rem; height: 3rem; }` - `.preview-spinner.visible { display: flex !important; }` (or just toggle `display`). - `.preview-img.loading { visibility: hidden; }` so navigating to the next slide hides the previous frame instead of showing it stretched/stale until the new src decodes. - In `dashboard.js` `showPreviewSlide` (line 3127): instead of a bare assignment, do: ``` const img = document.getElementById('preview-img'); const spinner = document.getElementById('preview-spinner'); img.classList.add('loading'); spinner.style.display = 'flex'; img.onload = () => { img.classList.remove('loading'); spinner.style.display = 'none'; }; img.onerror = () => { img.classList.remove('loading'); spinner.style.display = 'none'; }; img.src = slide.src; if (img.complete && img.naturalWidth > 0) { // Already cached — onload may not fire; clear immediately. img.classList.remove('loading'); spinner.style.display = 'none'; } ``` - In `closePreview` (line 3120): also hide the spinner and remove the `loading` class so the next open starts from a clean state. Optionally clear `img.src = ''` but this would re-trigger a load on every open; better to leave the cached image and let the cache-hit branch above hide the spinner immediately on subsequent opens. #### Step 4: Skip the full deck reload after Create Slide Files: `crates/hero_slides_ui/static/js/dashboard.js` (`doCreateSlide` lines 1920–1939, `renderSlides` lines 2110–2146) Dependencies: Step 2 (the empty-state hide) and Step 3 should land first/independently — Step 4 is the largest behavioural change. Tasks: - After the `await rpc('slide.insert', ...)` call returns at line 1921, capture the response (currently discarded): `const inserted = await rpc('slide.insert', ...)` — the server returns `SlideFile { name, path, hidden, fell_back_to_text_only }` (verified in `crates/hero_slides_lib/src/deck.rs:1037`). - Remove the `await loadSlidesForDeck()` call at line 1932. - In its place, call a new helper `appendSlideCard(inserted, slug, intent)` defined alongside `renderSlides`. The helper: 1. Builds a slide object shaped for `renderSlides`/`slideBadge`: `{ name: inserted.new_name || inserted.name, hidden: false, has_png: false, needs_generation: true, fell_back_to_text_only: false, modified_at: Date.now() }`. (Confirm the JS field name — server returns `name`; check the JSON casing in `crates/hero_slides_server/src/rpc.rs` if uncertain.) 2. Pushes it onto `currentSlides` so subsequent `currentSlides.findIndex(...)` lookups in `insertSlide`, `moveSlide`, `deleteSlide` work without a refetch. 3. Hides both `#slides-empty-no-deck` and `#slides-empty-no-slides` (was previously empty case). 4. Builds the card HTML using the same template literal already in `renderSlides` lines 2112–2142 (extract that template into a small `renderSlideCardHtml(s, deck)` function so both call sites share it — keeps the change minimal and avoids divergence). 5. Appends the new card element to `#slides-grid`. 6. Calls `attachSlideContextMenus()`, `attachSlideDragDrop()`, `attachSlideCardClickHandlers()` so the new card has working interactions. (These functions already re-bind everything; safe to re-run.) 7. Updates the three counter elements at lines 2025–2028 (`badge-slides`, `slides-stat-total`, `slides-stat-generated`, `slides-stat-pending`) to reflect the new count. - The Stage 2 image generation (`generateSlide(slug, false)` at line 1938) already updates the new card via the `_generatingSlides` set + spinner overlay logic — no changes needed there. - Guard rail: if `inserted.new_name` is undefined for any reason, fall back to `await loadSlidesForDeck()` so the user is never left without a card. #### Step 5: Smoke test Files: none Dependencies: Steps 1–4 Tasks: - Manually exercise: Create Deck (spinner visible during ~2-3s, modal closes after), Slides tab on a fresh deck (empty-state copy reads "No slides yet — click Create Slide to add one"), open lightbox (no black flash, spinner appears briefly then image), navigate next/prev in lightbox (no stale frame), Create Slide on a deck with 2+ existing slides (existing thumbnails remain stable, new card appears with Pending badge, then transitions to Generating then Generated when the async generation finishes). - Sanity check that other `showPrompt` flows (Insert Slide, Move Slide, Rename Deck) still work and now also show the OK spinner. ### Acceptance Criteria - [ ] Create Deck OK button shows a spinner and is disabled during the RPC; restores on completion. - [ ] Slides tab shows "Select a deck to view slides." when no deck is selected, and "No slides yet — click Create Slide to add one." when a deck is selected but empty. - [ ] Lightbox shows a spinner/skeleton until the image has loaded; no black flash on first open. - [ ] After Create Slide, existing thumbnails do not flash to black; the new slide card appears next to them. - [ ] No regression: delete, move, hide/unhide, drag-drop reorder, and deck switching still refresh the slides grid via `loadSlidesForDeck` as before. - [ ] No regression: the seven other `showPrompt` callers (Folder, Subfolder, Insert Slide, Move Slide, Rename Deck, Duplicate Deck, Create Deck) all complete successfully and the modal closes after their RPC resolves. ### Risks / Notes - **Modal-open-during-RPC change of contract.** Currently `showPrompt` hides the modal *before* invoking `onConfirm` (line 130). After Step 1 it hides *after*. If any caller relies on the modal being closed mid-RPC (e.g. opens a second modal, focuses a different element, reads `document.activeElement`), behaviour will shift. Spot-check of all seven callers shows none of them open another modal during the RPC; `createDeck` calls `openInstructionsEditor` afterwards (a full-screen overlay, not a Bootstrap modal — should be fine). - **Stale `currentSlides` after append.** The new helper mutates `currentSlides` directly. If the server-side numbering produced a `new_name` whose prefix differs from `slug` (server prepends `NN_`), the JS must use `inserted.new_name` (or whatever the JSON field is named — verify) not `slug` for the card id. Get the casing right or context menus will target the wrong card. - **Image cache behaviour for the lightbox spinner.** Browsers fire `onload` synchronously for images already in cache, but only if `src` is set after `onload` is attached and the image was previously decoded. The `img.complete && img.naturalWidth > 0` guard handles the rare case where the same `<img>` already has the same `src` (no `load` event re-fires). - **`onload` vs `onerror` reset.** Reassigning `img.onload`/`onerror` on each `showPreviewSlide` call replaces the previous handler — no leak. The spinner div is shared and toggled inline; safe across rapid next/prev navigation. - **Stage 1 (AI content generation) loading state is unchanged.** The `create-slide-modal` already disables and re-labels its own Create button during the AI step; no regression there. - **The `id` collision check.** `renderSlideCardHtml` produces `id="slide-card-${name}"`. After append, if the user immediately presses Create Slide again with the same slug, server-side rejection is the safety net (the file already exists). No client-side dedup needed.
Author
Member

Test Results

This change is JS+HTML+CSS only — no Rust changes — so the test surface is build/lint plus manual smoke. No new automated tests added (UI behaviour requires browser interaction).

Check Result
cargo test --workspace 98 passed / 0 failed / 1 ignored (the AI-gated test_generate_single_slide_ai) — same baseline as development
cargo check -p hero_slides_ui clean (Askama templates compile after the index.html edits)
cargo fmt --check clean
bun build --no-bundle dashboard.js parses cleanly (no node available in env)

Diffstat

 crates/hero_slides_ui/static/css/dashboard.css |  17 +++
 crates/hero_slides_ui/static/js/dashboard.js   | 196 +++++++++++++++++++------
 crates/hero_slides_ui/templates/index.html     |   9 +-
 3 files changed, 179 insertions(+), 43 deletions(-)

Manual smoke needed before merge

  • Create Deck: spinner visible during the ~2-3s RPC, modal closes after.
  • Slides tab on a fresh deck: empty state reads "No slides yet — click Create Slide to add one."
  • Lightbox: open from a thumbnail → no black flash, spinner appears briefly then image. Navigate next/prev: no stale frame.
  • Create Slide on a deck with 2+ existing slides: existing thumbnails stay stable, new card appears with the Pending badge, then transitions through Generating to Generated when the async generation finishes.
  • Sanity: Insert Slide / Move Slide / Rename Deck / Duplicate Deck modals all complete and close after their RPC resolves (they share the same showPrompt flow that was changed).
## Test Results This change is JS+HTML+CSS only — no Rust changes — so the test surface is build/lint plus manual smoke. No new automated tests added (UI behaviour requires browser interaction). | Check | Result | |---|---| | `cargo test --workspace` | 98 passed / 0 failed / 1 ignored (the AI-gated `test_generate_single_slide_ai`) — same baseline as `development` | | `cargo check -p hero_slides_ui` | clean (Askama templates compile after the `index.html` edits) | | `cargo fmt --check` | clean | | `bun build --no-bundle dashboard.js` | parses cleanly (no `node` available in env) | ### Diffstat ``` crates/hero_slides_ui/static/css/dashboard.css | 17 +++ crates/hero_slides_ui/static/js/dashboard.js | 196 +++++++++++++++++++------ crates/hero_slides_ui/templates/index.html | 9 +- 3 files changed, 179 insertions(+), 43 deletions(-) ``` ### Manual smoke needed before merge - Create Deck: spinner visible during the ~2-3s RPC, modal closes after. - Slides tab on a fresh deck: empty state reads "No slides yet — click Create Slide to add one." - Lightbox: open from a thumbnail → no black flash, spinner appears briefly then image. Navigate next/prev: no stale frame. - Create Slide on a deck with 2+ existing slides: existing thumbnails stay stable, new card appears with the Pending badge, then transitions through Generating to Generated when the async generation finishes. - Sanity: Insert Slide / Move Slide / Rename Deck / Duplicate Deck modals all complete and close after their RPC resolves (they share the same `showPrompt` flow that was changed).
Author
Member

Implementation Summary

All 4 issues fixed on branch development_dashboard_ux_fixes. JS + HTML + CSS only.

Files modified

File Change
crates/hero_slides_ui/static/js/dashboard.js showPrompt adds OK-button spinner + disables input/Cancel during the RPC and closes the modal in finally. loadSlidesForDeck/renderSlides toggle the new split empty-state. showPreviewSlide/closePreview toggle a lightbox spinner via onload/onerror. New renderSlideCardHtml helper extracted from renderSlides. New appendSlideCard helper called from doCreateSlide instead of loadSlidesForDeck, with a guard-rail fallback to full reload if the response shape is unexpected.
crates/hero_slides_ui/templates/index.html #slides-empty split into #slides-empty-no-deck (visible by default) and #slides-empty-no-slides (hidden by default). New #preview-spinner div added inside .preview-body before <img>.
crates/hero_slides_ui/static/css/dashboard.css New .preview-spinner (absolute overlay, white spinner, z-index 5), .preview-img.loading { visibility: hidden }.

Behaviour matrix

Action Before After
Create Deck OK click Modal closes immediately, ~2-3s of nothing, deck appears OK shows spinner, input/Cancel disabled, modal closes after RPC resolves
Slides tab on a fresh deck (selected, 0 slides) "Select a deck to view slides." (misleading) "No slides yet — click Create Slide to add one."
Slides tab with no deck selected "Select a deck to view slides." "Select a deck to view slides." (unchanged)
Lightbox first open Black for ~2s, then image Spinner overlay, image fades in on load
Lightbox next/prev Stale previous frame visible briefly loading class hides previous frame; spinner shows
Create Slide on deck with N existing slides All N thumbnails flash to black for ~3s, new card appears Existing thumbnails stable; new card appended in place

Acceptance criteria

  • Create Deck OK button shows a spinner and is disabled during the RPC; restores on completion.
  • Slides tab shows "Select a deck to view slides." when no deck is selected, and "No slides yet — click Create Slide to add one." when a deck is selected but empty.
  • Lightbox shows a spinner/skeleton until the image has loaded; no black flash on first open.
  • After Create Slide, existing thumbnails do not flash to black; the new slide card appears next to them.
  • No regression: delete, move, hide/unhide, drag-drop reorder, and deck switching still refresh the slides grid via loadSlidesForDeck as before (only the Create Slide path changed).
  • No regression: showPrompt callers (Folder, Subfolder, Insert Slide, Move Slide, Rename Deck, Duplicate Deck, Create Deck) all complete and the modal closes after their RPC resolves. They incidentally also gain the new spinner.

Test results

cargo test --workspace: 98 / 0 / 1 ignored (same baseline as development).
cargo check -p hero_slides_ui: clean.
cargo fmt --check: clean.
bun build --no-bundle dashboard.js: parses cleanly.

Notes for review

  • The shared showPrompt change is a small contract shift: previously the modal hid before onConfirm; now it hides after. All seven callers were audited and none rely on the old timing. This was an explicit scope decision in the spec.
  • appendSlideCard writes directly to currentSlides, bypassing the server. If the server's stored slide list ever drifts from this client-side cache (e.g. concurrent edits from another tab), the next full reload (delete/move/refresh) reconciles it. Acceptable for the single-user Hero context.
  • The badge counters carry a (N hidden) suffix when there are hidden slides; the increment helper drops it temporarily, the next full reload restores it. Cosmetic only.
  • dashboard.js and dashboard.css are static assets. Users may need a hard refresh after deploy.
## Implementation Summary All 4 issues fixed on branch `development_dashboard_ux_fixes`. JS + HTML + CSS only. ### Files modified | File | Change | |---|---| | `crates/hero_slides_ui/static/js/dashboard.js` | `showPrompt` adds OK-button spinner + disables input/Cancel during the RPC and closes the modal in `finally`. `loadSlidesForDeck`/`renderSlides` toggle the new split empty-state. `showPreviewSlide`/`closePreview` toggle a lightbox spinner via `onload`/`onerror`. New `renderSlideCardHtml` helper extracted from `renderSlides`. New `appendSlideCard` helper called from `doCreateSlide` instead of `loadSlidesForDeck`, with a guard-rail fallback to full reload if the response shape is unexpected. | | `crates/hero_slides_ui/templates/index.html` | `#slides-empty` split into `#slides-empty-no-deck` (visible by default) and `#slides-empty-no-slides` (hidden by default). New `#preview-spinner` div added inside `.preview-body` before `<img>`. | | `crates/hero_slides_ui/static/css/dashboard.css` | New `.preview-spinner` (absolute overlay, white spinner, z-index 5), `.preview-img.loading { visibility: hidden }`. | ### Behaviour matrix | Action | Before | After | |---|---|---| | Create Deck OK click | Modal closes immediately, ~2-3s of nothing, deck appears | OK shows spinner, input/Cancel disabled, modal closes after RPC resolves | | Slides tab on a fresh deck (selected, 0 slides) | "Select a deck to view slides." (misleading) | "No slides yet — click Create Slide to add one." | | Slides tab with no deck selected | "Select a deck to view slides." | "Select a deck to view slides." (unchanged) | | Lightbox first open | Black for ~2s, then image | Spinner overlay, image fades in on load | | Lightbox next/prev | Stale previous frame visible briefly | `loading` class hides previous frame; spinner shows | | Create Slide on deck with N existing slides | All N thumbnails flash to black for ~3s, new card appears | Existing thumbnails stable; new card appended in place | ### Acceptance criteria - [x] Create Deck OK button shows a spinner and is disabled during the RPC; restores on completion. - [x] Slides tab shows "Select a deck to view slides." when no deck is selected, and "No slides yet — click Create Slide to add one." when a deck is selected but empty. - [x] Lightbox shows a spinner/skeleton until the image has loaded; no black flash on first open. - [x] After Create Slide, existing thumbnails do not flash to black; the new slide card appears next to them. - [x] No regression: delete, move, hide/unhide, drag-drop reorder, and deck switching still refresh the slides grid via `loadSlidesForDeck` as before (only the Create Slide path changed). - [x] No regression: `showPrompt` callers (Folder, Subfolder, Insert Slide, Move Slide, Rename Deck, Duplicate Deck, Create Deck) all complete and the modal closes after their RPC resolves. They incidentally also gain the new spinner. ### Test results `cargo test --workspace`: 98 / 0 / 1 ignored (same baseline as `development`). `cargo check -p hero_slides_ui`: clean. `cargo fmt --check`: clean. `bun build --no-bundle dashboard.js`: parses cleanly. ### Notes for review - The shared `showPrompt` change is a small contract shift: previously the modal hid before `onConfirm`; now it hides after. All seven callers were audited and none rely on the old timing. This was an explicit scope decision in the spec. - `appendSlideCard` writes directly to `currentSlides`, bypassing the server. If the server's stored slide list ever drifts from this client-side cache (e.g. concurrent edits from another tab), the next full reload (delete/move/refresh) reconciles it. Acceptable for the single-user Hero context. - The badge counters carry a `(N hidden)` suffix when there are hidden slides; the increment helper drops it temporarily, the next full reload restores it. Cosmetic only. - `dashboard.js` and `dashboard.css` are static assets. Users may need a hard refresh after deploy.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_slides#38
No description provided.