Dashboard: missing async feedback and wrong empty-state copy #38
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 === 0and never checks whether a deck is actually selected.Fix: branch on
selectedDeckPath:3. Lightbox shows black screen ~2 seconds on first open
dashboard.js:3107 sets
<img>.srcafter 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(oronload) fires. Optionally, preload thumbnails by settingloading="eager"on the lightbox-target.4. Thumbnail flash to black after Create Slide
After modal close, dashboard.js:1932 calls
loadSlidesForDeck(), which round-tripsrpc('deck.get', ...)and rerenders the entire slides grid viarenderSlides()(: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
srcso 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.
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, anddashboard.css— no Rust/server work, no new dependencies.Scope decisions
showPrompt(the shared prompt-modal flow). Issue #38 calls out Create Deck specifically, butshowPromptis also used by Create Folder, New Subfolder, Insert Slide, Move Slide, Rename Deck, and Duplicate Deck. Fixing it once inshowPromptgives Create Deck the spinner the issue asks for and incidentally fixes the same UX for the six other prompts at zero extra risk. The dedicatedcreate-slide-modalalready has its own loading state indoCreateSlide(lines 1899, 1946) — leave it alone.<img>.complete/onload/onerror; no preloading, no skeleton box, no image-cache layer. Minimal change.doCreateSlideatdashboard.js:1932) is changed. The other ~25 callers ofloadSlidesForDeck(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).positionis at the end of the deck (the current code path always appends —position = (currentSlides?.length || 0) + 1atdashboard.js:1886). Ifpositionever changes to mid-deck, fall back toloadSlidesForDeck()because renumbering is needed.Files to Modify/Create
crates/hero_slides_ui/static/js/dashboard.js— wrapshowPromptOK click in async loading state; split slides-empty render into two messages; addonload/onerrortoggle on lightbox image; replaceawait loadSlidesForDeck()afterslide.insertwith a direct DOM append.crates/hero_slides_ui/templates/index.html— split the#slides-emptyblock into#slides-empty-no-deckand#slides-empty-no-slides; add a spinner element (#preview-spinner) inside.preview-body.crates/hero_slides_ui/static/css/dashboard.css— add.preview-spinnerrules (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:
showPrompt, change the single-shot click handler so it:innerHTML, and replaces it with a Bootstrap spinner:<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Working....data-bs-dismissclose button (so users can't dismiss while RPC is in flight).awaitsonConfirm(val)inside a try/finally. Theawaitworks whether or not the caller returns a promise, so existing non-asynconConfirmcallbacks still function.finally, restores the OK button'sinnerHTML, re-enables the controls, and only then callsmodal.hide(). Move the existingmodal.hide()from beforeonConfirm()(line 130) to afterawait 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.onConfirm, swallow nothing — the existingtry/catchin each caller (e.g.createDeckat lines 4231–4249) already toasts errors. Keep the call-site error handling intact.showPromptcaller (dashboard.js:1116, 1148, 2560, 2592, 2920, 2935, 4228) to confirm the callback isasyncor returns a promise from an awaited RPC. Spot-check shows all seven are alreadyasync (...) => { ... }. 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:
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 samebi-card-imageicon and<p>No slides yet — click Create Slide to add one.</p>.dashboard.js, introduce a small helper (inline, not extracted) used in two places to set visibility:loadSlidesForDeck(lines 522–525), when!path: show#slides-empty-no-deck, hide#slides-empty-no-slides, clear the grid.renderSlides(lines 2030–2033), whenslides.length === 0: show#slides-empty-no-slides, hide#slides-empty-no-deck, clear the grid. (At this pointselectedDeckPathis necessarily non-empty becauserenderSlidesis only called fromloadSlidesForDeckafter thepathguard.)renderSlides(line 2035), when slides are present: hide both empty-state divs.#slides-emptyid. Confirm with grep:grep -n "slides-empty" dashboard.js index.htmlshould show onlyslides-empty-no-deck/slides-empty-no-slidesafterwards.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(showPreviewSlideat lines 3127–3148,closePreviewat lines 3120–3125)Dependencies: none
Tasks:
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>.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 toggledisplay)..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.dashboard.jsshowPreviewSlide(line 3127): instead of a bare assignment, do:closePreview(line 3120): also hide the spinner and remove theloadingclass so the next open starts from a clean state. Optionally clearimg.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(doCreateSlidelines 1920–1939,renderSlideslines 2110–2146)Dependencies: Step 2 (the empty-state hide) and Step 3 should land first/independently — Step 4 is the largest behavioural change.
Tasks:
await rpc('slide.insert', ...)call returns at line 1921, capture the response (currently discarded):const inserted = await rpc('slide.insert', ...)— the server returnsSlideFile { name, path, hidden, fell_back_to_text_only }(verified incrates/hero_slides_lib/src/deck.rs:1037).await loadSlidesForDeck()call at line 1932.appendSlideCard(inserted, slug, intent)defined alongsiderenderSlides. The helper: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 returnsname; check the JSON casing incrates/hero_slides_server/src/rpc.rsif uncertain.)currentSlidesso subsequentcurrentSlides.findIndex(...)lookups ininsertSlide,moveSlide,deleteSlidework without a refetch.#slides-empty-no-deckand#slides-empty-no-slides(was previously empty case).renderSlideslines 2112–2142 (extract that template into a smallrenderSlideCardHtml(s, deck)function so both call sites share it — keeps the change minimal and avoids divergence).#slides-grid.attachSlideContextMenus(),attachSlideDragDrop(),attachSlideCardClickHandlers()so the new card has working interactions. (These functions already re-bind everything; safe to re-run.)badge-slides,slides-stat-total,slides-stat-generated,slides-stat-pending) to reflect the new count.generateSlide(slug, false)at line 1938) already updates the new card via the_generatingSlidesset + spinner overlay logic — no changes needed there.inserted.new_nameis undefined for any reason, fall back toawait loadSlidesForDeck()so the user is never left without a card.Step 5: Smoke test
Files: none
Dependencies: Steps 1–4
Tasks:
showPromptflows (Insert Slide, Move Slide, Rename Deck) still work and now also show the OK spinner.Acceptance Criteria
loadSlidesForDeckas before.showPromptcallers (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
showPrompthides the modal before invokingonConfirm(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, readsdocument.activeElement), behaviour will shift. Spot-check of all seven callers shows none of them open another modal during the RPC;createDeckcallsopenInstructionsEditorafterwards (a full-screen overlay, not a Bootstrap modal — should be fine).currentSlidesafter append. The new helper mutatescurrentSlidesdirectly. If the server-side numbering produced anew_namewhose prefix differs fromslug(server prependsNN_), the JS must useinserted.new_name(or whatever the JSON field is named — verify) notslugfor the card id. Get the casing right or context menus will target the wrong card.onloadsynchronously for images already in cache, but only ifsrcis set afteronloadis attached and the image was previously decoded. Theimg.complete && img.naturalWidth > 0guard handles the rare case where the same<img>already has the samesrc(noloadevent re-fires).onloadvsonerrorreset. Reassigningimg.onload/onerroron eachshowPreviewSlidecall replaces the previous handler — no leak. The spinner div is shared and toggled inline; safe across rapid next/prev navigation.create-slide-modalalready disables and re-labels its own Create button during the AI step; no regression there.idcollision check.renderSlideCardHtmlproducesid="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.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).
cargo test --workspacetest_generate_single_slide_ai) — same baseline asdevelopmentcargo check -p hero_slides_uiindex.htmledits)cargo fmt --checkbun build --no-bundle dashboard.jsnodeavailable in env)Diffstat
Manual smoke needed before merge
showPromptflow that was changed).Implementation Summary
All 4 issues fixed on branch
development_dashboard_ux_fixes. JS + HTML + CSS only.Files modified
crates/hero_slides_ui/static/js/dashboard.jsshowPromptadds OK-button spinner + disables input/Cancel during the RPC and closes the modal infinally.loadSlidesForDeck/renderSlidestoggle the new split empty-state.showPreviewSlide/closePreviewtoggle a lightbox spinner viaonload/onerror. NewrenderSlideCardHtmlhelper extracted fromrenderSlides. NewappendSlideCardhelper called fromdoCreateSlideinstead ofloadSlidesForDeck, with a guard-rail fallback to full reload if the response shape is unexpected.crates/hero_slides_ui/templates/index.html#slides-emptysplit into#slides-empty-no-deck(visible by default) and#slides-empty-no-slides(hidden by default). New#preview-spinnerdiv added inside.preview-bodybefore<img>.crates/hero_slides_ui/static/css/dashboard.css.preview-spinner(absolute overlay, white spinner, z-index 5),.preview-img.loading { visibility: hidden }.Behaviour matrix
loadingclass hides previous frame; spinner showsAcceptance criteria
loadSlidesForDeckas before (only the Create Slide path changed).showPromptcallers (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 asdevelopment).cargo check -p hero_slides_ui: clean.cargo fmt --check: clean.bun build --no-bundle dashboard.js: parses cleanly.Notes for review
showPromptchange is a small contract shift: previously the modal hid beforeonConfirm; 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.appendSlideCardwrites directly tocurrentSlides, 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.(N hidden)suffix when there are hidden slides; the increment helper drops it temporarily, the next full reload restores it. Cosmetic only.dashboard.jsanddashboard.cssare static assets. Users may need a hard refresh after deploy.