From 6005bd32b725e330d4943c658207a5df0eb4b1a1 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Sun, 17 May 2026 18:00:37 -0700 Subject: [PATCH] feat(wc): audience-mode slide overview grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolbar's existing layout-grid icon now toggles a slide overview: the presentation host becomes a 2-column grid with every slide rendered as a 16:9 preview at scale. Clicking a slide navigates to it and exits overview; ESC also exits. - xstate: `overview: boolean` context field, `overview.toggle` and `overview.set` events with a `toggleOverview` action. Per-tab UI state — not relayed across the cross-tab bridge. - presentation/wc.ts: `:host([data-overview])` layout. Exposes `--morkdeck-slide-host-{height,aspect,snap}` CSS variables so the slide consumes them via var() rather than us fighting :host vs ::slotted specificity. `grid-auto-rows: max-content` keeps rows at the slides' aspect-ratio'd natural height (228px each) instead of dividing 100vh evenly and overflowing slides into later rows. - Slide click delegation walks `event.composedPath()` to find the enclosing , then sends navigate.scroll + an `overview.set: false` so the same toolbar button is the exit. - IntersectionObserver guarded to no-op while overview is on — otherwise it picks "whichever slide is most-intersecting in the new grid" as current and destroys the round-trip. - Scroll re-anchor on every overview transition deferred to requestAnimationFrame; reading slide.offsetTop synchronously in updated() raced with the browser's layout/scroll-snap settling after the attribute swap. Co-Authored-By: Claude Opus 4.7 --- deno.lock | 11 ++ packages/runtime/actor/machine.ts | 7 ++ packages/runtime/actor/setup.ts | 17 +++ packages/runtime/actor/types.ts | 12 ++ packages/wc/components/presentation/wc.ts | 130 ++++++++++++++++++++++ packages/wc/components/slide.ts | 14 ++- packages/wc/components/toolbar.ts | 6 +- 7 files changed, 193 insertions(+), 4 deletions(-) diff --git a/deno.lock b/deno.lock index f113e19..6f4eb17 100644 --- a/deno.lock +++ b/deno.lock @@ -12,6 +12,8 @@ "jsr:@deno/loader@~0.3.3": "0.3.4", "jsr:@es-toolkit/es-toolkit@^1.39.9": "1.39.9", "jsr:@eta-dev/eta@^3.5.0": "3.5.0", + "jsr:@levischuck/tiny-qr-svg@^0.0.9": "0.0.9", + "jsr:@levischuck/tiny-qr@^0.0.9": "0.0.9", "jsr:@std/async@1": "1.0.14", "jsr:@std/async@^1.0.14": "1.0.14", "jsr:@std/bytes@^1.0.2": "1.0.5", @@ -135,6 +137,15 @@ "@eta-dev/eta@3.5.0": { "integrity": "6b70827efc14c7cbf08498ac7a922ecab003641caf3852a6cb5b1b12ee58fb37" }, + "@levischuck/tiny-qr@0.0.9": { + "integrity": "d6ab20a6a7ef0780c2db450520ebcb65511803e2a9ac1e559aade714c717b212" + }, + "@levischuck/tiny-qr-svg@0.0.9": { + "integrity": "09a82d027837284bece0e6ee92efd5632fad8beb5fe01ff32aa4828094c76071", + "dependencies": [ + "jsr:@levischuck/tiny-qr" + ] + }, "@std/async@1.0.14": { "integrity": "62e954a418652c704d37563a3e54a37d4cf0268a9dcaeac1660cc652880b5326" }, diff --git a/packages/runtime/actor/machine.ts b/packages/runtime/actor/machine.ts index 54d1823..a049044 100644 --- a/packages/runtime/actor/machine.ts +++ b/packages/runtime/actor/machine.ts @@ -21,6 +21,7 @@ export const presentation = machineBase.createMachine({ activeTool: "pen", // Rose Pine "love" — high contrast against the dark slide canvas. penColor: "#eb6f92", + overview: false, }, states: { initializing: { @@ -63,6 +64,12 @@ export const presentation = machineBase.createMachine({ "role.set": { actions: ["setRole"], }, + "overview.toggle": { + actions: ["toggleOverview"], + }, + "overview.set": { + actions: ["toggleOverview"], + }, "draw.stroke.start": { actions: ["addStroke"], }, diff --git a/packages/runtime/actor/setup.ts b/packages/runtime/actor/setup.ts index 77ec2c3..9a38055 100644 --- a/packages/runtime/actor/setup.ts +++ b/packages/runtime/actor/setup.ts @@ -165,6 +165,23 @@ export const machineBase = setup({ return { ...context, role: event.role }; }), + /** + * Flip the audience-mode overview grid on or off. `overview.toggle` + * is the toolbar button's primary path; `overview.set` is for the + * click-to-navigate-and-exit handler in the presentation component + * so it can deterministically set false (toggle would be wrong if + * something else changed the value between the click and the send). + */ + toggleOverview: assign(({ context, event }) => { + if (event.type === "overview.toggle") { + return { ...context, overview: !context.overview }; + } + if (event.type === "overview.set") { + return { ...context, overview: event.value }; + } + return context; + }), + /** * Append a freshly-started stroke to the slide's stroke list. The * stroke arrives with its initial point already in place so the diff --git a/packages/runtime/actor/types.ts b/packages/runtime/actor/types.ts index 2479b3d..76bf6ae 100644 --- a/packages/runtime/actor/types.ts +++ b/packages/runtime/actor/types.ts @@ -75,6 +75,13 @@ export interface Context { activeTool: Tool; /** Pen color (CSS color string). */ penColor: string; + /** + * Audience-mode overview grid. When true the presentation renders + * every slide as a clickable preview in a 2-column grid; clicking + * a preview navigates to that slide and exits overview. Per-tab UI + * state; not relayed across the cross-tab bridge. + */ + overview: boolean; } type NavigationEvent = { @@ -102,6 +109,10 @@ type TimerEvent = type RoleEvent = { type: "role.set"; role: Role }; +type OverviewEvent = + | { type: "overview.toggle" } + | { type: "overview.set"; value: boolean }; + type DrawEvent = | { type: "draw.stroke.start"; slideId: string; stroke: Stroke } | { @@ -123,6 +134,7 @@ export type Events = | PresentationEvent | TimerEvent | RoleEvent + | OverviewEvent | DrawEvent; export type Presentation = ActorRefFrom; diff --git a/packages/wc/components/presentation/wc.ts b/packages/wc/components/presentation/wc.ts index 208169c..957150a 100644 --- a/packages/wc/components/presentation/wc.ts +++ b/packages/wc/components/presentation/wc.ts @@ -82,6 +82,22 @@ export class PresentationWC extends LitElement { @state() accessor penColor: string = "#eb6f92"; + /** + * Audience-mode overview grid: shows every slide as a clickable + * preview at reduced size. Per-tab UI state — not broadcast over + * the cross-tab bridge. + */ + @state() + accessor overview = false; + + /** + * Previous overview state, used in updated() to detect transitions + * and re-anchor the scroll position to the current slide (the + * scroll-snap and grid layouts have very different offsetTop + * coordinates for the same slide). + */ + #prevOverview = false; + @property({ type: String }) accessor uuid: string = ""; @@ -184,6 +200,52 @@ export class PresentationWC extends LitElement { } } + /* ─── Overview mode ────────────────────────────────────────────── + Triggered from the toolbar's grid button. Renders every slide + as a clickable preview in a 2-column grid that scrolls if + there are more slides than fit. Slides are sized by aspect + ratio (16:9 cells) — the slide host reads its own height / + aspect / snap-align from CSS variables, so this rule reaches + across the slot boundary cleanly without ::slotted/!important + skirmishes. */ + :host([data-overview]) { + /* Keep the host at 100vh so it stays the scroll container — + host.scrollTo() needs the host to actually be scrollable. + (height: auto would expand the host to content size, leaving + the page-level window as the scroller and breaking the + enter/exit scroll-sync that re-anchors to the current slide.) + The grid's content can exceed 100vh; overflow-y: scroll on + the base :host shows the bar. align-content: start prevents + row stretching, and explicit grid-auto-rows: max-content + keeps each row at its slide's natural aspect-ratio'd height + rather than dividing 100vh among N rows. */ + scroll-snap-type: none; + display: grid; + grid-template-columns: 1fr 1fr; + grid-auto-rows: max-content; + align-content: start; + justify-content: stretch; + gap: 2vh; + padding: 3vh 4vw; + --morkdeck-slide-host-height: auto; + --morkdeck-slide-host-aspect: 16 / 9; + --morkdeck-slide-host-snap: none; + } + + :host([data-overview]) ::slotted(morkdeck-slide) { + cursor: pointer; + outline: 1px solid ${color("highlight.med")}; + transition: outline 200ms ease-out, transform 200ms ease-out; + } + + :host([data-overview]) ::slotted(morkdeck-slide:hover) { + outline: 2px solid ${color("link")}; + } + + :host([data-overview]) ::slotted(morkdeck-slide:focus-visible) { + outline: 2px solid ${color("link")}; + } + /* ─── Presenter mode ─────────────────────────────────────────────── */ :host([data-role="presenter"]) { @@ -476,9 +538,33 @@ export class PresentationWC extends LitElement { #onKeyDown = (e: KeyboardEvent) => { if (this.role === "presenter" && e.key === "Escape") { this.presentation.send({ type: "role.set", role: "audience" }); + return; + } + if (this.overview && e.key === "Escape") { + this.presentation.send({ type: "overview.set", value: false }); } }; + /** + * Click handler delegated to the host. When overview mode is on, + * a click anywhere inside a slide preview navigates to that slide + * and exits overview. Walking from event.target via .closest() + * picks up the slide regardless of which inner element actually + * received the click (image, paragraph, etc.). + */ + #onClick = (e: MouseEvent) => { + if (!this.overview) return; + const path = e.composedPath(); + const slide = path.find( + (n): n is HTMLElement => + n instanceof HTMLElement && n.tagName === "MORKDECK-SLIDE", + ); + if (!slide) return; + e.preventDefault(); + this.presentation.send({ type: "navigate.scroll", slideId: slide.id }); + this.presentation.send({ type: "overview.set", value: false }); + }; + /** * When the audience tab regains visibility (e.g., the presenter window * was closed and focus returned to the audience), the IntersectionObserver @@ -507,6 +593,10 @@ export class PresentationWC extends LitElement { window.addEventListener("keydown", this.#onKeyDown); document.addEventListener("visibilitychange", this.#onVisibilityChange); window.addEventListener("focus", this.#onVisibilityChange); + // Delegated click listener for overview-mode slide navigation. + // Attached to the host so we don't have to (re)bind on every + // slide individually. The handler no-ops when overview is off. + this.addEventListener("click", this.#onClick); // ── URL routing ─────────────────────────────────────────────────── // The presenter tab is opened with ?role=presenter&uuid=. @@ -573,6 +663,7 @@ export class PresentationWC extends LitElement { if (ctx.laser !== this.laser) this.laser = ctx.laser; if (ctx.activeTool !== this.activeTool) this.activeTool = ctx.activeTool; if (ctx.penColor !== this.penColor) this.penColor = ctx.penColor; + if (ctx.overview !== this.overview) this.overview = ctx.overview; }); // Start the per-slide canvas render loop. Each frame we iterate @@ -617,6 +708,7 @@ export class PresentationWC extends LitElement { window.removeEventListener("keydown", this.#onKeyDown); document.removeEventListener("visibilitychange", this.#onVisibilityChange); window.removeEventListener("focus", this.#onVisibilityChange); + this.removeEventListener("click", this.#onClick); if (this.#tickInterval) clearInterval(this.#tickInterval); if (this.#rafId) cancelAnimationFrame(this.#rafId); this.#bridge?.close(); @@ -628,6 +720,38 @@ export class PresentationWC extends LitElement { if (this.dataset.role !== this.role) { this.dataset.role = this.role; } + // Same trick for overview — the :host([data-overview]) grid layout + // and the CSS variables that feed slide sizing both key off this. + if (this.overview) { + if (!this.hasAttribute("data-overview")) { + this.setAttribute("data-overview", ""); + } + } else if (this.hasAttribute("data-overview")) { + this.removeAttribute("data-overview"); + } + // Toggling overview swaps the host between scroll-snap and grid + // layouts — slide.offsetTop is wildly different in each. Without + // re-syncing, the host's scrollTop stays where it was and lands + // at slide 1 (or some arbitrary mid-grid position) instead of + // the slide the viewer was actually on. Re-anchor on every + // transition in either direction. + // + // Defer to the next frame: reading offsetTop synchronously in + // updated() races with the browser's scroll-snap settling after + // the attribute change. By rAF, the new layout is committed and + // offsetTop is reliable. Instant scroll because the layout + // change itself isn't animated; a smooth scroll after an + // instant reflow reads as a glitch, not a transition. + if (this.#prevOverview !== this.overview) { + this.#prevOverview = this.overview; + const targetIndex = this.currentIndex; + requestAnimationFrame(() => { + const slide = this.slides[targetIndex]; + if (slide) { + this.scrollTo({ top: slide.offsetTop, behavior: "instant" }); + } + }); + } this.#syncSlots(); this.#syncNotesContent(); } @@ -689,6 +813,12 @@ export class PresentationWC extends LitElement { fireUpdates(entries: IntersectionObserverEntry[]) { // IO drives audience-mode scroll navigation only. if (this.role !== "audience") return; + // Overview shows many slides at once in a grid; whichever happens + // to be most-intersecting isn't a meaningful "current slide". + // Letting IO drive currentIndex here would also overwrite the + // index the viewer was on when they entered overview — breaking + // our ability to return to that slide on exit. + if (this.overview) return; // …and only when this tab is the focused tab. In Phase 2, the // audience tab is typically on a secondary display while the user is // on the presenter tab. Smooth scrolls triggered by remote diff --git a/packages/wc/components/slide.ts b/packages/wc/components/slide.ts index b2f567a..e9276cf 100644 --- a/packages/wc/components/slide.ts +++ b/packages/wc/components/slide.ts @@ -15,13 +15,21 @@ export class Slide extends MorkdeckElement { 16:9 letterbox that doesn't grow with content. The previous aspect-ratio + max-width/max-height pattern was either over-constrained (with inset:0) or let content expand the section - past 16:9 (with height:auto). */ + past 16:9 (with height:auto). + + Height / aspect / scroll-snap-align read from CSS custom + properties so the presentation can swap them out for overview + mode without us having to fight :host vs ::slotted specificity. + Defaults match the audience-scroll behavior; the presentation + sets the variables on :host([data-overview]) to make slides + become aspect-ratio'd grid cells. */ container-type: size; display: grid; place-items: center; width: 100%; - height: 100%; - scroll-snap-align: center; + height: var(--morkdeck-slide-host-height, 100%); + aspect-ratio: var(--morkdeck-slide-host-aspect, auto); + scroll-snap-align: var(--morkdeck-slide-host-snap, center); overflow: hidden; } diff --git a/packages/wc/components/toolbar.ts b/packages/wc/components/toolbar.ts index f25cc9c..8fe85cb 100644 --- a/packages/wc/components/toolbar.ts +++ b/packages/wc/components/toolbar.ts @@ -100,7 +100,11 @@ export class Toolbar extends MorkdeckElement { } toggleOverview() { - // TODO: Display all slides in overview + // Flip the audience-mode overview grid on or off. The actual + // layout switch lives in the presentation component's + // :host([data-overview]) CSS; the xstate event toggles a context + // boolean and the host attribute reflects it. + this.deck.send({ type: "overview.toggle" }); } goPrev() { -- 2.51.2