@khanglvm/relay 0.10.0 → 0.10.2

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
@@ -35,7 +35,8 @@ That's it. Next time your agent needs a decision or wants to show you a plan,
35
35
  it opens a board like the ones above and waits for your Submit.
36
36
 
37
37
  Keep relay current with **`rly upgrade`** — it installs the latest CLI and
38
- refreshes the skill in one step, leaving any boards you have open untouched.
38
+ refreshes the skill (via `npx skills`, falling back to the bundled copy) in one
39
+ step, leaving any boards you have open untouched.
39
40
 
40
41
  ## What it improves
41
42
 
package/docs/AGENT.md CHANGED
@@ -11,6 +11,22 @@ leave comments, and select text in markdown blocks to annotate — their comment
11
11
  come back in `result.annotations` alongside their answers. Treat annotations as
12
12
  first-class feedback.
13
13
 
14
+ **Read ALL four feedback channels, not just `answers`.** A result carries the
15
+ user's input across **four** places, each first-class — never act on `answers`
16
+ alone:
17
+
18
+ - `answers` — the per-question values (what they picked/typed).
19
+ - `notes` — per-question free-text notes (`result.notes[questionId]`): the small
20
+ note box under a question, where users qualify or override a pick. **Single
21
+ (radio) questions show this box by default**, so it is a very common place for
22
+ the real reasoning to land. Easy to miss because empty notes are omitted —
23
+ **always inspect `result.notes`.**
24
+ - `comment` — one board-level free-text note ("Anything else?").
25
+ - `annotations` — element-level inline comments on specific blocks/data points.
26
+
27
+ If any of `notes`, `comment`, or `annotations` is non-empty, it can override or
28
+ contradict an `answers` value — reconcile them before generating output.
29
+
14
30
  Everything machine-relevant is on **stdout as JSON**; human-facing logs go to
15
31
  stderr. Exit codes: `0` submitted/acknowledged · `2` timeout · `3` cancelled ·
16
32
  `4` usage error · `5` not found.
@@ -137,12 +153,18 @@ boolean/bool/yn→yesno, input→text, longtext→textarea, rating/likert→scal
137
153
  ```
138
154
 
139
155
  Unanswered questions are absent from `answers` and listed in `skipped`.
140
- Questions with `"note": true` show a small optional free-text field; non-empty
141
- notes come back in `notes` keyed by question id. **`single` (radio) questions
142
- show this note by default** so the user can qualify their pick set
143
- `"note": false` to hide it. On `timeout`/`cancelled`, a
144
- `draft` field carries the autosaved partial answers and any annotations written
145
- so far.
156
+
157
+ `notes` (a `{ questionId: "text" }` map) is **always present** in a result —
158
+ `{}` when empty so you can never miss that the channel exists; iterate it
159
+ even when you only expected `answers`. Questions with `"note": true` show a
160
+ small optional free-text field, and only **non-empty** notes appear as keys.
161
+ **`single` (radio) questions show this note by default** so the user can
162
+ qualify or override their pick — set `"note": false` to hide it. A note like
163
+ `notes.approach = "actually B, not A"` is the user's real intent and outranks
164
+ the `answers.approach` radio value — always check `notes` before acting.
165
+
166
+ On `timeout`/`cancelled`, a `draft` field carries the autosaved partial
167
+ answers, notes, and any annotations written so far.
146
168
 
147
169
  ## Blocks
148
170
 
@@ -277,7 +299,10 @@ Rules of thumb:
277
299
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
278
300
 
279
301
  // Image — local file path (embedded at spec time, works offline), http(s) URL,
280
- // or data URI. "height" caps the displayed height; zoom/full-screen included.
302
+ // or data URI. "height" sets only the COMPACT inline preview — every image keeps
303
+ // a full-screen + zoom (⌘/Ctrl+wheel or −/+, up to 8× native) + drag-to-pan
304
+ // viewer, so a small height never hides detail. Local images embed up to 8 MB;
305
+ // for a huge / high-resolution image pass an http(s) URL (streamed, no size cap).
281
306
  { "type": "image", "src": "screenshots/variant-a.png", "alt": "Variant A", "height": 220 }
282
307
  { "type": "image", "src": "https://example.com/mock.png" }
283
308
  ```
@@ -314,7 +339,7 @@ real path over telling the user to paste it into a terminal.
314
339
  - `mermaid`: natural flow, max-height 1200 px with internal scroll. Override with `"height"`.
