File size: 25,105 Bytes
16cc7db
520d742
 
 
 
f883922
520d742
f883922
2fd66f0
 
 
 
 
 
 
 
520d742
f883922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8b66e50
01abd36
8b66e50
f883922
 
 
 
 
 
 
520d742
 
 
f883922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e774bf1
f883922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e774bf1
8b66e50
f883922
 
 
8b66e50
f883922
 
 
 
 
 
 
 
 
8b66e50
f883922
e774bf1
 
 
81b8171
f883922
 
 
01abd36
 
 
 
f883922
 
 
 
 
 
 
 
8b66e50
f883922
8b66e50
f883922
 
 
 
520d742
e774bf1
f883922
 
 
 
 
 
 
 
 
 
 
8b66e50
f883922
 
 
 
 
 
 
 
e774bf1
 
8b66e50
 
 
 
 
 
 
 
 
 
 
 
e9b01c7
 
 
 
 
8b66e50
 
e774bf1
8b66e50
 
 
 
e9b01c7
8b66e50
 
 
 
 
 
 
f883922
 
 
e774bf1
 
f883922
 
2fd66f0
e774bf1
 
 
f883922
 
 
e774bf1
f883922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e774bf1
f883922
 
 
 
 
e774bf1
 
 
 
 
f883922
 
 
e774bf1
 
 
f883922
e774bf1
 
 
 
 
 
 
 
01abd36
e774bf1
 
 
 
 
 
 
 
 
 
 
 
 
f883922
 
e774bf1
8b66e50
e774bf1
8b66e50
 
 
e774bf1
8b66e50
f883922
 
 
 
e774bf1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f883922
 
 
8b66e50
 
 
 
f883922
8b66e50
f883922
 
 
8b66e50
 
 
 
 
f883922
 
 
 
 
 
 
 
 
8b66e50
 
 
 
 
 
