lavish-axi 0.1.35 → 0.1.37

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
@@ -31,10 +31,10 @@ HTML is the new markdown. Lavish is the new editor for your HTML artifacts.
31
31
  Agents are good at producing rich HTML artifacts, but the human-agent collaboration loop on such artifacts is lacking and falls back into screenshots and long responses for “tell me what to change.”
32
32
  That loses the thing HTML is best at: interactivity.
33
33
 
34
- Lavish Editor opens agent-generated HTML files in a local browser, lets you pinpoint elements or selected text and send feedback to the agent to address.
34
+ Lavish Editor opens agent-generated HTML files in a local browser, lets you pinpoint elements, selected text, or Mermaid diagram nodes and send feedback to the agent to address.
35
35
 
36
36
  - **Local-first** - Review local HTML artifacts with a local CLI and no cloud dependency in the core feedback loop; hosted sharing through third-party ht-ml.app is explicit and opt-in.
37
- - **Human-AI collaboration** - Annotate elements, selected text ranges, and send messages to the agent without leaving Lavish Editor.
37
+ - **Human-AI collaboration** - Annotate elements, selected text ranges, and Mermaid diagram nodes, and send messages to the agent without leaving Lavish Editor.
38
38
  - **Battery included** - Lavish Editor teaches your agent good visualization for common use cases such as product or technical plans, design explorations and more out of the box.
39
39
 
