diff --git a/db/seeds/development.rb b/db/seeds/development.rb index ab245c8f..7862d33c 100644 --- a/db/seeds/development.rb +++ b/db/seeds/development.rb @@ -156,12 +156,26 @@ module DevelopmentSeed AGENT_ORGANIZE_RUN_ID = "development-seed-organize-run" CONTENT_FIXTURES = { + # Deliberately wider than a document column: this is the fixture that + # demonstrates the legibility floor (the diagram keeps its real size + # and scrolls rather than shrinking to unreadable) and the pan/zoom + # expanded view. flowchart: <<~MARKDOWN, ```mermaid flowchart LR - Client --> Gateway - Gateway --> API - API --> DB[(Database)] + Client[Merchant client] --> Gateway[Edge gateway] + Gateway --> Auth[Token exchange] + Auth --> API[Orders API] + API --> Rules[Eligibility rules] + Rules --> Engine[Pricing engine] + Engine --> Ledger[(Price ledger)] + Engine --> Risk[Risk scoring] + Risk --> Capture[Capture service] + Capture --> Settle[Settlement batch] + Settle --> Payout[(Payout store)] + API --> Events[Event feed] + Events --> Search[(Search index)] + Events --> Warehouse[(Warehouse)] ``` MARKDOWN state_diagram: <<~MARKDOWN, @@ -182,11 +196,23 @@ module DevelopmentSeed S-->>A: Receipt ID ``` MARKDOWN + # Wide and long on purpose — the fixture that shows a table framed and + # wrapped in the document, and opened as a spreadsheet. table: <<~MARKDOWN, - | Metric | Control | Treatment | - | --- | ---: | ---: | - | Success | 61.2% | 62.9% | - | Errors | 18.4% | 17.7% | + | Variant | Cohort | Sessions | Success | Errors | p95 latency | Revenue / session | Notes | + | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | + | Control | All | 41,208 | 61.2% | 18.4% | 412ms | $2.14 | Baseline ranker, unchanged since March | + | Treatment A | All | 40,876 | 62.9% | 17.7% | 438ms | $2.31 | Recall boost on long-tail queries | + | Treatment B | All | 12,004 | 59.8% | 21.3% | 507ms | $1.96 | Reranker on top; latency cost too high | + | Control | New | 8,912 | 54.1% | 22.8% | 401ms | $1.42 | New accounts see fewer personalized results | + | Treatment A | New | 8,844 | 58.6% | 19.9% | 430ms | $1.71 | Largest lift in the whole experiment | + | Treatment B | New | 2,610 | 55.2% | 24.1% | 512ms | $1.38 | Same latency story as the pooled cohort | + | Control | Returning | 32,296 | 63.1% | 17.2% | 414ms | $2.34 | | + | Treatment A | Returning | 32,032 | 64.1% | 17.1% | 440ms | $2.47 | Small but consistent across every week | + | Treatment B | Returning | 9,394 | 61.1% | 20.5% | 505ms | $2.12 | | + | Control | Mobile | 19,441 | 57.8% | 20.6% | 486ms | $1.88 | Mobile is latency-bound before it is ranking-bound | + | Treatment A | Mobile | 19,388 | 59.9% | 19.4% | 511ms | $2.02 | Lift holds, but the latency budget is nearly spent | + | Treatment B | Mobile | 5,702 | 54.3% | 25.7% | 604ms | $1.61 | Rejected on mobile outright | MARKDOWN code_walkthrough: <<~'MARKDOWN', How the discount engine decides what every line item costs. Each stage is diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index e54ed81c..44d671a8 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2009,17 +2009,32 @@ img.avatar { .markdown-rendered .mermaid-diagram { position: relative; - display: flex; - justify-content: center; - overflow-x: auto; margin-bottom: var(--space-md); - padding: var(--space-md); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); cursor: zoom-in; } +/* The diagram's scroll box, separate from the frame so the expand control + stays pinned to the corner instead of scrolling away with a wide + diagram. */ +.markdown-rendered .mermaid-diagram__canvas { + display: flex; + justify-content: center; + overflow-x: auto; + padding: var(--space-md); + border-radius: inherit; + scrollbar-width: thin; +} + +/* Past the legibility floor a diagram stops shrinking to fit and keeps its + real size — the controller pins the SVG's pixel width and this box + scrolls instead, so labels stay readable. */ +.markdown-rendered .mermaid-diagram--scrolling .mermaid-diagram__canvas { + justify-content: flex-start; +} + .markdown-rendered .mermaid-diagram__expand { position: absolute; top: var(--space-sm); @@ -2042,51 +2057,21 @@ img.avatar { opacity: 1; } -.mermaid-lightbox { - width: 94vw; - height: 92vh; - max-width: 94vw; - max-height: 92vh; - padding: var(--space-lg); - margin: auto; - border: 1px solid var(--color-border); - border-radius: 12px; - background: var(--color-surface); - color: var(--color-text); - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25); - cursor: zoom-out; -} - -.mermaid-lightbox::backdrop { - background: rgba(0, 0, 0, 0.35); -} - -.mermaid-lightbox__close { - position: absolute; - top: var(--space-sm); - right: var(--space-sm); - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-xs); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - color: var(--color-text-muted); - cursor: pointer; -} - -.mermaid-lightbox__close:hover { - color: var(--color-text); -} - /* 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 { +.markdown-rendered .mermaid-diagram__canvas > svg { max-width: 100%; height: auto; } +/* `flex: none` is load-bearing: the canvas is a flex container, and a flex + item shrinks to fit by default — which would undo the pixel width the + controller just pinned and put the diagram straight back to unreadable. */ +.markdown-rendered .mermaid-diagram--scrolling .mermaid-diagram__canvas > svg { + max-width: none; + flex: none; +} + /* Mermaid sizes node boxes assuming its own label line-height (~1.2); the page's inherited line-height (1.6) makes wrapped labels taller than the boxes mermaid measured, clipping the second line. Pin labels back to the @@ -2094,9 +2079,9 @@ img.avatar { .markdown-rendered .mermaid-diagram foreignObject div, .markdown-rendered .mermaid-diagram foreignObject span, .markdown-rendered .mermaid-diagram foreignObject p, -.mermaid-lightbox foreignObject div, -.mermaid-lightbox foreignObject span, -.mermaid-lightbox foreignObject p { +.expander--diagram foreignObject div, +.expander--diagram foreignObject span, +.expander--diagram foreignObject p { line-height: 1.2; } @@ -2137,6 +2122,153 @@ img.avatar { font-weight: 600; } +/* ── Data grid: a markdown table in the document ────────────────────────── + The bare rules above still apply wherever a table isn't wrapped (deck + slides own their own tabular typography). Everywhere else the table sits + in a frame it can scroll inside, instead of a block it overflows. */ +.markdown-rendered .data-grid { + position: relative; + margin-bottom: var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); +} + +.markdown-rendered .data-grid__frame { + overflow: auto; + /* Tall tables scroll inside the frame so the header can pin; short ones + never reach this and are unaffected. */ + max-height: 75vh; + border-radius: inherit; + scrollbar-width: thin; +} + +.markdown-rendered .data-grid table { + /* Columns size to their content and the table still fills a narrow + frame. width:100% squeezed columns to nothing instead, which is how a + wide table ended up unreadable *and* off the page. */ + width: max-content; + min-width: 100%; + margin: 0; + /* Collapsed borders don't travel with a sticky header — the header's own + border scrolls out from under it. Separate borders plus inset shadows + stay put. */ + border-collapse: separate; + border-spacing: 0; + font-size: var(--text-sm); +} + +.markdown-rendered .data-grid th, +.markdown-rendered .data-grid td { + border: none; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + padding: var(--space-sm) var(--space-md); + text-align: left; + vertical-align: top; + min-width: 6ch; + max-width: 34ch; + overflow-wrap: break-word; +} + +.markdown-rendered .data-grid tr > *:last-child { + border-right: none; +} + +.markdown-rendered .data-grid tbody tr:last-child > * { + border-bottom: none; +} + +.markdown-rendered .data-grid thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-surface-muted); + white-space: nowrap; + font-weight: 600; + box-shadow: inset 0 -1px 0 var(--color-border); +} + +.markdown-rendered .data-grid tbody tr:nth-child(even) > * { + background: var(--color-surface-muted); +} + +/* Edge fades: the honest signal that the frame is holding more than it's + showing. Only lit when there really is more in that direction. */ +.markdown-rendered .data-grid::before, +.markdown-rendered .data-grid::after { + content: ""; + position: absolute; + top: 1px; + bottom: 1px; + width: 2rem; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.markdown-rendered .data-grid::before { + left: 1px; + border-radius: var(--radius) 0 0 var(--radius); + background: linear-gradient(to right, var(--color-surface), transparent); +} + +.markdown-rendered .data-grid::after { + right: 1px; + border-radius: 0 var(--radius) var(--radius) 0; + background: linear-gradient(to left, var(--color-surface), transparent); +} + +.markdown-rendered .data-grid.is-scrolled-start::before, +.markdown-rendered .data-grid.is-scrolled-end::after { + opacity: 1; +} + +.markdown-rendered .data-grid__expand { + position: absolute; + top: var(--space-sm); + right: var(--space-sm); + z-index: 3; + display: none; + align-items: center; + justify-content: center; + padding: var(--space-xs); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text-muted); + box-shadow: var(--shadow-pop); + cursor: zoom-in; + opacity: 0; + transition: opacity 0.15s ease; +} + +/* Offered only where it buys something — a four-column, five-row table is + already entirely visible. */ +.markdown-rendered .data-grid.is-expandable .data-grid__expand { + display: flex; +} + +.markdown-rendered .data-grid.is-expandable:hover .data-grid__expand, +.markdown-rendered .data-grid.is-expandable:focus-within .data-grid__expand, +.markdown-rendered .data-grid__expand:focus-visible { + opacity: 1; +} + +.markdown-rendered .data-grid__expand:hover { + color: var(--color-text); + border-color: var(--color-border-strong); +} + +/* Nothing hovers on a touch screen, and tap-to-expand isn't available + here — selecting table text is how a comment gets anchored. */ +@media (hover: none) { + .markdown-rendered .data-grid.is-expandable .data-grid__expand { + opacity: 1; + } +} + .markdown-rendered hr { border: none; border-top: 1px solid var(--color-border); @@ -6518,3 +6650,407 @@ img.avatar { font-size: 0.75rem; } } + +/* ═══ Expanded view ═══════════════════════════════════════════════════════ + The shared takeover surface (coplan/expander.js) behind an expanded + Mermaid diagram and an expanded data table. The chrome — title bar, + toolbar, status line, dismissal — is identical for both; only the body + differs. */ + +/* showModal() dims the page but doesn't stop it scrolling behind the + overlay. */ +.expander-open { + overflow: hidden; +} + +.expander { + display: flex; + flex-direction: column; + width: 94vw; + height: 92vh; + max-width: 94vw; + max-height: 92vh; + padding: 0; + margin: auto; + overflow: hidden; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +.expander::backdrop { + background: rgba(8, 12, 24, 0.55); + backdrop-filter: blur(2px); +} + +.expander__bar { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: none; + padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-md); + background: var(--color-surface-muted); + border-bottom: 1px solid var(--color-border); +} + +.expander__title { + flex: 1; + min-width: 0; + margin: 0; + font-size: var(--text-base); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.expander__tools { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.expander__tool, +.expander__close { + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 var(--space-xs); + background: var(--color-surface); + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius); + cursor: pointer; +} + +.expander__tool:hover, +.expander__close:hover { + color: var(--color-text); + border-color: var(--color-border-strong); +} + +.expander__tool.is-active { + color: var(--color-primary); + background: var(--color-primary-light); + border-color: var(--color-primary); +} + +.expander__tool--hidden { + display: none; +} + +.expander__close { + margin-left: var(--space-xs); +} + +.expander__readout { + min-width: 3.5rem; + text-align: center; + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); +} + +.expander__body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.expander__status { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: none; + padding: var(--space-xs) var(--space-md); + background: var(--color-surface-muted); + border-top: 1px solid var(--color-border); + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.expander__hint { + margin-left: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Diagram body: the pan/zoom viewport ──────────────────────────────── */ + +.expander__canvas { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + /* The browser's own pan and pinch would fight the controller's. */ + touch-action: none; + cursor: grab; +} + +.expander__canvas.is-grabbing { + cursor: grabbing; +} + +.expander__canvas:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.expander__canvas > svg { + display: block; +} + +/* ── Table body: the spreadsheet ──────────────────────────────────────── */ + +.data-sheet { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +/* The value bar. Cells stay clipped to one line so a wide table is + scannable; this is where the focused cell is shown in full. */ +.data-sheet__value { + display: flex; + align-items: baseline; + gap: var(--space-sm); + flex: none; + min-height: 2.75rem; + padding: var(--space-sm) var(--space-md); + background: var(--color-bg-muted); + border-bottom: 1px solid var(--color-border); +} + +.data-sheet__value-label { + flex: none; + max-width: 12rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-muted); +} + +.data-sheet__value-content { + flex: 1; + min-width: 0; + max-height: 5.5rem; + overflow-y: auto; +} + +.data-sheet__value-content > *:last-child { + margin-bottom: 0; +} + +.data-sheet__value.is-empty .data-sheet__value-content::after { + content: "empty"; + color: var(--color-text-muted); + font-style: italic; +} + +.data-sheet__frame { + flex: 1; + min-height: 0; + overflow: auto; + scrollbar-width: thin; +} + +.data-sheet__frame:focus { + outline: none; +} + +.data-sheet__table { + width: max-content; + min-width: 100%; + background: var(--color-surface); + border-collapse: separate; + border-spacing: 0; + font-size: var(--text-sm); +} + +/* Body cells are transparent on purpose: it lets a background on the + column's paint the crosshair band for free, however many rows the + table has. */ +.data-sheet__table th, +.data-sheet__table td { + position: relative; + max-width: 40ch; + padding: var(--space-sm) var(--space-md); + background: transparent; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.data-sheet__frame.is-wrapped th, +.data-sheet__frame.is-wrapped td { + white-space: normal; + overflow: visible; + overflow-wrap: break-word; +} + +.data-sheet__table thead th { + position: sticky; + top: 0; + z-index: 3; + padding-right: calc(var(--space-md) + 0.75rem); + background: var(--color-surface-muted); + font-weight: 600; + cursor: pointer; + user-select: none; + box-shadow: inset 0 -1px 0 var(--color-border); +} + +.data-sheet__table thead th:first-child { + left: 0; + z-index: 4; +} + +/* The row-label column pins too — the header alone doesn't tell you which + row you're reading once you've scrolled sideways. Opaque, so the cells + it slides over disappear under it cleanly. */ +.data-sheet__table tbody tr > *:first-child { + position: sticky; + left: 0; + z-index: 2; + background: var(--color-surface); + font-weight: 500; + box-shadow: inset -1px 0 0 var(--color-border); +} + +/* The crosshair. Mixed from --color-primary rather than using + --color-primary-light, which is near-white in light mode and would make + the bands invisible on exactly the theme that needs them most. + Translucent on purpose: the row band paints over the column band, so + the cell where they cross deepens on its own. */ +.data-sheet__table col.is-cursor-column, +.data-sheet__table tbody tr.is-cursor-row > * { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); +} + +/* The pinned cells can't be translucent — rows scrolling underneath would + show through them — so they take the same tint, mixed solid. */ +.data-sheet__table tbody tr.is-cursor-row > *:first-child { + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); +} + +.data-sheet__table thead th.is-cursor-column { + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 14%, var(--color-surface-muted)); +} + +/* Written the long way round so it outranks the pinned first column's own + box-shadow — the cursor has to be visible in column A too. */ +.data-sheet__table tbody tr > td.is-cursor, +.data-sheet__table tbody tr > th.is-cursor { + z-index: 5; + box-shadow: inset 0 0 0 2px var(--color-primary); + outline: none; +} + +/* Sort state: a triangle in the header's reserved right-hand gutter. */ +.data-sheet__table thead th::after { + content: ""; + position: absolute; + right: var(--space-sm); + top: calc(50% - 2px); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + opacity: 0; + transition: opacity 0.12s ease; +} + +.data-sheet__table thead th:hover::after { + border-top: 5px solid currentColor; + opacity: 0.4; +} + +.data-sheet__table thead th.is-sorted-asc::after { + border-top: none; + border-bottom: 5px solid var(--color-primary); + opacity: 1; +} + +.data-sheet__table thead th.is-sorted-desc::after { + border-bottom: none; + border-top: 5px solid var(--color-primary); + opacity: 1; +} + +/* A comment anchor inside a cell still shows, but as a mark, not as + something you can open — the sheet is a copy, and its threads live back + in the document. */ +.data-sheet mark { + background: transparent; + color: inherit; + text-decoration: underline wavy var(--color-highlight-open-border); + text-decoration-skip-ink: none; + pointer-events: none; +} + +.data-sheet__address { + min-width: 3.25rem; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--color-text); +} + +.data-sheet__address.is-copied { + color: var(--color-success); +} + +.data-sheet__address.is-copied::after { + content: " copied"; + font-family: var(--font-sans); + font-weight: 400; +} + +.data-sheet__column { + font-weight: 600; + color: var(--color-text); +} + +@media (max-width: 640px) { + /* A phone has no room for a framed dialog: the takeover takes the screen. + Sized by `inset` rather than 100vw/100dvh — a modal dialog's default + `margin: auto` centres it, so any disagreement between the viewport units + and the actual layout width leaves a sliver of backdrop down one edge. */ + .expander { + inset: 0; + width: auto; + height: auto; + max-width: none; + max-height: none; + margin: 0; + border: none; + border-radius: 0; + } + + /* No room for keyboard instructions beside the cell address — but the + diagram's hint is the only place the pan/zoom gestures are named, and + it's short enough to keep. */ + .expander__hint { + display: none; + } + + .expander--diagram .expander__hint { + display: block; + } + + .data-sheet__value-label { + max-width: 7rem; + } +} diff --git a/engine/app/assets/stylesheets/coplan/deck.css b/engine/app/assets/stylesheets/coplan/deck.css index aee79919..7b1adcb5 100644 --- a/engine/app/assets/stylesheets/coplan/deck.css +++ b/engine/app/assets/stylesheets/coplan/deck.css @@ -217,10 +217,21 @@ background: var(--deck-title-bg); } +/* In the document the SVG sits in a padded scroll box, so a diagram too wide + to read at the column's width can keep its real size and scroll. Neither + half of that belongs on a slide: the slide is a fixed frame the diagram is + sized to fit, and a scrollbar across it would be part of the artifact. */ +.deck-slide .deck-content .mermaid-diagram__canvas { + padding: 0; + overflow: visible; +} + /* 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 { + full-slide glyph over the diagram. It's addressed through the canvas + because that's where the diagram controller puts it; a `> svg` child + selector here silently matches nothing and the slide loses its sizing. */ +.deck-slide .deck-content .mermaid-diagram__canvas > svg { max-width: 100%; max-height: 34cqi; } @@ -320,7 +331,7 @@ width: 100%; } -.deck-slide--stage .deck-content .mermaid-diagram > svg { +.deck-slide--stage .deck-content .mermaid-diagram__canvas > svg { width: 100%; height: 44cqi; max-width: none !important; diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index b9120cbb..56e2c434 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -34,7 +34,7 @@ module MarkdownHelper # version. Bump it whenever the rendering pipeline changes output for the # same input (new tags, attribute changes, checkbox wiring, etc.), or # stale HTML will be served from cache. - RENDER_CACHE_VERSION = 12 + RENDER_CACHE_VERSION = 13 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -51,7 +51,12 @@ module MarkdownHelper # document when rendering a slice of a larger plan (slideshow slides). # Checkbox toggles write to source lines by number, so their data-line # must stay document-absolute even when the render sees only a fragment. - def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0) + # + # data_tables: wrap tables in their scroll frame and give them the + # spreadsheet expander. Decks opt out — 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. + def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0, data_tables: true) render_options = { unsafe: true } # Sourcepos is only needed to wire checkboxes to their source lines; # make_checkboxes_interactive strips it from the final output. @@ -65,6 +70,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: result = select_footnotes(result, footnotes) return result.html_safe if footnotes == :only + result = wrap_data_tables(result) if data_tables tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid coplan--syntax-highlight" }) end @@ -244,6 +250,34 @@ def make_checkboxes_interactive(html, content, line_offset: 0) doc.to_html end + # A table is a block of data, not a paragraph. Each one gets a scroll + # frame — so a wide table can't run off the page the way it does bare — + # and the Stimulus controller that pins its header, fades its edges, and + # opens it as a full-screen spreadsheet. + # + # This runs after sanitization on purpose: the frame carries + # data-controller and data-*-target attributes that document markup is + # deliberately never allowed to write for itself. It adds structure + # only, never text, so comment anchors — which count occurrences in the + # rendered text — see exactly what they saw before. + def wrap_data_tables(html) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + tables = doc.css("table").reject { |table| table.ancestors("table").any? } + return html if tables.empty? + + tables.each do |table| + grid = doc.document.create_element("div", class: "data-grid", + "data-controller" => "coplan--data-grid") + frame = doc.document.create_element("div", class: "data-grid__frame", + "data-coplan--data-grid-target" => "frame") + table.add_previous_sibling(grid) + grid.add_child(frame) + frame.add_child(table) + end + + doc.to_html + end + # Rewrites commonmarker's per-document footnote ids (#fn-N / #fnref-N) # and the hrefs that point at them so multiple fragments can coexist in # one DOM. Only ids/fragments with the fn-/fnref- shape are touched — diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb index d5834c5b..dfb9fd6a 100644 --- a/engine/app/helpers/coplan/slideshows_helper.rb +++ b/engine/app/helpers/coplan/slideshows_helper.rb @@ -31,6 +31,7 @@ def render_slideshow(content, interactive: true, theme: "coplan") classification = Slideshows::Classify.call(preamble + slide.source) lead_by_slide[slide.index.to_s] = classification.lead inner = render_markdown(preamble + slide.source, interactive:, footnotes: :exclude, + data_tables: false, line_offset: slide.start_line - 1 - preamble.count("\n")) tag.section(inner, class: [ "deck-slide", "deck-slide--#{classification.pattern}", @@ -44,7 +45,7 @@ def render_slideshow(content, interactive: true, theme: "coplan") # on every slide. The document-mode render is the ground truth readers # and agents link against; these passes rewrite the deck to match it. deck = Nokogiri::HTML::DocumentFragment.parse(safe_join(sections)) - document = Nokogiri::HTML::DocumentFragment.parse(render_markdown(content, interactive: false)) + document = Nokogiri::HTML::DocumentFragment.parse(render_markdown(content, interactive: false, data_tables: false)) renumber_deck_footnotes(deck, document) align_heading_ids(deck, document) mirror_section_link_enhancement(deck, document) diff --git a/engine/app/javascript/controllers/coplan/data_grid_controller.js b/engine/app/javascript/controllers/coplan/data_grid_controller.js new file mode 100644 index 00000000..a5824bb8 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/data_grid_controller.js @@ -0,0 +1,380 @@ +import { Controller } from "@hotwired/stimulus" +import { openExpander, attachExpandAffordance, nearestHeading, ICONS } from "coplan/expander" + +// A markdown table, twice over. +// +// In the document it stays compact and well-behaved: columns sized to their +// content, long cells wrapped, a scroll frame so a wide table can never run +// off the page, a header that pins itself when the table is tall enough to +// scroll, and fades at the edges so it's obvious there's more. +// +// Expanded it becomes a spreadsheet: header row and first column pinned, a +// cell cursor you drive with the arrow keys, a crosshair on the current row +// and column, a value bar showing the focused cell in full, and sortable +// columns. The surface itself is shared with Mermaid diagrams — see +// coplan/expander. + +// Below these, a table is small enough that offering to expand it is noise. +const WORTH_EXPANDING_ROWS = 8 +const WORTH_EXPANDING_COLUMNS = 4 + +export default class extends Controller { + static targets = [ "frame" ] + + connect() { + this.table = this.frameTarget.querySelector("table") + if (!this.table) return + + this.onScroll = () => this.measure() + this.frameTarget.addEventListener("scroll", this.onScroll, { passive: true }) + this.observer = new ResizeObserver(() => this.measure()) + this.observer.observe(this.frameTarget) + this.observer.observe(this.table) + + this.affordance = attachExpandAffordance(this.element, { + label: "Expand table", + hint: "Open as a spreadsheet", + className: "data-grid__expand", + onExpand: () => this.expand() + }) + + this.measure() + } + + disconnect() { + this.frameTarget?.removeEventListener("scroll", this.onScroll) + this.observer?.disconnect() + this.expanded?.close() + } + + // Edge fades and the expand affordance both depend on whether the frame + // actually has more to show than it's showing. + measure() { + const frame = this.frameTarget + const overflowX = frame.scrollWidth - frame.clientWidth > 1 + const overflowY = frame.scrollHeight - frame.clientHeight > 1 + + this.element.classList.toggle("is-scrolled-start", frame.scrollLeft > 1) + this.element.classList.toggle("is-scrolled-end", + overflowX && Math.ceil(frame.scrollLeft + frame.clientWidth) < frame.scrollWidth - 1) + this.element.classList.toggle("is-tall", overflowY) + + const rows = this.table.rows.length + const columns = this.table.rows[0]?.cells.length || 0 + this.element.classList.toggle("is-expandable", + overflowX || overflowY || rows > WORTH_EXPANDING_ROWS || columns > WORTH_EXPANDING_COLUMNS) + } + + expand() { + if (this.expanded) return + + const expander = openExpander({ + title: nearestHeading(this.element) || "Table", + label: "Expanded table", + variant: "grid", + status: true, + onClose: () => { this.expanded = null; this.sheet = null } + }) + this.expanded = expander + this.sheet = new Sheet(this.table, expander) + } +} + +// The expanded spreadsheet. Owns a clone of the document's table, so +// sorting and the cursor never touch what the page (or a comment anchor) +// is looking at. +class Sheet { + constructor(sourceTable, expander) { + this.expander = expander + this.table = sourceTable.cloneNode(true) + this.table.className = "data-sheet__table" + this.table.setAttribute("role", "grid") + + this.headerCells = Array.from(this.table.tHead?.rows[0]?.cells || []) + this.bodyRows = Array.from(this.table.tBodies[0]?.rows || []) + this.sourceOrder = this.bodyRows.slice() + this.columnCount = this.headerCells.length || this.bodyRows[0]?.cells.length || 0 + this.sort = null + this.cursor = null + + this.build() + this.wire() + if (this.bodyRows.length > 0) this.moveTo(0, 0) + else this.frame.focus({ preventScroll: true }) + } + + build() { + // A per column is what makes the crosshair free: a class on one + // paints the whole column, with no per-cell bookkeeping on a + // table that could be hundreds of rows long. + const group = document.createElement("colgroup") + this.columns = Array.from({ length: this.columnCount }, () => { + const col = document.createElement("col") + group.append(col) + return col + }) + this.table.prepend(group) + + this.headerCells.forEach((cell, index) => { + cell.setAttribute("scope", "col") + cell.dataset.column = index + cell.setAttribute("aria-sort", "none") + cell.tabIndex = -1 + }) + + this.bodyRows.forEach(row => { + Array.from(row.cells).forEach((cell, index) => { + cell.dataset.column = index + cell.tabIndex = -1 + cell.setAttribute("role", "gridcell") + }) + }) + + this.valueBar = node("div", "data-sheet__value") + this.valueLabel = node("span", "data-sheet__value-label") + this.valueContent = node("div", "data-sheet__value-content") + this.valueBar.append(this.valueLabel, this.valueContent) + + this.frame = node("div", "data-sheet__frame") + this.frame.tabIndex = 0 + this.frame.append(this.table) + + const sheet = node("div", "data-sheet") + sheet.append(this.valueBar, this.frame) + this.expander.body.append(sheet) + + this.wrapButton = this.expander.addTool({ + label: "Wrap cell text", + hint: "Wrap cell text", + icon: ICONS.wrap, + onClick: () => this.toggleWrap() + }) + this.resetButton = this.expander.addTool({ + label: "Reset sort order", + hint: "Back to the document's order", + icon: ICONS.unsort, + className: "expander__tool expander__tool--hidden", + onClick: () => this.applySort(null) + }) + + this.address = node("span", "data-sheet__address") + this.column = node("span", "data-sheet__column") + this.dimensions = node("span", "data-sheet__dimensions") + this.dimensions.textContent = + `${count(this.bodyRows.length, "row")} × ${count(this.columnCount, "column")}` + const hint = node("span", "expander__hint") + hint.textContent = "Arrows move · Home/End jump · ⌘C copies the cell" + this.expander.setStatus([ this.address, this.column, this.dimensions, hint ]) + } + + wire() { + this.table.addEventListener("click", event => { + const cell = event.target.closest("td, th") + if (!cell) return + if (cell.parentElement.parentElement === this.table.tHead) { + this.applySort(this.nextSortFor(Number(cell.dataset.column))) + return + } + const row = this.bodyRows.indexOf(cell.parentElement) + if (row >= 0) this.moveTo(row, Number(cell.dataset.column)) + }) + + this.frame.addEventListener("keydown", event => this.handleKey(event)) + } + + handleKey(event) { + if (event.altKey) return + + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") { + if (!this.cursor) return + event.preventDefault() + // Only say "copied" once the clipboard has actually taken it: over + // plain http there's no clipboard API at all, and a flash that lies + // costs more than one that never appears. + navigator.clipboard?.writeText(this.cursor.textContent.trim()) + .then(() => this.flashCopied(), () => {}) + return + } + if (event.metaKey || event.ctrlKey) return + + const lastRow = this.bodyRows.length - 1 + const lastColumn = this.columnCount - 1 + const page = Math.max(1, Math.floor(this.frame.clientHeight / (this.cursor?.offsetHeight || 32)) - 1) + let { row, column } = this.position || { row: 0, column: 0 } + + switch (event.key) { + case "ArrowUp": row -= 1; break + case "ArrowDown": row += 1; break + case "ArrowLeft": column -= 1; break + case "ArrowRight": column += 1; break + case "PageUp": row -= page; break + case "PageDown": row += page; break + // Home/End move along the row; with shift they jump to the corners of + // the whole table, the way a spreadsheet's ctrl+Home does (ctrl is + // already spoken for by copy). + case "Home": + column = 0 + if (event.shiftKey) row = 0 + break + case "End": + column = lastColumn + if (event.shiftKey) row = lastRow + break + default: return + } + + event.preventDefault() + this.moveTo(clamp(row, 0, lastRow), clamp(column, 0, lastColumn)) + } + + moveTo(row, column) { + const cell = this.bodyRows[row]?.cells[column] + if (!cell) return + + this.cursor?.classList.remove("is-cursor") + this.cursor?.removeAttribute("aria-selected") + this.bodyRows.forEach(candidate => candidate.classList.remove("is-cursor-row")) + this.columns.forEach(candidate => candidate.classList.remove("is-cursor-column")) + this.headerCells.forEach(candidate => candidate.classList.remove("is-cursor-column")) + + this.position = { row, column } + this.cursor = cell + cell.classList.add("is-cursor") + cell.setAttribute("aria-selected", "true") + this.bodyRows[row].classList.add("is-cursor-row") + this.columns[column]?.classList.add("is-cursor-column") + this.headerCells[column]?.classList.add("is-cursor-column") + + this.address.textContent = `${columnName(column)}${row + 1}` + const header = this.headerCells[column]?.textContent.trim() + this.column.textContent = header || "" + this.column.hidden = !header + + this.valueLabel.textContent = header || columnName(column) + this.valueContent.replaceChildren(...Array.from(cell.cloneNode(true).childNodes)) + this.valueBar.classList.toggle("is-empty", cell.textContent.trim() === "") + + cell.focus({ preventScroll: true }) + this.reveal(cell) + } + + // Focus scrolls a cell into view on its own, but it doesn't know the + // pinned header and first column are sitting on top of the region it + // scrolled to — so it happily parks the cursor underneath them. + reveal(cell) { + const frame = this.frame.getBoundingClientRect() + const box = cell.getBoundingClientRect() + const headerHeight = this.table.tHead?.getBoundingClientRect().height || 0 + const gutter = this.position.column === 0 ? 0 : (this.bodyRows[0]?.cells[0]?.getBoundingClientRect().width || 0) + + let left = 0 + let top = 0 + if (box.top < frame.top + headerHeight) top = box.top - frame.top - headerHeight + else if (box.bottom > frame.bottom) top = box.bottom - frame.bottom + if (box.left < frame.left + gutter) left = box.left - frame.left - gutter + else if (box.right > frame.right) left = box.right - frame.right + + if (left || top) this.frame.scrollBy({ left, top, behavior: "instant" }) + } + + nextSortFor(column) { + if (this.sort?.column !== column) return { column, direction: "asc" } + return this.sort.direction === "asc" ? { column, direction: "desc" } : null + } + + applySort(sort) { + this.sort = sort + const body = this.table.tBodies[0] + if (!body) return + + if (sort) { + const values = new Map(this.sourceOrder.map(row => + [ row, (row.cells[sort.column]?.textContent || "").trim() ])) + const numeric = this.sourceOrder.every(row => isNumeric(values.get(row))) + const order = sort.direction === "asc" ? 1 : -1 + this.bodyRows = this.sourceOrder.slice().sort((a, b) => { + const left = values.get(a) + const right = values.get(b) + // Blanks sort last in both directions — a missing value isn't + // "smallest", it's absent. + if (left === "" || right === "") return left === right ? 0 : left === "" ? 1 : -1 + if (numeric) return (numberOf(left) - numberOf(right)) * order + return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" }) * order + }) + } else { + this.bodyRows = this.sourceOrder.slice() + } + + body.append(...this.bodyRows) + this.headerCells.forEach((cell, index) => { + const active = sort?.column === index + cell.setAttribute("aria-sort", active ? (sort.direction === "asc" ? "ascending" : "descending") : "none") + cell.classList.toggle("is-sorted-asc", active && sort.direction === "asc") + cell.classList.toggle("is-sorted-desc", active && sort.direction === "desc") + }) + this.resetButton.classList.toggle("expander__tool--hidden", !sort) + + // The cursor follows its cell, not its coordinates — the value you were + // looking at is the thing you care about after a re-sort. + if (this.cursor) { + const row = this.bodyRows.indexOf(this.cursor.parentElement) + if (row >= 0) this.moveTo(row, this.position.column) + } + } + + toggleWrap() { + const wrapped = this.frame.classList.toggle("is-wrapped") + this.wrapButton.classList.toggle("is-active", wrapped) + this.wrapButton.setAttribute("aria-pressed", String(wrapped)) + // Hand the keyboard back to the grid: the arrow keys only reach the + // sheet's own listener while focus is inside the frame, so leaving it on + // the toolbar button would strand the cursor. + if (this.cursor) { + this.cursor.focus({ preventScroll: true }) + this.reveal(this.cursor) + } + } + + flashCopied() { + this.address.classList.add("is-copied") + setTimeout(() => this.address.classList.remove("is-copied"), 600) + } +} + +function node(name, className) { + const element = document.createElement(name) + element.className = className + return element +} + +function clamp(value, low, high) { + return Math.min(high, Math.max(low, value)) +} + +function count(n, noun) { + return `${n} ${noun}${n === 1 ? "" : "s"}` +} + +// Spreadsheet column names: A, B, … Z, AA, AB. +function columnName(index) { + let name = "" + let n = index + do { + name = String.fromCharCode(65 + (n % 26)) + name + n = Math.floor(n / 26) - 1 + } while (n >= 0) + return name +} + +// Table numbers in plans wear units: "$1,200", "38%", "12ms", "~4". A column +// counts as numeric only if every value in it reads as one, so a column of +// names never gets sorted by the digits inside it. +const NUMERIC = /^[^\d-]{0,3}-?[\d,]+(\.\d+)?[^\d]{0,4}$/ + +function isNumeric(value) { + return value === "" || NUMERIC.test(value) +} + +function numberOf(value) { + return Number.parseFloat(value.replace(/[^\d.-]/g, "")) || 0 +} diff --git a/engine/app/javascript/controllers/coplan/mermaid_controller.js b/engine/app/javascript/controllers/coplan/mermaid_controller.js index e8fd7059..e2994509 100644 --- a/engine/app/javascript/controllers/coplan/mermaid_controller.js +++ b/engine/app/javascript/controllers/coplan/mermaid_controller.js @@ -1,9 +1,22 @@ import { Controller } from "@hotwired/stimulus" +import { openExpander, attachExpandAffordance, nearestHeading, ICONS } from "coplan/expander" +import { createPanZoom } from "coplan/pan_zoom" let diagramId = 0 let mermaidPromise let configuredTheme +// How far a diagram may be shrunk to fit the document column before +// legibility loses the argument. Past this, the diagram keeps its real size +// and its frame scrolls sideways instead — a label you can't read is worse +// than a scrollbar. +const MIN_INLINE_SCALE = 0.8 + +// The same bargain in the expanded surface, where there's somewhere to pan +// to: a wide diagram "fitted" to a phone would land at 15%, so fit stops at +// roughly 9px labels and lets you drag the rest into view. +const MIN_EXPANDED_SCALE = 0.55 + export default class extends Controller { connect() { this.renderGeneration ||= 0 @@ -11,6 +24,9 @@ export default class extends Controller { this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)") window.addEventListener("coplan:theme-changed", this.boundThemeChange) this.colorSchemeQuery.addEventListener("change", this.boundThemeChange) + this.sizeObserver = new ResizeObserver(entries => { + entries.forEach(entry => this.sizeDiagram(entry.target.closest(".mermaid-diagram"))) + }) this.renderDiagrams() } @@ -18,7 +34,8 @@ export default class extends Controller { this.renderGeneration += 1 window.removeEventListener("coplan:theme-changed", this.boundThemeChange) this.colorSchemeQuery.removeEventListener("change", this.boundThemeChange) - this.lightbox?.close() + this.sizeObserver?.disconnect() + this.closeExpanded() } async renderDiagrams() { @@ -38,6 +55,10 @@ export default class extends Controller { try { const mermaid = await loadMermaid() + // Mermaid measures label text in the DOM to size its node boxes. Lexend + // arrives as a swapped webfont, so measuring before it lands sizes every + // box for the fallback face and the real labels then overflow them. + await document.fonts?.ready if (!this.element.isConnected || generation !== this.renderGeneration) return for (const { container, source } of sources) { @@ -70,10 +91,20 @@ export default class extends Controller { diagram.setAttribute("aria-label", "Mermaid diagram") diagram.dataset.mermaidSource = source diagram.dataset.mermaidTheme = theme - diagram.innerHTML = svg + + // The SVG lives in its own scroll box so the expand affordance stays + // pinned to the frame's corner instead of scrolling away with a wide + // diagram. + const canvas = document.createElement("div") + canvas.className = "mermaid-diagram__canvas" + canvas.innerHTML = svg + diagram.append(canvas) + this.makeExpandable(diagram) sourceContainer.replaceWith(diagram) - bindFunctions?.(diagram) + bindFunctions?.(canvas) + this.sizeDiagram(diagram) + this.sizeObserver?.observe(canvas) } catch { document.getElementById(id)?.remove() document.getElementById(`d${id}`)?.remove() @@ -83,57 +114,118 @@ export default class extends Controller { } } + // Decides between fitting the diagram to the column and letting it keep + // its real size behind a horizontal scroll. + sizeDiagram(diagram) { + const canvas = diagram?.querySelector(".mermaid-diagram__canvas") + const svg = canvas?.querySelector("svg") + if (!svg) return + + // A slide is a fixed, scaled artifact and deck.css sizes its diagrams to + // the slide canvas. The floor's whole move is to pin a pixel width, which + // on a slide pins the diagram wider than the slide it has to fit inside. + if (diagram.closest(".deck")) return + + const { width, height } = naturalSize(svg) + // The content box, not clientWidth — that includes the canvas's padding, + // while the SVG's `max-width: 100%` resolves against the box inside it. + // Measuring the wrong one calls a diagram legible and then lets CSS + // shrink it past the floor regardless. + const padding = getComputedStyle(canvas) + const available = canvas.clientWidth - + parseFloat(padding.paddingLeft) - parseFloat(padding.paddingRight) + if (!width || available <= 0) return + + const scrolling = available / width < MIN_INLINE_SCALE + diagram.classList.toggle("mermaid-diagram--scrolling", scrolling) + const nextWidth = scrolling ? `${Math.round(width)}px` : "" + const nextHeight = scrolling ? `${Math.round(height)}px` : "" + // Only write when the value changes: the observer watches this element, + // and an unconditional write would keep re-triggering itself. + if (svg.style.width !== nextWidth) svg.style.width = nextWidth + if (svg.style.height !== nextHeight) svg.style.height = nextHeight + } + makeExpandable(diagram) { - const expand = document.createElement("button") - expand.type = "button" - expand.className = "mermaid-diagram__expand" - expand.setAttribute("aria-label", "Expand diagram") - expand.title = "Expand diagram" - expand.innerHTML = EXPAND_ICON - diagram.append(expand) + attachExpandAffordance(diagram, { + label: "Expand diagram", + hint: "Expand diagram", + className: "mermaid-diagram__expand", + onExpand: () => this.openExpanded(diagram) + }) diagram.addEventListener("click", event => { // Let clicks on interactive nodes inside the diagram behave normally. // Comment marks open their thread popover — expanding here would // force-hide it (showModal closes every open popover). if (event.target.closest("a, mark.anchor-highlight")) return - this.openLightbox(diagram) + this.openExpanded(diagram) }) } - openLightbox(diagram) { + openExpanded(diagram) { const svg = diagram.querySelector("svg") - if (!svg || this.lightbox) return - - const lightbox = document.createElement("dialog") - lightbox.className = "mermaid-lightbox" - lightbox.setAttribute("aria-label", "Expanded Mermaid diagram") - lightbox.dataset.turboTemporary = "" + if (!svg || this.expanded) return - const close = document.createElement("button") - close.type = "button" - close.className = "mermaid-lightbox__close" - close.setAttribute("aria-label", "Close expanded diagram") - close.title = "Close" - close.innerHTML = CLOSE_ICON + const { width, height } = naturalSize(svg) + const expander = openExpander({ + title: nearestHeading(diagram) || "Diagram", + label: "Expanded Mermaid diagram", + variant: "diagram", + status: true, + onClose: () => { + this.panZoom?.destroy() + this.panZoom = null + this.expanded = null + } + }) + this.expanded = expander + const viewport = document.createElement("div") + viewport.className = "expander__canvas" + viewport.tabIndex = 0 const content = svg.cloneNode(true) content.removeAttribute("width") content.removeAttribute("height") - content.style.maxWidth = "none" - content.style.width = "100%" - content.style.height = "100%" - - lightbox.append(close, content) - lightbox.addEventListener("click", () => lightbox.close()) - lightbox.addEventListener("close", () => { - lightbox.remove() - if (this.lightbox === lightbox) this.lightbox = null + viewport.append(content) + expander.body.append(viewport) + + let readout + this.panZoom = createPanZoom(viewport, content, { + width, + height, + minFit: MIN_EXPANDED_SCALE, + onChange: scale => { if (readout) readout.textContent = `${Math.round(scale * 100)}%` } + }) + + // The canvas owns the pan/zoom keys, so every toolbar button hands focus + // straight back to it — otherwise one click on Zoom in leaves +/-/0/1 + // firing against a button that ignores them. + const tool = spec => expander.addTool({ + ...spec, + onClick: () => { spec.onClick(); viewport.focus({ preventScroll: true }) } }) - this.lightbox = lightbox - document.body.append(lightbox) - lightbox.showModal() + tool({ label: "Zoom out", hint: "Zoom out (−)", icon: ICONS.zoomOut, onClick: () => this.panZoom.zoomOut() }) + readout = expander.addToolReadout(`${Math.round(this.panZoom.scale * 100)}%`) + tool({ label: "Zoom in", hint: "Zoom in (+)", icon: ICONS.zoomIn, onClick: () => this.panZoom.zoomIn() }) + tool({ label: "Fit to screen", hint: "Fit to screen (0)", icon: ICONS.fit, onClick: () => this.panZoom.fit() }) + tool({ label: "Actual size", hint: "Actual size (1)", icon: ICONS.actual, onClick: () => this.panZoom.actualSize() }) + + const hint = document.createElement("span") + hint.className = "expander__hint" + // On a touch screen the gestures are the whole interface — and the + // keyboard shortcuts are not available — so say the touch ones instead. + hint.textContent = window.matchMedia("(hover: none)").matches + ? "Drag to pan · pinch to zoom · double-tap to fit" + : "Drag to pan · scroll or pinch to zoom · double-click to fit · 0 fit · 1 actual size" + expander.setStatus(hint) + + viewport.focus({ preventScroll: true }) + } + + closeExpanded() { + this.expanded?.close() } showError(sourceContainer) { @@ -149,18 +241,6 @@ export default class extends Controller { } } -const EXPAND_ICON = ` - -` - -const CLOSE_ICON = ` - -` - function loadMermaid() { if (mermaidPromise) return mermaidPromise @@ -168,6 +248,16 @@ function loadMermaid() { return mermaidPromise } +// A mermaid SVG carries its laid-out size in the viewBox; the width/height +// attributes are the responsive "100%" mermaid applies on top. +function naturalSize(svg) { + const box = svg.viewBox?.baseVal + if (box?.width) return { width: box.width, height: box.height } + + const rect = svg.getBoundingClientRect() + return { width: rect.width, height: rect.height } +} + // A deck is a fixed artifact — its diagrams follow the deck theme, not // the reader's app scheme, so a dark-mode reader sees the same slides as // a light-mode one. Outside a deck, diagrams follow the app. The theme @@ -186,17 +276,38 @@ function configureMermaid(mermaid, dark) { const theme = dark ? "dark" : "light" if (theme === configuredTheme) return theme + const fontFamily = appFontStack() mermaid.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: dark ? "base" : "default", - ...(dark && { themeVariables: darkThemeVariables() }) + // Mermaid's 14px default reads small next to 16px body copy, and it's + // the label size that survives (or doesn't) being scaled to fit. + fontFamily, + themeVariables: { fontSize: "16px", fontFamily, ...(dark && darkThemeVariables()) }, + themeCSS: THEME_CSS, + flowchart: { padding: 14, nodeSpacing: 55, rankSpacing: 60, useMaxWidth: true }, + sequence: { actorFontSize: 15, messageFontSize: 15, noteFontSize: 14 }, + gantt: { fontSize: 14 } }) configuredTheme = theme return theme } +// Thin hairlines are the other half of "hard to read" — at a reduced scale +// a 1px edge disappears well before its label does. +const THEME_CSS = ` + .flowchart-link, .relationshipLine, .messageLine0, .messageLine1 { stroke-width: 1.75px; } + .edgeLabel { font-size: 14px; } + .cluster rect { stroke-width: 1.25px; } +` + +function appFontStack() { + const declared = getComputedStyle(document.documentElement).getPropertyValue("--font-sans").trim() + return declared || "system-ui, sans-serif" +} + function darkThemeVariables() { return { darkMode: true, diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js index 32158b38..1c139771 100644 --- a/engine/app/javascript/controllers/coplan/text_selection_controller.js +++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js @@ -36,7 +36,13 @@ export default class extends Controller { this._boundPopoverToggle = this._handlePopoverToggle.bind(this) this.contentTarget.addEventListener("mouseup", this._boundHandleMouseUp) document.addEventListener("mousedown", this._boundHandleDocumentMouseDown) - window.addEventListener("scroll", this._handleScroll, { passive: true }) + // Captured on the document rather than bound to `window`: a `scroll` + // event from a nested scroller — a data table's frame, the expanded + // sheet — does not bubble, so a window listener never hears it and an + // open popover sits still while the mark it points at scrolls away. + // Capture at the document sees both, and the handler returns + // immediately unless a popover is actually open. + document.addEventListener("scroll", this._handleScroll, { capture: true, passive: true }) this.highlightAnchors() // Watch for broadcast-appended threads and re-highlight @@ -57,7 +63,7 @@ export default class extends Controller { this.contentTarget.removeEventListener("mouseup", this._boundHandleMouseUp) document.removeEventListener("mousedown", this._boundHandleDocumentMouseDown) document.removeEventListener("turbo:before-cache", this._boundBeforeCache) - window.removeEventListener("scroll", this._handleScroll) + document.removeEventListener("scroll", this._handleScroll, { capture: true }) this._cancelHoverOpen() this._cancelHoverClose() clearTimeout(this._linkedThreadRetry) diff --git a/engine/app/javascript/coplan/expander.js b/engine/app/javascript/coplan/expander.js new file mode 100644 index 00000000..f9a0aeed --- /dev/null +++ b/engine/app/javascript/coplan/expander.js @@ -0,0 +1,168 @@ +// The shared takeover surface. Two very different things in a plan — a +// Mermaid diagram and a data table — both need the identical outer +// experience: a modal that owns the viewport, one title/toolbar/close +// chrome, disciplined dismissal, and the keyboard to itself while open. +// Only what happens *inside* the body differs (pan-zoom for a diagram, a +// cell cursor for a table), so that part is the caller's business. +// +// Dismissal is deliberately narrow: backdrop or Escape. The Mermaid +// lightbox this replaces closed on any click anywhere, which is precisely +// why it could never support panning — every drag ended in a close. + +let current = null + +export const ICONS = { + expand: icon(''), + close: icon(''), + zoomIn: icon(''), + zoomOut: icon(''), + fit: icon(''), + actual: icon(''), + wrap: icon(''), + unsort: icon('') +} + +// Opens the surface. Returns a handle: fill `body`, hang controls off +// `addTool`, write a one-line readout with `setStatus`. +export function openExpander({ title = "", label = "Expanded view", variant = null, status = false, onClose = null } = {}) { + current?.close() + + const dialog = document.createElement("dialog") + dialog.className = [ "expander", variant && `expander--${variant}` ].filter(Boolean).join(" ") + dialog.setAttribute("aria-label", label) + // 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 = "" + + const bar = element("header", "expander__bar") + const heading = element("h2", "expander__title") + heading.textContent = title + const tools = element("div", "expander__tools") + const closeButton = toolButton({ label: "Close", hint: "Close (Esc)", icon: ICONS.close, className: "expander__close" }) + closeButton.addEventListener("click", () => dialog.close()) + bar.append(heading, tools, closeButton) + + const body = element("div", "expander__body") + dialog.append(bar, body) + + const statusBar = status ? element("footer", "expander__status") : null + if (statusBar) dialog.append(statusBar) + + dialog.addEventListener("pointerdown", event => { + // The bar, body and status bar tile the dialog completely, so the + // dialog itself is only ever the event target for a backdrop press. + if (event.target === dialog) dialog.close() + }) + + // While a takeover is open it owns the keyboard: the page's hotkey + // controllers all listen on `document`, so stopping the event here — on + // the way out, after everything inside the dialog has seen it — keeps + // `[`/`]`/`j`/Backspace/Ctrl+Space from firing against the document + // hidden behind the overlay. Escape still closes: that's the UA's + // default action, which propagation doesn't govern. + dialog.addEventListener("keydown", event => { + if (!isTyping(event.target)) event.stopPropagation() + }) + + const handle = { + dialog, + body, + addTool: spec => { + const button = toolButton(spec) + if (spec.onClick) button.addEventListener("click", spec.onClick) + tools.append(button) + return button + }, + addToolReadout: text => { + const readout = element("span", "expander__readout") + readout.textContent = text + tools.append(readout) + return readout + }, + setStatus: nodes => { + if (!statusBar) return + statusBar.replaceChildren(...(Array.isArray(nodes) ? nodes : [ nodes ])) + }, + setTitle: text => { heading.textContent = text }, + close: () => dialog.close() + } + + dialog.addEventListener("close", () => { + document.documentElement.classList.remove("expander-open") + dialog.remove() + if (current === handle) current = null + onClose?.() + }) + + current = handle + document.documentElement.classList.add("expander-open") + document.body.append(dialog) + dialog.showModal() + return handle +} + +// The corner control that opens the surface. Shared so a table and a +// diagram present the same affordance in the same place. +export function attachExpandAffordance(container, { label, hint, onExpand, className }) { + const button = toolButton({ label, hint: hint || label, icon: ICONS.expand, className }) + // Turbo caches the DOM as it stands, including this button — and on a back + // navigation the controller reconnects against that snapshot and appends + // another one. Marking it temporary keeps it out of the cached copy, so + // there's always exactly one affordance in the corner. + button.dataset.turboTemporary = "" + button.addEventListener("click", event => { + event.preventDefault() + event.stopPropagation() + onExpand() + }) + container.append(button) + return button +} + +export function closeExpander() { + current?.close() +} + +// The heading a block sits under, so an expanded view can say what you're +// looking at instead of just "Table" or "Diagram". Walks backwards through +// preceding siblings, then out of the enclosing section, and stops at the +// rendered-markdown root. +export function nearestHeading(element) { + let node = element + while (node && !node.classList?.contains("markdown-rendered")) { + if (/^H[1-6]$/.test(node.tagName)) return node.textContent.trim() + node = node.previousElementSibling || node.parentElement + } + return null +} + +function toolButton({ label, hint, icon: markup, text, className }) { + const button = document.createElement("button") + button.type = "button" + button.className = className || "expander__tool" + button.setAttribute("aria-label", label) + if (hint) button.title = hint + button.innerHTML = markup || "" + if (text) { + const span = document.createElement("span") + span.textContent = text + button.append(span) + } + return button +} + +function element(name, className) { + const node = document.createElement(name) + node.className = className + return node +} + +function icon(paths) { + return `` +} + +function isTyping(target) { + const tag = target?.tagName + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target?.isContentEditable +} diff --git a/engine/app/javascript/coplan/pan_zoom.js b/engine/app/javascript/coplan/pan_zoom.js new file mode 100644 index 00000000..f6866fa7 --- /dev/null +++ b/engine/app/javascript/coplan/pan_zoom.js @@ -0,0 +1,212 @@ +// Pan and zoom over a fixed-size piece of content inside a viewport. +// Written for Mermaid SVGs but deliberately knows nothing about them: it +// takes a viewport, a content element, and the content's natural size, and +// drives a transform. +// +// Gestures: drag to pan, wheel to zoom at the cursor, two-finger pinch to +// zoom, shift+wheel to pan sideways, double-click to toggle fit and close- +// up, and +/-/0/1/arrows from the keyboard. + +const KEYS = new Set([ "+", "=", "-", "_", "0", "1", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" ]) + +export function createPanZoom(viewport, content, { + width, + height, + min = 0.1, + max = 8, + padding = 24, + // Fitting a three-node diagram to a 1400px screen would blow it up to a + // cartoon. Fit is allowed to enlarge, but only so far. + maxFit = 2, + // ...and the other end: a wide diagram "fitted" to a phone is a field of + // specks. Past this floor, fit stops shrinking and the surface pans + // instead. Zooming out by hand still goes all the way to `min`. + minFit = min, + onChange = null +} = {}) { + const abort = new AbortController() + const signal = abort.signal + const pointers = new Map() + + let scale = 1 + let x = 0 + let y = 0 + let fitScale = 1 + + content.style.position = "absolute" + content.style.top = "0" + content.style.left = "0" + content.style.transformOrigin = "0 0" + content.style.width = `${width}px` + content.style.height = `${height}px` + content.style.maxWidth = "none" + + function apply() { + const box = viewport.getBoundingClientRect() + x = clampAxis(x, width * scale, box.width) + y = clampAxis(y, height * scale, box.height) + content.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})` + onChange?.(scale) + } + + function setScale(next, anchorX, anchorY) { + const clamped = Math.min(max, Math.max(min, next)) + if (clamped === scale) return + const ratio = clamped / scale + // Hold the content point under the anchor still. + x = anchorX - ratio * (anchorX - x) + y = anchorY - ratio * (anchorY - y) + scale = clamped + apply() + } + + function viewportPoint(event) { + const box = viewport.getBoundingClientRect() + return [ event.clientX - box.left, event.clientY - box.top ] + } + + function center() { + const box = viewport.getBoundingClientRect() + return [ box.width / 2, box.height / 2 ] + } + + function fit() { + const box = viewport.getBoundingClientRect() + const available = { width: Math.max(1, box.width - padding * 2), height: Math.max(1, box.height - padding * 2) } + fitScale = Math.min(maxFit, Math.max(minFit, Math.min(available.width / width, available.height / height))) + scale = fitScale + // Centre on whichever axis the whole thing does fit; on an axis held up + // by the floor, start at the beginning of the content rather than its + // middle — `apply` clamps these to the edge. + x = width * scale <= box.width ? (box.width - width * scale) / 2 : 0 + y = height * scale <= box.height ? (box.height - height * scale) / 2 : 0 + apply() + } + + function actualSize() { + const [ cx, cy ] = center() + setScale(1, cx, cy) + } + + function zoomBy(factor, anchor) { + const [ ax, ay ] = anchor || center() + setScale(scale * factor, ax, ay) + } + + viewport.addEventListener("wheel", event => { + event.preventDefault() + if (event.shiftKey && !event.ctrlKey) { + x -= event.deltaY || event.deltaX + apply() + return + } + // 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)) + }, { passive: false, signal }) + + viewport.addEventListener("pointerdown", event => { + if (event.button !== 0 && event.pointerType === "mouse") return + viewport.setPointerCapture(event.pointerId) + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }) + viewport.classList.add("is-grabbing") + }, { signal }) + + viewport.addEventListener("pointermove", event => { + const previous = pointers.get(event.pointerId) + if (!previous) return + const current = { x: event.clientX, y: event.clientY } + + if (pointers.size === 1) { + x += current.x - previous.x + y += current.y - previous.y + pointers.set(event.pointerId, current) + apply() + return + } + + if (pointers.size === 2) { + const other = [ ...pointers.entries() ].find(([ id ]) => id !== event.pointerId)?.[1] + if (!other) return + const box = viewport.getBoundingClientRect() + const before = distance(previous, other) + const after = distance(current, other) + const midBefore = midpoint(previous, other, box) + const midAfter = midpoint(current, other, box) + pointers.set(event.pointerId, current) + + // Pan by how far the grip moved, then scale about where it now is. + x += midAfter[0] - midBefore[0] + y += midAfter[1] - midBefore[1] + if (before > 0) setScale(scale * (after / before), ...midAfter) + else apply() + } + }, { signal }) + + const release = event => { + pointers.delete(event.pointerId) + if (pointers.size === 0) viewport.classList.remove("is-grabbing") + } + viewport.addEventListener("pointerup", release, { signal }) + viewport.addEventListener("pointercancel", release, { signal }) + + viewport.addEventListener("dblclick", event => { + event.preventDefault() + // Near the fit scale, a double-click means "let me look closer"; from + // anywhere else it means "show me the whole thing again". + if (Math.abs(scale - fitScale) < 0.01) setScale(Math.max(1, fitScale * 2), ...viewportPoint(event)) + else fit() + }, { signal }) + + viewport.addEventListener("keydown", event => { + if (event.metaKey || event.ctrlKey || !KEYS.has(event.key)) return + event.preventDefault() + const step = event.shiftKey ? 200 : 60 + switch (event.key) { + case "+": case "=": zoomBy(1.25); break + case "-": case "_": zoomBy(0.8); break + case "0": fit(); break + case "1": actualSize(); break + case "ArrowUp": y += step; apply(); break + case "ArrowDown": y -= step; apply(); break + case "ArrowLeft": x += step; apply(); break + case "ArrowRight": x -= step; apply(); break + } + }, { signal }) + + const resize = new ResizeObserver(() => apply()) + resize.observe(viewport) + + fit() + + return { + fit, + actualSize, + zoomIn: () => zoomBy(1.25), + zoomOut: () => zoomBy(0.8), + get scale() { return scale }, + destroy: () => { + abort.abort() + resize.disconnect() + } + } +} + +// Keeps the content anchored to the viewport: when it's larger, no empty +// gap can be dragged in; when it's smaller, it stays fully visible. +function clampAxis(position, scaledLength, viewportLength) { + if (scaledLength <= viewportLength) { + return Math.min(Math.max(position, 0), viewportLength - scaledLength) + } + return Math.min(Math.max(position, viewportLength - scaledLength), 0) +} + +function distance(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function midpoint(a, b, box) { + return [ (a.x + b.x) / 2 - box.left, (a.y + b.y) / 2 - box.top ] +} diff --git a/engine/config/importmap.rb b/engine/config/importmap.rb index 40134bd5..a6f260eb 100644 --- a/engine/config/importmap.rb +++ b/engine/config/importmap.rb @@ -1,5 +1,10 @@ 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" +# Statically imported by the mermaid and data-grid controllers, which are +# themselves preloaded — preload these too or every page pays a round trip +# to discover them. +pin "coplan/expander", to: "coplan/expander.js", preload: true +pin "coplan/pan_zoom", to: "coplan/pan_zoom.js", preload: true 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/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index abeb2ab0..0923791e 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -246,4 +246,51 @@ expect(html).not_to include('class="mention"') end end + + describe "data tables" do + let(:markdown) do + <<~MD + | Phase | Owner | + |---|---| + | One | sam | + MD + end + + it "frames a table so a wide one scrolls instead of running off the page" do + doc = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(markdown)) + + grid = doc.at_css("div.data-grid") + expect(grid["data-controller"]).to eq("coplan--data-grid") + frame = grid.at_css("div.data-grid__frame") + expect(frame["data-coplan--data-grid-target"]).to eq("frame") + expect(frame.element_children.map(&:name)).to eq([ "table" ]) + end + + it "adds no visible text, so comment anchors count what they counted before" do + framed = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(markdown)).text + bare = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(markdown, data_tables: false)).text + + expect(framed).to eq(bare) + end + + it "frames every table in a document" do + doc = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown("#{markdown}\n#{markdown}")) + + expect(doc.css("div.data-grid").size).to eq(2) + expect(doc.css("table").all? { |table| table.parent.classes.include?("data-grid__frame") }).to be(true) + end + + it "leaves a document without tables alone" do + html = helper.render_markdown("Just a paragraph.") + + expect(html).not_to include("data-grid") + end + + it "can be turned off, for decks that own their own tabular layout" do + html = helper.render_markdown(markdown, data_tables: false) + + expect(html).not_to include("data-grid") + expect(html).to include("") + end + end end diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb index da93674c..a2b4a5c2 100644 --- a/spec/system/comment_ux_spec.rb +++ b/spec/system/comment_ux_spec.rb @@ -175,7 +175,7 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:) expect(page).to have_css('.mermaid-diagram[data-mermaid-theme="dark"]', wait: 10) end - it "expands a Mermaid diagram into a lightbox on click" do + it "expands a Mermaid diagram into the takeover surface on click" do plan.current_plan_version.update!(content_markdown: <<~MARKDOWN) ```mermaid flowchart LR @@ -187,10 +187,12 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:) expect(page).to have_css(".mermaid-diagram svg", wait: 10) find(".mermaid-diagram").click - expect(page).to have_css(".mermaid-lightbox svg") + expect(page).to have_css(".expander--diagram .expander__canvas > svg") - find(".mermaid-lightbox").click - expect(page).not_to have_css(".mermaid-lightbox") + # Dismissal is the backdrop or Escape — a click inside is a pan, not + # a close. + find(".expander__close").click + expect(page).not_to have_css(".expander--diagram") end it "keeps invalid Mermaid source readable" do diff --git a/spec/system/data_grid_spec.rb b/spec/system/data_grid_spec.rb new file mode 100644 index 00000000..aa819d4b --- /dev/null +++ b/spec/system/data_grid_spec.rb @@ -0,0 +1,287 @@ +require "rails_helper" + +# A markdown table in a plan, in both of its forms: the framed, compact one +# that sits in the document, and the spreadsheet it expands into. +RSpec.describe "Data tables", type: :system do + let(:author) { create(:coplan_user, email: "author@example.com") } + + let(:plan_content) do + <<~MARKDOWN + # Rollout + + ## Phases + + | Phase | Owner | Region | Traffic | Latency | Notes | + |---|---|---|---|---|---| + | Pilot | sam | us-east | 2% | 180ms | Internal merchants only, behind the flag | + | Ramp | kim | us-west | 25% | 165ms | Waiting on the ledger backfill to finish | + | Wide | ada | eu-central | 60% | 210ms | Needs a second region before it can go on | + | Full | lee | global | 100% | 150ms | Flag removed, config baked in | + | Hold | raj | ap-south | 0% | 195ms | Paused pending a compliance review | + | Sunset | tom | us-east | 0% | 172ms | Old path retired after two clean weeks | + | Audit | joe | global | 5% | 188ms | Sampled traffic for the quarterly review | + | Repair | pat | us-west | 10% | 205ms | Retry path rebuilt, watching error rates | + | Verify | nia | eu-central | 40% | 169ms | Shadow reads compared against the legacy | + + The table above is the plan of record. + MARKDOWN + end + + let(:plan) do + p = CoPlan::Plan.create!(title: "Rollout Plan", created_by_user: author) + version = CoPlan::PlanVersion.create!( + plan: p, revision: 1, + content_markdown: plan_content, actor_type: "human", actor_id: author.id + ) + p.update!(current_plan_version: version, current_revision: 1) + p + end + + def sign_in(user) + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + expect(page).to have_button("Menu") + end + + # The affordance only shows on hover, so a click has to be preceded by + # one — and the controller only offers it once it has measured the table. + def open_spreadsheet + expect(page).to have_css(".data-grid.is-expandable") + find(".data-grid").hover + find(".data-grid__expand").click + expect(page).to have_css(".expander--grid .data-sheet__table") + end + + def computed(selector, property) + page.evaluate_script( + "getComputedStyle(document.querySelector(#{selector.to_json})).#{property}" + ) + end + + def frame_overflows? + page.evaluate_script(<<~JS) + (() => { + const frame = document.querySelector(".data-grid__frame") + return frame.scrollWidth - frame.clientWidth > 1 + })() + JS + end + + # Sixteen columns of unbreakable tokens: no amount of wrapping saves this + # one, so it has to scroll. + let(:wide_plan) do + header = (1..16).map { |n| "Column#{n}" } + row = (1..16).map { |n| "value-#{n}-unbreakable" } + markdown = [ + "# Wide", "", + "| #{header.join(' | ')} |", + "|#{([ '---' ] * 16).join('|')}|", + "| #{row.join(' | ')} |", "" + ].join("\n") + + p = CoPlan::Plan.create!(title: "Wide Plan", created_by_user: author) + version = CoPlan::PlanVersion.create!( + plan: p, revision: 1, + content_markdown: markdown, actor_type: "human", actor_id: author.id + ) + p.update!(current_plan_version: version, current_revision: 1) + p + end + + before { sign_in(author) } + + describe "in the document" do + it "wraps long cells so a realistic table fits the column" do + visit plan_page_path(plan) + + expect(page).to have_css(".data-grid__frame table") + expect(computed(".data-grid__frame", "overflowX")).to eq("auto") + expect(frame_overflows?).to be(false) + expect(page).to have_no_css(".data-grid.is-scrolled-end") + end + + it "keeps a table too wide to wrap inside its own frame, not off the page" do + visit plan_page_path(wide_plan) + + # This is the reported bug: the table used to widen the document. + expect(page.evaluate_script("document.documentElement.scrollWidth <= window.innerWidth")).to be(true) + expect(frame_overflows?).to be(true) + # ...and it says so, rather than hiding the rest silently. + expect(page).to have_css(".data-grid.is-scrolled-end") + end + + # The frame is a new scroll container between the mark and the viewport. + # A `scroll` event from it doesn't bubble, so a popover positioned once + # against the mark's viewport coordinates would sit still while the mark + # slid out from under it. + it "keeps an open thread popover on its mark when the frame scrolls" do + thread = wide_plan.comment_threads.create!( + plan_version: wide_plan.current_plan_version, + anchor_text: "value-2-unbreakable", anchor_occurrence: 1, + created_by_user: author, status: "open" + ) + thread.comments.create!(author_type: "human", author_id: author.id, + body_markdown: "Where does this value come from?") + + visit plan_page_path(wide_plan) + find(".data-grid mark.anchor-highlight").click + expect(page).to have_css("#comment_thread_#{thread.id}_popover", visible: true) + + travel = page.evaluate_script(<<~JS) + (() => { + const mark = document.querySelector(".data-grid mark.anchor-highlight") + const popover = document.querySelector("#comment_thread_#{thread.id}_popover") + const left = el => el.getBoundingClientRect().left + const before = { mark: left(mark), popover: left(popover) } + const frame = document.querySelector(".data-grid__frame") + frame.scrollLeft += 200 + return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { + resolve({ + mark: Math.round(before.mark - left(mark)), + popover: Math.round(before.popover - left(popover)) + }) + }))) + })() + JS + + expect(travel["mark"]).to be > 0 + expect(travel["popover"]).to be_within(2).of(travel["mark"]) + end + + it "pins the header row inside the frame" do + visit plan_page_path(plan) + + expect(page).to have_css(".data-grid thead th") + expect(computed(".data-grid thead th", "position")).to eq("sticky") + end + + it "offers to expand a table big enough to be worth it" do + visit plan_page_path(plan) + + expect(page).to have_css(".data-grid.is-expandable") + expect(page).to have_css(".data-grid__expand", visible: :all) + end + + it "still offers exactly one affordance after a cached back navigation" do + visit plan_page_path(plan) + expect(page).to have_css(".data-grid__expand", visible: :all) + + # A Turbo visit, not a fresh load: that's what fills the snapshot cache + # the back navigation then restores. + page.execute_script("Turbo.visit('#{root_path}')") + expect(page).to have_current_path(root_path) + page.go_back + expect(page).to have_css(".data-grid__frame table") + + # Turbo caches the DOM as the controller left it; without care the + # affordance is restored *and* appended again. + expect(page).to have_css(".data-grid__expand", visible: :all, count: 1) + end + end + + describe "expanded" do + before do + visit plan_page_path(plan) + open_spreadsheet + end + + it "titles itself with the heading the table sits under" do + expect(page).to have_css(".expander__title", text: "Phases") + expect(page).to have_css(".data-sheet__dimensions", text: "9 rows × 6 columns") + end + + it "pins both the header row and the row-label column" do + expect(computed(".data-sheet__table thead th", "position")).to eq("sticky") + expect(computed(".data-sheet__table tbody tr td:first-child", "position")).to eq("sticky") + end + + it "starts the cursor on the first cell and moves it with the arrow keys" do + expect(page).to have_css(".data-sheet__address", text: "A1") + expect(page).to have_css(".data-sheet__value-label", text: "Phase") + + find(".data-sheet__table td.is-cursor").send_keys(:arrow_right, :arrow_down) + + expect(page).to have_css(".data-sheet__address", text: "B2") + expect(page).to have_css(".data-sheet__column", text: "Owner") + expect(page).to have_css(".data-sheet__value-content", text: "kim") + expect(page).to have_css(".data-sheet__table tr.is-cursor-row td", text: "Ramp") + end + + it "shows a long value in full even though the cell itself is clipped" do + find(".data-sheet__table td.is-cursor").send_keys(:end) + + expect(page).to have_css(".data-sheet__value-label", text: "Notes") + expect(page).to have_css(".data-sheet__value-content", + text: "Internal merchants only, behind the flag") + end + + it "keeps the cursor inside the table at the edges" do + find(".data-sheet__table td.is-cursor").send_keys(:arrow_up, :arrow_left) + + expect(page).to have_css(".data-sheet__address", text: "A1") + end + + it "sorts a column on click and returns to the document's order" do + first_owner = -> { page.evaluate_script('document.querySelector(".data-sheet__table tbody td:nth-child(2)").textContent.trim()') } + expect(first_owner.call).to eq("sam") + + find(".data-sheet__table thead th", text: "Owner").click + expect(page).to have_css(".data-sheet__table thead th.is-sorted-asc", text: "Owner") + expect(first_owner.call).to eq("ada") + + find(".data-sheet__table thead th", text: "Owner").click + expect(page).to have_css(".data-sheet__table thead th.is-sorted-desc", text: "Owner") + expect(first_owner.call).to eq("tom") + + find("button[aria-label='Reset sort order']").click + expect(page).to have_no_css(".data-sheet__table thead th.is-sorted-asc") + expect(first_owner.call).to eq("sam") + end + + it "sorts a column of numbers by value, not by its digits as text" do + find(".data-sheet__table thead th", text: "Traffic").click + + order = page.evaluate_script( + 'Array.from(document.querySelectorAll(".data-sheet__table tbody td:nth-child(4)")).map(c => c.textContent.trim())' + ) + expect(order).to eq([ "0%", "0%", "2%", "5%", "10%", "25%", "40%", "60%", "100%" ]) + end + + it "gives the keyboard back to the grid after a toolbar click" do + find("button[aria-label='Wrap cell text']").click + page.driver.browser.action.send_keys(:arrow_down).perform + + expect(page).to have_css(".data-sheet__address", text: "A2") + end + + it "stays open when you click inside it" do + find(".data-sheet__table tbody td", text: "Ramp").click + + expect(page).to have_css(".expander--grid") + expect(page).to have_css(".data-sheet__address", text: "A2") + end + + it "closes on Escape, leaving the document's own table untouched" do + find(".data-sheet__table td.is-cursor").send_keys(:escape) + + expect(page).to have_no_css(".expander--grid") + expect(page).to have_css(".data-grid__frame table") + end + end + + it "leaves a table small enough to read as it is" do + small = CoPlan::Plan.create!(title: "Small Plan", created_by_user: author) + version = CoPlan::PlanVersion.create!( + plan: small, revision: 1, actor_type: "human", actor_id: author.id, + content_markdown: "| A | B |\n|---|---|\n| 1 | 2 |\n" + ) + small.update!(current_plan_version: version, current_revision: 1) + + visit plan_page_path(small) + + expect(page).to have_css(".data-grid__frame table") + expect(page).to have_no_css(".data-grid.is-expandable") + end +end diff --git a/spec/system/deck_ux_spec.rb b/spec/system/deck_ux_spec.rb index 974c95db..8bfdc2d5 100644 --- a/spec/system/deck_ux_spec.rb +++ b/spec/system/deck_ux_spec.rb @@ -316,14 +316,14 @@ def attachments_on_screen? describe "Mermaid diagrams on a slide" do it "keeps the expand control chip-sized instead of scaling it to the canvas" do visit plan_page_path(plan) - expect(page).to have_css(".deck-slide .mermaid-diagram > svg", wait: 15) + expect(page).to have_css(".deck-slide .mermaid-diagram__canvas > 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) }; + return { diagram: box(diagram.querySelector(".mermaid-diagram__canvas > svg")), icon: box(icon) }; })() JS @@ -331,6 +331,47 @@ def attachments_on_screen? expect(sizes["diagram"]).to be > 200 expect(sizes["icon"]).to be <= 24 end + + # The deck sizes slide diagrams in cqi against the slide canvas. Those + # rules address the SVG through the diagram's DOM, so a change to that + # DOM can leave them matching nothing — silently, because a small + # diagram still looks fine unsized. + it "sizes a slide's diagram from the deck's rules, not the document's" do + visit plan_page_path(plan) + expect(page).to have_css(".deck-slide .mermaid-diagram__canvas > svg", wait: 15) + + sizing = page.evaluate_script(<<~JS) + (() => { + const canvas = document.querySelector(".deck-slide .mermaid-diagram__canvas") + const slide = canvas.closest(".deck-slide") + const svg = canvas.querySelector("svg") + const canvasStyle = getComputedStyle(canvas) + const svgStyle = getComputedStyle(svg) + return { + stage: slide.classList.contains("deck-slide--stage"), + bound: slide.classList.contains("deck-slide--stage") + ? svgStyle.height + : svgStyle.maxHeight, + overflowX: canvasStyle.overflowX, + paddingLeft: canvasStyle.paddingLeft, + scrolling: canvas.closest(".mermaid-diagram").classList.contains("mermaid-diagram--scrolling"), + fits: svg.getBoundingClientRect().height <= slide.getBoundingClientRect().height + } + })() + JS + + # A cqi rule resolves to a pixel length; "none"/"auto" means the + # selector missed and the slide is showing an unsized diagram. + expect(sizing["bound"]).to match(/\d+(\.\d+)?px/) + expect(sizing["fits"]).to be(true) + + # ...and none of the document's scroll-box treatment comes along: a + # slide is a fixed frame, so the diagram is fitted to it rather than + # pinned at its natural size behind a scrollbar. + expect(sizing["overflowX"]).to eq("visible") + expect(sizing["paddingLeft"]).to eq("0px") + expect(sizing["scrolling"]).to be(false) + end end describe "back-matter links" do diff --git a/spec/system/diagram_expand_spec.rb b/spec/system/diagram_expand_spec.rb new file mode 100644 index 00000000..8dcacadc --- /dev/null +++ b/spec/system/diagram_expand_spec.rb @@ -0,0 +1,164 @@ +require "rails_helper" + +# Expanding a Mermaid diagram. These specs drive the real pipeline — Mermaid +# loads from the CDN pinned in the importmap and renders in the browser — so +# they need network access, same as CI. +RSpec.describe "Expanding a Mermaid diagram", type: :system do + let(:author) { create(:coplan_user, email: "author@example.com") } + + # Wide enough that fitting it to the document column would shrink it past + # the legibility floor. + let(:wide_diagram) do + chain = (1..14).map { |n| "S#{n}[Stage number #{n}]" }.each_cons(2).map { |a, b| " #{a} --> #{b}" } + "```mermaid\nflowchart LR\n#{chain.join("\n")}\n```" + end + + let(:plan_content) do + <<~MARKDOWN + # Payment Flow + + ## The pipeline + + #{wide_diagram} + + The ledger records every movement. + MARKDOWN + end + + let(:plan) do + p = CoPlan::Plan.create!(title: "Diagram Plan", created_by_user: author) + version = CoPlan::PlanVersion.create!( + plan: p, revision: 1, + content_markdown: plan_content, actor_type: "human", actor_id: author.id + ) + p.update!(current_plan_version: version, current_revision: 1) + p + end + + def sign_in(user) + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + expect(page).to have_button("Menu") + end + + def wait_for_diagram + expect(page).to have_css(".mermaid-diagram__canvas svg", wait: 20) + end + + def zoom_percent + find(".expander__readout").text.to_i + end + + def canvas_transform + page.evaluate_script('document.querySelector(".expander__canvas > svg").style.transform') + end + + before do + sign_in(author) + visit plan_page_path(plan) + wait_for_diagram + end + + describe "in the document" do + it "keeps a wide diagram at a readable size and scrolls it instead" do + expect(page).to have_css(".mermaid-diagram--scrolling") + + readable = page.evaluate_script(<<~JS) + (() => { + const canvas = document.querySelector(".mermaid-diagram__canvas") + const svg = canvas.querySelector("svg") + const natural = svg.viewBox.baseVal.width + return { + scale: svg.getBoundingClientRect().width / natural, + scrolls: canvas.scrollWidth - canvas.clientWidth > 1 + } + })() + JS + + # Shown at full size, with the frame — not the page — taking the + # overflow. + expect(readable["scale"]).to be_within(0.02).of(1.0) + expect(readable["scrolls"]).to be(true) + expect(page.evaluate_script("document.documentElement.scrollWidth <= window.innerWidth")).to be(true) + end + end + + describe "expanded" do + before do + find(".mermaid-diagram").hover + find(".mermaid-diagram__expand").click + expect(page).to have_css(".expander--diagram .expander__canvas > svg") + end + + it "titles itself with the heading the diagram sits under" do + expect(page).to have_css(".expander__title", text: "The pipeline") + end + + it "opens fitted to the screen and can be zoomed from the toolbar" do + fitted = zoom_percent + expect(fitted).to be > 0 + + find("button[aria-label='Zoom in']").click + expect(zoom_percent).to be > fitted + + find("button[aria-label='Actual size']").click + expect(zoom_percent).to eq(100) + + find("button[aria-label='Fit to screen']").click + expect(zoom_percent).to eq(fitted) + end + + it "zooms from the keyboard" do + fitted = zoom_percent + + find(".expander__canvas").send_keys("+") + expect(zoom_percent).to be > fitted + + find(".expander__canvas").send_keys("0") + expect(zoom_percent).to eq(fitted) + end + + it "gives the keyboard back to the canvas after a toolbar click" do + fitted = zoom_percent + find("button[aria-label='Zoom in']").click + expect(zoom_percent).to be > fitted + + # Sent to whatever is focused — which has to be the canvas again, or + # the shortcuts die the moment anyone touches the toolbar. + page.driver.browser.action.send_keys("0").perform + expect(zoom_percent).to eq(fitted) + end + + it "pans on drag, and a drag does not dismiss the surface" do + before_drag = canvas_transform + canvas = find(".expander__canvas") + + page.driver.browser.action + .move_to(canvas.native, 0, 0) + .click_and_hold + .move_by(60, 40) + .release + .perform + + # The old lightbox closed on any click, which is why it could never + # be panned. + expect(page).to have_css(".expander--diagram") + expect(canvas_transform).not_to eq(before_drag) + end + + it "closes on Escape" do + find(".expander__canvas").send_keys(:escape) + + expect(page).to have_no_css(".expander--diagram") + expect(page).to have_css(".mermaid-diagram__canvas svg") + end + end + + it "still expands on a click anywhere in the diagram" do + find(".mermaid-diagram__canvas").click + + expect(page).to have_css(".expander--diagram .expander__canvas > svg") + end +end