Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ A Host-header allowlist middleware (`buildAllowedHostnames`/`isAllowedRequestHos
README's Network binding bullet owns the user-facing env vars and the non-loopback security warning.
Server self-shutdown keys off live connections (browser SSE and agent polls), not session status, so the next `lavish-axi <file>` re-spawns a fresh server and adopts the session from `state.json` when it is still resumable; README's Server cleanup bullet owns the user-facing idle-timeout rules.
State lives at `~/.lavish-axi/state.json` (`LAVISH_AXI_STATE_DIR`), shared across all projects and keyed by a sha256 prefix of the canonicalized file path - the canonical HTML path _is_ the identity, so the CLI never needs opaque session IDs (`src/session-store.js:sessionKey`).
Alongside `sessions`, that file carries a top-level `config` object for device-wide settings (currently `config.theme`, written by `lavish-axi config`). `SessionStore.readState` therefore preserves top-level keys it does not recognize instead of rebuilding state from `sessions` - rebuilding is what used to drop the config on the next session write.

### Request flow

Expand Down Expand Up @@ -154,11 +155,12 @@ No need to explicitly document the telemetry behaviors.
- Annotation handlers ignore native controls (`button`, `input`, `select`, `textarea`, `option`, `label`, `summary`, and editable regions) and their descendants, so they stay interactive without markup; `data-lavish-action` opts custom non-native controls out of annotation and gives them a pointer cursor. Artifact-author guidance for input patterns (`data-lavish-question`, `queueKey`, per-question submits) is owned by the input playbook in `src/playbooks.js`.
- For text annotations, `prompt.selector` is the common ancestor/container selector, not the complete identity. Use the `target` range boundaries and snapshot context to locate the exact selected text.
- For non-whiteboard Mermaid diagram nodes, a click annotates the whole rendered `<g>` node - not the sub-shape under the cursor - and hover highlights the same node. `SessionStore.normalizeTarget` routes these targets through `normalizeMermaidNodeTarget`, which strips them to the fixed `type`/`diagramId`/`nodeId`/`label`/`selector` shape, while text-range and other/legacy targets pass through unchanged.
- `SessionStore` re-reads and re-writes the entire `state.json` on every operation. There's no in-memory cache and no locking - acceptable because writes are infrequent and serialized through the single server process.
- `SessionStore` re-reads and re-writes the entire `state.json` on every operation. There's no in-memory cache and no locking - acceptable only because writes stay serialized through the single server process, so keep every mutation on that path: a state-changing CLI command posts to the running server (`lavish-axi config` -> `POST /api/config`) and writes `state.json` itself only when no server answers `/health`. `writeState` still writes a per-process temp file and renames it into place, so a concurrent or interrupted write can never leave a half-written file that every later `readState` throws on.
- The chrome and the sandboxed artifact document cannot see each other's keyboard events (no `allow-same-origin`), so any keyboard shortcut that must work regardless of focus needs its own capture-phase `document.addEventListener("keydown", ..., true)` in _both_ `src/chrome-client.js` and `src/artifact-sdk.js`, not just one.
The annotate/explore mode toggle hotkey (`MODE_TOGGLE_HOTKEY_KEY`, Cmd/Ctrl+I) is the reference implementation: the chrome owns the mode state and toggles it directly; the SDK side has no mode state of its own, so on catching the hotkey it `postMessage`s `{ type: "lavish:toggleAnnotationMode" }` to the chrome, which drives the exact same `toggleAnnotationMode()` function the on-screen switch's `onclick` calls.
Requiring a modifier (`metaKey || ctrlKey`) is what lets the listener safely call `preventDefault()` without breaking plain typing (including typing the bound letter itself) in the chat box or an annotation-card textarea.
- Tests use `LAVISH_AXI_STATE_DIR` and ephemeral ports to stay isolated. When adding tests that spin up the server, do the same.
- `src/chrome.css` is themed, not dark-only: every color a chrome rule paints must come from a semantic token (`--bg*`, `--fg*`, `--border*`, `--accent*`, `--danger*`, `--hover`, `--scrim*`, `--banner*`, `--shadow-color`) whose value is a `light-dark(light, dark)` pair, because the preference only selects a `color-scheme` on `:root[data-theme-pref]` and the browser resolves each token from there. A literal color or a raw palette variable in a rule (`var(--ink-900)`, `rgba(15, 17, 21, 0.9)`) survives dark mode and silently breaks light mode; a `test/server.test.js` regression pins the token declarations, but nothing catches a literal inside a rule.
- Circular close buttons (`.pill-close`, `.share-close`) render an inline SVG x mark with two symmetric strokes, not a text `x`/`&times;` glyph.
Font metrics put text glyphs off from the geometric center even under flex centering, while the SVG centers via flex plus equal viewBox margins.
Keep the SVG strokes on `currentColor` so existing hover color rules still apply, and follow this pattern for any new circular icon-only button.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ pnpm link
| `lavish-axi design` | Show agent-facing design guidance, including optional CDN and Mermaid snippets. |
| `lavish-axi setup hooks` | Install or repair optional SessionStart hooks for Claude Code, Codex, OpenCode, and GitHub Copilot CLI; restart the agent session afterward. |
| `lavish-axi server` | Run the local Lavish Editor server. |
| `lavish-axi config [theme]` | Read or set device-wide settings. `lavish-axi config theme <system\|light\|dark>` controls the appearance of the editor chrome; artifacts are never restyled. |

Known playbook IDs: `diagram`, `table`, `comparison`, `plan`, `code`, `input`, `slides`.
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.
Expand All @@ -221,6 +222,23 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook
| `lavish-axi stop` | `--port <port>` | Shut down a server running on a non-default port. |
| `lavish-axi server` | `--verbose` | Log session and watcher events to stderr; can also be enabled with `LAVISH_AXI_DEBUG=1`. Detached server output is appended to `~/.lavish-axi/server.log` (or `LAVISH_AXI_STATE_DIR/server.log`) for startup and crash diagnostics. |

### Appearance

The Lavish Editor chrome follows your OS appearance by default. Pin it with:

```sh
lavish-axi config theme light
lavish-axi config theme dark
lavish-axi config theme system # default, follows the OS
lavish-axi config theme # print the current preference
```

The preference is device-wide, stored in `state.json` next to your sessions, so every browser on the machine agrees. Open editor windows pick up a change on their next load.

Under `system`, the chrome follows the OS live: change your OS appearance and the top bar, conversation panel, and chrome overlays switch immediately. A whiteboard editor that is already open keeps the appearance it opened with and adopts the new one the next time it is opened.

This themes the chrome surfaces only: the top bar, conversation panel, chrome overlays, and the whiteboard editor. Artifacts are never restyled: their markup and CSS are untouched, so they keep rendering identically outside Lavish. UI that Lavish injects inside the artifact iframe, such as the annotation card and its text highlight, stays dark regardless of this preference and is not covered by this setting.

## Development

```sh
Expand Down
5 changes: 5 additions & 0 deletions src/chrome-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,12 @@ const whiteboardFlushes = new Map();
const whiteboardSaveChains = new Map();
const inlineWhiteboardChannels = new Map();

// The chrome's own theme resolves in CSS from data-theme-pref, but Excalidraw needs the resolved
// value as a prop, so mirror the same resolution here: a pinned preference wins, `system` follows
// the OS. Without this a pinned light chrome would frame a dark canvas (and vice versa).
function whiteboardTheme() {
const preference = document.documentElement.dataset.themePref;
if (preference === "light" || preference === "dark") return preference;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

Expand Down
99 changes: 67 additions & 32 deletions src/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,47 @@
--amber-900: #25230f;
--amber-700: #5d4d1b;
--rust-500: #f06464;
--bg: var(--ink-900);
--bg-panel: var(--ink-800);
--bg-bar: var(--ink-700);
--bg-elevated: var(--ink-600);
--fg: var(--cream-100);
--fg-muted: var(--steel-100);
--fg-dim: var(--steel-200);
--fg-faint: var(--steel-300);
--fg-label: var(--steel-400);
--border: var(--steel-600);
--border-subtle: var(--steel-700);
--border-strong: var(--steel-500);
--accent: var(--brass-500);
--accent-hover: var(--brass-400);
--accent-ink: var(--brass-ink);
--danger: var(--rust-500);
--paper-50: #fffdf8;
--paper-100: #fdfaf3;
--paper-200: #f8f4ec;
--paper-300: #f2ede2;
--slate-900: #16191f;
--slate-700: #373c47;
--slate-600: #484f5c;
--slate-500: #565d6c;
--slate-400: #646e83;
--sand-300: #eae2d4;
--sand-400: #ded5c4;
--sand-500: #c8bda8;
--brass-700: #8a6a12;
--brass-800: #6f5410;
--brass-900: #6b4d05;
--rust-700: #b4322f;
--bg: light-dark(var(--paper-300), var(--ink-900));
--bg-panel: light-dark(var(--paper-200), var(--ink-800));
--bg-bar: light-dark(var(--paper-100), var(--ink-700));
--bg-elevated: light-dark(var(--paper-50), var(--ink-600));
--fg: light-dark(var(--slate-900), var(--cream-100));
--fg-muted: light-dark(var(--slate-700), var(--steel-100));
--fg-dim: light-dark(var(--slate-600), var(--steel-200));
--fg-faint: light-dark(var(--slate-500), var(--steel-300));
--fg-label: light-dark(var(--slate-400), var(--steel-400));
--border: light-dark(var(--sand-400), var(--steel-600));
--border-subtle: light-dark(var(--sand-300), var(--steel-700));
--border-strong: light-dark(var(--sand-500), var(--steel-500));
--accent: light-dark(var(--brass-700), var(--brass-500));
--accent-hover: light-dark(var(--brass-800), var(--brass-400));
--accent-ink: light-dark(var(--cream-50), var(--brass-ink));
--danger: light-dark(var(--rust-700), var(--rust-500));
--danger-tint: light-dark(rgba(180, 50, 47, 0.09), rgba(240, 100, 100, 0.1));
--hover: light-dark(rgba(70, 58, 34, 0.07), var(--steel-700));
--scrim: light-dark(rgba(242, 237, 226, 0.72), rgba(15, 17, 21, 0.72));
--scrim-strong: light-dark(rgba(242, 237, 226, 0.86), rgba(15, 17, 21, 0.86));
--scrim-heavy: light-dark(rgba(242, 237, 226, 0.92), rgba(15, 17, 21, 0.92));
--banner-bg: light-dark(rgba(253, 244, 221, 0.95), rgba(37, 35, 15, 0.92));
--banner-border: light-dark(rgba(138, 106, 18, 0.4), rgba(244, 201, 93, 0.35));
--banner-fg: light-dark(var(--brass-900), var(--brass-400));
--shadow-color: light-dark(rgba(76, 62, 40, 0.14), rgba(0, 0, 0, 0.35));
--font-serif: "EB Garamond", "Iowan Old Style", Georgia, serif;
--font-sans: Geist, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
Expand Down Expand Up @@ -93,8 +118,8 @@
--radius-pill: 999px;
--hairline: 1px solid var(--border);
--hairline-subtle: 1px solid var(--border-subtle);
--shadow-tooltip: 0 16px 44px rgba(0, 0, 0, 0.35);
--shadow-floating: 0 20px 70px rgba(0, 0, 0, 0.35);
--shadow-tooltip: 0 16px 44px var(--shadow-color);
--shadow-floating: 0 20px 70px var(--shadow-color);
--bar-h: 56px;
--panel-w: 360px;
--ease: cubic-bezier(0.2, 0.6, 0.2, 1);
Expand All @@ -103,6 +128,19 @@
--dur-slow: 320ms;
--annotate-outline: 2px solid var(--accent);
--annotate-offset: 2px;
color-scheme: light dark;
}
/* The preference only has to choose a color-scheme - every themed token above already carries
both palettes via light-dark(), so the browser picks per token. `system` leaves both in play so
it follows the OS live with no JS, and the render-blocking stylesheet means no flash either. */
:root[data-theme-pref="system"] {
color-scheme: light dark;
}
:root[data-theme-pref="light"] {
color-scheme: light;
}
:root[data-theme-pref="dark"] {
color-scheme: dark;
}
* {
box-sizing: border-box;
Expand All @@ -123,9 +161,6 @@ body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body.lavish {
color-scheme: dark;
}
:focus-visible {
outline: var(--annotate-outline);
outline-offset: var(--annotate-offset);
Expand Down Expand Up @@ -234,7 +269,7 @@ body.lavish {
.more-button:hover:not(:disabled),
.more-button[aria-expanded="true"] {
border-color: var(--border-strong);
background: var(--steel-700);
background: var(--hover);
color: var(--fg);
}
.more-button:disabled {
Expand Down Expand Up @@ -286,7 +321,7 @@ body.lavish {
color: var(--fg-muted);
}
.menu-file:hover {
background: var(--steel-700);
background: var(--hover);
}
.menu-file > svg {
opacity: 0.7;
Expand Down Expand Up @@ -358,7 +393,7 @@ body.lavish {
color: var(--fg);
}
.menu-item:hover:not(:disabled) {
background: var(--steel-700);
background: var(--hover);
}
.menu-item:disabled {
cursor: not-allowed;
Expand All @@ -375,7 +410,7 @@ body.lavish {
opacity: 1;
}
.menu-item.danger:hover:not(:disabled) {
background: rgba(240, 100, 100, 0.1);
background: var(--danger-tint);
}
.button {
border: 0;
Expand Down Expand Up @@ -412,7 +447,7 @@ body.lavish {
color: var(--danger);
}
.button-danger:hover:not(:disabled) {
background: rgba(240, 100, 100, 0.1);
background: var(--danger-tint);
}
.button-danger > svg {
flex-shrink: 0;
Expand All @@ -424,7 +459,7 @@ body.lavish {
display: grid;
place-items: center;
padding: var(--space-16);
background: rgba(15, 17, 21, 0.72);
background: var(--scrim);
}
.share-overlay[hidden] {
display: none;
Expand Down Expand Up @@ -873,7 +908,7 @@ body.lavish {
.ended-overlay {
position: fixed;
inset: var(--bar-h) 0 0 0;
background: rgba(15, 17, 21, 0.86);
background: var(--scrim-strong);
display: flex;
align-items: center;
justify-content: center;
Expand Down Expand Up @@ -915,10 +950,10 @@ body.lavish {
left: var(--space-8);
right: var(--space-8);
z-index: 20;
border: 1px solid rgba(244, 201, 93, 0.35);
border: 1px solid var(--banner-border);
border-radius: var(--radius-lg);
background: rgba(37, 35, 15, 0.92);
color: var(--brass-400);
background: var(--banner-bg);
color: var(--banner-fg);
padding: var(--space-6) var(--space-8);
font-size: var(--text-sm);
line-height: var(--lh-sm);
Expand Down Expand Up @@ -974,7 +1009,7 @@ body.layout-gate-active iframe#artifact {
.whiteboard-overlay {
position: fixed;
inset: 0;
background: rgba(15, 17, 21, 0.92);
background: var(--scrim-heavy);
z-index: 80;
display: flex;
}
Expand Down
Loading
Loading