From 32c3772a57968b399cd1f6935ec1592b806feadb Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Sun, 23 Aug 2026 14:11:36 -0500 Subject: [PATCH 1/2] Mark up a live slide: the pen, and three deck fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reported bugs on slide decks, plus the presenter's pen. Hovering a Mermaid diagram on a slide showed a full-canvas expand icon: `.mermaid-diagram svg` is a descendant selector, so it also matched the expand button's own icon and sized it to the slide (356x356 on a stage slide, now 16x16). Same over-reach fixed in document mode. While there, deck diagrams were painting the document's framed panel from the host's --color-* tokens, putting an app-dark box behind a light deck. Clicking "Attachments" in the left column reloaded the page and left you at the top. turbo-rails 2.0.23's `locationWithActionIsSamePage` returns false unconditionally, so every same-page fragment link is a full visit — and the scroll it then performs races the deck's async slide layout. The back-matter links now jump in place, the way the outline links already do. Marking up a slide mid-show, two gestures that share one drag: - Stowed, a drag highlights text in the deck's own accent as inverse video — it has to read from the back of a room, on a projector, in every theme. - `d` arms the pen (DeckInk): a drag paints, and each stroke fades on its own a couple of seconds later. Nothing is saved, nothing is broadcast, there is no undo. A laser pointer with a short memory. Either way a still click stays "next", and the mark goes with the slide it was drawn on. Two gotchas worth knowing: preventing pointerdown's default suppresses the compatibility mousedown, so a pen stroke leaves no travel for the drag guard to measure (the pen reports its own gesture); and the stroke node must not be created until the gesture has travelled, or a tap paints a one-frame dot under every advance click. The pen's color is a new required theme token, --deck-pen (plus optional --deck-title-pen), because the design system's rule is that deck colors come only from --deck-* tokens: rose on coplan, amber on graphite, teal on poster — each picked so it can't be mistaken for that theme's accent. Also fixes a bug found on the way: the deck outline listed only slide 1's headings, because buildToc scanned the first .markdown-rendered and a deck renders one per slide. Co-Authored-By: Claude Opus 5 --- docs/SLIDE_SPEC.md | 2 + .../assets/stylesheets/coplan/application.css | 4 +- .../stylesheets/coplan/deck-theme-coplan.css | 1 + .../coplan/deck-theme-graphite.css | 1 + .../stylesheets/coplan/deck-theme-poster.css | 4 + engine/app/assets/stylesheets/coplan/deck.css | 147 ++++++++++- .../coplan/content_nav_controller.js | 36 ++- .../coplan/deck_presenter_controller.js | 103 +++++++- engine/app/javascript/coplan/deck_ink.js | 238 ++++++++++++++++++ engine/app/views/coplan/plans/show.html.erb | 17 +- engine/config/importmap.rb | 1 + spec/system/deck_ux_spec.rb | 238 ++++++++++++++++++ 12 files changed, 772 insertions(+), 20 deletions(-) create mode 100644 engine/app/javascript/coplan/deck_ink.js create mode 100644 spec/system/deck_ux_spec.rb diff --git a/docs/SLIDE_SPEC.md b/docs/SLIDE_SPEC.md index 0069d3a1..4f7ed754 100644 --- a/docs/SLIDE_SPEC.md +++ b/docs/SLIDE_SPEC.md @@ -709,6 +709,8 @@ Every theme defines all of: | `--deck-title-bg` | title-slide canvas (lets a theme art-direct openers) | | `--deck-title-ink` | text on the title canvas | | `--deck-title-accent` | accent on the title canvas (a theme whose title canvas is the accent color must pick a visible one) | +| `--deck-pen` | the presenter's ink: strokes drawn over a slide during a show, and never part of the artifact. Pick something the theme's own accent can't be mistaken for | +| `--deck-title-pen` | ink on the title canvas — optional, falls back to `--deck-pen`. Required only of a theme whose title canvas would swallow it | | `--deck-font-display` | headings | | `--deck-font-text` | body | | `--deck-font-mono` | code | diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index b38d0375..0ded574b 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1874,7 +1874,9 @@ img.avatar { color: var(--color-text); } -.markdown-rendered .mermaid-diagram svg { +/* The diagram's own SVG only — the expand button's icon is an svg inside + the same container, and it must keep its own 16px geometry. */ +.markdown-rendered .mermaid-diagram > svg { max-width: 100%; height: auto; } diff --git a/engine/app/assets/stylesheets/coplan/deck-theme-coplan.css b/engine/app/assets/stylesheets/coplan/deck-theme-coplan.css index 65d8bd22..88186815 100644 --- a/engine/app/assets/stylesheets/coplan/deck-theme-coplan.css +++ b/engine/app/assets/stylesheets/coplan/deck-theme-coplan.css @@ -16,6 +16,7 @@ --deck-title-bg: #f6f8fc; --deck-title-ink: #16181f; --deck-title-accent: #2563eb; + --deck-pen: #e11d48; --deck-font-display: "Lexend", system-ui, sans-serif; --deck-font-text: "Lexend", system-ui, sans-serif; --deck-font-mono: "Hack", ui-monospace, "SF Mono", Menlo, monospace; diff --git a/engine/app/assets/stylesheets/coplan/deck-theme-graphite.css b/engine/app/assets/stylesheets/coplan/deck-theme-graphite.css index 0c0d003f..7c9a0f85 100644 --- a/engine/app/assets/stylesheets/coplan/deck-theme-graphite.css +++ b/engine/app/assets/stylesheets/coplan/deck-theme-graphite.css @@ -12,6 +12,7 @@ --deck-title-bg: #101218; --deck-title-ink: #f2f4f9; --deck-title-accent: #6ea8fe; + --deck-pen: #ffc857; --deck-font-display: "Lexend", system-ui, sans-serif; --deck-font-text: "Lexend", system-ui, sans-serif; --deck-font-mono: "Hack", ui-monospace, "SF Mono", Menlo, monospace; diff --git a/engine/app/assets/stylesheets/coplan/deck-theme-poster.css b/engine/app/assets/stylesheets/coplan/deck-theme-poster.css index 2838bfe6..4d29f380 100644 --- a/engine/app/assets/stylesheets/coplan/deck-theme-poster.css +++ b/engine/app/assets/stylesheets/coplan/deck-theme-poster.css @@ -12,6 +12,10 @@ --deck-title-bg: #d94f30; --deck-title-ink: #fff6ee; --deck-title-accent: #fff6ee; + /* Teal against vermilion — the pen must not read as the accent. The + title canvas IS the accent, so the pen there is the paper color. */ + --deck-pen: #0e6f68; + --deck-title-pen: #fff6ee; --deck-font-display: "Lexend", system-ui, sans-serif; --deck-font-text: "Lexend", system-ui, sans-serif; --deck-font-mono: "Hack", ui-monospace, "SF Mono", Menlo, monospace; diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index 1f79a924..ff9ee8fb 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -193,12 +193,34 @@ height: 0.85em; } +/* On a slide the diagram IS the content, so it loses the document's framed + panel. That framing is painted from the host's --color-* tokens, which + flip with the reader's light/dark mode — the same leak the code panel + above pins shut — and it put an app-dark box behind a light deck's + diagram. The expand chip is deck chrome for the same reason. */ .deck-slide .deck-content .mermaid-diagram { display: flex; justify-content: center; + margin: 0.8em 0; + padding: 0; + background: none; + border: none; } -.deck-slide .deck-content .mermaid-diagram svg { +.deck-slide .deck-content .mermaid-diagram__expand { + background: var(--deck-bg); + border-color: var(--deck-rule); + color: var(--deck-muted); +} + +.deck-slide--title .deck-content .mermaid-diagram__expand { + background: var(--deck-title-bg); +} + +/* The diagram's own SVG only — a .mermaid-diagram also holds the expand + button, whose icon is an svg too, and sizing that to the canvas puts a + full-slide glyph over the diagram. */ +.deck-slide .deck-content .mermaid-diagram > svg { max-width: 100%; max-height: 34cqi; } @@ -293,7 +315,7 @@ width: 100%; } -.deck-slide--stage .deck-content .mermaid-diagram svg { +.deck-slide--stage .deck-content .mermaid-diagram > svg { width: 100%; height: 44cqi; max-width: none !important; @@ -498,6 +520,127 @@ pointer-events: none; } +/* Selecting text mid-show is the presenter's marker: drag across a line to + put the room's eyes on it (the presenter swallows the drag so it doesn't + also page forward, and starves the comment affordance). Inverse video in + the deck's own accent, not the OS selection tint — this has to read from + the back of a room, on a projector, in every theme. Title slides invert + against their own canvas instead: poster's title background IS its + accent, so an accent block would vanish there. */ +.deck--presenting .deck-slide::selection, +.deck--presenting .deck-slide ::selection { + background: var(--deck-accent); + color: var(--deck-accent-ink); +} + +.deck--presenting .deck-slide--title::selection, +.deck--presenting .deck-slide--title ::selection { + background: var(--deck-title-accent, var(--deck-accent)); + color: var(--deck-title-bg); +} + +/* ---- the pen — ephemeral ink over the canvas -------------------------- + `d` arms the pen mid-show (DeckInk): a drag paints instead of selecting. + Strokes are a gesture in the room, so each one fades on its own timer and + nothing is stored. The layer is a child of .deck because the presented + deck is a top-layer popover — a sibling on the page would paint + underneath it — and it is sized to the canvas rather than to a slide, so + the mark stays put while the pointer roams. */ + +.deck-ink { + position: absolute; + inset: 0; + z-index: 2; + /* Ink never eats the click: navigation, links, and checkboxes keep + working under it. Strokes come from listeners on the document. */ + pointer-events: none; +} + +.deck-ink__stroke { + fill: none; + /* Themes registered before the pen existed still get a visible mark. */ + stroke: var(--deck-pen, var(--deck-accent)); + stroke-linecap: round; + stroke-linejoin: round; + /* Lifts the stroke off a canvas close to its own hue without a second + path underneath it. */ + filter: drop-shadow(0 0.15cqi 0.3cqi rgb(0 0 0 / 0.35)); +} + +/* A theme may art-direct its title canvas into the pen's own color + (poster's title slide IS the accent), so the pen gets the same + title-slide escape hatch the accent does. :has() rather than + inheritance: the layer belongs to the canvas, not to a slide. */ +.deck--presenting:has(.deck-slide--current.deck-slide--title) .deck-ink__stroke { + stroke: var(--deck-title-pen, var(--deck-pen, var(--deck-accent))); +} + +/* Held at full strength long enough to make the point, then gone. The + stroke gets this only on release — a slow deliberate line must not start + fading while it is still being drawn. */ +.deck-ink__stroke--done { + animation: deck-ink-fade 2.6s ease-in forwards; +} + +@keyframes deck-ink-fade { + 0%, 45% { opacity: 1; } + 100% { opacity: 0; } +} + +/* With the pen out the canvas is a drawing surface: no text selection to + fight the stroke, no touch scrolling to swallow it, and a crosshair so + the presenter can see the mode. */ +.deck--inking, +.deck--inking .deck-slide { + cursor: crosshair; + user-select: none; + touch-action: none; +} + +/* Pen-out indicator, mirroring the slide number's inset on the other + corner. The key hint is pseudo-element content: real text here would be + counted as slide text by comment anchoring. */ +.deck-ink-badge { + position: absolute; + left: 1.6cqi; + bottom: 1.1cqi; + z-index: 3; + display: flex; + align-items: center; + gap: 0.6cqi; + padding: 0.4cqi 0.9cqi; + border: 1px solid var(--deck-rule); + border-radius: 999px; + background: var(--deck-bg); + color: var(--deck-pen, var(--deck-accent)); + font-family: var(--deck-font-text); + font-size: 1.5cqi; + line-height: 1; +} + +.deck-ink-badge svg { + width: 1.8cqi; + height: 1.8cqi; +} + +.deck-ink-badge::after { + content: "d"; + color: var(--deck-muted); +} + +/* The badge's shell follows the canvas it sits on, and only the glyph is + the pen. A theme's --deck-muted is a color for its body canvas: on an + art-directed title canvas the hint has to come from that canvas's own + ink instead, or it disappears into the background. */ +.deck--presenting:has(.deck-slide--current.deck-slide--title) .deck-ink-badge { + background: var(--deck-title-bg); + color: var(--deck-title-pen, var(--deck-pen, var(--deck-accent))); +} + +.deck--presenting:has(.deck-slide--current.deck-slide--title) .deck-ink-badge::after { + color: var(--deck-title-ink); +} + /* Fullscreen is requested on the presenter wrapper (it survives live-update swaps of the deck inside it); paint it black so nothing of the page shows around the canvas. */ diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 999933da..578f8fa6 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -33,12 +33,16 @@ export default class extends Controller { } buildToc() { - const rendered = this.contentTarget.querySelector(".markdown-rendered") - if (!rendered) return + if (!this.contentTarget.querySelector(".markdown-rendered")) return this.listTarget.innerHTML = "" this._itemsById = new Map() - this._headings = Array.from(rendered.querySelectorAll("h1, h2, h3")) + // A document renders one .markdown-rendered; a deck renders one per + // slide. Scan the whole content column either way — scanning the first + // rendered block gave a deck an outline of slide 1 and nothing else. + this._headings = Array.from( + this.contentTarget.querySelectorAll(".markdown-rendered h1, .markdown-rendered h2, .markdown-rendered h3") + ) if (this._headings.length === 0) { this.sidebarTarget.style.display = "none" @@ -143,6 +147,24 @@ export default class extends Controller { heading.scrollIntoView({ behavior: "smooth", block: "start" }) } + // The back-matter links (References, Attachments) jump the same way the + // outline above does. Turbo counts a same-page fragment link as a full + // visit — it refetches and re-renders the page — so the bare anchor read + // as a reload, and the scroll it performed landed against a layout that + // async rendering (Mermaid, highlighting, deck slides) had not settled + // yet. Scrolling in place needs neither. + jumpToSection(event) { + const id = event.currentTarget.getAttribute("href")?.replace(/^#/, "") + const target = id && document.getElementById(id) + if (!target) return + + event.preventDefault() + history.replaceState(null, "", `#${id}`) + + this._ignoreScroll = true + target.scrollIntoView({ behavior: "smooth", block: "start" }) + } + _setActiveLink(id) { this.listTarget.querySelectorAll(".content-nav__link").forEach(link => { link.classList.remove("content-nav__link--active") @@ -194,12 +216,14 @@ export default class extends Controller { updateCommentBadges() { if (!this._headings || this._headings.length === 0) return - const rendered = this.contentTarget.querySelector(".markdown-rendered") - if (!rendered) return + if (!this.contentTarget.querySelector(".markdown-rendered")) return + // Walk the whole content column: on a deck, consecutive outline + // headings live in different slides, so the span between two of them + // crosses .markdown-rendered blocks. this._headings.forEach((heading, index) => { const nextHeading = this._headings[index + 1] - const threads = this.collectThreadsBetween(heading, nextHeading, rendered) + const threads = this.collectThreadsBetween(heading, nextHeading, this.contentTarget) let pendingCount = 0 let todoCount = 0 diff --git a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js index ba0306b1..553e5f5c 100644 --- a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js +++ b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js @@ -1,4 +1,10 @@ import { Controller } from "@hotwired/stimulus" +import { DeckInk } from "coplan/deck_ink" + +// How far the pointer may travel between mousedown and click and still count +// as a click. Past it, the gesture was a drag — the presenter highlighting a +// line, not asking for the next slide. +const DRAG_SLOP = 4 /* * coplan--deck-presenter @@ -10,6 +16,11 @@ import { Controller } from "@hotwired/stimulus" * wrapper, with the fixed-overlay CSS as the fallback when the browser * refuses, so presenting works either way. * + * Two gestures mark up a slide without leaving the show. Dragging across + * text highlights it in the deck's accent — the room's eyes follow the + * marker. `d` arms the pen (DeckInk) and a drag paints instead; either way + * the mark is temporary, and a still click is always "next". + * * The wrapper sits OUTSIDE the live-update swap target: an edit landing * mid-presentation replaces the deck underneath without disconnecting this * controller. Every entry point re-acquires the current deck by lookup, and @@ -20,8 +31,10 @@ export default class extends Controller { connect() { this.presenting = false this.index = 0 + this.ink = new DeckInk(this.element) this._onKeydown = this._handleKeydown.bind(this) this._onClick = this._handleClick.bind(this) + this._onMouseDown = this._handleMouseDown.bind(this) this._onMouseUp = this._handleMouseUp.bind(this) this._onFullscreenChange = this._handleFullscreenChange.bind(this) // Turbo snapshots the page before controllers disconnect; a cached @@ -51,6 +64,7 @@ export default class extends Controller { this._pageOverflow = document.documentElement.style.overflow document.documentElement.style.overflow = "hidden" document.addEventListener("click", this._onClick, true) + document.addEventListener("mousedown", this._onMouseDown, true) document.addEventListener("mouseup", this._onMouseUp, true) // A comment thread popover left open on the page must not float above // the show. @@ -116,9 +130,26 @@ export default class extends Controller { this._promoteDeck() slides.forEach((slide, i) => slide.classList.toggle("deck-slide--current", i === this.index)) slides[this.index].scrollTop = 0 + this._clearSelectionOutside(slides[this.index]) + // Ink is drawn on the canvas, not on the slide's text, so nothing about + // it survives the slide it was drawn on — including a stroke still in + // progress when a key advanced the show. + this.ink.clear() history.replaceState(history.state, "", `#present-${this.index + 1}`) } + // A highlight belongs to the slide it was drawn on. Once the show moves + // on (or the deck is swapped underneath), drop it — a stale selection + // would make the next bare click read as the tail of a drag and stall the + // show. + _clearSelectionOutside(slide) { + const selection = document.getSelection() + if (!selection || selection.isCollapsed) return + if (slide.contains(selection.anchorNode) && slide.contains(selection.focusNode)) return + + selection.removeAllRanges() + } + // The show must escape the page: an ancestor with backdrop-filter (the // plan's glass .card) is the containing block for position: fixed, which // would trap the overlay at the card's size and stacking level. Top-layer @@ -197,16 +228,26 @@ export default class extends Controller { case "End": this._navigate(event, Infinity) break + case "d": + // Draw. The pen is a mode because the two marking gestures share + // one drag: with it stowed a drag highlights text, with it out a + // drag paints. The badge it raises is what tells the presenter + // which. + event.preventDefault() + event.stopPropagation() + this.ink.toggle() + break case "Escape": event.preventDefault() event.stopPropagation() - // A popover above the show (a reference preview, a pinned thread) - // is what Escape visibly targets — dismiss it and keep presenting; - // only a bare Escape ends the show. The browser may drop native - // fullscreen on this same keypress (that exit is uncancelable), so - // mark the peel: the resulting fullscreenchange is forgiven and - // the show continues on the top-layer fallback. - if (this._dismissForeignPopovers()) { + // Escape peels one layer at a time: whatever is visibly in front of + // the show goes first — a popover (a reference preview, a pinned + // thread), then the pen — and only a bare Escape ends the show. The + // browser may drop native fullscreen on the same keypress (that + // exit is uncancelable), so mark the peel: the resulting + // fullscreenchange is forgiven and the show continues on the + // top-layer fallback. + if (this._dismissForeignPopovers() || this._stowPen()) { if (document.fullscreenElement === this.element) this._peeling = true return } @@ -234,6 +275,14 @@ export default class extends Controller { return dismissed } + // Puts the pen away. Returns whether there was a pen out to put away. + _stowPen() { + if (!this.ink.armed) return false + + this.ink.stow() + return true + } + _navigate(event, index) { event.preventDefault() event.stopPropagation() @@ -252,6 +301,18 @@ export default class extends Controller { const popover = event.target.closest?.("[popover]") if (popover && popover !== deck && popover.matches(":popover-open")) return + // Dragging across a slide marks it up — a highlight, or a pen stroke — + // and the point being made must not also page forward (advancing hides + // the slide, which takes the mark with it). Shift extends a highlight + // for the same reason. Everything else is a still click, which stays + // "next" unconditionally: it advances, and _show drops whatever was + // left on the slide being left behind. + if (this._gestureWasDrag(event) || event.shiftKey) { + event.preventDefault() + event.stopPropagation() + return + } + // A same-document link mid-show: the target's slide is display: none, // so the browser's fragment jump would show nothing — and would clobber // the #present-N resume hash. Navigate the show to the slide that owns @@ -282,6 +343,30 @@ export default class extends Controller { this._show(this.index + 1) } + _handleMouseDown(event) { + if (!this.presenting) return + + this._pointerOrigin = { x: event.clientX, y: event.clientY } + } + + // Whether the click that just landed is the tail of a drag rather than a + // click in place. Pointer travel is the whole test for a highlight on + // purpose: a live selection is NOT evidence, because Chrome holds off + // collapsing one until mouseup when the press lands inside it (it is + // waiting to see a selection drag) — reading the selection here would + // swallow the click that a presenter aims at their own highlight, and + // stall the show. A pen stroke has no travel to read (see + // DeckInk#consumePainted), so the pen answers for itself. + _gestureWasDrag(event) { + if (this.ink.consumePainted()) return true + + const origin = this._pointerOrigin + if (!origin) return false + + return Math.abs(event.clientX - origin.x) > DRAG_SLOP || + Math.abs(event.clientY - origin.y) > DRAG_SLOP + } + // text-selection offers "comment on this selection" from mouseup on the // content target — mid-show that affordance would pop over the deck. // Starve the listener; native click synthesis is unaffected, so slide @@ -332,7 +417,11 @@ export default class extends Controller { _teardown() { document.removeEventListener("click", this._onClick, true) + document.removeEventListener("mousedown", this._onMouseDown, true) document.removeEventListener("mouseup", this._onMouseUp, true) + this._pointerOrigin = null + // The pen never outlives the show it was drawn with. + this.ink.destroy() if (this._pageOverflow !== undefined) { document.documentElement.style.overflow = this._pageOverflow this._pageOverflow = undefined diff --git a/engine/app/javascript/coplan/deck_ink.js b/engine/app/javascript/coplan/deck_ink.js new file mode 100644 index 00000000..d1d83471 --- /dev/null +++ b/engine/app/javascript/coplan/deck_ink.js @@ -0,0 +1,238 @@ +/* + * DeckInk — the presenter's pen. + * + * Mid-show, `d` arms the pen: a drag paints over the slide instead of + * selecting text, and each stroke fades out on its own a couple of seconds + * after it lands. Ink is a gesture in the room, not an edit — nothing is + * saved, nothing is broadcast, there is no undo, and the slide cleans + * itself up. A laser pointer with a short memory. + * + * Owned by coplan--deck-presenter, which arbitrates the gestures around it + * (a drag never also pages forward) and stows the pen when the show ends. + */ + +const SVG_NS = "http://www.w3.org/2000/svg" + +// A stroke holds, then fades; deck-ink-fade owns the curve. Mirrored here +// so the node still gets reaped where that animation never runs — a +// reduced-motion setting, a test run with animations off. +const STROKE_LIFE_MS = 2600 +// Pointer travel below this doesn't move the pen. Dropping those samples +// keeps the path short enough to re-serialize at pointer rate. +const MIN_STEP = 2 +// A press that never travels this far was a tap: the presenter asking for +// the next slide, not drawing. A stray dot would read as a glitch. +const MIN_TRAVEL = 4 +// Pen weight as a fraction of canvas width, so the mark is the same weight +// on a laptop and on a projector. +const STROKE_RATIO = 0.005 + +// Pen-out indicator. Glyph only: this lives inside the comment-anchor +// content target, which counts occurrences of the visible text under it, so +// the key hint is pseudo-element content in CSS instead of a text node. +const BADGE_HTML = ` + ` + +const round = value => Math.round(value * 10) / 10 + +// Quadratic through the midpoints between samples: the recorded points +// become control points, so the line curves through a fast scribble instead +// of showing every polygon corner. Never called with fewer than two points +// — a stroke only gets a path once it has travelled. +const pathData = points => { + let data = `M ${round(points[0].x)} ${round(points[0].y)}` + for (let i = 1; i < points.length - 1; i++) { + const point = points[i] + const next = points[i + 1] + data += ` Q ${round(point.x)} ${round(point.y)} ${round((point.x + next.x) / 2)} ${round((point.y + next.y) / 2)}` + } + const last = points[points.length - 1] + return `${data} L ${round(last.x)} ${round(last.y)}` +} + +export class DeckInk { + // wrapper: the .deck-presenter element. The deck inside it is replaced + // wholesale when a collaborator's edit lands mid-show, so every entry + // point re-acquires it (and re-creates the layer) instead of holding a + // reference across the swap. + constructor(wrapper) { + this.wrapper = wrapper + this.armed = false + this.stroke = null + this.painted = false + this._reapers = new Set() + this._onPointerDown = this._handlePointerDown.bind(this) + this._onPointerMove = this._handlePointerMove.bind(this) + this._onPointerEnd = this._handlePointerEnd.bind(this) + } + + toggle() { + this.armed ? this.stow() : this.arm() + } + + arm() { + if (this.armed) return + + this.armed = true + // Capture phase on the document: a stroke must not depend on what the + // pointer happens to be over, and slide content (links, checkboxes, + // a diagram's expand chip) must not swallow the press. + document.addEventListener("pointerdown", this._onPointerDown, true) + document.addEventListener("pointermove", this._onPointerMove, true) + document.addEventListener("pointerup", this._onPointerEnd, true) + document.addEventListener("pointercancel", this._onPointerEnd, true) + this._dress() + } + + stow() { + if (!this.armed) return + + this._endStroke() + this.armed = false + document.removeEventListener("pointerdown", this._onPointerDown, true) + document.removeEventListener("pointermove", this._onPointerMove, true) + document.removeEventListener("pointerup", this._onPointerEnd, true) + document.removeEventListener("pointercancel", this._onPointerEnd, true) + this.wrapper.querySelector(".deck")?.classList.remove("deck--inking") + this.wrapper.querySelectorAll(".deck-ink-badge").forEach(badge => badge.remove()) + // Strokes already drawn keep fading — the show moved on from the pen, + // not from the point being made. + } + + // A mark belongs to the slide it was drawn on: when the show moves (or + // the deck is swapped underneath), the ink goes with it. Re-dresses the + // canvas on the way out, because a swapped-in deck arrives bare. + clear() { + this._endStroke(true) + this._reapers.forEach(reaper => clearTimeout(reaper)) + this._reapers.clear() + this.wrapper.querySelectorAll(".deck-ink").forEach(layer => layer.remove()) + if (this.armed) this._dress() + } + + destroy() { + this.stow() + this.clear() + } + + // Whether the click now landing is the tail of a stroke. Preventing + // pointerdown's default (below) suppresses the compatibility mousedown + // with it, so a stroke leaves no pointer travel for the presenter's own + // drag guard to measure — the pen has to own up to its gesture, or the + // stroke would also page the show forward. One-shot: the next press + // starts the answer over. + consumePainted() { + const painted = this.painted + this.painted = false + return painted + } + + _handlePointerDown(event) { + // Only the primary contact draws: a right-click opens the context menu, + // and the second finger of a pinch is not a second pen. + if (!event.isPrimary || event.button !== 0) return + + const deck = this.wrapper.querySelector(".deck") + if (!deck || !deck.contains(event.target)) return + // Real UI above the show keeps its pointer: the diagram lightbox, a + // popover the presenter opened on purpose. + if (event.target.closest("dialog")) return + const popover = event.target.closest("[popover]") + if (popover && popover !== deck && popover.matches(":popover-open")) return + + // This press starts a mark, not a text selection or a link/image drag. + event.preventDefault() + + this.painted = false + const rect = deck.getBoundingClientRect() + this.stroke = { + deck, rect, pointerId: event.pointerId, path: null, travel: 0, + points: [{ x: event.clientX - rect.left, y: event.clientY - rect.top }] + } + } + + _handlePointerMove(event) { + const stroke = this.stroke + if (!stroke || event.pointerId !== stroke.pointerId) return + + const point = { x: event.clientX - stroke.rect.left, y: event.clientY - stroke.rect.top } + const last = stroke.points[stroke.points.length - 1] + const step = Math.hypot(point.x - last.x, point.y - last.y) + if (step < MIN_STEP) return + + stroke.points.push(point) + stroke.travel += step + // Nothing is drawn until the gesture proves itself a stroke: a press + // that goes nowhere is the presenter advancing the show, and a dot + // flashing under every click would read as a rendering bug. + if (!stroke.path) { + if (stroke.travel <= MIN_TRAVEL) return + stroke.path = this._path(stroke) + } + stroke.path.setAttribute("d", pathData(stroke.points)) + } + + _handlePointerEnd(event) { + if (!this.stroke || event.pointerId !== this.stroke.pointerId) return + + this._endStroke() + } + + _endStroke(discard = false) { + const stroke = this.stroke + this.stroke = null + // No path means the press never became a stroke — nothing to fade, + // and the click it synthesizes belongs to the show. + if (!stroke?.path) return + if (discard) return stroke.path.remove() + + this.painted = true + // Hand the fade to CSS, and reap the node on a timer regardless: + // animationend never arrives where animations are turned off, and a + // stroke that outlives its fade would sit on the slide forever. + stroke.path.classList.add("deck-ink__stroke--done") + stroke.path.addEventListener("animationend", () => stroke.path.remove(), { once: true }) + const reaper = setTimeout(() => { + stroke.path.remove() + this._reapers.delete(reaper) + }, STROKE_LIFE_MS + 250) + this._reapers.add(reaper) + } + + _path(stroke) { + const path = document.createElementNS(SVG_NS, "path") + path.setAttribute("class", "deck-ink__stroke") + path.setAttribute("stroke-width", round(stroke.rect.width * STROKE_RATIO)) + this._layer(stroke.deck, stroke.rect).appendChild(path) + return path + } + + _layer(deck, rect) { + let layer = deck.querySelector(":scope > .deck-ink") + if (!layer) { + layer = document.createElementNS(SVG_NS, "svg") + layer.setAttribute("class", "deck-ink") + layer.setAttribute("aria-hidden", "true") + deck.appendChild(layer) + } + // The viewBox is the canvas in CSS pixels, so pointer coordinates map + // straight through with no scaling math — and a live stroke keeps its + // place if the window is resized under it. + layer.setAttribute("viewBox", `0 0 ${round(rect.width)} ${round(rect.height)}`) + return layer + } + + // The mode has to be visible: a drag that suddenly paints instead of + // highlighting is otherwise a mystery. + _dress() { + const deck = this.wrapper.querySelector(".deck") + if (!deck) return + + deck.classList.add("deck--inking") + if (!deck.querySelector(":scope > .deck-ink-badge")) deck.insertAdjacentHTML("beforeend", BADGE_HTML) + } +} diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 42be4a1c..0066b7c5 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -62,9 +62,15 @@ set apart so it reads as "after the document", not part of it. %> @@ -84,7 +90,10 @@ <% if @plan.presentation? %>
- diff --git a/engine/config/importmap.rb b/engine/config/importmap.rb index 82290eef..40134bd5 100644 --- a/engine/config/importmap.rb +++ b/engine/config/importmap.rb @@ -1,4 +1,5 @@ pin "@rails/actioncable", to: "actioncable.esm.js" pin "coplan/web_push", to: "coplan/web_push.js" +pin "coplan/deck_ink", to: "coplan/deck_ink.js" pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs", preload: false pin_all_from CoPlan::Engine.root.join("app/javascript/controllers/coplan"), under: "controllers/coplan", preload: true diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb new file mode 100644 index 00000000..14d5af3a --- /dev/null +++ b/spec/system/deck_ux_spec.rb @@ -0,0 +1,238 @@ +require "rails_helper" + +# Browser-level coverage for the deck's pointer and navigation behavior: +# the two ways a presenter marks up a live slide (selecting text, and the +# pen), the Mermaid expand chip staying chip-sized on a slide, and the +# back-matter links jumping in place instead of refetching the page. +RSpec.describe "Deck UX", type: :system do + let(:user) { create(:coplan_user, email: "presenter@example.com") } + let(:deck_type) { create(:plan_type, name: "Presentation", behavior: "presentation") } + + let(:deck_content) do + <<~MARKDOWN + # Shared workspaces + + Every slide earns its layout from its shape alone. + + --- + + ## What the classifier sees + + - A lone heading becomes a title slide + - One image or diagram takes the whole stage + + --- + + ## How a slide finds its shape + + ```mermaid + flowchart LR + markdown --> Split + Split --> Classify + ``` + MARKDOWN + end + + let(:plan) do + p = create(:plan, :published, created_by_user: user, plan_type: deck_type, title: "Readout deck") + version = CoPlan::PlanVersion.create!( + plan: p, revision: 2, + content_markdown: deck_content, actor_type: "human", actor_id: user.id + ) + p.update!(current_plan_version: version, current_revision: 2) + p + end + + before do + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_button("Menu") + end + + # Sweeps a real selection across an element the way a presenter drags a + # line: mouse down inside it, move, release. Selenium's action chain is + # what makes this a genuine drag — a scripted Range wouldn't exercise the + # click the browser synthesizes at the end of one. + def drag_across(selector) + element = find(selector, match: :first).native + width = element.rect.width.to_i + page.driver.browser.action + .move_to(element, -(width / 2) + 2, 0) + .click_and_hold + .move_to(element, (width / 2) - 2, 0) + .release + .perform + end + + def current_slide + page.evaluate_script( + %{document.querySelector(".deck-slide--current")?.dataset.slide} + ) + end + + # Strokes are ephemeral by design, so counting them after the fact is a + # race with their own fade. Watch the canvas instead and count every + # stroke that was ever added to it. + def watch_strokes + page.execute_script(<<~JS) + window.__strokes = 0; + new MutationObserver(records => records.forEach(record => { + record.addedNodes.forEach(node => { + if (node.classList?.contains("deck-ink__stroke")) window.__strokes++; + }); + })).observe(document.querySelector(".deck"), { childList: true, subtree: true }); + JS + end + + def strokes_drawn + page.evaluate_script("window.__strokes") + end + + def start_show + click_button "Present" + expect(page).to have_css(".deck--presenting .deck-slide--current", wait: 5) + end + + def attachments_on_screen? + page.evaluate_script(<<~JS) + (() => { + const box = document.getElementById("footnote-attachments").getBoundingClientRect(); + return box.top < window.innerHeight && box.bottom > 0; + })() + JS + end + + describe "present mode" do + it "treats a drag as a highlight and a bare click as the next slide" do + visit plan_path(plan) + start_show + expect(current_slide).to eq("1") + + # Advance to a slide with body text to sweep. + find(".deck-slide--current").click + expect(current_slide).to eq("2") + + drag_across(".deck-slide--current li") + + # The selection is the point being made — the show must not have moved + # out from under it, and no comment affordance may cover the slide. + expect(current_slide).to eq("2") + expect(page.evaluate_script("document.getSelection().toString()")) + .to include("A lone heading becomes a title slide") + expect(page).to have_css(".comment-popover", visible: :hidden) + + # A bare click aimed at the presenter's own highlight still advances, + # and leaves the mark behind with the slide. + find(".deck-slide--current li", match: :first).click + expect(current_slide).to eq("3") + expect(page.evaluate_script("document.getSelection().toString()")).to eq("") + end + end + + describe "the pen" do + it "paints a drag, holds the slide, and lets the stroke fade on its own" do + visit plan_path(plan) + start_show + find(".deck-slide--current").click + expect(current_slide).to eq("2") + + send_keys("d") + expect(page).to have_css(".deck--inking .deck-ink-badge") + + watch_strokes + drag_across(".deck-slide--current li") + + # The stroke is the point being made: the show holds, and the drag + # paints instead of selecting. + expect(strokes_drawn).to eq(1) + expect(current_slide).to eq("2") + expect(page.evaluate_script("document.getSelection().toString()")).to eq("") + + # Temporary by design — nothing to erase, nothing saved. + expect(page).to have_no_css(".deck-ink__stroke", wait: 6) + end + + it "still advances on a bare click, and leaves no dot behind" do + visit plan_path(plan) + start_show + send_keys("d") + expect(page).to have_css(".deck--inking") + + watch_strokes + find(".deck-slide--current").click + + expect(current_slide).to eq("2") + # A press that never travels is the presenter advancing the show, so + # the pen must not even create a node — a one-frame dot under every + # click reads as a rendering bug. + expect(strokes_drawn).to eq(0) + # The pen stays out across the slide change. + expect(page).to have_css(".deck--inking .deck-ink-badge") + end + + it "puts the pen away on Escape without ending the show" do + visit plan_path(plan) + start_show + send_keys("d") + expect(page).to have_css(".deck--inking") + + send_keys(:escape) + expect(page).to have_no_css(".deck--inking") + expect(page).to have_no_css(".deck-ink-badge") + expect(page).to have_css(".deck--presenting") + + send_keys(:escape) + expect(page).to have_no_css(".deck--presenting") + end + end + + describe "Mermaid diagrams on a slide" do + it "keeps the expand control chip-sized instead of scaling it to the canvas" do + visit plan_path(plan) + expect(page).to have_css(".deck-slide .mermaid-diagram > svg", wait: 15) + + sizes = page.evaluate_script(<<~JS) + (() => { + const diagram = document.querySelector(".deck-slide .mermaid-diagram"); + const icon = diagram.querySelector(".mermaid-diagram__expand svg"); + const box = el => Math.round(el.getBoundingClientRect().width); + return { diagram: box(diagram.querySelector(":scope > svg")), icon: box(icon) }; + })() + JS + + # The diagram still takes the stage; the chip's icon stays an icon. + expect(sizes["diagram"]).to be > 200 + expect(sizes["icon"]).to be <= 24 + end + end + + describe "back-matter links" do + it "scrolls to the attachments section without refetching the page" do + plan.attachments.attach( + io: StringIO.new("handout"), filename: "handout.txt", content_type: "text/plain" + ) + + visit plan_path(plan) + expect(page).to have_css(".deck-slide", wait: 5) + + # Turbo counts a same-page fragment link as a full visit, so a bare + # anchor here refetched and re-rendered the page — and the scroll it + # then performed raced the deck's async rendering. Nothing may be + # fetched, and the section must end up on screen. + page.execute_script(<<~JS) + window.__visited = false; + document.addEventListener("turbo:visit", () => { window.__visited = true }); + JS + + find(".content-nav__footnote-link", text: "Attachments").click + + expect(page).to have_current_path(/#footnote-attachments\z/, url: true, wait: 5) + expect(page.evaluate_script("window.__visited")).to be(false) + + # The jump is a smooth scroll, so poll rather than sample once. + 20.times { break if attachments_on_screen?; sleep 0.15 } + expect(attachments_on_screen?).to be(true) + end + end +end From 9edc9f758264cc1e2c68510fece16a562d05d9b5 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Sun, 23 Aug 2026 14:29:43 -0500 Subject: [PATCH 2/2] Keep a stale gesture from eating the next click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the presenter's drag guard could swallow a click that wasn't a drag, both from state outliving the gesture that set it. The mouse origin was never cleared. After any physical click it stayed set, so the next click synthesized without coordinates — Space on a focused checkbox, or assistive technology — measured (0, 0) against the last mouse position, read as a drag, and was swallowed before the interactive-control pass-through could run. Ticking a task box mid-show silently did nothing. The origin is now consumed by the click its own press produced. A cancelled pointer claimed a click that never came. pointercancel ran the same path as pointerup and set the painted flag, but a cancellation synthesizes no click, so the flag sat there until some later unrelated click ate it instead of advancing the show. Only a lifted pointer claims a click now, which also fixes the same leak on Escape mid-stroke; a cancelled stroke is discarded, because it was never made. Both are pinned by system specs that fail without the fix. Co-Authored-By: Claude Opus 5 --- .../coplan/deck_presenter_controller.js | 19 +++++-- engine/app/javascript/coplan/deck_ink.js | 13 ++++- spec/system/deck_ux_spec.rb | 56 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js index 553e5f5c..d425da1d 100644 --- a/engine/app/javascript/controllers/coplan/deck_presenter_controller.js +++ b/engine/app/javascript/controllers/coplan/deck_presenter_controller.js @@ -291,6 +291,16 @@ export default class extends Controller { _handleClick(event) { if (!this.presenting) return + + // The origin belongs to the click its own press produced, and to nothing + // after it. A click synthesized from the keyboard (Space on a focused + // checkbox) or from assistive technology carries no coordinates, so + // measuring it against a mouse position left over from earlier read as a + // drag — the click was swallowed before the interactive-control + // pass-through below, and the box silently did not move. + const origin = this._pointerOrigin + this._pointerOrigin = null + if (event.target.closest?.("dialog")) return const deck = this.element.querySelector(".deck") @@ -307,7 +317,7 @@ export default class extends Controller { // for the same reason. Everything else is a still click, which stays // "next" unconditionally: it advances, and _show drops whatever was // left on the slide being left behind. - if (this._gestureWasDrag(event) || event.shiftKey) { + if (this._gestureWasDrag(event, origin) || event.shiftKey) { event.preventDefault() event.stopPropagation() return @@ -356,11 +366,10 @@ export default class extends Controller { // waiting to see a selection drag) — reading the selection here would // swallow the click that a presenter aims at their own highlight, and // stall the show. A pen stroke has no travel to read (see - // DeckInk#consumePainted), so the pen answers for itself. - _gestureWasDrag(event) { + // DeckInk#consumePainted), so the pen answers for itself. No origin means + // no press of ours came first: the keyboard, or assistive technology. + _gestureWasDrag(event, origin) { if (this.ink.consumePainted()) return true - - const origin = this._pointerOrigin if (!origin) return false return Math.abs(event.clientX - origin.x) > DRAG_SLOP || diff --git a/engine/app/javascript/coplan/deck_ink.js b/engine/app/javascript/coplan/deck_ink.js index d1d83471..ebb04095 100644 --- a/engine/app/javascript/coplan/deck_ink.js +++ b/engine/app/javascript/coplan/deck_ink.js @@ -100,7 +100,8 @@ export class DeckInk { this.wrapper.querySelector(".deck")?.classList.remove("deck--inking") this.wrapper.querySelectorAll(".deck-ink-badge").forEach(badge => badge.remove()) // Strokes already drawn keep fading — the show moved on from the pen, - // not from the point being made. + // not from the point being made. A stroke still in the air is committed + // and left to fade too, but claims no click: Escape is not a press. } // A mark belongs to the slide it was drawn on: when the show moves (or @@ -179,6 +180,15 @@ export class DeckInk { _handlePointerEnd(event) { if (!this.stroke || event.pointerId !== this.stroke.pointerId) return + // Only a lifted pointer synthesizes the click the presenter has to + // swallow, so only a lifted pointer claims one. A cancelled gesture (the + // browser took the pointer away mid-stroke) produces no click at all — + // claiming one would leave the flag set, and the presenter's next + // unrelated click would be eaten instead of advancing the show. The + // half-drawn mark goes with it: a cancelled stroke was never made. + if (event.type === "pointercancel") return this._endStroke(true) + + this.painted = !!this.stroke.path this._endStroke() } @@ -190,7 +200,6 @@ export class DeckInk { if (!stroke?.path) return if (discard) return stroke.path.remove() - this.painted = true // Hand the fade to CSS, and reap the node on a timer regardless: // animationend never arrives where animations are turned off, and a // stroke that outlives its fade would sit on the slide forever. diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index 14d5af3a..ff37c7b7 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -30,6 +30,13 @@ markdown --> Split Split --> Classify ``` + + --- + + ## Before the readout + + - [ ] Confirm the fit report is clean + - [ ] Send the deck round MARKDOWN end @@ -128,6 +135,26 @@ def attachments_on_screen? expect(current_slide).to eq("3") expect(page.evaluate_script("document.getSelection().toString()")).to eq("") end + + it "still hands a focused control its keys after the mouse has been used" do + visit plan_path(plan) + start_show + + # Three mouse clicks to reach the task slide — each one leaves a + # pointer origin behind, which is the whole point of the setup. + 3.times { find(".deck-slide--current").click } + expect(current_slide).to eq("4") + + checkbox = find(".deck-slide--current input[type='checkbox']", match: :first) + checkbox.execute_script("this.focus()") + + # Space on a focused checkbox is the presenter ticking a box mid-show. + # The click the browser synthesizes for it carries no coordinates, so + # measuring it against the last mouse position read as a drag: the + # click was swallowed and the box silently did not move. + send_keys(:space) + expect(checkbox).to be_checked + end end describe "the pen" do @@ -171,6 +198,35 @@ def attachments_on_screen? expect(page).to have_css(".deck--inking .deck-ink-badge") end + it "does not eat the next click when a stroke is cancelled mid-air" do + visit plan_path(plan) + start_show + send_keys("d") + expect(page).to have_css(".deck--inking") + + # The browser can take the pointer away mid-stroke (a touch becoming a + # system gesture). No click follows a cancellation, so the pen must not + # claim one — the next real click belongs to the show. Scripted because + # a driver has no way to make the browser cancel a pointer. + page.execute_script(<<~JS) + const slide = document.querySelector(".deck-slide--current"); + const box = slide.getBoundingClientRect(); + const fire = (type, x, y) => slide.dispatchEvent(new PointerEvent(type, { + pointerId: 7, isPrimary: true, button: 0, buttons: 1, + bubbles: true, cancelable: true, clientX: x, clientY: y + })); + fire("pointerdown", box.left + 40, box.top + 40); + for (let i = 1; i <= 20; i++) fire("pointermove", box.left + 40 + i * 8, box.top + 40); + fire("pointercancel", box.left + 200, box.top + 40); + JS + + # A cancelled stroke was never made. + expect(page).to have_no_css(".deck-ink__stroke") + + find(".deck-slide--current").click + expect(current_slide).to eq("2") + end + it "puts the pen away on Escape without ending the show" do visit plan_path(plan) start_show