Expand tables into a spreadsheet, and let diagrams be explored - #211
HamptonMakes wants to merge 5 commits into
Conversation
Markdown tables used to flow off the page. They're now framed: columns size to their content, long cells wrap, and anything wider than the column scrolls inside its own bordered frame with edge fades that say so. A table big enough to be worth it offers to expand into a spreadsheet takeover — both axes pinned, an arrow-driven cell cursor with a row/column crosshair, a value bar for cells the grid clips, numeric-aware column sorting with a reset, a wrap toggle, and an A1-style address readout. ⌘C copies the focused cell. Sorting works on a clone, so the document's own table — and the comment anchors that depend on its rendered text — never moves. Diagrams get the exploration they were missing. The old lightbox closed on any click, so a drag could never pan; it's replaced by a real pan/zoom surface: drag, scroll or pinch to zoom at the cursor, double-click to toggle fit and close-up, +/-/0/1 and arrows from the keyboard. Labels are legible now too — 16px in the app's own face, thicker strokes, and fonts loaded before Mermaid measures its boxes. Fitting stops before it becomes a thumbnail: inline, a diagram that would shrink past 80% keeps its real size and scrolls; expanded, fit floors at 55% and pans instead. Both features sit on one takeover surface (coplan/expander) and one affordance, with pan/zoom factored out separately — a spreadsheet wants real scrolling, not a transform. Decks opt out of table framing; a slide is a fixed artifact whose typography the deck layout already owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The expand affordance is injected by JS, so Turbo cached it and the controller appended a second one on every back navigation. Marked data-turbo-temporary, with a spec that reproduces the duplicate. - ⌘C flashed "copied" even where there is no clipboard API (plain http); the flash now waits for the write to resolve. - Clicking any toolbar button moved focus off the canvas and the grid frame, which own the keys — so one click on Zoom in or Wrap killed +/-/0/1 and the arrows. Both hand focus straight back. - Phones: the expander is sized by inset rather than 100vw/100dvh (a modal's margin:auto left a sliver of backdrop down one edge), fit floors at 55% instead of opening a 2000px diagram at 15%, and the gesture hint stays — worded for touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e0970b937
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const canvas = document.createElement("div") | ||
| canvas.className = "mermaid-diagram__canvas" | ||
| canvas.innerHTML = svg | ||
| diagram.append(canvas) |
There was a problem hiding this comment.
Preserve deck sizing through the new canvas wrapper
Nesting the SVG under .mermaid-diagram__canvas prevents the deck-specific selectors in engine/app/assets/stylesheets/coplan/deck.css:223-226 and 323-327 from matching because they still require .mermaid-diagram > svg. Consequently, stage diagrams no longer receive width: 100%; height: 44cqi, while other slide diagrams lose their max-height, causing thumbnails or content that exceeds the fixed slide canvas. Update those selectors, and any related deck sizing rules, for the new DOM shape.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Confirmed and fixed in cd2b003 — thank you, this was a real regression and it would have shipped broken slides.
Both selectors retargeted to .mermaid-diagram__canvas > svg. Two things beyond the selector swap, since the canvas brings more than nesting with it:
- The canvas is neutralized on a slide (
padding: 0; overflow: visible). The document's padded scroll box is wrong on a fixed frame — a scrollbar across a slide would become part of the artifact. - Decks now opt out of the inline legibility floor entirely (
if (diagram.closest(".deck")) return), for the same reason they opt out of table framing. The floor's move is to pin a pixel width on the SVG, and an inline width beats.deck-slide--stage … { width: 100% }on specificity — so a wide diagram would have been pinned at its natural size and pushed straight off the slide.
Also fixed the reason this got through: the deck spec only asserted the diagram was wider than 200px. It now asserts the cqi rules resolve to an actual length, that the diagram fits its slide, and that none of the scroll-box treatment comes along. Verified failing against the old selectors.
| this.onScroll = () => this.measure() | ||
| this.frameTarget.addEventListener("scroll", this.onScroll, { passive: true }) |
There was a problem hiding this comment.
Reposition thread popovers when the table frame scrolls
When a comment is anchored in a tall or wide table, scrolling this new inner frame only calls measure(). Native scroll events do not bubble, and text_selection_controller.js:39 listens only to window, so an open thread popover remains at its old viewport coordinates while its highlighted anchor moves inside the table. Forward this scroll to the popover tracker or have that controller observe nested scroll containers.
AGENTS.md reference: AGENTS.md:L159-L163
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Real — measured it before fixing. With a thread open in a table, scrolling the frame moved the mark 47px and left the popover at exactly its old coordinates.
Fixed in cd2b003 by capturing on the document instead of binding to window:
document.addEventListener("scroll", this._handleScroll, { capture: true, passive: true })Chose that over forwarding the event from the grid controller because it covers every nested scroller — this frame, the expanded sheet, and whatever gets added later — rather than needing each new one to remember to announce itself. _handleScroll returns on its first line unless a popover is actually open, so the extra traffic costs nothing. Regression spec included, verified failing with the window listener.
| const { width, height } = naturalSize(svg) | ||
| const available = canvas.clientWidth | ||
| if (!width || !available) return | ||
|
|
||
| const scrolling = available / width < MIN_INLINE_SCALE |
There was a problem hiding this comment.
Measure the diagram canvas content box for the scale floor
clientWidth includes the canvas's horizontal padding, but the SVG's max-width: 100% is constrained by the smaller content box. On narrow layouts, a diagram near the 80% threshold can therefore be classified as fitting while CSS actually shrinks it below 80%; with the current 32px total padding, this is particularly noticeable on phones. Subtract the computed inline padding, or measure a padding-free inner element, before deciding whether to enable scrolling.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Correct, fixed in cd2b003 — available now subtracts the computed inline padding before the comparison.
For the record the padding is 16px a side, so the floor was measuring 32px more room than the SVG actually gets.
One honest note: this one ships without a spec. It only misbehaves within 32px of the threshold, so a spec would have to pin a diagram at a hand-tuned width and would break the moment the padding changes — it would be testing the arithmetic rather than the behaviour. The reasoning is in a comment at the measurement instead.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical findings remain, along with moderate deck and accessibility issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR adds explorable Mermaid diagrams and spreadsheet-style Markdown tables, with deck opt-out support and regression coverage.
Changes:
- Adds shared expansion and Mermaid pan/zoom behavior.
- Adds scrollable tables with sorting and keyboard navigation.
- Updates helpers, styles, importmap, seeds, and tests.
File summaries
| File | Summary | Final review notes |
|---|---|---|
spec/system/diagram_expand_spec.rb |
Tests diagram expansion and pan/zoom behavior. | No final comment. |
spec/system/deck_ux_spec.rb |
Updates deck Mermaid selector coverage. | No final comment. |
spec/system/data_grid_spec.rb |
Tests spreadsheet interactions. | No final comment. |
spec/system/comment_ux_spec.rb |
Updates takeover UX coverage. | No final comment. |
spec/helpers/markdown_helper_spec.rb |
Tests table wrapping and anchor preservation. | No final comment. |
engine/config/importmap.rb |
Pins new JavaScript modules. | No final comment. |
engine/app/javascript/coplan/pan_zoom.js |
Implements pan, zoom, and gestures. | Nit (1 vote): Add browser coverage for offset wheel zoom and two-pointer pinch. |
engine/app/javascript/coplan/expander.js |
Provides shared takeover behavior. | Critical (2 votes): Clean up the active expander before Turbo snapshots. |
engine/app/javascript/controllers/coplan/mermaid_controller.js |
Handles Mermaid sizing and expansion. | Moderate (2 votes): Update deck selectors and overrides for the new canvas structure. |
engine/app/javascript/controllers/coplan/data_grid_controller.js |
Implements spreadsheet behavior. | Moderate (1 vote): Initialize aria-pressed.Moderate (1 vote): Make sortable headers keyboard-operable. Critical (1 vote): Guard the optional clipboard promise before calling .then. |
engine/app/helpers/coplan/slideshows_helper.rb |
Disables table enhancement in decks. | No final comment. |
engine/app/helpers/coplan/markdown_helper.rb |
Adds post-sanitize table wrappers. | No final comment. |
engine/app/assets/stylesheets/coplan/application.css |
Styles grids and takeover surfaces. | Moderate (1 vote): Update deck selectors/overrides for the new wrapper structure. |
db/seeds/development.rb |
Adds wider demonstration fixtures. | No final comment. |
Review details
Suppressed comments (4)
engine/app/assets/stylesheets/coplan/application.css:2028
- This wrapper now also changes the deck DOM, but deck.css still targets
.mermaid-diagram > svgand never resets the new canvas's document rules. Because slides remain inside.markdown-rendered, the canvas gets document padding/overflow, while the stage sizing rules match nothing; deck diagrams can lose their fixed/scaled layout. Update the deck selectors/overrides (or scope these rules to document mode) together with this wrapper.
.markdown-rendered .mermaid-diagram__canvas {
display: flex;
justify-content: center;
overflow-x: auto;
padding: var(--space-md);
engine/app/javascript/controllers/coplan/data_grid_controller.js:151
- This control toggles
.is-wrappedand later updatesaria-pressed, but it has no initialaria-pressedstate. A keyboard or screen-reader user entering the sheet cannot tell whether wrapping is currently enabled until after the first activation.
this.wrapButton = this.expander.addTool({
label: "Wrap cell text",
hint: "Wrap cell text",
icon: ICONS.wrap,
onClick: () => this.toggleWrap()
})
engine/app/javascript/controllers/coplan/data_grid_controller.js:123
- The sortable headers are the only way to invoke
applySort, but they are assignedtabIndex = -1andhandleKeyhas no Enter/Space activation path. Keyboard and assistive-technology users therefore cannot sort a column, even thougharia-sortexposes the sort state; make the header control keyboard-operable (or place a real button in it).
this.headerCells.forEach((cell, index) => {
cell.setAttribute("scope", "col")
cell.dataset.column = index
cell.setAttribute("aria-sort", "none")
cell.tabIndex = -1
})
engine/app/javascript/coplan/pan_zoom.js:107
- The added system coverage exercises toolbar, keyboard, and mouse-drag behavior, but not this new cursor-anchored wheel path (or the two-pointer pinch branch). Those paths contain independent zoom/anchor math, so regressions can pass the advertised suite; add browser coverage for wheel zoom at an offset and a pinch gesture.
// A trackpad pinch arrives as ctrl+wheel with much smaller deltas than
// a mouse wheel notch, so it needs a stronger multiplier to feel 1:1.
const intensity = event.ctrlKey ? 0.012 : 0.0022
const step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
setScale(scale * Math.exp(-step * intensity), ...viewportPoint(event))
- Files reviewed: 14/14 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| navigator.clipboard?.writeText(this.cursor.textContent.trim()) | ||
| .then(() => this.flashCopied(), () => {}) |
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Respectfully, I think this one is a false positive — optional chaining short-circuits the whole chain, not just the single access it sits on. When navigator.clipboard is nullish the entire expression evaluates to undefined and .then is never reached, so there's no dereference to throw on.
Checked it in a live page against a stub rather than trusting my reading:
const fake = {}
fake.clipboard?.writeText("x").then(() => {}, () => {})
// → undefined, no error thrownSo the plain-http path does exactly what the change intended: no write, no flash, no throw. Leaving it as is — happy to be shown otherwise if you have a case where the short-circuit doesn't apply.
| // Turbo caches the page on navigation; a cached open dialog would come | ||
| // back as a dead overlay with no controller behind it. | ||
| dialog.dataset.turboTemporary = "" |
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Also a false positive, I believe — close() is already called on the way out, just not from a turbo:before-cache handler.
Both controllers close their surface in disconnect() (data_grid_controller.js:47, mermaid_controller.js:33), and Stimulus disconnects when Turbo swaps the body — so the close event fires and its handler clears expander-open, drops current, and tears down pan/zoom.
Verified in dev: opened a diagram expander, ran Turbo.visit("/"), then read the page back.
before: { cls: "expander-open", dialogs: 1, overflow: "hidden" }
after: { cls: "", dialogs: 0, overflow: "visible" }
data-turbo-temporary is belt-and-braces for the snapshot itself — it's what keeps a cached copy of the dialog from coming back as a dead overlay. Worth adding that the same attribute on the expand affordance is load-bearing: without it Turbo restored the injected button and the reconnecting controller appended a second one. There's a spec for that one.
| const canvas = document.createElement("div") | ||
| canvas.className = "mermaid-diagram__canvas" | ||
| canvas.innerHTML = svg | ||
| diagram.append(canvas) |
There was a problem hiding this comment.
🤖 (reply from Hampton's AI agent)
Confirmed and fixed in cd2b003 — see the reply on Codex's thread for the same issue for the details. You were both right, and your point about the canvas's padding/overflow leaking onto slides was the part I would have missed if I'd only swapped the selectors: the canvas is now explicitly neutralized on a slide, and decks opt out of the pixel-pinning legibility floor altogether.
…ment Three real findings from the review bots. The deck sized slide diagrams with `.mermaid-diagram > svg`, and the SVG is now a grandchild under the canvas — so slides lost `max-height: 34cqi` and stage slides lost `width: 100%; height: 44cqi`. Retargeted, and the canvas itself is neutralized on a slide: the document's padded scroll box is wrong on a fixed frame. Decks also opt out of the inline legibility floor entirely, for the same reason they opt out of table framing — the floor pins a pixel width, which on a slide pins the diagram wider than the slide. The deck spec that should have caught this only asserted the diagram was wider than 200px. It now asserts the cqi rules actually resolve to a length, that the diagram fits its slide, and that none of the scroll-box treatment comes along. An open thread popover sat still while the table frame scrolled out from under its mark: `scroll` doesn't bubble, so the window listener never heard the new inner scroller. Captured on the document instead, which also covers the expanded sheet and anything nested later. The legibility floor measured `clientWidth`, which includes the canvas's 32px of padding, while the SVG's `max-width: 100%` resolves against the box inside it — so a diagram near the threshold was called legible and then shrunk past it anyway. No spec for this one: it only shows up within 32px of the threshold, and a spec pinned there would break on any padding change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…agram-expand-39dcde * origin/main: Simplify comment threads to Resolve/Reopen, drop accept/discard (#209)
#209 landed 40 minutes after this branch's previous commit and collapsed thread statuses to open/resolved. The popover-drift spec added here created its fixture thread with the old "pending", which is valid on this branch and invalid on the merge — so the suite was green locally and red on CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A markdown table used to flow off the page — Zandt reported it — and a Mermaid diagram, though it "opened", couldn't be zoomed or panned once it did. Both are the same shape of problem: a block of content bigger than the column it was handed, with nowhere to go.
So both get the same answer. One takeover surface (
coplan/expander), one affordance in the same corner, and past that the two diverge exactly where they should: a diagram wants a transform to pan and zoom, a spreadsheet wants real scrolling.A table, twice
In the document it stays compact and behaves: columns sized to their content, long cells wrapped at 34ch, the header pinned once there's enough to scroll, and — the actual bug — a scroll frame, so a table too wide to wrap takes its overflow itself instead of widening the page. Fades at both edges say there's more.
Expanded it's a spreadsheet. Both axes pinned, a cell cursor on the arrow keys with a crosshair on its row and column, an A1-style address, a value bar that shows the focused cell in full when the grid clips it, ⌘C, click-to-sort columns that know
$1,200and38%are numbers, and a reset back to the document's order. Sorting runs on a clone — the document's own table never moves, which is what keeps comment anchors (they count occurrences of rendered text) pointing where they did.The wrapper is added post-sanitize on purpose: it carries
data-controller, which document markup is deliberately never allowed to write for itself. It adds structure and never text, so the anchor text is byte-identical — there's a spec that asserts exactly that.A diagram you can actually explore
The old lightbox closed on any click, which is precisely why it could never be panned: every drag ended in a dismissal. It's replaced by a real pan/zoom surface — drag, wheel or pinch to zoom at the cursor, double-click to toggle fit and close-up,
+ - 0 1and arrows from the keyboard. Dismissal narrowed to backdrop-or-Escape.Legibility was the other half of the complaint. Labels are 16px in the app's own face, edges are thicker, and Mermaid now measures its node boxes after
document.fonts.ready— Lexend arrives as a swapped webfont, so measuring early sized every box for the fallback and let the real labels overflow them.And fitting stops before it becomes a thumbnail. Inline, a diagram that would have to shrink past 80% keeps its real size and scrolls its frame instead. Expanded, fit floors at 55% and pans — on a phone the seeded flowchart used to open at 16%, a field of specks.
Decks opt out
data_tables: false. A slide is a fixed, scaled artifact whose typography the deck layout engine already owns, and a nested scroll frame inside a transformed slide belongs to nobody.Verification
2031 examples, 0 failures. RuboCop clean across 457 files. 21 new examples across
data_grid_specanddiagram_expand_spec, including the reported bug stated directly — a 16-column table of unbreakable tokens, assertingdocumentElement.scrollWidth <= innerWidthwhile its own frame overflows — and the pan regression, a real Selenium drag that has to move the transform and leave the surface open.The review pass found five real defects; each fix has a regression that I confirmed fails without it:
data-turbo-temporary. The spec has to make a realTurbo.visit— a plainvisitis a fresh load and never reproduces it.)100vwplus a modal's defaultmargin: auto.Checked by hand in dev, light and dark, at 1440×900 and 375×812.
Seeds
Per house habit the dev seeds demo it: the flowchart fixture is now a 13-node branching chain (wide enough to hit the legibility floor), and the table fixture is an 8×12 experiment-results table with the units and blanks that make sorting interesting.