315
340
  - `chart`: default 320 px. Override with `"height"`.
316
341
  - `html`: default 360 px. Override with `"height"`.
317
- - `image`: natural size (never upscaled), max-height 1200 px with scroll. `"height"` caps it.
342
+ - `image`: natural size (never upscaled), max-height 1200 px with scroll. `"height"` caps the **inline preview only** — full-screen + zoom (up to 8× native) always reach full detail. Local images embed up to 8 MB; use an http(s) URL for larger.
318
343
  - `table`: natural flow.
319
344
  - All heights clamp to 100–2400 px.
320
345
  - Inside OPTION cards, always set a compact `"height"` (~140–260) on
@@ -538,13 +563,23 @@ call rather than calling it repeatedly in a loop.
538
563
  check whether the user has started answering.
539
564
  - In the board `intro`, tell users they can hover chart points / select text to
540
565
  leave inline comments — they won't discover it otherwise.
541
- - Check `result.annotations` before generating your next output; a comment on a
542
- specific data point or a quoted sentence often overrides the checkbox answer.
566
+ - Check `result.notes` AND `result.annotations` before generating your next
567
+ output not just `answers`. A per-question note (e.g. `notes.scope = "docs can
568
+ wait"`) or a comment on a specific data point / quoted sentence often qualifies
569
+ or overrides the checkbox answer. `notes` is always present (`{}` when empty),
570
+ so iterate it every time.
543
571
  - Use `rly reopen <id> --replies replies.json` to answer the user's element
544
572
  comments and reopen the board as a conversation thread.
545
573
  - Use `rly update <id>` to push spec changes to a running board — the page
546
574
  reloads and answers survive via draft autosave. Batch updates; do not spam.
575
+ - When the user asks to *see* code changes — "show me the diff", "show me git
576
+ diff", "review these changes" — capture `git diff` (or `git show <sha>`) and
577
+ render it in a `diff` block instead of printing it to the terminal; for a
578
+ brand-new file use a `code` block. Point them at a file to inspect with a
579
+ clickable local path in markdown.
547
580
  - For sensitive PlantUML diagrams, set `"server": "https://your-server"` to avoid
548
581
  sending source to the public plantuml.com server.
549
582
  - Bundled universal skill (Claude Code, Codex, any SKILL.md-aware agent):
550
583
  `rly skill install` — or `npx skills add khanglvm/relay --skill relay --all`.
584
+ `rly upgrade` refreshes the CLI **and** the skill (via npx skills, falling back
585
+ to the bundled copy) in one step.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Browser-based 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, wait for submit, read answers as JSON.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relay
3
- description: "The tool for collecting user requirements, decisions, and answers (choice, yes-no, text, scale questions) and for presenting prototypes, plans, structures, or reports with rich visuals - mermaid/graphviz/plantuml diagrams, charts, sortable tables, custom HTML - plus inline comments on any element. Opens a browser board, waits for Submit, returns JSON answers, comments, and edited diagrams. Use PROACTIVELY instead of (a) native ask-user tools for 2+ answers or options needing explanation, (b) ASCII trees/tables/diagrams in the terminal or prose descriptions of structures/designs/plans, (c) hand-rolled HTML demos. Triggers: collect requirements, ask the user, get decisions/feedback, present a prototype, plan approval, design review, show me the structure, repo/folder structure, file tree, codebase map, architecture overview, dependency graph, visualize, diagram, chart, data table, compare alternatives, survey, which do you prefer, let the user edit the diagram. Skip for a single trivial yes/no confirmation."
3
+ description: "The tool for collecting user requirements, decisions, and answers (choice, yes-no, text, scale) and for presenting prototypes, plans, structures, code changes, or reports with rich visuals - mermaid/graphviz/plantuml diagrams, charts, tables, code, diffs, video, custom HTML, clickable file-links - plus inline comments on any element. Opens a browser board, waits for Submit, returns JSON answers, per-question notes, comments, and annotations. Use PROACTIVELY instead of (a) native ask-user tools for 2+ answers or options needing explanation, (b) ASCII trees/tables/diagrams in the terminal or prose for structures/designs/plans, (c) hand-rolled HTML demos. Triggers: collect requirements, ask the user, get decisions/feedback, present a prototype, plan/design review, show me the structure/file tree, architecture or dependency graph, visualize, diagram, chart, table, compare alternatives, survey, edit the diagram, show me the diff / git diff, video walkthrough, open a file. Skip for a single yes/no confirmation."
4
4
  ---
