From 7224ce1456c01424de24537edee0a2e2f84e0130 Mon Sep 17 00:00:00 2001 From: alice Date: Sat, 8 Aug 2026 22:06:50 +0000 Subject: [PATCH] fix(board): the gate rail folds to a banner and never pushes the board (ready-6ec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Waiting on you" rail grew as gates folded in over the first minute, pushing the whole board down again and again — the view changed under a reader who was only looking. It is now a FOLDABLE BANNER: collapsed by default to one fixed line ("Waiting on you · N of M · Show"), so gates arriving only tick the count — they never reflow the board. Expanding is a deliberate click, and even then the gate cards OVERLAY the board (absolute, capped at 62vh, scrollable, shadowed) rather than shoving it — the first card does not move whether the rail is open or closed (measured). The list stays in the DOM; only its display folds, so focus preservation and every gate assertion are unchanged (jsdom ignores the CSS). Full board suite 1005 passing; both states screenshotted. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/board/src/board/board.css | 43 ++++++++++++++++++++++++++++++ web/board/src/board/render.test.ts | 15 +++++++++++ web/board/src/board/render.ts | 36 ++++++++++++++++++------- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/web/board/src/board/board.css b/web/board/src/board/board.css index 866f751..540bd3c 100644 --- a/web/board/src/board/board.css +++ b/web/board/src/board/board.css @@ -138,18 +138,61 @@ details.confidential-notice > summary:hover { color: var(--ink); } } .gate-rail { + position: relative; border: 1px solid var(--gate-line); border-left: 4px solid var(--gate); background: var(--gate-bg); border-radius: var(--radius); margin-bottom: 12px; } +/* The head is the FOLD TOGGLE — a full-width button that shows the count and + opens/closes the rail. Collapsed, it is the whole rail: one fixed line. */ .gate-rail-head { display: flex; align-items: center; gap: 10px; padding: 11px 14px 9px; flex-wrap: wrap; + width: 100%; + font: inherit; + text-align: left; + background: none; + border: 0; + cursor: pointer; +} +.gate-rail-caret { + margin-left: auto; + font-size: 11.5px; + font-weight: 640; + color: var(--gate); + border: 1px solid var(--gate-line); + border-radius: 100px; + padding: 2px 10px; + flex: none; +} +.gate-rail-head:hover .gate-rail-caret { background: var(--gate); color: #fff; border-color: var(--gate); } +@media (prefers-color-scheme: dark) { .gate-rail-head:hover .gate-rail-caret { color: #1a1408; } } +:root[data-theme="dark"] .gate-rail-head:hover .gate-rail-caret { color: #1a1408; } +/* COLLAPSED: the list is out of flow entirely, so gates folding in over the + first minute never push the board or reflow the view (ready-...). */ +.gate-rail:not(.open) .gate-list { display: none; } +/* EXPANDED: the list OVERLAYS the board — absolute, capped, scrollable — so even + opening it (a deliberate act) never shoves the real content down. */ +.gate-rail.open { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.gate-rail.open .gate-list { + position: absolute; + top: 100%; + left: -1px; + right: -1px; + z-index: 40; + max-height: 62vh; + overflow-y: auto; + background: var(--gate-bg); + border: 1px solid var(--gate-line); + border-top: 0; + border-radius: 0 0 var(--radius) var(--radius); + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.2); + padding: 8px; } .gate-rail-dot { width: 8px; diff --git a/web/board/src/board/render.test.ts b/web/board/src/board/render.test.ts index fada11c..c6c16f1 100644 --- a/web/board/src/board/render.test.ts +++ b/web/board/src/board/render.test.ts @@ -239,6 +239,21 @@ describe("the gate rail is a stack of self-contained cards", () => { // and there is exactly one card per gate — not a header + a loose control expect(container.querySelectorAll(".gate-list > .gate-item").length).toBe(1); }); + + it("folds to a banner by default and expands on click, without dropping the cards", () => { + const gated = makeItem({ id: "g1", status: "waiting", waitingType: "gate", gateMsgId: "m1", gate: "design" }); + ws = mountBoardWorkspace(container, [gated]); + // Collapsed by default: no .open, so the list is out of flow (CSS) and gates + // folding in never push the board. The head is the toggle. + expect(container.querySelector(".gate-rail")!.classList.contains("open")).toBe(false); + expect(container.querySelector(".gate-rail-head")!.getAttribute("aria-expanded")).toBe("false"); + // The cards stay in the DOM even when folded — nothing is lost, it is display only. + expect(container.querySelector('.gate-item[data-id="g1"]')).not.toBeNull(); + // Clicking the head expands it. + container.querySelector(".gate-rail-head")!.click(); + expect(container.querySelector(".gate-rail")!.classList.contains("open")).toBe(true); + expect(container.querySelector(".gate-rail-head")!.getAttribute("aria-expanded")).toBe("true"); + }); }); describe("board identity: names, never coordinates", () => { diff --git a/web/board/src/board/render.ts b/web/board/src/board/render.ts index 4f8f81e..5d0d961 100644 --- a/web/board/src/board/render.ts +++ b/web/board/src/board/render.ts @@ -314,6 +314,10 @@ export class BoardWorkspace { /** Whether the degraded-board status list is showing all rows or just the * first BOARD_STATUS_CAP behind a "+N more" disclosure (ready-412). */ private boardStatusExpanded = false; + /** Whether the "Waiting on you" gate rail is folded to its one-line banner + * (default) or expanded to the overlay of gate cards (ready-...). Collapsed by + * default so gates folding in during load never push the board or reflow it. */ + private gateRailExpanded = false; private readonly onKeydown = (e: KeyboardEvent) => { if (e.key === "Escape" && this.selectedId !== undefined) { this.closeDetail(); @@ -964,17 +968,29 @@ export class BoardWorkspace { return clear; } - const rail = el("div", { className: "gate-rail" }); - rail.append( - el("div", { className: "gate-rail-head" }, [ - el("span", { className: "gate-rail-dot" }), - el("h2", { className: "gate-rail-heading", textContent: "Waiting on you" }), - el("span", { - className: "gate-rail-sub", - textContent: `${gated.length} of ${this.openCount()} — the only ones that cannot move without your decision.`, - }), - ]), + // A FOLDABLE BANNER, not a growing wall (ready-...). Collapsed, the rail is + // one fixed-height line — so gates folding in over the first minute only tick + // the count, they never push the board down or reflow the view under a reader + // who is just looking. Expanding is a deliberate act, and even then the list + // OVERLAYS the board (CSS: .gate-rail.open .gate-list is absolute) rather than + // shoving it. The list is always in the DOM; only its display is folded. + const rail = el("div", { className: `gate-rail${this.gateRailExpanded ? " open" : ""}` }); + const head = el("button", { className: "gate-rail-head", type: "button" }); + head.setAttribute("aria-expanded", String(this.gateRailExpanded)); + head.append( + el("span", { className: "gate-rail-dot" }), + el("span", { className: "gate-rail-heading", textContent: "Waiting on you" }), + el("span", { + className: "gate-rail-sub", + textContent: `${gated.length} of ${this.openCount()} — the only ones that cannot move without your decision.`, + }), + el("span", { className: "gate-rail-caret", textContent: this.gateRailExpanded ? "Hide" : "Show" }), ); + head.addEventListener("click", () => { + this.gateRailExpanded = !this.gateRailExpanded; + this.render(); + }); + rail.append(head); const list = el("ul", { className: "gate-list" }); for (const item of gated) {