lavish-axi 0.1.59 → 0.1.61

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.
@@ -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"));
@@ -1898,16 +1908,68 @@ async function exportArtifact() {
1898
1908
  }
1899
1909
  }
1900
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
+
1901
1950
  function openShareDialog() {
1902
1951
  closeMenus();
1903
1952
  shareDialog.hidden = false;
1904
1953
  shareStatus.textContent = "";
1905
1954
  shareStatus.classList.remove("error");
1906
- shareResult.hidden = true;
1955
+ renderShareResult();
1956
+ shareGenerateInput.checked = false;
1907
1957
  sharePasswordInput.value = "";
1958
+ syncSharePasswordInput();
1908
1959
  sharePasswordInput.focus();
1909
1960
  }
1910
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
+
1911
1973
  function closeShareDialog() {
1912
1974
  shareDialog.hidden = true;
1913
1975
  }
@@ -1920,24 +1982,83 @@ async function copyToButton(value, button, label) {
1920
1982
  }, 1200);
1921
1983
  }
1922
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
+
1923
2026
  async function publishShare(event) {
1924
2027
  event.preventDefault();
1925
2028
  sharePublishButton.disabled = true;
1926
2029
  shareStatus.classList.remove("error");
1927
2030
  shareStatus.textContent = "Publishing to ht-ml.app...";
1928
- shareResult.hidden = true;
1929
- const password = sharePasswordInput.value.trim();
1930
- const passwordProtected = Boolean(password);
2031
+ renderShareResult();
2032
+ const generating = shareGenerateInput.checked;
2033
+ const password = generating ? "" : sharePasswordInput.value.trim();
2034
+ const passwordProtected = generating || Boolean(password);
1931
2035
  try {
1932
2036
  const response = await fetch("/api/" + key + "/share", {
1933
2037
  method: "POST",
1934
2038
  headers: { "content-type": "application/json" },
1935
- body: JSON.stringify(password ? { password } : {}),
2039
+ body: JSON.stringify(generating ? { generate_password: true } : password ? { password } : {}),
1936
2040
  });
1937
2041
  const data = await response.json();
1938
- if (!response.ok) throw new Error(data.error || "publish failed");
1939
- shareUrlInput.value = data.url || "";
1940
- 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
+ });
1941
2062
  const unresolvedAssets = Array.isArray(data.unresolved_local_assets) ? data.unresolved_local_assets : [];
1942
2063
  const notices = Array.isArray(data.notices) ? data.notices : [];
1943
2064
  const warningCount = unresolvedAssets.length;
@@ -1951,10 +2072,16 @@ async function publishShare(event) {
1951
2072
  : passwordProtected
1952
2073
  ? "Published. This page is PASSWORD-PROTECTED; viewers also need the password."
1953
2074
  : "Published. Anyone with the link can view this page.";
1954
- 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
+ }
1955
2079
  shareUrlInput.focus();
1956
2080
  shareUrlInput.select();
1957
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.
1958
2085
  shareStatus.classList.add("error");
1959
2086
  shareStatus.textContent = error instanceof Error ? error.message : String(error);
1960
2087
  } finally {
@@ -3087,6 +3214,10 @@ shareDialog.addEventListener("click", (event) => {
3087
3214
  });
3088
3215
  copyShareUrlButton.onclick = () => copyToButton(shareUrlInput.value, copyShareUrlButton, "Copy URL");
3089
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;
3090
3221
  endButton.onclick = () => {
3091
3222
  closeMenus();
3092
3223
  endSession();
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);