5
5
 
6
6
  # relay (`rly`)
@@ -28,6 +28,9 @@ Schema).** The essentials are below.
28
28
  | Present a prototype / demo an idea | **rly show** — never hand-roll an HTML file + server |
29
29
  | Gather requirements / plan approval / feedback round | **rly** |
30
30
  | Architecture or flow that benefits from a diagram | **rly** (mermaid block) |
31
+ | "Show me the diff" / git diff / code changes / before-after | **rly** (`diff` block — run `git diff`, render it; never dump it in the terminal) |
32
+ | A demo, screen recording or walkthrough | **rly** (`video` block) |
33
+ | Point the user at a file to open (log, capture, report) | **rly** (a clickable local file-link in markdown) |
31
34
  | Something you can decide yourself from context | neither — just decide |
32
35
 
33
36
  Once the user has answered one board in a session, prefer boards for later
@@ -119,11 +122,25 @@ single/multi question.
119
122
  { "type": "table", "columns": ["A","B"], "rows": [["x","y"]], "sortable": true }
120
123
  // ^ use a `table` block for tabular data — sortable + per-cell comments.
121
124
  // (markdown blocks render GFM pipe tables too, but those are display-only.)
122
- { "type": "code", "lang": "js", "code": "const x = 1;" }
125
+ { "type": "code", "lang": "js", "code": "const x = 1;", "filename": "demo.js" }
126
+ { "type": "code", "codeFile": "src/server.js" } // load text from a local file
127
+ { "type": "diff", "lang": "js", "filename": "src/auth.js", "view": "split",
128
+ "diff": "@@ -1,3 +1,3 @@\n ctx\n-old line\n+new line\n ctx" }
129
+ // ^ a unified / `git diff` text rendered as a colored, line-numbered comparison
130
+ // (no git needed — just paste the diff). "view":"split" = side-by-side; the
131
+ // viewer also has a live Unified⇄Split toggle. "diffFile" loads it from a file.
132
+ { "type": "video", "src": "https://youtu.be/dQw4w9WgXcQ", "title": "Demo walkthrough" }
133
+ { "type": "video", "src": "recordings/demo.mp4", "title": "Local capture", "height": 360 }
134
+ // ^ YouTube/Vimeo URL embeds a player; an http(s) media URL or a local video
135
+ // file (mp4/webm/ogv/mov/mkv/m4v) plays inline (local files stream, not embedded).
123
136
  { "type": "html", "html": "<p>hi</p>", "height": 360 }
124
137
  { "type": "html", "htmlFile": "viz.html", "height": 400 }
125
138
  { "type": "image", "src": "screenshot.png" } // local file, URL, or data URI
126
139
  { "type": "image", "src": "https://…/mock.png", "alt": "Mockup B", "height": 220 }
140
+ // ^ `height` only sets the COMPACT inline preview — every image keeps a full-
141
+ // screen + zoom (⌘/Ctrl+wheel or −/+, up to 8× native) + drag-to-pan viewer,
142
+ // so detail is always reachable regardless of height. Local images embed up
143
+ // to 8 MB; for a huge/high-detail image pass an http(s) URL (streamed, no cap).
127
144
  ```
128
145
 
129
146
  ### Visual options — show each choice, don't describe it
@@ -154,6 +171,15 @@ stays dependency-free. PlantUML uses the public plantuml.com server by default;
154
171
  pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
155
172
  `"htmlHeight"` on root or questions are still accepted and normalised automatically.
156
173
 
174
+ ### Local file links — clickable, open in the default app
175
+
176
+ Write a local path in any markdown (the `intro` or a `markdown` block) — `~/clip.mp4`,
177
+ `./src/app.ts`, `/abs/report.pdf`, a `file://` URL, or a backtick-wrapped path — and it
178
+ renders as a click-to-open link that opens the file in the user's OS default app
179
+ (editor, video player, viewer …); `[label](~/path)` works too. Only paths you actually
180
+ wrote on the board can be opened (same-origin + allowlist guarded). Surface a real
181
+ clickable path instead of telling the user to paste it into a terminal.
182
+
157
183
  ## Annotations
158
184
 
159
185
  Users can hover chart points, diagram nodes (mermaid + graphviz), table cells,
@@ -181,6 +207,23 @@ mention annotation in the board intro.
181
207
  Read annotations before generating your next output — a comment on a specific
182
208
  data point often carries sharper signal than a checkbox answer.
183
209
 