40
40
  Lavish Editor is an [AXI](https://axi.md), which means -
@@ -54,6 +54,8 @@ npx skills add kunchenguid/lavish-axi --skill lavish
54
54
  That is the entire setup - no npm install needed.
55
55
  The skill teaches your agent to run Lavish through `npx -y lavish-axi`, so the CLI comes along on demand.
56
56
  Its frontmatter also includes Hermes Agent metadata, so Hermes-compatible harnesses can categorize and surface it as a first-class productivity skill.
57
+ This installs the public `lavish` skill.
58
+ The repository also contains an internal `lavish-design` brand skill for maintainers; default `npx skills add ... --list` and skills.sh discovery hide it unless `INSTALL_INTERNAL_SKILLS=1` is set.
57
59
 
58
60
  Then, in agents that expose skills as slash commands (Claude Code, for example), invoke it directly:
59
61
 
@@ -155,15 +157,18 @@ pnpm link
155
157
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
156
158
  For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`.
157
159
  Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other.
160
+ Queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen.
158
161
  The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session) and can submit queued prompts with **Send & end session**, which sends the prompts and user-ended attribution together.
159
162
  - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline.
160
163
  In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately.
164
+ Cmd+I or Ctrl+I toggles between annotate and explore mode from either the browser chrome or the artifact iframe, including while focus is in a textarea or control.
161
165
  - **Agent presence** - The browser shows when no agent is listening, keeps queued feedback and fresh layout warnings for the next successful `lavish-axi poll` send even across reloads, and only blocks human sends while the agent is working on delivered feedback. The no-timeout poll writes an immediate stderr banner and periodic stderr heartbeats while stdout stays reserved for the final response; if the poll is interrupted or times out, re-run it because queued feedback is never lost.
162
166
  - **Session end etiquette** - Lavish tracks who ended a session: a human clicking **End session** (or **Send & end session**) in the browser is a user-initiated end, while `lavish-axi end <html-file>` is agent-initiated.
163
167
  A plain `lavish-axi <html-file>` after a user-initiated end refuses to reopen the browser and returns guidance instead; pass `--reopen` only when the user asks for further review or something important needs their visual attention.
164
168
  Agent-initiated ends keep reopening normally, same as before.
165
169
  `lavish-axi poll`'s `ended` response and the `feedback` response for the final batch before an end both carry `next_step` guidance telling the agent to stop polling and deliver remaining updates in chat instead of reopening.
166
170
  - **Precise targets** - Text annotations include selected text plus range anchors, so agents are not limited to whole-element selectors.
171
+ - **Mermaid diagrams** - Rendered Mermaid diagrams become pannable and zoomable while you explore (drag to pan, scroll to zoom) and freeze when you turn on annotation so a click lands on a single node. Clicking a node annotates the whole node and sends the agent its diagram id, node id, and rendered label instead of just a CSS selector. Lavish only enhances the live render, so the saved HTML still opens identically anywhere.
167
172
  - **Server cleanup** - The detached server stops after the last session ends when nothing is connected, or after `LAVISH_AXI_IDLE_TIMEOUT_MS` (default 30 minutes) with no browser or poll connections.
168
173
  Set `LAVISH_AXI_IDLE_TIMEOUT_MS=0` or `off` to disable idle self-shutdown.
169
174
  - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
@@ -7,8 +7,15 @@ const filePath = String(sessionData.file || "");
7
7
  const queueStorageKey = "lavish-axi:queued:" + key;
8
8
  const internalQueueKeyField = "_lavishQueueKey";
9
9
  const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
10
+ const MODE_TOGGLE_HOTKEY_KEY = String(sessionData.modeToggleHotkeyKey || "").toLowerCase();
11
+
12
+ function isModeToggleHotkeyEvent(event) {
13
+ if (event.shiftKey || event.altKey) return false;
14
+ return Boolean(event.metaKey || event.ctrlKey) && String(event.key || "").toLowerCase() === MODE_TOGGLE_HOTKEY_KEY;
15
+ }
10
16
 
11
17
  const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
18
+ const panelScroll = /** @type {HTMLDivElement} */ (document.getElementById("panelScroll"));
12
19
  const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("annotationPills"));
13
20
  const chatLog = /** @type {HTMLDivElement} */ (document.getElementById("chatLog"));
14
21
  const chatInput = /** @type {HTMLTextAreaElement} */ (document.getElementById("chatInput"));
@@ -140,6 +147,7 @@ function render() {
140
147
  closeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(closeButton.dataset.index), event));
141
148
  }
142
149
  updateSendState();
150
+ scrollPanelToBottom();
143
151
  }
144
152
 
145
153
  function updateSendState() {
@@ -198,14 +206,15 @@ async function copyText(text) {
198
206
  return true;
199
207
  }
200
208
 
201
- function addChat(role, text) {
209
+ function addChat(role, text, shouldScroll = true) {
202
210
  if (!text) return;
203
211
 
204
212
  const el = document.createElement("div");
205
213
  el.className = "bubble " + role;
206
214
  el.innerHTML = "<small>" + (role === "agent" ? "Agent" : "You") + "</small><div>" + escapeHtml(text) + "</div>";
207
215
  chatLog.appendChild(el);
208
- chatLog.scrollTop = chatLog.scrollHeight;
216
+ if (shouldScroll) scrollElementIntoView(el);
217
+ return el;
209
218
  }
210
219
 
211
220
  function syncChat(chat) {
@@ -213,9 +222,14 @@ function syncChat(chat) {
213
222
  el.remove();
214
223
  }
215
224
 
216
- for (const item of chat) addChat(item.role, item.text);
217
- if (workingBubble) chatLog.appendChild(workingBubble);
218
- chatLog.scrollTop = chatLog.scrollHeight;
225
+ let lastChatBubble = null;
226
+ for (const item of chat) lastChatBubble = addChat(item.role, item.text, false) || lastChatBubble;
227
+ if (workingBubble) {
228
+ chatLog.appendChild(workingBubble);
229
+ scrollElementIntoView(workingBubble);
230
+ } else if (lastChatBubble) {
231
+ scrollElementIntoView(lastChatBubble);
232
+ }
219
233
  }
220
234
 
221
235
  function setAgentPresence(state) {
@@ -235,7 +249,15 @@ function setAgentPresence(state) {
235
249
  workingBubble.innerHTML = '<span class="spinner"></span><span>Working...</span>';
236
250
  chatLog.appendChild(workingBubble);
237
251
  }
238
- chatLog.scrollTop = chatLog.scrollHeight;
252
+ scrollElementIntoView(workingBubble);
253
+ }
254
+
255
+ function scrollPanelToBottom() {
256
+ panelScroll.scrollTop = panelScroll.scrollHeight;
257
+ }
258
+
259
+ function scrollElementIntoView(el) {
260
+ el.scrollIntoView({ block: "nearest", inline: "nearest" });
239
261
  }
240
262
 
241
263
  function removeQueuedPrompt(index, event) {
@@ -701,15 +723,19 @@ window.addEventListener("message", (event) => {
701
723
  }
702
724
  if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
703
725
  if (msg.type === "lavish:endSession") endSession();
726
+ if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
704
727
  });
705
728
 
706
729
  loadFrame();
707
730
 
708
- annotationSwitch.onclick = () => {
731
+ function toggleAnnotationMode() {
732
+ if (ended) return;
709
733
  annotation = !annotation;
710
734
  annotationSwitch.setAttribute("aria-pressed", String(annotation));
711
735
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation });
712
- };
736
+ }
737
+
738
+ annotationSwitch.onclick = toggleAnnotationMode;
713
739
 
714
740
  sendButton.onclick = () => sendQueued(false);
715
741
  sendFromMenuButton.onclick = () => sendQueued(false);
@@ -754,6 +780,17 @@ document.addEventListener("keydown", (event) => {
754
780
  }
755
781
  }
756
782
  });
783
+ // Capture phase so the mode hotkey fires no matter where focus is in the chrome - including
784
+ // mid-keystroke in chatInput or an annotation-card textarea - without disturbing normal typing.
785
+ document.addEventListener(
786
+ "keydown",
787
+ (event) => {
788
+ if (!isModeToggleHotkeyEvent(event)) return;
789
+ event.preventDefault();
790
+ toggleAnnotationMode();
791
+ },
792
+ true,
793
+ );
757
794
  frame.addEventListener("load", () => {
758
795
  postToFrame({ type: "lavish:setAnnotationMode", enabled: annotation && !ended });
759
796
  // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
package/dist/chrome.css CHANGED
@@ -639,11 +639,21 @@ body.lavish {
639
639
  margin: 16px 16px 8px;
640
640
  font-weight: var(--w-semi);
641
641
  letter-spacing: 0;
642
+ flex: 0 0 auto;
643
+ }
644
+ .panel-scroll {
645
+ flex: 1 1 auto;
646
+ min-width: 0;
647
+ min-height: 0;
648
+ overflow-y: auto;
649
+ overflow-x: hidden;
650
+ display: flex;
651
+ flex-direction: column;
642
652
  }
643
653
  .chat {
644
- flex: 1;
654
+ flex: 0 0 auto;
645
655
  min-height: 0;
646
- overflow: auto;
656
+ overflow: visible;
647
657
  padding: 0 16px 12px;
648
658
  display: flex;
649
659
  flex-direction: column;
@@ -704,9 +714,13 @@ body.lavish {
704
714
  gap: 8px;
705
715
  padding: 12px 16px;
706
716
  border-top: var(--hairline-subtle);
717
+ background: var(--bg-panel);
707
718
  min-width: 0;
708
719
  flex-shrink: 0;
709
720
  box-sizing: border-box;
721
+ position: sticky;
722
+ bottom: 0;
723
+ z-index: 4;
710
724
  }
711
725
  .presence-banner {
712
726
  border: 1px solid var(--border);
@@ -726,9 +740,17 @@ body.lavish {
726
740
  flex-wrap: wrap;
727
741
  gap: 6px;
728
742
  min-width: 0;
743
+ padding: 0 16px 12px;
744
+ flex: 0 0 auto;
745
+ }
746
+ .annotation-pills:empty {
747
+ display: none;
729
748
  }
730
749
  .pill-wrap {
731
- position: relative;
750
+ display: flex;
751
+ flex-direction: column;
752
+ align-items: flex-start;
753
+ width: min(320px, 100%);
732
754
  max-width: 100%;
733
755
  }
734
756
  .pill {
@@ -769,11 +791,10 @@ body.lavish {
769
791
  }
770
792
  .pill-tooltip {
771
793
  display: none;
772
- position: absolute;
794
+ position: static;
773
795
  z-index: 5;
774
- left: 0;
775
- bottom: calc(100% + 8px);
776
- width: min(320px, 80vw);
796
+ width: 100%;
797
+ margin-top: 8px;
777
798
  border: 1px solid var(--border-strong);
778
799
  border-radius: var(--radius-lg);
779
800
  background: var(--bg-bar);
package/dist/cli.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
2
7
 
3
8
  // src/cli.js
4
9
  import { spawn, spawnSync } from "node:child_process";
@@ -4188,7 +4193,67 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
4188
4193
  import chokidar from "chokidar";
4189
4194
  import express from "express";
4190
4195
 
4196
+ // src/mermaid-node.js
4197
+ var mermaid_node_exports = {};
4198
+ __export(mermaid_node_exports, {
4199
+ isMermaidSvg: () => isMermaidSvg,
4200
+ mermaidNodeElement: () => mermaidNodeElement,
4201
+ mermaidNodeFrom: () => mermaidNodeFrom,
4202
+ normalizeMermaidNodeTarget: () => normalizeMermaidNodeTarget,
4203
+ readNodeLabel: () => readNodeLabel
4204
+ });
4205
+ function isMermaidSvg(svg) {
4206
+ if (!svg) return false;
4207
+ const id = svg.id || "";
4208
+ if (id.startsWith("mermaid-") || id.startsWith("mermaid_")) return true;
4209
+ if (svg.getAttribute?.("aria-roledescription")) return true;
4210
+ return !!(svg.closest && svg.closest(".mermaid, [data-lavish-mermaid]"));
4211
+ }
4212
+ function readNodeLabel(labelEl) {
4213
+ if (!labelEl) return "";
4214
+ let source = labelEl;
4215
+ if (labelEl.querySelector?.("br") && labelEl.cloneNode) {
4216
+ source = labelEl.cloneNode(true);
4217
+ for (const br of source.querySelectorAll("br")) br.replaceWith(document.createTextNode(" "));
4218
+ }
4219
+ return (source.textContent || "").trim().replace(/\s+/g, " ").slice(0, 120);
4220
+ }
4221
+ function mermaidNodeElement(el) {
4222
+ if (!el || !el.closest) return null;
4223
+ const node = el.closest("g.node, g.nodes > g");
4224
+ if (!node) return null;
4225
+ const svg = node.closest("svg");
4226
+ return svg && isMermaidSvg(svg) ? node : null;
4227
+ }
4228
+ function mermaidNodeFrom(el, selector) {
4229
+ const node = mermaidNodeElement(el);
4230
+ if (!node) return null;
4231
+ const svg = node.closest("svg");
4232
+ const labelEl = node.querySelector(".nodeLabel, .label, foreignObject span, text");
4233
+ return {
4234
+ type: "mermaid-node",
4235
+ diagramId: svg.id || "",
4236
+ nodeId: node.id || "",
4237
+ label: readNodeLabel(labelEl),
4238
+ selector: typeof selector === "function" ? selector(node) : ""
4239
+ };
4240
+ }
4241
+ function normalizeMermaidNodeTarget(target) {
4242
+ return {
4243
+ type: "mermaid-node",
4244
+ diagramId: String(target.diagramId || ""),
4245
+ nodeId: String(target.nodeId || ""),
4246
+ label: String(target.label || ""),
4247
+ selector: String(target.selector || "")
4248
+ };
4249
+ }
4250
+
4191
4251
  // src/artifact-sdk.js
4252
+ var MODE_TOGGLE_HOTKEY_KEY = "i";
4253
+ function isModeToggleHotkeyEvent(event) {
4254
+ if (event.shiftKey || event.altKey) return false;
4255
+ return Boolean(event.metaKey || event.ctrlKey) && String(event.key || "").toLowerCase() === MODE_TOGGLE_HOTKEY_KEY;
4256
+ }
4192
4257
  function deriveLavishQueueKey(element, options = {}) {
4193
4258
  function stringValue(value) {
4194
4259
  return value === null || value === void 0 ? "" : String(value);
@@ -4328,7 +4393,8 @@ function resolveVisibleSpillCandidates(spillCandidates, { epsilon = 1 } = {}) {
4328
4393
  )
4329
4394
  );
4330
4395
  }
4331
- function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl) {
4396
+ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInteractiveControl, mermaid = mermaid_node_exports) {
4397
+ const { isMermaidSvg: isMermaidSvg2, mermaidNodeFrom: mermaidNodeFrom2, mermaidNodeElement: mermaidNodeElement2 } = mermaid;
4332
4398
  let annotationMode = true;
4333
4399
  let hovered = null;
4334
4400
  let selected = null;
@@ -4340,6 +4406,12 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4340
4406
  if (!ids.has(el)) ids.set(el, String(++counter));
4341
4407
  return ids.get(el);
4342
4408
  }
4409
+ function escapeAnnotationText(value) {
4410
+ return String(value).replace(
4411
+ /[&<>"']/g,
4412
+ (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]
4413
+ );
4414
+ }
4343
4415
  function selector(el) {
4344
4416
  if (!el || !el.tagName) return "";
4345
4417
  const parts = [];
@@ -4362,12 +4434,138 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4362
4434
  return parts.join(" > ");
4363
4435
  }
4364
4436
  function context(el) {
4365
- return {
4437
+ const base = {
4366
4438
  uid: uid(el),
4367
4439
  selector: selector(el),
4368
4440
  tag: (el.tagName || "").toLowerCase(),
4369
4441
  text: (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 240)
4370
4442
  };
4443
+ const mermaidNode = mermaidNodeFrom2(el, selector);
4444
+ if (mermaidNode) {
4445
+ base.tag = "mermaid-node";
4446
+ base.text = mermaidNode.label || base.text;
4447
+ base.target = mermaidNode;
4448
+ }
4449
+ return base;
4450
+ }
4451
+ function annotationTargetEl(el) {
4452
+ return mermaidNodeElement2(el) || el;
4453
+ }
4454
+ const mermaidViewports = /* @__PURE__ */ new WeakMap();
4455
+ function findMermaidSvgs() {
4456
+ const svgs = /* @__PURE__ */ new Set();
4457
+ for (const svg of document.querySelectorAll("svg")) {
4458
+ if (isMermaidSvg2(svg)) svgs.add(svg);
4459
+ }
4460
+ return [...svgs];
4461
+ }
4462
+ function createViewport(svg) {
4463
+ const bbox = svg.getBBox ? safeBBox(svg) : null;
4464
+ const initial = readViewBox(svg) || (bbox ? { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height } : null);
4465
+ if (!initial) return null;
4466
+ svg.setAttribute("viewBox", `${initial.x} ${initial.y} ${initial.w} ${initial.h}`);
4467
+ const view = { ...initial };
4468
+ let frozen = false;
4469
+ let panning = null;
4470
+ function apply() {
4471
+ svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.w} ${view.h}`);
4472
+ }
4473
+ function reset() {
4474
+ Object.assign(view, initial);
4475
+ apply();
4476
+ }
4477
+ function zoomAt(clientX, clientY, factor) {
4478
+ const rect = svg.getBoundingClientRect();
4479
+ if (!rect.width || !rect.height) return;
4480
+ const px = (clientX - rect.left) / rect.width;
4481
+ const py = (clientY - rect.top) / rect.height;
4482
+ const fx = view.x + view.w * px;
4483
+ const fy = view.y + view.h * py;
4484
+ const next = Math.min(Math.max(view.w * factor, initial.w / 40), initial.w * 8);
4485
+ const scale = next / view.w;
4486
+ view.w = next;
4487
+ view.h *= scale;
4488
+ view.x = fx - (fx - view.x) * scale;
4489
+ view.y = fy - (fy - view.y) * scale;
4490
+ apply();
4491
+ }
4492
+ function onWheel(event) {
4493
+ if (frozen) return;
4494
+ event.preventDefault();
4495
+ zoomAt(event.clientX, event.clientY, event.deltaY > 0 ? 1.15 : 1 / 1.15);
4496
+ }
4497
+ function onPointerDown(event) {
4498
+ if (frozen || event.button !== 0) return;
4499
+ panning = { x: event.clientX, y: event.clientY, vx: view.x, vy: view.y };
4500
+ svg.setPointerCapture?.(event.pointerId);
4501
+ svg.style.cursor = "grabbing";
4502
+ }
4503
+ function onPointerMove(event) {
4504
+ if (!panning) return;
4505
+ const rect = svg.getBoundingClientRect();
4506
+ if (!rect.width || !rect.height) return;
4507
+ view.x = panning.vx - (event.clientX - panning.x) / rect.width * view.w;
4508
+ view.y = panning.vy - (event.clientY - panning.y) / rect.height * view.h;
4509
+ apply();
4510
+ }
4511
+ function onPointerUp(event) {
4512
+ panning = null;
4513
+ svg.releasePointerCapture?.(event.pointerId);
4514
+ svg.style.cursor = frozen ? "" : "grab";
4515
+ }
4516
+ svg.addEventListener("wheel", onWheel, { passive: false });
4517
+ svg.addEventListener("pointerdown", onPointerDown);
4518
+ svg.addEventListener("pointermove", onPointerMove);
4519
+ svg.addEventListener("pointerup", onPointerUp);
4520
+ svg.addEventListener("pointercancel", onPointerUp);
4521
+ function setFrozen(next) {
4522
+ frozen = !!next;
4523
+ panning = null;
4524
+ svg.style.cursor = frozen ? "" : "grab";
4525
+ svg.style.touchAction = frozen ? "" : "none";
4526
+ }
4527
+ setFrozen(false);
4528
+ return { reset, setFrozen };
4529
+ }
4530
+ function safeBBox(svg) {
4531
+ try {
4532
+ return svg.getBBox();
4533
+ } catch {
4534
+ return null;
4535
+ }
4536
+ }
4537
+ function readViewBox(svg) {
4538
+ const raw = svg.getAttribute?.("viewBox");
4539
+ if (!raw) return null;
4540
+ const parts = raw.trim().split(/[\s,]+/).map(Number);
4541
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
4542
+ return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
4543
+ }
4544
+ function enhanceMermaid() {
4545
+ for (const svg of findMermaidSvgs()) {
4546
+ if (mermaidViewports.has(svg)) continue;
4547
+ const viewport = createViewport(svg);
4548
+ if (viewport) {
4549
+ viewport.setFrozen(annotationMode);
4550
+ mermaidViewports.set(svg, viewport);
4551
+ }
4552
+ }
4553
+ }
4554
+ let mermaidEnhanceScheduled = false;
4555
+ function scheduleMermaidEnhance() {
4556
+ if (mermaidEnhanceScheduled) return;
4557
+ mermaidEnhanceScheduled = true;
4558
+ const run2 = () => {
4559
+ mermaidEnhanceScheduled = false;
4560
+ enhanceMermaid();
4561
+ };
4562
+ if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(run2);
4563
+ else window.setTimeout(run2, 50);
4564
+ }
4565
+ function setMermaidFrozen(frozen) {
4566
+ for (const svg of findMermaidSvgs()) {
4567
+ mermaidViewports.get(svg)?.setFrozen(frozen);
4568
+ }
4371
4569
  }