f883922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520d742
16cc7db
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>AI Lineage Explorer</title>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
  <script>
    // 2026 explorer: explorer/explorer.js, the eqtylab/explorer `preview` branch bundled into one
    // file (explorer/bundle.mjs). In a private Space it needs the page's `?__sign=` token, like legacy/.
    (function () {
      var sign = new URLSearchParams(location.search).get("__sign");
      import("./explorer/explorer.js" + (sign ? "?__sign=" + encodeURIComponent(sign) : ""));
    })();
  </script>
  <style>
    :root {
      --bg: #ffffff; --fg: #0b0b0c; --muted: #6b6f76; --line: #e3e5e8; --card: #fafbfc;
      --accent: #16a3c7; --accent-soft: #8fe3f5; --danger: #c2410c;
      --font: "Inter", system-ui, sans-serif; --mono: "IBM Plex Mono", ui-monospace, monospace;
    }
    @media (prefers-color-scheme: dark) {
      :root { --bg: #0b0d10; --fg: #f1f2f4; --muted: #9aa0a8; --line: #262a30; --card: #13161a; --accent: #4cc9e6; --danger: #fb923c; }
    }
    * { box-sizing: border-box; }
    html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-family: var(--font); }
    a { color: var(--accent); }
    button, input { font: inherit; color: inherit; }

    /* ---------- Home ---------- */
    #home { max-width: 900px; margin: 0 auto; padding: 72px 16px 64px; text-align: center; }
    .brand { font-weight: 700; letter-spacing: -.02em; font-size: 22px; display: inline-flex; gap: 12px; align-items: center; }
    .brand small { font-weight: 400; font-size: 26px; }
    h1 { font-size: clamp(40px, 8vw, 76px); letter-spacing: -.045em; line-height: 1; margin: 28px 0 0; display: flex; gap: 18px; align-items: center; justify-content: center; flex-wrap: wrap; }
    .logo { width: .8em; height: .8em; border-radius: 50%; background: var(--accent-soft); display: grid; place-items: center; flex: none; }
    .logo svg { width: 55%; height: 55%; }
    h2 { font-size: clamp(24px, 4vw, 34px); font-weight: 600; letter-spacing: -.02em; margin: 56px 0 16px; }
    .lede { font-size: 19px; line-height: 1.45; max-width: 560px; margin: 0 auto; }

    .searchrow { display: flex; gap: 12px; margin: 56px auto 0; position: relative; text-align: left; }
    .search { flex: 1; position: relative; }
    .search input { width: 100%; height: 44px; border: 1px solid var(--line); background: var(--card); border-radius: 6px; padding: 0 12px 0 40px; font-size: 16px; }
    .search input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
    .search .icon { position: absolute; left: 12px; top: 12px; width: 20px; height: 20px; color: var(--muted); }
    .btn { height: 44px; padding: 0 16px; border: 1px solid var(--line); background: var(--bg); border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
    .btn:hover { border-color: var(--muted); }
    .results { position: absolute; top: 48px; left: 0; right: 0; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.12); z-index: 5; max-height: 320px; overflow: auto; display: none; }
    .results button { display: flex; width: 100%; text-align: left; border: 0; background: none; padding: 10px 14px; cursor: pointer; font-family: var(--mono); font-size: 14px; justify-content: space-between; gap: 12px; }
    .results button:hover, .results button.active { background: var(--card); }
    .results .meta { color: var(--muted); font-family: var(--font); font-size: 12px; }
    .hint { color: var(--muted); font-size: 13px; margin-top: 10px; text-align: left; }
    .hint code { font-family: var(--mono); }

    .examples-label { color: var(--muted); font-size: 13px; margin: 48px 0 12px; }
    .examples { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
    .card { border: 1px solid var(--line); background: var(--card); border-radius: 6px; padding: 14px; cursor: pointer; text-align: center; }
    .card:hover { border-color: var(--muted); }
    .card .name { font-family: var(--mono); font-size: 13.5px; word-break: break-all; }
    .card .meta { color: var(--muted); font-size: 12.5px; margin-top: 8px; }
    .card .tag { display: inline-block; font-size: 11px; border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-left: 4px; }

    details.token { margin-top: 40px; text-align: left; color: var(--muted); font-size: 13px; }
    details.token summary { cursor: pointer; }
    details.token .row { display: flex; gap: 8px; margin-top: 10px; }
    details.token input { flex: 1; height: 36px; border: 1px solid var(--line); background: var(--card); border-radius: 6px; padding: 0 10px; font-family: var(--mono); font-size: 13px; }

    /* ---------- Viewer ---------- */
    #viewer { display: none; height: 100%; flex-direction: column; }
    .bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--line); min-height: 52px; }
    .bar .title { font-family: var(--mono); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
    .bar .title a { color: var(--fg); text-decoration: none; }
    .bar .title a:hover { text-decoration: underline; }
    .bar .btn { height: 34px; font-size: 14px; }
    .stage { position: relative; flex: 1; min-height: 0; }
    eqty-explorer { display: block; height: 100%; }
    #legacy { display: block; width: 100%; height: 100%; border: 0; }
    .bar select { height: 34px; max-width: 40%; border: 1px solid var(--line); background: var(--bg); color: var(--fg); border-radius: 6px; padding: 0 8px; font-family: var(--font); font-size: 14px; font-weight: 500; cursor: pointer; }
    .bar .kind { font-size: 12px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 2px 8px; white-space: nowrap; }
    .overlay { position: absolute; inset: 0; display: grid; place-items: center; background: var(--bg); z-index: 50; padding: 16px; }
    .overlay .box { max-width: 520px; text-align: center; line-height: 1.5; }
    .overlay .err { color: var(--danger); font-weight: 500; }
    .spinner { width: 28px; height: 28px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; margin: 0 auto 14px; animation: spin .8s linear infinite; }
    @keyframes spin { to { transform: rotate(360deg); } }
    .hidden { display: none !important; }
    @media (max-width: 560px) { .searchrow { flex-direction: column; } #home { padding-top: 40px; } }
  </style>
</head>
<body>

<!-- ============ HOME ============ -->
<main id="home">
  <div class="brand">EQTY LAB <small>×</small> 🤗</div>
  <h1>
    <span class="logo" aria-hidden="true">
      <svg viewBox="0 0 24 24" fill="#0b0b0c"><rect x="3" y="3" width="7" height="6" rx="1.5"/><rect x="14" y="9" width="7" height="6" rx="1.5"/><rect x="14" y="17" width="7" height="5" rx="1.5"/><path d="M6.5 9v10.5H14M6.5 12H14" stroke="#0b0b0c" stroke-width="2" fill="none"/></svg>
    </span>
    AI Lineage Explorer
  </h1>

  <h2>AI Integrity begins right here.</h2>
  <p class="lede">Step inside your model to inspect its data sources, prep, governance, compute, architecture and evaluation.</p>
  <p class="lede" style="margin-top:18px">Build trust from ingestion to inference.</p>

  <div class="searchrow">
    <div class="search">
      <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
      <input id="q" type="text" autocomplete="off" spellcheck="false" placeholder="Search Hugging Face models, or paste a manifest URL" aria-label="Model id or manifest URL" />
      <div class="results" id="results" role="listbox"></div>
    </div>
    <button class="btn" id="uploadBtn" type="button">Upload a Manifest
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4m0 0-5 5m5-5 5 5M4 20h16"/></svg>
    </button>
    <input type="file" id="file" accept=".json,application/json" class="hidden" />
  </div>
  <div class="hint">Loads the JSON manifests in the model repo's <code>.eqtylab/</code> folder (or a root <code>manifest.json</code>), or opens the legacy explorer if the repo has an <code>.integrity/</code> folder instead. Press Enter to open <code>owner/model</code> directly.</div>

  <div class="examples-label">Examples:</div>
  <div class="examples" id="examples"></div>

  <details class="token">
    <summary>Private repos: add a Hugging Face read token</summary>
    <p>Only needed for manifests in private repos. Kept in this browser tab only (session storage) and sent only to huggingface.co.</p>
    <div class="row">
      <input id="token" type="password" placeholder="hf_…" autocomplete="off" />
      <button class="btn" id="tokenClear" type="button" style="height:36px">Clear</button>
    </div>
  </details>
</main>

<!-- ============ VIEWER ============ -->
<section id="viewer">
  <div class="bar">
    <button class="btn" id="back" type="button" aria-label="Back to search">← Search</button>
    <div class="title" id="vtitle"></div>
    <select id="vfile" class="hidden" aria-label="Manifest file in .eqtylab/"></select>
    <span class="kind hidden" id="vkind" title="This repo uses the legacy .integrity/ folder format">legacy explorer</span>
  </div>
  <div class="stage">
    <eqty-explorer id="ex" theme="auto"></eqty-explorer>
    <iframe id="legacy" class="hidden" title="Legacy lineage explorer"></iframe>
    <div class="overlay" id="overlay"><div class="box" id="overlayBox"></div></div>
  </div>
</section>

<script>
  // ---- Config: example cards on the home page ----
  const EXAMPLES = [
    "EQTYLab/Llama-3.1-Nemotron-Nano-4B-v1.1-banking77",
    "EQTYLab/Llama-3.1-Nemotron-Nano-4B-v1.1-banking77-test",
    "eci-io/climategpt-7b", // legacy .integrity/ folder
  ];
  const EQTY_DIR = ".eqtylab";                    // new format: JSON manifests in the repo's .eqtylab/ folder
  const EQTY_DEFAULT = ".eqtylab/manifest.json";  //   opened first when present, else the first file
  const MANIFEST_FILE = "manifest.json";          // new format, older layout: single manifest at the repo root
  const LEGACY_FILE = ".integrity/integrity.json"; // legacy format: .integrity/ folder, opened in legacy/index.html
  const HF = "https://huggingface.co";

  const $ = (id) => document.getElementById(id);
  // .eqtylab/sub/base-model-benchmarks.json -> "Sub / Base Model Benchmarks"
  const fileLabel = (path) => path.slice(EQTY_DIR.length + 1).replace(/\.json$/i, "").split("/")
    .map((part) => part.replace(/[-_]+/g, " ").trim().replace(/(^|\s)(\S)/g, (m, sp, c) => sp + c.toUpperCase()))
    .join(" / ");
  const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

  // ---- token (optional, per-tab) ----
  const getToken = () => { try { return sessionStorage.getItem("hf_token") || ""; } catch { return ""; } };
  const setToken = (v) => { try { v ? sessionStorage.setItem("hf_token", v) : sessionStorage.removeItem("hf_token"); } catch {} };
  $("token").value = getToken();
  $("token").addEventListener("change", (e) => setToken(e.target.value.trim()));
  $("tokenClear").onclick = () => { $("token").value = ""; setToken(""); };
  const hfFetch = (url, opts = {}) => {
    const t = getToken();
    return fetch(url, t && url.startsWith(HF) ? { ...opts, headers: { Authorization: `Bearer ${t}` } } : opts);
  };

  // ---- routing: ?model=owner/name | ?manifest=<url> | home ----
  function route() {
    const p = new URLSearchParams(location.search);
    if (p.get("model")) return openModel(p.get("model"), p.get("file"));
    if (p.get("manifest")) return openUrl(p.get("manifest"));
    showHome();
  }
  function go(params) {
    const qs = new URLSearchParams(params).toString();
    history.pushState(null, "", qs ? `?${qs}` : location.pathname);
    route();
  }
  window.addEventListener("popstate", route);

  function showHome() {
    closeLegacy();
    $("viewer").style.display = "none";
    $("home").style.display = "block";
    document.title = "AI Lineage Explorer";
  }
  function showViewer(titleHtml) {
    $("home").style.display = "none";
    $("viewer").style.display = "flex";
    $("vtitle").innerHTML = titleHtml;
    $("vfile").classList.add("hidden");
    seq++; // drop any .eqtylab/ file still loading for the previous view
    closeLegacy();
  }

  // ======================================================================
  // LEGACY explorer (.integrity/ folder format)
  // Prebuilt app in legacy/ (from the EQTYLab/lineage-explorer space), shown in an
  // iframe. Everything else on this page is the 2026 explorer (manifest.json).
  // ======================================================================
  // legacy integrity.json: { graph: { nodes, edges } }; manifests have no top-level graph
  const isLegacyJson = (j) => !!(j && j.graph && Array.isArray(j.graph.nodes));
  // the legacy explorer only loads by repo id (from huggingface.co/<id>/raw/main/.integrity/)
  const legacyRepoId = (url) => (url.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/]+)\/(?:raw|resolve|blob|tree)\/[^/]+\/\.integrity(?:\/|$)/) || [])[1];
  // A private Space is opened with a one-time `?__sign=` token in the URL. The frame's
  // request gets no cookie (SameSite=Lax, cross-site), so it needs the token too; legacy/index.html
  // is a single self-contained file, so that one request is all it makes. Captured at startup
  // because navigation here rewrites the query string.
  const SIGN = new URLSearchParams(location.search).get("__sign");
  function openLegacy(params, label) {
    clearTimeout(fallbackTimer);
    setManifest(null);
    ex.classList.add("hidden");
    $("overlay").classList.add("hidden");
    $("vkind").classList.remove("hidden");
    $("legacy").classList.remove("hidden");
    $("legacy").src = `legacy/index.html?${new URLSearchParams(SIGN ? { ...params, __sign: SIGN } : params)}`;
    document.title = `${label} · AI Lineage Explorer`;
  }
  function closeLegacy() {
    if ($("legacy").getAttribute("src")) $("legacy").removeAttribute("src");
    $("legacy").classList.add("hidden");
    $("vkind").classList.add("hidden");
    ex.classList.remove("hidden");
  }
  $("back").onclick = () => go({});

  let seq = 0; // bumped per .eqtylab/ file load and per view change

  // ---- overlay states ----
  const ex = $("ex");
  // On a deep link (?model=…) this runs before the explorer's module has loaded, and
  // assigning .manifest then would create a plain property that shadows the element's setter, so
  // the manifest never reaches it. Wait for the definition (writes still apply in call order).
  const setManifest = (m) => customElements.whenDefined("eqty-explorer").then(() => { ex.manifest = m; });
  let fallbackTimer;
  function loading(msg, clear) {
    $("overlay").classList.remove("hidden");
    if (clear) setManifest(null);
    $("overlayBox").innerHTML = `<div class="spinner"></div><div>${esc(msg)}</div>`;
  }
  function fail(html) {
    clearTimeout(fallbackTimer);
    $("overlay").classList.remove("hidden");
    $("overlayBox").innerHTML = `<p class="err">Couldn't load a manifest</p><p>${html}</p><p><button class="btn" onclick="document.getElementById('file').click()">Upload a manifest instead</button></p>`;
  }
  ex.addEventListener("manifestchange", (e) => {
    if (!e.detail) return;
    // the explorer verifies signatures before drawing; give it a moment, then reveal
    clearTimeout(fallbackTimer);
    fallbackTimer = setTimeout(() => $("overlay").classList.add("hidden"), 600);
  });
  ex.addEventListener("error", (e) => fail(esc(e.detail?.message || e.detail || "The manifest could not be parsed.")));

  function render(json, label) {
    loading("Verifying lineage…");
    setManifest(json);
    clearTimeout(fallbackTimer);
    fallbackTimer = setTimeout(() => $("overlay").classList.add("hidden"), 30000);
    document.title = `${label} · AI Lineage Explorer`;
  }

  // ---- open a model repo: .eqtylab/*.json → root manifest.json → legacy .integrity/ ----
  const resolveUrl = (id, path) => `${HF}/${id}/resolve/main/${path.split("/").map(encodeURIComponent).join("/")}`;
  const denied = (id) => fail(`<code>${esc(id)}</code> is private or gated. Add a read token under “Private repos” on the home page, then try again.`);

  async function openModel(id, file) {
    id = id.trim().replace(/^https?:\/\/huggingface\.co\//, "").replace(/\/+$/, "");
    const repoUrl = `${HF}/${id}`;
    showViewer(`<a href="${esc(repoUrl)}" target="_blank" rel="noopener">${esc(id)}</a>`);
    loading("Looking for manifests…", true);

    // 1. every JSON file in .eqtylab/ (subfolders included), picked from a dropdown
    let r;
    try { r = await hfFetch(`${HF}/api/models/${id}/tree/main/${EQTY_DIR}?recursive=true`); } catch { r = null; }
    if (!r) return fail(`Network error listing <code>${esc(id)}</code>.`);
    if (r.status === 401 || r.status === 403) return denied(id);
    if (r.ok) {
      const files = (await r.json()).filter((e) => e.type === "file" && /\.json$/i.test(e.path)).map((e) => e.path).sort();
      if (files.length) {
        const pick = files.includes(file) ? file : files.includes(EQTY_DEFAULT) ? EQTY_DEFAULT : files[0];
        const sel = $("vfile");
        sel.innerHTML = files.map((f) => `<option value="${esc(f)}" title="${esc(f)}">${esc(fileLabel(f))}</option>`).join("");
        sel.value = pick;
        sel.classList.remove("hidden");
        sel.onchange = () => {
          history.pushState(null, "", `?${new URLSearchParams({ model: id, file: sel.value })}`);
          loadEqtyFile(id, sel.value);
        };
        return loadEqtyFile(id, pick);
      }
    }

    // 2. no .eqtylab/ (or no JSON in it): a single manifest.json at the repo root
    loading("Fetching manifest…");
    const url = `${repoUrl}/resolve/main/${MANIFEST_FILE}`;
    try { r = await hfFetch(url); } catch { r = null; }
    if (!r) return fail(`Network error fetching <code>${esc(url)}</code>.`);
    if (r.status === 401 || r.status === 403) return denied(id);
    if (r.status === 404) {
      // 3. the legacy .integrity/ folder format
      let l;
      try { l = await hfFetch(`${repoUrl}/resolve/main/${LEGACY_FILE}`, { method: "HEAD" }); } catch { l = null; }
      if (l && l.ok) return openLegacy({ model: id }, id);
      return fail(`No JSON files in <code>${EQTY_DIR}/</code>, no <code>${MANIFEST_FILE}</code> and no <code>${LEGACY_FILE}</code> in <code>${esc(id)}</code> (or the repo doesn't exist${getToken() ? "" : " / is private"}).`);
    }
    if (!r.ok) return fail(`HTTP ${r.status} fetching <code>${esc(url)}</code>.`);
    try { render(await r.json(), id); } catch { fail(`<code>${MANIFEST_FILE}</code> in <code>${esc(id)}</code> isn't valid JSON.`); }
  }

  // load one file from .eqtylab/; `seq` drops a slow response if another file was picked meanwhile
  async function loadEqtyFile(id, path) {
    const mine = ++seq;
    loading(`Fetching ${path}…`, true);
    const url = resolveUrl(id, path);
    let json;
    try {
      const r = await hfFetch(url);
      if (mine !== seq) return;
      if (r.status === 401 || r.status === 403) return denied(id);
      if (!r.ok) return fail(`HTTP ${r.status} fetching <code>${esc(path)}</code>.`);
      json = await r.json();
    } catch { if (mine === seq) fail(`<code>${esc(path)}</code> in <code>${esc(id)}</code> couldn't be fetched or isn't valid JSON.`); return; }
    if (mine !== seq) return;
    if (isLegacyJson(json))
      return fail(`<code>${esc(path)}</code> is a legacy <code>integrity.json</code>, which only opens from a repo's <code>.integrity/</code> folder.`);
    render(json, `${id} · ${path.slice(EQTY_DIR.length + 1)}`);
  }

  // ---- open an arbitrary manifest URL ----
  async function openUrl(url) {
    showViewer(`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(url)}</a>`);
    const label = url.split("/").slice(-3).join("/");
    // a URL into a repo's legacy .integrity/ folder
    const legacyId = legacyRepoId(url);
    if (legacyId) return openLegacy({ model: legacyId }, legacyId);
    loading("Fetching manifest…", true);
    let json;
    try {
      const r = await hfFetch(url);
      if (!r.ok) return fail(`HTTP ${r.status} fetching <code>${esc(url)}</code>.`);
      json = await r.json();
    } catch { return fail(`Couldn't fetch <code>${esc(url)}</code> (network or CORS error, or not JSON).`); }
    if (isLegacyJson(json))
      return fail(`<code>${esc(url)}</code> is a legacy <code>integrity.json</code>. The legacy explorer can only open these from a Hugging Face repo's <code>.integrity/</code> folder.`);
    render(json, label);
  }

  // ---- upload ----
  $("uploadBtn").onclick = () => $("file").click();
  $("file").addEventListener("change", async (e) => {
    const f = e.target.files[0]; e.target.value = "";
    if (!f) return;
    history.pushState(null, "", location.pathname);
    showViewer(esc(f.name));
    let json;
    try { json = JSON.parse(await f.text()); } catch { return fail(`<code>${esc(f.name)}</code> isn't valid JSON.`); }
    // legacy integrity.json points at sibling files in its .integrity/ folder, so it can't be viewed on its own
    if (isLegacyJson(json))
      return fail(`<code>${esc(f.name)}</code> is a legacy <code>.integrity/integrity.json</code>, which needs the rest of its <code>.integrity/</code> folder. Open the model repo (or a URL to its <code>.integrity</code> folder) instead.`);
    render(json, f.name);
  });

  // ---- search box ----
  const q = $("q"), results = $("results");
  let items = [], active = -1, debounce;
  function submit(v) {
    v = (v || q.value).trim();
    if (!v) return;
    results.style.display = "none";
    if (/^https?:\/\//.test(v) && !/^https?:\/\/huggingface\.co\/[^/]+\/[^/]+\/?$/.test(v)) go({ manifest: v });
    else go({ model: v.replace(/^https?:\/\/huggingface\.co\//, "") });
  }
  function drawResults() {
    if (!items.length) { results.style.display = "none"; return; }
    results.innerHTML = items.map((m, i) =>
      `<button type="button" role="option" class="${i === active ? "active" : ""}" data-id="${esc(m.id)}">
         <span>${esc(m.id)}</span><span class="meta">${esc(m.pipeline_tag || "")}</span></button>`).join("");
    results.style.display = "block";
  }
  results.addEventListener("mousedown", (e) => { const b = e.target.closest("button"); if (b) { e.preventDefault(); submit(b.dataset.id); } });
  q.addEventListener("input", () => {
    clearTimeout(debounce);
    const v = q.value.trim();
    if (v.length < 2 || /^https?:/.test(v)) { items = []; drawResults(); return; }
    debounce = setTimeout(async () => {
      try {
        const r = await hfFetch(`${HF}/api/models?search=${encodeURIComponent(v)}&limit=8`);
        items = r.ok ? await r.json() : [];
      } catch { items = []; }
      active = -1; drawResults();
    }, 200);
  });
  q.addEventListener("keydown", (e) => {
    if (e.key === "ArrowDown" && items.length) { active = (active + 1) % items.length; drawResults(); e.preventDefault(); }
    else if (e.key === "ArrowUp" && items.length) { active = (active - 1 + items.length) % items.length; drawResults(); e.preventDefault(); }
    else if (e.key === "Enter") { submit(active >= 0 ? items[active].id : q.value); }
    else if (e.key === "Escape") { items = []; drawResults(); }
  });
  q.addEventListener("blur", () => setTimeout(() => (results.style.display = "none"), 100));

  // ---- example cards ----
  const fmtDate = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" });
  $("examples").innerHTML = EXAMPLES.map((id) =>
    `<div class="card" tabindex="0" data-id="${esc(id)}"><div class="name">${esc(id)}</div><div class="meta">&nbsp;</div></div>`).join("");
  $("examples").addEventListener("click", (e) => { const c = e.target.closest(".card"); if (c) go({ model: c.dataset.id }); });
  $("examples").addEventListener("keydown", (e) => { const c = e.target.closest(".card"); if (c && e.key === "Enter") go({ model: c.dataset.id }); });
  EXAMPLES.forEach(async (id) => {
    const meta = document.querySelector(`.card[data-id="${CSS.escape(id)}"] .meta`);
    try {
      const r = await hfFetch(`${HF}/api/models/${id}`);
      if (!r.ok) { meta.textContent = r.status === 401 ? "private" : ""; return; }
      const m = await r.json();
      meta.innerHTML = `Updated ${esc(fmtDate(m.lastModified))} · ${esc(m.pipeline_tag || "model")} · ↓ ${m.downloads ?? 0} · ♡ ${m.likes ?? 0}` +
        (m.private ? ` <span class="tag">private</span>` : "");
    } catch {}
  });

  route();
</script>
</body>
</html>