From 9632e012887b73e79f1549415392bdfca12945d1 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 4 Jul 2026 17:09:30 -0400 Subject: [PATCH] feat(viewer): add mermaid fullscreen mode --- .changeset/bright-mermaids-expand.md | 5 + guide/AGENT_HOWTO.md | 3 +- guide/DESIGN_GUIDE.md | 55 +++++++++-- viewer/src/Card.tsx | 140 +++++++++++++++++++++++++-- viewer/src/icons.tsx | 12 +++ viewer/src/styles.css | 106 +++++++++++++++++++- 6 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 .changeset/bright-mermaids-expand.md diff --git a/.changeset/bright-mermaids-expand.md b/.changeset/bright-mermaids-expand.md new file mode 100644 index 0000000..c54a6f3 --- /dev/null +++ b/.changeset/bright-mermaids-expand.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add fullscreen Mermaid diagrams and guidance for vertical, card-friendly flowcharts. diff --git a/guide/AGENT_HOWTO.md b/guide/AGENT_HOWTO.md index 7e267ad..9fe8e08 100644 --- a/guide/AGENT_HOWTO.md +++ b/guide/AGENT_HOWTO.md @@ -10,7 +10,7 @@ A post is a card built from ordered **surfaces**, each with a `kind`: - **`html`** — markup you write, rendered in a sandboxed iframe. Reach for it to draw: diagrams, UI sketches, data viz, explainers. - **`markdown`** — trusted viewer-rendered prose. -- **`mermaid`** — diagram source rendered by the trusted viewer. +- **`mermaid`** — diagram source rendered in a sandboxed Mermaid frame. Prefer vertical `flowchart TD`/`TB`; wide `LR` maps shrink in the card and should be split or opened fullscreen. - **`diff`** — a patch you send as _data_, rendered natively by the trusted viewer as a syntax-highlighted code review. - **`terminal`** — monospace/ANSI output. - **`image`** — an uploaded image asset. @@ -52,6 +52,7 @@ Rules of thumb: - One concept per post, with a clear title. A series of small posts beats one giant page. - **Iterate with `sideshow update `** (same card, new version) instead of publishing near-duplicates. Versions are kept; the user can flip between them. - For html surfaces, use the built-in kit from the guide (pre-styled form elements, SVG utility classes) before writing CSS; for anything else use the theme CSS variables so posts work in dark mode. +- For Mermaid, start with vertical `flowchart TD`/`TB`, short wrapped labels, and `subgraph` grouping. Use `LR` only for compact pipelines; split big architecture maps into several diagrams. ## The feedback loop diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md index 2ed00b2..963e14f 100644 --- a/guide/DESIGN_GUIDE.md +++ b/guide/DESIGN_GUIDE.md @@ -24,12 +24,15 @@ a `kind`: - **`mermaid`** — diagram source you hand over as _text_; the viewer renders it to an SVG (flowcharts, sequence diagrams, ERDs, gantt, state, …). Reach for it when the _shape_ of a system is the point and you'd rather describe it than - draw SVG by hand. Renders as data, not sandboxed markup (securityLevel - `strict`); for bespoke vector art hand-write inline `` in an `html` surface - instead. The viewer themes the diagram (light and dark) automatically — **don't - set your own colors**. Highlight flowchart nodes with `:::accent` (or - `class A,B accent`) and edges with `accentLine` (pair with `linkStyle`); - sequence diagrams style actors globally only. + draw SVG by hand. The source travels as data and renders in a sandboxed Mermaid + frame (securityLevel `strict`); for bespoke vector art hand-write inline `` + in an `html` surface instead. Prefer vertical flowcharts (`flowchart TD`/`TB`) + for sideshow cards; + wide `LR` system maps shrink to fit the card and become unreadable. The viewer + themes the diagram (light and dark) automatically — **don't set your own + colors**. Highlight flowchart nodes with `:::accent` (or `class A,B accent`) + and edges with `accentLine` (pair with `linkStyle`); sequence diagrams style + actors globally only. - **`diff`** — a patch you hand over as _data_; the trusted viewer renders it natively as a syntax-highlighted code review (split or unified). Reach for it to show a changeset or review code, not to draw. @@ -92,6 +95,46 @@ For a diff, send a `patch` — it carries only the changed lines, so it is the compact, preferred form. Use `files` (full before/after contents) only when you don't have a patch. A diff surface takes an optional `"layout": "unified" | "split"`. +### Mermaid layout tips + +Mermaid diagrams render inside the same card column as everything else, so huge +left-to-right canvases are scaled down until the text is tiny. Optimize for the +card first: + +- Default to `flowchart TD` or `flowchart TB`. Use `LR` only for short, truly + linear flows with a handful of columns. +- Split whole-system architecture maps into multiple diagrams/posts: context, + data flow, deploy/runtime, and ownership are usually easier to read separately. +- Keep node labels short; put explanation in a markdown surface above or below + the diagram. Use `
` in labels when a name needs wrapping. +- Use `subgraph` blocks to group layers vertically rather than stretching one + row across the screen. +- If a diagram still needs to be wide, it is okay: the viewer offers a fullscreen + control on Mermaid surfaces, but the inline card should remain legible enough + to preview. + +Prefer this shape: + +```mermaid +flowchart TD + subgraph Product[Product surface] + Dashboard[Dashboard
Next.js] + Agent[Agent gateway
MCP tools] + end + subgraph Backend[Backend] + API[API
oRPC] + Ingest[Ingest pipeline] + DB[(Postgres)] + end + Dashboard --> API + Agent --> API + API --> Ingest + Ingest --> DB +``` + +Avoid one-screen maps that put every package/service in a single `flowchart LR` +row; they will fit the width by shrinking the text. + ## Uploads (images, traces, files) Push a binary asset once, reference it by id. Three ways, same result: diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index b827d5e..8224719 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -25,7 +25,16 @@ import { postImageLink, } from "./api.ts"; import { isSandboxedSurfaceKind, SURFACE_FRAME_CLASSES } from "../../server/types.ts"; -import { CommentIcon, ImageIcon, LinkIcon, OpenIcon, PinIcon, TrashIcon } from "./icons.tsx"; +import { + CommentIcon, + ImageIcon, + LinkIcon, + MaximizeIcon, + OpenIcon, + PinIcon, + TrashIcon, +} from "./icons.tsx"; +import { root } from "./host.ts"; import { ImageSurface } from "./ImageSurface.tsx"; import { JsonSurface } from "./JsonSurface.tsx"; import { activeTheme, resolvedMode } from "./theme.ts"; @@ -66,6 +75,12 @@ export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unkn iframe.style.height = Math.min(Math.max(Number(reportedHeight), MIN_FRAME_H), MAX_FRAME_H) + "px"; } +type FullscreenSurface = { + src: string; + title: string; + frameClass?: string; +}; + // While a deep-link scroll poll is active, IntersectionObserver callbacks on // other cards must not call focusPost — they would overwrite the URL with // whichever card happens to cross the 50% threshold mid-scroll. The poll sets @@ -160,17 +175,47 @@ function pollScrollIntoView(el: HTMLElement, postId: string): () => void { export function Card(props: { post: Post; standalone?: boolean }) { let card!: HTMLDivElement; + let fullscreenDialog: HTMLDivElement | undefined; + let fullscreenCloseButton: HTMLButtonElement | undefined; + let fullscreenOpener: HTMLButtonElement | undefined; const iframes = new Set(); // Absolute surface index -> its sandboxed-surface iframe. Lets the version // dropdown rebuild each `/s/:id?part=N` src across every surface with a frame. const surfaceFrames = new Map(); const [annotating, setAnnotating] = createSignal(false); const [anchorDraft, setAnchorDraft] = createSignal(null); + const [fullscreenSurface, setFullscreenSurface] = createSignal(null); let stopPoll: (() => void) | undefined; + const surfaceTitle = (surfaceIndex: number) => + props.post.surfaces.length > 1 + ? `${props.post.title} (surface ${surfaceIndex + 1})` + : props.post.title; + + const surfaceSrc = (surfaceIndex: number) => + appPath( + `/s/${props.post.id}?part=${surfaceIndex}&ver=${props.post.version}&cb=${props.post.version}&theme=${activeTheme()}&mode=${resolvedMode()}`, + ); + const anchoredComments = (surfaceIndex: number) => comments().filter((c) => c.postId === props.post.id && c.anchor?.surfaceIndex === surfaceIndex); + const closeFullscreen = () => { + const opener = fullscreenOpener; + fullscreenOpener = undefined; + setFullscreenSurface(null); + queueMicrotask(() => { + if (opener?.isConnected) opener.focus(); + }); + }; + + const focusableFullscreenEls = () => + Array.from( + fullscreenDialog?.querySelectorAll( + 'button, iframe, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter((el) => !el.hasAttribute("disabled") && el.tabIndex >= 0); + const sendPinnedComment = async (text: string) => { const anchor = anchorDraft(); if (!anchor) return "place a pin first"; @@ -196,6 +241,37 @@ export function Card(props: { post: Post; standalone?: boolean }) { createEffect(scrollIfTarget); onCleanup(() => stopPoll?.()); + createEffect(() => { + if (!fullscreenSurface()) return; + queueMicrotask(() => fullscreenCloseButton?.focus()); + const onKey = (event: Event) => { + const e = event as KeyboardEvent; + if (e.key === "Escape") { + e.preventDefault(); + closeFullscreen(); + return; + } + if (e.key !== "Tab") return; + const focusable = focusableFullscreenEls(); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = root().activeElement; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } else if (!active || !fullscreenDialog?.contains(active)) { + e.preventDefault(); + first.focus(); + } + }; + root().addEventListener("keydown", onKey); + onCleanup(() => root().removeEventListener("keydown", onKey)); + }); + onMount(() => { cardEls.set(props.post.id, { card, iframes }); onCleanup(() => cardEls.delete(props.post.id)); @@ -323,14 +399,8 @@ export function Card(props: { post: Post; standalone?: boolean }) { sandbox="allow-scripts" loading="lazy" class={SURFACE_FRAME_CLASSES[surface.kind]} - title={ - props.post.surfaces.length > 1 - ? `${props.post.title} (surface ${i() + 1})` - : props.post.title - } - src={appPath( - `/s/${props.post.id}?part=${i()}&ver=${props.post.version}&cb=${props.post.version}&theme=${activeTheme()}&mode=${resolvedMode()}`, - )} + title={surfaceTitle(i())} + src={surfaceSrc(i())} > @@ -343,6 +413,24 @@ export function Card(props: { post: Post; standalone?: boolean }) { + + +
{(c) => } @@ -368,6 +456,40 @@ export function Card(props: { post: Post; standalone?: boolean }) { ); }} + + {(surface) => ( +
+
(fullscreenDialog = el)} + class="surface-fullscreen-dialog" + role="dialog" + aria-modal="true" + aria-label={`Fullscreen view of ${surface.title}`} + onClick={(e) => e.stopPropagation()} + > +
+

{surface.title}

+ +
+ +
+
+ )} +
+ + + + + + ); +} + // lucide: plug — a connect glyph for the empty-sidebar affordance. export function PlugIcon() { return ( diff --git a/viewer/src/styles.css b/viewer/src/styles.css index 4152c64..4f7f365 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -578,6 +578,41 @@ select.vbadge { .surface-shell { position: relative; } +.surface-fullscreen { + position: absolute; + top: 8px; + right: 8px; + z-index: 5; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + color: var(--muted); + background: color-mix(in srgb, var(--surface) 88%, transparent); + border: 0.5px solid var(--border-2); + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s, + background-color 0.15s, + color 0.15s; +} +.surface-shell:hover .surface-fullscreen, +.surface-shell:focus-within .surface-fullscreen { + opacity: 1; +} +.surface-fullscreen:hover, +.surface-fullscreen:focus-visible { + color: var(--text); + background: var(--surface); +} +.surface-fullscreen svg { + width: 15px; + height: 15px; +} .surface-shell.annotating { outline: 1px solid var(--accent); outline-offset: -1px; @@ -828,6 +863,68 @@ iframe { border-top: 0.5px solid var(--border); background: transparent; } +.surface-fullscreen-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + flex-direction: column; + padding: 18px; + background: rgba(0, 0, 0, 0.72); +} +.surface-fullscreen-dialog { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--surface); + border: 0.5px solid var(--border-2); + border-radius: 16px; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45); +} +.surface-fullscreen-head { + flex: none; + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 9px 12px 9px 16px; + border-bottom: 0.5px solid var(--border); + background: var(--panel); +} +.surface-fullscreen-head h2 { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--text); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.surface-fullscreen-head .x { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + color: var(--faint); + background: none; + border: none; + border-radius: 8px; + cursor: pointer; +} +.surface-fullscreen-head .x:hover { + color: var(--text); + background: var(--hover); +} +.surface-fullscreen-frame { + flex: 1; + min-height: 0; + height: auto; + border-top: 0; +} /* Shown when a surface's kind isn't recognized by this (possibly stale) viewer. */ .surface-unsupported { border-top: 0.5px solid var(--border); @@ -1684,7 +1781,8 @@ iframe { } /* narrow or touch: hover-revealed actions must stay reachable */ @media (max-width: 700px), (hover: none) { - .card-head .act { + .card-head .act, + .surface-fullscreen { opacity: 1; } .sess .x { @@ -1729,6 +1827,12 @@ iframe { width: calc(100vw - 24px); max-width: none; } + .surface-fullscreen-backdrop { + padding: 8px; + } + .surface-fullscreen-dialog { + border-radius: 12px; + } } /* ---- session view toggle (Stream / Timeline) ---- */