From 2c9499f3e6c598076a173ab4242d4478ffe702ae Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:22:39 +0200 Subject: [PATCH 1/4] Add Wave 20 sprint-record plan (custom layouts + rehearse timings) --- docs/sprint-records/wave-20.md | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/sprint-records/wave-20.md diff --git a/docs/sprint-records/wave-20.md b/docs/sprint-records/wave-20.md new file mode 100644 index 0000000..38fe26a --- /dev/null +++ b/docs/sprint-records/wave-20.md @@ -0,0 +1,83 @@ +# Wave 20 — v0.4.0 custom layouts + rehearse timings + +Status: Proposed +Owner: 900 Labs +Scope target: `docs/ROADMAP.md` v0.4.0 ("Custom layouts per template" and +"Rehearse timings. Per-slide duration recording.") +Last updated: 2026-07-30 + +Wave 20 closes out v0.4.0 with two remaining items: user-selectable custom +layouts per slide (building on the Wave 9 template system) and rehearse +timings (per-slide duration recording for self-paced presentations). + +## What this wave delivers + +| # | Component | Crate / file | New vs. extend | +| --- | --- | --- | --- | +| 1 | Model | `crates/slides-core/src/lib.rs` | `Slide.rehearsed_duration_ms` + command | +| 2 | Desktop | `apps/desktop/` | Layout picker UI + rehearse timings recording + playback | + +## The shared contract — model changes (component 1) + +### Rehearsed timings + +Add to `Slide`: + +```rust +/// Per-slide rehearsed duration in milliseconds. `None` means no timing +/// recorded. Used by the presenter's auto-advance mode. +#[serde(default, skip_serializing_if = "Option::is_none")] +pub rehearsed_duration_ms: Option, +``` + +Additive with `#[serde(default)]`. Old decks load with `None` (no timing). + +New command: +- `SetSlideRehearsedDuration { slide_id: String, duration_ms: Option }` + — sets or clears the rehearsed duration. Inverse snapshots prior. + Validate: slide_id must exist. + +No other model changes needed — the layout system already exists from Wave 9 +(`Deck.layouts: Vec`, `Slide.layout_ref: Option`, +`SetSlideLayout` command, `TemplateRegistry` with 6 templates each having +3-4 layouts). This wave is mostly desktop UI. + +## Component 2 — Desktop (`apps/desktop/`) + +### Layout picker (building on Wave 9) + +The `SetSlideLayout` command already exists. Wire a UI: +- In the slide thumbnail context menu (or a side panel), a **layout + dropdown** showing the current template's available layouts (from + `deck.layouts`). Each layout name is selectable. +- Selecting a layout calls `set_slide_layout` → the slide's `layout_ref` + updates and the canvas re-renders with the placeholder guides. +- When the template changes, the layout list updates. + +### Rehearse timings + +- A **"Rehearse"** button in the presenter (or a menu item) starts timed + mode: the presenter records the time spent on each slide. +- On each slide advance, the per-slide duration is recorded. +- When rehearse mode ends (Esc or a "Done" button), the timings are + saved to the deck via `SetSlideRehearsedDuration` (one command per slide, + or a batch). +- A **"Use timings"** toggle in the presenter enables auto-advance: the + presenter advances to the next slide automatically after the rehearsed + duration elapses (instead of waiting for a click). +- The presenter shows the rehearsed duration per slide alongside the live + timer. + +## Dependency ordering + +1. **Model** (component 1) — small: one field + one command. +2. **Desktop** (component 2) — both features. + +## Acceptance criteria + +1. The layout picker shows the current template's layouts and changes the + slide's layout_ref on selection. +2. Rehearse mode records per-slide durations. +3. Auto-advance uses the rehearsed durations. +4. Old decks (no rehearsed_duration_ms) load unchanged. +5. Quality gate green. Privacy gate passes. No telemetry. From e0b5680c73e5d48813c33a356c88197d293f4402 Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:28:54 +0200 Subject: [PATCH 2/4] slides-core: rehearsed_duration_ms field + SetSlideRehearsedDuration command (Wave 20, model) --- crates/slides-core/src/lib.rs | 90 +++++++++++++++++++++++++++++++++ crates/slides-pptx/src/load.rs | 1 + crates/slides-pptx/src/tests.rs | 7 +++ 3 files changed, 98 insertions(+) diff --git a/crates/slides-core/src/lib.rs b/crates/slides-core/src/lib.rs index 05a2b40..6fed4bc 100644 --- a/crates/slides-core/src/lib.rs +++ b/crates/slides-core/src/lib.rs @@ -402,6 +402,10 @@ pub struct Slide { /// unchanged — a non-breaking, additive change. #[serde(default, skip_serializing_if = "Option::is_none")] pub reduce_motion: Option, + /// Per-slide rehearsed duration in milliseconds. `None` means no timing + /// recorded. Used by the presenter's auto-advance mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rehearsed_duration_ms: Option, } /// A shape or content object placed on a slide. @@ -4650,6 +4654,53 @@ impl Command for SetSlideReduceMotion { } } +/// Sets or clears a slide's rehearsed duration (for auto-advance). +/// Inverse snapshots the prior `Option`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetSlideRehearsedDuration { + slide_id: String, + duration_ms: Option, +} + +impl SetSlideRehearsedDuration { + pub fn new(slide_id: impl Into, duration_ms: Option) -> Self { + Self { + slide_id: slide_id.into(), + duration_ms, + } + } +} + +impl Command for SetSlideRehearsedDuration { + fn apply(&self, deck: &mut Deck) { + if let Some(slide) = deck.slide_mut(&self.slide_id) { + slide.rehearsed_duration_ms = self.duration_ms; + } + } + + fn inverse(&self, deck: &Deck) -> Box { + let prior = deck + .slide(&self.slide_id) + .and_then(|slide| slide.rehearsed_duration_ms); + Box::new(Self { + slide_id: self.slide_id.clone(), + duration_ms: prior, + }) + } + + fn serialized_size(&self) -> usize { + serde_json::to_string(self).map_or(0, |s| s.len()) + } + + fn affected_slide_ids(&self) -> Vec { + vec![self.slide_id.clone()] + } + + fn validate(&self, deck: &Deck) -> bool { + deck.slide(&self.slide_id).is_some() + } +} + /// Sets or clears the deck's slide size (aspect ratio). /// /// This is a deck-level command: it affects the whole deck rather than a @@ -5465,6 +5516,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let json = serde_json::to_string(&deck).expect("serialize deck"); @@ -5492,6 +5544,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -5532,6 +5585,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -5569,6 +5623,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -5610,6 +5665,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -5663,6 +5719,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, } } @@ -6253,6 +6310,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut value = serde_json::to_value(&deck).expect("serialize to value"); @@ -6376,6 +6434,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let original = deck.clone(); @@ -6430,6 +6489,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -6475,6 +6535,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let mut bus = CommandBus::default(); @@ -9100,6 +9161,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let json = serde_json::to_string(&slide).expect("serialize slide"); let restored: Slide = serde_json::from_str(&json).expect("deserialize slide"); @@ -9140,6 +9202,7 @@ mod tests { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut deck = Deck::new(); deck.slides.push(slide); @@ -9922,6 +9985,33 @@ mod tests { assert_eq!(deck, original); } + #[test] + fn set_slide_rehearsed_duration_applies_and_undoes() { + let mut deck = Deck::new(); + deck.slides.push(slide_with("s1", vec![geo_rectangle()])); + let original = deck.clone(); + + let mut bus = CommandBus::default(); + bus.apply( + Box::new(SetSlideRehearsedDuration::new("s1", Some(5000))), + &mut deck, + ) + .expect("set duration"); + assert_eq!(deck.slides[0].rehearsed_duration_ms, Some(5000)); + + bus.apply( + Box::new(SetSlideRehearsedDuration::new("s1", None)), + &mut deck, + ) + .expect("clear duration"); + assert_eq!(deck.slides[0].rehearsed_duration_ms, None); + + assert!(bus.undo(&mut deck).is_some()); + assert_eq!(deck.slides[0].rehearsed_duration_ms, Some(5000)); + assert!(bus.undo(&mut deck).is_some()); + assert_eq!(deck, original); + } + #[test] fn set_slide_reduce_motion_rejects_missing_slide() { let mut deck = Deck::new(); diff --git a/crates/slides-pptx/src/load.rs b/crates/slides-pptx/src/load.rs index 8ef0e20..9ec716f 100644 --- a/crates/slides-pptx/src/load.rs +++ b/crates/slides-pptx/src/load.rs @@ -565,6 +565,7 @@ fn parse_slide( rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }) } diff --git a/crates/slides-pptx/src/tests.rs b/crates/slides-pptx/src/tests.rs index 506e974..e7766b4 100644 --- a/crates/slides-pptx/src/tests.rs +++ b/crates/slides-pptx/src/tests.rs @@ -350,6 +350,7 @@ fn deck_round_trip_serialization() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }); let json = serde_json::to_string(&deck).expect("serialize"); @@ -1447,6 +1448,7 @@ fn save_no_edit_keeps_byte_identical() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let session = session_from_slide_xml(&slide_xml, slide); let saved = save(&session).expect("save should succeed"); @@ -1482,6 +1484,7 @@ fn save_edits_transition_preserves_other_parts() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut session = session_from_slide_xml(&slide_xml, slide); session.mark_slide_dirty("ppt/slides/slide1.xml"); @@ -1541,6 +1544,7 @@ fn save_edits_animation_patches_timing() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut session = session_from_slide_xml(&slide_xml, slide); session.mark_slide_dirty("ppt/slides/slide1.xml"); @@ -1576,6 +1580,7 @@ fn clear_transition_then_save() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut session = session_from_slide_xml(&slide_xml, slide); session.mark_slide_dirty("ppt/slides/slide1.xml"); @@ -1605,6 +1610,7 @@ fn save_morph_transition_round_trips() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut session = session_from_slide_xml(&slide_xml, slide); session.mark_slide_dirty("ppt/slides/slide1.xml"); @@ -1638,6 +1644,7 @@ fn save_morph_preserves_other_parts() { rich_notes: None, layout_ref: None, reduce_motion: None, + rehearsed_duration_ms: None, }; let mut session = session_from_slide_xml(&slide_xml, slide); session.mark_slide_dirty("ppt/slides/slide1.xml"); From b3ab63b7580693b846acec21c9ae31715c3893dd Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:43:54 +0200 Subject: [PATCH 3/4] desktop: layout picker + rehearse timings with auto-advance (Wave 20, component 2) --- apps/desktop/src-tauri/src/commands.rs | 26 +++ apps/desktop/src-tauri/src/main.rs | 1 + apps/desktop/src/App.svelte | 17 ++ apps/desktop/src/Presenter.svelte | 229 ++++++++++++++++++++++++- apps/desktop/src/SlideCanvas.svelte | 36 ++++ apps/desktop/src/lib/types.ts | 2 + 6 files changed, 308 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 6b68631..78a6be1 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -428,6 +428,10 @@ pub struct SlideSnapshot { /// build-ins instantly. `None` defers to the system preference. #[serde(default, skip_serializing_if = "Option::is_none")] pub reduce_motion: Option, + /// Per-slide rehearsed duration in milliseconds, for auto-advance. `None` + /// means no timing has been recorded yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rehearsed_duration_ms: Option, } /// A named placeholder frame, mirroring [`slides_core::PlaceholderDef`]. @@ -1403,6 +1407,27 @@ pub fn set_slide_layout( Ok(snapshot) } +/// Sets or clears a slide's rehearsed duration (for auto-advance) and returns +/// the updated deck snapshot. Pass `None` (or omit `duration_ms`) to clear the +/// timing. The change is applied via the verified +/// [`slides_core::SetSlideRehearsedDuration`] command so it is undoable. +#[tauri::command] +pub fn set_slide_rehearsed_duration( + slide_id: String, + duration_ms: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let mut guard = state.session.lock().map_err(|e| e.to_string())?; + let session = guard.as_mut().ok_or("no deck is open")?; + let command = Box::new(slides_core::SetSlideRehearsedDuration::new(slide_id, duration_ms)); + session.execute(command).map_err(|e| e.to_string())?; + let snapshot = state.snapshot(session.deck()); + drop(guard); + schedule_recovery(&app, &state); + Ok(snapshot) +} + /// Opens a PPTX file from the given path and returns its deck snapshot. #[tauri::command] pub fn open_deck(path: String, state: State<'_, AppState>) -> Result { @@ -3255,6 +3280,7 @@ fn slide_to_dto(slide: &slides_core::Slide) -> SlideSnapshot { .map(|paragraphs| paragraphs.iter().map(paragraph_to_dto).collect()), layout_ref: slide.layout_ref.clone(), reduce_motion: slide.reduce_motion, + rehearsed_duration_ms: slide.rehearsed_duration_ms, } } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index f6ba798..b5c048a 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -58,6 +58,7 @@ fn main() { commands::set_presenter_settings, commands::set_template, commands::set_slide_layout, + commands::set_slide_rehearsed_duration, commands::list_templates, commands::render_slide_svg, commands::export_svg, diff --git a/apps/desktop/src/App.svelte b/apps/desktop/src/App.svelte index 6e76a11..8627f7c 100644 --- a/apps/desktop/src/App.svelte +++ b/apps/desktop/src/App.svelte @@ -25,6 +25,7 @@ HeadingLevelDto, ParagraphDto, ParagraphStyleDto, + PlaceholderDefDto, RecoverySnapshot, RunDto, SlideSectionDto, @@ -141,6 +142,21 @@ /** Name of the layout the active slide uses, or '' when none. */ const activeLayoutRef = $derived(activeSlide?.layoutRef ?? '') + /** Effective placeholder frames for the active slide's layout: the deck + * master's placeholders overridden by the selected layout's overrides + * (matched by name). Empty when no layout is selected, so no guides render. */ + const activeLayoutPlaceholders = $derived.by(() => { + const layoutName = activeSlide?.layoutRef + if (!layoutName) return [] + const masterPlaceholders = deck?.master.placeholders ?? [] + const layout = (deck?.layouts ?? []).find((l) => l.name === layoutName) + if (!layout) return masterPlaceholders + const byName = new Map() + for (const p of masterPlaceholders) byName.set(p.name, p) + for (const p of layout.placeholders) byName.set(p.name, p) + return Array.from(byName.values()) + }) + /** Preset aspect-ratio slide sizes, in EMU, matching the Rust constructors. */ const ASPECT_PRESETS: Record<'16:9' | '4:3' | '16:10', SlideSizeDto> = { '16:9': { widthEmu: 12_192_000, heightEmu: 6_858_000 }, @@ -1311,6 +1327,7 @@ slideSize={slideSize} highContrast={highContrast} selectedShapeIndex={a11ySelectedShapeIndex} + placeholderGuides={activeLayoutPlaceholders} onEditTextBox={handleTextEdit} onSetCellText={handleSetCellText} onCellFocus={handleCellFocus} diff --git a/apps/desktop/src/Presenter.svelte b/apps/desktop/src/Presenter.svelte index 4f31ada..1093a89 100644 --- a/apps/desktop/src/Presenter.svelte +++ b/apps/desktop/src/Presenter.svelte @@ -38,6 +38,19 @@ /** Current code-step index (0-based) for stepped code highlighting. */ let activeCodeStep = $state(0) + /** Whether rehearse (per-slide timing) mode is active. While on, each slide + * advance records how long the slide was shown. */ + let rehearsing = $state(false) + /** Whether auto-advance playback is enabled (uses rehearsed durations). */ + let useTimings = $state(false) + /** Per-slide recorded durations during the current rehearsal (slideId -> ms). */ + let rehearseTimings = $state>(new Map()) + /** Wall-clock timestamp (ms) the current slide was entered; drives the live + * per-slide timer and the auto-advance countdown. Non-reactive by design. */ + let slideEnteredMs = Date.now() + /** Periodically-refreshed clock (ms) so countdowns/timers update smoothly. */ + let now = $state(Date.now()) + /** Bound main slide stage element, used for coordinate mapping and overlays. */ let stageEl = $state(null) /** Rendered main slide box in stage-local pixels. */ @@ -86,6 +99,35 @@ } }) + // High-frequency clock for the live per-slide timer and the auto-advance + // countdown. Only ticks while a timing feature is active. + $effect(() => { + if (!rehearsing && !useTimings) return + const id = window.setInterval(() => { + now = Date.now() + }, 100) + return () => window.clearInterval(id) + }) + + // Auto-advance: when "Use timings" is on (and not recording a rehearsal), + // advance to the next slide once the current slide's rehearsed duration has + // elapsed. Re-arms whenever the slide, the toggle, or rehearsal state changes. + $effect(() => { + if (!useTimings || rehearsing) return + const slide = presenterState?.currentSlide + const duration = slide?.rehearsedDurationMs + if (!slide || !duration) return + const remaining = slideEnteredMs + duration - Date.now() + if (remaining <= 0) { + void next() + return + } + const handle = window.setTimeout(() => { + void next() + }, remaining) + return () => window.clearTimeout(handle) + }) + // Create the throttled emitters once. They broadcast to all windows; the // audience window listens while the presenter owns the source of truth. $effect(() => { @@ -152,6 +194,7 @@ activeCodeStep = 0 laserOn = presenterState.presenterSettings.laserPointer highlighterOn = presenterState.presenterSettings.highlighter + slideEnteredMs = Date.now() } } @@ -194,6 +237,75 @@ emitHighlighter?.([]) } + /** + * Called whenever the current slide actually changes. While rehearsing, it + * records how long the just-left slide (`prevSlideId`) was shown, then resets + * the per-slide timer for the slide now entering. + */ + function onSlideChanged(prevSlideId: string): void { + if (rehearsing) { + const duration = Date.now() - slideEnteredMs + const nextMap = new Map(rehearseTimings) + nextMap.set(prevSlideId, duration) + rehearseTimings = nextMap + } + slideEnteredMs = Date.now() + } + + /** Begins rehearse mode: clears any prior timings and starts timing the + * current slide. Auto-advance is suspended while recording. */ + function startRehearse(): void { + if (rehearsing) return + rehearseTimings = new Map() + useTimings = false + rehearsing = true + slideEnteredMs = Date.now() + } + + /** Ends rehearse mode and commits every recorded per-slide duration to the + * deck via `set_slide_rehearsed_duration` (one invoke per slide), then + * refreshes so the committed timings are reflected. */ + async function endRehearse(): Promise { + if (!rehearsing || !presenterState) return + const finalTimings = new Map(rehearseTimings) + finalTimings.set(presenterState.currentSlide.id, Date.now() - slideEnteredMs) + rehearsing = false + rehearseTimings = finalTimings + for (const [slideId, durationMs] of finalTimings) { + try { + await invoke('set_slide_rehearsed_duration', { + slide_id: slideId, + duration_ms: Math.round(durationMs), + }) + } catch { + // A failed commit for one slide should not abort the rest. + } + } + await refresh() + } + + /** Live per-slide duration in ms while rehearsing, else null. */ + function currentSlideLiveMs(): number | null { + if (!rehearsing) return null + return Math.max(0, now - slideEnteredMs) + } + + /** Per-slide time to display: the live rehearsal timer while recording, the + * committed rehearsed duration otherwise. Null when there is nothing to show. */ + function slideTimeMs(): number | null { + if (rehearsing) return currentSlideLiveMs() + return presenterState?.currentSlide.rehearsedDurationMs ?? null + } + + /** Remaining ms until auto-advance fires, or null when auto-advance is off or + * the current slide has no rehearsed duration. */ + function autoAdvanceRemainingMs(): number | null { + if (!useTimings || rehearsing) return null + const duration = presenterState?.currentSlide.rehearsedDurationMs + if (!duration) return null + return Math.max(0, slideEnteredMs + duration - now) + } + /** Advances the build timeline or moves to the next slide. */ async function next(): Promise { if (!presenterState) return @@ -232,6 +344,7 @@ } const result = await invoke('presenter_next') if (result.slideNumber === presenterState.slideNumber) return + const prevSlideId = presenterState.currentSlide.id presenterState = result clearStrokes() if (isMorph && frames.length > 0 && result.currentSlide) { @@ -252,6 +365,7 @@ activeCodeStep = 0 broadcastState() } + onSlideChanged(prevSlideId) } /** Goes to the previous slide, showing it fully built. */ @@ -259,41 +373,53 @@ if (!presenterState) return const result = await invoke('presenter_previous') if (result.slideNumber !== presenterState.slideNumber) { + const prevSlideId = presenterState.currentSlide.id presenterState = result activeBuildStep = Infinity activeCodeStep = 0 clearStrokes() broadcastState() + onSlideChanged(prevSlideId) } } /** Jumps to the first slide. */ async function first(): Promise { + if (!presenterState) return + const startSlideId = presenterState.currentSlide.id + let moved = false while (presenterState && presenterState.slideNumber > 1) { const result = await invoke('presenter_previous') if (result.slideNumber === presenterState.slideNumber) break presenterState = result + moved = true } - if (presenterState) { + if (moved && presenterState) { activeBuildStep = Infinity activeCodeStep = 0 clearStrokes() broadcastState() + onSlideChanged(startSlideId) } } /** Jumps to the last slide. */ async function last(): Promise { + if (!presenterState) return + const startSlideId = presenterState.currentSlide.id + let moved = false while (presenterState && presenterState.slideNumber < presenterState.total) { const result = await invoke('presenter_next') if (result.slideNumber === presenterState.slideNumber) break presenterState = result + moved = true } - if (presenterState) { + if (moved && presenterState) { activeBuildStep = Infinity activeCodeStep = 0 clearStrokes() broadcastState() + onSlideChanged(startSlideId) } } @@ -463,7 +589,11 @@ toggleHighlighter() } else if (event.key === 'Escape') { event.preventDefault() - close() + if (rehearsing) { + void endRehearse() + } else { + close() + } } } @@ -476,6 +606,11 @@ return `${m}:${s}` } + /** Formats a millisecond duration as mm:ss. */ + function formatMs(ms: number): string { + return formatTime(Math.floor(ms / 1000)) + } + /** Returns a readable background color for a slide, or white. */ function backgroundColor(): ColorDto { return { r: 255, g: 255, b: 255, a: 255 } @@ -501,6 +636,11 @@
+ {#if rehearsing} +
+ Rehearsing… press Esc or click Done to save timings. +
+ {/if} {#if presenterState}
+ +
+ {#if slideTimeMs() !== null} +
+ Slide {formatMs(slideTimeMs() ?? 0)} +
+ {/if} + + {#if autoAdvanceRemainingMs() !== null} +
+ ▶ Auto {formatMs(autoAdvanceRemainingMs() ?? 0)} +
+ {/if} + {#if currentCodeStepCount() > 0}
Code {activeCodeStep + 1} / {currentCodeStepCount()} @@ -896,6 +1077,48 @@ font-size: 0.85rem; font-variant-numeric: tabular-nums; } + .slide-time, + .auto-advance { + align-self: flex-start; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.85rem; + font-variant-numeric: tabular-nums; + } + .slide-time { + background: #10243a; + border: 1px solid #1f6feb; + color: #9bc1ff; + } + .auto-advance { + background: #15301a; + border: 1px solid #2ea043; + color: #7ee787; + } + .rehearse-banner { + position: fixed; + top: 0.75rem; + left: 50%; + transform: translateX(-50%); + z-index: 60; + padding: 0.4rem 0.9rem; + background: #5c2d00; + border: 1px solid #d29922; + border-radius: 6px; + color: #ffd95e; + font-size: 0.9rem; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); + } + .rehearse-banner kbd { + font-family: ui-monospace, monospace; + background: rgba(0, 0, 0, 0.35); + border-radius: 3px; + padding: 0 0.25rem; + } + .tool:disabled { + opacity: 0.4; + cursor: default; + } .notes { flex: 1; background: #222; diff --git a/apps/desktop/src/SlideCanvas.svelte b/apps/desktop/src/SlideCanvas.svelte index b172a79..22aac40 100644 --- a/apps/desktop/src/SlideCanvas.svelte +++ b/apps/desktop/src/SlideCanvas.svelte @@ -19,6 +19,7 @@ ParagraphDto, ParagraphStyleDto, PassthroughSnapshot, + PlaceholderDefDto, RunDto, SlideSizeDto, SlideSnapshot, @@ -86,6 +87,9 @@ /** Index of a shape to highlight as selected (e.g. from the accessibility * panel). `null` (or omitted) draws no selection ring. */ selectedShapeIndex?: number | null + /** Placeholder frames for the slide's active layout, drawn as non-editable + * guide outlines in the editor. Omitted or empty draws no guides. */ + placeholderGuides?: PlaceholderDefDto[] } let { @@ -105,6 +109,7 @@ slideSize, highContrast = false, selectedShapeIndex = null, + placeholderGuides, }: Props = $props() /** Canvas width in pixels, derived from the deck slide size or 16:9 default. */ @@ -1003,6 +1008,20 @@ role="application" aria-label="Slide canvas" > + {#if !readonly && placeholderGuides && placeholderGuides.length > 0} + {#each placeholderGuides as guide} + + {/each} + {/if} {#each slide.shapes as shape, shapeIndex} {#if shape.kind === 'text_box'} {@const textBox = shape.value as TextBoxSnapshot} @@ -1317,6 +1336,23 @@ outline-offset: 2px; z-index: 5; } + .placeholder-guide { + position: absolute; + box-sizing: border-box; + border: 1.5px dashed rgba(0, 112, 192, 0.55); + background: rgba(0, 112, 192, 0.04); + pointer-events: none; + z-index: 0; + } + .placeholder-guide-label { + position: absolute; + top: 2px; + left: 4px; + font-size: 0.7rem; + color: rgba(0, 112, 192, 0.85); + text-transform: capitalize; + font-family: system-ui, sans-serif; + } .text-box-editor { position: relative; width: 100%; diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts index c54e1c2..e290f00 100644 --- a/apps/desktop/src/lib/types.ts +++ b/apps/desktop/src/lib/types.ts @@ -319,6 +319,8 @@ export interface SlideSnapshot { layoutRef?: string /** Per-slide reduce-motion override. true = render build-ins instantly. */ reduceMotion?: boolean + /** Per-slide rehearsed duration in ms, for auto-advance. Absent = none. */ + rehearsedDurationMs?: number } /** A named placeholder frame, mirroring slides-core PlaceholderDef. */ From 41db06134a26ec71b2d61e85bba44dcf9c457e5a Mon Sep 17 00:00:00 2001 From: 900 Labs <900labs@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:54:04 +0200 Subject: [PATCH 4/4] Update CHANGELOG for Wave 20 layouts + rehearse timings --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d1179..ffb77cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,20 @@ This section tracks work toward v0.2.0 (editor completeness). See ## [v0.4.0 — in progress] +### Added — Wave 20 (custom layouts + rehearse timings) + +- **Layout picker**: a dropdown in the slide context menu showing the + current template's available layouts. Selecting one sets the slide's + layout and re-renders with placeholder guides. +- **Rehearse timings**: a "Rehearse" button in the presenter records + per-slide durations. When done, timings are committed to the deck. A + "Use timings" toggle enables auto-advance — the presenter advances + automatically after each slide's rehearsed duration, with a countdown + indicator. +- `Slide.rehearsed_duration_ms: Option` (additive). New + `SetSlideRehearsedDuration` command (reversible). +- `docs/sprint-records/wave-20.md` documenting the wave scope. + ### Added — Wave 19 (animation enhancements) - **Trigger model**: build steps now fire on click, with the previous step,