4372
4570
  function closestElement(node) {
4373
4571
  if (!node) return document.body;
@@ -4465,6 +4663,7 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4465
4663
  }
4466
4664
  if (!annotationMode && style) style.remove();
4467
4665
  if (!annotationMode) closeCard();
4666
+ setMermaidFrozen(annotationMode);
4468
4667
  }
4469
4668
  function queuePrompt(prompt, options = {}) {
4470
4669
  const originElement = options.element || document.activeElement || document.body;
@@ -4832,17 +5031,20 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4832
5031
  const root = ensureShadow();
4833
5032
  closeCard();
4834
5033
  const c = options.context || context(target);
5034
+ let anchor = target;
4835
5035
  if (options.range) {
4836
5036
  highlightTextRange(options.range);
4837
5037
  } else {
4838
- selected = target;
5038
+ anchor = annotationTargetEl(target);
5039
+ selected = anchor;
4839
5040
  highlightElement(selected);
4840
5041
  }
4841
- const rect = options.range ? options.range.getBoundingClientRect() : target.getBoundingClientRect();
5042
+ const rect = options.range ? options.range.getBoundingClientRect() : anchor.getBoundingClientRect();
4842
5043
  const card = document.createElement("div");
4843
5044
  card.className = "lavish-annotation-card";
4844
- const heading = c.tag === "text" ? "Annotate text" : "Annotate &lt;" + c.tag + "&gt;";
4845
- const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : "Tell the agent what to change about this element...";
5045
+ const nodeLabel = c.tag === "mermaid-node" ? c.target?.label || c.text || "" : "";
5046
+ const heading = c.tag === "text" ? "Annotate text" : c.tag === "mermaid-node" ? "Annotate node" + (nodeLabel ? ": " + escapeAnnotationText(nodeLabel) : "") : "Annotate &lt;" + c.tag + "&gt;";
5047
+ const placeholder = c.tag === "text" ? "Tell the agent what to change about this text..." : c.tag === "mermaid-node" ? "Tell the agent what to change about this diagram node..." : "Tell the agent what to change about this element...";
4846
5048
  card.innerHTML = '<div class="lavish-heading">' + heading + '</div><textarea placeholder="' + placeholder + '"></textarea><div class="lavish-hint">Enter to queue &middot; ' + (/Mac|iP(hone|ad|od)/.test(navigator.platform) ? "\u2318" : "Ctrl") + '+Enter to send now</div><div class="lavish-row"><button class="lavish-cancel" type="button">Cancel</button><button class="lavish-send" type="button">Queue</button></div>';
4847
5049
  root.appendChild(card);
4848
5050
  const left = Math.min(Math.max(12, rect.left), window.innerWidth - card.offsetWidth - 12);
@@ -4896,6 +5098,15 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4896
5098
  window.scrollTo(Number(msg.x) || 0, Number(msg.y) || 0);
4897
5099
  }
