feat(overlay): drag a panel's height, and reset both axes

Half of this was already built. A floating dock's height is written at tear-off,
clamped, persisted and restored — and nothing could change it, because there was
no handle. A docked dock had no height at all, being `position: fixed` with both
`top` and `bottom` anchored. `grep row-resize src/` was empty.

Both docks get a bottom-edge splitter, the width mechanism turned ninety
degrees: one `Draggable` with no feedback, one monitor triple, one clamp. Its
own `DND.dockHeight` type rather than a suffix on `splitter`, because
`watchSplitters` selects with `startsWith(DND.splitter)` and an
`"airship:splitter-v"` would match it and be parsed as a width drag.

A docked panel keeps filling its edge until you pin it: `.dock-h` drops the
`bottom` anchor and takes `--*-h`, and `h: 0` means unpinned. That sentinel is
the load-bearing part. `clampHeight`'s floor is `MIN_DOCK_H`, so a stored 0 run
through it comes back as 200 — every docked panel in every install silently
pinning itself to a fifth of the window on the first reload. `restoreSizes`
preserves it, `applyPlacement` removes the custom property rather than
publishing a clamped 0 (so a class regression degrades to the anchors instead of
snapping to 200px), and a cancelled drag writes the raw stored pair straight
back rather than going through the clamp — `dockHeight` substitutes
`offsetHeight` for the sentinel, so restoring from it would end an Escape by
pinning a panel that was filling its edge, and persist it.

Both branches of `dockHeight` start from what the panel is *painted* at.
`applyPlacement` paints `min(h, room below y)` and deliberately leaves `h`
alone, so a panel torn off at full height and carried down the screen had a
stored 860 against a painted 380 — and the grip did nothing for the first 480px
of travel.

Docked height goes in the size store, renamed from `dock-widths` with a
migration off the old key, rather than joining the placement blob:
`restoreDocks` skips anything that is not floating, so a docked height stored
there would be written on every drop and never read back — a worse failure than
not persisting it, because it looks like it works until you reload.

`resetWidth` becomes `resetSize` and answers both axes. Width goes back to a
literal because a panel's default width is one; height *unpins* instead,
because a docked column's right height is "the edge" — a fact about the window
that a literal would get wrong the moment it changed size, and that `bottom`
answers for free.

Both resize gestures shipped undocumented — the splitter is a 7px transparent
strip that shows a hairline on hover — so the catalog gains rows for them and
they appear in the sheet and `CONTROLS.md` like everything else.
This commit is contained in:
Nayan
2026-08-16 13:05:37 +05:30
parent 9ee3e2e274
commit 70ba926cd4
9 changed files with 842 additions and 63 deletions
+2
View File
@@ -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
+341 -61
View File
@@ -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<Record<string, readonly [number, number]>> = {
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<Side, number> = {
left: LEFT_W,
right: RIGHT_W,
/** Live dock sizes, persisted across reloads and reset from the splitters. */
private readonly size: Record<Side, DockSize> = {
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<Side, DockPlacement> = {
@@ -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<Record<Side, number>>)
? (JSON.parse(raw) as Partial<Record<Side, number | Partial<DockSize>>>)
: 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);
}
/**
+9
View File
@@ -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. */
+226
View File
@@ -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<string, string>();
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<HTMLElement>(`.${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);
});
});
+162
View File
@@ -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<HTMLElement>("[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<HTMLElement>(
`.${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.`,
}
),
};
+24
View File
@@ -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.",
+43 -2
View File
@@ -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;
}
+6
View File
@@ -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; }
+29
View File
@@ -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