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}
-