Przeglądaj źródła

web windows: maximized window is frameless and flush to the layer (title moves into the header with a restore button), retire the dead dock reserve, clamp px geometry to the layer when the dock appears or the page resizes

Alexander Korolev 1 miesiąc temu
rodzic
commit
305f9066a6
3 zmienionych plików z 121 dodań i 25 usunięć
  1. 9 1
      specs/tech-architecture/web-dashboard.md
  2. 68 15
      src/web/ui.html
  3. 44 9
      src/web/ui.playwright.mjs

+ 9 - 1
specs/tech-architecture/web-dashboard.md

@@ -157,7 +157,15 @@ UI outgrows ~2–3k lines.)
 - **Window manager** (vanilla, `ui.html`): absolute windows, drag by
   titlebar, corner resize, `▢` **maximize** (fills the window layer minus
   the dock strip; a second click restores the exact previous geometry),
-  minimize → dock chip → restore, close. Every window ever created is
+  minimize → dock chip → restore, close. Maximized = **frameless fill**: no
+  border/radius/shadow, the window *is* the layer (flush to the dock strip,
+  which is a sibling below it and stays usable) and its title moves into the
+  header with a restore button (`#max-restore`); exactly one window can be
+  maximized. Window geometry is px, so `fitToLayer()` clamps windows back
+  into the layer whenever the dock appears/disappears or the page resizes
+  (run at the *end* of `renderDock` — at boot the dock is `:empty`, i.e.
+  hidden, and the layer is one dock-height taller while windows are
+  created). Every window ever created is
   registered in `winRegistry`, and the dock lists minimized ones from
   there — a window kept only in a local variable (recipes/raw/logs used to
   be) minimizes into nowhere. Default layout: left column = Topology

+ 68 - 15
src/web/ui.html

@@ -54,6 +54,12 @@
     border-radius: 10px; display: flex; flex-direction: column;
     box-shadow: 0 6px 24px rgba(0,0,0,.35); animation: winIn .3s ease both;
   }
