lavish-axi 0.1.58 → 0.1.60

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
@@ -53,7 +53,7 @@ npx skills add kunchenguid/lavish-axi --skill lavish
53
53
 
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
- In restricted subprocess sandboxes, CI, or agent harnesses where `npx -y` exits opaquely, the skill also documents direct installed-copy fallbacks through the local or global npm install path.
56
+ It stays a short stub and sends the agent to `npx -y lavish-axi --help`, `design`, and `playbook` for current instructions, so an installed copy cannot go stale against a newer CLI.
57
57
  Its frontmatter also includes Hermes Agent metadata, so Hermes-compatible harnesses can categorize and surface it as a first-class productivity skill.
58
58
  This installs the public `lavish` skill.
59
59
  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.
@@ -166,9 +166,9 @@ pnpm link
166
166
  - **Self-paint warning** - `lavish-axi <html-file>`, `export`, and `share` run a render-free check for artifacts missing an explicit page background and return a one-line `self_paint_warning`.
167
167
  The check fails open - any stylesheet link, `@import`, Tailwind runtime script, `color-scheme`, or `html`/`body`/`:root` background signal suppresses it - and it never blocks the open.
168
168
  - **Open-time layout gate** - The browser chrome masks an artifact only while the real in-iframe audit waits for fonts and final geometry.
169
- The first completed check reveals the artifact, whatever it found, unless the mask is naming one of the failures below; the gate never holds the review hostage waiting for a repair.
170
- The user can click **Show anyway**, and a bounded safety timeout fails open when no check has completed.
171
- If the review cannot load at all - the chrome's own script never runs, or the server does not answer the artifact's load request after several retries - the mask is replaced by a message naming the problem and a **Check and reload** button, instead of holding the artifact behind a check that will never complete.
169
+ The first completed client-side check reveals the artifact, whatever it found and even if reporting that check to the server fails; the gate never holds the review hostage waiting for a repair or a network round-trip.
170
+ The user can click **Show anyway**, and a bounded safety timeout fails open from every gate state.
171
+ If the review cannot load at all - the chrome's own script never runs, or the server does not answer the artifact's load request after several retries - the mask names the problem and offers **Check and reload** without removing the independent **Show anyway** escape.
172
172
  A review already loaded in another browser tab is named the same way, with a **Take over here** button that moves it into the current tab, because Lavish loads an artifact in one tab at a time.
173
173
  - **Layout issues inbox** - Detection is passive. After fonts and finite animations settle, the injected SDK confirms severe failures from direct rendered evidence such as materially escaped meaningful content or required controls, clipped text fragments, viewport reachability, or near-total semantic occlusion.
174
174
  Explicit ellipsis and line clamp, standard visually hidden accessibility text, intentional scrollers or masks, parent overhang, generic element scroll geometry, decorative overlap, and uncertain motion do not produce findings by themselves.
@@ -184,11 +184,14 @@ pnpm link
184
184
  - **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
185
  - **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
186
  `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.
187
- Publishing sends the artifact to ht-ml.app's servers, public by default, or private and password-protected with `--password`; the response includes a secret `update_key` shown once for later management.
187
+ Publishing sends the artifact to ht-ml.app's servers, public by default, or private and password-protected with `--private` (Lavish generates the password and returns it once, in the command output and in the browser publish dialog) or `--password <pw>` (one you supply, never echoed back). A generated password is a shared secret: give it to whoever should read the page, and expect it to appear in your agent's transcript, because the agent has to relay it. Lavish does not store it.
188
+ The response includes a secret `update_key` shown once. Keep it to republish the same URL later with `--site <site_id> --update-key <key>`, which replaces the HTML in place and leaves the password alone unless you pass `--private` (rotate to a new generated one) or `--password <pw>` (set one). Locking a page that was public is not instant - it can keep answering from ht-ml.app's CDN cache for minutes after the password is set - while a page that was already private has no such cached copy.
189
+ A page's password cannot be removed: ht-ml.app accepts a request to clear one and silently ignores it, so Lavish offers no way to make a private page public rather than telling you it did something the host did not do. Republish as a new page if you need a public URL.
190
+ ht-ml.app has no delete endpoint, so `--unpublish --site <site_id> --update-key <key>` replaces the page with a short placeholder and locks it behind a discarded random password rather than removing it: the URL still resolves and the host still holds what was published. The content swap is immediate, but the lock is not: a page that was public can keep serving the placeholder to uncredentialed visitors from ht-ml.app's CDN cache for minutes afterwards. The `update_key` plus `--private` republishes real content behind a new password you can share.
188
191
  Bundling never fetches remote URLs, Lavish itself does not set a CSP, local reads stay confined and size-capped, and absolute `file://` paths outside safe inlined asset references are redacted before output.
189
192
  Per-asset and per-bundle inline caps default to 10 MB and 25 MB, overridable with `LAVISH_AXI_EXPORT_MAX_ASSET_BYTES` and `LAVISH_AXI_EXPORT_MAX_BUNDLE_BYTES`.
190
193
  Unresolved local assets or export notices such as author-set CSP meta tags and redacted file URLs are surfaced in command or browser output.
