diff --git a/.agents/skills/lavish-design/README.md b/.agents/skills/lavish-design/README.md index 57e07c0d..b0e1c356 100644 --- a/.agents/skills/lavish-design/README.md +++ b/.agents/skills/lavish-design/README.md @@ -128,7 +128,7 @@ The brand mark **Lavish Editor** is set in Geist Sans at `font-weight: 750` with - **8-pt grid** with a `4` and `2` half-step. The chrome uses `6 / 8 / 10 / 12 / 16 / 24 / 32` repeatedly. - **Top bar height: 56px.** Sticky. The one truly fixed element. -- **Panel gutter: 360px** for the side conversation panel; the artifact takes the rest. +- **Panel gutter: 360px default** for the side conversation panel; the user can drag the splitter to resize it (clamped to `min 280` and `60%` of the viewport), the choice persists in `localStorage` and resets to 360 on a splitter double-click. The artifact takes the rest. - Composer padding: `12px 16px`. Buttons: `9–10px 12px`. ### Radii @@ -184,7 +184,7 @@ A "card" in Lavish is a slab of `#11141a` or `#1c212b` with a 1px border (`#3037 ### Layout rules - The top bar is fixed at 56px, full-width, sticky. -- The side conversation panel is a fixed 360px wide on the right. +- The side conversation panel defaults to 360px wide on the right and can be resized by dragging the splitter between the artifact and the panel. - The artifact takes the remainder. - The annotation card is positioned relative to the clicked element or selected text range via `getBoundingClientRect()` and clamped 12px from any viewport edge. - The chat input lives at the bottom of the side panel; pills (queued prompts) sit _above_ the textarea, never inside it. diff --git a/README.md b/README.md index 87c193d3..79cc14b1 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ pnpm link For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`. Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other. Queued annotation preview pills and chat history share a scrollable Conversation panel above a sticky composer, so long feedback queues do not push the text box or send controls off screen. + Drag the splitter between the artifact and the Conversation panel to resize the panel horizontally; the chosen width persists in localStorage across sessions, is re-clamped against the viewport on resize, and a double-click on the splitter resets it to the default. The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session), while the composer exposes **Send & End** beside **Send to Agent** to submit queued prompts and user-ended attribution together. - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline. In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. diff --git a/src/chrome-client.js b/src/chrome-client.js index 4430275a..903110ca 100644 --- a/src/chrome-client.js +++ b/src/chrome-client.js @@ -19,6 +19,7 @@ function isModeToggleHotkeyEvent(event) { } const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact")); +const splitter = /** @type {HTMLDivElement} */ (document.getElementById("splitter")); const panelScroll = /** @type {HTMLDivElement} */ (document.getElementById("panelScroll")); const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("annotationPills")); const chatLog = /** @type {HTMLDivElement} */ (document.getElementById("chatLog")); @@ -1863,6 +1864,136 @@ frame.addEventListener("load", () => { initializeLayoutGate(); +const panelWidth = globalThis.LavishPanelWidth; +let currentPanelWidth = panelWidth ? panelWidth.PANEL_DEFAULTS.default : 360; +let splitterDragPointerId = null; + +function applyPanelWidth(px) { + const width = Math.round(Number(px) || 0); + if (!width) return; + currentPanelWidth = width; + document.documentElement.style.setProperty("--panel-w", width + "px"); +} + +function commitPanelWidth() { + if (!panelWidth) return; + panelWidth.savePanelWidth(safeLocalStorage(), currentPanelWidth); +} + +function clampPanelWidthForViewport(px) { + if (!panelWidth) return px; + return panelWidth.clampPanelWidth(px, window.innerWidth); +} + +function syncPanelWidthToViewport() { + const clamped = clampPanelWidthForViewport(currentPanelWidth); + if (clamped !== currentPanelWidth) { + applyPanelWidth(clamped); + commitPanelWidth(); + } +} + +function safeLocalStorage() { + try { + return window.localStorage; + } catch { + return null; + } +} + +function startSplitterDrag(event) { + if (!panelWidth || !splitter) return; + // Pointer events arrive for mouse, pen, and touch - guard against right/middle clicks + // and modifier-driven drags that would interfere with normal browser gestures. + if (event.button !== undefined && event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + splitterDragPointerId = event.pointerId; + document.body.classList.add("dragging-splitter"); + // Listen on `window`, not `document`: pointer events dispatched on window + // do not bubble to document, and during a drag the cursor frequently enters + // the artifact iframe (whose pointer events we disable via the + // `dragging-splitter` class). Window-level listeners are the only way to + // keep the drag live while the cursor is over the iframe. + window.addEventListener("pointermove", onSplitterMove); + window.addEventListener("pointerup", endSplitterDrag); + window.addEventListener("pointercancel", endSplitterDrag); + // Apply the initial drag position immediately so the panel follows the cursor + // from the first frame, even when the user starts mid-element. + onSplitterMove(event); +} + +function onSplitterMove(event) { + if (event.pointerId !== undefined && splitterDragPointerId !== null && event.pointerId !== splitterDragPointerId) { + return; + } + const proposed = window.innerWidth - event.clientX; + applyPanelWidth(clampPanelWidthForViewport(proposed)); +} + +function endSplitterDrag(event) { + if ( + event && + event.pointerId !== undefined && + splitterDragPointerId !== null && + event.pointerId !== splitterDragPointerId + ) { + return; + } + splitterDragPointerId = null; + document.body.classList.remove("dragging-splitter"); + window.removeEventListener("pointermove", onSplitterMove); + window.removeEventListener("pointerup", endSplitterDrag); + window.removeEventListener("pointercancel", endSplitterDrag); + commitPanelWidth(); +} + +function resetPanelWidth() { + if (!panelWidth) return; + applyPanelWidth(panelWidth.PANEL_DEFAULTS.default); + commitPanelWidth(); +} + +function initializePanelWidth() { + if (!panelWidth) return; + const storage = safeLocalStorage(); + let rawStored = null; + if (storage) { + try { + rawStored = storage.getItem(panelWidth.PANEL_STORAGE_KEY); + } catch { + rawStored = null; + } + } + const stored = panelWidth.loadStoredPanelWidth(storage, window.innerWidth); + applyPanelWidth(stored); + if (rawStored === null || String(stored) !== rawStored) { + commitPanelWidth(); + } + if (splitter) { + splitter.addEventListener("pointerdown", startSplitterDrag); + splitter.addEventListener("dblclick", () => { + resetPanelWidth(); + }); + } + window.addEventListener("resize", onWindowResize); +} + +// On the stacked/mobile breakpoint the splitter is `display:none`, so the +// desktop-chosen width should be left alone. Without this guard, briefly +// resizing the window to a phone width would clamp the stored width down +// and overwrite localStorage, permanently shrinking the panel until the +// user drags it back out. +const mobileBreakpointMatches = + typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 860px)") : null; + +function onWindowResize() { + if (mobileBreakpointMatches && mobileBreakpointMatches.matches) return; + syncPanelWidthToViewport(); +} + +initializePanelWidth(); + const events = new EventSource("/events/" + key); events.addEventListener("reload", () => { resetFrame().then((reloaded) => { diff --git a/src/chrome.css b/src/chrome.css index fac026a7..f11c61e4 100644 --- a/src/chrome.css +++ b/src/chrome.css @@ -874,7 +874,7 @@ body.lavish { height: calc(100vh - var(--bar-h)); min-height: 0; display: grid; - grid-template-columns: minmax(0, 1fr) var(--panel-w); + grid-template-columns: minmax(0, 1fr) auto var(--panel-w); } .frame { min-width: 0; @@ -883,6 +883,55 @@ body.lavish { position: relative; overflow: hidden; } +.splitter { + width: 8px; + margin: 0 -4px; + cursor: col-resize; + background: transparent; + position: relative; + touch-action: none; + outline: none; +} +.splitter::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-0.5px); + background: var(--border-strong); + opacity: 0; + transition: opacity var(--dur-fast) var(--ease); +} +.splitter::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 3px; + height: 28px; + transform: translate(-50%, -50%); + border-radius: var(--radius-pill); + background: var(--border-strong); + opacity: 0; + transition: opacity var(--dur-fast) var(--ease); +} +.splitter:hover::after, +.splitter:hover::before, +body.dragging-splitter .splitter::after, +body.dragging-splitter .splitter::before { + opacity: 1; +} +body.dragging-splitter { + cursor: col-resize; + user-select: none; + -webkit-user-select: none; +} +body.dragging-splitter iframe, +body.dragging-splitter .frame { + pointer-events: none; +} .panel { width: var(--panel-w); border-left: var(--hairline-subtle); @@ -1251,6 +1300,9 @@ body.layout-gate-active iframe#artifact { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) min(42vh, 360px); } + .splitter { + display: none; + } .panel { width: 100%; border-left: 0; diff --git a/src/panel-width.js b/src/panel-width.js new file mode 100644 index 00000000..c6ff401f --- /dev/null +++ b/src/panel-width.js @@ -0,0 +1,81 @@ +/** + * Helpers for the conversation/chat panel's draggable width. + * + * The panel width is stored as a plain number of CSS pixels in localStorage + * so it survives reloads and works across multiple sessions. These helpers + * stay pure (no DOM, no storage, no globals) so the chrome can call them + * with injected storage objects and the rules can be unit tested headlessly. + * + * The browser-side `chrome-client.js` receives an inlined copy of this module + * (see `serializePanelWidthForBrowser` in `server.js`) so it can use the same + * clamp/persistence rules without an extra HTTP round-trip. + */ + +export const PANEL_DEFAULTS = Object.freeze({ + min: 280, + maxViewportFraction: 0.6, + default: 360, +}); + +export const PANEL_STORAGE_KEY = "lavish-axi:panel-w"; + +function resolveDefaults(defaults) { + if (defaults && typeof defaults === "object") return defaults; + return PANEL_DEFAULTS; +} + +// Re-exported so the inlined browser copy in `serializePanelWidthForBrowser` can +// see it; keeping it unexported would leave the `toString()`d helpers with a +// dangling `resolveDefaults is not defined` reference at runtime. +export { resolveDefaults }; + +/** + * Coerce a raw value (e.g. a stored localStorage string, a drag distance, or a + * pixel-stringified CSS value) into a valid panel width in CSS pixels. + * + * - Nullish / empty / non-numeric / non-positive input returns the default. + * - The result is clamped to `[min, maxViewportFraction * viewportWidth]`, + * floored at `min` so a tiny viewport never collapses the panel below it. + * - A non-positive or non-finite viewport returns the default rather than + * dividing by zero or producing Infinity. + */ +export function clampPanelWidth(rawValue, viewportWidth, defaults) { + const { min, maxViewportFraction, default: fallback } = resolveDefaults(defaults); + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return fallback; + const parsed = Number.parseFloat(String(rawValue ?? "")); + if (!Number.isFinite(parsed)) return fallback; + const max = Math.max(min, maxViewportFraction * viewportWidth); + return Math.min(max, Math.max(min, parsed)); +} + +/** + * Read the stored panel width from a storage-like object (e.g. localStorage) + * and clamp it against the current viewport. Falls back to the default on any + * error (storage disabled, value corrupt, etc.) so a broken value can never + * wedge the chrome on first paint. + */ +export function loadStoredPanelWidth(storage, viewportWidth, defaults) { + const { default: fallback } = resolveDefaults(defaults); + if (!storage || typeof storage.getItem !== "function") return fallback; + let raw; + try { + raw = storage.getItem(PANEL_STORAGE_KEY); + } catch { + return fallback; + } + return clampPanelWidth(raw, viewportWidth, defaults); +} + +/** + * Persist a panel width. Silent on error: persistence is best-effort and a + * quota/disabled-storage failure must never break the drag interaction. + */ +export function savePanelWidth(storage, width) { + if (!storage || typeof storage.setItem !== "function") return; + if (typeof width !== "number" || !Number.isFinite(width) || width <= 0) return; + try { + storage.setItem(PANEL_STORAGE_KEY, String(width)); + } catch { + // Ignore - persistence is best-effort. + } +} diff --git a/src/server.js b/src/server.js index 2190b5d1..55c9986b 100644 --- a/src/server.js +++ b/src/server.js @@ -44,6 +44,14 @@ 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 { + clampPanelWidth as clampPanelWidthHelper, + loadStoredPanelWidth as loadStoredPanelWidthHelper, + PANEL_DEFAULTS, + PANEL_STORAGE_KEY, + resolveDefaults, + savePanelWidth as savePanelWidthHelper, +} from "./panel-width.js"; import { canonicalFile, SessionStore, sessionKey } from "./session-store.js"; const chromeClientUrl = new URL("./chrome-client.js", import.meta.url); @@ -1458,6 +1466,23 @@ export function extractArtifactHead(html) { return { faviconTag, title }; } +function serializePanelWidthForBrowser() { + // Inline the same pure helpers the chrome uses to clamp/persist the panel + // width so they share one source of truth with the unit tests in + // test/panel-width.test.js. Wrapped in an IIFE that exposes them on + // `globalThis.LavishPanelWidth`, mirroring how the artifact SDK is inlined + // (see `createSdkJs`). + return `(() => { +const PANEL_DEFAULTS=${JSON.stringify(PANEL_DEFAULTS)}; +const PANEL_STORAGE_KEY=${JSON.stringify(PANEL_STORAGE_KEY)}; +const resolveDefaults=${resolveDefaults.toString()}; +const clampPanelWidth=${clampPanelWidthHelper.toString()}; +const loadStoredPanelWidth=${loadStoredPanelWidthHelper.toString()}; +const savePanelWidth=${savePanelWidthHelper.toString()}; +globalThis.LavishPanelWidth={ PANEL_DEFAULTS, PANEL_STORAGE_KEY, resolveDefaults, clampPanelWidth, loadStoredPanelWidth, savePanelWidth }; +})();`; +} + export function createChromeHtml( session, { @@ -1500,12 +1525,13 @@ ${faviconTag}
LavishEditor
-
+
Checking layout.
One moment.