+  /* maximized: the frame disappears and the window IS the layer; its title
+     moves into the header (with a restore button) */
+  .win.max { border: 0; border-radius: 0; box-shadow: none; }
+  .win.max .win-resize { display: none; }
+  #maxinfo { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--muted); }
+  #maxinfo .maxtitle { color: var(--fg); font-weight: 600; }
   .win-bar {
     display: flex; align-items: center; gap: 6px; padding: 7px 10px;
     border-bottom: 1px solid var(--border); cursor: grab; user-select: none;
@@ -350,6 +356,8 @@
 <body>
 <header>
   <h1>sm121</h1>
+  <div id="maxinfo" hidden><span>⛶</span><span class="maxtitle" id="max-title"></span>
+    <button class="tbtn" id="max-restore" title="restore window size">❐</button></div>
   <div class="spacer"></div>
   <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
   <button class="tbtn" id="theme">◐</button>
@@ -410,6 +418,7 @@ function sameSnapshot(a, b) {
 // --- theme ---------------------------------------------------------------
 const root = document.documentElement;
 root.dataset.theme = localStorage.getItem("sm121-theme") || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
+$("#max-restore").onclick = () => { if (maxedHandle) maxedHandle.unmaximize(); };
 $("#theme").onclick = () => {
   root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
   localStorage.setItem("sm121-theme", root.dataset.theme);
@@ -426,6 +435,38 @@ const winRegistry = new Set();
 const logWins = new Map(); // attach_id -> { win, pre, buf, paused, ... }
 const front = (el) => { el.style.zIndex = ++ztop; };
 
+// At most one maximized window: its title shows in the header with a
+// restore button (the window itself has no frame left to click).
+let maxedHandle = null;
+function showMaxTitle(h) {
+  if (maxedHandle && maxedHandle !== h) maxedHandle.unmaximize();
+  maxedHandle = h;
+  $("#max-title").textContent = h.title;
+  $("#maxinfo").hidden = false;
+}
+function hideMaxTitle(h) {
+  if (maxedHandle !== h) return;
+  maxedHandle = null;
+  $("#maxinfo").hidden = true;
+}
+
+// Windows keep px geometry, so a shrinking layer (dock appears, browser
+// resize) would push their bottom edge out of view — clamp them back in.
+function fitToLayer() {
+  const layer = $("#winlayer");
+  const W = layer.clientWidth, H = layer.clientHeight;
+  for (const h of winRegistry) {
+    const w = h.win;
+    if (!w.isConnected || w.classList.contains("max")) continue;
+    const left = parseFloat(w.style.left) || 0, top = parseFloat(w.style.top) || 0;
+    if (left + w.offsetWidth > W) w.style.width = Math.max(240, W - left) + "px";
+    if (top + w.offsetHeight > H) {
+      if (top > H - 120) w.style.top = Math.max(0, H - 200) + "px";
+      w.style.height = Math.max(140, H - (parseFloat(w.style.top) || 0)) + "px";
+    }
+  }
+}
+
 function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, removeOnClose }) {
   const win = document.createElement("section");
   win.className = "win" + (extraCls ? " " + extraCls : "");
@@ -445,38 +486,48 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
   win.append(bar, body, rz);
   $("#winlayer").appendChild(win);
   const closed = { value: false };
-  bar.querySelector('[data-act="min"]').onclick = () => { win.style.display = "none"; renderDock(); };
-  // maximize = fill the window layer (minus the dock strip, which hosts the
-  // + menu and holds the chips of minimized windows)
+  bar.querySelector('[data-act="min"]').onclick = () => {
+    unmax(); // minimize a normal-shaped window; the dock chip restores it
+    win.style.display = "none";
+    renderDock();
+  };
+  // maximize = drop the frame and fill the window layer (the dock is a
+  // sibling below it, so it stays usable); the title moves to the header
   const maxBtn = bar.querySelector('[data-act="max"]');
   let geom = null;
+  const unmax = () => {
+    if (!geom) return;
+    win.classList.remove("max");
+    Object.assign(win.style, geom);
+    geom = null;
+    maxBtn.textContent = "▢";
+    maxBtn.title = "maximize";
+    hideMaxTitle(handle);
+  };
   maxBtn.onclick = () => {
     front(win);
-    if (geom) {
-      Object.assign(win.style, geom);
-      geom = null;
-      maxBtn.textContent = "▢";
-      maxBtn.title = "maximize";
-      return;
-    }
+    if (geom) { unmax(); return; }
     geom = { left: win.style.left, top: win.style.top, width: win.style.width, height: win.style.height };
+    win.classList.add("max");
     win.style.left = "0px";
     win.style.top = "0px";
-    win.style.width = $("#winlayer").clientWidth + "px";
-    win.style.height = Math.max(200, $("#winlayer").clientHeight - 40) + "px";
+    win.style.width = "100%";
+    win.style.height = "100%";
     maxBtn.textContent = "❐";
     maxBtn.title = "restore size";
+    showMaxTitle(handle);
   };
   bar.querySelector('[data-act="close"]').onclick = () => {
     if (removeOnClose) win.remove(); // log windows die with their attach
     else win.style.display = "none";
     closed.value = true;
+    hideMaxTitle(handle);
     winRegistry.delete(handle);
     renderDock();
     if (onClosed) onClosed();
   };
   bar.addEventListener("pointerdown", (e) => {
-    if (e.target.closest(".wbtn")) return;
+    if (e.target.closest(".wbtn") || win.classList.contains("max")) return;
     front(win);
     const ox = win.offsetLeft, oy = win.offsetTop; // baseline — read ONCE
     drag(e.clientX, e.clientY, (dx, dy) => {
@@ -497,7 +548,7 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
   const handle = {
     win, body, title,
     label: short || title,
-    unmaximize() { if (geom) maxBtn.onclick(); },
+    unmaximize: unmax,
     restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
     isMinimized: () => win.style.display === "none",
     isClosed: () => closed.value,
@@ -534,6 +585,7 @@ function renderDock() {
   plus.title = "new window";
   plus.onclick = (e) => { e.stopPropagation(); toggleDockMenu(); };
   dock.appendChild(plus);
+  fitToLayer(); // the dock just appeared/disappeared → the layer resized
 }
 let dockMenuEl = null;
 function closeDockMenu() { if (dockMenuEl) { dockMenuEl.remove(); dockMenuEl = null; } }
@@ -1584,7 +1636,7 @@ function initManagedWindows() {
   const W = innerWidth;
   // vertical budget = the actual window layer (header/dock already excluded);
   // +40 reserve for the dock, which appears when a window gets minimized
-  const L = Math.max(420, $("#winlayer").clientHeight - 40);
+  const L = Math.max(420, $("#winlayer").clientHeight);
   // topology map: the primary window (left); state/jobs stack on the right
   const tw = Math.max(560, Math.floor(W * 0.58));
   const rx = tw + 30, rw = Math.max(340, W - rx - 12);
@@ -1635,6 +1687,7 @@ async function boot() {
   } catch {}
   connectSSE();
   // 30 s heartbeat refresh (mirrors the server poll; harmless if state is identical)
+  addEventListener("resize", fitToLayer);
   setInterval(async () => {
     try {
       const res = await fetch("/api/state");

+ 44 - 9
src/web/ui.playwright.mjs

@@ -360,23 +360,47 @@ const wBefore = await page.evaluate(
 );
 await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
 await page.waitForTimeout(400);
-const maxInfo = await page.evaluate(() => ({
-  win: Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width),
-  layer: Math.round(document.querySelector("#winlayer").getBoundingClientRect().width),
-}));
+const m = await page.evaluate(() => {
+  const w = document.querySelector("#win-recipes");
+  const cs = getComputedStyle(w);
+  const r = w.getBoundingClientRect();
+  const lr = document.querySelector("#winlayer").getBoundingClientRect();
+  return {
+    w: [Math.round(r.width), Math.round(r.height)],
+    l: [Math.round(lr.width), Math.round(lr.height)],
+    strip: Math.round(document.querySelector("#dock").getBoundingClientRect().top - r.bottom),
+    border: cs.borderTopWidth,
+    radius: cs.borderRadius,
+    label: document.querySelector("#maxinfo").hidden ? "" : document.querySelector("#max-title").textContent,
+  };
+});
 ok(
-  "recipes: maximize fills the window layer",
-  Math.abs(maxInfo.win - maxInfo.layer) < 2,
-  JSON.stringify(maxInfo),
+  "recipes: maximize fills the layer flush (no strip above the dock)",
+  m.strip === 0 && Math.abs(m.w[0] - m.l[0]) < 2 && Math.abs(m.w[1] - m.l[1]) < 2,
+  JSON.stringify(m),
 );
-await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
+ok("recipes: maximized window has no frame", m.border === "0px" && m.radius === "0px", `${m.border} / ${m.radius}`);
+ok("recipes: maximized title moves into the header", m.label === "Recipes", m.label);
+// the dock stays reachable under a maximized window
+ok(
+  "recipes: dock stays usable while maximized",
+  await page.evaluate(() => {
+    const b = document.querySelector("#dock").getBoundingClientRect();
+    return !!document.elementFromPoint(b.x + 20, b.y + b.height / 2)?.closest("#dock");
+  }),
+);
+await page.click("#max-restore"); // header restore button
 await page.waitForTimeout(400);
 ok(
-  "recipes: un-maximize restores the size",
+  "recipes: header button un-maximizes back to the old size",
   Math.abs(
     (await page.evaluate(() => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width))) - wBefore,
   ) < 2,
 );
+ok(
+  "recipes: header label cleared after restore",
+  await page.evaluate(() => document.querySelector("#maxinfo").hidden),
+);
 await page.evaluate(() => document.querySelector("#win-recipes").style.display = "none");
 
 // the map is a fixed-aspect drawing: its window is sized to it, otherwise
@@ -391,6 +415,17 @@ const band = await page.evaluate(() => {
 });
 ok("topo: window fits the drawing (no letterbox bands)", band < 8, String(band));
 
+// the default layout ends flush at the dock: px geometry is clamped to the
+// layer, which shrinks when the dock (a sibling) becomes visible
+const flush = await page.evaluate(() => {
+  const dock = document.querySelector("#dock").getBoundingClientRect();
+  const shown = [...document.querySelectorAll(".win")].filter(
+    (w) => !w.classList.contains("max") && getComputedStyle(w).display !== "none",
+  );
+  return Math.round(dock.top - Math.max(...shown.map((w) => w.getBoundingClientRect().bottom)));
+});
+ok("windows end flush at the dock strip (no dead space)", Math.abs(flush) <= 1, String(flush));
+
 // --- no page JS errors across the whole flow -------------------------------------
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));