diff --git a/README.md b/README.md index 1b9b9ff..b900abf 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ user types /deckmark:use-deckmark ↓ agent asks: mode? style? motion? audience? length? ↓ +agent + user align on storyline outline (core argument per slide) agent: init_deck → writes content.md → build_deck ↓ agent: start_review → "open , press A to annotate, click Done" @@ -100,7 +101,7 @@ Annotations live in `./annotations/session-.json` next to your deck. | Tool | Purpose | |---|---| | `init_deck` | Scaffold a project (`content.md`, config, agent instructions, `.gitignore`). | -| `build_deck` | Render `content.md` to `./build/index.html` with reveal.js. Accepts `style`/`mode`/`motion`/`slideNumbers` params. | +| `build_deck` | Render `content.md` to `./build/index.html` with reveal.js. Accepts `style`/`mode`/`motion`/`slideNumbers` plus optional `customCss`/`template`/`markedPlugins` overrides. | | `start_review` | Launch the local annotation review server, return URL + session id. | | `wait_for_close` | Block until the user clicks "Done" in the browser, or until timeout. | | `get_annotations` | Read annotations from disk (works even if Done wasn't clicked). | diff --git a/mcp/tools/build.ts b/mcp/tools/build.ts index 921538f..ebd121e 100644 --- a/mcp/tools/build.ts +++ b/mcp/tools/build.ts @@ -1,5 +1,5 @@ // mcp/tools/build.ts -import { resolve } from 'node:path'; +import { isAbsolute, relative, resolve } from 'node:path'; import { buildDeck, listStyles, @@ -16,6 +16,18 @@ interface BuildInput { mode?: DeckMode; motion?: DeckMotion[]; slideNumbers?: boolean | 'c' | 'c/t' | 'h.v' | 'h/v'; + customCss?: string; + template?: string; + markedPlugins?: string[]; +} + +function resolveWithinDir(dir: string, filePath: string, label: string): string { + const resolved = resolve(dir, filePath); + const rel = relative(dir, resolved); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error(`${label} must be relative to dir`); + } + return resolved; } export const buildDeckTool = { @@ -52,6 +64,19 @@ export const buildDeckTool = { ], default: false, description: 'Show slide numbers. true → "current / total" (e.g. 3/8). false → off. Strings are passed through to reveal.js: c = current, c/t = current/total, h.v / h/v = horizontal+vertical indices.' + }, + customCss: { + type: 'string', + description: 'Optional CSS file (relative to dir) appended after built-in style theme.' + }, + template: { + type: 'string', + description: 'Optional HTML template file (relative to dir) using {{DECKMARK_*}} placeholders.' + }, + markedPlugins: { + type: 'array', + items: { type: 'string' }, + description: 'Optional local module paths (relative to dir) exporting default/register(marked) to extend markdown rendering.' } } }, @@ -66,7 +91,10 @@ export const buildDeckTool = { style: opts.style, mode: opts.mode, motion: opts.motion, - slideNumbers: opts.slideNumbers + slideNumbers: opts.slideNumbers, + customCssPath: opts.customCss ? resolveWithinDir(cwd, opts.customCss, 'customCss') : undefined, + templatePath: opts.template ? resolveWithinDir(cwd, opts.template, 'template') : undefined, + markedPlugins: opts.markedPlugins?.map(p => resolveWithinDir(cwd, p, 'markedPlugins')) }); return { out_dir: result.outDir, diff --git a/mcp/tools/review.ts b/mcp/tools/review.ts index dbbba69..a5025ab 100644 --- a/mcp/tools/review.ts +++ b/mcp/tools/review.ts @@ -2,7 +2,7 @@ import { resolve } from 'node:path'; import type { FastifyInstance } from 'fastify'; import { createServer } from '../../runtime/server/factory.ts'; -import { createSession, readSession } from '../../runtime/store/session-store.ts'; +import { createSession, readSession, closeSession } from '../../runtime/store/session-store.ts'; import { buildHash } from '../../runtime/store/build-hash.ts'; interface RunningServer { @@ -34,6 +34,11 @@ export const startReviewTool = { handler: async (input: Record) => { const opts = input as unknown as StartInput; const deckDir = opts.dir ? resolve(process.cwd(), opts.dir) : process.cwd(); + const existing = [...running.values()].filter(r => r.deckDir === deckDir); + for (const r of existing) { + try { await closeSession({ deckDir: r.deckDir, sessionId: r.sessionId }); } catch { /* ignore */ } + try { await r.app.close(); } catch { /* ignore */ } + } const hash = await buildHash(resolve(deckDir, 'build')); const session = await createSession({ deckDir, engine: 'reveal', buildHash: hash }); const app = await createServer({ deckDir, sessionId: session.session_id }); diff --git a/runtime/engines/reveal.ts b/runtime/engines/reveal.ts index f218ee1..7b5e06f 100644 --- a/runtime/engines/reveal.ts +++ b/runtime/engines/reveal.ts @@ -1,8 +1,8 @@ import { mkdir, readFile, writeFile, readdir, cp, lstat, rm } from 'node:fs/promises'; import type { Dirent } from 'node:fs'; import { dirname, join, basename, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { marked } from 'marked'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { Marked } from 'marked'; // Relative (no leading slash) so the emitted HTML works both as served // from the local review server AND as a standalone file:// open after @@ -124,6 +124,12 @@ export interface BuildOpts { motion?: DeckMotion[]; /** Show slide numbers in the corner. Pass true for "current / total", a string for custom reveal.js format. */ slideNumbers?: boolean | 'c' | 'c/t' | 'h.v' | 'h/v'; + /** Optional path to extra CSS appended after the built-in deckmark theme. */ + customCssPath?: string; + /** Optional path to a custom HTML template with DECKMARK_* placeholders. */ + templatePath?: string; + /** Optional list of local module paths exporting default/register(marked) plugin hooks. */ + markedPlugins?: string[]; } export interface BuildResult { @@ -171,6 +177,7 @@ export async function buildDeck(opts: BuildOpts): Promise { const slideNumberValue: false | string = slideNumbers === false ? false : (slideNumbers === true ? 'c/t' : slideNumbers); + const md = await createMarkedRenderer(opts.markedPlugins); const raw = await readFile(opts.contentPath, 'utf8'); const blocks = raw.split(/^\s*---\s*$/m).map(s => s.trim()).filter(Boolean); @@ -180,7 +187,7 @@ export async function buildDeck(opts: BuildOpts): Promise { const slugCounts = new Map(); const sections = blocks.map((block, i) => { - let html = marked.parse(block, { async: false }) as string; + let html = md.parse(block, { async: false }) as string; if (fragmentReveals) { html = applyFragmentReveals(html); } @@ -198,10 +205,27 @@ export async function buildDeck(opts: BuildOpts): Promise { } catch { // theme file missing — engine still produces a valid (unstyled) deck } + if (opts.customCssPath) { + const customCss = await readFile(opts.customCssPath, 'utf8'); + deckmarkTheme += `\n${customCss}`; + } const transition = slideTransitions ? config.transition : 'none'; - - const htmlDocument = ` + const revealInitScript = `window.__deckmarkReveal = Reveal; + Reveal.initialize({ + hash: true, + controlsLayout: 'edges', + controlsBackArrows: 'faded', + transition: ${JSON.stringify(transition)}, + center: false, + width: '100%', + height: '100%', + margin: 0, + autoAnimate: ${autoAnimate}, + fragments: ${fragmentReveals}, + slideNumber: ${JSON.stringify(slideNumberValue)} + });`; + const defaultHtmlDocument = ` @@ -221,23 +245,21 @@ ${sections.join('\n')} `; + const htmlDocument = opts.templatePath + ? renderTemplate(await readFile(opts.templatePath, 'utf8'), { + DECKMARK_MODE: mode, + DECKMARK_STYLE: style, + DECKMARK_REVEAL_PREFIX: REVEAL_PREFIX, + DECKMARK_BASE_THEME: baseTheme, + DECKMARK_THEME_CSS: deckmarkTheme, + DECKMARK_SLIDES: sections.join('\n'), + DECKMARK_REVEAL_INIT: revealInitScript + }) + : defaultHtmlDocument; // Clean the build dir before each build so stale entries (especially any // pre-existing symlinks) can't survive a rebuild. Because rm() is @@ -259,6 +281,30 @@ ${sections.join('\n')} `buildDeck: refusing to clean filesystem root as outDir: ${resolvedOutDir}` ); } + + function renderTemplate(template: string, vars: Record): string { + let out = template; + for (const [key, value] of Object.entries(vars)) { + out = out.replaceAll(`{{${key}}}`, value); + } + return out; + } + + async function createMarkedRenderer(pluginPaths: string[] | undefined): Promise { + const md = new Marked(); + if (!pluginPaths?.length) return md; + for (const p of pluginPaths) { + const mod = await import(pathToFileURL(resolve(p)).href); + const register = typeof mod.default === 'function' + ? mod.default + : (typeof mod.register === 'function' ? mod.register : null); + if (!register) { + throw new Error(`Invalid marked plugin module at ${p}: expected default/register function`); + } + register(md); + } + return md; + } const outDirWithSep = resolvedOutDir.endsWith(sep) ? resolvedOutDir : resolvedOutDir + sep; if (resolvedContent === resolvedOutDir || resolvedContent.startsWith(outDirWithSep)) { throw new Error( diff --git a/runtime/overlay/annotation-mode.ts b/runtime/overlay/annotation-mode.ts index 04a131a..a6549b6 100644 --- a/runtime/overlay/annotation-mode.ts +++ b/runtime/overlay/annotation-mode.ts @@ -7,6 +7,7 @@ import type { AnnotationInput } from '../types/session.ts'; export function mountAnnotationMode(_root: HTMLElement): void { let outline: HTMLDivElement | null = null; let currentEl: Element | null = null; + let pointerDown: { x: number; y: number } | null = null; const onMouseMove = (e: MouseEvent) => { if (getState().mode !== 'annotating') return; @@ -18,6 +19,12 @@ export function mountAnnotationMode(_root: HTMLElement): void { const onClick = (e: MouseEvent) => { if (getState().mode !== 'annotating') return; + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + if (pointerDown) { + const dx = e.clientX - pointerDown.x; + const dy = e.clientY - pointerDown.y; + if (Math.hypot(dx, dy) > 8) return; + } // If a popover is already open, do not start another annotation — the // popover singleton would close the first one and the user would lose // their in-progress comment. @@ -30,6 +37,9 @@ export function mountAnnotationMode(_root: HTMLElement): void { }; // capture phase so we run before reveal.js + document.addEventListener('pointerdown', (e) => { + pointerDown = { x: e.clientX, y: e.clientY }; + }, true); document.addEventListener('mousemove', onMouseMove, true); document.addEventListener('click', onClick, true); @@ -45,6 +55,8 @@ export function mountAnnotationMode(_root: HTMLElement): void { if (!el) return null; if (el.closest('.deckmark-toolbar, .deckmark-popover, .deckmark-pin, .deckmark-done-dialog')) return null; + if (el.closest('.controls, .progress, .slide-number, .speaker-notes, [data-prevent-swipe]')) + return null; const section = el.closest('section'); if (!section) return null; if (el === section) return section.firstElementChild ?? section; diff --git a/runtime/overlay/pin.ts b/runtime/overlay/pin.ts index 88d3e3c..4203da3 100644 --- a/runtime/overlay/pin.ts +++ b/runtime/overlay/pin.ts @@ -2,23 +2,16 @@ import { getState, subscribe } from './state.ts'; import type { Annotation } from '../types/session.ts'; export function mountPins(_root: HTMLElement): void { - const layer = document.createElement('div'); - layer.id = 'deckmark-pins'; - Object.assign(layer.style, { - position: 'absolute', - inset: '0', - pointerEvents: 'none', - zIndex: '999997' - } as Partial); - document.body.appendChild(layer); - const render = () => { const s = getState(); - layer.innerHTML = ''; + clearPinLayers(); if (s.mode === 'hidden' || !s.session) return; const onCurrent = s.session.annotations.filter(a => a.slide.index === s.currentSlideIndex); + const currentSection = document.querySelector(`section[data-slide-index="${s.currentSlideIndex}"]`); + if (!currentSection) return; + const layer = ensureSlideLayer(currentSection); for (let i = 0; i < onCurrent.length; i++) { - placePin(layer, onCurrent[i], i + 1); + placePin(layer, currentSection, onCurrent[i], i + 1); } }; @@ -28,7 +21,7 @@ export function mountPins(_root: HTMLElement): void { render(); } -function placePin(layer: HTMLElement, a: Annotation, num: number): void { +function placePin(layer: HTMLElement, section: HTMLElement, a: Annotation, num: number): void { let target: Element | null = null; try { target = document.querySelector(a.element.selector); @@ -37,16 +30,41 @@ function placePin(layer: HTMLElement, a: Annotation, num: number): void { } if (!target) return; // stale selector — skip (v2 could re-anchor via bbox) const rect = target.getBoundingClientRect(); + const sectionRect = section.getBoundingClientRect(); + const scaleX = section.offsetWidth > 0 ? sectionRect.width / section.offsetWidth : 1; + const scaleY = section.offsetHeight > 0 ? sectionRect.height / section.offsetHeight : 1; + const unscaledX = Number.isFinite(scaleX) && scaleX > 0 ? (rect.left - sectionRect.left) / scaleX : rect.left - sectionRect.left; + const unscaledY = Number.isFinite(scaleY) && scaleY > 0 ? (rect.top - sectionRect.top) / scaleY : rect.top - sectionRect.top; const pin = document.createElement('div'); pin.className = 'deckmark-pin'; pin.dataset.status = a.status; pin.textContent = String(num); pin.title = a.comment; - pin.style.left = `${window.scrollX + rect.left - 12}px`; - pin.style.top = `${window.scrollY + rect.top - 12}px`; + pin.style.left = `${unscaledX - 12}px`; + pin.style.top = `${unscaledY - 12}px`; pin.style.pointerEvents = 'auto'; pin.addEventListener('click', () => { alert(`Annotation #${num}\n\n${a.comment}`); }); layer.appendChild(pin); } + +function clearPinLayers(): void { + document.querySelectorAll('.deckmark-pin-layer').forEach(layer => layer.remove()); +} + +function ensureSlideLayer(section: HTMLElement): HTMLElement { + if (getComputedStyle(section).position === 'static') { + section.style.position = 'relative'; + } + const layer = document.createElement('div'); + layer.className = 'deckmark-pin-layer'; + Object.assign(layer.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '999997' + } as Partial); + section.appendChild(layer); + return layer; +} diff --git a/runtime/overlay/popover.ts b/runtime/overlay/popover.ts index 033fb58..5b7d8df 100644 --- a/runtime/overlay/popover.ts +++ b/runtime/overlay/popover.ts @@ -37,7 +37,12 @@ export function showPopover( ta.focus(); let resolved = false; + let removeOutsideListener: (() => void) | null = null; const cleanup = () => { + if (removeOutsideListener) { + removeOutsideListener(); + removeOutsideListener = null; + } if (activePopover === pop) activePopover = null; pop.remove(); }; @@ -72,6 +77,15 @@ export function showPopover( cancelBtn.addEventListener('click', cancel); cancelBtn.addEventListener('pointerdown', cancel); + const onOutsidePointerDown = (e: PointerEvent) => { + const targetEl = e.target as Node | null; + if (targetEl && !pop.contains(targetEl)) cancel(e); + }; + document.addEventListener('pointerdown', onOutsidePointerDown, true); + removeOutsideListener = () => { + document.removeEventListener('pointerdown', onOutsidePointerDown, true); + }; + ta.addEventListener('keydown', (e) => { if (e.key === 'Escape') cancel(); if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submit(); diff --git a/skills/deckmark/SKILL.md b/skills/deckmark/SKILL.md index b4e385f..307d9ef 100644 --- a/skills/deckmark/SKILL.md +++ b/skills/deckmark/SKILL.md @@ -74,7 +74,7 @@ If the user describes design in words, map to the three axes: - "smooth" / "animated" → `motion: ['slide-transitions']` - "build up" / "reveal one at a time" → add `'fragment-reveals'` -### Style influences content, not just visuals +### Style influences tone, not structure Write `content.md` to suit the chosen style: @@ -86,6 +86,13 @@ Write `content.md` to suit the chosen style: Same content can look great in any style; tuning the prose to the personality is what makes the chosen style feel intentional. +Before drafting any slides, lock the narrative first: + +- define audience + decision goal ("what should this deck change?") +- agree on 3–6 core arguments +- map a simple arc (problem → evidence → conclusion / next step) +- assign one primary point per slide in the outline + ## Annotation data shape Each annotation has: @@ -105,12 +112,14 @@ Each session also has: - `summary` — overall guidance from the Done dialog. Apply as a global theme, not a single change. - `build_hash` — sha256 of the build at review start; if it differs from a later build, selectors may be stale. -## Workflow +## Workflow (Plan → Draft → Refine) ``` ask design (mode/style/motion) + content (audience/length) ↓ -init_deck → write content.md → build_deck(style, mode, motion) +init_deck → plan storyline with user (audience/goals/core arguments/outline) + ↓ +write content.md from the approved outline → build_deck(style, mode, motion) ↓ start_review → [user annotates in browser] ↓ diff --git a/test/integration/mcp-flow.test.ts b/test/integration/mcp-flow.test.ts index 2ed864c..ec209e1 100644 --- a/test/integration/mcp-flow.test.ts +++ b/test/integration/mcp-flow.test.ts @@ -107,8 +107,22 @@ test('full MCP flow: init → build → start_review → POST annotation → get const startOut = JSON.parse(startRes.content[0].text) as { url: string; session_id: string }; assert.match(startOut.url, /^http:\/\/127\.0\.0\.1:\d+$/); + // starting a new review for the same deck should close the old server first + const startRes2 = await client.call('tools/call', { + name: 'start_review', + arguments: { dir: deckDir } + }) as { content: Array<{ text: string }> }; + const startOut2 = JSON.parse(startRes2.content[0].text) as { url: string; session_id: string }; + assert.notEqual(startOut2.session_id, startOut.session_id); + const oldStop = await client.call('tools/call', { + name: 'stop_review', + arguments: { session_id: startOut.session_id } + }) as { content: Array<{ text: string }> }; + const oldStopOut = JSON.parse(oldStop.content[0].text) as { stopped: boolean }; + assert.equal(oldStopOut.stopped, false); + // POST an annotation via HTTP - const post = await fetch(`${startOut.url}/api/annotations`, { + const post = await fetch(`${startOut2.url}/api/annotations`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -142,6 +156,6 @@ test('full MCP flow: init → build → start_review → POST annotation → get // stop_review await client.call('tools/call', { name: 'stop_review', - arguments: { session_id: startOut.session_id } + arguments: { session_id: startOut2.session_id } }); }); diff --git a/test/unit/engines-reveal.test.ts b/test/unit/engines-reveal.test.ts index 00a89d8..c36a019 100644 --- a/test/unit/engines-reveal.test.ts +++ b/test/unit/engines-reveal.test.ts @@ -317,3 +317,54 @@ test('buildDeck does NOT sync deckmark internals (AGENTS.md, annotations/, .giti assert.ok(existsSync(join(dir, 'build', 'assets', 'logo.svg')), 'user asset should be copied'); await rm(dir, { recursive: true }); }); + +test('buildDeck appends custom CSS and supports custom HTML template placeholders', async () => { + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), SAMPLE_CONTENT); + await writeFile(join(dir, 'custom.css'), '.custom-token { color: hotpink; }'); + await writeFile(join(dir, 'template.html'), ` + + + + + + +
{{DECKMARK_SLIDES}}
+ + +`); + await buildDeck({ + contentPath: join(dir, 'content.md'), + outDir: join(dir, 'build'), + customCssPath: join(dir, 'custom.css'), + templatePath: join(dir, 'template.html') + }); + const html = await readFile(join(dir, 'build', 'index.html'), 'utf8'); + assert.match(html, /class="slides"/); + assert.match(html, /data-mode="light"/); + assert.match(html, /\.custom-token \{ color: hotpink; \}/); + assert.match(html, /Reveal\.initialize/); + await rm(dir, { recursive: true }); +}); + +test('buildDeck loads marked plugin modules', async () => { + const dir = await tmpDir(); + await writeFile(join(dir, 'content.md'), '# Slide One\n\n[[plugin-token]]'); + await writeFile(join(dir, 'marked-plugin.mjs'), `export default function register(marked) { + marked.use({ + hooks: { + preprocess(markdown) { + return markdown.replaceAll('[[plugin-token]]', '**from-plugin**'); + } + } + }); +}`); + await buildDeck({ + contentPath: join(dir, 'content.md'), + outDir: join(dir, 'build'), + markedPlugins: [join(dir, 'marked-plugin.mjs')] + }); + const html = await readFile(join(dir, 'build', 'index.html'), 'utf8'); + assert.match(html, /from-plugin<\/strong>/); + await rm(dir, { recursive: true }); +});