210
+ ## Reading the result — four feedback channels
211
+
212
+ A result is more than `answers`. **Always read all four** — never act on
213
+ `answers` alone; the user's real intent often lives in the others:
214
+
215
+ | field | what it is |
216
+ |---|---|
217
+ | `answers` | per-question values `{ questionId: value }` (skipped ones absent, listed in `skipped`) |
218
+ | `notes` | **per-question free-text notes** `{ questionId: "text" }` — the note box under a question. **Always present (`{}` when empty)**; iterate it every time. `single` (radio) questions show this box by default, so it's a very common place for the user's reasoning — and easy to miss. |
219
+ | `comment` | one board-level free-text note ("Anything else?") |
220
+ | `annotations` | element-level inline comments (array; see below) |
221
+
222
+ A `notes[questionId]`, a `comment`, or an `annotation` can qualify or override
223
+ the matching `answers` value (e.g. `answers.approach = "a"` but
224
+ `notes.approach = "actually B"` → the user means B). Reconcile them before
225
+ generating output. The same channels appear under `draft` on timeout/cancel.
226
+
184
227
  ### Reply to annotations (agent → user conversation)
185
228
 
186
229
  ```sh
@@ -210,6 +253,14 @@ urgency, `textarea` for constraints.
210
253
  `html`/`image` block rendering that variant (see Visual options above); `scale`
211
254
  for confidence; `textarea` for what's missing from both.
212
255
 
256
+ **Show a git diff / code changes** — when the user says "show me the diff" /
257
+ "show me git diff" / "review these changes": capture `git diff` (or `git diff
258
+ <ref>`, `git show <sha>`) and present it in a `diff` block — set `"view":
259
+ "split"` for side-by-side — instead of dumping it in the terminal. Pair it with
260
+ a `yesno` "Apply these changes?" and a `textarea` for feedback; users can select
261
+ diff text to comment on a specific line. For a brand-new file prefer a `code` block; for a
262
+ recorded walkthrough of the change add a `video` block.
263
+
213
264
  **Metrics review** — board-level `chart` block (bar or line) showing the key
214
265
  numbers, followed by a `table` block for the raw data; at least one question
215
266
  asking what to act on. In the intro, tell the user they can click chart points
package/src/cli.js CHANGED
@@ -773,6 +773,25 @@ function removeLegacySkills() {
773
773
  return removed;
774
774
  }
775
775
 
776
+ // Stamps the current CLI version into every installed skill dir's .rly-version.
777
+ // `rly skill install` writes this itself, but an `npx skills add` refresh does
778
+ // not — so after any refresh we re-stamp the dirs that now have a SKILL.md,
779
+ // keeping skillFreshnessWarning() from nagging about a just-refreshed skill.
780
+ function stampSkillVersion() {
781
+ const stamped = [];
782
+ for (const dir of Object.values(KNOWN_SKILL_DIRS())) {
783
+ try {
784
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
785
+ fs.writeFileSync(path.join(dir, '.rly-version'), VERSION);
786
+ stamped.push(dir);
787
+ }
788
+ } catch {
789
+ // best effort — a marker we couldn't write just re-triggers the nudge
790
+ }
791
+ }
792
+ return stamped;
793
+ }
794
+
776
795
  // Short instruction block for an agent's main instruction file (CLAUDE.md,
777
796
  // AGENTS.md, …). A skill is only a tier-1 hint that agents can ignore; these
778
797
  // rules, appended to the instructions the agent always reads, enforce usage.
@@ -783,8 +802,12 @@ const SKILL_RULES = `## relay — ask & show in the browser, not the terminal
783
802
  beyond one trivial yes/no)? Do NOT ask in plain terminal text. Build a board