191
- Use `--token` or `LAVISH_AXI_HTML_APP_TOKEN` for an optional bearer token; set `LAVISH_AXI_HTML_APP_API_URL` to override the ht-ml.app API base and point `share` at a backend you control (see [Self-hosting the share backend](docs/self-hosting-share.md) for the contract it must implement).
194
+ Use `--token` or `LAVISH_AXI_HTML_APP_TOKEN` for an optional bearer token when publishing a new page (a republish or `--unpublish` authorizes with the `update_key` instead and rejects `--token`); set `LAVISH_AXI_HTML_APP_API_URL` to override the ht-ml.app API base and point `share` at a backend you control (see [Self-hosting the share backend](docs/self-hosting-share.md) for the contract it must implement).
192
195
  - **Live reload** - Lavish watches the HTML artifact file by default and preserves review context across reloads: the artifact iframe scroll position, an open annotation card's unsent text, and answers to `data-lavish-question` controls (application-owned form state is left alone). Unsent annotation text also survives a full reload of the review page itself. While a queued layout-issue batch is outstanding, closely spaced saves coalesce so one batch of fixes costs one refresh. To also reload on sibling asset changes, add `data-lavish-live-reload-root` to the root element or `<meta name="lavish-live-reload" content="root">`.
193
196
  If the element an unsent annotation was attached to is gone from the artifact for good, Lavish cannot reopen that card, so it writes your text into the conversation panel under **Unsent annotation** - selectable, never written over anything you have typed, and kept there across reloads; no note is ever dropped to make room for a newer one, and a note the browser refuses to store says so where it is shown.
