Create Slide: slug double-prefixed by auto-numbering, then fails silently #37
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
The Create Slide flow double-prefixes the slug when the user already typed a numeric prefix, then the deck-generation job fails to find the file (it uses the raw slug while the file was saved with an extra prefix), and the failure surfaces as a silent "Pending" state with no error in the UI. Combined, this is a single user-facing bug with three contributing code points.
Reproduction
06_conclusion.Expected: file saved as
06_conclusion.md; gen runs; thumbnail appears.Actual:
06_06_conclusion.md(double prefix).Missing required file: slide not found: 06_conclusion.md.A second contributing UX issue appears earlier in the flow: the Create Deck modal (and Create Slide modal) silently keeps itself open if the user submits an empty name, with no inline validation message.
Root causes
1. Server unconditionally prepends
NN_to the slug (the actual file-save bug)crates/hero_slides_lib/src/slide_ops.rs:226:atis the new slide's position;slugis whatever the client passed. There's no "if slug already starts with\d{2}_, strip it" logic.Client side, the Create Slide handler at
crates/hero_slides_ui/static/js/dashboard.js:1876and:2569passes the user's slug verbatim toslide.insert.2. Pending-state badge is rendered unconditionally
crates/hero_slides_ui/static/js/dashboard.js:2003-2010:The "Pending" badge is shown for any slide whose generation result is missing. There's no check for the case where the generation job exists but failed because the filename didn't match — that should be rendered as an error badge with the actual error.
3. Empty-name submission silently keeps modal open
crates/hero_slides_ui/static/js/dashboard.js:128-131:The Create Slide variant at
:1880does toast but still doesn't dismiss the modal.Suggested fix
slide_ops.rs::insert_slide, strip a leading\d{2}_fromslugbefore prepending. Or: detect the prefix and reject with a clear error pointing the user at the slugify rule.failed, render an error badge with the failure reason instead of "Pending".showPrompt, render an inline error inside the modal and keep focus in the input. Don't fall through to a no-opreturn.Severity
High. The double-prefix silently corrupts the slide-file naming convention and the failure isn't surfaced anywhere the user looks first.
Implementation Spec for Issue #37
Objective
Fix the "Create Slide" double-prefix bug at three layers: stop the server from prepending
NN_when the slug already carries one, surface failed-slide outcomes fromdeck.generateAsyncin the dashboard with an explicit error badge, and replace the silent empty-name no-op in bothshowPromptand the Create Slide modal with inline validation that keeps the modal open.Scope decisions
insert_slidedetects a leading^\d{2}_inslugand removes it before computing the final filename. The boolean is bubbled up so the client can show a non-blocking notice. Rejection was considered and ruled out —06_conclusionis a plausible thing for a user to type from muscle memory; a hard error breaks existing UI flows that already lower-case + slugify on the client.slug_normalized: boolto theslide.insertJSON-RPC response. Internallyinsert_slidereturns(String, bool)andslide_insertreturns(SlideFile, bool). Tests cover the stripping logic atslide_ops.rslevel.status.per_slidefromdeck.generateJobStatus(already shipped in PR #40). When the poller seesdone, build aMap<slideName, errorMessage>ofok: falseentries, store it as_failedSlides, thenloadSlidesForDeck()triggers the badge re-render.slideBadge(s)reads_failedSlidesand emits a redstate-badge-errorwith atitle=tooltip showing the error. The map is cleared at the start of everydeck.generateAsyncrun, and individual entries are cleared when a slide later succeeds. No new RPC, no extra state on the slide objects.showPromptanddoCreateSlideshow inline error text via a sibling<div class="invalid-feedback d-block">and addis-invalidto the input. They do not callonConfirm, do not hide the modal, and re-focus the input. Typing in the input clears the invalid state.slide.insertaccept arbitrary slug characters. Existing client-side slug normalization (replace(/[^a-z0-9_]/g, '_')) is left untouched — only the leading-numeric-prefix case is added.Files to Modify/Create
crates/hero_slides_lib/src/slide_ops.rs— extractstrip_leading_position_prefix(&str) -> (String, bool)helper; changeinsert_slideto returnResult<(String, bool), HeroSlidesError>; apply the strip before constructingnew_name; updateduplicate_slidecaller. Add unit tests.crates/hero_slides_lib/src/deck.rs—slide_insertpropagates the bool. Change return type toResult<(SlideFile, bool), HeroSlidesError>. Update internal callers/tests.crates/hero_slides_server/src/rpc.rs—handle_slide_insert: destructure the tuple; includeslug_normalizedin the JSON response.crates/hero_slides_ui/static/js/dashboard.js:showPrompt: clear-on-open invalid state, on empty input addis-invalid, set inline error text, focus input, return without hiding. Wire aninputlistener that clears the invalid state on the next keystroke.doCreateSlide: replacetoast(...) + returnon empty slug with the same inline-error treatment. After successfulslide.insert, ifresult.slug_normalized === true, show an info toast.insertSlide(the in-place insert at a position): same response handling._failedSlides = new Map()near_generatingSlides. HelpersmarkSlideFailed(name, err),clearSlideFailed(name),clearAllFailedSlides().slideBadge(s): when_failedSlides.has(s.name), return<span class="state-badge state-badge-error" title="..." >Error</span>(priority below the generating spinner so retry shows spinner).pollGenerateProgress: onstatus.donewithArray.isArray(status.per_slide), mark/clear per slide beforeloadSlidesForDeck(). CallclearAllFailedSlides()at the start ofgenerateDeck().clearSlideFailed(name); on failure,markSlideFailed(name, msg).crates/hero_slides_ui/templates/index.html— add<div class="invalid-feedback" id="prompt-modal-error"></div>under#prompt-modal-input; add<div class="invalid-feedback" id="create-slide-title-error"></div>under#create-slide-title.crates/hero_slides_ui/static/css/dashboard.css— add.state-badge-error(red fill, white text). Match the existing badge palette (verify the file by greping for.state-badge-pending).Implementation Plan
Step 1: Server — slug strip-and-flag
Files:
crates/hero_slides_lib/src/slide_ops.rs,crates/hero_slides_lib/src/deck.rs,crates/hero_slides_server/src/rpc.rs.Dependencies: none.
Tasks:
fn strip_leading_position_prefix(slug: &str) -> (String, bool)nearparse_slide_stem. Logic: ifslug.len() >= 3, first two bytes are ASCII digits, third is_, and the rest is non-empty, return(rest.to_string(), true); else(slug.to_string(), false). ASCII-only — matches the existingzero_pad2convention.insert_slide, call the helper on the input slug before the existingformat!("{}_{}", zero_pad2(at), slug). Change return type toResult<(String, bool), HeroSlidesError>.duplicate_slideto destructure:let (new_name, _) = insert_slide(dir, position + 1, &slug)?;.slide_insertindeck.rsto calllet (new_name, slug_normalized) = insert_slide(...)?;and returnOk((SlideFile { name: new_name, ... }, slug_normalized)). Change signature toResult<(SlideFile, bool), HeroSlidesError>.deck.rsto destructure:let (s1, _) = slide_insert(...)?;.handle_slide_insertinrpc.rsto destructure the tuple and emitjson!({ "new_name": slide.name, "inserted": true, "slug_normalized": slug_normalized }).Step 2: Server — unit tests for strip behavior
Files:
crates/hero_slides_lib/src/slide_ops.rs.Dependencies: Step 1.
Tasks:
#[test] fn test_strip_leading_position_prefix_basic()covering:"06_conclusion" -> ("conclusion", true),"99_x" -> ("x", true),"conclusion" -> ("conclusion", false),"6_conclusion" -> ("6_conclusion", false)(single digit unchanged),"ab_conclusion" -> false,"06" -> false(no body),"06_" -> false(empty body).#[test] fn test_insert_slide_strips_double_prefix(): build a 5-slide deck, callinsert_slide(&dir, 6, "06_conclusion"), assert returned(name, normalized)is("06_conclusion", true)and the resulting filename is06_conclusion.md. Add a sibling test for clean slug returningfalse.Step 3: Dashboard —
showPromptinline validationFiles:
crates/hero_slides_ui/templates/index.html,crates/hero_slides_ui/static/js/dashboard.js.Dependencies: none.
Tasks:
<div class="invalid-feedback" id="prompt-modal-error"></div>after#prompt-modal-input.showPrompt: at modal-open, clearis-invalidon the input and reset the error div text. In thesubmithandler, replaceif (!val) return;with: setinput.classList.add('is-invalid'), set the error div text to "This field is required.", focus the input, return without hiding. Re-bind on the cloned OK button per existing pattern. Add aninputlistener (also re-bound per show) that stripsis-invalidand clears the error text on the next keystroke. The Enter-to-submit path (existing keydown handler on the input) goes through the samesubmitfunction so it gets the same validation.Step 4: Dashboard — Create Slide modal inline validation + slug-normalized toast
Files:
crates/hero_slides_ui/templates/index.html,crates/hero_slides_ui/static/js/dashboard.js.Dependencies: Step 1 (response contains
slug_normalized).Tasks:
<div class="invalid-feedback" id="create-slide-title-error"></div>directly under#create-slide-title.doCreateSlide: replace the existingif (!slug) { toast(...); titleEl.focus(); return; }block with: settitleEl.classList.add('is-invalid'), set#create-slide-title-errortext to "Please enter a slide title/slug.", focus, return. Add an input listener (wire-once on modal show) to clear the invalid state. Successful creation also clears it.await rpc('slide.insert', ...)succeeds, if the response hasslug_normalized: true, calltoast('Slug normalized: removed leading number prefix', 'warn').insertSlide(the position-aware variant) — capture the result and conditionally toast.Step 5: Dashboard — failed-slide badge
Files:
crates/hero_slides_ui/static/js/dashboard.js,crates/hero_slides_ui/static/css/dashboard.css.Dependencies: none (uses already-shipped PR #40 payload).
Tasks:
_generatingSlides, addconst _failedSlides = new Map();plusmarkSlideFailed(name, err),clearSlideFailed(name),clearAllFailedSlides()helpers.slideBadge(s), insert at the top: if_failedSlides.has(s.name), return<span class="state-badge state-badge-error" title="${escapeHtml(msg)}">Error</span>. Order: spinner (regenerating) > error > pending > generated. The spinner-wins ordering is enforced because per-slide generate paths callclearSlideFailed(name)before adding to_generatingSlides..state-badge-errorCSS rule using the dashboard's red palette.generateDeck, callclearAllFailedSlides()immediately beforerpc('deck.generateAsync', ...).pollGenerateProgress, inside bothstatus.donebranches (partial-success and plain succeeded), iteratestatus.per_slide(when present) andmarkSlideFailed/clearSlideFailedaccordingly beforeloadSlidesForDeck()._generatingSlides, alsoclearSlideFailed(name). On per-slide success (poller seeshas_png: true),clearSlideFailed(name). On per-slide failure (caught error from poller),markSlideFailed(name, message).Step 6: Manual smoke test pass
Files: none.
Dependencies: Steps 1–5.
Tasks:
06_conclusionin Insert Slide; verify file is06_conclusion.md, an info toast announces the normalization, and the slide renders.Acceptance Criteria
06_conclusionon a 5-slide deck creates file06_conclusion.md(no double-prefix). Server response includesslug_normalized: true; clean slugs returnslug_normalized: false.title=tooltip. A successful regenerate of that slide clears the error.showPromptshows an inline error under the input, appliesis-invalid, keeps focus in the input, and keeps the modal open. Typing in the input clears the invalid state.cargo test -p hero_slides_libpasses with two new test cases forstrip_leading_position_prefixandinsert_slidedouble-prefix handling.slide_ops.rsanddeck.rstests still pass after the tuple-return signature change.Risks / Notes
insert_slide(fromResult<String, _>toResult<(String, bool), _>) andslide_insert(fromResult<SlideFile, _>toResult<(SlideFile, bool), _>) is a breaking API change. The lib appears to be consumed inside this workspace only (the server andduplicate_slide); blast radius is contained. Confirm by grepingslide_insert/insert_slideoutsideslide_ops.rsanddeck.rs— if any other crate uses them (e.g. CLI, agent), update those callers in the same change._failedSlidesmap is in-memory and lost on page reload. Stale failures are not persisted, which is correct: an error badge is a freshness signal, not durable state. After reload the slide reverts to Pending until the next gen run.state-badge-errorclass must exist in the CSS or the badge renders unstyled. Verify the actual CSS file path; if.state-badge-pendinglives in an inline<style>block, add the new rule there.showPrompt, the existingokBtn.replaceWith(okBtn.cloneNode(true))pattern strips listeners on every show. The reworked listener wiring must remain compatible: re-bind both click and the input event after the clone.developmentAFTER PR #41 merged, so all the dashboard helpers (appendSlideCard,renderSlideCardHtml,slides-empty-no-deck/no-slides,showPromptspinner) are present. Step 3 modifies the spinner-equippedshowPrompt, not the original.Test Results
cargo test --workspacetest_generate_single_slide_ai). 7 new tests added inslide_ops.rsforstrip_leading_position_prefixandinsert_slidedouble-prefix handling.cargo fmt --checkcargo fmt)cargo clippy --workspace --no-depsunnecessary_sort_bywarnings inslide_ops.rs:219and:448(unchanged fromdevelopment)bun build --no-bundle dashboard.jsNew automated tests (Step 2)
test_strip_leading_position_prefix_strips_double_digit_prefix—06_conclusion,99_x,00_introall stripped, flagtrue.test_strip_leading_position_prefix_leaves_clean_slug_untouched—conclusion,my_slide_nameunchanged, flagfalse.test_strip_leading_position_prefix_does_not_strip_single_digit—6_conclusionunchanged.test_strip_leading_position_prefix_does_not_strip_letters—ab_conclusionunchanged.test_strip_leading_position_prefix_handles_no_body—06_and06unchanged (no body to keep).test_insert_slide_strips_double_prefix— fullinsert_slide(&dir, 6, "06_conclusion")returns("06_conclusion", true); file06_conclusion.mdexists.test_insert_slide_clean_slug_returns_false— clean slugsummaryreturns flagfalse.Diffstat
Manual smoke needed before merge
06_conclusionon a 5-slide deck via Insert Slide. Expect: file06_conclusion.md(no double prefix); aSlug normalized: removed leading number prefixtoast.showPrompt-driven modals (Create Deck, Rename, Duplicate, Insert Slide, Move Slide, Folder, Subfolder): inline error appears, modal stays open, typing clears the error.mkdir -p output/02_x.pngper #39 testing). Verify the failed slide shows a redErrorbadge with the failure message in thetitle=tooltip; regenerating that slide clears the badge.Implementation Summary
All 5 implementation steps complete on branch
development_slug_double_prefix(rebased on latestdevelopmentpost-merge of PRs #40 and #41).Files modified
crates/hero_slides_lib/src/slide_ops.rsstrip_leading_position_prefix(&str) -> (String, bool)helper.insert_slidenow strips a leadingNN_from the user's slug before prepending the position prefix, and returns(String, bool). 7 new unit tests covering the strip logic and the fullinsert_slideflow.crates/hero_slides_lib/src/deck.rsslide_insertpropagates the newslug_normalizedbool: signature changed toResult<(SlideFile, bool), HeroSlidesError>. Internal callers (slide_copy_to_deckplus 4 in-file tests) updated to destructure.crates/hero_slides_rhai/src/slide_module.rsslide_insertdestructures the tuple and exposesslug_normalizedin the returnedMap.crates/hero_slides_server/src/rpc.rshandle_slide_insertincludesslug_normalizedin the JSON response alongsidenew_nameandinserted.crates/hero_slides_ui/static/js/dashboard.jsshowPrompt: clones the input on each open (drops stale listeners), wires per-keystroke clear-invalid handler, replaces silent empty-input bail with inlineis-invalid+ error message; the existing PR #41 spinner phase is left intact.doCreateSlideandinsertSlide: captureslide.insertresponse; show a warn toast"Slug normalized: removed leading number prefix"whenslug_normalized: true. New_failedSlidesMap and helpers;slideBadgerenders a redErrorbadge with tooltip when an entry exists.generateDeckclears all failed entries before each run.pollGenerateProgress(both partial-success and plain branches) readsstatus.per_slideand marks/clears per slide. Per-slide generate paths (generateSlide,pollSlideGenerateProgress,generateAllSlides) clear on retry/success and mark on failure.crates/hero_slides_ui/templates/index.html<div class="invalid-feedback" id="prompt-modal-error"></div>under#prompt-modal-inputand<div class="invalid-feedback" id="create-slide-title-error"></div>under#create-slide-title.crates/hero_slides_ui/static/css/dashboard.csswas not modified —.state-badge-erroralready exists at line 157 with a red palette matching the dashboard theme.Behavior
06_conclusionon a 5-slide deck06_06_conclusion.md; later generation fails silently with a "Pending" badge06_conclusion.md. Warn toast: "Slug normalized: removed leading number prefix". Generation works.showPrompt(Create Deck, Rename, Duplicate, Insert Slide, Move Slide, Folder, Subfolder)Errorbadge with the failure message intitle=tooltip. Regenerating clears it.Acceptance criteria
06_conclusionon a 5-slide deck creates file06_conclusion.md(no double-prefix). Server response includesslug_normalized: true; clean slugs returnslug_normalized: false.title=tooltip. Regenerating clears the error.showPromptshows an inline error, appliesis-invalid, keeps focus in the input, keeps the modal open. Typing clears the invalid state.cargo test -p hero_slides_libpasses with 7 new test cases.cargo test --workspace: 105 passed / 0 failed / 1 ignored.Notes for review
insert_slideandslide_insert) is a breaking change for any downstream consumer. In-tree callers updated:duplicate_slide,slide_copy_to_deck,handle_slide_insert, the Rhai binding, and 4 in-file tests. Outside this workspace none were found._failedSlidesis in-memory and lost on page reload. After reload a slide reverts to "Pending" until the next generation pass — acceptable for an ephemeral status indicator.slug_normalizedin the result Map. Existing Rhai scripts that readname,path,slug,number,hiddencontinue to work; the new key is additive.showPromptlost its{ once: true }flag. Without that change, a validation-failure submit would permanently disable OK on that modal open. The cloneNode-on-show in PR #41 already covers per-invocation listener cleanup, so removing the flag is safe..state-badge-errorwas already present indashboard.css:157, so no CSS edits were needed.rust_embed. After deploy users may need a hard refresh, and thehero_slides_uibinary needs rebuilding.