lavish-axi 0.1.75 → 0.1.76

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
@@ -181,6 +181,10 @@ pnpm link
181
181
  `Resolved` requires a newer successful artifact load plus a complete check at the same viewport that no longer detects it; it then leaves the count but keeps a bounded history.
182
182
  `Still present` (recurring) means a queued issue survived a newer revision, so it is selectable again with its earlier attempt retained. `Unverified` means a reload or check failed or was incomplete, so the prior issue was preserved rather than cleared. `Returned` means a resolved issue came back on a later revision.
183
183
  Dismissal applies only to the current artifact revision; a later revision surfaces the issue again if it is still detected. A check at one viewport never clears an issue found at another, and a viewport removed from the configured diagnostic set (`LAVISH_AXI_DIAGNOSTIC_VIEWPORTS`, default all) is marked obsolete with an explicit reason rather than reading as fixed.
184
+ - **Revisions legend** - Opt-in. After a round of feedback the agent regenerates the whole file, and a one-sentence edit is invisible on a long artifact. The agent can declare what it changed in the artifact itself: one `<script type="application/json" data-lavish-revisions>` array of `{ id, label, timestamp, summary }` entries, plus `data-lavish-revision="<id>"` on each block it edited or added. Run `lavish-axi design` for the snippet.
185
+ A **Revisions** button then appears in the top bar with a legend listing each round - colour swatch, label, time, and the agent's summary - and **Reveal** steps through that round's changed blocks, flashing them one at a time.
186
+ Each revision's swatch carries a distinct border style and fill pattern as well as a colour, so the rounds stay distinguishable without colour. Artifacts with no registry show no button and are otherwise unaffected, a malformed registry is ignored rather than surfaced as an error, and at most 6 revisions are listed - one per distinguishable swatch.
187
+ Lavish never marks up the artifact for this: the legend lives in the browser chrome, so the served page stays identical to the saved file, and the registry and marks travel with the file through export.
184
188
  - **Local assets** - Copy local images, CSS, fonts, and scripts next to the HTML artifact and reference them with relative paths from that directory; root-prefixed paths such as `/assets/logo.png` will not resolve through Lavish's artifact route.
185
189
  - **Export and sharing** - `lavish-axi export` writes `<name>.export.html` by inlining local assets only, stripping the annotation SDK, and leaving remote CDN/font references as links that still need network access.
