Explorar o código

report/web: model-column copy + Hugging Face buttons, and a favicon

The recipe-name cell has had a hover copy button; the model cell now mirrors it:
a ⧉ copy button for the model id plus an HF link to the checkpoint page (only
for Hub repo ids — a local model path is not linked). Both cells reserve room on
the right so wrapped model ids no longer slide under the buttons, and clicking
the Hub link does not toggle the row open.

Both pages (the report page and the dashboard shell) now declare an inline SVG
favicon (green tile, dark spark — the variant that stays legible at 16 px), so
the browser never asks for /favicon.ico.

Covered by a Rust needle test and five Playwright checks (link href/target/rel,
copy value, no row toggle, favicon).
Alexander Korolev hai 1 día
pai
achega
4c4e608ec1
Modificáronse 4 ficheiros con 90 adicións e 7 borrados
  1. 27 7
      src/report/page.html
  2. 42 0
      src/report/report.playwright.mjs
  3. 20 0
      src/report/ui_tests.rs
  4. 1 0
      src/web/ui.html

+ 27 - 7
src/report/page.html

@@ -3,6 +3,7 @@
 <head>
 <meta charset="utf-8">
 <title>sm121 — tested recipes</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%235ee0a0'/%3E%3Cpath d='M16 5.5l2.7 7.8 7.8 2.7-7.8 2.7L16 26.5l-2.7-7.8L5.5 16l7.8-2.7z' fill='%23141a21'/%3E%3C/svg%3E">
 <style>
   :root { color-scheme: light dark; }
   body { font: 13px/1.45 system-ui, sans-serif; margin: 16px; }
@@ -27,13 +28,19 @@
   .prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
   a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
   tr.row { cursor: pointer; }
-  td.recipe { position: relative; }
-  td.recipe .copy { position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
+  /* hover-revealed cell buttons sit at the right edge, so both cells
+     reserve room for them instead of letting the text slide under */
+  td.recipe, td.model { position: relative; }
+  td.recipe { padding-right: 26px; }
+  td.model { padding-right: 64px; }
+  td.recipe .copy, td.model .copy, td.model .hf { position: absolute; top: 50%; transform: translateY(-50%);
     visibility: hidden; cursor: pointer; padding: 0 4px; border-radius: 3px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
-    background: canvas; color: inherit; font: inherit; line-height: 1.3; }
-  td.recipe:hover .copy { visibility: visible; }
-  td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
-  td.recipe .copied { color: green; font-weight: 600; }
+    background: canvas; color: inherit; font: inherit; line-height: 1.3; text-decoration: none; }
+  td.recipe .copy, td.model .copy { right: 3px; }
+  td.model .hf { right: 32px; font-size: 10px; font-weight: 600; letter-spacing: .3px; }
+  td.recipe:hover .copy, td.model:hover .copy, td.model:hover .hf { visibility: visible; }
+  td.recipe .copy:hover, td.model .copy:hover, td.model .hf:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
+  td.recipe .copied, td.model .copied { color: green; font-weight: 600; }
   td.recipe .medals { letter-spacing: 1px; }
   .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
   td.num { text-align: right; white-space: nowrap; }
