diff --git a/AGENTS.md b/AGENTS.md index c5ccf48d..ebf269fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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 @@ -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 `` 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`/`×` 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. diff --git a/README.md b/README.md index 529dc53b..0193a9ce 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. @@ -221,6 +222,23 @@ For flows, architecture, state, or sequence diagrams, open the diagram playbook | `lavish-axi stop` | `--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 diff --git a/src/chrome-client.js b/src/chrome-client.js index cb5c2480..6b57b9d5 100644 --- a/src/chrome-client.js +++ b/src/chrome-client.js @@ -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"; } diff --git a/src/chrome.css b/src/chrome.css index f3bf07a1..8bfd6e41 100644 --- a/src/chrome.css +++ b/src/chrome.css @@ -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; @@ -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); @@ -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; @@ -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); @@ -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 { @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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); @@ -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; } diff --git a/src/cli.js b/src/cli.js index 16ab39d7..6ad4b78e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -18,10 +18,29 @@ import { publishToHtmlApp } from "./html-app.js"; import { clientHost, defaultPort, ensureStateDir, hostForUrl, serverLogFile, stateFile } from "./paths.js"; import { findPlaybook, listPlaybooks, playbookIds, PLAYBOOK_ROUTER_HELP } from "./playbooks.js"; import { resolveDesignAssetPath, serve } from "./server.js"; -import { canonicalFile, sessionKey, SessionStore } from "./session-store.js"; +import { + canonicalFile, + normalizeThemePreference, + sessionKey, + SessionStore, + THEME_PREFERENCES, +} from "./session-store.js"; import { initDefaultTelemetry } from "./telemetry.js"; -const COMMANDS = new Set(["open", "poll", "end", "stop", "server", "playbook", "design", "setup", "export", "share"]); +const COMMANDS = new Set([ + "open", + "poll", + "end", + "stop", + "server", + "playbook", + "design", + "setup", + "export", + "share", + "config", +]); +const CONFIG_KEYS = Object.freeze(["theme"]); // SDK-reserved built-ins (e.g. `update`) must reach runAxiCli untouched; otherwise // the bare-arg normalization below would rewrite them into the hidden `open` command. const RESERVED = new Set(RESERVED_COMMANDS); @@ -96,6 +115,7 @@ export async function run(argv) { server: serverCommand, export: exportCommand, share: shareCommand, + config: configCommand, }, getCommandHelp: (command) => getCommandHelp(command, { agent }), }); @@ -195,6 +215,76 @@ export function createPlaybookOutput(args) { return { playbook }; } +// Device-wide settings live in state.json alongside sessions, so one machine has one answer +// rather than one per browser profile. +async function configCommand(args) { + const store = new SessionStore(stateFile()); + const [key, value] = args.filter((arg) => !arg.startsWith("-")); + + if (!key) { + return { config: { theme: await store.getThemePreference() } }; + } + if (!CONFIG_KEYS.includes(key)) { + throw new AxiError(`Unknown config key: ${key}`, "VALIDATION_ERROR", [`Known keys: ${CONFIG_KEYS.join(", ")}`]); + } + if (value === undefined) { + return { config: { theme: await store.getThemePreference() } }; + } + if (!normalizeThemePreference(value)) { + throw new AxiError(`Unsupported theme: ${value}`, "VALIDATION_ERROR", [ + `Run \`lavish-axi config theme <${THEME_PREFERENCES.join("|")}>\``, + ]); + } + + return { + config: { theme: await writeThemePreference(store, value) }, + help: ["Open Lavish Editor windows pick this up on their next load"], + }; +} + +// state.json is read-modify-written whole with no cross-process locking, so hand the write to the +// running server when there is one - a second writer would otherwise clobber a concurrent session +// write from its own stale snapshot. Writing here directly is only safe when nothing is running, +// and never spawns a server just to store a setting. +async function writeThemePreference(store, value) { + const port = defaultPort(); + const running = await runningServer(port); + if (!running) { + return store.setThemePreference(value); + } + // A server too old to know /api/config would both reject the write and drop config on its next + // session write, so refuse instead of writing beside it and reporting a value that never sticks. + if (shouldRestartServer(VERSION, running.health)) { + throw staleServerConfigError(port, running.health); + } + try { + const response = await postJson(`${running.baseUrl}/api/config`, { theme: value }); + return normalizeThemePreference(response?.config?.theme) || value; + } catch (error) { + // Take the write back only when the server is genuinely gone - it died between the health + // check and the write. One that still answers stays the sole writer of state.json. + if (await runningServer(port)) throw error; + return store.setThemePreference(value); + } +} + +function staleServerConfigError(port, health) { + const version = + typeof health?.version === "string" && health.version !== "" + ? `version ${health.version}` + : "a version older than this setting"; + return new AxiError(`Lavish Editor server on port ${port} is running ${version}, not ${VERSION}`, "SERVER_ERROR", [ + "That server cannot store this setting, and writing it here would clobber its session writes", + "Run `lavish-axi stop` to shut it down, then re-run this command", + ]); +} + +async function runningServer(port = defaultPort()) { + const baseUrl = `http://${hostForUrl(clientHost())}:${port}`; + const health = await fetchHealth(baseUrl); + return health && health.app === "lavish-axi" ? { baseUrl, health } : null; +} + export function createOpenOutput({ file, url, status, agent = "generic" }) { return { session: { file, url, status }, @@ -1057,7 +1147,7 @@ export function getCommandHelp(command, { agent = "generic" } = {}) { } function createTopLevelHelp({ agent = "generic" } = {}) { - return `lavish-axi - Lavish Editor AXI\n\nUsage:\n lavish-axi\n lavish-axi [--no-open] [--no-gate] [--reopen]\n lavish-axi poll [--agent-reply "..."]\n lavish-axi end \n lavish-axi export [--out ]\n lavish-axi share [--password ] [--token ]\n lavish-axi stop\n lavish-axi playbook [playbook_id]\n lavish-axi design\n lavish-axi setup hooks\n\n${DESIGN_SYSTEM_HINT}\n\nNote: poll long-polls indefinitely by default until the user sends feedback, ends the session, or the browser proves a severe layout failure, staying silent while it waits - never kill it. Repair and re-check every returned layout failure before involving the human; cosmetic and uncertain observations are never returned. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}\n\n`; + return `lavish-axi - Lavish Editor AXI\n\nUsage:\n lavish-axi\n lavish-axi [--no-open] [--no-gate] [--reopen]\n lavish-axi poll [--agent-reply "..."]\n lavish-axi end \n lavish-axi export [--out ]\n lavish-axi share [--password ] [--token ]\n lavish-axi stop\n lavish-axi playbook [playbook_id]\n lavish-axi design\n lavish-axi setup hooks\n lavish-axi config [theme [${THEME_PREFERENCES.join("|")}]]\n\n${DESIGN_SYSTEM_HINT}\n\nNote: poll long-polls indefinitely by default until the user sends feedback, ends the session, or the browser proves a severe layout failure, staying silent while it waits - never kill it. Repair and re-check every returned layout failure before involving the human; cosmetic and uncertain observations are never returned. Do not pass --timeout-ms during normal agent use; it is for tests and debugging only. ${pollExecutionGuidance({ agent })} ${POLL_SEND_AND_END_RULE}\n\n`; } function createCommandHelp({ agent = "generic" } = {}) { @@ -1071,6 +1161,7 @@ function createCommandHelp({ agent = "generic" } = {}) { playbook: `Usage: lavish-axi playbook [playbook_id]\n\nList focused artifact guidance playbooks, or show one playbook by ID. Known IDs: diagram, table, comparison, plan, code, input, slides.\n\n${PLAYBOOK_ROUTER_HELP}\n\nExamples:\n lavish-axi playbook\n lavish-axi playbook diagram\n lavish-axi playbook input\n`, design: `Usage: lavish-axi design\n\nShow a copy-pasteable CDN snippet for Tailwind CSS browser runtime v4 + DaisyUI v5 + themes, Mermaid diagram tooling, a content-to-playbook router, an optional layout safety CSS snippet, plus technical reference for DaisyUI components. ${PLAYBOOK_ROUTER_HELP} Lavish artifacts stay portable HTML. This CDN snippet is the design fallback, not the default: inspect the subject project before falling back, and paste the layout safety CSS only when useful for dense nested grid/flex layouts, badges, wide fonts, or local media. ${DESIGN_PRIORITY_RULE}\n`, setup: `Usage: lavish-axi setup hooks\n\nInstall or repair agent SessionStart hooks for lavish-axi ambient context in Claude Code, Codex, OpenCode, and GitHub Copilot CLI. Restart your agent session afterward to receive the context.\n`, + config: `Usage: lavish-axi config [theme [${THEME_PREFERENCES.join("|")}]]\n\nRead or set device-wide Lavish settings, stored in state.json next to your sessions so every browser on this machine agrees.\n\n\`theme\` controls the appearance of the Lavish Editor chrome surfaces only (the top bar, conversation panel, chrome overlays, and whiteboard editor), not the artifact. \`system\` follows the OS appearance, switching the chrome live with it, while a whiteboard editor that is already open keeps the appearance it opened with and adopts the new one the next time it is opened; \`light\` and \`dark\` pin it. Defaults to \`system\`. Open editor windows pick up a change on their next load.\n\nThis does not alter the artifact's own markup or styling - artifacts stay portable and render identically outside Lavish. UI injected inside the artifact iframe, such as the annotation card, stays dark regardless of this preference.\n\nExamples:\n lavish-axi config\n lavish-axi config theme\n lavish-axi config theme light\n`, server: `Usage: lavish-axi server [--port 4387] [--verbose]\n\nRun the local Lavish Editor server. Pass --verbose (or set LAVISH_AXI_DEBUG=1) to log session and watcher events to stderr. Detached server output is appended to ~/.lavish-axi/server.log, or LAVISH_AXI_STATE_DIR/server.log when set, for startup and crash diagnostics.\n\nLAVISH_AXI_HOST sets the bind address (default 127.0.0.1; 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. LAVISH_AXI_LINK_HOST sets the hostname written into generated session links (default: the bind address, or loopback when bound to a wildcard). See README's Allowed hosts section for Host allowlisting and LAVISH_AXI_ALLOWED_HOSTS. LAVISH_AXI_NO_OPEN=1 (or --no-open) suppresses the local browser launch.\n`, }; } diff --git a/src/server.js b/src/server.js index 503388cb..be049c9b 100644 --- a/src/server.js +++ b/src/server.js @@ -39,7 +39,13 @@ import { import { publishToHtmlApp } from "./html-app.js"; import { injectLavishSdk } from "./html-transform.js"; import { bindHost, extraAllowedHosts, hostForUrl, IPV6_LOOPBACK_HOST, linkHost, LOOPBACK_HOST } from "./paths.js"; -import { canonicalFile, SessionStore, sessionKey } from "./session-store.js"; +import { + canonicalFile, + DEFAULT_THEME_PREFERENCE, + normalizeThemePreference, + SessionStore, + sessionKey, +} from "./session-store.js"; const chromeClientUrl = new URL("./chrome-client.js", import.meta.url); const chromeCssUrl = new URL("./chrome.css", import.meta.url); @@ -193,6 +199,21 @@ export async function serve({ setImmediate(shutdown); }); + // Device-wide config writes go through here so this process stays the only writer of + // state.json, which is read-modify-written whole and has no cross-process locking. + app.post("/api/config", async (req, res, next) => { + try { + const theme = normalizeThemePreference((req.body || {}).theme); + if (!theme) { + res.status(400).json({ error: "unsupported theme preference" }); + return; + } + res.json({ config: { theme: await store.setThemePreference(theme) } }); + } catch (error) { + next(error); + } + }); + app.post("/api/sessions", async (req, res, next) => { try { const file = await canonicalFile(req.body.file); @@ -463,6 +484,7 @@ export async function serve({ layoutGateEnabled: shouldEnableLayoutGate(req.query || {}), faviconTag, title: title ? `${title} · Lavish` : "Lavish Editor", + themePreference: await store.getThemePreference(), }), ); } catch (error) { @@ -1230,7 +1252,12 @@ export function extractArtifactHead(html) { export function createChromeHtml( session, - { layoutGateEnabled = true, faviconTag = LAVISH_DEFAULT_FAVICON, title = "Lavish Editor" } = {}, + { + layoutGateEnabled = true, + faviconTag = LAVISH_DEFAULT_FAVICON, + title = "Lavish Editor", + themePreference = DEFAULT_THEME_PREFERENCE, + } = {}, ) { const sessionJson = jsonScript({ key: session.key, @@ -1244,8 +1271,11 @@ export function createChromeHtml( const layoutGateHidden = layoutGateEnabled ? "" : " hidden"; const modeHotkeyUpper = MODE_TOGGLE_HOTKEY_KEY.toUpperCase(); const modeToggleHint = `Toggle annotate/explore mode (⌘${modeHotkeyUpper} / Ctrl+${modeHotkeyUpper})`; + // The stylesheet below is render-blocking, so keying the theme off this attribute resolves it + // before first paint - no inline boot script and no flash of the wrong theme. + const themePref = normalizeThemePreference(themePreference) || DEFAULT_THEME_PREFERENCE; return ` - + diff --git a/src/session-store.js b/src/session-store.js index d96089dc..3431df51 100644 --- a/src/session-store.js +++ b/src/session-store.js @@ -1,10 +1,17 @@ import crypto from "node:crypto"; -import { readFile, realpath, writeFile } from "node:fs/promises"; +import { readFile, realpath, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { normalizeMermaidNodeTarget } from "./mermaid-node.js"; import { EXCALIDRAW_SCENE_TARGET_TYPE, normalizeExcalidrawSceneTarget } from "./whiteboard-core.js"; +export const THEME_PREFERENCES = Object.freeze(["system", "light", "dark"]); +export const DEFAULT_THEME_PREFERENCE = "system"; + +export function normalizeThemePreference(value) { + return THEME_PREFERENCES.includes(value) ? value : null; +} + export class SessionStore { constructor(file) { this.file = file; @@ -180,11 +187,29 @@ export class SessionStore { return session; } + async getThemePreference() { + const state = await this.readState(); + return normalizeThemePreference(state.config?.theme) || DEFAULT_THEME_PREFERENCE; + } + + async setThemePreference(value) { + const theme = normalizeThemePreference(value); + if (!theme) throw new Error(`Unsupported theme preference: ${value}`); + const state = await this.readState(); + state.config = { ...(isPlainObject(state.config) ? state.config : {}), theme }; + await this.writeState(state); + return theme; + } + + // Spread the parsed state rather than picking `sessions` out of it: state.json also carries + // top-level device config (see setThemePreference), and rebuilding the object from a single key + // would drop that config on the next session write. async readState() { try { const raw = await readFile(this.file, "utf8"); const parsed = JSON.parse(raw); - return { sessions: parsed.sessions || {} }; + const state = isPlainObject(parsed) ? parsed : {}; + return { ...state, sessions: isPlainObject(state.sessions) ? state.sessions : {} }; } catch (error) { if (error && error.code === "ENOENT") { return { sessions: {} }; @@ -193,8 +218,19 @@ export class SessionStore { } } + // Write a per-call temp file and rename it into place. A truncate-then-write can be observed + // (or interleaved with a write from another process, or another in-flight request) as a + // half-written file, and every later readState would then throw on JSON.parse until the file is + // removed by hand. The temp name is unique per call so two overlapping writes never share it. async writeState(state) { - await writeFile(this.file, `${JSON.stringify(state, null, 2)}\n`); + const temp = `${this.file}.${process.pid}-${crypto.randomUUID()}.tmp`; + try { + await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`); + await rename(temp, this.file); + } catch (error) { + await unlink(temp).catch(() => {}); + throw error; + } } } @@ -274,6 +310,10 @@ function normalizeLayoutWarnings(layoutWarnings, deliveredKeys = new Set()) { }); } +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + function normalizeFiniteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; diff --git a/test/chrome-client-queue.test.js b/test/chrome-client-queue.test.js index 47167eb7..c6c62c17 100644 --- a/test/chrome-client-queue.test.js +++ b/test/chrome-client-queue.test.js @@ -180,6 +180,7 @@ async function createChromeHarness({ } }, document: { + documentElement: element("html"), body: element("body"), getElementById(id) { return element(id); diff --git a/test/cli-output.test.js b/test/cli-output.test.js index bfc0285d..1b27849a 100644 --- a/test/cli-output.test.js +++ b/test/cli-output.test.js @@ -1909,3 +1909,189 @@ async function startFakeHtmlApp(requests) { close: () => new Promise((resolve) => server.close(() => resolve())), }; } + +function cliSpawnOptions(stateDir, port) { + return { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { + ...process.env, + LAVISH_AXI_STATE_DIR: stateDir, + LAVISH_AXI_TELEMETRY: "0", + // Pin the control-channel port so these never reach a real server on the developer's machine. + LAVISH_AXI_PORT: String(port), + }, + }; +} + +// Port 1 has nothing listening, so the CLI's control-channel health check refuses immediately. +function runCli(args, stateDir, port = 1) { + return spawnSync(process.execPath, [fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url)), ...args], { + ...cliSpawnOptions(stateDir, port), + encoding: "utf8", + }); +} + +// Whenever the CLI talks to a server hosted by this test process, spawn asynchronously: spawnSync +// blocks the event loop, so the in-process server could never answer and both sides would deadlock. +function runCliAsync(args, stateDir, port) { + const child = spawn( + process.execPath, + [fileURLToPath(new URL("../bin/lavish-axi.js", import.meta.url)), ...args], + cliSpawnOptions(stateDir, port), + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + return new Promise((resolve) => { + child.on("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +test("config theme defaults to system and persists every supported value", async () => { + const dir = await mkdtemp(`${os.tmpdir()}/lavish-axi-config-test-`); + try { + const unset = runCli(["config"], dir); + assert.equal(unset.status, 0, unset.stderr); + assert.match(unset.stdout, /theme: system/); + + for (const theme of ["light", "dark", "system"]) { + const set = runCli(["config", "theme", theme], dir); + assert.equal(set.status, 0, set.stderr); + assert.match(set.stdout, new RegExp(`theme: ${theme}`)); + + const stored = JSON.parse(await readFile(`${dir}/state.json`, "utf8")); + assert.equal(stored.config.theme, theme); + assert.match(runCli(["config", "theme"], dir).stdout, new RegExp(`theme: ${theme}`)); + } + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +test("config rejects unsupported themes and unknown keys without writing state", async () => { + const dir = await mkdtemp(`${os.tmpdir()}/lavish-axi-config-test-`); + try { + runCli(["config", "theme", "dark"], dir); + + const badTheme = runCli(["config", "theme", "sepia"], dir); + assert.notEqual(badTheme.status, 0); + assert.match(badTheme.stdout + badTheme.stderr, /VALIDATION_ERROR/); + assert.match(badTheme.stdout + badTheme.stderr, /system\|light\|dark/); + + const badKey = runCli(["config", "colour"], dir); + assert.notEqual(badKey.status, 0); + assert.match(badKey.stdout + badKey.stderr, /VALIDATION_ERROR/); + assert.match(badKey.stdout + badKey.stderr, /Known keys: theme/); + + const stored = JSON.parse(await readFile(`${dir}/state.json`, "utf8")); + assert.equal(stored.config.theme, "dark"); + } finally { + await rm(dir, { force: true, recursive: true }); + } +}); + +test("config is a real command, not an html path normalized to open", () => { + assert.deepEqual(normalizeArgv(["config"]), ["config"]); + assert.deepEqual(normalizeArgv(["config", "theme", "light"]), ["config", "theme", "light"]); + assert.match(getCommandHelp("config"), /lavish-axi config \[theme \[system\|light\|dark\]\]/); +}); + +// state.json is read-modify-written whole with no locking, so the running server has to stay its +// only writer. A second writer would clobber a concurrent session write from its own stale snapshot. +test("config theme hands the write to a running server instead of writing state itself", async () => { + const dir = await mkdtemp(`${os.tmpdir()}/lavish-axi-config-test-`); + const requests = []; + const fake = await startFakeLavishServer(requests); + try { + const set = await runCliAsync(["config", "theme", "dark"], dir, fake.port); + assert.equal(set.status, 0, set.stderr); + assert.match(set.stdout, /theme: dark/); + + assert.deepEqual( + requests.map((request) => `${request.method} ${request.url}`), + ["GET /health", "POST /api/config"], + ); + assert.deepEqual(requests[1].body, { theme: "dark" }); + assert.equal(existsSync(`${dir}/state.json`), false, "the CLI must not write state.json itself"); + } finally { + await fake.close(); + await rm(dir, { force: true, recursive: true }); + } +}); + +// A server from an older release rejects /api/config and drops config on its next session write, +// so a direct write beside it would report a preference that silently never sticks. +test("config theme refuses to write beside a stale running server", async () => { + const dir = await mkdtemp(`${os.tmpdir()}/lavish-axi-config-test-`); + const requests = []; + const fake = await startFakeLavishServer(requests, { configStatus: 404, version: "0.0.1-stale" }); + try { + const set = await runCliAsync(["config", "theme", "light"], dir, fake.port); + assert.notEqual(set.status, 0); + const output = set.stdout + set.stderr; + assert.match(output, /SERVER_ERROR/); + assert.match(output, /0\.0\.1-stale/); + assert.match(output, /lavish-axi stop/); + + assert.deepEqual( + requests.map((request) => `${request.method} ${request.url}`), + ["GET /health"], + "a stale server must not be handed the write", + ); + assert.equal(existsSync(`${dir}/state.json`), false, "the CLI must not write state.json itself"); + } finally { + await fake.close(); + await rm(dir, { force: true, recursive: true }); + } +}); + +// Pre-handshake servers answer /health without a version at all. +test("config theme refuses to write beside a pre-handshake running server", async () => { + const dir = await mkdtemp(`${os.tmpdir()}/lavish-axi-config-test-`); + const requests = []; + const fake = await startFakeLavishServer(requests, { configStatus: 404, version: null }); + try { + const set = await runCliAsync(["config", "theme", "light"], dir, fake.port); + assert.notEqual(set.status, 0); + assert.match(set.stdout + set.stderr, /SERVER_ERROR/); + assert.equal(existsSync(`${dir}/state.json`), false, "the CLI must not write state.json itself"); + } finally { + await fake.close(); + await rm(dir, { force: true, recursive: true }); + } +}); + +async function startFakeLavishServer(requests, { configStatus = 200, version = VERSION } = {}) { + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + requests.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null }); + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, app: "lavish-axi", ...(version ? { version } : {}) })); + return; + } + if (req.url === "/api/config" && configStatus === 200) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ config: { theme: raw ? JSON.parse(raw).theme : "system" } })); + return; + } + res.writeHead(configStatus, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined))); + const address = server.address(); + return { + port: typeof address === "object" && address ? address.port : 0, + close: () => new Promise((resolve) => server.close(() => resolve(undefined))), + }; +} diff --git a/test/server.test.js b/test/server.test.js index ad7133b4..0c6a146b 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -26,7 +26,7 @@ import { resolveWatchTarget, serve, } from "../src/server.js"; -import { canonicalFile, sessionKey } from "../src/session-store.js"; +import { canonicalFile, SessionStore, sessionKey } from "../src/session-store.js"; async function chromeClientSource() { return readFile(new URL("../src/chrome-client.js", import.meta.url), "utf8"); @@ -359,7 +359,8 @@ test("chrome declares the Lavish design-system tokens", async () => { assert.match(css, /--text-display:92px/); assert.match(css, /--lh-display:1/); assert.match(css, /--space-32:64px/); - assert.match(css, /--shadow-floating:0 20px 70px rgba\(0,0,0,.35\)/); + assert.match(css, /--shadow-color:light-dark\(rgba\(76,62,40,.14\),rgba\(0,0,0,.35\)\)/); + assert.match(css, /--shadow-floating:0 20px 70px var\(--shadow-color\)/); assert.match(css, /--ease:cubic-bezier\(.2,.6,.2,1\)/); assert.match(css, /--dur-slow:320ms/); assert.match(css, /--bar-h:56px/); @@ -2825,7 +2826,8 @@ test("ended session shows an overlay card over the dimmed chrome", async () => { assert.match(html, /class="ended-copy">\/tmp\/artifact\.html'); }); + +// The chrome is themed purely through the semantic token layer, so any dark-only literal sitting +// directly on a rule is a surface that would stay dark in light mode. `.frame` is the deliberate +// exception: it is the artifact viewport, not chrome, and must stay white in both themes. +test("chrome rules carry no dark-only color literals outside the token block", async () => { + const css = await readFile(new URL("../src/chrome.css", import.meta.url), "utf8"); + const body = css.slice(css.indexOf("}") + 1); + const literals = [ + ...body.matchAll(/^\s*(?:background|border|box-shadow|color)[^;]*?(rgba?\([^)]*\)|#[0-9a-fA-F]{3,8})/gm), + ] + .map((match) => match[0].trim()) + .filter((declaration) => !declaration.includes("#fff")); + + assert.deepEqual(literals, [], `theme these through tokens instead: ${literals.join(" | ")}`); + + // Same failure mode, harder to spot: a raw ramp swatch (--ink-*, --brass-*, ...) used straight + // in a rule is pinned to the dark ramp and never gets a light counterpart. + const rampUses = [ + ...body.matchAll(/^\s*[a-z-]+:[^;]*var\(--(?:ink|steel|cream|brass|sage|amber|rust)-[^)]*\)[^;]*/gm), + ] + .map((match) => match[0].trim()) + .filter((declaration) => !/^(?:--|font-)/.test(declaration)); + + assert.deepEqual(rampUses, [], `use a semantic token, not a raw ramp swatch: ${rampUses.join(" | ")}`); +}); + +test("every themeable chrome token is defined", async () => { + const css = await chromeCssSource(); + for (const token of [ + "--danger-tint", + "--hover", + "--scrim", + "--scrim-strong", + "--scrim-heavy", + "--banner-bg", + "--banner-border", + "--banner-fg", + "--shadow-color", + ]) { + assert.match(css, new RegExp(`${token}:`), `${token} is not defined`); + } +}); + +test("chrome html carries the device theme preference for CSS to resolve", async () => { + const session = { key: "abc123", file: "/tmp/report.html", chat: [] }; + + assert.match(createChromeHtml(session), //); + for (const theme of ["system", "light", "dark"]) { + assert.match( + createChromeHtml(session, { themePreference: theme }), + new RegExp(``), + ); + } + // An unknown preference must not reach the attribute. + assert.match(createChromeHtml(session, { themePreference: "sepia" }), //); +}); + +test("/session/:key reflects the stored device theme preference", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-serve-")); + const stateFile = path.join(dir, "state.json"); + const artifact = path.join(dir, "report.html"); + await writeFile(artifact, "

Hi

", "utf8"); + + const store = new SessionStore(stateFile); + const server = await serve({ port: 0, stateFile, version: "9.9.9-test" }); + try { + const session = await store.upsertSession(artifact, `http://127.0.0.1:${server.port}/session/x`); + + for (const theme of ["light", "dark", "system"]) { + await store.setThemePreference(theme); + const res = await fetch(`http://127.0.0.1:${server.port}/session/${session.key}`); + assert.match(await res.text(), new RegExp(``)); + } + } finally { + await server.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +// Variant C carries both palettes on each token and lets the preference pick a color-scheme, so +// the invariants worth pinning are: the three color-scheme rules exist, and no themeable token +// was left holding a single palette. +test("the theme preference maps to a color-scheme", async () => { + const css = await chromeCssSource(); + + assert.match(css, /:root\[data-theme-pref="system"\]\{color-scheme:light dark;?\}/); + assert.match(css, /:root\[data-theme-pref="light"\]\{color-scheme:light;?\}/); + assert.match(css, /:root\[data-theme-pref="dark"\]\{color-scheme:dark;?\}/); +}); + +test("every themeable token carries both palettes", async () => { + const css = await readFile(new URL("../src/chrome.css", import.meta.url), "utf8"); + const root = css.slice(css.indexOf(":root {"), css.indexOf("}")); + + const singlePalette = [ + ...root.matchAll(/^\s*(--(?:bg|fg|border|accent|danger|hover|scrim|banner)[\w-]*|--shadow-color)\s*:\s*([^;]+);/gm), + ] + .filter(([, , value]) => !value.includes("light-dark(")) + .map(([, token]) => token); + + assert.deepEqual(singlePalette, [], `these would stay dark in light mode: ${singlePalette.join(", ")}`); +}); + +// The CLI posts here rather than writing state.json itself, so the server stays the only writer +// of a file that is read-modify-written whole with no locking. +test("POST /api/config stores a supported theme and rejects anything else", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-serve-")); + const stateFile = path.join(dir, "state.json"); + const store = new SessionStore(stateFile); + const server = await serve({ port: 0, stateFile, version: "9.9.9-test" }); + const post = (body) => + fetch(`http://127.0.0.1:${server.port}/api/config`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + try { + for (const theme of ["light", "dark", "system"]) { + const res = await post({ theme }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { config: { theme } }); + assert.equal(await store.getThemePreference(), theme); + } + + await post({ theme: "dark" }); + for (const body of [{ theme: "sepia" }, { theme: "" }, {}]) { + assert.equal((await post(body)).status, 400); + } + assert.equal(await store.getThemePreference(), "dark"); + } finally { + await server.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("the whiteboard editor follows the pinned chrome theme, not just the OS", async () => { + const js = await chromeClientSource(); + const theme = js.slice(js.indexOf("function whiteboardTheme()")); + + assert.match(theme, /dataset\.themePref/); + assert.match(theme, /prefers-color-scheme: dark/); + assert.ok( + theme.indexOf("dataset.themePref") < theme.indexOf("prefers-color-scheme"), + "a pinned preference must win over the OS appearance", + ); +}); diff --git a/test/session-store.test.js b/test/session-store.test.js index 124e34ee..6d6a2bab 100644 --- a/test/session-store.test.js +++ b/test/session-store.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -706,3 +706,108 @@ test("freeform user prompts are stored in session chat history", async () => { await rm(dir, { recursive: true, force: true }); } }); + +test("theme preference defaults to system and round-trips every supported value", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-store-")); + try { + const store = new SessionStore(path.join(dir, "state.json")); + assert.equal(await store.getThemePreference(), "system"); + + for (const theme of ["light", "dark", "system"]) { + assert.equal(await store.setThemePreference(theme), theme); + assert.equal(await store.getThemePreference(), theme); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("setThemePreference rejects unsupported values and leaves the stored theme alone", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-store-")); + try { + const store = new SessionStore(path.join(dir, "state.json")); + await store.setThemePreference("dark"); + + for (const bad of ["sepia", "", null, undefined, "DARK"]) { + await assert.rejects(() => store.setThemePreference(bad), /Unsupported theme preference/); + } + assert.equal(await store.getThemePreference(), "dark"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Regression: readState used to rebuild state from `sessions` alone, so any device config was +// dropped by the next session write - the theme preference would silently reset itself. +test("device config survives session writes", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-store-")); + try { + const stateFile = path.join(dir, "state.json"); + const artifact = path.join(dir, "artifact.html"); + await writeFile(artifact, "

Hello

"); + + const store = new SessionStore(stateFile); + await store.setThemePreference("light"); + + const session = await store.upsertSession(artifact, "http://localhost:4387/session/test"); + assert.equal(await store.getThemePreference(), "light"); + + await store.queuePrompts(session.key, { + prompts: [{ uid: "u1", prompt: "tighten this", selector: "h1", tag: "annotation", text: "Hello" }], + }); + assert.equal(await store.getThemePreference(), "light"); + + await store.addAgentReply(session.key, "done"); + assert.equal(await store.getThemePreference(), "light"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readState preserves unrecognized top-level keys", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-store-")); + try { + const stateFile = path.join(dir, "state.json"); + const artifact = path.join(dir, "artifact.html"); + await writeFile(artifact, "

Hello

"); + await writeFile(stateFile, JSON.stringify({ sessions: {}, future_key: { kept: true } })); + + const store = new SessionStore(stateFile); + await store.upsertSession(artifact, "http://localhost:4387/session/test"); + + const state = JSON.parse(await readFile(stateFile, "utf8")); + assert.deepEqual(state.future_key, { kept: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// state.json now has more than one possible writer (the server plus `lavish-axi config` when no +// server is running), and a truncate-then-write can be caught half-finished - after which every +// readState throws on JSON.parse forever. Writes must land through an atomic rename, with a temp +// name unique per call so overlapping writes never share one. +test("writeState never leaves a partial file or a stray temp file behind", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "lavish-store-")); + try { + const stateFile = path.join(dir, "state.json"); + const bulky = (theme) => ({ + config: { theme }, + sessions: Object.fromEntries( + Array.from({ length: 200 }, (_, index) => [`k${index}`, { key: `k${index}`, note: "x".repeat(400) }]), + ), + }); + + await Promise.all([ + new SessionStore(stateFile).writeState(bulky("light")), + new SessionStore(stateFile).writeState(bulky("dark")), + new SessionStore(stateFile).writeState(bulky("system")), + ]); + + const parsed = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(Object.keys(parsed.sessions).length, 200); + assert.ok(["light", "dark", "system"].includes(parsed.config.theme)); + assert.deepEqual(await readdir(dir), ["state.json"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +});