lavish-axi 0.1.47 → 0.1.49

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
@@ -162,6 +162,8 @@ pnpm link
162
162
  - **Portable artifacts** - The artifact runs in an iframe while Lavish injects a small SDK for annotations, snapshots, feedback controls, and render-time layout checks.
163
163
  Lavish does not inject any design system, so the saved HTML file renders identically whether you open it through `lavish-axi` or directly in a browser.
164
164
  Run `lavish-axi design` for the single source of agent-facing design guidance and optional CDN or Mermaid snippets.
165
+ - **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`.
166
+ 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.
165
167
  - **Open-time layout gate** - The browser chrome masks an artifact only while the real in-iframe audit waits for fonts and final geometry.
166
168
  The first completed check always reveals the artifact, whatever it found; the gate never holds the review hostage waiting for a repair.
167
169
  The user can click **Show anyway**, and a bounded safety timeout fails open when no check has completed.
package/dist/cli.mjs CHANGED
@@ -447,7 +447,7 @@ function createDesignOutput() {
447
447
  playbooks: listPlaybooks()
448
448
  },
449
449
  design: {
450
- summary: "Use this Lavish CDN fallback only if (1) the user gave no design direction and (2) you already inspected the project the artifact is about and found no design system or style conventions to match. If you have not checked the subject project yet, check first. Lavish does not auto-inject any design system; artifacts stay portable HTML. " + DESIGN_PRIORITY_RULE + " Paste the CDN snippet below into your `<head>`.",
450
+ summary: "Use this Lavish CDN fallback only if (1) the user gave no design direction and (2) you already inspected the project the artifact is about and found no design system or style conventions to match. If you have not checked the subject project yet, check first. Lavish does not auto-inject any design system; artifacts stay portable HTML. Paint an explicit page background and readable text. " + DESIGN_PRIORITY_RULE + " Paste the CDN snippet below into your `<head>`.",
451
451
  cdn_snippet: DESIGN_CDN_SNIPPET,
452
452
  cdn_urls: DESIGN_CDN_URLS,
453
453
  versions: { tailwind: TAILWIND_BROWSER_VERSION, daisyui: DAISYUI_VERSION },
@@ -4450,11 +4450,53 @@ function linkFailureReason(error) {
4450
4450
  return message || "link creation failed";
4451
4451
  }
4452
4452
 
4453
+ // src/self-paint.js
4454
+ var ROOT_TAG_RE = /<(?:html|body)\b([^>]*)>/gi;
4455
+ var STYLE_BLOCK_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
4456
+ var CSS_RULE_RE = /([^{}]+)\{([^{}]*)\}/g;
4457
+ var ROOT_SELECTOR_TOKEN_RE = /(^|[\s,>~+])(html|body|:root|\*)(?![\w-])/i;
4458
+ var SELF_PAINT_WARNING = "This artifact never paints its own page surface: no background on html/body/:root, no bg-* class or data-theme on html/body, and no stylesheet that could set one. Lavish injects no design system, so text that assumes a dark or light host surface can render invisible. Set an explicit background and readable text.";
4459
+ function analyzeSelfPaint(html) {
4460
+ const source = typeof html === "string" ? html : "";
4461
+ if (/<link\b[^>]*\brel\s*=\s*["']?[^"'>]*stylesheet/i.test(source)) {
4462
+ return { painted: true, signal: "stylesheet-link" };
4463
+ }
4464
+ if (/<script\b[^>]*\bsrc\s*=\s*["']?[^"'>]*tailwind/i.test(source)) {
4465
+ return { painted: true, signal: "tailwind-runtime" };
4466
+ }
4467
+ if (/<meta\b[^>]*\bname\s*=\s*["']?color-scheme/i.test(source)) {
4468
+ return { painted: true, signal: "color-scheme" };
4469
+ }
4470
+ for (const [, attrs] of source.matchAll(ROOT_TAG_RE)) {
4471
+ if (/\bdata-theme\s*=/i.test(attrs)) return { painted: true, signal: "data-theme" };
4472
+ const className = attrValue(attrs, "class");
4473
+ if (className && /(^|[^\w-])bg-/i.test(className)) return { painted: true, signal: "background-class" };
4474
+ const style = attrValue(attrs, "style");
4475
+ if (style && /background/i.test(style)) return { painted: true, signal: "inline-background" };
4476
+ if (style && /color-scheme\s*:/i.test(style)) return { painted: true, signal: "color-scheme" };
4477
+ }
4478
+ for (const [, css] of source.matchAll(STYLE_BLOCK_RE)) {
4479
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
4480
+ if (/@import\b/i.test(stripped)) return { painted: true, signal: "css-import" };
4481
+ if (/color-scheme\s*:/i.test(stripped)) return { painted: true, signal: "color-scheme" };
4482
+ for (const [, selector, declarations] of stripped.matchAll(CSS_RULE_RE)) {
4483
+ if (!/background/i.test(declarations)) continue;
4484
+ if (ROOT_SELECTOR_TOKEN_RE.test(selector)) return { painted: true, signal: "root-background-rule" };
4485
+ }
4486
+ }
4487
+ return { painted: false, signal: null };
4488
+ }
4489
+ function attrValue(attrs, name) {
4490
+ const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
4491
+ if (!match) return null;
4492
+ return match[1] ?? match[2] ?? match[3] ?? null;
4493
+ }
4494
+
4453
4495
  // src/server.js
4454
4496
  import crypto4 from "node:crypto";
4455
4497
  import { EventEmitter } from "node:events";
4456
4498
  import { existsSync as existsSync2 } from "node:fs";
4457
- import { readFile as readFile4 } from "node:fs/promises";
4499
+ import { readFile as readFile4, realpath as realpath3 } from "node:fs/promises";
4458
4500
  import { homedir } from "node:os";
4459
4501
  import path6 from "node:path";
4460
4502
  import { fileURLToPath as fileURLToPath3 } from "node:url";
@@ -7769,7 +7811,7 @@ async function serve({
7769
7811
  return;
7770
7812
  }
7771
7813
  const root = path6.dirname(session.file);
7772
- const file = resolveArtifactAsset(root, assetPath);
7814
+ const file = await resolveArtifactAsset(root, assetPath);
7773
7815
  if (!file) {
7774
7816
  res.status(403).send("Forbidden");
7775
7817
  return;
@@ -7894,9 +7936,9 @@ data: ${JSON.stringify({ state: computePresence(req.params.key, activePolls, del
7894
7936
  res.setHeader("cache-control", "no-store");
7895
7937
  res.type("html").send(createWhiteboardFrameHtml(createWhiteboardChannelToken(whiteboardChannelSecret)));
7896
7938
  });
7897
- app.get(/^\/whiteboard-assets\/(.+)$/, (req, res, next) => {
7939
+ app.get(/^\/whiteboard-assets\/(.+)$/, async (req, res, next) => {
7898
7940
  try {
7899
- const file = resolveArtifactAsset(whiteboardAssetsDir, req.params[0]);
7941
+ const file = await resolveArtifactAsset(whiteboardAssetsDir, req.params[0]);
7900
7942
  if (!file) {
7901
7943
  res.status(403).send("Forbidden");
7902
7944
  return;
@@ -8192,13 +8234,32 @@ function optionalBodyString(value) {
8192
8234
  const trimmed = String(value ?? "").trim();
8193
8235
  return trimmed || void 0;
8194
8236
  }
8195
- function resolveArtifactAsset(root, assetPath) {
8237
+ async function resolveArtifactAsset(root, assetPath) {
8196
8238
  const file = path6.resolve(root, assetPath);
8197
8239
  const relative = path6.relative(root, file);
8198
8240
  if (relative.startsWith("..") || path6.isAbsolute(relative)) {
8199
8241
  return null;
8200
8242
  }
8201
- return file;
8243
+ let real;
8244
+ try {
8245
+ real = await realpath3(file);
8246
+ } catch (error) {
8247
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
8248
+ return file;
8249
+ }
8250
+ throw error;
8251
+ }
8252
+ let realRoot;
8253
+ try {
8254
+ realRoot = await realpath3(root);
8255
+ } catch {
8256
+ realRoot = path6.resolve(root);
8257
+ }
8258
+ const relativeReal = path6.relative(realRoot, real);
8259
+ if (relativeReal === ".." || relativeReal.startsWith(`..${path6.sep}`) || path6.isAbsolute(relativeReal)) {
8260
+ return null;
8261
+ }
8262
+ return real;
8202
8263
  }
8203
8264
  async function watchSession(session, watchers, events, logEvent, reloadDebounceMs = () => RELOAD_DEBOUNCE_MS) {
8204
8265
  if (watchers.has(session.key)) {
@@ -8674,7 +8735,7 @@ var POLL_WAKE_PATH_RULES = Object.freeze([
8674
8735
  ]);
8675
8736
  var POLL_SEND_AND_END_RULE = "`Send & End` ends the session. Its final feedback is still delivered once. After that response, polling stops, and the agent must not reopen the session uninvited.";
8676
8737
  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.";
8677
- var VERSION = "0.1.47";
8738
+ var VERSION = "0.1.49";
8678
8739
  function detectInvokingAgent(env = process.env) {
8679
8740
  return ["CODEX_SANDBOX", "CODEX_THREAD_ID"].some((key) => Object.hasOwn(env, key)) ? "codex" : "generic";
8680
8741
  }
@@ -8818,10 +8879,12 @@ function createPlaybookOutput(args) {
8818
8879
  }
8819
8880
  return { playbook };
8820
8881
  }
8821
- function createOpenOutput({ file, url, status, agent = "generic" }) {
8882
+ function createOpenOutput({ file, url, status, agent = "generic", selfPaintWarning = void 0 }) {
8883
+ const selfPaintPrefix = selfPaintWarning ? `First fix the unpainted page surface flagged in self_paint_warning and save - Lavish live-reloads the artifact automatically, so you do not need to re-run \`lavish-axi ${file}\`. ` : "";
8822
8884
  return {
8823
8885
  session: { file, url, status },
8824
- next_step: `Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
8886
+ ...selfPaintWarning ? { self_paint_warning: selfPaintWarning } : {},
8887
+ next_step: `${selfPaintPrefix}Do not respond to the user just yet. Now you must run \`lavish-axi poll ${file}\`. This command long-polls until the user sends feedback or ends the session, and it stays silent the whole time - that is normal, never kill it. Layout issues the browser detects do not return this poll; they wait in the user's Layout issues inbox until the user queues them, then arrive as an ordinary tag "layout-warnings" prompt. Do not pass --timeout-ms during normal agent use. ${pollExecutionGuidance({ agent })} After applying feedback, run \`lavish-axi poll ${file} --agent-reply "<message for the user>"\` without --timeout-ms to show your response in Lavish Editor and wait for more feedback. If the user ends the session, stop polling and do not reopen it by re-running \`lavish-axi ${file}\` unless the user asks for further review or something genuinely important needs their visual attention - deliver routine updates directly in this conversation instead. When reopening is warranted, run \`lavish-axi ${file} --reopen\`.`
8825
8888
  };
