Generate web slides from Comark
morkdeck CLAUDE.md
19 kB · 415 lines
Markdown
at main

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 <source.md> — 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 <source.md> [--output dist/] — One-shot static build. Writes <output>/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                <morkdeck-qr> 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 <mermaid content="..."> elements; the highlight plugin is run separately (see below).
  3. highlightTree (core/highlight.ts) — walks the parsed AST, collects every <pre>'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 <hr> 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 <figcaption>)
    • 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 <mermaid> 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 <div class="mermaid mermaid-error"> 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):

<morkdeck-presentation> #

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.

<morkdeck-slide> #

Container-query host for each slide. Shadow DOM <section> is the 16:9 letterbox (width: min(100cqi, 100cqb * 16/9)). Renders an annotation <canvas> 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).

<morkdeck-toolbar> #

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=...).

<morkdeck-qr> #

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:

---
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 <figcaption> 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 <head>). 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.