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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/lavish-design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
131 changes: 131 additions & 0 deletions src/chrome-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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) => {
Expand Down
54 changes: 53 additions & 1 deletion src/chrome.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
81 changes: 81 additions & 0 deletions src/panel-width.js
Original file line number Diff line number Diff line change
@@ -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.
}
}
Loading
Loading