浏览代码

web: window switcher + toml→repo navigation

Window manager (dashboard):
- maximize state is per-window: several windows can be maximized at
  once; the previously shown one no longer pops back to a corner but
  stays maximized and hidden, reachable from the new always-on ▦
  switcher in the header
- minimizing a maximized window keeps its maximize state (comes back
  full-screen); a hidden maximized window no longer counts as
  minimized (dock only lists truly minimized ones)
- switcher menu lists every open window with its state glyph
  (⛶/▪/–); activateWindow is now shared by the dock menu, the
  switcher, openRecipes/openRaw
- playwright: old 'minimize hops to the dock' check replaced with the
  new semantics (hidden-max + switcher round trip); reload section
  only maximizes when not already maximized (the button is a toggle)

Recipes report: recipe.toml viewer links
- the viewer now computes links from the toml (src/report/repo.rs):
  docker_image_dir → build-dir listing, build_profile → the profile
  file, venv_setup_script → recipe-local script, script = … →
  built-in host-scripts/ or recipe-local; the client wraps the
  highlighted string values in anchors
- new /repo/{...} route: file browser for docker-images/,
  host-scripts/, recipes/ (dir listings + files through the viewer,
  plain-name paths, no traversal); base-aware so the /recipes-nested
  dashboard mount works
- the toml link for a no-run recipe now shows too (previous commit)
Alexander Korolev 1 月之前
父节点
当前提交
20bf94b024
共有 7 个文件被更改,包括 390 次插入 和 25 次删除
  1. 2 0
      src/report/mod.rs
  2. 247 0
      src/report/repo.rs
  3. 26 6
      src/report/serve.rs
  4. 1 1
      src/report/ui_tests.rs
  5. 19 2
      src/report/view.html
  6. 66 12
      src/web/ui.html
  7. 29 4
      src/web/ui.playwright.mjs

+ 2 - 0
src/report/mod.rs

@@ -22,6 +22,7 @@ pub(crate) mod parse;
 pub(crate) mod provenance;
 pub(crate) mod quality;
 pub(crate) mod render;
+pub(crate) mod repo;
 pub(crate) use render::{html_page, html_page_at, markdown};
 pub(crate) mod serve;
 pub(crate) use parse::{PerfCell, bench_complete, parse_bench_json, perf_from};