4898
5100
  });
5101
+ document.addEventListener(
5102
+ "keydown",
5103
+ (event) => {
5104
+ if (!isModeToggleHotkeyEvent(event)) return;
5105
+ event.preventDefault();
5106
+ parent.postMessage({ type: "lavish:toggleAnnotationMode" }, "*");
5107
+ },
5108
+ true
5109
+ );
4899
5110
  let scrollFrame = 0;
4900
5111
  window.addEventListener(
4901
5112
  "scroll",
@@ -4913,9 +5124,10 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4913
5124
  (event) => {
4914
5125
  if (!annotationMode || isLavishUi(event.target) || isLavishAction(event.target) || isInteractiveControl(event.target))
4915
5126
  return;
4916
- if (event.target === selected) return;
5127
+ const target = annotationTargetEl(event.target);
5128
+ if (target === selected) return;
4917
5129
  if (hovered && hovered !== selected) clearHighlight(hovered);
4918
- hovered = event.target;
5130
+ hovered = target;
4919
5131
  highlightElement(hovered);
4920
5132
  },
4921
5133
  true
@@ -4963,6 +5175,12 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
4963
5175
  } else {
4964
5176
  startLayoutAudit();
4965
5177
  }
5178
+ enhanceMermaid();
5179
+ if (document.readyState === "loading") {
5180
+ document.addEventListener("DOMContentLoaded", enhanceMermaid, { once: true });
5181
+ }
5182
+ const mermaidObserver = new MutationObserver(() => scheduleMermaidEnhance());
5183
+ mermaidObserver.observe(document.documentElement, { childList: true, subtree: true });
4966
5184
  }
