@khanglvm/relay 0.13.4 → 0.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,10 +61,11 @@ rly mcp config
61
61
 
62
62
  When the agent calls a relay tool, the host renders relay's `ui://relay/board`
63
63
  resource in a sandboxed iframe, hands it the board spec, and the user's answers
64
- flow back to the agent over the bridge (`ui/update-model-context`) markdown,
65
- code, diffs, tables, charts, mermaid/graphviz diagrams, images, and forms, all
66
- in-chat. The classic browser board (`rly ask` / `rly show`) is unchanged; pick
67
- whichever surface fits.
64
+ flow back to the agent as a `ui/message` user turn, with
65
+ `ui/update-model-context` used as a best-effort structured context sync
66
+ markdown, code, diffs, tables, charts, mermaid/graphviz diagrams, images, and
67
+ forms, all in-chat. The classic browser board (`rly ask` / `rly show`) is
68
+ unchanged; pick whichever surface fits.
68
69
 
69
70
  ## What it improves
70
71
 
@@ -87,7 +88,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
87
88
  | | |
88
89
  |---|---|
89
90
  | `rly help` | every command at a glance |
90
- | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s) — library-free; great for plans, READMEs, reports |
91
+ | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
91
92
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
92
93
  | `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
93
94
  | `rly mcp` | run relay as an MCP App server so boards render **inline** in the chat — **stdio** for local desktop hosts (Claude Desktop, Codex), or `rly mcp --http` (Streamable HTTP) for web/mobile/remote; `rly mcp config` / `rly mcp install --target claude\|codex` to register it |
@@ -114,10 +115,11 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
114
115
  - **Same board, postMessage transport.** The inline board reuses relay's block
115
116
  renderer (markdown, code, diff, table, chart, mermaid, graphviz, image, html)
116
117
  over the MCP Apps JSON-RPC bridge: the spec arrives as the tool result, the
117
- user's answers go back via `ui/update-model-context`, the iframe auto-sizes
118
- via `ui/notifications/size-changed`, and vendored Chart.js / Mermaid / Viz.js
119
- load on demand through the host's `resources/read` (no `/vendor` route, no
120
- server in the sandbox).
118
+ user's submit goes back via `ui/message` so the agent resumes, the structured
119
+ payload is also offered through `ui/update-model-context`, the iframe
120
+ auto-sizes via `ui/notifications/size-changed`, and vendored Chart.js /
121
+ Mermaid / Viz.js load on demand through the host's `resources/read` (no
122
+ `/vendor` route, no server in the sandbox).
121
123
  - **One-command setup** — `rly mcp install --target claude|codex` writes the
122
124
  host config; `rly mcp config` prints the snippet for any MCP host. The classic
123
125
  browser board is untouched.
@@ -162,6 +164,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
162
164
  - **`video` block** — YouTube/Vimeo embeds, a direct media URL, or a local
163
165
  video file streamed from the server with HTTP Range (seekable), never
164
166
  embedded in the payload.
167
+ - **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
168
+ stream from the board server and are never embedded in the page payload.
165
169
  - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
166
170
  board whose connection drops blocks further input instead of losing it, and
167
171
  `rly rescue <id>` re-serves on the same port so an open tab reconnects.
package/docs/AGENT.md CHANGED
@@ -111,7 +111,8 @@ finalizes when your call completes — so the user sees it build, not a blank wa
111
111
  Both take **the exact same board spec** documented below (the tool `inputSchema`
112
112
  *is* this spec). Call the tool with your spec; the host shows the board, the user
113
113
  fills it in, and their answers come back to you (answers, per-question notes,
114
- comment) read them just as you would the CLI's result JSON. There is **no
114
+ comment) as a user-message turn, with structured context synced when the host
115
+ supports it — read them just as you would the CLI's result JSON. There is **no
115
116
  `--detach`/`rly wait` dance, no stdout parsing, and no timeout** in this mode; the
116
117
  board stays live until the user submits and the host delivers the result.
117
118
 
@@ -150,6 +151,7 @@ Read a markdown file (no questions; library-free renderer; submit reads "Done"):
150
151
  rly view PLAN.md # one file
151
152
  rly view README.md CHANGELOG.md # several, each under a filename heading
152
153
  rly view data.csv # .csv/.tsv/.json → a sortable, filterable table
154
+ rly view report.pdf # .pdf → inline PDF viewer
153
155
  ```