186
190
  `lavish-axi share` publishes the same local-inlined HTML to [ht-ml.app](https://ht-ml.app), a third-party hosting service not part of Lavish.
@@ -178,6 +178,12 @@ const warningsSelectAll = /** @type {HTMLInputElement} */ (document.getElementBy
178
178
  const warningsSelected = /** @type {HTMLSpanElement} */ (document.getElementById("warningsSelected"));
179
179
  const warningsList = /** @type {HTMLDivElement} */ (document.getElementById("warningsList"));
180
180
  const warningsQueueButton = /** @type {HTMLButtonElement} */ (document.getElementById("warningsQueueButton"));
181
+ const revisionsWrap = /** @type {HTMLDivElement} */ (document.getElementById("revisionsWrap"));
182
+ const revisionsButton = /** @type {HTMLButtonElement} */ (document.getElementById("revisionsButton"));
183
+ const revisionsCount = /** @type {HTMLSpanElement} */ (document.getElementById("revisionsCount"));
184
+ const revisionsDrawer = /** @type {HTMLDivElement} */ (document.getElementById("revisionsDrawer"));
185
+ const revisionsSummary = /** @type {HTMLParagraphElement} */ (document.getElementById("revisionsSummary"));
186
+ const revisionsList = /** @type {HTMLDivElement} */ (document.getElementById("revisionsList"));
181
187
  const sendHint = /** @type {HTMLDivElement} */ (document.getElementById("sendHint"));
182
188
  const whiteboardOverlay = /** @type {HTMLDivElement} */ (document.getElementById("whiteboardOverlay"));
183
189
  const whiteboardFrame = /** @type {HTMLIFrameElement} */ (document.getElementById("whiteboardFrame"));
@@ -2431,6 +2437,265 @@ function revealWarning(warning) {
2431
2437
  postToFrame({ type: "lavish:revealElement", selector: warning.selector });
2432
2438
  }
2433
2439
 
2440
+ // ---------------------------------------------------------------------------
2441
+ // Revision legend
2442
+ //
2443
+ // The agent declares what it changed in the artifact HTML itself (a
2444
+ // `data-lavish-revisions` registry plus `data-lavish-revision` marks); the SDK
2445
+ // reads it and posts it here. The legend lives entirely in the chrome, so the
2446
+ // served artifact still matches the file on disk - Lavish never paints the
2447
+ // change indicator into the page. Reveal borrows the same transient marker the
2448
+ // layout-issues drawer uses, and only when the reader asks for it.
2449
+
2450
+ // The payload crosses postMessage from a sandboxed frame rendering author
2451
+ // content, so it is untrusted here whatever built it. These bound what is
2452
+ // examined, not what is accepted: capping accepted rows alone would let a
2453
+ // registry of ten thousand records walk the whole array on the chrome's main
2454
+ // thread before yielding its handful of rows.
2455
+ // Server-owned, injected into the session JSON from src/artifact-revisions.js.
2456
+ // The swatch is presentation the chrome owes the reader, not something the
2457
+ // artifact gets a say in, so nothing about it is read from the message.
2458
+ const REVISION_PALETTE = (Array.isArray(sessionData.revisionPalette) ? sessionData.revisionPalette : []).filter(
2459
+ (entry) => entry && /^#[0-9a-fA-F]{6}$/.test(String(entry.hex)),
2460
+ );
2461
+ const REVISION_LIMITS = {
2462
+ // Derived from the server-injected palette (src/artifact-revisions.js) rather than a
2463
+ // second hardcoded number, so the two can never drift; falls back to 6 only if the
2464
+ // palette itself came through empty.
2465
+ entries: REVISION_PALETTE.length > 0 ? REVISION_PALETTE.length : 6,
2466
+ rawEntries: 256,
2467
+ marks: 200,
2468
+ rawMarks: 2000,
2469
+ id: 60,
2470
+ label: 80,
2471
+ summary: 400,
2472
+ timestamp: 40,
2473
+ excerpt: 120,
2474
+ selector: 400,
2475
+ };
2476
+ const REVISION_BORDER_STYLES = ["solid", "dashed", "dotted", "double"];
2477
+
2478
+ /** @type {any[]} */
2479
+ let revisionEntries = [];
2480
+ /** @type {any[]} */
2481
+ let revisionMarks = [];
2482
+ /** @type {Map<string, number>} */
2483
+ const revisionRevealCursor = new Map();
2484
+ let revisionsDrawerOpen = false;
2485
+
2486
+ // Display-only fields. Anything the legend merely shows can be shortened.
2487
+ function revisionText(value, max) {
2488
+ if (typeof value !== "string" && typeof value !== "number") return "";
2489
+ const text = String(value).trim();
2490
+ return text.length > max ? text.slice(0, max) : text;
2491
+ }
2492
+
2493
+ // Identity fields, mirroring `exactRevisionText` in src/artifact-revisions.js.
2494
+ // The message is untrusted here, so shortening an id or a selector is worse
2495
+ // than dropping it: two distinct overlong ids collapse into one legend row, and
2496
+ // the first 400 characters of a long selector are a different valid selector
2497
+ // that Reveal would resolve to some other block.
2498
+ function revisionExact(value, max) {
2499
+ if (typeof value !== "string" && typeof value !== "number") return "";
2500
+ const text = String(value).trim();
2501
+ return text.length > 0 && text.length <= max ? text : "";
2502
+ }
2503
+
2504
+ function revisionPatternFill(pattern) {
2505
+ if (pattern === "diagonal") return "repeating-linear-gradient(45deg, currentColor 0 2px, transparent 2px 5px)";
2506
+ if (pattern === "dots") return "radial-gradient(currentColor 1px, transparent 1px)";
2507
+ return "";
2508
+ }
2509
+
2510
+ // The swatch for a revision's position in the registry. Position is the only
2511
+ // thing the artifact influences, and it cannot repeat one: ids are deduplicated
2512
+ // before this runs, so each accepted revision gets a distinct index and
2513
+ // therefore a distinct swatch.
2514
+ function revisionPresentation(index) {
2515
+ const entry = REVISION_PALETTE.length > 0 ? REVISION_PALETTE[index % REVISION_PALETTE.length] : null;
2516
+ return {
2517
+ color: entry ? String(entry.hex) : "#0072b2",
2518
+ borderStyle: REVISION_BORDER_STYLES.includes(String(entry && entry.borderStyle))
2519
+ ? String(entry.borderStyle)
2520
+ : "solid",
2521
+ pattern: revisionPatternFill(entry && entry.pattern),
2522
+ };
2523
+ }
2524
+
2525
+ // Only text crosses from the artifact into the legend, and it crosses as
2526
+ // textContent. Nothing the message carries reaches a style property.
2527
+ function normalizeRevisionMessage(msg) {
2528
+ const rawRevisions = Array.isArray(msg && msg.revisions) ? msg.revisions : [];
2529
+ const revisions = [];
2530
+ const byId = new Map();
2531
+ let examined = 0;
2532
+ for (const raw of rawRevisions) {
2533
+ if (examined >= REVISION_LIMITS.rawEntries || revisions.length >= REVISION_LIMITS.entries) break;
2534
+ examined += 1;
2535
+ if (!raw || typeof raw !== "object") continue;
2536
+ const id = revisionExact(raw.id, REVISION_LIMITS.id);
2537
+ if (!id || /\s/.test(id) || byId.has(id)) continue;
2538
+ const entry = {
2539
+ id,
2540
+ label: revisionText(raw.label, REVISION_LIMITS.label) || id,
2541
+ timestamp: revisionText(raw.timestamp, REVISION_LIMITS.timestamp),
2542
+ summary: revisionText(raw.summary, REVISION_LIMITS.summary),
2543
+ ...revisionPresentation(revisions.length),
2544
+ marks: [],
2545
+ };
2546
+ byId.set(id, entry);
2547
+ revisions.push(entry);
2548
+ }
2549
+
2550
+ const rawMarks = Array.isArray(msg && msg.marks) ? msg.marks : [];
2551
+ const marks = [];
2552
+ examined = 0;
2553
+ for (const raw of rawMarks) {
2554
+ if (examined >= REVISION_LIMITS.rawMarks || marks.length >= REVISION_LIMITS.marks) break;
2555
+ examined += 1;
2556
+ if (!raw || typeof raw !== "object") continue;
2557
+ const revisionId = revisionExact(raw.revision_id, REVISION_LIMITS.id);
2558
+ const selector = revisionExact(raw.selector, REVISION_LIMITS.selector);
2559
+ const owner = byId.get(revisionId);
2560
+ if (!owner || !selector) continue;
2561
+ const mark = {
2562
+ revisionId,
2563
+ selector,
2564
+ tag: revisionText(raw.tag, 40).toLowerCase(),
2565
+ excerpt: revisionText(raw.excerpt, REVISION_LIMITS.excerpt),
2566
+ };
2567
+ owner.marks.push(mark);
2568
+ marks.push(mark);
2569
+ }
2570
+
2571
+ return { revisions, marks };
2572
+ }
2573
+
2574
+ function resetRevisionLegend() {
2575
+ revisionEntries = [];
2576
+ revisionMarks = [];
2577
+ revisionRevealCursor.clear();
2578
+ renderRevisionLegend();
2579
+ }
2580
+
2581
+ function applyRevisionMessage(msg) {
2582
+ const normalized = normalizeRevisionMessage(msg);
2583
+ revisionEntries = normalized.revisions;
2584
+ revisionMarks = normalized.marks;
2585
+ for (const id of [...revisionRevealCursor.keys()]) {
2586
+ if (!revisionEntries.some((entry) => entry.id === id)) revisionRevealCursor.delete(id);
2587
+ }
2588
+ renderRevisionLegend();
2589
+ }
2590
+
2591
+ function revisionMarkSummary(entry) {
2592
+ if (entry.marks.length === 0) return "no marked blocks";
2593
+ return entry.marks.length === 1 ? "1 marked block" : entry.marks.length + " marked blocks";
2594
+ }
2595
+
2596
+ function buildRevisionRow(entry) {
2597
+ const row = document.createElement("div");
2598
+ row.className = "revision-row";
2599
+
2600
+ const swatch = document.createElement("span");
2601
+ swatch.className = "revision-swatch";
2602
+ swatch.setAttribute("aria-hidden", "true");
2603
+ swatch.style.color = entry.color;
2604
+ swatch.style.borderColor = entry.color;
2605
+ swatch.style.borderStyle = entry.borderStyle;
2606
+ if (entry.pattern) swatch.style.backgroundImage = entry.pattern;
2607
+ row.appendChild(swatch);
2608
+
2609
+ const body = document.createElement("div");
2610
+ body.className = "revision-body";
2611
+
2612
+ const head = document.createElement("div");
2613
+ head.className = "revision-head";
2614
+ const label = document.createElement("span");
2615
+ label.className = "revision-label";
2616
+ label.textContent = entry.label;
2617
+ head.appendChild(label);
2618
+ if (entry.timestamp) {
2619
+ const time = document.createElement("span");
2620
+ time.className = "revision-time";
2621
+ time.textContent = entry.timestamp;
2622
+ head.appendChild(time);
2623
+ }
2624
+ body.appendChild(head);
2625
+
2626
+ if (entry.summary) {
2627
+ const summary = document.createElement("p");
2628
+ summary.className = "revision-summary";
2629
+ summary.textContent = entry.summary;
2630
+ body.appendChild(summary);
2631
+ }
2632
+
2633
+ const foot = document.createElement("div");
2634
+ foot.className = "revision-foot";
2635
+ const count = document.createElement("span");
2636
+ count.className = "revision-marks";
2637
+ count.textContent = revisionMarkSummary(entry);
2638
+ foot.appendChild(count);
2639
+ if (entry.marks.length > 0) {
2640
+ const reveal = document.createElement("button");
2641
+ reveal.type = "button";
2642
+ reveal.className = "revision-reveal";
2643
+ const position = (revisionRevealCursor.get(entry.id) || 0) % entry.marks.length;
2644
+ reveal.textContent = entry.marks.length === 1 ? "Reveal" : "Reveal " + (position + 1) + "/" + entry.marks.length;
2645
+ reveal.setAttribute("aria-label", "Reveal the next block changed in " + entry.label);
2646
+ // Bound per row rather than delegated from the list: the rows are rebuilt
2647
+ // on every render anyway, so there is no listener to accumulate, and the
2648
+ // button carries the revision it belongs to without a data attribute round
2649
+ // trip through the DOM.
2650
+ reveal.onclick = () => revealNextRevisionMark(entry.id);
2651
+ foot.appendChild(reveal);
2652
+ }
2653
+ body.appendChild(foot);
2654
+
2655
+ row.appendChild(body);
2656
+ return row;
2657
+ }
2658
+
2659
+ function renderRevisionLegend() {
2660
+ if (!revisionsWrap) return;
2661
+ const count = revisionEntries.length;
2662
+ revisionsWrap.hidden = count === 0 || ended;
2663
+ if (revisionsWrap.hidden && revisionsDrawerOpen) setRevisionsDrawerOpen(false);
2664
+ revisionsCount.textContent = String(count);
2665
+ revisionsButton.setAttribute("aria-label", count === 1 ? "1 revision" : count + " revisions");
2666
+ revisionsSummary.textContent =
2667
+ (count === 1 ? "1 revision" : count + " revisions") +
2668
+ " · " +
2669
+ (revisionMarks.length === 1 ? "1 marked block" : revisionMarks.length + " marked blocks");
2670
+
2671
+ revisionsList.replaceChildren(...revisionEntries.map(buildRevisionRow));
2672
+ }
2673
+
2674
+ function setRevisionsDrawerOpen(open) {
2675
+ revisionsDrawerOpen = open && !ended;
2676
+ revisionsDrawer.hidden = !revisionsDrawerOpen;
2677
+ revisionsButton.setAttribute("aria-expanded", String(revisionsDrawerOpen));
2678
+ if (revisionsDrawerOpen) closeMenus();
2679
+ }
2680
+
2681
+ function closeRevisionsDrawer({ restoreFocus = false } = {}) {
2682
+ if (!revisionsDrawerOpen) return;
2683
+ setRevisionsDrawerOpen(false);
2684
+ if (restoreFocus) revisionsButton.focus();
2685
+ }
2686
+
2687
+ // Step through a revision's marked blocks one at a time. The existing reveal
2688
+ // path flashes one element and clears the previous marker, so a revision that
2689
+ // touched several blocks is walked rather than lit up all at once.
2690
+ function revealNextRevisionMark(id) {
2691
+ const entry = revisionEntries.find((candidate) => candidate.id === id);
2692
+ if (!entry || entry.marks.length === 0) return;
2693
+ const position = (revisionRevealCursor.get(id) || 0) % entry.marks.length;
2694
+ postToFrame({ type: "lavish:revealElement", selector: entry.marks[position].selector });
2695
+ revisionRevealCursor.set(id, (position + 1) % entry.marks.length);
2696
+ renderRevisionLegend();
2697
+ }
2698
+
2434
2699
  async function dismissWarning(id) {
2435
2700
  try {
2436
2701
  const response = await fetch("/api/" + key + "/layout-warnings/dismiss", {
@@ -2530,6 +2795,8 @@ function markSessionEnded() {
2530
2795
  closeShareDialog();
2531
2796
  closeWarningsDrawer();
2532
2797
  renderWarnings();
2798
+ closeRevisionsDrawer();
2799
+ renderRevisionLegend();
2533
2800
  closeWhiteboard();
2534
2801
  annotationSwitch.disabled = true;
2535
2802
  moreButton.disabled = true;
@@ -2837,6 +3104,11 @@ async function replaceArtifactFrame({ recoveryRetry = false } = {}) {
2837
3104
  clearTimeout(artifactSilenceTimer);
2838
3105
  // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
2839
3106
  if (!artifactSrc) {
3107
+ // The next document reports its own registry once it loads; until then the
3108
+ // previous revision's legend would point at blocks that may no longer exist.
3109
+ // Only clear it here, right before the frame is actually replaced - a preserved
3110
+ // load (superseded/out-of-order/exhausted retries below) must leave it intact.
3111
+ resetRevisionLegend();
2840
3112
  startLayoutGateCycle();
2841
3113
  const currentSrc = frame.src || "about:blank";
2842
3114
  frame.src = currentSrc + (currentSrc.includes("?") ? "&" : "?") + "lavish_reload=" + Date.now();
@@ -2953,6 +3225,9 @@ async function replaceArtifactFrame({ recoveryRetry = false } = {}) {
2953
3225
  inlineWhiteboardChannels.clear();
2954
3226
  setHandoffSuperseded(false);
2955
3227
  startLayoutGateCycle();
3228
+ // The next document reports its own registry once it loads; until then the
3229
+ // previous revision's legend would point at blocks that may no longer exist.
3230
+ resetRevisionLegend();
2956
3231
  frame.src = artifactFrameSrcForLoad({ revision, token });
2957
3232
  return true;
2958
3233
  }
@@ -3688,6 +3963,7 @@ window.addEventListener("message", (event) => {
3688
3963
  // chrome cannot see every live reference, so reclamation is the sweeper's job.
3689
3964
  if (msg.type === "lavish:sendQueuedPrompts") sendQueued();
3690
3965
  if (msg.type === "lavish:endSession") endSession();
3966
+ if (msg.type === "lavish:revisions") applyRevisionMessage(msg);
3691
3967
  if (msg.type === "lavish:toggleAnnotationMode") toggleAnnotationMode();
3692
3968
  });
3693
3969
 
@@ -3831,9 +4107,14 @@ sendButton.onclick = () => sendQueued(false);
3831
4107
  sendAndEndButton.onclick = () => sendQueued(true);
3832
4108
  moreButton.onclick = () => {
3833
4109
  closeWarningsDrawer();
4110
+ closeRevisionsDrawer();
3834
4111
  toggleMenu(moreButton, moreMenu);
3835
4112
  };
3836
4113
  warningsButton.onclick = toggleWarningsDrawer;
4114
+ revisionsButton.onclick = () => {
4115
+ closeWarningsDrawer();
4116
+ setRevisionsDrawerOpen(revisionsDrawer.hidden);
4117
+ };
3837
4118
  warningsSelectAll.onchange = toggleSelectAllWarnings;
3838
4119
  warningsQueueButton.onclick = queueSelectedWarningFixes;
3839
4120
  chatAttachButton.onclick = () => chatAttachInput.click();
@@ -3963,6 +4244,7 @@ document.addEventListener("mousedown", (event) => {
3963
4244
  const target = /** @type {Node} */ (event.target);
3964
4245
  if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
3965
4246
  if (warningsDrawerOpen && !warningsWrap.contains(target)) closeWarningsDrawer();
4247
+ if (revisionsDrawerOpen && !revisionsWrap.contains(target)) closeRevisionsDrawer();
3966
4248
  });
3967
4249
  // A non-modal popover closes when focus leaves it, so keyboard users are never stranded inside a
3968
4250
  // panel they cannot see the end of.
@@ -3970,6 +4252,10 @@ warningsWrap.addEventListener("focusout", (event) => {
3970
4252
  const next = /** @type {Node | null} */ (event.relatedTarget);
3971
4253
  if (warningsDrawerOpen && next && !warningsWrap.contains(next)) closeWarningsDrawer();
3972
4254
  });
4255
+ revisionsWrap.addEventListener("focusout", (event) => {
4256
+ const next = /** @type {Node | null} */ (event.relatedTarget);
4257
+ if (revisionsDrawerOpen && next && !revisionsWrap.contains(next)) closeRevisionsDrawer();
4258
+ });
3973
4259
  whiteboardCloseButton.onclick = closeWhiteboard;
3974
4260
  document.addEventListener("keydown", (event) => {
3975
4261
  if (event.key === "Escape") {
@@ -3977,6 +4263,8 @@ document.addEventListener("keydown", (event) => {
3977
4263
  closeWhiteboard();
3978
4264
  } else if (!shareDialog.hidden) {
3979
4265
  closeShareDialog();
4266
+ } else if (revisionsDrawerOpen) {
4267
+ closeRevisionsDrawer({ restoreFocus: true });
3980
4268
  } else if (warningsDrawerOpen) {
3981
4269
  closeWarningsDrawer({ restoreFocus: true });
3982
4270
  } else if (!moreMenu.hidden) {
package/dist/chrome.css CHANGED
@@ -635,6 +635,158 @@ body.lavish {
635
635
  opacity: 0.8;
636
636
  flex-shrink: 0;
637
637
  }
638
+ .revisions-wrap {
639
+ position: relative;
640
+ display: flex;
641
+ align-items: center;
642
+ }
643
+ .revisions-wrap[hidden] {
644
+ display: none;
645
+ }
646
+
647
+ .revisions-button {
648
+ display: inline-flex;
649
+ align-items: center;
650
+ gap: 6px;
651
+ height: 34px;
652
+ padding: 0 10px;
653
+ border-radius: var(--radius-md);
654
+ border: 1px solid var(--border);
655
+ background: var(--bg-panel);
656
+ color: var(--fg);
657
+ font: 600 12px/1 var(--font-sans);
658
+ cursor: pointer;
659
+ }
660
+
661
+ .revisions-button:hover {
662
+ background: var(--bg-elevated);
663
+ }
664
+
665
+ .revisions-count {
666
+ min-width: 18px;
667
+ padding: 0 5px;
668
+ border-radius: 9px;
669
+ background: var(--accent);
670
+ color: var(--brass-ink);
671
+ font: 700 11px/18px var(--font-sans);
672
+ text-align: center;
673
+ }
674
+
675
+ .revisions-drawer {
676
+ right: 0;
677
+ top: 42px;
678
+ width: min(360px, calc(100vw - 24px));
679
+ padding: 0;
680
+ }
681
+
682
+ .revisions-head {
683
+ padding: 12px 14px 8px;
684
+ }
685
+
686
+ .revisions-title {
687
+ margin: 0;
688
+ font-size: 13px;
689
+ font-weight: 700;
690
+ }
691
+
692
+ .revisions-summary {
693
+ margin: 4px 0 0;
694
+ font-size: 11px;
695
+ color: var(--fg-faint);
696
+ }
697
+
698
+ .revisions-list {
699
+ max-height: 320px;
700
+ overflow-y: auto;
701
+ padding: 0 14px;
702
+ }
703
+
704
+ .revision-row {
705
+ display: flex;
706
+ gap: 10px;
707
+ padding: 10px 0;
708
+ border-top: 1px solid var(--border);
709
+ }
710
+
711
+ /* Colour is never the only signal: the swatch also carries the revision's
712
+ border style and fill pattern, so the legend still separates revisions in
713
+ greyscale or with a colour vision deficiency. */
714
+ .revision-swatch {
715
+ flex: 0 0 auto;
716
+ width: 16px;
717
+ height: 16px;
718
+ margin-top: 2px;
719
+ border-width: 2px;
720
+ border-radius: 4px;
721
+ }
722
+
723
+ .revision-body {
724
+ min-width: 0;
725
+ flex: 1 1 auto;
726
+ }
727
+
728
+ .revision-head {
729
+ display: flex;
730
+ align-items: baseline;
731
+ gap: 8px;
732
+ }
733
+
734
+ .revision-label {
735
+ font-size: 12px;
736
+ font-weight: 700;
737
+ }
738
+
739
+ .revision-time {
740
+ font-size: 11px;
741
+ color: var(--fg-faint);
742
+ }
743
+
744
+ .revision-summary {
745
+ margin: 4px 0 0;
746
+ font-size: 11px;
747
+ line-height: 1.45;
748
+ color: var(--fg-faint);
749
+ }
750
+
751
+ .revision-foot {
752
+ display: flex;
753
+ align-items: center;
754
+ justify-content: space-between;
755
+ gap: 8px;
756
+ margin-top: 6px;
757
+ }
758
+
759
+ .revision-marks {
760
+ font-size: 11px;
761
+ color: var(--fg-faint);
762
+ }
763
+
764
+ .revision-reveal {
765
+ border: 1px solid var(--border);
766
+ border-radius: 8px;
767
+ background: var(--bg-elevated);
768
+ color: var(--fg);
769
+ padding: 4px 8px;
770
+ font: 700 11px/1 var(--font-sans);
771
+ cursor: pointer;
772
+ }
773
+
774
+ .revision-reveal:hover {
775
+ background: var(--steel-700);
776
+ }
777
+
778
+ .revisions-foot {
779
+ padding: 10px 14px 12px;
780
+ border-top: 1px solid var(--border);
781
+ }
782
+
783
+ .revisions-note {
784
+ margin: 0;
785
+ font-size: 11px;
786
+ line-height: 1.45;
787
+ color: var(--fg-faint);
788
+ }
789
+
638
790
  .menu-item.danger {
639
791
  color: var(--danger);
640
792
  }
package/dist/cli.mjs CHANGED
@@ -453,6 +453,10 @@ var LAYOUT_SAFETY_CSS_SNIPPET = `<style>
453
453
  height: auto;
454
454
  }
455
455
  </style>`;
456
+ var REVISION_REGISTRY_SNIPPET = `<script type="application/json" data-lavish-revisions>
457
+ [{ "id": "r1", "label": "Round 1", "timestamp": "2026-01-01T10:00:00Z", "summary": "Tightened the pricing copy" }]
458
+ </script>
459
+ <section data-lavish-revision="r1">...the block you changed...</section>`;
456
460
  var DESIGN_PRIORITY_RULE = "Decide the design direction in this strict priority order, and only move to the next step when the current one truly yields nothing: (1) if the user asked for a specific look or named design system, use that; (2) otherwise you must first inspect the project the artifact is about - the subject or product whose content or UI it represents, which may differ from your current working directory - and match that project's design system: Tailwind or theme config, shared CSS variables or design tokens, component library, brand assets, or existing styled pages. If the artifact previews, proposes, or mocks a specific app's UI, render it in that app's own design system so it faithfully shows the product, even when you are running in a different repo; (3) only when both steps come up empty, use the Lavish-recommended Tailwind CSS browser runtime v4 + DaisyUI v5, available via CDN, and prefer that CDN snippet over hand-writing styles unless explicitly instructed otherwise by the user.";
457
461
  var DESIGN_SYSTEM_HINT = "Lavish does not auto-inject any design system - artifacts stay portable so they render identically when opened directly without lavish-axi running. Before writing any HTML: " + DESIGN_PRIORITY_RULE + " Run `lavish-axi design` for a content-to-playbook router, a copy-pasteable CDN snippet, the whiteboard (Mermaid) opt-in snippet, and the DaisyUI component reference. When you deliver the artifact, state which of the three design sources you used and why.";
458
462
  var DAISYUI_THEMES = [
@@ -515,6 +519,11 @@ function createDesignOutput() {
515
519
  cdn_urls: { mermaid: MERMAID_CDN_URL },
516
520
  versions: { mermaid: MERMAID_VERSION }
517
521
  },
522
+ revision_marking: {
523
+ use_when: "Opt-in only: on a regeneration that answers the reviewer's feedback, declare what you changed so the browser can show a Revisions legend. Skip it on a first draft, and skip it when you rewrote the whole artifact - a legend that marks everything says nothing.",
524
+ registry_snippet: REVISION_REGISTRY_SNIPPET,
525
+ how: 'Append one entry per round to the `data-lavish-revisions` JSON (oldest first, stable `id`s), and put `data-lavish-revision="<id>"` on each block you actually edited or added. Lavish reads them and never restyles the page, so the saved file looks the same opened directly.'
526
+ },
518
527
  theme_usage: [
519
528
  'Default to `<html data-theme="luxury">` - it matches the Lavish look. Pick a different theme from the list below only when the user asked for one or the content clearly calls for it.',
520
529
  'Set a nested section theme with `<section data-theme="night">`.',
@@ -4703,6 +4712,182 @@ import { fileURLToPath as fileURLToPath3 } from "node:url";
4703
4712
  import chokidar from "chokidar";
4704
4713
  import express from "express";
4705
4714
 
4715
+ // src/artifact-revisions.js
4716
+ var artifact_revisions_exports = {};
4717
+ __export(artifact_revisions_exports, {
4718
+ collectRevisionMarks: () => collectRevisionMarks,
4719
+ exactRevisionText: () => exactRevisionText,
4720
+ isAddressableRevisionId: () => isAddressableRevisionId,
4721
+ isUniqueElementId: () => isUniqueElementId,
4722
+ normalizeRevisionEntry: () => normalizeRevisionEntry,
4723
+ parseRevisionRegistry: () => parseRevisionRegistry,
4724
+ readArtifactRevisions: () => readArtifactRevisions,
4725
+ revisionLimits: () => revisionLimits,
4726
+ revisionPalette: () => revisionPalette,
4727
+ revisionPresentationForIndex: () => revisionPresentationForIndex,
4728
+ revisionSelectorFor: () => revisionSelectorFor,
4729
+ truncateRevisionText: () => truncateRevisionText
4730
+ });
4731
+ function revisionPalette() {
4732
+ return [
4733
+ { hex: "#0072b2", borderStyle: "solid", pattern: "none" },
4734
+ { hex: "#d55e00", borderStyle: "dashed", pattern: "diagonal" },
4735
+ { hex: "#009e73", borderStyle: "dotted", pattern: "none" },
4736
+ { hex: "#cc79a7", borderStyle: "double", pattern: "dots" },
4737
+ { hex: "#56b4e9", borderStyle: "dashed", pattern: "none" },
4738
+ { hex: "#e69f00", borderStyle: "solid", pattern: "diagonal" }
4739
+ ];
4740
+ }
4741
+ function revisionPresentationForIndex(index) {
4742
+ const palette = revisionPalette();
4743
+ const position = Number.isInteger(index) && index >= 0 ? index % palette.length : 0;
4744
+ return palette[position];
4745
+ }
4746
+ function revisionLimits() {
4747
+ return {
4748
+ // One row per palette entry, derived rather than written down: a cap larger
4749
+ // than the palette would hand the seventh revision the first one's colour
4750
+ // *and* its border and pattern, which is precisely the case the palette
4751
+ // exists to prevent.
4752
+ entries: revisionPalette().length,
4753
+ rawEntries: 256,
4754
+ marks: 200,
4755
+ rawMarks: 2e3,
4756
+ registryBytes: 64 * 1024,
4757
+ id: 60,
4758
+ label: 80,
4759
+ summary: 400,
4760
+ timestamp: 40,
4761
+ excerpt: 120,
4762
+ selector: 400,
4763
+ selectorDepth: 32
4764
+ };
4765
+ }
4766
+ function truncateRevisionText(value, max) {
4767
+ if (typeof value !== "string" && typeof value !== "number") return "";
4768
+ const text = String(value).trim();
4769
+ const limit = Number.isInteger(max) && max > 0 ? max : 0;
4770
+ if (!limit || text.length <= limit) return text;
4771
+ return text.slice(0, limit);
4772
+ }
4773
+ function isAddressableRevisionId(id) {
4774
+ const text = typeof id === "string" || typeof id === "number" ? String(id) : "";
4775
+ return text.length > 0 && text === text.trim() && !/\s/.test(text);
4776
+ }
4777
+ function exactRevisionText(value, max) {
4778
+ const text = typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
4779
+ return text.length > 0 && text.length <= max ? text : "";
4780
+ }
4781
+ function normalizeRevisionEntry(entry, index) {
4782
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
4783
+ const limits = revisionLimits();
4784
+ const id = exactRevisionText(entry.id, limits.id);
4785
+ if (!isAddressableRevisionId(id)) return null;
4786
+ return {
4787
+ id,
4788
+ index: Number.isInteger(index) && index >= 0 ? index : 0,
4789
+ label: truncateRevisionText(entry.label, limits.label) || id,
4790
+ timestamp: truncateRevisionText(entry.timestamp, limits.timestamp),
4791
+ summary: truncateRevisionText(entry.summary, limits.summary),
4792
+ mark_count: 0
4793
+ };
4794
+ }
4795
+ function parseRevisionRegistry(doc) {
4796
+ const script = doc && doc.querySelector ? doc.querySelector("script[data-lavish-revisions]") : null;
4797
+ if (!script) return [];
4798
+ const limits = revisionLimits();
4799
+ const text = String(script.textContent || "").trim();
4800
+ if (!text || text.length > limits.registryBytes) return [];
4801
+ let parsed;
4802
+ try {
4803
+ parsed = JSON.parse(text);
4804
+ } catch {
4805
+ return [];
4806
+ }
4807
+ if (!Array.isArray(parsed)) return [];
4808
+ const revisions = [];
4809
+ const seen = /* @__PURE__ */ new Set();
4810
+ let examined = 0;
4811
+ for (const raw of parsed) {
4812
+ if (examined >= limits.rawEntries || revisions.length >= limits.entries) break;
4813
+ examined += 1;
4814
+ const entry = normalizeRevisionEntry(raw, revisions.length);
4815
+ if (!entry || seen.has(entry.id)) continue;
4816
+ seen.add(entry.id);
4817
+ revisions.push(entry);
4818
+ }
4819
+ return revisions;
4820
+ }
4821
+ function isUniqueElementId(doc, id) {
4822
+ if (!doc || typeof doc.querySelectorAll !== "function") return false;
4823
+ try {
4824
+ const found = doc.querySelectorAll(`#${id}`);
4825
+ return Boolean(found) && found.length === 1;
4826
+ } catch {
4827
+ return false;
4828
+ }
4829
+ }
4830
+ function revisionSelectorFor(element, doc) {
4831
+ const limits = revisionLimits();
4832
+ const parts = [];
4833
+ let node = element;
4834
+ let depth = 0;
4835
+ while (node && node.nodeType === 1) {
4836
+ if (depth >= limits.selectorDepth) return "";
4837
+ const tag = String(node.tagName || "").toLowerCase();
4838
+ if (!tag) return "";
4839
+ const id = node.getAttribute ? String(node.getAttribute("id") || "").trim() : "";
4840
+ if (id && /^[A-Za-z][-\w]*$/.test(id) && isUniqueElementId(doc, id)) {
4841
+ parts.unshift(`#${id}`);
4842
+ return parts.join(" > ");
4843
+ }
4844
+ const parent2 = node.parentElement;
4845
+ let part = tag;
4846
+ if (parent2 && parent2.children) {
4847
+ const siblings = [];
4848
+ for (const child of parent2.children) {
4849
+ if (String(child.tagName || "").toLowerCase() === tag) siblings.push(child);
4850
+ }
4851
+ if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
4852
+ }
4853
+ parts.unshift(part);
4854
+ node = parent2;
4855
+ depth += 1;
4856
+ }
4857
+ return parts[0] === "html" ? parts.join(" > ") : "";
4858
+ }
4859
+ function collectRevisionMarks(doc, revisions) {
4860
+ const limits = revisionLimits();
4861
+ const declared = /* @__PURE__ */ new Map();
4862
+ for (const revision of Array.isArray(revisions) ? revisions : []) {
4863
+ if (revision && isAddressableRevisionId(revision.id)) declared.set(revision.id, revision);
4864
+ }
4865
+ const marks = [];
4866
+ const elements = doc && doc.querySelectorAll ? doc.querySelectorAll("[data-lavish-revision]") : [];
4867
+ let examined = 0;
4868
+ for (const element of elements) {
4869
+ if (examined >= limits.rawMarks || marks.length >= limits.marks) break;
4870
+ examined += 1;
4871
+ const id = element.getAttribute ? exactRevisionText(element.getAttribute("data-lavish-revision"), limits.id) : "";
4872
+ if (!declared.has(id)) continue;
4873
+ const selector = exactRevisionText(revisionSelectorFor(element, doc), limits.selector);
4874
+ if (!selector) continue;
4875
+ declared.get(id).mark_count += 1;
4876
+ marks.push({
4877
+ revision_id: id,
4878
+ selector,
4879
+ tag: String(element.tagName || "").toLowerCase(),
4880
+ excerpt: truncateRevisionText(String(element.textContent || "").replace(/\s+/g, " "), limits.excerpt)
4881
+ });
4882
+ }
4883
+ return marks;
4884
+ }
4885
+ function readArtifactRevisions(doc) {
4886
+ const revisions = parseRevisionRegistry(doc);
4887
+ const marks = collectRevisionMarks(doc, revisions);
4888
+ return { revisions, marks };
4889
+ }
4890
+
4706
4891
  // src/mermaid-node.js
4707
4892
  var mermaid_node_exports = {};
4708
4893
  __export(mermaid_node_exports, {
@@ -6840,6 +7025,19 @@ function createArtifactSdk(deriveQueueKey, isNativeInteractive = isNativeInterac
6840
7025
  }
6841
7026
  const mermaidObserver = new MutationObserver(() => scheduleMermaidEnhance());
6842
7027
  mermaidObserver.observe(document.documentElement, { childList: true, subtree: true });
7028
+ function reportArtifactRevisions() {
7029
+ let payload = { revisions: [], marks: [] };
7030
+ try {
7031
+ payload = readArtifactRevisions(document);
7032
+ } catch {
7033
+ }
7034
+ postArtifactMessage("lavish:revisions", payload);
7035
+ }
7036
+ if (document.readyState === "loading") {
7037
+ document.addEventListener("DOMContentLoaded", reportArtifactRevisions, { once: true });
7038
+ } else {
7039
+ reportArtifactRevisions();
7040
+ }
6843
7041
  }
6844
7042
 
6845
7043
  // src/layout-warnings.js
@@ -11492,7 +11690,12 @@ function createChromeHtml(session, {
11492
11690
  modeToggleHotkeyKey: MODE_TOGGLE_HOTKEY_KEY,
11493
11691
  attachmentMaxBytes,
11494
11692
  attachmentMaxCount,
11495
- attachmentAcceptedMime: acceptedMime
11693
+ attachmentAcceptedMime: acceptedMime,
11694
+ // The legend's swatches are server-owned, not artifact-owned. The chrome
11695
+ // derives every swatch from this palette by registry position, so an
11696
+ // artifact cannot hand all its revisions the same colour and pattern and
11697
+ // collapse the one signal that tells the rounds apart.
11698
+ revisionPalette: revisionPalette()
11496
11699
  });
11497
11700
  const { head: pathHead, tail: pathTail } = displayPathParts(session.file);
11498
11701
  const bodyClass = layoutGateEnabled ? "lavish layout-gate-active" : "lavish";
@@ -11509,7 +11712,7 @@ ${faviconTag}
11509
11712
  <link rel="stylesheet" href="/chrome.css">
11510
11713
  </head>
11511
11714
  <body class="${bodyClass}">
11512
- <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><div class="warnings-wrap" id="warningsWrap" hidden><button class="warnings-button" id="warningsButton" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="warningsDrawer">${chromeIcons.warning}<span class="warnings-count" id="warningsCount">0</span></button><div class="menu warnings-drawer" id="warningsDrawer" role="dialog" aria-labelledby="warningsTitle" aria-describedby="warningsSummary" hidden><div class="warnings-head"><h2 class="warnings-title" id="warningsTitle">Layout issues</h2><p class="warnings-summary" id="warningsSummary"></p></div><div class="warnings-toolbar"><label class="warnings-selectall"><input type="checkbox" id="warningsSelectAll"><span>Select all</span></label><span class="warnings-selected" id="warningsSelected" role="status" aria-live="polite"></span></div><div class="warnings-list" id="warningsList"></div><div class="warnings-foot"><p class="warnings-note">Queueing sends a repair request with your next feedback. An issue is marked resolved only after a newer artifact load and a complete check at the same viewport no longer finds it.</p><button class="button" id="warningsQueueButton" type="button" disabled>Queue selected fixes</button></div></div></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="${escapeHtml2(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 ${escapeHtml2(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml2(pathHead)}</span><span class="path-tail">${escapeHtml2(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>
11715
+ <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><div class="warnings-wrap" id="warningsWrap" hidden><button class="warnings-button" id="warningsButton" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="warningsDrawer">${chromeIcons.warning}<span class="warnings-count" id="warningsCount">0</span></button><div class="menu warnings-drawer" id="warningsDrawer" role="dialog" aria-labelledby="warningsTitle" aria-describedby="warningsSummary" hidden><div class="warnings-head"><h2 class="warnings-title" id="warningsTitle">Layout issues</h2><p class="warnings-summary" id="warningsSummary"></p></div><div class="warnings-toolbar"><label class="warnings-selectall"><input type="checkbox" id="warningsSelectAll"><span>Select all</span></label><span class="warnings-selected" id="warningsSelected" role="status" aria-live="polite"></span></div><div class="warnings-list" id="warningsList"></div><div class="warnings-foot"><p class="warnings-note">Queueing sends a repair request with your next feedback. An issue is marked resolved only after a newer artifact load and a complete check at the same viewport no longer finds it.</p><button class="button" id="warningsQueueButton" type="button" disabled>Queue selected fixes</button></div></div></div><div class="revisions-wrap" id="revisionsWrap" hidden><button class="revisions-button" id="revisionsButton" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="revisionsDrawer"><span class="revisions-button-text">Revisions</span><span class="revisions-count" id="revisionsCount">0</span></button><div class="menu revisions-drawer" id="revisionsDrawer" role="dialog" aria-labelledby="revisionsTitle" aria-describedby="revisionsSummary" hidden><div class="revisions-head"><h2 class="revisions-title" id="revisionsTitle">Revisions</h2><p class="revisions-summary" id="revisionsSummary"></p></div><div class="revisions-list" id="revisionsList"></div><div class="revisions-foot"><p class="revisions-note">The agent declares these in the artifact itself. Reveal flashes the next block it marked for that revision; nothing about the page is restyled, so the saved file still looks the way it does here.</p></div></div></div><button class="annotate-switch" id="annotation" type="button" aria-pressed="true" title="${escapeHtml2(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 ${escapeHtml2(session.file)}">${chromeIcons.file}<span class="menu-file-text"><span class="path-head">${escapeHtml2(pathHead)}</span><span class="path-tail">${escapeHtml2(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>
11513
11716
  <div class="layout"><div class="frame"><iframe id="artifact" sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads" data-artifact-src="/artifact/${session.key}/index.html"></iframe></div><div class="panel-scrim" id="panelScrim"></div><aside class="panel" id="panel"><div class="panel-head" id="panelHead"><span class="panel-handle" aria-hidden="true"></span><div class="panel-head-row"><h2>Conversation</h2><span class="panel-summary" id="panelSummary" role="status" aria-live="polite"></span><button class="panel-toggle" id="panelToggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="Show conversation">${chromeIcons.chevronUp}</button></div></div><div class="panel-scroll" id="panelScroll"><div class="chat" id="chatLog"></div><div class="chat chat-queued" id="queuedLog"></div></div><div class="composer" id="chatComposer"><div class="presence-banner handoff-banner" id="handoffBanner" hidden><span>This review is open in another Lavish tab.</span><button class="handoff-takeover" id="handoffTakeover" type="button">Take over here</button></div><div class="presence-banner handoff-banner" id="outdatedBanner" hidden><span id="outdatedText">The Lavish server this page was connected to is no longer running. Reloading will work once it is running again.</span><span class="outdated-actions"><button class="handoff-takeover" id="outdatedReload" type="button">Check and reload</button><button class="handoff-takeover" id="outdatedDismiss" type="button">Dismiss</button></span></div><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="chat-attachments" id="chatAttachments"></div><div class="chat-attachment-toolbar"><button class="chat-attach" id="chatAttach" type="button">Attach images</button><input id="chatAttachInput" type="file" accept="${escapeHtml2(acceptedMime.join(","))}" multiple hidden><span class="chat-attachment-notice" id="chatAttachmentNotice" role="status"></span></div><div class="send-hint" id="sendHint" hidden>Write a message or annotate an element first.</div><div class="actions" id="sendActions"><button class="button button-danger" id="sendAndEnd" type="button">${chromeIcons.exit}<span>Send &amp; End</span></button><button class="button" id="send">Send to Agent</button></div></div></aside></div>
11514
11717
  <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 class="share-check"><input id="shareGenerate" type="checkbox"><span>Generate a password (makes this page private)</span></label><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 id="shareUrlResult">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 id="sharePasswordResult" hidden>Password (shared secret)<div class="share-copy-row"><input id="sharePasswordOut" readonly><button class="share-copy-btn" id="copySharePassword" type="button">Copy password</button></div></label><label id="shareSiteIdResult" hidden>Site ID<div class="share-copy-row"><input id="shareSiteId" readonly><button class="share-copy-btn" id="copyShareSiteId" type="button">Copy site ID</button></div></label><label id="shareUpdateKeyResult">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" id="shareUpdateKeyNote">Keep the update key private. ht-ml.app returns it once and it is the only way to update this page later; the service has no delete. Republish this page&#39;s HTML with <code>lavish-axi share &lt;file&gt; --site &lt;site id&gt; --update-key &lt;key&gt;</code>, and add <code>--private</code> to also lock it behind a new generated password.</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>
11515
11718
  <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><button class="button ended-action layout-gate-bypass" id="layoutGateBypass" type="button" hidden>Show anyway</button></div></div>
@@ -11552,6 +11755,7 @@ function serializeModuleHelpers(module) {
11552
11755
  function createSdkJs(key, artifactRevision = 0, artifactLoadToken = "", { maxAttachmentCount, maxAttachmentBytes, acceptedImageMime = ACCEPTED_IMAGE_MIME } = {}) {
11553
11756
  const mermaidHelperSource = serializeModuleHelpers(mermaid_node_exports);
11554
11757
  const tableHelperSource = serializeModuleHelpers(table_cell_exports);
11758
+ const revisionHelperSource = serializeModuleHelpers(artifact_revisions_exports);
11555
11759
  const revisionNumber = Number(artifactRevision);
11556
11760
  const revision = Number.isFinite(revisionNumber) && revisionNumber >= 0 ? Math.trunc(revisionNumber) : 0;
11557
11761
  const loadToken = String(artifactLoadToken || "").slice(0, 200);
@@ -11583,6 +11787,7 @@ const deriveAttachmentNoticeState=${deriveAttachmentNoticeState.toString()};
11583
11787
  ${mermaidHelperSource.declarations}
11584
11788
  const mermaidHelpers={ ${mermaidHelperSource.names.join(", ")} };
11585
11789
  ${tableHelperSource.declarations}
11790
+ ${revisionHelperSource.declarations}
11586
11791
  (${createArtifactSdk.toString()})(deriveQueueKey, isNativeInteractiveControl, mermaidHelpers, artifactRevision, artifactLoadToken, key, ${JSON.stringify(sdkOptions)});
11587
11792
  })();`;
11588
11793
  }
@@ -11790,7 +11995,7 @@ var AGENT_REPLY_INPUT_LIMIT_BYTES = AGENT_REPLY_JSON_LIMIT_BYTES - AGENT_REPLY_J
11790
11995
  var AGENT_REPLY_LIMIT_LABEL = "2 MB JSON request limit";
11791
11996
  var POLL_STATE_HEADER = "lavish-poll-state";
11792
11997
  var CODEX_POLL_WAKE_PATH_GUIDANCE = "Codex detected: completed background tasks may not resume Codex automatically, so keep the poll attached to the active turn.";
11793
- var VERSION = "0.1.75";
11998
+ var VERSION = "0.1.76";
11794
11999
  function detectInvokingAgent(env = process.env) {
11795
12000
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
11796
12001
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
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",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "lavish-axi",
4
- "version": "0.1.75",
4
+ "version": "0.1.76",
5
5
  "description": "HTML is the new markdown. Lavish is the new editor for your HTML artifacts.",
6
6
  "author": {
7
7
  "name": "Kun Chen",