@@ -427,6 +428,7 @@ pub async fn run(
         page,
         initial,
         show_archived,
+        "",
     );
     if open {
         let url = format!("http://{addr}/");

+ 247 - 0
src/report/repo.rs

@@ -0,0 +1,247 @@
+//! Repo file browser behind `/repo/...` — the target of the viewer's toml
+//! links: build dirs under `docker-images/` (Dockerfile, versions.env,
+//! profiles, patches), built-in `host-scripts/`, and recipe-local files
+//! under `recipes/`. The root is the process cwd (the launcher runs from the
+//! repo root); paths are plain names, no traversal, top level restricted.
+//!
+//! `toml_links` scans a recipe.toml for the repo-referencing values and
+//! builds the `{value, href, title}` list the viewer wraps into anchors.
+
+use super::serve::AppState;
+use axum::{extract::Path, response::Response};
+
+fn repo_root_ok() -> Option<std::path::PathBuf> {
+    let cwd = std::env::current_dir().ok()?;
+    (cwd.join("docker-images").is_dir() || cwd.join("host-scripts").is_dir()).then_some(cwd)
+}
+
+fn plain_seg(s: &str) -> bool {
+    !s.contains("..")
+        && s.chars()
+            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
+}
+
+fn repo_resolve(path: &str) -> Option<std::path::PathBuf> {
+    let root = repo_root_ok()?;
+    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
+    let first = segs.first().copied()?;
+    if !matches!(first, "docker-images" | "host-scripts" | "recipes") {
+        return None;
+    }
+    if !segs.iter().all(|s| plain_seg(s)) {
+        return None;
+    }
+    Some(segs.iter().fold(root, |p, s| p.join(s)))
+}
+
+fn dir_listing(base: &str, name: &str, p: &std::path::Path) -> String {
+    let mut items: Vec<(String, bool, u64)> = Vec::new();
+    if let Ok(rd) = std::fs::read_dir(p) {
+        for e in rd.flatten() {
+            let n = e.file_name().to_string_lossy().to_string();
+            let (dir, size) = if e.path().is_dir() {
+                (true, 0)
+            } else {
+                (false, e.metadata().map(|m| m.len()).unwrap_or(0))
+            };
+            items.push((n, dir, size));
+        }
+    }
+    items.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
+    let rows: Vec<String> = items
+        .iter()
+        .map(|(n, dir, size)| {
+            let cls = if *dir { "d" } else { "f" };
+            let sz = if *dir {
+                String::from("—")
+            } else if *size >= 1_048_576 {
+                format!("{:.1} MB", (*size as f64) / 1_048_576.0)
+            } else if *size >= 1024 {
+                format!("{} KB", size / 1024)
+            } else {
+                format!("{} B", size)
+            };
+            let url = if *dir {
+                format!("{base}repo/{n}/")
+            } else {
+                format!("{base}repo/{n}")
+            };
+            format!(
+                "<tr><td class=\"{cls}\">{n}</td><td class=\"sz\">{sz}</td><td><a href=\"{url}\">open</a></td></tr>"
+            )
+        })
+        .collect();
+    let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;");
+    format!(
+        "<!doctype html><meta charset=\"utf-8\"><title>{name}</title>\n\
+         <style>body{{font:13px/1.5 system-ui;margin:18px;max-width:720px}}table{{border-collapse:collapse}}td,th{{border-bottom:1px solid #8884;padding:3px 10px;text-align:left}}td.d{{font-weight:600}}td.sz{{opacity:.6}}a{{color:#3b82f6;text-decoration:none}}a:hover{{text-decoration:underline}}.crumb{{margin-bottom:10px;font-size:12px;opacity:.7}}</style>\n\
+         <div class=\"crumb\">{name}</div>\n\
+         <table><tr><th>name</th><th>size</th><th></th></tr>{rows}</table>\n",
+        name = esc(name),
+        rows = rows.join(""),
+    )
+}
+
+/// `GET /repo/{...}` — directory listing page, or the file rendered through
+/// the shared viewer (same highlighting/copy as `/r/...`).
+pub(crate) async fn repo_route(
+    axum::extract::State(state): axum::extract::State<AppState>,
+    Path(path): Path<String>,
+) -> Response {
+    use axum::http::StatusCode;
+    use axum::response::IntoResponse;
+    let p = match repo_resolve(&path) {
+        Some(p) => p,
+        None => {
+            return (
+                StatusCode::NOT_FOUND,
+                axum::response::Html(
+                    "<!doctype html><meta charset=\"utf-8\">repo path not allowed".to_string(),
+                ),
+            )
+                .into_response();
+        }
+    };
+    if p.is_dir() {
+        let name = p
+            .components()
+            .rev()
+            .map(|c| c.as_os_str().to_string_lossy().to_string())
+            .collect::<Vec<_>>()
+            .join("/");
+        return axum::response::Html(dir_listing(&state.base, &name, &p)).into_response();
+    }
+    let text = match std::fs::read_to_string(&p) {
+        Ok(t) => t,
+        Err(_) => {
+            return (
+                StatusCode::NOT_FOUND,
+                axum::response::Html(
+                    "<!doctype html><meta charset=\"utf-8\">file not found".to_string(),
+                ),
+            )
+                .into_response();
+        }
+    };
+    let clean = crate::ansi::strip_ansi(text.as_bytes());
+    let clean = String::from_utf8_lossy(&clean)
+        .replace("\r\n", "\n")
+        .replace('\r', "\n");
+    let meta = serde_json::json!({
+        "recipe": "",
+        "file": path,
+        "content": clean,
+        "links": serde_json::Value::Null,
+    });
+    let meta = serde_json::to_string(&meta)
+        .unwrap_or_default()
+        .replace('<', "\\u003c");
+    axum::response::Html(super::serve::VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
+}
+
+/// Pull the first quoted value of a `key = "…"` line (a bare top-level key
+/// at line start, or `key = "…"` inside a `{ … }` array entry); comments and
+/// non-key occurrences (the key must be preceded only by whitespace or the
+/// array-entry brace) don't match.
+fn quoted_value(line: &str, key: &str) -> Option<String> {
+    let t = line.trim_start();
+    if t.starts_with('#') {
+        return None;
+    }
+    let kpos = t.find(key)?;
+    if !t[..kpos].bytes().all(|b| b == b' ' || b == b'{') {
+        return None;
+    }
+    let rest = t[kpos + key.len()..].trim_start();
+    let rest = rest.strip_prefix('=')?;
+    let s = rest.trim_start();
+    let s = s.strip_prefix('"')?;
+    let end = s.find('"')?;
+    Some(s[..end].to_string())
+}
+
+/// Scan a recipe.toml for repo-referencing values and turn them into viewer
+/// links: `docker_image_dir` → the build dir listing, `build_profile` → the
+/// profile file inside it (image dir defaults to the recipe name),
+/// `venv_setup_script` → the recipe-local script, and every `script = "…"`
+/// entry (built-in alias → `host-scripts/`, other → recipe-local). The
+/// viewer matches each value as a whole quoted string client-side.
+pub(crate) fn toml_links(content: &str, recipe: &str, base: &str) -> Vec<serde_json::Value> {
+    let first = |key: &str| -> Option<String> {
+        content.lines().filter_map(|l| quoted_value(l, key)).next()
+    };
+    let mut out: Vec<(String, String, String)> = Vec::new();
+    if let Some(v) = first("docker_image_dir") {
+        out.push((
+            v.clone(),
+            format!("{base}repo/docker-images/{v}/"),
+            format!("build dir: docker-images/{v}"),
+        ));
+    }
+    let img = first("docker_image_dir").unwrap_or_else(|| recipe.to_string());
+    if let Some(prof) = first("build_profile") {
+        out.push((
+            prof.clone(),
+            format!("{base}repo/docker-images/{img}/profiles/{prof}.env"),
+            format!("build profile: profiles/{prof}.env"),
+        ));
+    }
+    if let Some(script) = first("venv_setup_script") {
+        out.push((
+            script.clone(),
+            format!("{base}repo/recipes/{recipe}/{script}"),
+            format!("venv setup script: recipes/{recipe}/{script}"),
+        ));
+    }
+    for alias in content.lines().filter_map(|l| quoted_value(l, "script")) {
+        let is_builtin = crate::builtin_scripts::resolve(&alias).is_some();
+        let (href, title) = if is_builtin {
+            let file = alias.replace('_', "-") + ".sh";
+            (
+                format!("{base}repo/host-scripts/{file}"),
+                format!("built-in script: host-scripts/{file}"),
+            )
+        } else {
+            (
+                format!("{base}repo/recipes/{recipe}/{alias}"),
+                format!("script: recipes/{recipe}/{alias}"),
+            )
+        };
+        if !out.iter().any(|(v, _, _)| v == &alias) {
+            out.push((alias, href, title));
+        }
+    }
+    out.into_iter()
+        .map(|(value, href, title)| {
+            serde_json::json!({ "value": value, "href": href, "title": title })
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::quoted_value;
+
+    #[test]
+    fn quoted_value_picks_the_value_not_the_key_mention() {
+        assert_eq!(
+            quoted_value(
+                "docker_image_dir = \"glm52-nf3-hybrid\"",
+                "docker_image_dir"
+            )
+            .as_deref(),
+            Some("glm52-nf3-hybrid")
+        );
+        assert_eq!(
+            quoted_value("  { script = \"stop_dgx_services\" }", "script").as_deref(),
+            Some("stop_dgx_services")
+        );
+        // "venv_setup_script" contains "script" but is not the script key
+        assert_eq!(
+            quoted_value("venv_setup_script = \"venv.sh\"", "script"),
+            None
+        );
+        assert_eq!(quoted_value("# script = \"x\"", "script"), None);
+        assert_eq!(quoted_value("script = unquoted", "script"), None);
+    }
+}

+ 26 - 6
src/report/serve.rs

@@ -41,12 +41,16 @@ pub(crate) const KEEPALIVE: Duration = Duration::from_secs(30);
 /// (the launcher copying a report triplet) collapses into one collect.
 const DEBOUNCE: Duration = Duration::from_millis(500);
 
-/// Shared server state: the config (for `/r/...` file loads) plus the
-/// latest rows JSON (each SSE connection clones the receiver).
+/// Shared server state: the config (for `/r/...` and `/repo/...` file
+/// loads) plus the latest rows JSON (each SSE connection clones the
+/// receiver) plus the URL base this server is mounted under ("" for
+/// `report --serve`, "/recipes" when the dashboard nests it) — the viewer
+/// link targets carry it.
 #[derive(Clone)]
 pub(crate) struct AppState {
     pub(crate) config: Arc<Config>,
     pub(crate) data: tokio::sync::watch::Receiver<String>,
+    pub(crate) base: String,
 }
 
 /// True when the event touches a report-relevant path: anything under a
@@ -277,7 +281,7 @@ pub(crate) fn clean_log(content: &str) -> String {
 
 /// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
 /// raw toggle), syntax-highlighted TOML/JSON, plain logs.
-const VIEW_TEMPLATE: &str = include_str!("view.html");
+pub(crate) const VIEW_TEMPLATE: &str = include_str!("view.html");
 
 async fn report_file(
     axum::extract::State(state): axum::extract::State<AppState>,
@@ -302,7 +306,12 @@ async fn report_file(
             } else {
                 clean
             };
-            let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean });
+            let links = if file == "recipe.toml" {
+                super::repo::toml_links(&clean, &recipe, &state.base)
+            } else {
+                Vec::new()
+            };
+            let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean, "links": links });
             let meta = serde_json::to_string(&meta)
                 .unwrap_or_default()
                 .replace('<', "\\u003c");
@@ -324,7 +333,13 @@ async fn report_file(
 /// rows JSON snapshot used to seed the watch channel (from the same
 /// `collect` that rendered the page, so the first paint and the first SSE
 /// message agree).
-pub(crate) fn router(config: Arc<Config>, page: String, initial: String, archived: bool) -> Router {
+pub(crate) fn router(
+    config: Arc<Config>,
+    page: String,
+    initial: String,
+    archived: bool,
+    base: &str,
+) -> Router {
     let (tx, rx) = tokio::sync::watch::channel(initial);
     tokio::spawn(refresh_loop(config.clone(), tx, KEEPALIVE, archived));
     Router::new()
@@ -337,5 +352,10 @@ pub(crate) fn router(config: Arc<Config>, page: String, initial: String, archive
         )
         .route("/events", get(events_stream))
         .route("/r/{recipe}/{file}", get(report_file))
-        .with_state(AppState { config, data: rx })
+        .route("/repo/{*path}", get(super::repo::repo_route))
+        .with_state(AppState {
+            config,
+            data: rx,
+            base: base.to_string(),
+        })
 }

+ 1 - 1
src/report/ui_tests.rs

@@ -125,7 +125,7 @@ async fn report_playwright_checks() {
     let initial = serde_json::to_string(&rows).unwrap();
     let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
     let addr = listener.local_addr().unwrap();
-    let app = serve::router(cfg, page, initial, false);
+    let app = serve::router(cfg, page, initial, false, "");
     let _h = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
     let reports = dir.join("test-recipe-nvfp4/reports");
     // tokio::process (not std::process::output — that would block this

+ 19 - 2
src/report/view.html

@@ -38,6 +38,10 @@
   .key { color: light-dark(#0b5cad, #7ab8f5); }
   .num { color: light-dark(#b35900, #f0a860); }
   .kw  { color: light-dark(#7a2ea0, #c792ea); }
+  /* toml values that reference repo files (build dir, profile, scripts):
+     same color as the string, dotted underline marks the link */
+  .rlink { text-decoration: underline dotted; cursor: pointer; }
+  .rlink:hover { color: light-dark(#0b5cad, #7ab8f5); }
 </style>
 </head>
 <body>
@@ -49,9 +53,22 @@
 <div id="out"></div>
 <script>
 "use strict";
-const META = {{META}};               // {recipe, file, content}
+const META = {{META}};               // {recipe, file, content, links?}
 const EXT = (META.file.split(".").pop() || "").toLowerCase();
 const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+// toml values referencing repo files become links (server-computed list of
+// {value, href, title}); the highlighter wraps a plain string in exactly
+// <span class="str">&quot;…&quot;</span>, so wrap that fragment in an anchor
+function linkify(html) {
+  const links = META.links;
+  if (!Array.isArray(links) || !links.length) return html;
+  for (const l of links) {
+    const frag = '<span class="str">&quot;' + esc(l.value) + '&quot;</span>';
+    const rep = '<span class="str"><a class="rlink" href="' + l.href + '" title="' + esc(l.title) + '">&quot;' + esc(l.value) + '&quot;</a></span>';
+    html = html.split(frag).join(rep);
+  }
+  return html;
+}
 document.getElementById("path").textContent = META.recipe + " / " + META.file;
 document.title = META.recipe + " — " + META.file;
 
@@ -188,7 +205,7 @@ function paint() {
   if (mode === "rendered") out.innerHTML = mdHtml(META.content);
   else {
     const raw = esc(META.content.replace(/\r\n?/g, "\n"));
-    out.innerHTML = "<pre>" + (EXT === "toml" ? hlToml(raw) : EXT === "json" ? hlJson(META.content) : raw) + "</pre>";
+    out.innerHTML = "<pre>" + (EXT === "toml" ? linkify(hlToml(raw)) : EXT === "json" ? hlJson(META.content) : raw) + "</pre>";
   }
   if (HAS_TABS) {
     document.getElementById("tabs").innerHTML =

+ 66 - 12
src/web/ui.html

@@ -266,6 +266,13 @@
     background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
     box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both;
   }
+  /* window switcher menu (header): same look, anchored below the header */
+  .winmenu { position: fixed; top: 42px; right: 12px; bottom: auto;
+    display: flex; flex-direction: column; gap: 2px;
+    background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px;
+    box-shadow: 0 12px 40px rgba(0,0,0,.5); z-index: 300; animation: winIn .15s ease both; }
+  .winmenu .dock-chip { text-align: left; }
+  .winmenu .wmax-on { font-weight: 600; }
 
   /* --- confirm dialog ------------------------------------------------------- */
   .dlg-back[hidden] { display: none; }
@@ -365,6 +372,7 @@
     <button class="wbtn" id="max-min" title="minimize">–</button>
     <button class="wbtn" id="max-close" title="close">×</button></div>
   <div class="spacer"></div>
+  <button class="tbtn" id="winmenu" title="all open windows (switch without losing maximize state)">▦</button>
   <div class="ssec" id="sse"><span class="dot"></span><span id="sse-t">connecting…</span></div>
   <button class="tbtn" id="theme">◐</button>
 </header>
@@ -443,20 +451,62 @@ 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;
+// Multiple windows can be maximized at once — each keeps its own state —
+// but only one is shown: the rest stay maximized and merely hidden, and
+// the header switcher (▦) moves between them without losing anything.
+let maxedHandle = null; // the maximized window currently on screen
 function showMaxTitle(h) {
-  if (maxedHandle && maxedHandle !== h) maxedHandle.unmaximize();
+  if (maxedHandle && maxedHandle !== h) maxedHandle.win.style.display = "none";
   maxedHandle = h;
+  h.win.style.display = "";
+  front(h.win);
   $("#max-title").textContent = h.title;
   $("#maxinfo").hidden = false;
 }
 function hideMaxTitle(h) {
   if (maxedHandle !== h) return;
   maxedHandle = null;
-  $("#maxinfo").hidden = true;
+  // a still-maximized window behind this one comes to the front of the
+  // maximized stack — the layer never goes back to bare
+  const others = [...winRegistry].filter(
+    (x) => x !== h && x.isMaximized() && !x.isClosed() && x.win.isConnected,
+  );
+  if (others.length) showMaxTitle(others[others.length - 1]);
+  else $("#maxinfo").hidden = true;
+}
+
+// Bring any open window forward: un-minimize if needed, and if it is
+// maximized, make it the one shown (hiding the other maximized ones).
+function activateWindow(h) {
+  if (h.isMinimized()) h.restore();
+  if (h.isMaximized()) {
+    for (const o of winRegistry)
+      if (o !== h && o.isMaximized() && !o.isClosed() && o.win.isConnected) o.win.style.display = "none";
+    h.win.style.display = "";
+    showMaxTitle(h);
+  } else front(h.win);
+  saveWinState();
+}
+let winMenuEl = null;
+function toggleWinMenu() {
+  if (winMenuEl) { winMenuEl.remove(); winMenuEl = null; return; }
+  const m = document.createElement("div");
+  m.className = "winmenu";
+  const open = [...winRegistry].filter((h) => !h.isClosed() && h.win.isConnected);
+  const glyph = (h) => (h.isMaximized() ? "⛶ " : h.isMinimized() ? "– " : "▪ ");
+  for (const h of open) {
+    const b = document.createElement("button");
+    b.className = "dock-chip" + (h === maxedHandle ? " wmax-on" : "");
+    b.textContent = glyph(h) + h.label;
+    b.title = (h.isMaximized() ? "maximized" : h.isMinimized() ? "minimized" : "floating") + " — click to show";
+    b.onclick = () => { winMenuEl.remove(); winMenuEl = null; activateWindow(h); };
+    m.appendChild(b);
+  }
+  document.body.appendChild(m);
+  winMenuEl = m;
+  setTimeout(() => addEventListener("click", () => { m.remove(); winMenuEl = null; }, { once: true }), 0);
 }
+$("#winmenu").onclick = (e) => { e.stopPropagation(); toggleWinMenu(); };
 
 // --- window layout persistence -------------------------------------------
 // The dashboard has no session: geometry, minimized/closed/maximized and
@@ -525,8 +575,10 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
   $("#winlayer").appendChild(win);
   const closed = { value: false };
   const doMin = () => {
-    unmax(); // minimize a normal-shaped window; the dock chip restores it
+    // minimize keeps the maximize state: a maximized window minimized goes
+    // to the maximized stack (▦ switcher) and comes back full-screen
     win.style.display = "none";
+    if (maxedHandle === handle) hideMaxTitle(handle);
     renderDock();
     saveWinState();
   };
@@ -626,7 +678,9 @@ function makeWin({ id, title, short, x, y, w, h, bodyHtml, onClosed, extraCls, r
       return { left: g.left, top: g.top, width: g.width, height: g.height };
     },
     restore() { win.style.display = ""; closed.value = false; front(win); renderDock(); },
-    isMinimized: () => win.style.display === "none",
+    // a hidden maximized window is display:none but NOT minimized — it
+    // waits in the maximized stack, reachable from the ▦ switcher
+    isMinimized: () => win.style.display === "none" && !geom,
     isClosed: () => closed.value,
   };
   winRegistry.add(handle);
@@ -681,9 +735,9 @@ function toggleDockMenu() {
     b.onclick = () => { closeDockMenu(); fn(); };
     m.appendChild(b);
   };
-  item("topo", () => { (wins.topo.isMinimized() ? wins.topo.restore() : front(wins.topo.win)); });
-  item("state", () => { (wins.state.isMinimized() ? wins.state.restore() : front(wins.state.win)); });
-  item("jobs", () => { (wins.jobs.isMinimized() ? wins.jobs.restore() : front(wins.jobs.win)); });
+  item("topo", () => activateWindow(wins.topo));
+  item("state", () => activateWindow(wins.state));
+  item("jobs", () => activateWindow(wins.jobs));
   item("recipes", openRecipes);
   item("logs…", openLogsPicker);
   item("raw", openRaw);
@@ -1543,7 +1597,7 @@ let recipesWin = null;
 // viewer and live updates stay one implementation (paths prefix-rewritten
 // server-side, theme inherited through the iframe's color-scheme).
 function openRecipes(g) {
-  if (recipesWin) { recipesWin.restore(); return recipesWin; } // also un-hides a minimized one
+  if (recipesWin) { activateWindow(recipesWin); return recipesWin; } // shows a minimized/hidden-max one too
   const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
   recipesWin = makeWin({
     id: "win-recipes", title: "Recipes", x: r("left", 24), y: r("top", 24),
@@ -1558,7 +1612,7 @@ function openRecipes(g) {
 }
 
 async function openRaw(g) {
-  if (rawWin) { rawWin.restore(); return rawWin; }
+  if (rawWin) { activateWindow(rawWin); return rawWin; }
   const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
   const handle = makeWin({
     title: "raw", x: r("left", 20), y: r("top", 20),

+ 29 - 4
src/web/ui.playwright.mjs

@@ -464,11 +464,30 @@ await page.waitForTimeout(400);
 await page.click("#max-min");
 await page.waitForTimeout(200);
 ok(
-  "recipes: header minimize hops to the dock",
-  (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 1,
+  "recipes: header minimize hides the window but keeps its maximize state (no dock chip)",
+  (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 0 &&
+    (await page.evaluate(() => {
+      const w = document.querySelector("#win-recipes");
+      return w.style.display === "none" && w.classList.contains("max");
+    })),
+);
+// the ▦ switcher lists the hidden maximized window and brings it back full-screen
+await page.click("#winmenu");
+await page.waitForTimeout(200);
+ok(
+  "recipes: switcher lists the hidden maximized window",
+  (await page.locator(".winmenu .dock-chip:has-text('Recipes')").count()) === 1,
 );
-await page.click('#dock .dock-chip:has-text("Recipes")');
+await page.click(".winmenu .dock-chip:has-text('Recipes')");
 await page.waitForTimeout(400);
+ok(
+  "recipes: switcher shows it maximized again (full layer)",
+  await page.evaluate(() => {
+    const w = document.querySelector("#win-recipes");
+    const lr = document.querySelector("#winlayer").getBoundingClientRect();
+    return w.style.display !== "none" && w.classList.contains("max") && Math.abs(w.getBoundingClientRect().width - lr.width) < 2;
+  }),
+);
 await page.evaluate(() => document.querySelector("#win-recipes").style.display = "none");
 
 // the map is a fixed-aspect drawing: its window is sized to it, otherwise
@@ -505,7 +524,13 @@ await page.click("#dock .plus");
 await page.click('.dockmenu .dock-chip:text-is("recipes")');
 await page.waitForSelector("#win-recipes iframe");
 await page.waitForTimeout(400);
-await page.evaluate(() => document.querySelector("#win-recipes .win-bar .wbtn[data-act='max']").click());
+// the window may already come back maximized (state is kept) — only
+// maximize when it isn't (the button is a toggle)
+await page.evaluate(() => {
+  const w = document.querySelector("#win-recipes");
+  if (!w.classList.contains("max"))
+    document.querySelector("#win-recipes .win-bar .wbtn[data-act='max']").click();
+});
 await page.waitForTimeout(500);
 const laidOut = await page.evaluate(() => ({
   saved: JSON.parse(localStorage.getItem("sm121-wins") || "null"),