Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-mermaids-expand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add fullscreen Mermaid diagrams and guidance for vertical, card-friendly flowcharts.
3 changes: 2 additions & 1 deletion guide/AGENT_HOWTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <id>`** (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

Expand Down
55 changes: 49 additions & 6 deletions guide/DESIGN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg>` 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 `<svg>`
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.
Expand Down Expand Up @@ -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 `<br/>` 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<br/>Next.js]
Agent[Agent gateway<br/>MCP tools]
end
subgraph Backend[Backend]
API[API<br/>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:
Expand Down
140 changes: 131 additions & 9 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLIFrameElement>();
// 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<number, HTMLIFrameElement>();
const [annotating, setAnnotating] = createSignal(false);
const [anchorDraft, setAnchorDraft] = createSignal<CommentAnchor | null>(null);
const [fullscreenSurface, setFullscreenSurface] = createSignal<FullscreenSurface | null>(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<HTMLElement>(
'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";
Expand All @@ -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));
Expand Down Expand Up @@ -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())}
></iframe>
</Match>
<Match when={surface.kind === "image"}>
Expand All @@ -343,6 +413,24 @@ export function Card(props: { post: Post; standalone?: boolean }) {
<JsonSurface surface={surface as JsonSurfaceData} />
</Match>
</Switch>
<Show when={surface.kind === "mermaid"}>
<button
class="surface-fullscreen"
type="button"
title="Open diagram fullscreen"
aria-label="Open diagram fullscreen"
onClick={(e) => {
fullscreenOpener = e.currentTarget;
setFullscreenSurface({
src: surfaceFrames.get(i())?.src ?? surfaceSrc(i()),
title: surfaceTitle(i()),
frameClass: SURFACE_FRAME_CLASSES[surface.kind],
});
}}
>
<MaximizeIcon />
</button>
</Show>
<div class="surface-pins">
<For each={anchoredComments(i())}>{(c) => <AnchoredComment comment={c} />}</For>
<Show when={anchorDraft()?.surfaceIndex === i() ? anchorDraft() : null} keyed>
Expand All @@ -368,6 +456,40 @@ export function Card(props: { post: Post; standalone?: boolean }) {
);
}}
</For>
<Show when={fullscreenSurface()} keyed>
{(surface) => (
<div class="surface-fullscreen-backdrop" onClick={closeFullscreen}>
<div
ref={(el) => (fullscreenDialog = el)}
class="surface-fullscreen-dialog"
role="dialog"
aria-modal="true"
aria-label={`Fullscreen view of ${surface.title}`}
onClick={(e) => e.stopPropagation()}
>
<div class="surface-fullscreen-head">
<h2>{surface.title}</h2>
<button
ref={(el) => (fullscreenCloseButton = el)}
class="x"
type="button"
aria-label="Close fullscreen diagram"
onClick={closeFullscreen}
>
</button>
</div>
<iframe
sandbox="allow-scripts"
loading="eager"
class={`surface-fullscreen-frame ${surface.frameClass ?? ""}`}
title={`${surface.title} fullscreen`}
src={surface.src}
></iframe>
</div>
</div>
)}
</Show>
<Show when={!props.standalone}>
<Thread
postId={props.post.id}
Expand Down
12 changes: 12 additions & 0 deletions viewer/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ export function ImageIcon() {
);
}

// lucide: maximize-2
export function MaximizeIcon() {
return (
<Icon>
<path d="M15 3h6v6" />
<path d="m21 3-7 7" />
<path d="m3 21 7-7" />
<path d="M9 21H3v-6" />
</Icon>
);
}

// lucide: plug — a connect glyph for the empty-sidebar affordance.
export function PlugIcon() {
return (
Expand Down
Loading
Loading