194
197
  - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`.
@@ -237,27 +240,27 @@ pnpm link
237
240
  - **Local-first state** - Session state stays under `~/.lavish-axi/` by default, or `LAVISH_AXI_STATE_DIR` when set.
238
241
  - **Diagnostic viewports** - `LAVISH_AXI_DIAGNOSTIC_VIEWPORTS` sets which viewport classes the layout-issue inbox tracks (`mobile`, `compact`, `desktop`; comma-separated, default all). Warnings whose class leaves the set are marked obsolete with an explicit reason instead of silently reading as fixed.
239
242
  - **Server port** - Set `LAVISH_AXI_PORT` to choose the server port; it defaults to `4387`.
240
- - **Network binding** - The server binds to loopback (`127.0.0.1`) by default. Set `LAVISH_AXI_HOST` to bind elsewhere; a wildcard (`0.0.0.0` or `::`) binds every interface. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to anything that can reach it, so only do so on a trusted network. Set `LAVISH_AXI_LINK_HOST` to control the hostname written into generated session links (defaults to the bind address, or loopback when bound to a wildcard).
241
- - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: the loopback names (`127.0.0.1`, `::1`, `localhost`) plus the configured bind and link host. If you reach the server under another name - a wildcard bind accessed by LAN IP, a reverse-proxy hostname, or an extra interface - list those names in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated) to allow them. Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add your public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely (only when the server sits behind your own authentication or proxy). Mutating routes also reject a present foreign `Origin` or `Referer` (`403`); header-less CLI control requests remain allowed where supported.
243
+ - **Network binding** - With Tailscale running, the review server automatically listens only on loopback (`127.0.0.1`) and this machine's Tailscale IPv4 address - never on `0.0.0.0` or every interface. The generated session link uses the machine's MagicDNS name, so it is the phone-ready URL to open from another device on the same tailnet. When Tailscale is absent or down, Lavish silently falls back to loopback-only and prints no phone URL. If Tailscale is running but MagicDNS is unavailable or its address cannot be bound after brief retries, Lavish visibly warns that phone access is unavailable and remains loopback-only. Binding beyond loopback exposes an unauthenticated server that can read and serve arbitrary local files to devices that can reach it, so the tailnet should be trusted. Any explicit `LAVISH_AXI_HOST` overrides automatic Tailscale binding; wildcard values are restricted to loopback, while a non-wildcard value selects that one concrete bind address. `LAVISH_AXI_LINK_HOST` controls the link host when automatic Tailscale binding is disabled.
244
+ - **Allowed hosts** - To defend against DNS rebinding, the server rejects (`403`) any request whose `Host` header is missing or not one it answers to: loopback names plus the concrete Tailscale IPv4 address and MagicDNS name when the Tailscale listener is successfully bound. If you configure a reverse proxy or another intentional hostname, list it in `LAVISH_AXI_ALLOWED_HOSTS` (whitespace-separated). Behind a reverse proxy, the forwarded `X-Forwarded-Host` is validated against the same list, so add the public hostname there and have the proxy send it together with `X-Forwarded-Proto`. Set `LAVISH_AXI_ALLOWED_HOSTS` to `*` to disable the check entirely, only when the server sits behind your own authentication or proxy. Mutating routes also reject a present foreign `Origin` or `Referer` (`403`); header-less CLI control requests remain allowed where supported.
242
245
  - **Browser opening** - Set `LAVISH_AXI_NO_OPEN=1`, equivalent to `--no-open`, to create or resume a session without launching a browser window.
243
246
 
244
247
  ## CLI Reference
245
248
 
246
- | Command | Description |
247
- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
248
- | `lavish-axi` | Show current sessions and usage guidance. |
249
- | `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. |
250
- | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. |
251
- | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session; detected layout issues wait in the user's Layout issues inbox and arrive only when queued. Leave no-timeout polls running, or re-run them if interrupted. Codex guidance keeps polls attached to the active turn. On `status: ended`, stop polling and do not reopen uninvited. |
252
- | `lavish-axi end <html-file>` | End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later. |
253
- | `lavish-axi export <html-file>` | Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links. |
254
- | `lavish-axi share <html-file>` | Publish the artifact (local assets inlined) to [ht-ml.app](https://ht-ml.app), a third-party host not part of Lavish, and print a visitable URL plus a secret update key; shares are public by default, and `--password` makes viewers enter the password before viewing. |
255
- | `lavish-axi stop` | Shut down the background server. |
256
- | `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook; agents must open each matching playbook before writing HTML. |
257
- | `lavish-axi design` | Show agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet. |
258
- | `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward. |
259
- | `lavish-axi setup plugin` | Register the installed package as an [Agent Plugin](https://agent-plugins.org) in VS Code, Cursor, and GitHub Copilot CLI; opt-in, idempotent, no marketplace involved. Reload each client afterward. |
260
- | `lavish-axi server` | Run the local Lavish Editor server. |
249
+ | Command | Description |
250
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
251
+ | `lavish-axi` | Show current sessions and usage guidance. |
252
+ | `lavish-axi update` | Check for or apply the latest npm release through the AXI SDK self-updater. |
253
+ | `lavish-axi <html-file>` | Open or resume a Lavish Editor session, with the open-time layout gate enabled by default. Unresolved layout issues from earlier in the session are preserved. Refuses to reopen a session the user explicitly ended from the browser unless `--reopen` is passed. |
254
+ | `lavish-axi poll <html-file>` | Long-poll until the user sends feedback or ends the session; detected layout issues wait in the user's Layout issues inbox and arrive only when queued. Leave no-timeout polls running, or re-run them if interrupted. Codex guidance keeps polls attached to the active turn. On `status: ended`, stop polling and do not reopen uninvited. |
255
+ | `lavish-axi end <html-file>` | End a session as the agent; unlike a user-initiated end from the browser, this still allows a plain reopen later. |
256
+ | `lavish-axi export <html-file>` | Write a portable copy of the artifact: one HTML file with its local assets inlined, so it opens with no server and no sibling files. Remote CDN/font references are left as links. |
257
+ | `lavish-axi share <html-file>` | Publish the artifact (local assets inlined) to [ht-ml.app](https://ht-ml.app), a third-party host not part of Lavish, and print a visitable URL plus a secret update key; shares are public by default, `--private` locks one behind a generated password, and the same command republishes or unpublishes an existing page with `--site`/`--update-key`. |
258
+ | `lavish-axi stop` | Shut down the background server. |
259
+ | `lavish-axi playbook [id]` | List focused artifact guidance or show one playbook; agents must open each matching playbook before writing HTML. |
260
+ | `lavish-axi design` | Show agent-facing design guidance, including optional CDN snippets and the whiteboard (Mermaid) opt-in snippet. |
261
+ | `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward. |
262
+ | `lavish-axi setup plugin` | Register the installed package as an [Agent Plugin](https://agent-plugins.org) in VS Code, Cursor, and GitHub Copilot CLI; opt-in, idempotent, no marketplace involved. Reload each client afterward. |
263
+ | `lavish-axi server` | Run the local Lavish Editor server. |
261
264
 
262
265
  Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `code`, `input`, `slides`.
263
266
  One artifact often combines several playbooks, such as a plan that includes a comparison and a diagram, so agents must match against each `use_when` trigger and open every matching playbook before writing HTML.
@@ -272,8 +275,12 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
272
275
  | `lavish-axi <html-file>` | `--reopen` | Reopen a session the user explicitly ended from the browser; without it, a plain open refuses and explains why instead of reopening uninvited. |
273
276
  | `lavish-axi update` | `--check` | Report current vs latest npm version without installing an update. |
274
277
  | `lavish-axi export` | `--out <path>` | Write the export to a specific path instead of `<name>.export.html` next to the source. |
275
- | `lavish-axi share` | `--password <pw>` | Make the third-party ht-ml.app page private; viewers must supply the password. |
276
- | `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`); never required to publish. |
278
+ | `lavish-axi share` | `--private` | Make the page private behind a password Lavish generates and returns once; hand it to viewers as a shared secret. |
279
+ | `lavish-axi share` | `--password <pw>` | Make the third-party ht-ml.app page private with a password you supply; viewers must supply the password. An empty or whitespace-only value (an unquoted `$PW` that is unset) is refused rather than publishing a public page. |
280
+ | `lavish-axi share` | `--site <site_id>` | Republish an existing page in place (with `--update-key`) instead of creating a new one; the URL does not change. |
281
+ | `lavish-axi share` | `--update-key <key>` | The secret returned when the page was published; required to republish or unpublish it. |
282
+ | `lavish-axi share` | `--unpublish` | Replace a published page with a locked placeholder (ht-ml.app cannot delete); takes `--site` and `--update-key` and no file. |
283
+ | `lavish-axi share` | `--token <t>` | Attach an optional bearer token (`LAVISH_AXI_HTML_APP_TOKEN`) when creating a page; never required, and rejected on a republish or `--unpublish`, where the `update_key` is the credential. |
277
284
  | `lavish-axi poll` | `--agent-reply "..."` | Show the agent's reply in the existing browser chat, conclude delivered work, and return presence to waiting before polling again. |
278
285
  | `lavish-axi poll` | `--timeout-ms <ms>` | Test/debug escape hatch only; agents should normally omit it and leave the long poll running. |
279
286
  | `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
@@ -133,8 +133,18 @@ const shareStatus = /** @type {HTMLDivElement} */ (document.getElementById("shar
133
133
  const shareResult = /** @type {HTMLDivElement} */ (document.getElementById("shareResult"));
134
134
  const shareUrlInput = /** @type {HTMLInputElement} */ (document.getElementById("shareUrl"));
135
135
  const shareUpdateKeyInput = /** @type {HTMLInputElement} */ (document.getElementById("shareUpdateKey"));
136
+ const shareGenerateInput = /** @type {HTMLInputElement} */ (document.getElementById("shareGenerate"));
137
+ const sharePasswordResult = /** @type {HTMLLabelElement} */ (document.getElementById("sharePasswordResult"));
138
+ const shareUrlResult = /** @type {HTMLLabelElement} */ (document.getElementById("shareUrlResult"));
139
+ const shareUpdateKeyResult = /** @type {HTMLLabelElement} */ (document.getElementById("shareUpdateKeyResult"));
140
+ const shareUpdateKeyNote = /** @type {HTMLParagraphElement} */ (document.getElementById("shareUpdateKeyNote"));
141
+ const shareSiteIdResult = /** @type {HTMLLabelElement} */ (document.getElementById("shareSiteIdResult"));
142
+ const shareSiteIdInput = /** @type {HTMLInputElement} */ (document.getElementById("shareSiteId"));
143
+ const sharePasswordOutput = /** @type {HTMLInputElement} */ (document.getElementById("sharePasswordOut"));
144
+ const copySharePasswordButton = /** @type {HTMLButtonElement} */ (document.getElementById("copySharePassword"));
136
145
  const copyShareUrlButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyShareUrl"));
137
146
  const copyUpdateKeyButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyUpdateKey"));
147
+ const copyShareSiteIdButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyShareSiteId"));
138
148
  const endButton = /** @type {HTMLButtonElement} */ (document.getElementById("end"));
139
149
  const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
140
150
  const copyHint = /** @type {HTMLSpanElement} */ (document.getElementById("copyHint"));
@@ -151,6 +161,8 @@ const layoutGateOverlay = /** @type {HTMLDivElement} */ (document.getElementById
151
161
  const layoutGateTitle = /** @type {HTMLDivElement} */ (document.getElementById("layoutGateTitle"));
152
162
  const layoutGateCopy = /** @type {HTMLParagraphElement} */ (document.getElementById("layoutGateCopy"));
153
163
  const layoutGateAction = /** @type {HTMLButtonElement} */ (document.getElementById("layoutGateAction"));
164
+ const layoutGateBypass = /** @type {HTMLButtonElement} */ (document.getElementById("layoutGateBypass"));
165
+ const layoutGateEscape = /** @type {any} */ (window).__lavishLayoutGateEscape;
154
166
  const warningsWrap = /** @type {HTMLDivElement} */ (document.getElementById("warningsWrap"));
155
167
  const warningsButton = /** @type {HTMLButtonElement} */ (document.getElementById("warningsButton"));
156
168
  const warningsCount = /** @type {HTMLSpanElement} */ (document.getElementById("warningsCount"));
@@ -185,7 +197,6 @@ let outdatedReloadInFlight = false;
185
197
  let unrestorableDraftMiss = null;
186
198
  let retiredDrafts = loadRetiredDrafts();
187
199
  let layoutGateVisible = false;
188
- let layoutGateArmed = false;
189
200
  let layoutGateManuallyBypassed = !layoutGateEnabled;
190
201
  let layoutGateFailureActive = false;
191
202
  // A failure only the user can retire. The artifact-load card clears itself once a load succeeds;
@@ -1261,6 +1272,7 @@ function normalizeLayoutFindings(value) {
1261
1272
  }
1262
1273
 
1263
1274
  function clearLayoutGateTimer() {
1275
+ layoutGateEscape?.cancel?.();
1264
1276
  if (layoutGateTimer) clearTimeout(layoutGateTimer);
1265
1277
  layoutGateTimer = undefined;
1266
1278
  }
@@ -1289,7 +1301,8 @@ function setLayoutGateActive(active) {
1289
1301
  // its own: the artifact never loaded and retrying stopped helping. The overlay is reused because
1290
1302
  // it already covers the empty artifact area; without this the user is left looking at either a
1291
1303
  // spinner that never resolves or a blank frame, with nothing explaining it and nothing to click.
1292
- // Bumping the cycle retires any pending reveal timer so a stale one cannot hide this card.
1304
+ // Bumping the cycle invalidates the previous timer; setLayoutGateFailure immediately replaces it
1305
+ // with a fresh hold timer so the card cannot strand the visual gate.
1293
1306
  function setLayoutGateFailure(title, copy, actionLabel = "Reload", onAction, { sticky = false } = {}) {
1294
1307
  if (ended) return;
1295
1308
  // A sticky card is the user's to retire, and that has to hold against being overwritten as
@@ -1299,8 +1312,9 @@ function setLayoutGateFailure(title, copy, actionLabel = "Reload", onAction, { s
1299
1312
  layoutGateFailureActive = true;
1300
1313
  layoutGateFailureSticky = sticky;
1301
1314
  layoutGateCycle += 1;
1302
- clearLayoutGateTimer();
1303
- layoutGateArmed = false;
1315
+ // Failure copy must not disable the visual gate's own recovery paths. Keep a fresh hold timer
1316
+ // over the card so a server replacement or any other failure cannot strand the artifact behind
1317
+ // a sticky message forever.
1304
1318
  if (layoutGateTitle) layoutGateTitle.textContent = title;
1305
1319
  if (layoutGateCopy) layoutGateCopy.textContent = copy;
1306
1320
  if (layoutGateAction) {
@@ -1308,7 +1322,12 @@ function setLayoutGateFailure(title, copy, actionLabel = "Reload", onAction, { s
1308
1322
  layoutGateAction.textContent = actionLabel;
1309
1323
  layoutGateAction.onclick = onAction || (() => location.reload());
1310
1324
  }
1325
+ if (layoutGateBypass) {
1326
+ layoutGateBypass.hidden = false;
1327
+ layoutGateBypass.onclick = () => forceRevealLayoutGate("manual");
1328
+ }
1311
1329
  setLayoutGateActive(true);
1330
+ armLayoutGateTimer();
1312
1331
  }
1313
1332
 
1314
1333
  // Every failure card in this feature is raised in a state where the server may not be listening,
@@ -1362,35 +1381,31 @@ function clearLayoutGateFailure() {
1362
1381
  layoutGateAction.textContent = "Show anyway";
1363
1382
  layoutGateAction.onclick = () => forceRevealLayoutGate("manual");
1364
1383
  }
1384
+ if (layoutGateBypass) layoutGateBypass.hidden = true;
1365
1385
  revealLayoutGate();
1366
1386
  }
1367
1387
 
1368
1388
  function revealLayoutGate() {
1369
- if (layoutGateFailureSticky) return;
1370
1389
  clearLayoutGateTimer();
1371
- layoutGateArmed = false;
1390
+ layoutGateEscape?.reveal?.();
1372
1391
  setLayoutGateActive(false);
1373
1392
  }
1374
1393
 
1375
1394
  function forceRevealLayoutGate(reason) {
1376
- if (!layoutGateEnabled || ended) return;
1377
- if (reason === "manual") layoutGateManuallyBypassed = true;
1395
+ if (ended) return;
1396
+ if (reason === "manual") {
1397
+ layoutGateManuallyBypassed = true;
1398
+ layoutGateEscape?.manualReveal?.();
1399
+ }
1378
1400
  revealLayoutGate();
1379
1401
  }
1380
1402
 
1381
- function startLayoutGateCycle() {
1382
- clearLayoutGateFailure();
1383
- // A sticky failure owns the overlay until the user acts on it, so a later load must not repaint
1384
- // the checking card over the message it left there.
1385
- if (layoutGateFailureSticky) return;
1386
- if (!layoutGateEnabled || layoutGateManuallyBypassed || ended) return;
1387
-
1388
- layoutGateCycle += 1;
1389
- layoutGateArmed = true;
1390
- setLayoutGateCard("checking");
1391
- setLayoutGateActive(true);
1403
+ function armLayoutGateTimer() {
1392
1404
  clearLayoutGateTimer();
1393
-
1405
+ if (layoutGateEscape?.arm) {
1406
+ layoutGateEscape.arm(layoutGateMaxHoldMs, () => forceRevealLayoutGate("timeout"));
1407
+ return;
1408
+ }
1394
1409
  const cycle = layoutGateCycle;
1395
1410
  layoutGateTimer = setTimeout(() => {
1396
1411
  if (cycle !== layoutGateCycle || !layoutGateVisible || ended) return;
@@ -1399,22 +1414,35 @@ function startLayoutGateCycle() {
1399
1414
  layoutGateTimer?.unref?.();
1400
1415
  }
1401
1416
 
1417
+ function startLayoutGateCycle() {
1418
+ clearLayoutGateFailure();
1419
+ if (!layoutGateEnabled || layoutGateManuallyBypassed || ended) return;
1420
+
1421
+ layoutGateCycle += 1;
1422
+ setLayoutGateActive(true);
1423
+ // A sticky failure owns the card copy, but never the reveal. Do not repaint it as a checking
1424
+ // card, and do arm a fresh timer for reloads that happen while the sticky card is present.
1425
+ if (!layoutGateFailureSticky) setLayoutGateCard("checking");
1426
+ armLayoutGateTimer();
1427
+ }
1428
+
1402
1429
  // The gate only waits for fonts and final geometry now. It never holds the artifact hostage
1403
1430
  // pending an agent repair: findings are the user's to triage, so a completed pass always reveals
1404
1431
  // and hands the result to the passive inbox.
1405
1432
  function handleLayoutGatePass() {
1406
- if (!layoutGateEnabled || layoutGateManuallyBypassed) return;
1407
- if (!layoutGateArmed && !layoutGateVisible) return;
1433
+ if (ended || !layoutGateVisible) return;
1408
1434
  revealLayoutGate();
1409
1435
  }
1410
1436
 
1411
1437
  function initializeLayoutGate() {
1438
+ if (layoutGateEscape?.isManuallyBypassed?.()) layoutGateManuallyBypassed = true;
1412
1439
  if (!layoutGateEnabled) {
1413
1440
  setLayoutGateActive(false);
1414
1441
  return;
1415
1442
  }
1416
1443
 
1417
1444
  if (layoutGateAction) layoutGateAction.onclick = () => forceRevealLayoutGate("manual");
1445
+ if (layoutGateBypass) layoutGateBypass.onclick = () => forceRevealLayoutGate("manual");
1418
1446
  startLayoutGateCycle();
1419
1447
  }
1420
1448
 
@@ -1801,6 +1829,7 @@ function markSessionEnded() {
1801
1829
  layoutGateManuallyBypassed = true;
1802
1830
  layoutGateFailureSticky = false;
1803
1831
  revealLayoutGate();
1832
+ layoutGateEscape?.end?.();
1804
1833
  postToFrame({ type: "lavish:setAnnotationMode", enabled: false });
1805
1834
  endedOverlay.hidden = false;
1806
1835
  }
@@ -1879,16 +1908,68 @@ async function exportArtifact() {
1879
1908
  }
1880
1909
  }
1881
1910
 
1911
+ // ONE owner for the whole result panel. Every path that renders an outcome - dialog open, a
1912
+ // successful publish, a retry after any failure, an indeterminate report, an incomplete 200 -
1913
+ // routes through here, because a row shown or hidden by one path and reset by another is how a
1914
+ // retry after a failed publish came to display "Published" with the once-only update_key still
1915
+ // hidden. Each row is derived from the value it would show, so nothing can be half-rendered.
1916
+ function renderShareResult({ url = "", siteId = "", password = "", updateKey = "" } = {}) {
1917
+ shareUrlInput.value = url;
1918
+ shareUrlResult.hidden = !url;
1919
+ // A self-hosted backend may not return one, and an empty box with a copy button is worse than
1920
+ // no row. Never derive it from the URL: that shape belongs to the backend, not Lavish.
1921
+ shareSiteIdInput.value = siteId;
1922
+ shareSiteIdResult.hidden = !siteId;
1923
+ sharePasswordOutput.value = password;
1924
+ sharePasswordResult.hidden = !password;
1925
+ shareUpdateKeyInput.value = updateKey;
1926
+ shareUpdateKeyResult.hidden = !updateKey;
1927
+ // The note's own copy tells the user to republish with `--site <site id> --update-key <key>`,
1928
+ // so it may only appear when BOTH halves of that credential are on screen. An update key with
1929
+ // no usable site id cannot update anything, and the status line says so instead.
1930
+ shareUpdateKeyNote.hidden = !(updateKey && siteId);
1931
+ shareResult.hidden = !(url || siteId || password || updateKey);
1932
+ // Returned so every sentence in the status line is derived from what was actually rendered.
1933
+ // Copy stamped from the request instead has promised rows the panel does not contain.
1934
+ return { url, siteId, password, updateKey };
1935
+ }
1936
+
1937
+ // Wording shared with the CLI's next_step for the same condition, so the two surfaces cannot
1938
+ // drift into describing the same dead end differently.
1939
+ const NO_SITE_ID_WARNING =
1940
+ " The host did not return a site id Lavish can use, and --site is half the republish credential, so this page can NEVER be republished or unpublished even though its update key is in hand.";
1941
+
1942
+ // What the page is gated behind, said only in terms of what the panel can show. A password the
1943
+ // user typed is never echoed by the server, so pointing at a row that was not rendered is the
1944
+ // same confidently-wrong sentence this feature exists to avoid.
1945
+ function publishedVisibilityText(isPublic, rendered, publicText) {
1946
+ if (isPublic) return publicText;
1947
+ return rendered.password ? "behind the password below" : "behind the password you supplied";
1948
+ }
1949
+
1882
1950
  function openShareDialog() {
1883
1951
  closeMenus();
1884
1952
  shareDialog.hidden = false;
1885
1953
  shareStatus.textContent = "";
1886
1954
  shareStatus.classList.remove("error");
1887
- shareResult.hidden = true;
1955
+ renderShareResult();
1956
+ shareGenerateInput.checked = false;
1888
1957
  sharePasswordInput.value = "";
1958
+ syncSharePasswordInput();
1889
1959
  sharePasswordInput.focus();
1890
1960
  }
1891
1961
 
1962
+ // A generated password and a typed one are the same field to the server, so the checkbox owns
1963
+ // the input rather than the two racing to decide what gets published.
1964
+ function syncSharePasswordInput() {
1965
+ const generating = shareGenerateInput.checked;
1966
+ sharePasswordInput.disabled = generating;
1967
+ sharePasswordInput.placeholder = generating
1968
+ ? "Lavish will generate one when you publish"
1969
+ : "Leave blank for a public page";
1970
+ if (generating) sharePasswordInput.value = "";
1971
+ }
1972
+
1892
1973
  function closeShareDialog() {
1893
1974
  shareDialog.hidden = true;
1894
1975
  }
@@ -1901,24 +1982,83 @@ async function copyToButton(value, button, label) {
1901
1982
  }, 1200);
1902
1983
  }
1903
1984
 
1985
+ function reportIndeterminatePublish(data) {
1986
+ const rendered = renderShareResult({ password: data.password || "" });
1987
+ shareStatus.classList.add("error");
1988
+ const reason = data.error ? data.error + " " : "";
1989
+ const visibility = publishedVisibilityText(data.public, rendered, "PUBLIC - anyone with the link could read it");
1990
+ shareStatus.textContent =
1991
+ reason +
1992
+ "ht-ml.app may or may not have published this page, so treat the outcome as unknown. If it did publish, the page is live " +
1993
+ visibility +
1994
+ ", and its URL and update key were lost with the failed response, so it can never be republished or unpublished. Publishing again creates a SECOND page rather than replacing it." +
1995
+ (rendered.password ? " Copy the password now - it is shown once here and Lavish does not store it." : "");
1996
+ }
1997
+
1998
+ // An incomplete 200 is NOT an unknown outcome: the host answered, so the page landed. Whatever
1999
+ // fields did arrive are rendered, because a url with no update_key names a live, public-by-default
2000
+ // page whose only write credential is gone - and saying "may or may not" there would throw away
2001
+ // the address Lavish is holding.
2002
+ function reportIncompletePublish(data) {
2003
+ const rendered = renderShareResult({
2004
+ url: data.url || "",
2005
+ siteId: data.site_id || "",
2006
+ password: data.password || "",
2007
+ updateKey: data.update_key || "",
2008
+ });
2009
+ shareStatus.classList.add("error");
2010
+ const visibility = publishedVisibilityText(data.public, rendered, "PUBLIC - anyone with the link can read it");
2011
+ const updateKeyNote = !rendered.updateKey
2012
+ ? "No update key came back, and ht-ml.app issues one only once and has no delete, so this page can never be republished or unpublished. "
2013
+ : rendered.siteId
2014
+ ? "Copy the update key below - it is issued once. "
2015
+ : "Copy the update key below - it is issued once, though" + NO_SITE_ID_WARNING.slice(1) + " ";
2016
+ shareStatus.textContent =
2017
+ "ht-ml.app accepted this publish, so the page IS live and " +
2018
+ visibility +
2019
+ ", but its response was malformed and Lavish could not read the whole result back. " +
2020
+ (rendered.url ? "Its address is below. " : "The response carried no URL, so Lavish cannot show the address. ") +
2021
+ updateKeyNote +
2022
+ "Publishing again creates a SECOND page rather than replacing it." +
2023
+ (rendered.password ? " Copy the password now - it is shown once here and Lavish does not store it." : "");
2024
+ }
2025
+
1904
2026
  async function publishShare(event) {
1905
2027
  event.preventDefault();
1906
2028
  sharePublishButton.disabled = true;
1907
2029
  shareStatus.classList.remove("error");
1908
2030
  shareStatus.textContent = "Publishing to ht-ml.app...";
1909
- shareResult.hidden = true;
1910
- const password = sharePasswordInput.value.trim();
1911
- const passwordProtected = Boolean(password);
2031
+ renderShareResult();
2032
+ const generating = shareGenerateInput.checked;
2033
+ const password = generating ? "" : sharePasswordInput.value.trim();
2034
+ const passwordProtected = generating || Boolean(password);
1912
2035
  try {
1913
2036
  const response = await fetch("/api/" + key + "/share", {
1914
2037
  method: "POST",
1915
2038
  headers: { "content-type": "application/json" },
1916
- body: JSON.stringify(password ? { password } : {}),
2039
+ body: JSON.stringify(generating ? { generate_password: true } : password ? { password } : {}),
1917
2040
  });
1918
2041
  const data = await response.json();
1919
- if (!response.ok) throw new Error(data.error || "publish failed");
1920
- shareUrlInput.value = data.url || "";
1921
- shareUpdateKeyInput.value = data.update_key || "";
2042
+ if (!response.ok) {
2043
+ // Only a host rejection proves nothing was published. On anything else the page may already
2044
+ // be live, and a password minted for this request is the one thing that could still open it,
2045
+ // so it is shown here rather than dying with the failed response.
2046
+ if (data.outcome === "published-incomplete") {
2047
+ reportIncompletePublish(data);
2048
+ return;
2049
+ }
2050
+ if (data.outcome === "indeterminate") {
2051
+ reportIndeterminatePublish(data);
2052
+ return;
2053
+ }
2054
+ throw new Error(data.error || "publish failed");
2055
+ }
2056
+ const rendered = renderShareResult({
2057
+ url: data.url || "",
2058
+ siteId: data.site_id || "",
2059
+ password: data.password || "",
2060
+ updateKey: data.update_key || "",
2061
+ });
1922
2062
  const unresolvedAssets = Array.isArray(data.unresolved_local_assets) ? data.unresolved_local_assets : [];
1923
2063
  const notices = Array.isArray(data.notices) ? data.notices : [];
1924
2064
  const warningCount = unresolvedAssets.length;
@@ -1932,10 +2072,16 @@ async function publishShare(event) {
1932
2072
  : passwordProtected
1933
2073
  ? "Published. This page is PASSWORD-PROTECTED; viewers also need the password."
1934
2074
  : "Published. Anyone with the link can view this page.";
1935
- shareResult.hidden = false;
2075
+ if (rendered.updateKey && !rendered.siteId) shareStatus.textContent += NO_SITE_ID_WARNING;
2076
+ if (rendered.password) {
2077
+ shareStatus.textContent += " Copy the password now - it is shown once here and Lavish does not store it.";
2078
+ }
1936
2079
  shareUrlInput.focus();
1937
2080
  shareUrlInput.select();
1938
2081
  } catch (error) {
2082
+ // Deliberately does NOT clear the panel. It is already cleared before the fetch, so the only
2083
+ // thing this could reach is a result the success path already rendered - and wiping that
2084
+ // destroys the once-issued update_key of a page that definitely published.
1939
2085
  shareStatus.classList.add("error");
1940
2086
  shareStatus.textContent = error instanceof Error ? error.message : String(error);
1941
2087
  } finally {
@@ -2737,14 +2883,24 @@ window.addEventListener("message", (event) => {
2737
2883
 
2738
2884
  const msg = event.data || {};
2739
2885
  const messageToken = String(msg.artifact_load_token || "");
2740
- if (messageToken !== artifactLoadToken) return;
2886
+ if (messageToken !== artifactLoadToken) {
2887
+ // A pass can be stamped by the load that just lost a token race. Ask the current artifact
2888
+ // document to run the audit again instead of consuming the only pass for this cycle.
2889
+ if (msg.type === "lavish:layoutDiagnostics") postToFrame({ type: "lavish:requestLayoutDiagnostics" });
2890
+ return;
2891
+ }
2741
2892
  const messageSequence = ++artifactMessageSequence;
2742
2893
  artifactSpokeToken = messageToken;
2743
2894
  clearTimeout(artifactSilenceTimer);
2744
2895
  if (msg.type === "lavish:layoutDiagnostics") {
2745
2896
  const diagnosticSequence = ++layoutDiagnosticSequence;
2897
+ const complete = msg.complete !== false;
2898
+ // The gate is visual, so the client-side settled pass is the release signal. Reporting the
2899
+ // pass is deliberately fire-and-forget: a server restart or a diagnostics 4xx/5xx must not
2900
+ // hold a rendered artifact hostage to a network round-trip.
2901
+ if (complete) handleLayoutGatePass();
2746
2902
  submitLayoutDiagnostics({
2747
- complete: msg.complete !== false,
2903
+ complete,
2748
2904
  targetPresenceComplete: msg.target_presence_complete === true,
2749
2905
  artifactRevision: msg.artifact_revision,
2750
2906
  artifactLoadToken: msg.artifact_load_token,
@@ -2759,9 +2915,14 @@ window.addEventListener("message", (event) => {
2759
2915
  if (messageSequence === artifactMessageSequence) armArtifactAvailabilityProbe(messageToken);
2760
2916
  return;
2761
2917
  }
2762
- if (msg.complete !== false) handleLayoutGatePass();
2763
2918
  })
2764
- .catch(() => {});
2919
+ .catch(() => {
2920
+ // A failed report is still a completed client-side pass. Keep this fallback explicit so a
2921
+ // future change cannot accidentally make the network request the gate's release path.
2922
+ if (complete && messageToken === artifactLoadToken && diagnosticSequence === layoutDiagnosticSequence) {
2923
+ handleLayoutGatePass();
2924
+ }
2925
+ });
2765
2926
  return;
2766
2927
  }
2767
2928
  // The artifact spoke, so it rendered and ran its SDK - there is nothing fatal to probe for.
@@ -3053,6 +3214,10 @@ shareDialog.addEventListener("click", (event) => {
3053
3214
  });
3054
3215
  copyShareUrlButton.onclick = () => copyToButton(shareUrlInput.value, copyShareUrlButton, "Copy URL");
3055
3216
  copyUpdateKeyButton.onclick = () => copyToButton(shareUpdateKeyInput.value, copyUpdateKeyButton, "Copy key");
3217
+ copySharePasswordButton.onclick = () =>
3218
+ copyToButton(sharePasswordOutput.value, copySharePasswordButton, "Copy password");
3219
+ copyShareSiteIdButton.onclick = () => copyToButton(shareSiteIdInput.value, copyShareSiteIdButton, "Copy site ID");
3220
+ shareGenerateInput.onchange = syncSharePasswordInput;
3056
3221
  endButton.onclick = () => {
3057
3222
  closeMenus();
3058
3223
  endSession();
@@ -3148,9 +3313,9 @@ setAgentPresence("waiting");
3148
3313
  // to wait for - start read-only instead of looking live until a Send gets silently refused.
3149
3314
  if (sessionData.initialEnded) markSessionEnded();
3150
3315
 
3151
- // Reaching this line is the only proof that this file parsed and ran to completion. The page it
3152
- // bootstraps ships with the layout-gate overlay already covering the artifact, and only this
3153
- // script ever takes it down - so the inline failsafe in the page holds it up until here.
3316
+ // Reaching this line is the only proof that this file parsed and ran to completion. The inline
3317
+ // bootstrap already owns the gate's bounded escape if this script fails; retire only its separate
3318
+ // boot-failure timer now that the full client has taken over.
3154
3319
  const chromeBootWindow = /** @type {Record<string, any>} */ (/** @type {unknown} */ (window));
3155
3320
  chromeBootWindow.__lavishChromeReady = true;
3156
3321
  chromeBootWindow.__lavishCancelChromeBootFailsafe?.();
package/dist/chrome.css CHANGED
@@ -803,6 +803,23 @@ body.lavish {
803
803
  .share-grid input::placeholder {
804
804
  color: var(--fg-label);
805
805
  }
806
+ .share-grid .share-check {
807
+ grid-template-columns: auto 1fr;
808
+ grid-auto-flow: column;
809
+ align-items: center;
810
+ gap: var(--space-6);
811
+ color: var(--fg-muted);
812
+ font-size: var(--text-sm);
813
+ font-weight: var(--w-regular);
814
+ letter-spacing: 0;
815
+ text-transform: none;
816
+ cursor: pointer;
817
+ }
818
+ .share-grid .share-check input {
819
+ width: auto;
820
+ padding: 0;
821
+ accent-color: var(--accent);
822
+ }
806
823
  .share-status {
807
824
  min-height: 20px;
808
825
  margin-top: var(--space-8);
@@ -824,6 +841,9 @@ body.lavish {
824
841
  .share-result[hidden] {
825
842
  display: none;
826
843
  }
844
+ .share-result label[hidden] {
845
+ display: none;
846
+ }
827
847
  .share-copy-row {
828
848
  display: flex;
829
849
  gap: var(--space-6);
@@ -1328,6 +1348,9 @@ body.lavish {
1328
1348
  .ended-action {
1329
1349
  margin-top: var(--space-8);
1330
1350
  }
1351
+ .layout-gate-bypass {
1352
+ margin-left: var(--space-4);
1353
+ }
1331
1354
  iframe {
1332
1355
  width: 100%;
1333
1356
  height: 100%;