4967
5185
 
4968
5186
  // src/html-transform.js
@@ -5198,6 +5416,7 @@ function normalizeFiniteNumber(value) {
5198
5416
  }
5199
5417
  function normalizeTarget(target) {
5200
5418
  if (!target || typeof target !== "object" || Array.isArray(target)) return null;
5419
+ if (target.type === "mermaid-node") return normalizeMermaidNodeTarget(target);
5201
5420
  return JSON.parse(JSON.stringify(target));
5202
5421
  }
5203
5422
 
@@ -5498,7 +5717,15 @@ async function serve({
5498
5717
  return;
5499
5718
  }
5500
5719
  await watchSession(session, watchers, events, logEvent);
5501
- res.type("html").send(createChromeHtml(session, { layoutGateEnabled: shouldEnableLayoutGate(req.query || {}) }));
5720
+ const artifactHtml = await readFile3(session.file, "utf8").catch(() => "");
5721
+ const { faviconTag, title } = extractArtifactHead(artifactHtml);
5722
+ res.type("html").send(
5723
+ createChromeHtml(session, {
5724
+ layoutGateEnabled: shouldEnableLayoutGate(req.query || {}),
5725
+ faviconTag,
5726
+ title: title ? `${title} \xB7 Lavish` : "Lavish Editor"
5727
+ })
5728
+ );
5502
5729
  } catch (error) {
5503
5730
  next(error);
5504
5731
  }
