# CLAUDE.md Guidance for Claude Code working in this repository. Keep this current when the architecture shifts — out-of-date orientation is worse than no orientation. ## Development Commands Two tasks, defined in the workspace root `deno.json`: - `deno task dev ` — Dev server on `:8000` with `--watch`, live-reload websocket, esbuild-bundled runtime served at `/static/bundle.js`. Reloads core changes via Deno's `--watch`; reloads the bundle by re-fetching on hard-reload (the bundle is rebuilt per request). - `deno task build [--output dist/]` — One-shot static build. Writes `/index.html`. The build's mermaid renderer needs `--allow-net` because it talks to a headless Chrome via WebSocket; that's already in the task definition. Both expect a path to a Markdown file. ## Workspace Layout This is a Deno workspace with six packages. Each package has its own `deno.json` and `README.md`. Cross-package imports use `@morkdeck/*` specifiers resolved by the workspace root. ``` morkdeck/ ├── deno.json workspace root, two tasks, root imports (lit, xstate) ├── examples/ │ └── showcase.md the canonical demo deck; exercises every layout └── packages/ ├── cli/ @morkdeck/cli — Cliffy commands │ ├── main.ts entry: dev + build subcommands │ ├── dev.ts delegates to @morkdeck/server │ └── build.ts one-shot renderPresentationHtml + write file ├── core/ @morkdeck/core — the render pipeline │ ├── renderer.ts renderPresentationHtml(): Markdown → HTML │ ├── highlight.ts dynamic Shiki language loader (AST walk) │ ├── mermaid.ts headless Chromium pool + disk cache │ ├── layout.ts slide-shape detection (title/focus/full/...) │ ├── icons.ts inline SVG icon set (Tabler) for colophon socials │ ├── favicon.ts per-deck color-hashed favicon generation │ ├── types.ts RenderOptions, Includes │ └── templates/ │ ├── presentation.eta HTML shell │ └── partials/ │ ├── favicon.eta │ ├── live-reload.eta │ └── slide-styles.eta inline CSS for slotted slide content ├── runtime/ @morkdeck/runtime — xstate state machine │ ├── morkdeck.ts bundle entry; imports @morkdeck/wc which │ registers all custom elements │ └── actor/ │ ├── types.ts Context + Events + Tool + Stroke + ... │ ├── setup.ts all xstate actions (assign reducers) │ └── machine.ts wires actions to events, initial context ├── server/ @morkdeck/server — dev server │ └── dev.ts Deno.serve + WebSocket live-reload + │ esbuild bundle on demand ├── theme/ @morkdeck/theme — design tokens │ ├── stylesheet.ts compileCss() — Terrazzo CSS variable output │ ├── typescript.ts TS helpers exporting token references │ └── tokens/ Terrazzo token JSON files └── wc/ @morkdeck/wc — Lit web components ├── element.ts MorkdeckElement base class (@subscribe decorator) ├── context.ts @lit/context tokens for the xstate actor + IO ├── density.ts density auto-fit (Tier 2): predicted heights └── components/ ├── presentation/wc.ts the big one: presentation host ├── slide.ts individual slide; container queries ├── toolbar.ts audience-mode toolbar └── qr.ts via @levischuck/tiny-qr ``` ## Render Pipeline (build-time) `renderPresentationHtml(path, options?)` in `core/renderer.ts`: 1. **Frontmatter** — `extractFrontmatter()` peels off optional leading `---\n...\n---` YAML block, returns `{meta, body}` where `meta` is the typed `DeckMeta`. 2. **Comark parse** with the mermaid plugin only. The mermaid plugin rewrites `` ```mermaid `` fences into `` elements; the highlight plugin is run separately (see below). 3. **highlightTree** (`core/highlight.ts`) — walks the parsed AST, collects every `
`'s language attribute, normalizes aliases
   (`py` → `python`, `ts` → `typescript`, ...), dynamically imports
   only those Shiki language modules, then calls Comark's exported
   `highlightCodeBlocks`. A deck with one Python block doesn't pay for
   Rust/Go/SQL. Modules are cached across parses so the dev server
   pays each import cost once per process.
4. **Slide splitting** — the tree is split into groups on `
` elements; each group becomes one slide. 5. **Layout detection** (`core/layout.ts`) — structural inference (no author-facing DSL): - `title` — first slide led by `h1` - `outro` — last slide containing only a single `h1` - `section` — heading-only mid-deck slide - `code-focus` / `diagram-focus` / `image-focus` — heading + lone media block, optionally with trailing blockquote (becomes `
`) - `code-full` / `diagram-full` / `image-full` — no heading + lone media block (full-bleed variants of the above) - `two-column` — `:::columns` directive - `content` — default 6. **Component handlers** in `renderHTML()` emit final HTML for custom tags: `morkdeck-presentation`, `morkdeck-slide`, `columns`, `col`, `mermaid` (calls `renderMermaid` → SVG), and the colophon for title/outro slides (when meta is set). 7. **Eta template** wraps body in the HTML shell with the inline stylesheet (`compileCss()` from `@morkdeck/theme`), favicon, and live-reload script (dev only). ## Static Mermaid Rendering `core/mermaid.ts` runs every `` source through a headless Chromium pool (via `@astral/astral`) and inlines the resulting SVG during render. This eliminates the runtime mermaid library entirely — no client-side JS, no font race, no foreignObject measurement bugs. - **Cache**: SHA-256 of (format | theme version | mermaid version | source). Stored per-machine under `%LOCALAPPDATA%\morkdeck\mermaid\` (Windows) or `~/.cache/morkdeck/mermaid/` (XDG / Unix). Cache hit skips Chrome entirely; typical incremental rebuilds spawn no subprocess. - **Browser selection**: prefers a system-installed Chrome/Edge over Astral's bundled snapshot (which often fails to start on Windows due to missing VC++ runtimes). Honors `MORKDECK_CHROME_PATH` env var. - **Error policy**: dev mode emits an inline `
` block showing message + source; build mode re-throws so the build bails. - **Font handling**: the renderer page explicitly loads the deck's weights via `document.fonts.load('400 20px Recursive')` etc. before signaling ready — earlier versions had a measurement race where mermaid sized foreignObjects against a fallback font and labels later clipped when the audience painted with Recursive. - **Cleanup**: `shutdownMermaidRenderer()` called from `cli/build.ts` in a `finally` block so the Chrome subprocess doesn't outlive a build. ## State Machine (xstate) `runtime/actor/` defines a single xstate machine that drives both audience and presenter modes. Two tabs share one machine *via the cross-tab bridge* (see below), so navigation done in one tab plays out in the other. ### Context (`actor/types.ts`) Key fields beyond the obvious `currentIndex` / `slides`: - `role: "audience" | "presenter"` — current tab's identity - `presentationId` / `sessionId` — uuid pairs for tab matching - `channel` — BroadcastChannel keyed off `presentationId` - Drawing: `strokes` (per-slide), `cursor`, `laser`, `activeTool`, `penColor` - Timer: `startedAt`, `countdownTargetMs`, `timerRunning`, `pausedElapsedMs`, `pausedAt` - Overview: `overview: boolean` (per-tab UI; not relayed) - Jump-back: `jumpReturnSlideId: string | null` (per-tab UI; cleared by `navigate.next`/`previous`; consumed by `navigate.jumpBack`) ### Events Major event families: - `navigate.*` — `next`, `previous`, `scroll` (IO-driven), `setReturn`, `jumpBack` (carries `slideId` so the audience tab can follow without a local return) - `presentation.start` — initial registration - `timer.*` — start, toggle, reset, setTarget - `role.set` — flip between audience/presenter - `overview.*` — toggle, set - `draw.*` — stroke start/point/erase, clear, cursor, laser, tool, color ### Cross-Tab Bridge `presentation/wc.ts` wraps `this.presentation.send` in a relay function. Events in the static `#RELAYED_EVENTS` set are `postMessage`'d to a BroadcastChannel keyed by `presentationId` before being processed locally. A `suppressBroadcast` flag prevents echo loops when the bridge receives a remote event and forwards it to `originalSend`. Currently relayed: navigation (`next`/`previous`/`scroll`/`jumpBack`) and drawing events. Not relayed: role, timer, overview, drawing tool selection — all per-tab UI state. ## Web Component Architecture Three main custom elements registered by `@morkdeck/wc/mod.ts` (imported transitively via the runtime bundle entry): ### `` The container. Provides the xstate actor via `@lit/context` so descendants can consume it without prop drilling. Hosts the audience-mode toolbar (slotted at host level) or the presenter chrome (rendered into the shadow root template). Layout switches based on host attributes: - `data-role="presenter"` — grid layout with stage + sidebar + chrome - `data-overview` — slide grid; audience version is host-level, presenter version wraps the slot in `.presenter-overview` so the chrome strip stays at the bottom - presenter chrome stays clickable via `:host([data-role="presenter"]:not([data-overview])) ::slotted(...) { pointer-events: none }` — the `:not` makes the rule disengage in overview so slides become clickable Drawing canvases live on each slide (see slide.ts); the presenter has an additional transparent input-capture canvas overlaid on `.stage`. ### `` Container-query host for each slide. Shadow DOM `
` is the 16:9 letterbox (`width: min(100cqi, 100cqb * 16/9)`). Renders an annotation `` inside the section that the presentation's render loop draws strokes/cursor/laser onto. Sizing variables consumed from the cascade so the presentation can override them in overview mode without `::slotted` specificity fights: - `--morkdeck-slide-host-height` (default `100%`, overview `auto`) - `--morkdeck-slide-host-aspect` (default `auto`, overview `16/9`) - `--morkdeck-slide-host-snap` (default `center`, overview `none`) Density auto-fit (`wc/density.ts`) picks `roomy` / `balanced` / `dense` per slide by predicting the total content height at each tier using pretext text measurement (no DOM mutation per tier). ### `` Audience-only toolbar at the bottom of the viewport. Buttons: fullscreen, slide overview toggle, prev/next/counter, share (TODO), presenter mode (opens new tab with `?role=presenter&uuid=...`). ### `` QR code generator using `@levischuck/tiny-qr` + `@levischuck/tiny-qr-svg`. Generates client-side from `document.URL` (or an explicit `value` attribute) so the QR always points to where the deck is actually being viewed. Used on the outro slide when `qr: true` is in frontmatter. ## Frontmatter / Deck Metadata Optional leading YAML in the source `.md`: ```yaml --- event: Bryophyta Society Spring Meeting date: May 2026 location: Portland, Oregon speaker: Graham Barber pronouns: he/him handle: "@graham.systems" avatar: https://.../avatar.jpg qr: true # or a fixed URL links: github: gambarber bluesky: graham.bsky.social mastodon: "@graham@botany.social" website: grahambarber.com --- ``` The renderer: - Reads `meta` once, threads through the title/outro slide handlers. - Emits a marquee strip (top) and a colophon block on title slides with avatar + name + byline (pronouns + handle) + context line + social icons. - The outro slide uses a two-column grid: wordmark left, colophon stack right, optional QR bottom-left. URL generation for social platforms is per-platform (`socialHref` in `renderer.ts`): GitHub → `github.com/handle`, Mastodon parses `@user@host` → `https://host/@user`, website prepends `https://` if missing, email becomes `mailto:`, etc. Extend the `SOCIAL_ICONS` map in `core/icons.ts` and add a case to `socialHref` for new platforms. ## Slide Layouts Detected structurally (no DSL); each gets specific CSS in `slide.ts` (host-level) and `slide-styles.eta` (slotted content). | Layout | Trigger | Notes | |---|---|---| | `title` | First slide led by `h1` | Marquee + wordmark + colophon | | `outro` | Last slide, lone `h1` | Two-column with optional QR | | `section` | Heading-only mid-deck | Centered headline | | `content` | Default | Asymmetric editorial column | | `code-focus` | Heading + lone code fence | Figure with code | | `code-full` | No heading + lone code fence | Full-bleed code | | `diagram-focus` | Heading + lone mermaid fence | Figure with SVG | | `diagram-full` | No heading + lone mermaid fence | Full-bleed diagram | | `image-focus` | Heading + lone image | Figure with image | | `image-full` | No heading + lone image | `object-fit: cover` | | `two-column` | `:::columns` directive | Two `:::col` children | Caption (trailing blockquote on a focus/full slide) is rendered as `
` styled per-layout in `slide-styles.eta`. ## Presenter Mode Opened via the audience toolbar's chalkboard icon — opens a new tab with `?role=presenter&uuid=...`. The two tabs share the BroadcastChannel; navigation events relay both directions. Chrome strip at the bottom: - Left: elapsed timer + optional countdown - Center: prev / counter / next / **overview toggle** / **jump-back** (conditional) - Right: countdown target input, timer toggle/reset, wall clock, exit Keyboard shortcuts (presenter only, ignored when typing into the target input): - `Esc` — exit presenter (closes tab if opened via toolbar, else flips role.set to audience) - `g` — toggle slide overview - `b` — jump back to the slide the presenter was on before their last overview navigation (no-op if no return stored) ### Drawing Tools Tool palette in the stage's top-right (pen / laser / eraser / clear). Pen and laser use `lazy-brush` for smoothed strokes; pen at radius 20 (deliberate lag), laser at radius 8 (responsive). All coords normalized 0..1 against the slide's section rect so the audience canvas (different pixel dimensions) renders the same stroke geometry. Right-click is always laser regardless of active tool — quick gesture without leaving pen mode. Eraser does whole-stroke hit-testing in normalized space (point-to-polyline distance, tolerance 0.025). Audience-side cursor is smoothed (frame-rate-independent exponential lerp toward the wire-cursor position, tau ≈ 55ms) so it glides between the 30Hz wire updates instead of stepping. ### Slide Overview (both modes) Toolbar (audience) or chrome button + `g` (presenter) toggles a 2-column grid of slide previews. CSS variables make slides aspect-ratio'd cells; container queries auto-scale their content. Click any slide → navigates to it + exits overview. The presenter also records the from-slide into `jumpReturnSlideId`, which surfaces the jump-back button in the chrome until either consumed or invalidated by a `next`/`previous` (presenter resumed natural flow). IntersectionObserver is guarded with `if (this.overview) return` so the grid layout's mass slide-crossings don't fire spurious `navigate.scroll` events and overwrite `currentIndex`. Scroll re-anchoring on overview toggle is deferred to `requestAnimationFrame` — reading `slide.offsetTop` synchronously in `updated()` raced with the browser's scroll-snap settling after the attribute swap. ## Theme `@morkdeck/theme` ships Rose Pine (dark) tokens compiled via Terrazzo into CSS variables (`compileCss()` returns the CSS string the renderer inlines into ``). Container-query units (`cqi`, `cqmin`, `cqb`) drive most sizing so every slide proportionally scales with its container — the same deck renders correctly at a projector, in a presenter preview, and in an overview grid cell. Recursive is the single typeface, used across its variable axes (`wght`, `slnt`, `CASL`, `MONO`) instead of pairing with a separate mono / italic. See PRODUCT.md / DESIGN.md (in repo root if present) for the brand and design system definitions. ## Extending **Add a slide layout**: extend the `Layout` union in `core/layout.ts`, add a branch to `detectLayout()`, then style it in `slide.ts` (host-level for the section box) and `slide-styles.eta` (slotted content). Existing layouts are good references — pattern is "detect structurally, style via attribute selector". **Add a Comark component**: add an entry to the `components` map in `renderHTML()` (in `core/renderer.ts`). Handler receives `[tag, attrs, ...children]` and a `{ render }` context. **Add an xstate event**: declare in `actor/types.ts` (extend the `Events` union), add the reducer in `actor/setup.ts` (`assign(...)`), wire it in `actor/machine.ts`. If it should cross the tab boundary, add to `#RELAYED_EVENTS` in `presentation/wc.ts`. **Add a presenter keyboard shortcut**: edit `#onKeyDown` in `presentation/wc.ts`. Always guard on `role === "presenter"` and skip when `target` is an `INPUT` / `TEXTAREA` so the shortcut doesn't eat into form fields. **Add a Shiki language**: nothing to do — `core/highlight.ts` walks the AST and dynamically imports whatever languages appear in fence strings, falling through to plain-text rendering for ones Shiki doesn't ship. To add a non-obvious alias (e.g. `py` → `python`), extend the `ALIASES` map in `highlight.ts`. **Add a social platform**: extend `SOCIAL_ICONS` in `core/icons.ts` with the SVG path, then add a `case` to `socialHref` / `socialLabel` in `core/renderer.ts` for the URL convention.