diff --git a/CONTROLS.md b/CONTROLS.md index bd08c09..9fb30ad 100644 --- a/CONTROLS.md +++ b/CONTROLS.md @@ -106,6 +106,8 @@ the editor also answers to a plain `+` and `-`. | Jump the camera | Press or drag the minimap | Press or drag the minimap | view mode, canvas only | Press anywhere on the minimap to jump there, and keep dragging to keep moving. | | Scrub a number | Drag a field's glyph | Drag a field's glyph | edit mode | Drag a field's glyph sideways. Shift for ten at a time, Alt for a tenth. | | Re-dock a panel | Double-click a panel header | Double-click a panel header | anywhere | Double-click a floating panel's header to put it back against the edge. | +| Resize a panel | Drag a panel edge | Drag a panel edge | anywhere | Drag the inner edge for width, the bottom edge for height. | +| Reset a panel's size | Double-click a panel edge | Double-click a panel edge | anywhere | Width goes back to its default; a docked panel goes back to filling its edge. | | Scroll the pending changes | Wheel over the strip | Wheel over the strip | anywhere | A vertical wheel scrolls the strip sideways, because a mouse has no sideways. | ## In any field diff --git a/packages/overlay/src/app.ts b/packages/overlay/src/app.ts index a616245..a3341bb 100644 --- a/packages/overlay/src/app.ts +++ b/packages/overlay/src/app.ts @@ -79,7 +79,7 @@ import { } from "./popover-host"; import { StructureSet } from "./structure-set"; import { injectStyles } from "./styles"; -import { MIN_DOCK_W } from "./styles/const"; +import { MIN_DOCK_H, MIN_DOCK_W } from "./styles/const"; import { InlineResolver, type Surface, type SurfaceResolver } from "./surface"; import { mountToastHost, type ToastOptions, toast } from "./toast"; import { setRuntimeTokens, setStaticTokens } from "./tokens/registry"; @@ -114,13 +114,21 @@ const NUDGE_AXIS: Readonly> = { const LEFT_W = 340; const RIGHT_W = 360; /** Resize bounds: narrow enough to be useful, never more than half the viewport. - * `MIN_DOCK_W` lives in `styles/const.ts` — the stories need it too. */ + * `MIN_DOCK_W` and `MIN_DOCK_H` live in `styles/const.ts` — the stories and the + * stylesheet need them too. */ const MAX_DOCK_W = 720; -const WIDTH_KEY = `${PREFIX}-dock-widths`; +/** + * Both dock sizes, in one store. + * + * Renamed from `${PREFIX}-dock-widths`, which held a bare number per side and is + * still read once as a migration — see `restoreSizes`. Height joined width here + * rather than joining `DOCKS_KEY` for two reasons. It is written by the same + * gesture, so the write cadence argument below applies to it identically; and + * `restoreDocks` deliberately skips any entry that is not floating, so a docked + * height put there would be persisted and then never read back. + */ +const SIZE_KEY = `${PREFIX}-dock-size`; -/** Floor for a floating panel's height. Below this a panel is a title bar with - * a sliver under it, which is worse than not being resizable at all. */ -const MIN_DOCK_H = 200; /** Fallback header height. A collapsed dock is `display: none` and every metric * inside it measures 0, so clamping one needs a number to fall back on. */ const HEAD_H = 40; @@ -143,7 +151,7 @@ type Side = "left" | "right"; type DockMode = "docked" | "floating"; /** Where a dock is. `x`/`y`/`h` are ignored while docked; the edge anchors in - * `docks.css.ts` drive it there. */ + * `docks.css.ts` drive it there, and its docked height is `DockSize.h`. */ interface DockPlacement { h: number; mode: DockMode; @@ -151,6 +159,26 @@ interface DockPlacement { y: number; } +/** + * How big a dock is on its edge. + * + * `h` of 0 means *unpinned*, not "zero tall": a docked panel is anchored `top` + * and `bottom` and fills its edge until a drag pins it, which is why the reset + * removes the pin rather than replacing it with a literal. A number for "the + * height of the window, less two insets" would be wrong the moment the window + * changed size, and would need re-deriving on every resize; `bottom` answers the + * same question for free and keeps answering it. + * + * A docked height and a *floating* height are deliberately two different + * numbers, held in two different stores. The useful height of a column on an + * edge and of a card over the page are not the same, and `floatHeight` has + * always derived one from the other at tear-off rather than carrying it across. + */ +interface DockSize { + h: number; + w: number; +} + /** * The backends the picker offers, each with its own product mark. * @@ -504,10 +532,10 @@ export class AirshipApp { private pendingSeen = 0; private rightOpen = false; - /** Live dock widths, persisted across reloads and reset from the splitters. */ - private readonly width: Record = { - left: LEFT_W, - right: RIGHT_W, + /** Live dock sizes, persisted across reloads and reset from the splitters. */ + private readonly size: Record = { + left: { h: 0, w: LEFT_W }, + right: { h: 0, w: RIGHT_W }, }; /** The dock a splitter drag is currently resizing, plus its start geometry. * `startX` matters only while floating — see `watchSplitters`. */ @@ -517,6 +545,16 @@ export class AirshipApp { startX: number; } | null = null; private readonly splitterDelta = new DragDelta(); + /** The dock a bottom-edge drag is resizing, the painted height it started + * from, and the stored pair a cancel has to put back. Separate from `resizing` + * so a cancel restores the axis it belongs to. */ + private resizingH: { + side: Side; + startH: number; + wasDocked: number; + wasFloat: number; + } | null = null; + private readonly heightDelta = new DragDelta(); /** Where each dock is: pinned to its edge, or floating and where. */ private readonly placement: Record = { @@ -937,7 +975,7 @@ export class AirshipApp { mount(): void { this.stage.bindSelection?.(() => this.selected); this.root = el("div", { id: `${PREFIX}-root` }); - this.restoreWidths(); + this.restoreSizes(); this.restoreDocks(); // Before the docks are built: `buildAgentButton` paints the tooltip from // this state, and a restore afterwards would leave the first frame showing @@ -978,6 +1016,7 @@ export class AirshipApp { this.clampPlacements(); this.applyWidths(); this.watchSplitters(); + this.watchHeightSplitters(); this.watchDockDrag(); window.addEventListener("resize", this.onViewportResize); this.stage.onLayoutChange(() => { @@ -1152,13 +1191,13 @@ export class AirshipApp { /** * A splitter on the dock's inner edge. dnd-kit drives the gesture so it picks * up the same activation threshold and Escape-to-cancel as every other drag; - * double-clicking snaps the dock back to its default width. + * double-clicking snaps the dock back to its default size. */ private buildSplitter(side: Side): HTMLElement { const handle = el("div", { class: `${cls("splitter")} ${cls(`splitter-${side}`)}`, "data-tip": "Drag to resize, double-click to reset", - onDblclick: () => this.resetWidth(side), + onDblclick: () => this.resetSize(side), }); this.dockScope.add( new Draggable( @@ -1176,6 +1215,145 @@ export class AirshipApp { return handle; } + /** + * A splitter on the dock's bottom edge, for its height. + * + * The same gesture on the other axis, and deliberately the same shape: one + * `Draggable` with no feedback, one monitor triple, one clamp. What it writes + * differs by mode — a docked panel's height is `size[side].h` and a floating + * one's is `placement[side].h` — because those are two different numbers with + * two different defaults, and `applyPlacement` picks between them. + * + * There is no `trackFloatingResize` twin. That exists because the right dock + * floats from a `left` anchor, so widening it from the *inner* edge would slide + * the grip out from under the cursor; a bottom edge leaves the top where it is + * in both modes, so neither dock needs compensating. + */ + private buildHeightSplitter(side: Side): HTMLElement { + const handle = el("div", { + class: `${cls("splitter")} ${cls("splitter-bottom")}`, + "data-tip": "Drag to resize, double-click to reset", + onDblclick: () => this.resetSize(side), + }); + this.dockScope.add( + new Draggable( + { + element: handle, + id: `${DND.dockHeight}:${side}`, + // As above: the handle must not travel, and only `y` is read. + plugins: FEEDBACK.none, + type: DND.dockHeight, + }, + manager + ) + ); + return handle; + } + + /** + * The height a bottom-edge drag starts from, by mode. + * + * Both branches answer with what the panel is *painted* at rather than with + * what is stored, and both need to for the same reason: a drag is a gesture + * against what you can see, so a stored number the paint does not match makes + * the grip inert until the delta has covered the difference. + * + * Floating is the case that bites. `applyPlacement` paints + * `min(h, room below y)` and deliberately leaves `h` alone — carrying a panel + * down the screen must not shrink it permanently — so a panel torn off at full + * height and dragged to the middle has `h` of 860 while showing 380. Starting + * from 860 would mean 480px of travel before the clamp let go. + * + * Docked, the stored 0 is the *unpinned* sentinel, so there is nothing to + * start from at all and the measured box is the only honest answer. + */ + private dockHeight(side: Side): number { + const p = this.placement[side]; + if (p.mode === "floating") { + return Math.min( + p.h, + Math.max(MIN_DOCK_H, window.innerHeight - p.y - this.inset) + ); + } + return this.size[side].h || this.dockEl(side).offsetHeight; + } + + private setDockHeight(side: Side, h: number): void { + const clamped = clampHeight( + h, + this.placement[side].mode === "floating" + ? this.placement[side].y + : this.inset, + this.inset + ); + if (this.placement[side].mode === "floating") { + this.placement[side].h = clamped; + } else { + this.size[side].h = clamped; + } + this.applyPlacement(side); + } + + private watchHeightSplitters(): void { + // Captured for the reason `watchSplitters` gives below, and separate from it + // so a cancelled drag restores the axis it belongs to rather than the other. + this.disposers.push( + manager.monitor.addEventListener("dragstart", () => { + const id = String(manager.dragOperation.source?.id ?? ""); + if (!id.startsWith(DND.dockHeight)) { + return; + } + const side: Side = id.endsWith("left") ? "left" : "right"; + this.resizingH = { + side, + startH: this.dockHeight(side), + // The *stored* pair, kept alongside the painted `startH` so Escape can + // put back exactly what was there. `startH` is a measurement and often + // a stand-in — for an unpinned docked panel it is `offsetHeight`, where + // the stored value is the 0 sentinel — so restoring from it would end a + // cancelled drag by pinning a panel that was filling its edge, and + // `saveSizes` below would write that pin to disk. A cancel has to be a + // no-op, and only the raw numbers can make it one. + wasDocked: this.size[side].h, + wasFloat: this.placement[side].h, + }; + this.heightDelta.start(); + this.controller.guard.setDragging(true, "row-resize"); + }), + manager.monitor.addEventListener("dragmove", (e) => { + const rz = this.resizingH; + if (!rz) { + return; + } + // No sign flip: both docks are anchored at the top and grow downwards. + this.setDockHeight(rz.side, rz.startH + this.heightDelta.update(e).y); + }), + manager.monitor.addEventListener("dragend", (e) => { + const rz = this.resizingH; + if (!rz) { + return; + } + if (e.canceled) { + // Written straight back rather than through `setDockHeight`, which + // clamps — and a clamp is exactly what must not happen here, because + // the 0 sentinel would come back as `MIN_DOCK_H`. + this.size[rz.side].h = rz.wasDocked; + this.placement[rz.side].h = rz.wasFloat; + this.applyPlacement(rz.side); + } + this.resizingH = null; + this.controller.guard.setDragging(false); + this.saveSizes(); + this.saveDocks(); + // The composer's cap is a fraction of the left dock's height, so a + // height drag is exactly the gesture that has to re-run it. + this.autoGrow(); + // The outline lives in viewport coords; realign after layout settles. + requestAnimationFrame(() => this.controller.drawOutline()); + }) + ); + } + private watchSplitters(): void { // Captured, like every other monitor listener in the repo. `manager` is a // module singleton, so `dockScope.clear()` in `destroy` takes the draggable @@ -1190,7 +1368,7 @@ export class AirshipApp { const side: Side = id.endsWith("left") ? "left" : "right"; this.resizing = { side, - startW: this.width[side], + startW: this.size[side].w, startX: this.placement[side].x, }; this.splitterDelta.start(); @@ -1204,7 +1382,7 @@ export class AirshipApp { // The left dock grows rightwards, the right dock grows leftwards. const dx = this.splitterDelta.update(e).x * (rz.side === "left" ? 1 : -1); - this.width[rz.side] = clampWidth(rz.startW + dx); + this.size[rz.side].w = clampWidth(rz.startW + dx); this.trackFloatingResize(rz); this.applyWidths(); }), @@ -1214,13 +1392,13 @@ export class AirshipApp { return; } if (e.canceled) { - this.width[rz.side] = rz.startW; + this.size[rz.side].w = rz.startW; this.placement[rz.side].x = rz.startX; this.applyWidths(); } this.resizing = null; this.controller.guard.setDragging(false); - this.saveWidths(); + this.saveSizes(); this.saveDocks(); this.autoGrow(); // The outline lives in viewport coords; realign after the layout settles. @@ -1252,7 +1430,7 @@ export class AirshipApp { return; } const right = rz.startX + rz.startW; - p.x = this.clampFloat("right", right - this.width.right, p.y).x; + p.x = this.clampFloat("right", right - this.size.right.w, p.y).x; } // -- Dock dragging --------------------------------------------------------- @@ -1371,10 +1549,30 @@ export class AirshipApp { ); } - private resetWidth(side: Side): void { - this.width[side] = side === "left" ? LEFT_W : RIGHT_W; + /** + * Put a panel back to its default size, on both axes. + * + * Width goes back to a literal because a panel's default width *is* one — 340 + * and 360 are choices about how much room a chat and an inspector need, and + * they do not follow from anything about the window. + * + * Height does not, and that is why the docked case unpins rather than + * resetting. A docked column's right height is "the edge", which is a fact + * about the window: a literal would be stale the moment it changed size and + * would need re-deriving on every resize, where dropping the pin hands the + * question back to the `bottom` anchor that answers it for free. A floating + * panel has no edge to fill, so it does get a number — the same one + * `floatHeight` derives for a panel torn off closed. + */ + private resetSize(side: Side): void { + this.size[side].w = side === "left" ? LEFT_W : RIGHT_W; + this.size[side].h = 0; + if (this.placement[side].mode === "floating") { + this.placement[side].h = clampHeight(window.innerHeight - 2 * this.inset); + this.saveDocks(); + } this.applyWidths(); - this.saveWidths(); + this.saveSizes(); this.autoGrow(); requestAnimationFrame(() => this.controller.drawOutline()); } @@ -1405,7 +1603,7 @@ export class AirshipApp { for (const side of ["left", "right"] as const) { root.style.setProperty( `--${PREFIX}-${side}-w`, - `${clampWidth(this.width[side])}px` + `${clampWidth(this.size[side].w)}px` ); this.applyPlacement(side); } @@ -1417,7 +1615,7 @@ export class AirshipApp { // every fit and every centring quietly off-centre to the left. const covers = (side: Side): number => this.isVisible(side) && this.placement[side].mode === "docked" - ? clampWidth(this.width[side]) + gutter + ? clampWidth(this.size[side].w) + gutter : 0; this.stage.setSafeInset?.({ left: covers("left"), @@ -1426,26 +1624,52 @@ export class AirshipApp { this.stage.relayout?.(); } - private restoreWidths(): void { + /** + * Both sizes, and the old width-only store read once on the way past. + * + * The previous key held a bare number per side. Migrating rather than dropping + * it costs six lines and is the difference between an upgrade nobody notices + * and every existing install silently losing the panel widths it was set up + * with. The old key is left in place rather than removed — a read that finds + * nothing is the same as a read that finds a stale number nothing looks at, + * and deleting somebody's data to tidy up is a bad trade. + */ + private restoreSizes(): void { try { - const raw = localStorage.getItem(WIDTH_KEY); + const raw = + localStorage.getItem(SIZE_KEY) ?? + localStorage.getItem(`${PREFIX}-dock-widths`); const saved = raw - ? (JSON.parse(raw) as Partial>) + ? (JSON.parse(raw) as Partial>>) : null; - if (saved?.left) { - this.width.left = clampWidth(saved.left); - } - if (saved?.right) { - this.width.right = clampWidth(saved.right); + for (const side of ["left", "right"] as const) { + const s = saved?.[side]; + if (typeof s === "number") { + this.size[side].w = clampWidth(s); + continue; + } + if (s?.w) { + this.size[side].w = clampWidth(s.w); + } + // Unclamped, and the sentinel preserved. Two separate points. + // + // A bare `clampHeight(Number(s.h) || 0)` would floor the 0 to + // `MIN_DOCK_H`, silently pinning every docked panel to a fifth of the + // window on the first reload. And clamping a *real* stored height here + // would make a smaller window permanent — the same one-way trip + // `clampPlacements` refuses to take with the floating height, for the + // same reason. `applyPlacement` clamps what is painted; the store keeps + // what the user asked for. + this.size[side].h = Number(s?.h) || 0; } } catch { // A malformed or blocked store just means the defaults stand. } } - private saveWidths(): void { + private saveSizes(): void { try { - localStorage.setItem(WIDTH_KEY, JSON.stringify(this.width)); + localStorage.setItem(SIZE_KEY, JSON.stringify(this.size)); } catch { // Private mode / quota — resizing still works for this session. } @@ -1489,10 +1713,17 @@ export class AirshipApp { * one-way trip, shrinking the panel a little on every downward drag and never * giving it back when you carry it up again. So `h` keeps what the user asked * for and only what is *painted* is fitted to the room below `y`. + * + * Docked, `--*-h` comes from `size[side].h` instead, and only `.dock-h` — set + * when that is non-zero — makes the stylesheet read it. A docked panel with no + * pinned height keeps its `bottom` anchor and fills its edge, which is a fact + * about the window that CSS can maintain for free and JS would have to + * recompute on every resize. */ private applyPlacement(side: Side): void { const p = this.placement[side]; const floating = p.mode === "floating"; + const pinned = this.size[side].h; const room = Math.max(MIN_DOCK_H, window.innerHeight - p.y - this.inset); const root = document.documentElement; root.style.setProperty(`--${PREFIX}-${side}-x`, `${Math.round(p.x)}px`); @@ -1501,13 +1732,30 @@ export class AirshipApp { // an unclamped number here would put the two edges out of step. root.style.setProperty( `--${PREFIX}-${side}-r`, - `${Math.round(window.innerWidth - p.x - clampWidth(this.width[side]))}px` - ); - root.style.setProperty( - `--${PREFIX}-${side}-h`, - `${Math.round(Math.min(p.h, room))}px` + `${Math.round(window.innerWidth - p.x - clampWidth(this.size[side].w))}px` ); + // Removed rather than set to something arbitrary when there is no height to + // publish, because of how the two consumers fail. `.dock-h`'s `var()` has no + // fallback, so an absent property makes `height` invalid at computed-value + // time and the declaration is dropped — leaving the `top`/`bottom` anchors in + // charge, which is exactly what an unpinned panel wants. Writing a clamped 0 + // instead would publish `MIN_DOCK_H`, so the day that class is applied when + // it should not be, the panel snaps to 200px rather than looking untouched. + const painted = floating + ? Math.min(p.h, room) + : pinned && clampHeight(pinned, this.inset, this.inset); + if (painted) { + root.style.setProperty( + `--${PREFIX}-${side}-h`, + `${Math.round(painted)}px` + ); + } else { + root.style.removeProperty(`--${PREFIX}-${side}-h`); + } this.dockEl(side).classList.toggle(cls("dock-float"), floating); + // Only a *docked* panel needs telling to read the height — a floating one + // has no `bottom` anchor to drop, and always reads it. + this.dockEl(side).classList.toggle(cls("dock-h"), !floating && pinned > 0); this.pillEl(side).classList.toggle(cls("pill-float"), floating); } @@ -1542,7 +1790,7 @@ export class AirshipApp { y: number ): { x: number; y: number } { const w = this.isOpen(side) - ? clampWidth(this.width[side]) + ? clampWidth(this.size[side].w) : this.pillEl(side).offsetWidth || MIN_DOCK_W; // `offsetHeight` reads 0 on a collapsed dock — it is `display: none` — which // is why the header is held in `this.heads` and why there is a floor. @@ -1583,25 +1831,29 @@ export class AirshipApp { */ private readonly onViewportResize = (): void => { for (const side of ["left", "right"] as const) { - this.width[side] = clampWidth(this.width[side]); + this.size[side].w = clampWidth(this.size[side].w); } this.clampPlacements(); this.applyWidths(); this.autoGrow(); this.saveDocks(); - this.saveWidths(); + this.saveSizes(); }; /** - * Placement gets its own store key rather than joining `WIDTH_KEY`. + * Placement gets its own store key rather than joining `SIZE_KEY`. * - * Not only because the payload shape differs — `restoreWidths` would read an - * object where it expects a number — but because of write cadence. `saveWidths` - * fires from the splitter's drop and from `resetWidth`, whose entire job is to - * put a width back; folding placement into the same blob would put the float - * state one careless `JSON.stringify(this.width)` away from being erased by a - * double-click on a splitter. Two keys make that impossible, and an existing - * install keeps its widths across the upgrade with no migration to write. + * Because of write cadence. `saveSizes` fires from the splitter's drop and + * from `resetSize`, whose entire job is to put a size back; folding placement + * into the same blob would put the float state one careless + * `JSON.stringify(this.size)` away from being erased by a double-click on a + * splitter. Two keys make that impossible. + * + * It is also why the *docked* height went to `SIZE_KEY` rather than joining + * `h` here. The loop below `continue`s past anything that is not floating, so + * a docked height stored in this blob would be written on every drop and never + * read back — which is a worse failure than not persisting it at all, because + * it looks like it works until you reload. */ private restoreDocks(): void { try { @@ -1734,7 +1986,6 @@ export class AirshipApp { this.bar = el("div", { class: cls("bar") }, [ ...this.editOnlyBar, ...this.viewOnlyBar, - // Outside both mode lists on purpose: the shortcuts sheet documents both this.buildSurfaceToggle(), el("div", { class: cls("bar-sep") }), this.buildEditToggle(), @@ -2456,8 +2707,8 @@ export class AirshipApp { this.buildAgentButton(), this.buildNewChatButton(), this.iconButton("history", "Past chats", () => this.toggleHistory()), - this.iconButton("rotate-ccw", "Reset width", () => - this.resetWidth("left") + this.iconButton("rotate-ccw", "Reset size", () => + this.resetSize("left") ), this.panelToggle("left", "chat", false), ]), @@ -2517,6 +2768,7 @@ export class AirshipApp { this.chatBody, this.framesBody, this.buildSplitter("left"), + this.buildHeightSplitter("left"), ] ); this.leftBrand = el("span", { @@ -2569,8 +2821,8 @@ export class AirshipApp { el("span", { class: cls("brand-name"), text: "Frames" }), ]), el("div", { class: cls("head-actions") }, [ - this.iconButton("rotate-ccw", "Reset width", () => - this.resetWidth("left") + this.iconButton("rotate-ccw", "Reset size", () => + this.resetSize("left") ), this.panelToggle("left", "frames", false), ]), @@ -2752,8 +3004,8 @@ export class AirshipApp { el("span", { class: cls("brand-name"), text: "Design" }), ]), el("div", { class: cls("head-actions") }, [ - this.iconButton("rotate-ccw", "Reset width", () => - this.resetWidth("right") + this.iconButton("rotate-ccw", "Reset size", () => + this.resetSize("right") ), this.panelToggle("right", "design", false), ]), @@ -2762,7 +3014,12 @@ export class AirshipApp { this.rightDock = el( "div", { class: `${cls("dock")} ${cls("dock-right")} ${cls("hidden")}` }, - [head, this.panel.element, this.buildSplitter("right")] + [ + head, + this.panel.element, + this.buildSplitter("right"), + this.buildHeightSplitter("right"), + ] ); this.rightPill = this.buildPill("right", "design", [ el("div", { class: cls("brand") }, [ @@ -4428,9 +4685,32 @@ function floatHeight( return clampHeight(open ? measured : window.innerHeight - 2 * inset); } -/** Keep a floating panel taller than a header and never taller than the window. */ -function clampHeight(h: number): number { - return clamp(h, MIN_DOCK_H, window.innerHeight); +/** + * Keep a panel taller than a header and inside the room below its top edge. + * + * Exported so the rule can be asserted directly, the way `dockVisible` below is + * and for the reason it gives: standing up an `AirshipApp` to check a clamp is a + * test of the mount path, not of the clamp. + * + * There is no `MAX_DOCK_H` twin to `MAX_DOCK_W`, and that asymmetry is + * deliberate rather than an omission. Half the viewport is a sensible ceiling + * for a side column's *width* and a nonsense one for its height — a docked panel + * wants the whole edge, and the only real ceiling is the room it has. + * + * Both insets default to the constant and both are parameters, so a caller that + * has the *measured* one — `this.inset`, read off `--ap-space-md` at mount — + * can pass it and keep the drag clamp and the paint clamp on the same number. + * They agree today because the token is 20; a theme that moved it would put them + * out of step, which is the sort of thing that shows up as a panel that will not + * quite reach the bottom of the window. + */ +export function clampHeight( + h: number, + top = DOCK_INSET, + bottom = DOCK_INSET +): number { + const room = Math.max(MIN_DOCK_H, window.innerHeight - top - bottom); + return clamp(h, MIN_DOCK_H, room); } /** diff --git a/packages/overlay/src/dnd/manager.ts b/packages/overlay/src/dnd/manager.ts index 456ec13..ec899ec 100644 --- a/packages/overlay/src/dnd/manager.ts +++ b/packages/overlay/src/dnd/manager.ts @@ -40,6 +40,15 @@ export interface Coordinates { */ export const DND = { canvasNode: "airship:canvas-node", + /** + * Dragging a dock's bottom edge to set its height. + * + * Its own type rather than a suffix on `splitter`, and that is load-bearing: + * `watchSplitters` selects a width drag with `id.startsWith(DND.splitter)`, so + * an `"airship:splitter-v"` would match it and be read as one — with the *side* + * then parsed off the end of the wrong id. + */ + dockHeight: "airship:dock-height", /** Dragging a dock's header — or its collapsed pill — to float or move it. */ dockMove: "airship:dock-move", /** Dragging a frame's edge or corner grip to resize the frame itself. */ diff --git a/packages/overlay/src/dock-size.test.ts b/packages/overlay/src/dock-size.test.ts new file mode 100644 index 0000000..9cf5579 --- /dev/null +++ b/packages/overlay/src/dock-size.test.ts @@ -0,0 +1,226 @@ +/** + * How tall a panel is allowed to be, and what "no height" means. + * + * Tested as a free function rather than through `AirshipApp`, for the reason + * `dock-gate.test.ts` gives about its own: standing up a socket, a dnd-kit + * manager and a live document to check a clamp is a test of the mount path. + * + * The interesting case is not the clamp, it is the **0 sentinel**. A docked + * panel is anchored `top` and `bottom` and fills its edge until a drag pins it, + * so `size[side].h` of 0 means *unpinned* — and `clampHeight`'s floor is + * `MIN_DOCK_H`, which would turn a stored 0 into 200 on the way back in. That + * was harmless for as long as docked mode ignored the number; the moment + * `.dock-h` started reading it, a round trip through `localStorage` would have + * silently pinned every panel in every install to a fifth of the window. Hence + * the guard in `restoreSizes`, and hence this file. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AirshipApp, clampHeight, type Stage } from "./app"; +import { ChromeLayer } from "./chrome-layer"; +import { cls, PREFIX } from "./dom"; +import { MIN_DOCK_H } from "./styles/const"; +import { InlineResolver } from "./surface"; + +/** The dock's inset from the viewport edge — `--ap-space-md`. */ +const INSET = 20; + +/** A stage with nothing in it, which is all `mount` requires. */ +function stubStage(): Stage { + const layer = new ChromeLayer(); + return { + destroy() { + // Nothing to take down. + }, + layer, + mount() { + layer.mount(document.body); + }, + onLayoutChange() { + // Nothing moves under a stage with no content. + }, + resolver: new InlineResolver(), + swallowPresses: true, + }; +} + +function stubStorage(): void { + const store = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + clear: () => store.clear(), + getItem: (k: string) => store.get(k) ?? null, + key: () => null, + length: 0, + removeItem: (k: string) => store.delete(k), + setItem: (k: string, v: string) => store.set(k, v), + }, + }); +} + +const apps: AirshipApp[] = []; + +function mount(): AirshipApp { + const app = new AirshipApp( + { mode: "inline", wsPath: "/__airship/ws" }, + stubStage() + ); + app.mount(); + apps.push(app); + return app; +} + +const seed = (key: string, value: unknown): void => { + localStorage.setItem(key, JSON.stringify(value)); +}; + +beforeEach(() => { + // happy-dom reports 768 by default; pinned here so the arithmetic below is + // about the clamp rather than about the environment. + window.innerHeight = 900; + vi.useFakeTimers(); + stubStorage(); + vi.stubGlobal( + "WebSocket", + class { + onclose: (() => void) | null = null; + addEventListener() { + // Never opens; nothing here is about what the socket carries. + } + close() { + this.onclose?.(); + } + send() { + // Nothing to send. + } + } + ); +}); + +afterEach(() => { + for (const app of apps.splice(0)) { + app.destroy(); + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +describe("clampHeight", () => { + it("keeps a panel taller than a header", () => { + expect(clampHeight(10)).toBe(MIN_DOCK_H); + expect(clampHeight(0)).toBe(MIN_DOCK_H); + }); + + it("keeps it inside the room below its top edge", () => { + // The window less the top offset and the bottom inset — not the whole + // window, which is what it used to be and which let a docked panel be + // clamped to a height that ran its foot past the edge it sits on. + expect(clampHeight(5000)).toBe(900 - INSET - INSET); + expect(clampHeight(5000, 300)).toBe(900 - 300 - INSET); + }); + + it("leaves a height that already fits alone", () => { + expect(clampHeight(420)).toBe(420); + }); + + it("never returns less than the floor, however little room there is", () => { + // A panel dragged to the bottom of a short window has negative room. The + // floor has to win, or the clamp inverts and `Math.min` picks the wrong end. + expect(clampHeight(400, 890)).toBe(MIN_DOCK_H); + }); + + it("has no ceiling of its own, unlike the width clamp", () => { + // `clampWidth` caps at half the viewport, which is right for a side column's + // width and nonsense for its height: a docked panel wants the whole edge. + window.innerHeight = 2000; + expect(clampHeight(1800)).toBe(1800); + }); +}); + +/* + * The store, exercised through the real thing. + * + * `restoreSizes` is private, so this mounts an app to reach it — the cost the + * `dockVisible` docstring warns about, paid on purpose here because a *copy* of + * the guard is exactly what this file must not be. The one edit it exists to + * prevent is somebody rewriting the sentinel line as + * `clampHeight(Number(s.h) || 0)`, and a reimplementation of that line in the + * test cannot fail when the source changes. + */ + +const KEY = `${PREFIX}-dock-size`; +const LEGACY_KEY = `${PREFIX}-dock-widths`; + +/** Whether a docked panel came back pinned, read off the DOM it drives. */ +function pinned(side: "left" | "right"): boolean { + const el = document.querySelector(`.${cls(`dock-${side}`)}`); + if (!el) { + throw new Error(`No ${side} dock.`); + } + return el.classList.contains(cls("dock-h")); +} + +describe("restoring the persisted size", () => { + it("brings the unpinned sentinel back as unpinned", () => { + // The regression: `clampHeight`'s floor is `MIN_DOCK_H`, so a stored 0 run + // through it comes back as 200 — and every docked panel in every install + // silently pins itself to a fifth of the window on the first reload. + seed(KEY, { left: { h: 0, w: 340 }, right: { h: 0, w: 360 } }); + mount(); + + expect(pinned("left")).toBe(false); + expect(pinned("right")).toBe(false); + }); + + it("brings a real stored height back pinned, and unclamped", () => { + // Unclamped in the *store*: clamping on read would make a smaller window + // permanent, which is the one-way trip `clampPlacements` refuses to take + // with the floating height. `applyPlacement` clamps what is painted. + seed(KEY, { left: { h: 5000, w: 340 }, right: { h: 300, w: 360 } }); + mount(); + + expect(pinned("left")).toBe(true); + expect(pinned("right")).toBe(true); + expect( + document.documentElement.style.getPropertyValue(`--${PREFIX}-right-h`) + ).toBe("300px"); + }); + + it("migrates the old width-only store rather than dropping it", () => { + // The previous key held a bare number per side. Losing it would reset every + // existing install's panel widths on upgrade, which is a rude way to ship a + // rename. + seed(LEGACY_KEY, { left: 300, right: 400 }); + mount(); + + expect( + document.documentElement.style.getPropertyValue(`--${PREFIX}-left-w`) + ).toBe("300px"); + expect(pinned("left")).toBe(false); + }); + + it("survives a store that is not the shape it expects", () => { + seed(KEY, { left: { h: "nonsense", w: null }, right: 12 }); + + expect(() => mount()).not.toThrow(); + expect(pinned("left")).toBe(false); + }); + + it("round-trips what it wrote", () => { + seed(KEY, { left: { h: 420, w: 300 }, right: { h: 0, w: 360 } }); + mount(); + const written = localStorage.getItem(KEY); + + document.body.replaceChildren(); + for (const app of apps.splice(0)) { + app.destroy(); + } + localStorage.setItem(KEY, written ?? ""); + mount(); + + expect(pinned("left")).toBe(true); + expect(pinned("right")).toBe(false); + }); +}); diff --git a/packages/overlay/src/docks.stories.ts b/packages/overlay/src/docks.stories.ts new file mode 100644 index 0000000..340de0d --- /dev/null +++ b/packages/overlay/src/docks.stories.ts @@ -0,0 +1,162 @@ +import type { Meta, StoryObj } from "@storybook/html-vite"; +import { cls, el, PREFIX } from "./dom"; +import { dock, inspectorBody, plainStage } from "./stories/chrome"; +import { MIN_DOCK_H, MIN_DOCK_W } from "./styles/const"; + +/* + * The docks, and the two axes they can be sized on. + * + * Both panels resized in width and only in width, from a splitter on the inner + * edge, and "Reset width" put that one number back. Height was half built: a + * *floating* panel's height was written at tear-off, clamped, persisted and + * restored, and nothing could change it; a *docked* panel had no height at all, + * being anchored `top` and `bottom` with no `height` between them. + * + * So there are two shapes here, and the difference between them is the whole + * design. Unpinned, a docked panel fills its edge and re-fits a resized window + * with no JS at all. Pinned, it drops the `bottom` anchor and takes + * `--*-h`. That is also why the reset *removes* the pin rather than replacing + * it with a number: "the edge" is a fact about the window, and a literal for it + * would be stale the moment the window changed size. + */ + +const meta: Meta = { + title: "Chrome/Docks", +}; + +export default meta; + +/** Enough rows to give the panel something to be a height *of*. */ +function filler(rows: number): HTMLElement { + return inspectorBody( + Array.from({ length: rows }, (_, i) => + el("div", { class: cls("sect") }, [ + el("div", { class: cls("sect-head") }, [ + el("span", { class: cls("sect-title"), text: `Section ${i + 1}` }), + ]), + ]) + ) + ); +} + +/** + * Unpinned and pinned, side by side. + * + * The left panel has no height of its own and runs the full stage; the right one + * carries `.dock-h` and stops where it was dragged to. Both carry the two + * splitters, which is the only place in the catalogue you can see them: they are + * 7px transparent strips that show a 2px hairline on hover and nothing + * otherwise, which is why both resize gestures shipped undocumented until + * `keys/catalog.ts` grew rows for them. + */ +export const Resizing: StoryObj = { + play: ({ canvasElement }) => { + const docks = [ + ...canvasElement.querySelectorAll("[data-story-dock]"), + ]; + if (docks.length !== 2) { + throw new Error( + `Expected two docks in the stage, found ${docks.length}.` + ); + } + const [filling, pinned] = docks; + + /* + * What is asserted, and what deliberately is not. + * + * `stories/chrome.ts` neutralises `position` and `inset` on + * `[data-story-dock]` so a dock can sit in a story stage rather than pinned + * to the viewport. That takes the *edge-filling* half of the design out of + * reach here — neither of these has an edge to fill, so comparing their + * heights would only be measuring how tall eight section headers happen to + * be, and it would flip the day somebody changed the filler. Better stated + * than faked; the real anchoring is `app.ts`'s business and + * `dock-size.test.ts` covers the state that drives it. + * + * What this can prove is the *branch*, which is what the `h: 0` sentinel + * exists to keep honest: pinned carries `.dock-h` and is exactly its + * `--*-h`, unpinned carries neither and is sized by its content. + */ + if (!pinned.classList.contains(cls("dock-h"))) { + throw new Error("The pinned dock is missing `.dock-h`."); + } + if (pinned.offsetHeight !== 260) { + throw new Error( + `A pinned dock should be exactly its --*-h, but it is ${pinned.offsetHeight}px.` + ); + } + if (filling.classList.contains(cls("dock-h"))) { + throw new Error("The unpinned dock should not carry `.dock-h`."); + } + if (filling.style.getPropertyValue(`--${PREFIX}-right-h`) !== "") { + throw new Error( + "The unpinned dock published a height it has no rule to read." + ); + } + + // Both strips are present and neither is lying across the panel. The bottom + // one resets `top` and `width`, which `.splitter` sets — without that it + // would be a 7px full-height column eating clicks down the panel's left + // edge, and it would still look correct in a screenshot. + for (const node of docks) { + const bottom = node.querySelector( + `.${cls("splitter-bottom")}` + ); + if (!bottom) { + throw new Error("No bottom splitter on the dock."); + } + if (bottom.offsetHeight !== 7) { + throw new Error( + `The bottom splitter is ${bottom.offsetHeight}px tall, not 7 — it is ` + + "still taking `.splitter`'s `top: 0; bottom: 0`." + ); + } + if (bottom.offsetWidth < node.offsetWidth - 2) { + throw new Error( + "The bottom splitter does not span the dock — it is still 7px wide." + ); + } + } + }, + render: () => + plainStage( + [ + dock(filler(8), { label: "Fills its edge", splitters: true }), + dock(filler(8), { + height: 260, + label: "Pinned height", + splitters: true, + width: 300, + }), + ], + { + try: "hover each edge — the inner strip is width, the bottom one is height, and a double-click on either resets both", + what: "A docked panel with no height of its own, beside one whose bottom edge has been dragged.", + } + ), +}; + +/** + * The floors, on both axes. + * + * `MIN_DOCK_W` is what a splitter clamps to and `MIN_DOCK_H` is its twin, and + * both live in `styles/const.ts` rather than in `app.ts` so the stylesheet, the + * clamp and this story cannot disagree about them. There is deliberately no + * `MAX_DOCK_H` to match `MAX_DOCK_W`: half the viewport is a sensible ceiling + * for a side column's width and a nonsense one for its height. + */ +export const AtTheFloor: StoryObj = { + render: () => + plainStage( + [ + dock(inspectorBody([]), { + height: MIN_DOCK_H, + label: "Smallest", + narrow: true, + }), + ], + { + what: `The narrowest and shortest a panel can be dragged — ${MIN_DOCK_W} × ${MIN_DOCK_H}. Below either it is a title bar with a sliver under it.`, + } + ), +}; diff --git a/packages/overlay/src/keys/catalog.ts b/packages/overlay/src/keys/catalog.ts index 9e24bb7..a9f8b72 100644 --- a/packages/overlay/src/keys/catalog.ts +++ b/packages/overlay/src/keys/catalog.ts @@ -929,6 +929,30 @@ export const GESTURES = [ surface: "both", title: "Re-dock a panel", }, + // Both of these shipped undocumented. The splitter is a 7px strip that shows + // a hairline on hover and nothing otherwise, so a reader who does not already + // know it is there has no way to find out — which is the exact case a gesture + // table exists for. + { + device: "any", + doc: "Drag the inner edge for width, the bottom edge for height.", + id: "gesture.dockResize", + impl: "app.ts#watchSplitters", + input: "Drag a panel edge", + mode: "any", + surface: "both", + title: "Resize a panel", + }, + { + device: "any", + doc: "Width goes back to its default; a docked panel goes back to filling its edge.", + id: "gesture.dockReset", + impl: "app.ts#buildSplitter", + input: "Double-click a panel edge", + mode: "any", + surface: "both", + title: "Reset a panel's size", + }, { device: "mouse", doc: "A vertical wheel scrolls the strip sideways, because a mouse has no sideways.", diff --git a/packages/overlay/src/stories/chrome.ts b/packages/overlay/src/stories/chrome.ts index 27efdd2..c8966e2 100644 --- a/packages/overlay/src/stories/chrome.ts +++ b/packages/overlay/src/stories/chrome.ts @@ -171,6 +171,15 @@ function dockHead(label: string): HTMLElement { } export interface DockOptions { + /** + * Pin the dock's height, the way a bottom-edge drag does. + * + * Omitted, the dock keeps its `top`/`bottom` anchors and fills the stage — + * which is the product's default and what every story before this one showed. + * Passing a number applies `.dock-h`, so the story sees exactly what a user + * who has dragged the bottom edge sees. + */ + height?: number; /** Header label. Defaults to "Design", matching the right dock. */ label?: string; /** @@ -182,6 +191,14 @@ export interface DockOptions { * after somebody changes the floor to 260. */ narrow?: boolean; + /** + * Render the two resize splitters. + * + * Off by default: they are invisible until hovered and they sit on the dock's + * edges, so in a story about a *control* they are two dead strips over the + * thing being looked at. On for the story that is about them. + */ + splitters?: boolean; /** * Override the dock width outright. The default is the product's own 360px. * Prefer `narrow` for the common case; this is for a story that wants some @@ -209,15 +226,39 @@ export function dock(body: HTMLElement, opts: DockOptions = {}): HTMLElement { const node = el( "div", { - class: `${cls("dock")} ${cls("dock-right")}`, + class: `${cls("dock")} ${cls("dock-right")}${ + opts.height === undefined ? "" : ` ${cls("dock-h")}` + }`, "data-story-dock": "", }, - [dockHead(opts.label ?? "Design"), body] + [ + dockHead(opts.label ?? "Design"), + body, + // Inert copies, not `AirshipApp.buildSplitter`'s: that registers a dnd-kit + // `Draggable` against the module-singleton manager, which a story has no + // business adding entities to and no teardown for. What is worth showing + // here is the strip and its hairline; the gesture belongs to the app. + ...(opts.splitters + ? [ + el("div", { + class: `${cls("splitter")} ${cls("splitter-right")}`, + "data-tip": "Drag to resize, double-click to reset", + }), + el("div", { + class: `${cls("splitter")} ${cls("splitter-bottom")}`, + "data-tip": "Drag to resize, double-click to reset", + }), + ] + : []), + ] ); const width = dockWidth(opts); if (width !== undefined) { node.style.setProperty(`--${PREFIX}-right-w`, `${width}px`); } + if (opts.height !== undefined) { + node.style.setProperty(`--${PREFIX}-right-h`, `${opts.height}px`); + } return node; } diff --git a/packages/overlay/src/styles/base.css.ts b/packages/overlay/src/styles/base.css.ts index d6cced0..c33400d 100644 --- a/packages/overlay/src/styles/base.css.ts +++ b/packages/overlay/src/styles/base.css.ts @@ -159,6 +159,12 @@ html[data-${PREFIX}-drag], html[data-${PREFIX}-drag] body, html[data-${PREFIX}-d user-select: none !important; } html[data-${PREFIX}-drag="col-resize"] { --${PREFIX}-drag-cursor: col-resize; } +/* The horizontal twin of \`col-resize\`, for a dock's bottom edge. Not + \`ns-resize\`, which is beside it: that is the cursor for a *handle* that moves + an edge in space, and this is a splitter that redistributes room between two + things — the distinction the pointer-cursor spec draws, and the one the width + splitter already relies on. */ +html[data-${PREFIX}-drag="row-resize"] { --${PREFIX}-drag-cursor: row-resize; } html[data-${PREFIX}-drag="ns-resize"] { --${PREFIX}-drag-cursor: ns-resize; } html[data-${PREFIX}-drag="ew-resize"] { --${PREFIX}-drag-cursor: ew-resize; } html[data-${PREFIX}-drag="nwse-resize"] { --${PREFIX}-drag-cursor: nwse-resize; } diff --git a/packages/overlay/src/styles/docks.css.ts b/packages/overlay/src/styles/docks.css.ts index 9fa0db0..caa35e7 100644 --- a/packages/overlay/src/styles/docks.css.ts +++ b/packages/overlay/src/styles/docks.css.ts @@ -97,6 +97,22 @@ export const css = ` .${PREFIX}-dock-left { left: var(--ap-space-md); width: var(--${PREFIX}-left-w, 340px); } .${PREFIX}-dock-right { right: var(--ap-space-md); width: var(--${PREFIX}-right-w, 360px); } +/* A docked panel with a pinned height. + + \`.dock\` anchors \`top\` *and* \`bottom\`, so a docked column fills its edge and + re-fits a resized window with no JS at all — which is why the default is to + have no height here, and why \`resetSize\` *removes* the pin rather than + replacing it with a number. A literal for "the window, less two insets" would + be stale the moment the window changed and would need re-deriving on every + resize; \`bottom\` answers the same question for free and keeps answering it. + + \`bottom\` and \`height\` cannot both win, so the pinned case drops the anchor + rather than fighting it. Two classes, so this beats the single-class edge + anchors above without \`!important\`, the same bargain \`.dock-float\` makes. */ +.${PREFIX}-dock-h { bottom: auto; } +.${PREFIX}-dock-left.${PREFIX}-dock-h { height: var(--${PREFIX}-left-h); } +.${PREFIX}-dock-right.${PREFIX}-dock-h { height: var(--${PREFIX}-right-h); } + /* A swappable dock body — the chat and the frame list share the left dock, one per mode (see \`AirshipApp.syncDocks\`). @@ -158,6 +174,19 @@ export const css = ` .${PREFIX}-splitter-left { right: -1px; } .${PREFIX}-splitter-right { left: -1px; } +/* The same strip, turned. \`top\` and \`width\` are *reset*, not merely unset: + \`.splitter\` sets both, so a rule that only added \`height\` would leave a + 7px-wide full-height column lying across the panel's left edge, eating clicks + on everything under it. Same for the hairline's \`top\`/\`bottom\`/\`width\`. */ +.${PREFIX}-splitter-bottom { + top: auto; bottom: -1px; left: 0; right: 0; + width: auto; height: 7px; cursor: row-resize; +} +.${PREFIX}-splitter-bottom::after { + top: 50%; bottom: auto; left: 0; right: 0; + width: auto; height: 2px; transform: translateY(-50%); +} + /* Collapsed panel — the dock's header row, left floating in the corner the dock itself would occupy. Same surface recipe as \`.dock\` and, crucially, the same \`top\`/\`left\`/\`right\`: expanding grows a panel downwards from here rather than