8826
8889
  }
8827
8890
  function createUserEndedOpenOutput({ file, url }) {
@@ -8837,6 +8900,7 @@ async function openCommand(args) {
8837
8900
  }
8838
8901
  await assertHtmlFile(file);
8839
8902
  const absolute = await canonicalFile(file);
8903
+ const selfPaintWarning = await selfPaintWarningForFile(absolute);
8840
8904
  const noGate = args.includes("--no-gate");
8841
8905
  const reopen = args.includes("--reopen");
8842
8906
  const baseUrl = await ensureServer({ forceRestart: shouldForceRestartForLocalBuild(process.argv[1] || "") });
@@ -8856,9 +8920,17 @@ async function openCommand(args) {
8856
8920
  file: absolute,
8857
8921
  url: response.url,
8858
8922
  status: response.status || "opened",
8859
- agent: detectInvokingAgent(process.env)
8923
+ agent: detectInvokingAgent(process.env),
8924
+ selfPaintWarning
8860
8925
  });
8861
8926
  }
8927
+ async function selfPaintWarningForFile(absolute) {
8928
+ try {
8929
+ return analyzeSelfPaint(await readFile5(absolute, "utf8")).painted ? void 0 : SELF_PAINT_WARNING;
8930
+ } catch {
8931
+ return void 0;
8932
+ }
8933
+ }
8862
8934
  function shouldOpenBrowser(args, env) {
8863
8935
  return !args.includes("--no-open") && env.LAVISH_AXI_NO_OPEN !== "1";
8864
8936
  }