Lavish is waiting for fonts and final geometry before revealing this artifact.

+ `; diff --git a/test/chrome-client-queue.test.js b/test/chrome-client-queue.test.js index 4655cb5e..7ef40edd 100644 --- a/test/chrome-client-queue.test.js +++ b/test/chrome-client-queue.test.js @@ -3,6 +3,8 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { PANEL_STORAGE_KEY } from "../src/panel-width.js"; + const sourceUrl = new URL("../src/chrome-client.js", import.meta.url); /** @typedef {{ key: string, file: string, layoutGateEnabled?: boolean, layoutGateMaxHoldMs?: number, modeToggleHotkeyKey?: string, initialLayoutWarnings?: any[], chromeLoadToken?: string, initialArtifactRevision?: number, initialArtifactLoadToken?: string, initialArtifactLoadSequence?: number }} HarnessSessionData */ @@ -18,8 +20,12 @@ async function createChromeHarness({ storage = new Map(), beginLoadResponses = [], handoffResponses = [], + innerWidth = 1200, + localStorageValues = new Map(), + localStorageGetItemThrows = false, } = {}) { const source = await readFile(sourceUrl, "utf8"); + const localStorageMap = new Map(localStorageValues); const postedToFrame = []; const postedToWhiteboard = []; const inlineWhiteboards = []; @@ -35,6 +41,8 @@ async function createChromeHarness({ let nextTimerId = 1; let reloadCount = 0; let artifactRevision = 0; + let currentInnerWidth = innerWidth; + let mobileBreakpointMatches = false; function fakeSetTimeout(fn, ms) { const timer = { @@ -97,14 +105,31 @@ async function createChromeHarness({ contains(name) { return classes.has(name); }, + has(name) { + return classes.has(name); + }, toString() { return [...classes].join(" "); }, }, - style: {}, + style: { + setProperty(name, value) { + this[name] = String(value); + }, + getPropertyValue(name) { + const value = this[name]; + return value === undefined ? "" : String(value); + }, + removeProperty(name) { + delete this[name]; + }, + }, setAttribute(name, value) { this[name] = String(value); }, + getAttribute(name) { + return Object.hasOwn(this, name) ? this[name] : null; + }, addEventListener(type, handler) { listeners.set(type, handler); }, @@ -232,6 +257,20 @@ async function createChromeHarness({ return fetchImpl(url, init); }; + const localStorageApi = { + getItem(key) { + if (localStorageGetItemThrows) throw new Error("getItem blocked"); + return localStorageMap.has(key) ? localStorageMap.get(key) : null; + }, + setItem(key, value) { + localStorageMap.set(key, String(value)); + localStorageWriteCount += 1; + }, + removeItem(key) { + localStorageMap.delete(key); + }, + }; + let localStorageWriteCount = 0; const context = { clearTimeout: fakeClearTimeout, console, @@ -262,6 +301,9 @@ async function createChromeHarness({ }, document: { body: element("body"), + get documentElement() { + return element("html"); + }, getElementById(id) { return element(id); }, @@ -269,6 +311,12 @@ async function createChromeHarness({ if (!documentListeners.has(type)) documentListeners.set(type, []); documentListeners.get(type).push({ handler, capture: Boolean(capture) }); }, + removeEventListener(type, handler) { + const list = documentListeners.get(type); + if (!list) return; + const index = list.findIndex((entry) => entry.handler === handler); + if (index !== -1) list.splice(index, 1); + }, createElement(tag) { const el = element(`${tag}-${elements.size}`); el.tagName = tag.toUpperCase(); @@ -296,7 +344,48 @@ async function createChromeHarness({ if (!windowListeners.has(type)) windowListeners.set(type, []); windowListeners.get(type).push(handler); }, + removeEventListener(type, handler) { + const list = windowListeners.get(type); + if (!list) return; + const index = list.indexOf(handler); + if (index !== -1) list.splice(index, 1); + }, + dispatchEvent(event) { + const list = windowListeners.get(event.type) || []; + for (const handler of list) handler(event); + return !event.defaultPrevented; + }, + get innerWidth() { + return currentInnerWidth; + }, + set innerWidth(value) { + currentInnerWidth = Number(value); + }, + localStorage: localStorageApi, + matchMedia(query) { + if (query === "(max-width: 860px)") { + return { + get matches() { + return mobileBreakpointMatches; + }, + }; + } + return { + get matches() { + return false; + }, + }; + }, }, + localStorage: localStorageApi, + }; + const helpers = await import("../src/panel-width.js"); + context.LavishPanelWidth = { + PANEL_DEFAULTS: helpers.PANEL_DEFAULTS, + PANEL_STORAGE_KEY: helpers.PANEL_STORAGE_KEY, + clampPanelWidth: helpers.clampPanelWidth, + loadStoredPanelWidth: helpers.loadStoredPanelWidth, + savePanelWidth: helpers.savePanelWidth, }; vm.runInNewContext(source, context, { filename: "chrome-client.js" }); @@ -338,13 +427,13 @@ async function createChromeHarness({ for (const handler of handlers) handler({ source: frame.contentWindow, data: message }); }, sendWhiteboardMessage(data) { - const handlers = windowListeners.get("message") || []; - assert.ok(handlers.length > 0, "chrome-client registered a message handler"); + const handlers = windowListeners.get("message"); + assert.ok(handlers && handlers.length > 0, "chrome-client registered a message handler"); for (const handler of handlers) handler({ source: whiteboardFrame.contentWindow, data }); }, sendInlineWhiteboardMessage(whiteboard, data) { - const handlers = windowListeners.get("message") || []; - assert.ok(handlers.length > 0, "chrome-client registered a message handler"); + const handlers = windowListeners.get("message"); + assert.ok(handlers && handlers.length > 0, "chrome-client registered a message handler"); for (const handler of handlers) handler({ source: whiteboard.source, data }); }, dispatchDocumentKeydown(eventProps) { @@ -384,6 +473,87 @@ async function createChromeHarness({ beginRequests, artifactBeginRequests, artifactLoadToken: frameLoadToken, + panelWidthPx() { + const value = element("html").style["--panel-w"]; + return value ? Number(String(value).replace("px", "")) : null; + }, + isDraggingSplitter() { + return Boolean(element("body").classList.has("dragging-splitter")); + }, + storedPanelWidth() { + return localStorageMap.get(PANEL_STORAGE_KEY) ?? null; + }, + localStorageWriteCount() { + return localStorageWriteCount; + }, + setInnerWidth(value) { + currentInnerWidth = Number(value); + }, + setMobileBreakpoint(matches) { + mobileBreakpointMatches = matches; + }, + dispatchWindowResize() { + const handlers = windowListeners.get("resize") || []; + for (const handler of handlers) handler({}); + }, + fireSplitterPointer( + type, + { + clientX = 600, + pointerId = 1, + button = 0, + metaKey = false, + ctrlKey = false, + altKey = false, + shiftKey = false, + } = {}, + ) { + const splitter = element("splitter"); + const pointerdownHandler = splitter.listeners.get("pointerdown"); + if (type === "pointerdown") { + if (!pointerdownHandler) + throw new Error("chrome-client did not register a pointerdown handler on the splitter"); + } + const event = { + type, + pointerId, + clientX, + button, + metaKey, + ctrlKey, + altKey, + shiftKey, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }; + if (type === "pointerdown") { + pointerdownHandler(event); + return event; + } + // Drag listeners live on `window`, not `document`, so dispatch via the + // fake window's event listener map to match how the chrome wires them. + const winHandlers = windowListeners.get(type) || []; + assert.ok(winHandlers.length > 0, `chrome-client did not register a window ${type} handler`); + for (const handler of winHandlers) handler(event); + return event; + }, + fireSplitterEvent(type, eventProps = {}) { + const splitter = element("splitter"); + const handler = splitter.listeners.get(type); + if (!handler) throw new Error(`chrome-client did not register a ${type} handler on the splitter`); + const event = { + key: "", + defaultPrevented: false, + ...eventProps, + preventDefault() { + this.defaultPrevented = true; + }, + }; + handler(event); + return event; + }, }; } @@ -2220,3 +2390,175 @@ test("a local asset failure inside the artifact is reported as a fatal artifact assert.equal(failure.body.failures[0].kind, "artifact-asset-unavailable"); assert.match(failure.body.failures[0].detail, /logo\.png/); }); + +test("chrome client applies a stored panel width on init and re-clamps it to the viewport", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1000, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "500"]]), + }); + + // 60% of 1000 = 600, so 500 sits inside the allowed range. + assert.equal(chrome.panelWidthPx(), 500); + assert.equal(chrome.storedPanelWidth(), "500"); + // The stored value already satisfied the clamp, so the init commit must be + // a no-op and skip the localStorage write entirely. + assert.equal(chrome.localStorageWriteCount(), 0); +}); + +test("chrome client falls back to the default and rewrites the corrupt stored value", async () => { + const chrome = await createChromeHarness({ + localStorageValues: new Map([[PANEL_STORAGE_KEY, "not-a-number"]]), + }); + + assert.equal(chrome.panelWidthPx(), 360); + // A corrupt value is replaced with the computed fallback so subsequent reloads + // don't have to repeat the recovery. + assert.equal(chrome.storedPanelWidth(), "360"); +}); + +test("chrome client clamps a stored width that exceeds the viewport on init", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1000, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "5000"]]), + }); + + // 60% of 1000 = 600, the cap. + assert.equal(chrome.panelWidthPx(), 600); + // The clamped value should be persisted back so the next load does the same. + assert.equal(chrome.storedPanelWidth(), "600"); +}); + +test("chrome client survives a localStorage.getItem that throws and still commits the default", async () => { + const chrome = await createChromeHarness({ localStorageGetItemThrows: true }); + + // getItem threw, so the chrome must still apply the default width instead + // of crashing during init. + assert.equal(chrome.panelWidthPx(), 360); + // And the self-heal commit should still write the default so a subsequent + // reload (with a working storage) lands on the same value. + assert.equal(chrome.storedPanelWidth(), "360"); +}); + +test("chrome client drags the splitter to a new width and persists the result", async () => { + const chrome = await createChromeHarness({ innerWidth: 1200 }); + + chrome.fireSplitterPointer("pointerdown", { clientX: 800 }); + assert.equal(chrome.isDraggingSplitter(), true); + // initial move from the same pointerdown (cursor 800px from the left in a 1200px viewport -> 400px panel) + assert.equal(chrome.panelWidthPx(), 400); + + chrome.fireSplitterPointer("pointermove", { clientX: 700 }); + assert.equal(chrome.panelWidthPx(), 500); + + chrome.fireSplitterPointer("pointerup", { clientX: 700 }); + assert.equal(chrome.isDraggingSplitter(), false); + assert.equal(chrome.storedPanelWidth(), "500"); +}); + +test("chrome client does not preventDefault on pointerup so the dblclick chain still fires", async () => { + const chrome = await createChromeHarness({ innerWidth: 1200 }); + + // pointerdown still prevents default - the chrome uses that to suppress + // focus and text selection when the user grabs the splitter. + const downEvent = chrome.fireSplitterPointer("pointerdown", { clientX: 800 }); + assert.equal(downEvent.defaultPrevented, true); + + // pointerup must NOT preventDefault. The browser's default action for + // pointerup synthesizes the click event that dblclick depends on; calling + // preventDefault here would silently break the "double-click to reset" + // affordance documented on the splitter. + const upEvent = chrome.fireSplitterPointer("pointerup", { clientX: 700 }); + assert.equal(upEvent.defaultPrevented, false); +}); + +test("chrome client ignores right-clicks when starting a splitter drag", async () => { + const chrome = await createChromeHarness({ innerWidth: 1200 }); + + chrome.fireSplitterPointer("pointerdown", { clientX: 800, button: 2 }); + assert.equal(chrome.isDraggingSplitter(), false); + assert.equal(chrome.panelWidthPx(), 360); +}); + +test("chrome client ignores modifier-keyed pointerdowns so the browser keeps its shortcuts", async () => { + const chrome = await createChromeHarness({ innerWidth: 1200 }); + + chrome.fireSplitterPointer("pointerdown", { clientX: 800, metaKey: true }); + assert.equal(chrome.isDraggingSplitter(), false); +}); + +test("chrome client ignores pointermove for a different pointer than the active drag", async () => { + const chrome = await createChromeHarness({ innerWidth: 1200 }); + + chrome.fireSplitterPointer("pointerdown", { clientX: 900, pointerId: 7 }); + assert.equal(chrome.isDraggingSplitter(), true); + chrome.fireSplitterPointer("pointermove", { clientX: 600, pointerId: 99 }); + // Different pointerId should be ignored; width should still reflect the last accepted move. + assert.equal(chrome.panelWidthPx(), 300); +}); + +test("chrome client resets the panel width on splitter double-click and persists the default", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1200, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "500"]]), + }); + assert.equal(chrome.panelWidthPx(), 500); + + chrome.fireSplitterEvent("dblclick", {}); + assert.equal(chrome.panelWidthPx(), 360); + assert.equal(chrome.storedPanelWidth(), "360"); +}); + +test("chrome client re-clamps the panel width when the window resizes", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1200, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "600"]]), + }); + assert.equal(chrome.panelWidthPx(), 600); + + // Shrink the viewport below the stored width; the panel must clamp. + chrome.setInnerWidth(800); + // 60% of 800 = 480 + chrome.dispatchWindowResize(); + assert.equal(chrome.panelWidthPx(), 480); + assert.equal(chrome.storedPanelWidth(), "480"); +}); + +test("chrome client leaves the stored width alone when the resize lands in the mobile breakpoint", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1200, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "600"]]), + }); + assert.equal(chrome.panelWidthPx(), 600); + + // Simulate the user opening DevTools or briefly resizing the window onto a + // phone-width viewport. The splitter is hidden in that mode, so the resize + // listener must not silently shrink the stored width and overwrite + // localStorage - otherwise the desktop choice would be lost. + chrome.setMobileBreakpoint(true); + chrome.setInnerWidth(420); + chrome.dispatchWindowResize(); + + assert.equal(chrome.panelWidthPx(), 600); + assert.equal(chrome.storedPanelWidth(), "600"); +}); + +test("chrome client resumes resync when the window leaves the mobile breakpoint", async () => { + const chrome = await createChromeHarness({ + innerWidth: 1200, + localStorageValues: new Map([[PANEL_STORAGE_KEY, "600"]]), + }); + assert.equal(chrome.panelWidthPx(), 600); + + chrome.setMobileBreakpoint(true); + chrome.setInnerWidth(420); + chrome.dispatchWindowResize(); + // Still at the desktop-chosen width - the mobile resize was a no-op. + assert.equal(chrome.panelWidthPx(), 600); + + chrome.setMobileBreakpoint(false); + chrome.setInnerWidth(800); + chrome.dispatchWindowResize(); + // 60% of 800 = 480 + assert.equal(chrome.panelWidthPx(), 480); + assert.equal(chrome.storedPanelWidth(), "480"); +}); diff --git a/test/panel-width.test.js b/test/panel-width.test.js new file mode 100644 index 00000000..88db79dd --- /dev/null +++ b/test/panel-width.test.js @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PANEL_DEFAULTS, + PANEL_STORAGE_KEY, + clampPanelWidth, + loadStoredPanelWidth, + savePanelWidth, +} from "../src/panel-width.js"; + +test("PANEL_DEFAULTS exposes a min, maxViewportFraction, and default width", () => { + assert.equal(typeof PANEL_DEFAULTS.min, "number"); + assert.equal(typeof PANEL_DEFAULTS.maxViewportFraction, "number"); + assert.equal(typeof PANEL_DEFAULTS.default, "number"); + assert.ok(PANEL_DEFAULTS.min > 0); + assert.ok(PANEL_DEFAULTS.maxViewportFraction > 0 && PANEL_DEFAULTS.maxViewportFraction < 1); + assert.ok(PANEL_DEFAULTS.default >= PANEL_DEFAULTS.min); +}); + +test("PANEL_STORAGE_KEY is a stable namespaced localStorage key", () => { + assert.equal(PANEL_STORAGE_KEY, "lavish-axi:panel-w"); +}); + +test("clampPanelWidth returns the value when it sits in the allowed range", () => { + assert.equal(clampPanelWidth("420", 1000), 420); + assert.equal(clampPanelWidth(420, 1000), 420); +}); + +test("clampPanelWidth clamps below the minimum to the minimum", () => { + assert.equal(clampPanelWidth("100", 1000), PANEL_DEFAULTS.min); + assert.equal(clampPanelWidth(0, 1000), PANEL_DEFAULTS.min); + assert.equal(clampPanelWidth(-50, 1000), PANEL_DEFAULTS.min); +}); + +test("clampPanelWidth clamps above the viewport-fraction maximum to that maximum", () => { + // 60% of 1000 = 600 + assert.equal(clampPanelWidth("900", 1000), 600); + assert.equal(clampPanelWidth(900, 1000), 600); +}); + +test("clampPanelWidth never lets max fall below the minimum for tiny viewports", () => { + // 60% of 400 = 240, but min is 280; max must be 280 in that case + assert.equal(clampPanelWidth("500", 400), PANEL_DEFAULTS.min); + assert.equal(clampPanelWidth("280", 400), PANEL_DEFAULTS.min); +}); + +test("clampPanelWidth falls back to the default for nullish, empty, or non-numeric input", () => { + assert.equal(clampPanelWidth(null, 1000), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth(undefined, 1000), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth("", 1000), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth("not-a-number", 1000), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth(NaN, 1000), PANEL_DEFAULTS.default); +}); + +test("clampPanelWidth parses decimal pixel values", () => { + assert.equal(clampPanelWidth("420.7", 1000), 420.7); +}); + +test("clampPanelWidth strips trailing 'px' units when present", () => { + assert.equal(clampPanelWidth("420px", 1000), 420); +}); + +test("clampPanelWidth returns the default when the viewport is non-positive or invalid", () => { + assert.equal(clampPanelWidth("420", 0), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth("420", -100), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth("420", NaN), PANEL_DEFAULTS.default); + assert.equal(clampPanelWidth("420", Infinity), PANEL_DEFAULTS.default); +}); + +test("clampPanelWidth honors a caller-provided defaults override", () => { + const overrides = { min: 200, maxViewportFraction: 0.5, default: 300 }; + assert.equal(clampPanelWidth("210", 1000, overrides), 210); + assert.equal(clampPanelWidth("150", 1000, overrides), 200); + // 50% of 1000 = 500 + assert.equal(clampPanelWidth("600", 1000, overrides), 500); + assert.equal(clampPanelWidth("not-a-number", 1000, overrides), 300); +}); + +test("loadStoredPanelWidth reads the stored value and clamps it", () => { + const storage = { + values: { [PANEL_STORAGE_KEY]: "500" }, + getItem(key) { + return this.values[key] ?? null; + }, + }; + assert.equal(loadStoredPanelWidth(storage, 1000), 500); +}); + +test("loadStoredPanelWidth returns the default when the key is missing or invalid", () => { + const empty = { getItem: () => null }; + const corrupt = { getItem: () => "not-a-number" }; + assert.equal(loadStoredPanelWidth(empty, 1000), PANEL_DEFAULTS.default); + assert.equal(loadStoredPanelWidth(corrupt, 1000), PANEL_DEFAULTS.default); +}); + +test("loadStoredPanelWidth swallows storage exceptions and returns the default", () => { + const broken = { + getItem: () => { + throw new Error("storage blocked"); + }, + }; + assert.equal(loadStoredPanelWidth(broken, 1000), PANEL_DEFAULTS.default); +}); + +test("loadStoredPanelWidth accepts a nullish storage gracefully", () => { + assert.equal(loadStoredPanelWidth(null, 1000), PANEL_DEFAULTS.default); + assert.equal(loadStoredPanelWidth(undefined, 1000), PANEL_DEFAULTS.default); +}); + +test("savePanelWidth writes a numeric string to the storage key", () => { + const writes = []; + const storage = { + setItem(key, value) { + writes.push([key, value]); + }, + }; + savePanelWidth(storage, 420); + assert.deepEqual(writes, [[PANEL_STORAGE_KEY, "420"]]); +}); + +test("savePanelWidth ignores non-finite or non-positive widths", () => { + const writes = []; + const storage = { + setItem(key, value) { + writes.push([key, value]); + }, + }; + savePanelWidth(storage, 0); + savePanelWidth(storage, -10); + savePanelWidth(storage, NaN); + savePanelWidth(storage, "420"); + assert.deepEqual(writes, []); +}); + +test("savePanelWidth swallows storage exceptions", () => { + const broken = { + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + assert.doesNotThrow(() => savePanelWidth(broken, 420)); +}); + +test("savePanelWidth accepts a nullish storage gracefully", () => { + assert.doesNotThrow(() => savePanelWidth(null, 420)); + assert.doesNotThrow(() => savePanelWidth(undefined, 420)); +}); diff --git a/test/server.test.js b/test/server.test.js index 29ede254..14e81cf8 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -2214,7 +2214,7 @@ test("/chrome.css serves the extracted chrome stylesheet", async () => { assert.match(normalizeCssForAssertions(body), /--ink-900:#0f1115/); assert.match( normalizeCssForAssertions(body), - /\.layout\{[^}]*grid-template-columns:minmax\(0,1fr\) ?var\(--panel-w\)/, + /\.layout\{[^}]*grid-template-columns:minmax\(0,1fr\) ?auto ?var\(--panel-w\)/, ); } finally { await server.close();