@@ -496,7 +503,8 @@ function render() {
       const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
       const tip = c[0] === "last" && lr
         ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
-      const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "");
+      const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "") +
+        (c[0] === "model" ? " model" : "");
       let body;
       if (c[0] === "stars") {
         const si = starInfo(r);
@@ -524,6 +532,17 @@ function render() {
       } else if (c[0] === "name") {
         body = medalBadge(r) + esc(v) +
           `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
+      } else if (c[0] === "model") {
+        // model id: copy it, and jump to the checkpoint's page on the Hub
+        // (a bare org/name is a Hub repo id; local paths are not linked)
+        const m = String(v);
+        const repo = /^[\w.-]+\/[\w.-]+$/.test(m);
+        body = esc(m) +
+          `<button class="copy" data-copy="${esc(m)}" title="copy model id">⧉</button>` +
+          (repo
+            ? `<a class="hf" href="https://huggingface.co/${encodeURI(m)}" target="_blank" rel="noopener"` +
+              ` title="open ${esc(m)} on Hugging Face">HF</a>`
+            : "");
       } else {
         body = esc(v);
       }
@@ -579,6 +598,7 @@ function copyName(btn) {
 document.querySelector("tbody").addEventListener("click", e => {
   const cp = e.target.closest("button.copy");
   if (cp) { copyName(cp); return; } // no row toggle under the copy button
+  if (e.target.closest("a.hf")) return; // open the Hub page, don't toggle the row
   const g = e.target.closest("tr.g1,tr.g2");
   if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
   const row = e.target.closest("tr.row");

+ 42 - 0
src/report/report.playwright.mjs

@@ -85,6 +85,48 @@ ok(
   await page.evaluate(() => document.getElementById("bellcount").style.display === "none"),
 );
 
+// --- model column: copy the model id + jump to the checkpoint on the Hub ---
+const modelCell = await page.evaluate(() => {
+  const td = [...document.querySelectorAll("td.model")].find(t => t.querySelector("a.hf"));
+  if (!td) return null;
+  const a = td.querySelector("a.hf"), cp = td.querySelector("button.copy");
+  return {
+    href: a.getAttribute("href"), target: a.getAttribute("target"), rel: a.getAttribute("rel") || "",
+    copy: cp && cp.dataset.copy, copyTitle: cp && cp.getAttribute("title"),
+  };
+});
+ok(
+  "model cell links to the Hub",
+  modelCell && modelCell.href === "https://huggingface.co/org/Test-Model-NVFP4",
+  JSON.stringify(modelCell),
+);
+ok(
+  "Hub link opens safely in a new tab",
+  modelCell && modelCell.target === "_blank" && /noopener/.test(modelCell.rel),
+);
+ok(
+  "model cell copies the model id",
+  modelCell && modelCell.copy === "org/Test-Model-NVFP4",
+  modelCell && String(modelCell.copy),
+);
+
+// clicking the copy button must not expand/collapse the row underneath
+const toggle = await page.evaluate(() => {
+  const td = [...document.querySelectorAll("td.model")].find(t => t.querySelector("button.copy"));
+  const before = document.querySelectorAll("tr.hist").length;
+  td.querySelector("button.copy").click();
+  return { before, after: document.querySelectorAll("tr.hist").length };
+});
+ok("copy button does not toggle the row", toggle.before === toggle.after, JSON.stringify(toggle));
+
+ok(
+  "favicon declared (no /favicon.ico round trip)",
+  await page.evaluate(() => {
+    const l = document.querySelector('link[rel="icon"]');
+    return !!l && l.getAttribute("href").startsWith("data:image/svg+xml");
+  }),
+);
+
 ok("no page errors", pageErrors.length === 0, pageErrors.join(" | "));
 
 await browser.close();

+ 20 - 0
src/report/ui_tests.rs

@@ -43,6 +43,26 @@ fn page_has_sync_button_and_row_actions() {
     }
 }
 
+/// The model cell carries the same affordances as the recipe name: a copy
+/// button (the model id) and — for Hub repo ids — a link to the checkpoint
+/// page. Local model paths must not be linked, and the dashboard page carries
+/// an inline favicon so the browser never asks for /favicon.ico.
+#[test]
+fn page_has_model_copy_and_hub_link() {
+    let page = html_page(&[]);
+    for needle in [
+        "a.hf",
+        "https://huggingface.co/",
+        "copy model id",
+        "open ${esc(m)} on Hugging Face",
+        "link rel=\"icon\"",
+        "data:image/svg+xml",
+        "if (e.target.closest(\"a.hf\")) return;",
+    ] {
+        assert!(page.contains(needle), "page missing {needle}");
+    }
+}
+
 /// The stale branch of the state cell: an open ledger run the live probe
 /// contradicts renders orange with the `sm121 runlock close` hint.
 #[test]

+ 1 - 0
src/web/ui.html

@@ -4,6 +4,7 @@
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>sm121</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%235ee0a0'/%3E%3Cpath d='M16 5.5l2.7 7.8 7.8 2.7-7.8 2.7L16 26.5l-2.7-7.8L5.5 16l7.8-2.7z' fill='%23141a21'/%3E%3C/svg%3E">
 <style>
   :root[data-theme="dark"] {
     --bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #8b949e;