784
803
  spec and run: \`rly ask --file spec.json --detach\`, then \`rly wait <boardId>\`.
785
804
  - Presenting a plan, structure, architecture, data, or a prototype? Show a
786
- relay board with mermaid/graphviz/chart/table/image/html blocks — never
787
- ASCII diagrams or walls of prose.
805
+ relay board with mermaid/graphviz/chart/table/code/diff/video/image/html
806
+ blocks — never ASCII diagrams or walls of prose.
807
+ - "Show me the diff / git diff / these changes"? Run \`git diff\` (or \`git show\`)
808
+ and render the output in a \`diff\` block — never paste a raw diff in the
809
+ terminal. Point the user at a file with a clickable local path in a markdown
810
+ block, and embed a screen recording with a \`video\` block.
788
811
  - When answer choices are visual (designs, layouts, variants), give each
789
812
  option its own visual (\`options[].blocks\`) so the user picks by looking.
790
813
  - Read the result JSON from stdout; treat \`comment\` and \`annotations\` as
@@ -1031,8 +1054,9 @@ function cmdAgent() {
1031
1054
  return 0;
1032
1055
  }
1033
1056
 
1034
- // `rly upgrade` — install the latest CLI globally AND refresh the bundled skill
1035
- // in one shot. (`update` is taken by the live-mutate command, so this is
1057
+ // `rly upgrade` — install the latest CLI globally AND refresh the skill (via the
1058
+ // npx-skills package manager, falling back to the bundled copy) in one shot.
1059
+ // (`update` is taken by the live-mutate command, so this is
1036
1060
  // `upgrade` / `self-update`.) Running boards are surfaced and handled: a global
1037
1061
  // reinstall overwrites relay's files, but live detached servers snapshot their
1038
1062
  // UI at first request and serve from memory, so they keep working on their own
@@ -1051,7 +1075,7 @@ async function cmdUpgrade(args) {
1051
1075
  if (args.dryRun === true) {
1052
1076
  printJson({
1053
1077
  dryRun: true,
1054
- wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'rly skill install'].filter(Boolean),
1078
+ wouldRun: [wantCli && `npm install -g ${PKG_NAME}@latest`, wantSkill && 'npx skills add khanglvm/relay --skill relay --all'].filter(Boolean),
1055
1079
  runningBoards: running.map((r) => r.id),
1056
1080
  runningHandling: running.length
1057
1081
  ? doStop
@@ -1108,17 +1132,30 @@ async function cmdUpgrade(args) {
1108
1132
  }
1109
1133
 
1110
1134
  if (wantSkill) {
1111
- // Spawn the freshly installed binary (on PATH) so the NEW bundled skill is
1112
- // what lands this process still holds the previous bundle in memory.
1113
- process.stderr.write('\nRefreshing the bundled skill (rly skill install)\n');
1114
- const r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
1135
+ // Refresh the skill through the npx-skills package manager the same channel
1136
+ // the skill is normally managed by (skills-lock.json, ~/.agents, ~/.claude, …)
1137
+ // — so the global install stays in sync and pulls the freshest skill for
1138
+ // khanglvm/relay. `npx -y` so the one-off download needs no prompt.
1139
+ process.stderr.write('\nRefreshing the relay skill (npx skills add khanglvm/relay --skill relay --all)\n');
1140
+ let r = spawnSync('npx', ['-y', 'skills', 'add', 'khanglvm/relay', '--skill', 'relay', '--all'], { stdio: 'inherit', shell: true });
1141
+ let how = 'npx-skills';
1142
+ if (r.error || r.status !== 0) {
1143
+ // Fallback: install the skill bundled with the CLI we just upgraded. Works
1144
+ // offline / when the skills CLI is unreachable, and matches this version.
1145
+ process.stderr.write('\nnpx skills unavailable — falling back to the bundled skill (rly skill install)\n');
1146
+ r = spawnSync('rly', ['skill', 'install'], { stdio: 'inherit', shell: true });
1147
+ how = 'bundled';
1148
+ }
1115
1149
  if (r.error || r.status !== 0) {
1116
1150
  process.stderr.write(
1117
1151
  `Skill refresh did not complete${r.error ? ` (${r.error.message})` : ` (exit ${r.status})`} — ` +
1118
- 'run `rly skill install` yourself (or `npx skills add khanglvm/relay --skill relay --all`).\n'
1152
+ 'run `npx skills add khanglvm/relay --skill relay --all` (or `rly skill install`) yourself.\n'
1119
1153
  );
1120
1154
  } else {
1121
- did.skill = 'installed';
1155
+ // Keep the freshness marker accurate however the skill landed, so the next
1156
+ // `rly` run doesn't nag that the just-refreshed skill is stale.
1157
+ stampSkillVersion();
1158
+ did.skill = how;
1122
1159
  }
1123
1160
  }
1124
1161
 
package/src/server.js CHANGED
@@ -740,7 +740,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
740
740
  answers: partial.answers ?? null,
741
741
  skipped: partial.skipped ?? null,
742
742
  comment: partial.comment ?? '',
743
- notes: partial.notes ?? null,
743
+ // Per-question notes are first-class feedback. Always emit the key (even
744
+ // empty {}) so a consumer can SEE it exists and never silently misses a
745
+ // note the user left under a question. Falls back to the autosaved draft
746
+ // on timeout/cancel.
747
+ notes: partial.notes ?? (record.draft?.notes || {}),
744
748
  annotations: partial.annotations ?? (record.draft?.annotations || []),
745
749
  blockEdits,
746
750
  createdAt: record.createdAt,
package/src/ui/blocks.css CHANGED
@@ -440,6 +440,10 @@
440
440
  object-fit: contain;
441
441
  object-position: left top;
442
442
  }
443
+ /* In full-screen a per-block height must never keep the image small — that
444
+ height only shapes the inline preview; the detail view needs every pixel.
445
+ (Belt-and-suspenders alongside the viewer's reapply() on full-screen toggle.) */
446
+ .blk-imagewrap.blk-full .blk-img { max-height: none !important; }
443
447
 
444
448
  /* ---------- viewer controls (full-screen) ---------- */
445
449
  /* The full-screen button lives in a reserved strip ABOVE the visual (user
package/src/ui/blocks.js CHANGED
@@ -1398,7 +1398,10 @@
1398
1398
  alt: block.alt || 'image',
1399
1399
  loading: 'lazy',
1400
1400
  });
1401
- if (block.height) img.style.maxHeight = clampHeight(block.height, 360) + 'px';
1401
+ // The per-block height only caps the COMPACT inline preview — the viewer
1402
+ // lifts it on zoom / full-screen so the user can always reach full detail.
1403
+ const fitMaxHeight = block.height ? clampHeight(block.height, 360) : null;
1404
+ if (fitMaxHeight) img.style.maxHeight = fitMaxHeight + 'px';
1402
1405
  img.addEventListener('error', () => {
1403
1406
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1404
1407
  });
@@ -1409,6 +1412,7 @@
1409
1412
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1410
1413
  label: 'image',
1411
1414
  comment: wholeBlockComment(ctx, blockId, 'image'),
1415
+ fitMaxHeight,
1412
1416
  });
1413
1417
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1414
1418
  else img.addEventListener('load', attachImgViewer, { once: true });
@@ -1450,6 +1454,7 @@
1450
1454
  fullOpen = null;
1451
1455
  window.dispatchEvent(new Event('resize'));
1452
1456
  if (c._rlyToolsSync) c._rlyToolsSync(); // re-pin toolbar to its scrolled corner
1457
+ if (c._rlyZoom) c._rlyZoom.reapply(); // restore the compact inline height cap
1453
1458
  }
1454
1459
  document.addEventListener('keydown', (e) => {
1455
1460
  if (e.key === 'Escape' && fullOpen) exitFull();
@@ -1569,6 +1574,13 @@
1569
1574
  const target = opts.zoomEl;
1570
1575
  const nat = opts.natural();
1571
1576
  const z = container._rlyZ;
1577
+ // A per-block height caps only the compact inline preview. The moment the
1578
+ // user zooms in (z !== null) or goes full-screen they want pixel detail, so
1579
+ // lift the cap there; restore it when back to the inline fit view.
1580
+ if (opts.fitMaxHeight != null) {
1581
+ const fit = z === null && !container.classList.contains('blk-full');
1582
+ target.style.maxHeight = fit ? opts.fitMaxHeight + 'px' : 'none';
1583
+ }
1572
1584
  if (z === null || !nat || !nat.w) {
1573
1585
  target.style.width = '100%';
1574
1586
  target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
@@ -1592,10 +1604,10 @@
1592
1604
  return shown > 0 ? shown / nat.w : 1;
1593
1605
  }
1594
1606
  function setZoom(next) {
1595
- container._rlyZ = next === null ? null : Math.min(5, Math.max(0.2, next));
1607
+ container._rlyZ = next === null ? null : Math.min(8, Math.max(0.2, next));
1596
1608
  apply();
1597
1609
  }
1598
- container._rlyZoom = { setZoom, currentZ };
1610
+ container._rlyZoom = { setZoom, currentZ, reapply: apply };
1599
1611
 
1600
1612
  const tools = el('div', { class: 'blk-tools' });
1601
1613
 
@@ -1652,6 +1664,7 @@
1652
1664
  fullBtn.textContent = '✕';
1653
1665
  window.dispatchEvent(new Event('resize'));
1654
1666
  container._rlyToolsSync();
1667
+ if (container._rlyZoom) container._rlyZoom.reapply(); // lift any inline height cap
1655
1668
  });
1656
1669
  tools.append(fullBtn);
1657
1670