154
156
 
155
157
  Show a git diff in one step (sugar — runs git diff, opens a diff board):
@@ -404,6 +406,10 @@ Rules of thumb:
404
406
  { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
405
407
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
406
408
 
409
+ // PDF — an http(s) PDF URL or a local .pdf file rendered inline. Local PDFs
410
+ // stream from the server and are never embedded in the page payload.
411
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
412
+
407
413
  // HTML — sandboxed iframe; default height 360
408
414
  { "type": "html", "html": "<h1>Hello</h1>", "height": 360 }
409
415
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
@@ -468,6 +474,7 @@ the question-types section above.
468
474
  | `code` | code snippets, config examples, command output — syntax-highlighted + line-numbered; load from a file with `codeFile` |
469
475
  | `diff` | proposed code changes / before-after — a unified diff rendered as a colored git-style comparison (no git needed) |
470
476
  | `video` | demos, screen recordings, walkthroughs — YouTube/Vimeo embeds, a media URL, or a local video file (streamed) |
477
+ | `pdf` | quotes, reports, exports, forms — local `.pdf` files or PDF URLs rendered inline; local files stream from the board server |
471
478
  | `image` | screenshots, mockup exports, photos — local files embed and work offline |
472
479
  | `palette` | color palettes / themes — swatch cards with hover-hex + click-to-copy; pair with a `color` question to let the user pick |
473
480
  | `kpi` | big-number metric cards (`items:[{label,value,delta?,dir?,sub?}]`) with up/down/flat-tinted deltas — at-a-glance numbers without a chart |
@@ -645,6 +652,25 @@ cancelled — with the full result JSON piped to stdin and `RLY_BOARD_ID`,
645
652
  a `wait` that obtains a terminal result. Write a file your harness watches,
646
653
  hit a webhook — whatever wakes you.
647
654
 
655
+ ### Codex browser-board pattern
656
+
657
+ Codex does not have a normal, user-facing "wake this agent turn from a browser
658
+ submit" command. If the Codex turn stops waiting, a later board submit can leave
659
+ the user needing to prompt manually. So when an agent is running in Codex and
660
+ uses the browser board path, keep the waiter in the foreground until the user
661
+ submits:
662
+
663
+ ```sh
664
+ rly ask --file spec.json --detach
665
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
666
+ ```
667
+
668
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
669
+ If it is still open and the user may continue, run `rly wait` again. Do not use
670
+ `--on-result` as the primary Codex return path; it can write files or hit
671
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
672
+ that wakes the current agent turn.
673
+
648
674
  ## Editable diagrams — let the user redraw your mermaid
649
675
 
650
676
  Add `"editable": true` to any mermaid block. The user gets an "Edit diagram"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.4",
3
+ "version": "0.13.6",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -86,6 +86,7 @@ priority call in a `rank` question — not in paragraphs. Unsure which exists? R
86
86
  | `image` | screenshots / mockups / renders — zoom+pan; `pins:true` → click-to-drop point comments |
87
87
  | `compare` | a before/after pair — draggable divider |
88
88
  | `video` | a demo / screen recording / walkthrough |
89
+ | `pdf` | a quote / report / exported document that should render inline |
89
90
  | `palette` | color schemes — swatch cards, hover-hex, click-to-copy |
90
91
  | `typography` | type choices — specimens at given size/weight/font |
91
92
  | `html` | anything bespoke — custom widgets, pixel-perfect mockups |
@@ -145,6 +146,22 @@ Push-wake instead of polling: add --on-result '<shell cmd>' to ask/show/reopen
145
146
  (or --notify-cmd on wait) - the command runs the moment the board finishes,
146
147
  with the full result JSON on stdin and RLY_BOARD_ID/RLY_STATUS/RLY_URL in env.
147
148
 
149
+ **Codex browser-board pattern.** Codex does not have a normal, user-facing
150
+ "wake this agent turn from a browser submit" command. If the Codex turn stops
151
+ waiting, a later board submit can leave the user needing to prompt manually.
152
+ So in Codex, keep the waiter in the foreground until the user submits:
153
+
154
+ ```sh
155
+ rly ask --file spec.json --detach
156
+ rly wait b-xxxxx --timeout 1800 --while-active --idle-grace 300
157
+ ```
158
+
159
+ If `rly wait` exits with `wait-timeout`, immediately run `rly result <boardId>`.
160
+ If it is still open and the user may continue, run `rly wait` again. Do not use
161
+ `--on-result` as the primary Codex return path; it can write files or hit
162
+ webhooks, but normal Codex CLI sessions do not expose a portable inbound API
163
+ that wakes the current agent turn.
164
+
148
165
  Blocking mode (`rly ask --file spec.json --timeout 1800`, no --detach) is fine
149
166
  ONLY when your shell tool has no execution time limit.
150
167
 
@@ -270,6 +287,8 @@ single/multi question.
270
287
  { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
271
288
  // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
272
289
  // file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
290
+ { "type": "pdf", "src": "reports/quote.pdf", "title": "Quote", "height": 900 }
291
+ // ^ local .pdf files and http(s) PDF URLs render inline; local files stream, not embedded.
273
292
  { "type": "html", "html": "<p>hi</p>", "height": 360 }
274
293
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
275
294
  { "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
package/src/cli.js CHANGED
@@ -279,13 +279,12 @@ async function cmdDiff(rest) {
279
279
  return runOrDetach(record, args);
280
280
  }
281
281
 
282
- // `rly view <file.md> [more.md …]` — quick read-only board that renders one or
283
- // more markdown files (README, plan, report) with the built-in no-library
284
- // renderer. Each file becomes a markdown block; with 2+ files a small filename
285
- // heading separates them. Sugar over `rly show` with markdown mdFile blocks.
282
+ // `rly view <file.md> [more.md …]` — quick read-only board that renders files
283
+ // with purpose-built blocks (markdown, data tables, PDF). With 2+ files a small
284
+ // filename heading separates them. Sugar over `rly show`.
286
285
  async function cmdView(args) {
287
286
  const files = args._;
288
- if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json> [more …] [--title T] [--detach]');
287
+ if (!files.length) throw new CliError('usage: rly view <file.md|.csv|.json|.pdf> [more …] [--title T] [--detach]');
289
288
  const multi = files.length > 1;
290
289
  const DATA_EXT = new Set(['csv', 'tsv', 'json']);
291
290
  const blocks = [];
@@ -293,6 +292,7 @@ async function cmdView(args) {
293
292
  if (multi) blocks.push({ type: 'markdown', md: `## ${path.basename(f)}` });
294
293
  const ext = path.extname(f).slice(1).toLowerCase();
295
294
  if (DATA_EXT.has(ext)) blocks.push({ type: 'table', rowsFile: f, sortable: true, filterable: true, exportable: true });
295
+ else if (ext === 'pdf') blocks.push({ type: 'pdf', src: f, title: path.basename(f) });
296
296
  else blocks.push({ type: 'markdown', mdFile: f });
297
297
  }
298
298
  const raw = { blocks };
@@ -879,8 +879,8 @@ what you're about to output would look better than monospace text — or is a fi
879
879
  the user should view — put it in relay instead of printing it.**
880
880
 
881
881
  - **Markdown file / doc / README / plan / report** → \`rly view file.md\` (renders it
882
- in the browser, no library; \`.csv\`/\`.json\` → a sortable table). Never \`cat\` a
883
- doc into the terminal for the user to read.
882
+ in the browser, no library; \`.csv\`/\`.json\` → a sortable table; \`.pdf\` → an
883
+ inline PDF viewer). Never \`cat\` a doc into the terminal for the user to read.
884
884
  - **Image / screenshot / mockup / diagram / chart / table** → a relay board with an
885
885
  \`image\` block (or \`mermaid\`/\`graphviz\`/\`chart\`/\`table\`/\`kpi\`/\`compare\`). The
886
886
  terminal can't show pixels; relay can — with click-to-zoom and comment-in-place.
@@ -889,6 +889,15 @@ the user should view — put it in relay instead of printing it.**
889
889
  \`AskUserQuestion\` / ask-user tool. Build a spec and run
890
890
  \`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`. **Holds in plan
891
891
  mode too**; reserve \`ExitPlanMode\` for the final plan-approval gate only.
892
+ - **Codex-specific browser-board pattern:** Codex does not have a normal,
893
+ user-facing "wake this agent turn from a browser submit" command. In Codex,
894
+ keep the waiter in the foreground until the user submits:
895
+ \`rly ask --file spec.json --detach\` then
896
+ \`rly wait <boardId> --timeout 1800 --while-active --idle-grace 300\`.
897
+ If wait exits with \`wait-timeout\`, immediately run \`rly result <boardId>\`;
898
+ if it is still open and the user may continue, run \`rly wait\` again. Do not
899
+ use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
900
+ do not expose a portable inbound API that wakes the current agent turn.
892
901
  - **A plan, structure, architecture, data, or prototype** → a relay board with
893
902
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
894
903
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
@@ -899,7 +908,7 @@ the user should view — put it in relay instead of printing it.**
899
908
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
900
909
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
901
910
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
902
- \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`palette\`,
911
+ \`code\`, \`diff\`, \`image\` (+\`pins\`), \`compare\` (before/after), \`video\`, \`pdf\`, \`palette\`,
903
912
  \`typography\`, \`html\`. Question types: \`single\`/\`multi\`/\`yesno\`/\`scale\`/\`color\`/
904
913
  \`text\`/\`textarea\` plus \`rank\` (prioritize), \`allocate\` (split a budget), \`checklist\`
905
914
  (per-item sign-off). For a business user, reach for \`kpi\`+\`chart\`+\`table\` and
@@ -1385,7 +1394,8 @@ USAGE
1385
1394
  rly ask ... --on-result "<cmd>" push-wake: run <cmd> when the board finishes (result JSON on stdin)
1386
1395
  rly show --html-file viz.html visualization-only board (submit button = acknowledge)
1387
1396
  rly view <file.md> [more.md …] quick read-only board rendering markdown file(s) (no lib)
1388
- (.csv/.tsv/.json render as a filterable, sortable table)
1397
+ (.csv/.tsv/.json render as a filterable, sortable table;
1398
+ .pdf streams in an inline PDF viewer)
1389
1399
  rly diff [git args…] run git diff and show it in a diff board (--split, --detach,
1390
1400
  --title; other args pass to git: rly diff --staged | HEAD~1 | -- path)
1391
1401
  rly wait <id> [--timeout 3600] block until board finishes, print result JSON
@@ -4,8 +4,9 @@
4
4
  // host app (Claude desktop/mobile, Codex, …) as a sandboxed inline iframe.
5
5
  // There is no local HTTP server here: every exchange with the host travels over
6
6
  // JSON-RPC on window.postMessage — the spec arrives as the tool result, the
7
- // user's answers go back via `ui/update-model-context`, and vendored libraries
8
- // (Chart.js / Mermaid / Viz.js) are pulled through the host's `resources/read`.
7
+ // user's final submission goes back as a `ui/message` user turn, and vendored
8
+ // libraries (Chart.js / Mermaid / Viz.js) are pulled through the host's
9
+ // `resources/read`.
9
10
  //
10
11
  // Rich blocks are rendered by the SAME window.RelayBlocks as the browser board
11
12
  // (markdown, code, diff, table, chart, mermaid, graphviz, image, html), so the
@@ -957,19 +958,30 @@
957
958
  annotations: data.annotations,
958
959
  };
959
960
  const text = summarize(data);
960
- let delivered = false;
961
+ let messageDelivered = false;
962
+ let contextDelivered = false;
961
963
  try {
964
+ // A completed relay form is a user reply, not passive context. Some hosts
965
+ // ACK `ui/update-model-context` without starting a new model turn, so send
966
+ // the transcript as a user message first to wake the agent reliably.
967
+ await request('ui/message', { role: 'user', content: { type: 'text', text } });
968
+ messageDelivered = true;
969
+ } catch {
970
+ // Older/leaner hosts may not expose app-initiated messages.
971
+ }
972
+ try {
973
+ // Keep the structured payload available to hosts that attach app context.
974
+ // This is best-effort because context updates are intentionally silent.
962
975
  await request('ui/update-model-context', { content: [{ type: 'text', text }], structuredContent: structured });
963
- delivered = true;
976
+ contextDelivered = true;
964
977
  } catch {
965
- // Fallback for hosts without update-model-context: post a chat message.
966
- try { await request('ui/message', { role: 'user', content: { type: 'text', text } }); delivered = true; } catch { /* give up gracefully */ }
978
+ // If ui/message worked, the agent still receives the submission transcript.
967
979
  }
968
980
  submitted = true;
969
- showDone(delivered);
981
+ showDone(messageDelivered, contextDelivered);
970
982
  }
971
983
 
972
- function showDone(delivered) {
984
+ function showDone(messageDelivered, contextDelivered) {
973
985
  // Collapse: leave fullscreen, drop the whole form for a one-line confirmation
974
986
  // so the host shrinks the iframe to a small footprint in the transcript.
975
987
  if (Annotate) { try { Annotate.teardown(); } catch { /* nothing to tear down */ } }
@@ -977,8 +989,10 @@
977
989
  app.replaceChildren(el('div', { class: 'mcp-done' },
978
990
  el('span', { class: 'mark' }, '✓'),
979
991
  el('span', { class: 'lead' }, QS.length ? 'Submitted' : 'Acknowledged'),
980
- el('span', { class: 'sub' }, delivered
992
+ el('span', { class: 'sub' }, messageDelivered
981
993
  ? '· sent back to the agent'
994
+ : contextDelivered
995
+ ? '· saved; send the agent a message to continue'
982
996
  : '· tell the agent you’ve responded')));
983
997
  // Report the small height immediately, then again next frame / after layout
984
998
  // settles — beats hosts that only grow on debounced size events.
package/src/mcp.js CHANGED
@@ -8,8 +8,8 @@
8
8
  // • the tools `relay_ask` / `relay_show` link to it via _meta.ui.resourceUri
9
9
  // • calling a tool returns the (normalized) board spec as structuredContent;
10
10
  // the host renders the resource in a sandboxed iframe and forwards the spec
11
- // • the iframe collects the user's answers and sends them back to the model
12
- // via `ui/update-model-context`
11
+ // • the iframe collects the user's answers and sends them back as a user
12
+ // message; it also syncs model context when the host supports it
13
13
  //
14
14
  // Framing is the MCP stdio transport: newline-delimited JSON-RPC, one message
15
15
  // per line, never embedded newlines. stdout carries ONLY protocol messages;
@@ -156,7 +156,7 @@ function buildResult(method, params, clientProtocol) {
156
156
  },
157
157
  serverInfo: { name: 'relay', version: PKG.version },
158
158
  instructions:
159
- 'relay renders interactive boards inline. Call relay_ask to collect decisions/feedback with real form controls, or relay_show to present plans/diagrams/data — instead of asking in plain text. Read the user\'s answers from the structuredContent that returns after they submit.',
159
+ 'relay renders interactive boards inline. Call relay_ask to collect decisions/feedback with real form controls, or relay_show to present plans/diagrams/data — instead of asking in plain text. When the user submits, relay sends their answers back as a user message so the agent continues.',
160
160
  };
161
161
  case 'ping':
162
162
  return {};
@@ -198,9 +198,10 @@ function readResource(params) {
198
198
 
199
199
  // A tool call: normalize the spec and hand it to the host as structuredContent.
200
200
  // The host renders ui://relay/board and forwards this spec to the iframe, which
201
- // collects the answers and returns them via ui/update-model-context. Spec errors
202
- // come back as an isError tool result (not a protocol error) so the model can
203
- // see and fix them.
201
+ // collects the answers and returns them via a UI-originated user message (with
202
+ // ui/update-model-context as a best-effort context sync). Spec errors come back
203
+ // as an isError tool result (not a protocol error) so the model can see and fix
204
+ // them.
204
205
  function callTool(params) {
205
206
  const name = params && params.name;
206
207
  if (name !== 'relay_ask' && name !== 'relay_show') {
package/src/server.js CHANGED
@@ -40,7 +40,9 @@ function readUi(name) {
40
40
 
41
41
  // Strips block bodies for the client payload: html blocks ship only metadata
42
42
  // (their bodies are served via /html/b/<id>), embedded images ship only
43
- // metadata (bytes served via /img/b/<id>), everything else ships as-is.
43
+ // metadata (bytes served via /img/b/<id>), streamed local media ships only
44
+ // metadata (bytes served via /video/b/<id> or /pdf/b/<id>), everything else
45
+ // ships as-is.
44
46
  function clientBlock(b) {
45
47
  // Cross-block fields preserved when we ship metadata-only (ref = reference-link
46
48
  // target name; pins = image coordinate comments). The default `return b` path
@@ -58,6 +60,9 @@ function clientBlock(b) {
58
60
  if (b && b.type === 'video' && typeof b.file === 'string') {
59
61
  return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
60
62
  }
63
+ if (b && b.type === 'pdf' && typeof b.file === 'string') {
64
+ return { id: b.id, type: 'pdf', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
65
+ }
61
66
  return b;
62
67
  }
63
68
 
@@ -619,6 +624,12 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
619
624
  const block = findBlock(record.spec, blockId, 'video');
620
625
  if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local video block "${blockId}"` });
621
626
  streamFile(req, res, block.file, block.mime || 'application/octet-stream');
627
+ } else if ((req.method === 'GET' || req.method === 'HEAD') && pathname.startsWith('/pdf/b/')) {
628
+ // Local PDF bytes, Range-streamed for the browser's built-in PDF viewer.
629
+ const blockId = decodeURIComponent(pathname.slice('/pdf/b/'.length));
630
+ const block = findBlock(record.spec, blockId, 'pdf');
631
+ if (!block || typeof block.file !== 'string') return sendJson(res, 404, { error: `no local pdf block "${blockId}"` });
632
+ streamFile(req, res, block.file, block.mime || 'application/pdf');
622
633
  } else if (req.method === 'GET' && pathname === '/html/board') {
623
634
  // Legacy alias → the board's first html block.
624
635
  const block = firstBoardHtml(record.spec);
package/src/spec.js CHANGED
@@ -39,7 +39,7 @@ const HTML_HEIGHT = { min: 100, max: 2400, boardDefault: 400, questionDefault: 3
39
39
 
40
40
  // Block heights clamp to the same window; defaults vary per block type.
41
41
  const BLOCK_HEIGHT = { min: 100, max: 2400 };
42
- export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
42
+ export const BLOCK_TYPES = ['markdown', 'mermaid', 'graphviz', 'plantuml', 'chart', 'table', 'code', 'diff', 'video', 'pdf', 'html', 'image', 'palette', 'kpi', 'typography', 'compare'];
43
43
  const CHART_KINDS = ['bar', 'line', 'pie', 'doughnut', 'radar', 'scatter'];
44
44
 
45
45
  // code/diff blocks may load their text from a local file (like htmlFile). Caps
@@ -55,6 +55,9 @@ const VIDEO_MIMES = {
55
55
  };
56
56
  const VIDEO_MAX_BYTES = 512 * 1024 * 1024;
57
57
 
58
+ const PDF_MIME = 'application/pdf';
59
+ const PDF_MAX_BYTES = 512 * 1024 * 1024;
60
+
58
61
  // image blocks: local files are embedded as data URIs at spec time (the page
59
62
  // then loads them via /img/b/<id>), so boards stay self-contained offline.
60
63
  const IMAGE_MIMES = {
@@ -343,6 +346,37 @@ function normalizeBlock(rawBlock, id, cwd, where) {
343
346
  return block;
344
347
  }
345
348
 
349
+ if (type === 'pdf') {
350
+ const src = asStr(rawBlock.src ?? rawBlock.file ?? rawBlock.url).trim();
351
+ if (!src) throw new CliError(`${where}: pdf block needs a "src" (http(s) PDF URL or local .pdf file path).`);
352
+ const block = { id, type: 'pdf' };
353
+ if (rawBlock.title !== undefined) block.title = asStr(rawBlock.title);
354
+ if (rawBlock.alt !== undefined && block.title === undefined) block.title = asStr(rawBlock.alt);
355
+ if (hasHeight) block.height = clampInt(rawBlock.height, BLOCK_HEIGHT.min, BLOCK_HEIGHT.max, undefined);
356
+ if (/^https?:/i.test(src)) {
357
+ block.src = src;
358
+ block.mime = PDF_MIME;
359
+ if (!block.title) block.title = path.basename(src.split(/[?#]/)[0]) || 'PDF';
360
+ return block;
361
+ }
362
+ const p = path.resolve(cwd, src);
363
+ const ext = path.extname(p).slice(1).toLowerCase();
364
+ if (ext !== 'pdf') throw new CliError(`${where}: unsupported pdf extension ".${ext}" — use a local .pdf file or an http(s) PDF URL.`);
365
+ let stat;
366
+ try {
367
+ stat = fs.statSync(p);
368
+ } catch {
369
+ throw new CliError(`${where}: cannot read pdf "${src}" (resolved: ${p})`);
370
+ }
371
+ if (stat.size > PDF_MAX_BYTES) {
372
+ throw new CliError(`${where}: pdf "${src}" is ${(stat.size / 1024 / 1024).toFixed(0)}MB — max ${PDF_MAX_BYTES / 1024 / 1024}MB.`);
373
+ }
374
+ block.file = p;
375
+ block.mime = PDF_MIME;
376
+ if (!block.title) block.title = path.basename(p);
377
+ return block;
378
+ }
379
+
346
380
  if (type === 'chart') {
347
381
  const hasConfig = rawBlock.config && typeof rawBlock.config === 'object' && !Array.isArray(rawBlock.config);
348
382
  const hasShorthand =
package/src/ui/blocks.css CHANGED
@@ -278,6 +278,14 @@
278
278
  .blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
279
279
  .blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
280
280
 
281
+ /* ---------- PDF (local stream / direct URL) ---------- */
282
+ .blk-pdfwrap { margin: 0; }
283
+ .blk-pdf {
284
+ display: block; width: 100%; height: min(900px, 78vh); min-height: 420px;
285
+ border: 0; border-radius: 10px; background: var(--card);
286
+ }
287
+ .blk-pdfwrap.blk-full .blk-pdf { height: calc(100vh - 92px); min-height: 0; }
288
+
281
289
  /* ---------- table ---------- */
282
290
  .blk-table {
283
291
  width: 100%;
package/src/ui/blocks.js CHANGED
@@ -692,6 +692,27 @@
692
692
  return wrap;
693
693
  }
694
694
 
695
+ // ---------- PDF (local stream or direct URL) ----------
696
+ function renderPdf(block, ctx, blockId) {
697
+ const wrap = el('div', { class: 'blk-pdfwrap' });
698
+ const src = block.hasFile ? '/pdf/b/' + encodeURIComponent(blockId) : block.src;
699
+ const title = block.title || 'PDF';
700
+ const frame = el('iframe', {
701
+ class: 'blk-pdf',
702
+ src,
703
+ title,
704
+ loading: 'lazy',
705
+ });
706
+ if (block.height != null) frame.style.height = clampHeight(block.height, 900) + 'px';
707
+ frame.addEventListener('error', () => {
708
+ wrap.replaceChildren(el('div', { class: 'blk-error' }, 'PDF failed to load'));
709
+ });
710
+ wrap.append(frame);
711
+ if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
712
+ attachViewer(wrap, { zoomEl: null, label: 'pdf', comment: wholeBlockComment(ctx, blockId, 'pdf') });
713
+ return wrap;
714
+ }
715
+
695
716
  // ---------- table ----------
696
717
  function normalizeColumns(columns) {
697
718
  return (columns || []).map((c, idx) => {
@@ -2199,6 +2220,10 @@
2199
2220
  inner = renderVideo(block, ctx, blockId);
2200
2221
  wrapper.append(inner);
2201
2222
  break;
2223
+ case 'pdf':
2224
+ inner = renderPdf(block, ctx, blockId);
2225
+ wrapper.append(inner);
2226
+ break;
2202
2227
  case 'chart':
2203
2228
  inner = renderChart(block, ctx, blockId);
2204
2229
  wrapper.append(inner);