@@ -5922,27 +6149,58 @@ function normalizeFlagValue(value) {
5922
6149
  if (Array.isArray(value)) return normalizeFlagValue(value[0]);
5923
6150
  return value === void 0 || value === null ? "" : String(value).trim().toLowerCase();
5924
6151
  }
5925
- function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
6152
+ var LAVISH_DEFAULT_FAVICON = `<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>\u{1F48E}</text></svg>">`;
6153
+ function readTagAttr(tag, name) {
6154
+ const attrRe = /([a-z][\w:-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi;
6155
+ const target = name.toLowerCase();
6156
+ let match;
6157
+ while ((match = attrRe.exec(tag)) !== null) {
6158
+ if (match[1].toLowerCase() === target) {
6159
+ return (match[3] ?? match[4] ?? match[5] ?? "").trim();
6160
+ }
6161
+ }
6162
+ return "";
6163
+ }
6164
+ function extractArtifactHead(html) {
6165
+ const head = String(html || "").slice(0, 1e4);
6166
+ let faviconTag = LAVISH_DEFAULT_FAVICON;
6167
+ const linkTags = head.match(/<link\b(?:"[^"]*"|'[^']*'|[^"'>])*>/gi) || [];
6168
+ const iconTag = linkTags.find((tag) => /(^|\s)icon(\s|$)/i.test(readTagAttr(tag, "rel")));
6169
+ const iconHref = iconTag ? readTagAttr(iconTag, "href") : "";
6170
+ if (iconHref && /^(data:|https?:|\/\/)/i.test(iconHref)) {
6171
+ const safeHref = iconHref.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
6172
+ faviconTag = `<link rel="icon" href="${safeHref}">`;
6173
+ }
6174
+ let title = "";
6175
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
6176
+ if (titleMatch) title = titleMatch[1].replace(/\s+/g, " ").trim();
6177
+ return { faviconTag, title };
6178
+ }
6179
+ function createChromeHtml(session, { layoutGateEnabled = true, faviconTag = LAVISH_DEFAULT_FAVICON, title = "Lavish Editor" } = {}) {
5926
6180
  const sessionJson = jsonScript({
5927
6181
  key: session.key,
5928
6182
  file: session.file,
5929
6183
  initialChat: session.chat || [],
5930
- layoutGateEnabled
6184
+ layoutGateEnabled,
6185
+ modeToggleHotkeyKey: MODE_TOGGLE_HOTKEY_KEY
5931
6186
  });
5932
6187
  const { head: pathHead, tail: pathTail } = displayPathParts(session.file);
5933
6188
  const bodyClass = layoutGateEnabled ? "lavish layout-gate-active" : "lavish";
5934
6189
  const layoutGateHidden = layoutGateEnabled ? "" : " hidden";
6190
+ const modeHotkeyUpper = MODE_TOGGLE_HOTKEY_KEY.toUpperCase();
6191
+ const modeToggleHint = `Toggle annotate/explore mode (\u2318${modeHotkeyUpper} / Ctrl+${modeHotkeyUpper})`;
5935
6192
  return `<!doctype html>
5936
6193
  <html>
5937
6194
  <head>
5938
6195
  <meta charset="utf-8">
5939
6196
  <meta name="viewport" content="width=device-width, initial-scale=1">
5940
- <title>Lavish Editor</title>
6197
+ <title>${escapeHtml(title)}</title>
6198
+ ${faviconTag}
5941
6199
  <link rel="stylesheet" href="/chrome.css">
5942
6200
  </head>
5943
6201
  <body class="${bodyClass}">
5944
- <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml(pathHead)}</span><span class="path-tail">${escapeHtml(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button">${chromeIcons.download}<span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button">${chromeIcons.globe}<span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
5945
- <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe><div class="layout-issue-banner" id="layoutIssueBanner" hidden>This surface may have layout issues. Your agent has been notified.</div></div><aside class="panel"><h2>Conversation</h2><div class="chat" id="chatLog"></div><div class="composer"><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><div class="annotation-pills" id="annotationPills"></div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions" id="sendActions"><span class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</span><div class="split"><button class="button send-main" id="send">Send to Agent</button><button class="button send-caret" id="sendCaret" type="button" title="Send options" aria-haspopup="menu" aria-expanded="false">${chromeIcons.caret}</button></div><div class="menu send-menu" id="sendMenu" hidden><button class="menu-item" id="sendFromMenu" type="button">${chromeIcons.send}<span>Send to Agent</span></button><button class="menu-item danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; end session</span></button></div></div></div></aside></div>
6202
+ <div class="bar"><div class="brand"><span class="brand-mark">Lavish</span><span class="brand-support">Editor</span></div><div class="spacer" aria-hidden="true"></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="${escapeHtml(modeToggleHint)}"><span class="switch-track" aria-hidden="true"><span class="switch-knob"></span></span><span>Annotate</span></button><div class="more-wrap" id="moreWrap"><button class="more-button" id="moreButton" type="button" title="More" aria-haspopup="menu" aria-expanded="false">${chromeIcons.more}</button><div class="menu more-menu" id="moreMenu" hidden><div class="menu-head"><div class="menu-label">Editing</div><button class="menu-file" id="copyPath" type="button" title="Copy path \xB7 ${escapeHtml(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml(pathHead)}</span><span class="path-tail">${escapeHtml(pathTail)}</span></span><span class="copy-hint" id="copyHint"><span class="icon-copy">${chromeIcons.copy}</span><span class="icon-check">${chromeIcons.check}</span><span id="copyHintText">Copy</span></span></button></div><div class="menu-rule"></div><button class="menu-item" id="reloadArtifact" type="button">${chromeIcons.refresh}<span>Reload artifact</span></button><button class="menu-item" id="copySnapshot" type="button">${chromeIcons.camera}<span>Copy DOM snapshot</span></button><button class="menu-item" id="exportArtifact" type="button">${chromeIcons.download}<span>Export standalone HTML</span></button><button class="menu-item" id="shareArtifact" type="button">${chromeIcons.globe}<span>Publish link</span></button><div class="menu-rule"></div><button class="menu-item danger" id="end" type="button">${chromeIcons.exit}<span>End session</span></button></div></div></div>
6203
+ <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe><div class="layout-issue-banner" id="layoutIssueBanner" hidden>This surface may have layout issues. Your agent has been notified.</div></div><aside class="panel"><h2>Conversation</h2><div class="panel-scroll" id="panelScroll"><div class="chat" id="chatLog"></div><div class="annotation-pills" id="annotationPills"></div></div><div class="composer"><div class="presence-banner" id="presenceBanner" hidden>Your agent is not listening. If this persists, ask your agent to poll for updates from Lavish.</div><textarea id="chatInput" placeholder="Write a message for the agent..."></textarea><div class="actions" id="sendActions"><span class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</span><div class="split"><button class="button send-main" id="send">Send to Agent</button><button class="button send-caret" id="sendCaret" type="button" title="Send options" aria-haspopup="menu" aria-expanded="false">${chromeIcons.caret}</button></div><div class="menu send-menu" id="sendMenu" hidden><button class="menu-item" id="sendFromMenu" type="button">${chromeIcons.send}<span>Send to Agent</span></button><button class="menu-item danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; end session</span></button></div></div></div></aside></div>
5946
6204
  <div class="share-overlay" id="shareDialog" role="dialog" aria-modal="true" aria-labelledby="shareTitleText" hidden><form class="share-card" id="shareForm"><div class="share-head"><div><div class="share-kicker">Publish to <a class="share-link" href="https://ht-ml.app" target="_blank" rel="noopener noreferrer">ht-ml.app</a></div><h2 id="shareTitleText">Publish artifact</h2></div><button class="share-close" id="shareClose" type="button" aria-label="Close publish dialog"><svg width="14" height="14" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><p class="share-note">ht-ml.app is a separate, third-party hosting service, not part of Lavish. Publishing sends this artifact to its servers.</p><p class="share-copy">This uploads this artifact to ht-ml.app with local assets inlined. Without a password, the page is PUBLIC and anyone with the link can open it. With a password, the page is PRIVATE and viewers must supply the password to view.</p><p class="share-note">Do not publish secrets. The Lavish annotation SDK is not included.</p><div class="share-grid"><label>Password (optional)<input id="sharePassword" name="password" type="password" autocomplete="new-password" placeholder="Leave blank for a public page"></label></div><div class="share-status" id="shareStatus" role="status"></div><div class="share-result" id="shareResult" hidden><label>Share URL<div class="share-copy-row"><input id="shareUrl" readonly><button class="share-copy-btn" id="copyShareUrl" type="button">Copy URL</button></div></label><label>Update key (secret)<div class="share-copy-row"><input id="shareUpdateKey" readonly><button class="share-copy-btn" id="copyUpdateKey" type="button">Copy key</button></div></label><p class="share-note">Keep the update key private. ht-ml.app returns it once and it is the only way to update or delete this page later.</p></div><div class="share-actions"><button class="share-cancel" id="shareCancel" type="button">Cancel</button><button class="button" id="sharePublish" type="submit">Publish</button></div></form></div>
5947
6205
  <div class="ended-overlay layout-gate-overlay" id="layoutGateOverlay"${layoutGateHidden}><div class="ended-card"><div class="ended-title" id="layoutGateTitle">Checking layout.<br>One moment.</div><p class="ended-copy" id="layoutGateCopy">Lavish is waiting for fonts and final geometry before revealing this artifact.</p><button class="button ended-action" id="layoutGateAction" type="button">Show anyway</button></div></div>
5948
6206
  <div class="ended-overlay" id="endedOverlay" hidden><div class="ended-card"><div class="ended-title">Session ended.<br>Return to your agent to continue.</div><p class="ended-copy">${escapeHtml(session.file)}</p></div></div>
@@ -5952,16 +6210,23 @@ function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
5952
6210
  </html>`;
5953
6211
  }
5954
6212
  function createSdkJs(key) {
6213
+ const mermaidHelperEntries = Object.entries(mermaid_node_exports).filter(([, value]) => typeof value === "function");
6214
+ const mermaidHelperDecls = mermaidHelperEntries.map(([name, fn]) => `const ${name}=${fn.toString()};`).join("\n");
6215
+ const mermaidHelperKeys = mermaidHelperEntries.map(([name]) => name).join(", ");
5955
6216
  return `(() => {
5956
6217
  const key=${JSON.stringify(key)};
5957
6218
  void key;
5958
6219
  const deriveQueueKey=${deriveLavishQueueKey.toString()};
5959
6220
  const isNativeInteractiveControl=${isNativeInteractiveControl.toString()};
6221
+ const MODE_TOGGLE_HOTKEY_KEY=${JSON.stringify(MODE_TOGGLE_HOTKEY_KEY)};
6222
+ const isModeToggleHotkeyEvent=${isModeToggleHotkeyEvent.toString()};
5960
6223
  const fragmentsSignificantlyOverlap=${fragmentsSignificantlyOverlap.toString()};
5961
6224
  const resolveVisibleSpillCandidates=${resolveVisibleSpillCandidates.toString()};
5962
6225
  const classifyHorizontalOverflow=${classifyHorizontalOverflow.toString()};
5963
6226
  const classifyVerticalOverflow=${classifyVerticalOverflow.toString()};
5964
- (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl);
6227
+ ${mermaidHelperDecls}
6228
+ const mermaidHelpers={ ${mermaidHelperKeys} };
6229
+ (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl, mermaidHelpers);
5965
6230
  })();`;
5966
6231
  }
5967
6232
  function escapeHtml(value) {
@@ -6147,7 +6412,7 @@ function normalizePagePath(path6) {
6147
6412
  var COMMANDS = /* @__PURE__ */ new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup", "export", "share"]);
6148
6413
  var RESERVED = new Set(RESERVED_COMMANDS);
6149
6414
  var DESCRIPTION = "Lavish Editor helps agents turn rich HTML artifacts into collaborative human review surfaces. Whenever you are about to give user a complex response that will be easier to understand via a rich / interactive page, consider using Lavish Editor. First generate an interactive HTML artifact according to user request, then run `lavish-axi <html-file>` so the user can visually review it, annotate elements or selected text, queue prompts, and send feedback back through `lavish-axi poll`.";
6150
- var VERSION = "0.1.35";
6415
+ var VERSION = "0.1.37";
6151
6416
  async function run(argv) {
6152
6417
  await ensureStateDir();
6153
6418
  const normalizedArgv = normalizeArgv(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "format": "prettier --write .",
24
24
  "format:check": "prettier --check .",
25
25
  "prepack": "npm run build",
26
+ "prepare": "npm run build",
26
27
  "test": "node --test",
27
28
  "typecheck": "tsc --noEmit"
28
29
  },