composer: a window you cannot reach, and an empty pipeline that filled itself

A floating window remembers its position per id, and only the DRAG handler
clamped. So a window moved to the right of a wide screen and reopened on a
narrow one opened entirely off the viewport: no titlebar on screen to grab,
nothing to drag back, and the position remembered -- unrecoverable without
clearing localStorage by hand. Reproduced at x=1400 in an 800px viewport.

The clamp lives in apply() now, so every path goes through it: opening,
restoring a remembered box, coming back from maximised, and the viewport
shrinking under an open window. Also guarded for a viewport narrower than
the grabbable strip, where the old bound went negative and pushed the window
further off rather than back on.

jsdom cannot see any of this -- getBoundingClientRect is zeros there, so a
window off the right edge measures identically to one in the middle. The
three tests are Playwright, and removing the clamp fails two of them.

And POST /api/pipeline could not accept an empty pipeline: `slots or
default()` resurrects the built-ins, because an empty list is falsy. A client
that removed every slot got them handed back. Absent means "use the default";
empty means "there is nothing", and only one of those is a fallback.

One of the tests I first wrote for this asserted len(x) < len(y) + 1, which
is true of almost anything. It counts the pipeline at every step now and
fails if it ever grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-29 19:10:43 +03:00
co-authored by Claude Opus 5
parent bd0c4be42e
commit b62e88f44f
4 changed files with 167 additions and 5 deletions
+30 -4
View File
@@ -36,7 +36,27 @@ define("floating", "window", ({
// Bound, because another window coming to the front shifts this one's z.
const layer = claimFor("floatingWindow", node);
// Enough of the window to grab: a strip of titlebar wide enough to hit and
// tall enough to see. Everything that positions the window goes through here,
// because the one path that did not was the one that lost it.
const GRABBABLE = 80;
const TITLEBAR = 32;
const clampIntoView = () => {
// Math.max(0, ...) matters on a viewport narrower than the reachable strip:
// without it the upper bound goes negative and the clamp pushes the window
// further off rather than back on.
box.x = Math.min(Math.max(box.x, -box.width + GRABBABLE),
Math.max(0, window.innerWidth - GRABBABLE));
box.y = Math.min(Math.max(box.y, 0), Math.max(0, window.innerHeight - TITLEBAR));
};
const apply = () => {
// Clamped on EVERY apply, not only while dragging. A position is remembered
// per window id, so a window moved to the right of a wide screen and reopened
// on a narrow one opened entirely off it -- no titlebar to grab, nothing to
// drag back, and the only way out was clearing localStorage by hand.
clampIntoView();
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
node.style.width = `${box.width}px`;
@@ -89,14 +109,19 @@ define("floating", "window", ({
const toFront = () => { layer.raise(); };
node.addEventListener("pointerdown", toFront);
// A window open while the viewport shrinks has the same problem as one
// reopened on a smaller screen.
const onViewportResize = () => apply();
window.addEventListener("resize", onViewportResize);
let start = null;
const stopDrag = drag(titlebar, {
onStart: () => { start = { ...box }; toFront(); },
onMove: ({ dx, dy }) => {
// Clamped so a window can never be dragged somewhere it cannot be
// dragged back from.
box.x = Math.min(Math.max(start.x + dx, -box.width + 80), window.innerWidth - 80);
box.y = Math.min(Math.max(start.y + dy, 0), window.innerHeight - 32);
// apply() does the clamping, so a window can never be dragged somewhere
// it cannot be dragged back from -- and the rule lives in one place.
box.x = start.x + dx;
box.y = start.y + dy;
apply();
},
onEnd: () => remember(id, box),
@@ -127,6 +152,7 @@ define("floating", "window", ({
remember(id, box);
dismissal.release();
stopDrag(); stopResize();
window.removeEventListener("resize", onViewportResize);
layer.release();
unmount(node);
if (onClose) onClose(reason);
+87
View File
@@ -0,0 +1,87 @@
// A floating window must always be reachable.
//
// Position is remembered per window id, so the dangerous case is not "where did
// it open" but "where did it open GIVEN a screen it no longer fits". jsdom
// cannot see this at all: getBoundingClientRect is zeros there, so a window
// entirely off the right edge measures the same as one in the middle.
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
});
async function openWindow(page, id) {
return page.evaluate(async (id) => {
const { composer } = await import("/funpack/app/composer/composer.js");
const handle = composer.floating.window({
id, title: "Attack", body: composer.text.md({ text: "content" }),
});
const box = handle.node.getBoundingClientRect();
return { x: box.x, y: box.y, w: box.width, h: box.height,
vw: innerWidth, vh: innerHeight };
}, id);
}
test("a window remembered off a wider screen still opens reachable", async ({ page }) => {
// Remember a position far to the right, as dragging on a wide screen would.
await page.evaluate(() => localStorage.setItem(
"cx.win.attack", JSON.stringify({ x: 1400, y: 40, width: 420, height: 320 })));
await page.setViewportSize({ width: 800, height: 600 });
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
const box = await openWindow(page, "attack");
// Something grabbable has to be on screen -- a titlebar with no pixels in the
// viewport cannot be dragged back, and the position is remembered, so the
// window would be lost until localStorage was cleared by hand.
expect(box.x, "the whole window is past the right edge").toBeLessThan(box.vw);
expect(box.x + box.w, "the whole window is past the left edge").toBeGreaterThan(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y, "the titlebar is below the bottom edge").toBeLessThan(box.vh);
// And enough of it to actually hit with a pointer.
const visible = Math.min(box.x + box.w, box.vw) - Math.max(box.x, 0);
expect(visible, "less than a grabbable strip is on screen").toBeGreaterThanOrEqual(60);
});
test("a window open while the viewport shrinks stays reachable", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await openWindow(page, "shrink");
await page.setViewportSize({ width: 600, height: 500 });
const box = await page.evaluate(() => {
const node = document.querySelector(".cx-window");
const r = node.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, vw: innerWidth };
});
expect(box.x).toBeLessThan(box.vw);
expect(box.x + box.w).toBeGreaterThan(0);
});
test("a window cannot be dragged somewhere it cannot be dragged back from", async ({ page }) => {
await page.setViewportSize({ width: 900, height: 700 });
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await openWindow(page, "dragged");
const bar = page.locator(".cx-window-bar").first();
await bar.hover();
await page.mouse.down();
await page.mouse.move(5000, 5000, { steps: 5 }); // far past both edges
await page.mouse.up();
const box = await page.evaluate(() => {
const r = document.querySelector(".cx-window").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, vw: innerWidth, vh: innerHeight };
});
expect(box.x).toBeLessThan(box.vw);
expect(box.y).toBeLessThan(box.vh);
expect(box.x + box.w).toBeGreaterThan(0);
});
+6 -1
View File
@@ -123,7 +123,12 @@ def register(routes, prefix=None):
except Exception: # noqa: BLE001
return web.json_response({"problems": ["that is not JSON"]}, status=400)
slots = body.get("slots") or _pipeline()
# `or` would resurrect the default here: an explicitly empty pipeline is
# falsy, and a client that has removed every slot is entitled to be told
# it has none rather than handed the defaults back.
slots = body.get("slots")
if slots is None:
slots = _pipeline()
action, slot_id = body.get("action"), body.get("slot")
if action == "replace":
+44
View File
@@ -147,3 +147,47 @@ def test_an_unknown_action_is_refused(server):
status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "explode", "slot": "model"})
assert status == 400 and body["problems"] # a malformed request, not an edit
def test_an_explicitly_empty_pipeline_is_not_replaced_by_the_default(server):
"""`slots or default()` resurrects the default, because an empty list is
falsy. A client that removed every slot is entitled to be told it has none
rather than handed the built-ins back."""
status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "check", "slots": []})
assert status == 200
assert body["slots"] == [], "an empty pipeline came back full"
def test_omitting_slots_still_falls_back_to_the_default(server):
"""The distinction that matters: absent means "use the default", empty means
"there is nothing". Only one of them is a fallback."""
status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "check"})
assert status == 200
assert body["slots"], "omitting slots should use the default pipeline"
def test_removing_slots_one_by_one_never_resurrects_the_default(server):
"""The failure this guards: each request carries the client's current slots,
so a fallback that fires on an empty list would silently hand the built-ins
back mid-way through and the count would jump UP."""
_status, body = _request(server, "GET", "/funpack/api/pipeline")
slots = body["slots"]
started_with = len(slots)
assert started_with > 1, "the default pipeline is too small to test this"
counts = [started_with]
# Consumers before sources: removing a source nothing can replace is refused,
# which is correct and not what this test is about.
for slot_id in [s["id"] for s in reversed(slots)]:
_status, body = _request(server, "POST", "/funpack/api/pipeline",
{"action": "remove", "slot": slot_id, "slots": slots})
if body["refused"]:
continue
slots = body["slots"]
counts.append(len(slots))
assert len(slots) < started_with, "nothing was actually removed"
assert counts == sorted(counts, reverse=True), (
f"the pipeline grew back part way through: {counts}")