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:8000with--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-netbecause 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:
- Frontmatter —
extractFrontmatter()peels off optional leading---\n...\n---YAML block, returns{meta, body}wheremetais the typedDeckMeta. - Comark parse with the mermaid plugin only. The mermaid plugin
rewrites
```mermaidfences into<mermaid content="...">elements; the highlight plugin is run separately (see below). - 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 exportedhighlightCodeBlocks. 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. - Slide splitting — the tree is split into groups on
<hr>elements; each group becomes one slide. - Layout detection (
core/layout.ts) — structural inference (no author-facing DSL):title— first slide led byh1outro— last slide containing only a singleh1section— heading-only mid-deck slidecode-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—:::columnsdirectivecontent— default
- Component handlers in
renderHTML()emit final HTML for custom tags:morkdeck-presentation,morkdeck-slide,columns,col,mermaid(callsrenderMermaid→ SVG), and the colophon for title/outro slides (when meta is set). - 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_PATHenv 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 fromcli/build.tsin afinallyblock 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 identitypresentationId/sessionId— uuid pairs for tab matchingchannel— BroadcastChannel keyed offpresentationId- 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 bynavigate.next/previous; consumed bynavigate.jumpBack)
Events #
Major event families:
navigate.*—next,previous,scroll(IO-driven),setReturn,jumpBack(carriesslideIdso the audience tab can follow without a local return)presentation.start— initial registrationtimer.*— start, toggle, reset, setTargetrole.set— flip between audience/presenteroverview.*— toggle, setdraw.*— 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 + chromedata-overview— slide grid; audience version is host-level, presenter version wraps the slot in.presenter-overviewso the chrome strip stays at the bottom- presenter chrome stays clickable via
:host([data-role="presenter"]:not([data-overview])) ::slotted(...) { pointer-events: none }— the:notmakes 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(default100%, overviewauto)--morkdeck-slide-host-aspect(defaultauto, overview16/9)--morkdeck-slide-host-snap(defaultcenter, overviewnone)
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
metaonce, 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 overviewb— 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.