@@ -9020,9 +9092,15 @@ async function exportCommand(args) {
9020
9092
  resolveAbsolute: resolveDesignAssetPath
9021
9093
  });
9022
9094
  await writeFile3(output, html);
9023
- return createExportOutput({ source: absolute, output, html, warnings });
9095
+ return createExportOutput({
9096
+ source: absolute,
9097
+ output,
9098
+ html,
9099
+ warnings,
9100
+ selfPaintWarning: analyzeSelfPaint(source).painted ? void 0 : SELF_PAINT_WARNING
9101
+ });
9024
9102
  }
9025
- function createExportOutput({ source, output, html, warnings }) {
9103
+ function createExportOutput({ source, output, html, warnings, selfPaintWarning = void 0 }) {
9026
9104
  const allWarnings = Array.isArray(warnings) ? warnings : [];
9027
9105
  const { unresolved, notices } = splitExportWarnings(allWarnings);
9028
9106
  const result = {
@@ -9044,6 +9122,10 @@ function createExportOutput({ source, output, html, warnings }) {
9044
9122
  } else {
9045
9123
  result.next_step = `Wrote ${output}. Open it directly or host it anywhere - it needs no Lavish server. Local assets are inlined; remote CDN/font references are left as links, so it needs network to render those.`;
9046
9124
  }
9125
+ if (selfPaintWarning) {
9126
+ result.self_paint_warning = selfPaintWarning;
9127
+ result.next_step = `Fix the unpainted page surface flagged in self_paint_warning and re-run the export before sharing the file - an exported page renders over whatever surface hosts it. ${result.next_step}`;
9128
+ }
9047
9129
  return result;
9048
9130
  }
9049
9131
  function assetWarningSummaries(warnings) {
@@ -9066,9 +9148,15 @@ async function shareCommand(args) {
9066
9148
  resolveAbsolute: resolveDesignAssetPath
9067
9149
  });
9068
9150
  const site = await publishToHtmlApp(html, { password, token });
9069
- return createShareOutput({ source: absolute, site, warnings, passwordProtected: Boolean(password) });
9151
+ return createShareOutput({
9152
+ source: absolute,
9153
+ site,
9154
+ warnings,
9155
+ passwordProtected: Boolean(password),
9156
+ selfPaintWarning: analyzeSelfPaint(source).painted ? void 0 : SELF_PAINT_WARNING
9157
+ });
9070
9158
  }
9071
- function createShareOutput({ source, site, warnings, passwordProtected = false }) {
9159
+ function createShareOutput({ source, site, warnings, passwordProtected = false, selfPaintWarning = void 0 }) {
9072
9160
  const allWarnings = Array.isArray(warnings) ? warnings : [];
9073
9161
  const { unresolved, notices } = splitExportWarnings(allWarnings);
9074
9162
  const isPasswordProtected = Boolean(passwordProtected);
@@ -9099,6 +9187,10 @@ function createShareOutput({ source, site, warnings, passwordProtected = false }
9099
9187
  } else {
9100
9188
  result.next_step = `Published a PUBLIC page that anyone with the link can view: ${site.url} - share this URL with the user. ${noticeNote ? `${noticeNote} ` : ""}The update_key is a secret shown only once; keep it to update or delete the page later (there is no recovery). ` + hostNote;
9101
9189
  }
9190
+ if (selfPaintWarning) {
9191
+ result.self_paint_warning = selfPaintWarning;
9192
+ result.next_step = `Fix the unpainted page surface flagged in self_paint_warning, then re-run the share command and share only its replacement URL - the hosted page renders over ht-ml.app's own surface. ${result.next_step}`;
9193
+ }
9102
9194
  return result;
9103
9195
  }
9104
9196
  async function stopCommand(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavish-axi",
3
- "version": "0.1.47",
3
+ "version": "0.1.49",
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.47",
4
+ "version": "0.1.49",
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",
@@ -32,6 +32,7 @@ Use lavish-axi when the user asks for a visual artifact, HTML explainer, interac
32
32
 
33
33
  1. Create the HTML artifact (default location `.lavish/<name>.html` in the working directory).
34
34
  2. Run `npx -y lavish-axi <html-file>` to open or resume a review session in the browser.
35
+ If the output carries a `self_paint_warning`, fix the unpainted page surface and save before polling - Lavish live-reloads the artifact.
35
36
  3. Run `npx -y lavish-axi poll <html-file>` to long-poll for the user's annotations and queued prompts.
36
37
  On the first poll, prefer `--agent-reply "<one-line summary of what you built and what to review first>"` so the conversation panel opens with context.
37
38
  Browser-detected layout issues are filed passively in the user's Layout issues inbox and arrive as an ordinary `layout-warnings` prompt only when the user selects and queues them. Never edit an issue the user has not queued. The only response that arrives without user action is `artifact_failures`, when the review surface itself is unusable.