Michael Rabinovich Cursor commited on
Commit
4a9408a
·
1 Parent(s): c4b5d70

gallery: searchable fixture picker, URL-persisted picks, cached render proxies

Browse files

Replace the always-open fixture chip grid with the compact reference UI:
current picks as removable pills + an "Add fixture" button opening a
searchable, grouped (Generation/Editing) dropdown, capped at 3 with a
"remove one to add another" note. Persist the 3 picks in ?fixtures= so
shared links reproduce the view; URL read/replaceState are try/catch
guarded for sandboxed iframes. Drop the external Google Fonts @import for
system font stacks so the page never errors in a sandboxed frame.

Serve thumbnails as lazy-loaded, hard-cached /render and /gt-render proxy
URLs (loading="lazy" + decoding="async" + CSS-constrained width) instead
of base64-inlining every tile, so only the ~33 visible thumbnails load and
fixture swaps / repeat visits hit cache. Requires the Space to be public.

Fix a stale test expecting the old 6-tuple _admin_delete signature.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (3) hide show
  1. app.py +31 -81
  2. gallery.py +243 -64
  3. tests/test_proxy.py +1 -1
app.py CHANGED
@@ -24,7 +24,6 @@ rather than render).
24
  """
25
  from __future__ import annotations
26
 
27
- import base64
28
  import html
29
  import logging
30
  import os
@@ -676,66 +675,27 @@ def _fetch_gt_render(fixture: str) -> bytes | None:
676
  return None
677
 
678
 
679
- def _data_uri(png_bytes: bytes | None) -> str | None:
680
- """Base64 ``data:`` URI for PNG bytes, or ``None``.
681
-
682
- The gallery inlines thumbnails as data URIs rather than referencing
683
- a proxy route, because while the Space is **private** HF's edge
684
- 404s in-browser requests to custom routes (same constraint that
685
- makes the report viewer use ``srcdoc`` + base64; see
686
- ``space-setup/post-gt-swap.md`` item 12). Inlining means the browser
687
- makes no second request. Switches to lazy proxy URLs once the Space
688
- is public.
689
- """
690
- if png_bytes is None:
691
- return None
692
- return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
693
-
694
-
695
- # When the Space is **public**, switch the gallery from inlining base64
696
- # thumbnails to referencing cached proxy URLs (``/render/...`` /
697
- # ``/gt-render/...``). That lets the browser lazy-fetch only the ~33
698
- # on-screen tiles (instead of shipping every fixture x row up front) and
699
- # lets the CDN/browser cache them hard, so fixture-swaps and repeat
700
- # visits are essentially free. While **private** this must stay off: HF's
701
- # edge 404s in-browser fetches to our custom routes, so the only thing
702
- # that renders in the browser is an inlined data URI. Flip
703
- # ``GALLERY_PUBLIC=1`` in the Space env once the Space is made public.
704
- GALLERY_PUBLIC = os.getenv("GALLERY_PUBLIC", "").strip().lower() in {
705
- "1", "true", "yes", "on",
706
- }
707
-
708
  # Long-lived immutable caching: a (submission, fixture) render never
709
  # changes (fixed camera + lighting; re-renders would be a new artifact),
710
  # so the browser/CDN can keep it forever. This is what makes fixture
711
- # swaps and repeat visits free once the Space is public.
 
712
  RENDER_CACHE_CONTROL = "public, max-age=31536000, immutable"
713
 
714
 
715
- def _render_data_uri(submission_id: str, fixture: str) -> str | None:
716
- """Resolver for a submission's per-fixture gallery thumbnail (private mode).
717
-
718
- Inlines the render as a base64 ``data:`` URI: the only mode that
719
- works in-browser while the Space is private.
720
- """
721
- return _data_uri(_fetch_render(submission_id, fixture))
722
-
723
-
724
- def _gt_data_uri(fixture: str) -> str | None:
725
- """Resolver for a fixture's ground-truth gallery thumbnail (private mode)."""
726
- return _data_uri(_fetch_gt_render(fixture))
727
-
728
-
729
  def _render_proxy_url(submission_id: str, fixture: str) -> str | None:
730
  """Resolver returning the cached proxy URL for a submission render.
731
 
732
- Public mode only. Returns the route string **without** fetching the
733
- bytes (that's the whole point: the browser lazy-fetches on demand).
734
- The gallery only calls this for fixtures whose per-fixture status is
735
- ``valid``; an absolute path resolves against the Space origin even
736
- inside the iframe ``srcdoc``. A render that 404s (valid status but a
737
- missing upload) degrades to the dashed cell client-side via the
738
- ``<img onerror>`` hook.
 
 
 
739
  """
740
  return f"/render/{submission_id}/{fixture}.png"
741
 
@@ -745,23 +705,13 @@ def _gt_proxy_url(fixture: str) -> str | None:
745
  return f"/gt-render/{fixture}.png"
746
 
747
 
748
- def _gallery_resolvers():
749
- """Pick the (render, gt) resolver pair for the current Space mode.
750
-
751
- Public -> lazy cached proxy URLs; private -> base64-inlined data URIs.
752
- """
753
- if GALLERY_PUBLIC:
754
- return _render_proxy_url, _gt_proxy_url
755
- return _render_data_uri, _gt_data_uri
756
-
757
-
758
  def serve_render(submission_id: str, fixture: str) -> Response:
759
  """Stream a submission's per-fixture render PNG with long-lived caching.
760
 
761
- Used only in public mode (see :data:`GALLERY_PUBLIC`): the gallery
762
- references ``/render/<id>/<fixture>.png`` and the browser fetches it
763
- lazily. Re-streams the dataset bytes (the Space holds the read token)
764
- with an immutable ``Cache-Control`` so the CDN/browser cache it hard.
765
  """
766
  png = _fetch_render(submission_id, fixture)
767
  if png is None:
@@ -788,19 +738,19 @@ def serve_gt_render(fixture: str) -> Response:
788
  def _gallery_iframe_html() -> str:
789
  """Build the gallery as a self-contained ``srcdoc`` iframe.
790
 
791
- Reads the live rows, renders the page with base64-inlined images,
792
- and inlines the whole document into an iframe ``srcdoc`` so it gets
793
- its own style context (no Gradio CSS collision) and makes no
794
- second HTTP request (works on the private Space). A Hub read
795
- failure degrades to an empty gallery rather than crashing the tab.
 
796
  """
797
  try:
798
  rows = _load_rows_from_hub()
799
  except LeaderboardDataError:
800
  logger.exception("Gallery row load failed; rendering empty gallery")
801
  rows = []
802
- render_resolver, gt_resolver = _gallery_resolvers()
803
- doc = render_gallery_page(rows, render_resolver, gt_resolver)
804
  escaped = html.escape(doc, quote=True)
805
  return (
806
  f'<iframe srcdoc="{escaped}" '
@@ -818,11 +768,11 @@ with gr.Blocks(title="CADGenBench Leaderboard", theme=gr.themes.Soft()) as block
818
  with gr.Tab("Gallery"):
819
  # Visual-first leaderboard. The bespoke surface (sticky GT row,
820
  # fixture picker, thumbnail grid, compare modal) is a
821
- # self-contained HTML doc inlined into an iframe `srcdoc` with
822
- # base64 thumbnails, so it keeps its own style context and makes
823
- # no second HTTP request (works on the private Space, where
824
- # HF's edge 404s in-browser custom-route fetches). Built at boot,
825
- # rebuilt on page load, and refreshed after admin actions.
826
  gallery_html = gr.HTML(value=_gallery_iframe_html())
827
  gallery_refresh_btn = gr.Button("Refresh gallery", size="sm")
828
  gallery_refresh_btn.click(
@@ -1151,9 +1101,9 @@ app.add_api_route(
1151
  serve_report,
1152
  methods=["GET"],
1153
  )
1154
- # Cached render proxies the gallery references in public mode (no-ops while
1155
- # private, where the gallery inlines base64 instead). Registered before the
1156
- # Gradio mount so they're not shadowed by the catch-all sub-app.
1157
  app.add_api_route(
1158
  "/render/{submission_id}/{fixture}.png",
1159
  serve_render,
 
24
  """
25
  from __future__ import annotations
26
 
 
27
  import html
28
  import logging
29
  import os
 
675
  return None
676
 
677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  # Long-lived immutable caching: a (submission, fixture) render never
679
  # changes (fixed camera + lighting; re-renders would be a new artifact),
680
  # so the browser/CDN can keep it forever. This is what makes fixture
681
+ # swaps and repeat visits free: only the ~33 on-screen thumbnails are
682
+ # fetched on first paint, and everything after that is a cache hit.
683
  RENDER_CACHE_CONTROL = "public, max-age=31536000, immutable"
684
 
685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
  def _render_proxy_url(submission_id: str, fixture: str) -> str | None:
687
  """Resolver returning the cached proxy URL for a submission render.
688
 
689
+ Returns the route string **without** fetching the bytes (that's the
690
+ whole point: the browser lazy-fetches on demand, so only the visible
691
+ tiles load). The gallery only calls this for fixtures whose
692
+ per-fixture status is ``valid``; an absolute path resolves against
693
+ the Space origin even inside the iframe ``srcdoc``. A render that
694
+ 404s (valid status but a missing upload) degrades to the dashed cell
695
+ client-side via the ``<img onerror>`` hook.
696
+
697
+ Requires the Space to be **public**: while private, HF's edge 404s
698
+ in-browser fetches to these custom routes.
699
  """
700
  return f"/render/{submission_id}/{fixture}.png"
701
 
 
705
  return f"/gt-render/{fixture}.png"
706
 
707
 
 
 
 
 
 
 
 
 
 
 
708
  def serve_render(submission_id: str, fixture: str) -> Response:
709
  """Stream a submission's per-fixture render PNG with long-lived caching.
710
 
711
+ The gallery references ``/render/<id>/<fixture>.png`` and the browser
712
+ fetches it lazily. Re-streams the dataset bytes (the Space holds the
713
+ read token) with an immutable ``Cache-Control`` so the CDN/browser
714
+ cache it hard.
715
  """
716
  png = _fetch_render(submission_id, fixture)
717
  if png is None:
 
738
  def _gallery_iframe_html() -> str:
739
  """Build the gallery as a self-contained ``srcdoc`` iframe.
740
 
741
+ Reads the live rows and renders the page (thumbnails referenced as
742
+ cached ``/render`` / ``/gt-render`` proxy URLs, lazy-loaded by the
743
+ browser), then inlines the whole document into an iframe ``srcdoc``
744
+ so it gets its own style context (no Gradio CSS collision). A Hub
745
+ read failure degrades to an empty gallery rather than crashing the
746
+ tab.
747
  """
748
  try:
749
  rows = _load_rows_from_hub()
750
  except LeaderboardDataError:
751
  logger.exception("Gallery row load failed; rendering empty gallery")
752
  rows = []
753
+ doc = render_gallery_page(rows, _render_proxy_url, _gt_proxy_url)
 
754
  escaped = html.escape(doc, quote=True)
755
  return (
756
  f'<iframe srcdoc="{escaped}" '
 
768
  with gr.Tab("Gallery"):
769
  # Visual-first leaderboard. The bespoke surface (sticky GT row,
770
  # fixture picker, thumbnail grid, compare modal) is a
771
+ # self-contained HTML doc inlined into an iframe `srcdoc` so it
772
+ # keeps its own style context. Thumbnails are lazy-loaded from
773
+ # the cached `/render` / `/gt-render` proxy routes (requires the
774
+ # Space to be public). Built at boot, rebuilt on page load, and
775
+ # refreshed after admin actions.
776
  gallery_html = gr.HTML(value=_gallery_iframe_html())
777
  gallery_refresh_btn = gr.Button("Refresh gallery", size="sm")
778
  gallery_refresh_btn.click(
 
1101
  serve_report,
1102
  methods=["GET"],
1103
  )
1104
+ # Cached render proxies the gallery's lazy-loaded thumbnails point at.
1105
+ # Registered before the Gradio mount so they're not shadowed by the
1106
+ # catch-all sub-app.
1107
  app.add_api_route(
1108
  "/render/{submission_id}/{fixture}.png",
1109
  serve_render,
gallery.py CHANGED
@@ -25,16 +25,22 @@ The page is data-driven: :func:`build_gallery_payload` shapes the
25
  top-10 verified rows + the fixture universe into a small JSON blob,
26
  which the page's JS renders. Render lookups are isolated behind the
27
  ``renderFor`` / ``gtRenderFor`` JS hooks (mirroring the design brief),
28
- pointed at the Space's render-proxy routes:
 
29
 
30
- - ``renderFor(sub, fixtureId)`` -> ``/render/<id>/<fixture>.png``
31
- (or ``null`` when the per-fixture status is invalid/missing, which
32
- draws the dashed "invalid generation" cell).
33
  - ``gtRenderFor(fixtureId)`` -> ``/gt-render/<fixture>.png``.
34
 
35
- Thumbnail clicks open the existing per-submission report (served by
36
- the Space's ``/reports/<id>.html`` proxy) deep-linked to the clicked
37
- fixture's card via ``#fixture=<name>``.
 
 
 
 
 
38
  """
39
  from __future__ import annotations
40
 
@@ -46,7 +52,8 @@ import json
46
  GALLERY_TOP_N = 10
47
 
48
  # Default number of fixture columns the picker opens with, capped at the
49
- # size of the available fixture universe.
 
50
  DEFAULT_FIXTURE_COLUMNS = 3
51
 
52
 
@@ -93,10 +100,11 @@ def _fixture_universe(rows: list[dict]) -> list[dict]:
93
  def _sub_payload(row: dict, render_resolver) -> dict:
94
  """Project one verified row into the compact shape the page JS needs.
95
 
96
- ``render_resolver(submission_id, fixture_id)`` returns the image
97
- source (a base64 data URI on the private Space, a proxy URL once
98
- public) for a *valid* fixture, or ``None``. Invalid/missing
99
- fixtures carry ``img: null`` so the page draws the dashed cell.
 
100
  """
101
  by_task = row.get("score_by_task_type") or {}
102
  pfs = row.get("per_fixture_scores") or {}
@@ -129,8 +137,7 @@ def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver) -> dic
129
  """Shape live rows into the JSON the gallery page renders from.
130
 
131
  Image sources are injected via two resolvers so this module stays
132
- agnostic to how renders are served (base64-inlined for the private
133
- Space, proxy URLs once public):
134
 
135
  - ``render_resolver(submission_id, fixture_id) -> str | None``
136
  - ``gt_resolver(fixture_id) -> str | None``
@@ -154,10 +161,13 @@ def build_gallery_payload(rows: list[dict], render_resolver, gt_resolver) -> dic
154
  def render_gallery_page(rows: list[dict], render_resolver, gt_resolver) -> str:
155
  """Build the full standalone gallery HTML document from live rows.
156
 
157
- ``render_resolver`` / ``gt_resolver`` supply image sources (see
158
- :func:`build_gallery_payload`). The caller (the Space) inlines
159
- base64 data URIs while private; a local harness can do the same so
160
- the page is self-contained with no second requests.
 
 
 
161
  """
162
  payload = build_gallery_payload(rows, render_resolver, gt_resolver)
163
  data_json = json.dumps(payload, ensure_ascii=False)
@@ -176,11 +186,11 @@ def render_gallery_page(rows: list[dict], render_resolver, gt_resolver) -> str:
176
 
177
 
178
  # ---------------------------------------------------------------------------
179
- # CSS (ported from the reference prototype, trimmed to the gallery surface)
 
180
  # ---------------------------------------------------------------------------
181
 
182
  _CSS = """
183
- @import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap');
184
  :root {
185
  --bg: #f4f5f7; --panel: #ffffff; --ink: #14161c; --ink-soft: #5b6170;
186
  --ink-faint: #9aa0ad; --line: #e3e5ea; --line-strong: #d2d5dd;
@@ -189,12 +199,13 @@ _CSS = """
189
  --gt: #0f766e; --gt-soft: #e6f4f2; --thumb-bg: #eceef2;
190
  --shadow: 0 1px 2px rgba(20,22,28,.04), 0 8px 24px rgba(20,22,28,.06);
191
  --radius: 14px;
 
192
  }
193
  * { box-sizing: border-box; }
194
  body {
195
  margin: 0; background: var(--bg); color: var(--ink);
196
- font-family: 'Archivo', sans-serif; -webkit-font-smoothing: antialiased;
197
- padding: 18px 0 60px;
198
  }
199
  .wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; }
200
 
@@ -207,17 +218,53 @@ body {
207
  letter-spacing: .06em; color: var(--ink-faint); margin-bottom: 12px;
208
  }
209
  .picker-help { font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--ink-faint); font-size: 12px; }
210
- .chips { display: flex; gap: 10px; flex-wrap: wrap; }
211
- .chip {
212
- font-family: inherit; font-size: 13.5px; cursor: pointer;
213
- border: 1px solid var(--line-strong); background: #fafbfc; color: var(--ink-soft);
214
- padding: 9px 14px; border-radius: 10px; display: flex; align-items: center;
215
- gap: 8px; transition: all .14s ease; font-weight: 500;
216
- }
217
- .chip:hover { border-color: var(--accent); color: var(--ink); }
218
- .chip.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
219
- .chip .tag { font-family: 'Space Mono', monospace; font-size: 10px; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,.05); text-transform: uppercase; letter-spacing: .03em; }
220
- .chip.on .tag { background: rgba(67,56,202,.12); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  .section-label {
223
  display: flex; align-items: center; gap: 10px; margin: 28px 0 14px;
@@ -225,7 +272,7 @@ body {
225
  text-transform: uppercase; letter-spacing: .05em;
226
  }
227
  .section-label .verified-pill {
228
- font-family: 'Space Mono', monospace; font-size: 10px; color: var(--good);
229
  background: var(--good-soft); padding: 3px 8px; border-radius: 999px;
230
  letter-spacing: .02em; display: inline-flex; align-items: center; gap: 5px;
231
  }
@@ -245,7 +292,7 @@ body {
245
  }
246
  .grid-head > div { padding: 13px 14px; display: flex; align-items: center; }
247
  .grid-head .fix-h { flex-direction: column; align-items: flex-start; gap: 2px; }
248
- .grid-head .fix-h .fname { color: var(--ink-soft); text-transform: none; letter-spacing: 0; font-family: 'Space Mono', monospace; font-size: 11px; font-weight: 700; }
249
  .grid-head .fix-h .ftask { font-size: 9.5px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .04em; }
250
 
251
  .grow.gt-row {
@@ -263,7 +310,7 @@ body {
263
  .grow.sub-row:hover { background: #fafbff; }
264
 
265
  .rank {
266
- padding: 16px 14px; font-family: 'Space Mono', monospace; font-weight: 700;
267
  font-size: 15px; color: var(--ink-faint); display: flex; align-items: center;
268
  justify-content: center;
269
  }
@@ -271,11 +318,11 @@ body {
271
 
272
  .ident { padding: 14px; display: flex; flex-direction: column; justify-content: center; gap: 3px; min-width: 0; }
273
  .ident .sub-name { font-weight: 600; font-size: 14.5px; line-height: 1.25; }
274
- .ident .submitter { font-size: 12px; color: var(--ink-faint); font-family: 'Space Mono', monospace; }
275
 
276
  .score-cell { padding: 14px; display: flex; flex-direction: column; justify-content: center; gap: 4px; }
277
  .score-cell .agg { font-size: 22px; font-weight: 800; letter-spacing: -.01em; }
278
- .score-cell .validity { font-size: 11.5px; font-family: 'Space Mono', monospace; color: var(--good); font-weight: 700; display: flex; align-items: baseline; gap: 5px; }
279
  .score-cell .validity .vlabel { font-weight: 400; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .04em; font-size: 10px; }
280
  .score-cell .validity.imperfect { color: #b45309; }
281
  .score-cell .validity.imperfect .vlabel { color: #c98a3a; }
@@ -287,6 +334,8 @@ body {
287
  transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
288
  }
289
  .thumb:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(20,22,28,.14); border-color: var(--accent); }
 
 
290
  .thumb img { width: 100%; height: 100%; display: block; object-fit: contain; }
291
  .thumb .open-hint {
292
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
@@ -297,7 +346,7 @@ body {
297
 
298
  .thumb.failed { cursor: default; background: var(--bad-soft); border: 1px dashed #e9b3ae; display: flex; align-items: center; justify-content: center; }
299
  .thumb.failed:hover { transform: none; box-shadow: none; border-color: #e9b3ae; }
300
- .thumb.failed .ftag { font-family: 'Space Mono', monospace; font-size: 10px; font-weight: 700; color: var(--bad); text-transform: uppercase; letter-spacing: .04em; text-align: center; line-height: 1.4; }
301
 
302
  .sub-row.open { background: #fafbff; }
303
  .detail {
@@ -329,15 +378,16 @@ body {
329
  .modal-back.show { display: flex; }
330
  .modal { background: var(--panel); border-radius: 16px; width: 100%; max-width: 620px; padding: 26px; box-shadow: 0 24px 60px rgba(0,0,0,.3); }
331
  .modal h4 { margin: 0 0 4px; font-size: 18px; }
332
- .modal .msub { color: var(--ink-faint); font-size: 13px; font-family: 'Space Mono', monospace; margin-bottom: 18px; }
333
  .modal-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
334
  .modal-compare figure { margin: 0; }
335
  .modal-compare figcaption { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-faint); font-weight: 700; margin-bottom: 6px; }
336
  .modal-compare .mthumb { width: 100%; aspect-ratio: 16/10; border-radius: 8px; background: var(--thumb-bg); border: 1px solid var(--line); overflow: hidden; }
337
  .modal-compare .mthumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
338
  .modal-compare .mthumb.failed { background: var(--bad-soft); border: 1px dashed #e9b3ae; display: flex; align-items: center; justify-content: center; }
339
- .modal-compare .mthumb.failed span { font-family: 'Space Mono', monospace; font-size: 10px; font-weight: 700; color: var(--bad); text-transform: uppercase; letter-spacing: .04em; text-align: center; }
340
  .modal-note { margin-top: 18px; font-size: 12.5px; color: var(--ink-soft); background: var(--accent-soft); padding: 12px 14px; border-radius: 10px; }
 
341
  .modal-close { margin-top: 20px; width: 100%; padding: 11px; border: 1px solid var(--line-strong); background: #fafbfc; border-radius: 10px; font-family: inherit; font-weight: 600; cursor: pointer; font-size: 14px; }
342
  .modal-close:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
343
  """
@@ -350,8 +400,18 @@ body {
350
  _BODY = """
351
  <div class="wrap">
352
  <div class="controls">
353
- <div class="label">Fixtures shown <span class="picker-help">- pick 3 to compare across all models (changes columns globally)</span></div>
354
- <div class="chips" id="chips"></div>
 
 
 
 
 
 
 
 
 
 
355
  </div>
356
  <div class="section-label">
357
  Validated leaderboard - Top 10
@@ -382,14 +442,16 @@ _BODY = """
382
  # ---------------------------------------------------------------------------
383
 
384
  _JS = """
385
- const DATA = window.GALLERY_DATA || {fixtures: [], subs: [], selected: []};
386
- const FIXTURES = DATA.fixtures;
387
- let selected = (DATA.selected || []).slice();
 
 
388
 
389
  // --- Render hooks. ---------------------------------------------------------
390
- // The image sources are injected by the server (base64 data URIs while the
391
- // Space is private; proxy URLs once public), so these just read the payload.
392
- // renderFor returns null for an invalid/missing fixture -> dashed cell.
393
  function renderFor(sub, fxId) {
394
  const c = sub.cells[fxId];
395
  return c ? c.img : null;
@@ -403,29 +465,134 @@ function fmt(x, d) { return (x === null || x === undefined) ? '-' : Number(x).to
403
  function pct(x) { return (x === null || x === undefined) ? '-' : Math.round(Number(x) * 100) + '%'; }
404
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
405
  function fixtureMeta(id) { return FIXTURES.find(f => f.id === id); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
- function buildChips() {
408
- const wrap = document.getElementById('chips');
409
- wrap.innerHTML = FIXTURES.map(f => {
410
- const on = selected.includes(f.id);
411
- const tag = f.task ? '<span class="tag">' + esc(f.task) + '</span>' : '';
412
- return '<button class="chip ' + (on ? 'on' : '') + '" data-id="' + esc(f.id) + '">' + tag + esc(f.name) + '</button>';
 
 
 
 
 
413
  }).join('');
414
- wrap.querySelectorAll('.chip').forEach(c => {
415
- c.onclick = () => {
416
- const id = c.dataset.id;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  if (selected.includes(id)) {
418
- if (selected.length <= 1) return; // keep at least 1
419
  selected = selected.filter(x => x !== id);
420
  } else {
421
- if (selected.length >= 3) selected.shift(); // cap at 3, drop oldest
422
  selected.push(id);
423
  }
424
- buildChips(); buildGallery();
425
  };
426
  });
427
  }
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  function buildHead() {
430
  const head = document.getElementById('gridHead');
431
  let h = '<div>#</div><div>Submission</div><div>Score</div>';
@@ -437,17 +604,26 @@ function buildHead() {
437
  head.innerHTML = h;
438
  }
439
 
 
 
 
 
 
 
 
440
  function thumbHTML(url, attrs, clickable) {
441
  if (!url) {
442
  return '<div class="thumb failed"><span class="ftag">invalid<br>generation</span></div>';
443
  }
444
  const hint = clickable ? '<span class="open-hint">open</span>' : '';
445
- return '<div class="thumb" ' + attrs + '><img loading="lazy" src="' + url + '" alt="">' + hint + '</div>';
 
 
446
  }
447
 
448
  function buildGallery() {
449
  const g = document.getElementById('gallery');
450
- g.style.setProperty('--ncol', selected.length);
451
  buildHead();
452
  g.querySelectorAll('.grow').forEach(n => n.remove());
453
 
@@ -555,8 +731,11 @@ function syncHeadHeight() {
555
  if (head) document.documentElement.style.setProperty('--head-h', head.offsetHeight + 'px');
556
  }
557
 
558
- buildChips();
 
 
559
  buildGallery();
 
560
  window.addEventListener('resize', syncHeadHeight);
561
  if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncHeadHeight);
562
  """
 
25
  top-10 verified rows + the fixture universe into a small JSON blob,
26
  which the page's JS renders. Render lookups are isolated behind the
27
  ``renderFor`` / ``gtRenderFor`` JS hooks (mirroring the design brief),
28
+ pointed at the cached render-proxy URLs the caller injects via the two
29
+ resolvers:
30
 
31
+ - ``renderFor(sub, fixtureId)`` -> ``/render/<id>/<fixture>.png`` (or
32
+ ``null`` when the per-fixture status is invalid/missing, which draws
33
+ the dashed "invalid generation" cell).
34
  - ``gtRenderFor(fixtureId)`` -> ``/gt-render/<fixture>.png``.
35
 
36
+ Thumbnails are lazy-loaded by the browser, so only the ~33 on-screen
37
+ tiles are fetched and CDN/browser caching makes fixture-swaps and
38
+ repeat visits essentially free. This requires the Space to be
39
+ **public** (HF's edge 404s in-browser fetches to our custom routes
40
+ while private).
41
+
42
+ Thumbnail clicks open a GT-vs-output compare modal that points at the
43
+ existing per-submission detail/report view.
44
  """
45
  from __future__ import annotations
46
 
 
52
  GALLERY_TOP_N = 10
53
 
54
  # Default number of fixture columns the picker opens with, capped at the
55
+ # size of the available fixture universe. Also the hard cap on the
56
+ # picker (the matrix only stays comparable with a small column count).
57
  DEFAULT_FIXTURE_COLUMNS = 3
58
 
59
 
 
100
  def _sub_payload(row: dict, render_resolver) -> dict:
101
  """Project one verified row into the compact shape the page JS needs.
102
 
103
+ ``render_resolver(submission_id, fixture_id)`` returns the cached
104
+ proxy URL for a *valid* fixture, or ``None``. Invalid/missing
105
+ fixtures carry ``img: null`` so the page draws the dashed cell;
106
+ note validity is driven by the per-fixture ``status`` in the data,
107
+ not by whether an image fetch happened to succeed.
108
  """
109
  by_task = row.get("score_by_task_type") or {}
110
  pfs = row.get("per_fixture_scores") or {}
 
137
  """Shape live rows into the JSON the gallery page renders from.
138
 
139
  Image sources are injected via two resolvers so this module stays
140
+ agnostic to how the cached render URLs are constructed:
 
141
 
142
  - ``render_resolver(submission_id, fixture_id) -> str | None``
143
  - ``gt_resolver(fixture_id) -> str | None``
 
161
  def render_gallery_page(rows: list[dict], render_resolver, gt_resolver) -> str:
162
  """Build the full standalone gallery HTML document from live rows.
163
 
164
+ ``render_resolver`` / ``gt_resolver`` supply the cached render-proxy
165
+ URLs (see :func:`build_gallery_payload`); the browser lazy-loads only
166
+ the on-screen thumbnails.
167
+
168
+ The document is self-contained and uses **system font stacks only**
169
+ (no external font CDN fetch) so it never errors inside a sandboxed
170
+ iframe.
171
  """
172
  payload = build_gallery_payload(rows, render_resolver, gt_resolver)
173
  data_json = json.dumps(payload, ensure_ascii=False)
 
186
 
187
 
188
  # ---------------------------------------------------------------------------
189
+ # CSS (ported from the reference prototype, trimmed to the gallery surface).
190
+ # Self-contained: system font stacks only, no external font CDN fetch.
191
  # ---------------------------------------------------------------------------
192
 
193
  _CSS = """
 
194
  :root {
195
  --bg: #f4f5f7; --panel: #ffffff; --ink: #14161c; --ink-soft: #5b6170;
196
  --ink-faint: #9aa0ad; --line: #e3e5ea; --line-strong: #d2d5dd;
 
199
  --gt: #0f766e; --gt-soft: #e6f4f2; --thumb-bg: #eceef2;
200
  --shadow: 0 1px 2px rgba(20,22,28,.04), 0 8px 24px rgba(20,22,28,.06);
201
  --radius: 14px;
202
+ --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
203
  }
204
  * { box-sizing: border-box; }
205
  body {
206
  margin: 0; background: var(--bg); color: var(--ink);
207
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
208
+ -webkit-font-smoothing: antialiased; padding: 18px 0 60px;
209
  }
210
  .wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; }
211
 
 
218
  letter-spacing: .06em; color: var(--ink-faint); margin-bottom: 12px;
219
  }
220
  .picker-help { font-weight: 500; text-transform: none; letter-spacing: 0; color: var(--ink-faint); font-size: 12px; }
221
+
222
+ /* Compact picker: current picks as removable pills + Add + Reset */
223
+ .picker-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
224
+ .pills { display: flex; gap: 8px; flex-wrap: wrap; }
225
+ .pill {
226
+ display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 600;
227
+ background: var(--accent-soft); border: 1px solid var(--accent); color: var(--accent);
228
+ padding: 8px 8px 8px 12px; border-radius: 10px; font-family: inherit;
229
+ }
230
+ .pill .pgroup { font-family: var(--mono); font-size: 10px; color: var(--accent); opacity: .7; }
231
+ .pill .pname { font-family: var(--mono); font-weight: 700; }
232
+ .pill .premove { cursor: pointer; border: none; background: rgba(67,56,202,.12); color: var(--accent); width: 18px; height: 18px; border-radius: 5px; font-size: 13px; line-height: 1; display: grid; place-items: center; font-family: inherit; }
233
+ .pill .premove:hover { background: var(--accent); color: #fff; }
234
+
235
+ .picker-anchor { position: relative; }
236
+ .add-fixture {
237
+ font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; color: var(--ink-soft);
238
+ background: #fafbfc; border: 1px dashed var(--line-strong); padding: 9px 14px; border-radius: 10px;
239
+ transition: all .14s ease;
240
+ }
241
+ .add-fixture:hover { border-color: var(--accent); color: var(--accent); border-style: solid; }
242
+ .add-fixture:disabled { opacity: .45; cursor: not-allowed; }
243
+ .reset-fixtures {
244
+ font-family: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; color: var(--ink-faint);
245
+ background: none; border: none; padding: 9px 6px; text-decoration: underline; text-underline-offset: 2px;
246
+ }
247
+ .reset-fixtures:hover { color: var(--accent); }
248
+
249
+ /* Searchable grouped dropdown over all fixtures */
250
+ .popover {
251
+ position: absolute; top: calc(100% + 8px); left: 0; z-index: 40; width: 340px;
252
+ background: var(--panel); border: 1px solid var(--line-strong); border-radius: 12px;
253
+ box-shadow: 0 16px 40px rgba(20,22,28,.18); overflow: hidden;
254
+ }
255
+ .popover[hidden] { display: none; }
256
+ .popover-search { width: 100%; border: none; border-bottom: 1px solid var(--line); padding: 13px 15px; font-family: inherit; font-size: 14px; outline: none; }
257
+ .popover-search:focus { background: #fbfbff; }
258
+ .popover-list { max-height: 300px; overflow-y: auto; padding: 6px; }
259
+ .popover-group { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--ink-faint); padding: 10px 10px 5px; position: sticky; top: 0; background: var(--panel); }
260
+ .popover-item { display: flex; align-items: center; gap: 9px; padding: 9px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
261
+ .popover-item:hover { background: var(--accent-soft); }
262
+ .popover-item.is-selected { color: var(--accent); font-weight: 600; }
263
+ .popover-item.is-selected::after { content: '\\2713'; margin-left: auto; color: var(--accent); }
264
+ .popover-item .itask { font-family: var(--mono); font-size: 9px; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,.05); text-transform: uppercase; }
265
+ .popover-item .iname { font-family: var(--mono); font-weight: 700; }
266
+ .popover-empty { padding: 18px; text-align: center; color: var(--ink-faint); font-size: 13px; }
267
+ .popover-cap { padding: 10px 12px; font-size: 11.5px; color: #b45309; background: #fdf3e7; border-top: 1px solid var(--line); }
268
 
269
  .section-label {
270
  display: flex; align-items: center; gap: 10px; margin: 28px 0 14px;
 
272
  text-transform: uppercase; letter-spacing: .05em;
273
  }
274
  .section-label .verified-pill {
275
+ font-family: var(--mono); font-size: 10px; color: var(--good);
276
  background: var(--good-soft); padding: 3px 8px; border-radius: 999px;
277
  letter-spacing: .02em; display: inline-flex; align-items: center; gap: 5px;
278
  }
 
292
  }
293
  .grid-head > div { padding: 13px 14px; display: flex; align-items: center; }
294
  .grid-head .fix-h { flex-direction: column; align-items: flex-start; gap: 2px; }
295
+ .grid-head .fix-h .fname { color: var(--ink-soft); text-transform: none; letter-spacing: 0; font-family: var(--mono); font-size: 11px; font-weight: 700; }
296
  .grid-head .fix-h .ftask { font-size: 9.5px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .04em; }
297
 
298
  .grow.gt-row {
 
310
  .grow.sub-row:hover { background: #fafbff; }
311
 
312
  .rank {
313
+ padding: 16px 14px; font-family: var(--mono); font-weight: 700;
314
  font-size: 15px; color: var(--ink-faint); display: flex; align-items: center;
315
  justify-content: center;
316
  }
 
318
 
319
  .ident { padding: 14px; display: flex; flex-direction: column; justify-content: center; gap: 3px; min-width: 0; }
320
  .ident .sub-name { font-weight: 600; font-size: 14.5px; line-height: 1.25; }
321
+ .ident .submitter { font-size: 12px; color: var(--ink-faint); font-family: var(--mono); }
322
 
323
  .score-cell { padding: 14px; display: flex; flex-direction: column; justify-content: center; gap: 4px; }
324
  .score-cell .agg { font-size: 22px; font-weight: 800; letter-spacing: -.01em; }
325
+ .score-cell .validity { font-size: 11.5px; font-family: var(--mono); color: var(--good); font-weight: 700; display: flex; align-items: baseline; gap: 5px; }
326
  .score-cell .validity .vlabel { font-weight: 400; color: var(--ink-faint); text-transform: uppercase; letter-spacing: .04em; font-size: 10px; }
327
  .score-cell .validity.imperfect { color: #b45309; }
328
  .score-cell .validity.imperfect .vlabel { color: #c98a3a; }
 
334
  transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
335
  }
336
  .thumb:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(20,22,28,.14); border-color: var(--accent); }
337
+ /* Display width is CSS-constrained so the browser downscales the existing
338
+ full-res render: no resize step, no new assets. */
339
  .thumb img { width: 100%; height: 100%; display: block; object-fit: contain; }
340
  .thumb .open-hint {
341
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
 
346
 
347
  .thumb.failed { cursor: default; background: var(--bad-soft); border: 1px dashed #e9b3ae; display: flex; align-items: center; justify-content: center; }
348
  .thumb.failed:hover { transform: none; box-shadow: none; border-color: #e9b3ae; }
349
+ .thumb.failed .ftag { font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--bad); text-transform: uppercase; letter-spacing: .04em; text-align: center; line-height: 1.4; }
350
 
351
  .sub-row.open { background: #fafbff; }
352
  .detail {
 
378
  .modal-back.show { display: flex; }
379
  .modal { background: var(--panel); border-radius: 16px; width: 100%; max-width: 620px; padding: 26px; box-shadow: 0 24px 60px rgba(0,0,0,.3); }
380
  .modal h4 { margin: 0 0 4px; font-size: 18px; }
381
+ .modal .msub { color: var(--ink-faint); font-size: 13px; font-family: var(--mono); margin-bottom: 18px; }
382
  .modal-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
383
  .modal-compare figure { margin: 0; }
384
  .modal-compare figcaption { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-faint); font-weight: 700; margin-bottom: 6px; }
385
  .modal-compare .mthumb { width: 100%; aspect-ratio: 16/10; border-radius: 8px; background: var(--thumb-bg); border: 1px solid var(--line); overflow: hidden; }
386
  .modal-compare .mthumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
387
  .modal-compare .mthumb.failed { background: var(--bad-soft); border: 1px dashed #e9b3ae; display: flex; align-items: center; justify-content: center; }
388
+ .modal-compare .mthumb.failed span { font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--bad); text-transform: uppercase; letter-spacing: .04em; text-align: center; }
389
  .modal-note { margin-top: 18px; font-size: 12.5px; color: var(--ink-soft); background: var(--accent-soft); padding: 12px 14px; border-radius: 10px; }
390
+ .modal-note a { color: var(--accent); font-weight: 600; }
391
  .modal-close { margin-top: 20px; width: 100%; padding: 11px; border: 1px solid var(--line-strong); background: #fafbfc; border-radius: 10px; font-family: inherit; font-weight: 600; cursor: pointer; font-size: 14px; }
392
  .modal-close:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
393
  """
 
400
  _BODY = """
401
  <div class="wrap">
402
  <div class="controls">
403
+ <div class="label">Fixtures shown <span class="picker-help">- pick up to 3 to compare across all models (changes columns globally)</span></div>
404
+ <div class="picker-row">
405
+ <div class="pills" id="pills"></div>
406
+ <div class="picker-anchor">
407
+ <button class="add-fixture" id="addFixtureBtn">+ Add fixture</button>
408
+ <div class="popover" id="popover" hidden>
409
+ <input type="text" class="popover-search" id="popoverSearch" placeholder="Search fixtures..." autocomplete="off">
410
+ <div class="popover-list" id="popoverList"></div>
411
+ </div>
412
+ </div>
413
+ <button class="reset-fixtures" id="resetFixtures" title="Reset to the default comparison set">Reset</button>
414
+ </div>
415
  </div>
416
  <div class="section-label">
417
  Validated leaderboard - Top 10
 
442
  # ---------------------------------------------------------------------------
443
 
444
  _JS = """
445
+ const DATA = window.GALLERY_DATA || {fixtures: [], subs: [], selected: [], gtImg: {}};
446
+ const FIXTURES = DATA.fixtures || [];
447
+ const MAX_FIXTURES = 3;
448
+ const DEFAULT_FIXTURES = (DATA.selected || []).slice();
449
+ let selected = DEFAULT_FIXTURES.slice();
450
 
451
  // --- Render hooks. ---------------------------------------------------------
452
+ // The image sources are cached render-proxy URLs injected by the server, so
453
+ // these just read the payload (the browser lazy-loads only the on-screen
454
+ // tiles). renderFor returns null for an invalid/missing fixture -> dashed cell.
455
  function renderFor(sub, fxId) {
456
  const c = sub.cells[fxId];
457
  return c ? c.img : null;
 
465
  function pct(x) { return (x === null || x === undefined) ? '-' : Math.round(Number(x) * 100) + '%'; }
466
  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
467
  function fixtureMeta(id) { return FIXTURES.find(f => f.id === id); }
468
+ function groupLabel(task) { return task ? (task.charAt(0).toUpperCase() + task.slice(1)) : 'Other'; }
469
+ function groupOf(f) { return groupLabel(f ? f.task : ''); }
470
+
471
+ // Distinct group labels in fixture order (e.g. Generation, Editing).
472
+ const GROUPS = (() => {
473
+ const seen = [];
474
+ FIXTURES.forEach(f => { const g = groupOf(f); if (!seen.includes(g)) seen.push(g); });
475
+ return seen;
476
+ })();
477
+
478
+ // --- URL persistence: ?fixtures=a,b,c -------------------------------------
479
+ // Wrapped in try/catch: history.replaceState and the URL read both throw in
480
+ // sandboxed iframe contexts (this caused an "Uncaught Error: Script error.").
481
+ function loadSelectedFromURL() {
482
+ try {
483
+ const p = new URLSearchParams(location.search).get('fixtures');
484
+ if (!p) return;
485
+ const ids = p.split(',').map(s => s.trim())
486
+ .filter(id => FIXTURES.some(f => f.id === id)).slice(0, MAX_FIXTURES);
487
+ if (ids.length) selected = ids;
488
+ } catch (e) { /* sandboxed context -> keep defaults */ }
489
+ }
490
+ function syncURL() {
491
+ try {
492
+ const u = new URL(location.href);
493
+ u.searchParams.set('fixtures', selected.join(','));
494
+ history.replaceState(null, '', u);
495
+ } catch (e) { /* sandboxed/cross-origin context -> URL persistence no-ops */ }
496
+ }
497
 
498
+ // --- Fixture picker: pills + searchable grouped dropdown -------------------
499
+ function renderPills() {
500
+ const wrap = document.getElementById('pills');
501
+ if (!selected.length) { wrap.innerHTML = ''; return; }
502
+ wrap.innerHTML = selected.map(id => {
503
+ const f = fixtureMeta(id);
504
+ const grp = (f && f.task) ? '<span class="pgroup">' + esc(groupOf(f)) + '</span>' : '';
505
+ return '<span class="pill">' + grp
506
+ + '<span class="pname">' + esc(f ? f.name : id) + '</span>'
507
+ + '<button class="premove" data-remove="' + esc(id) + '" title="Remove" aria-label="Remove ' + esc(id) + '">\\u00d7</button>'
508
+ + '</span>';
509
  }).join('');
510
+ wrap.querySelectorAll('.premove').forEach(b => {
511
+ b.onclick = () => {
512
+ if (selected.length <= 1) return; // keep at least 1 column
513
+ selected = selected.filter(x => x !== b.dataset.remove);
514
+ refreshPicker(); buildGallery(); syncURL();
515
+ };
516
+ });
517
+ }
518
+
519
+ let popoverQuery = '';
520
+ function renderPopoverList() {
521
+ const list = document.getElementById('popoverList');
522
+ const q = popoverQuery.trim().toLowerCase();
523
+ const match = f => !q || f.name.toLowerCase().includes(q)
524
+ || groupOf(f).toLowerCase().includes(q) || (f.task || '').toLowerCase().includes(q);
525
+ let html = '';
526
+ GROUPS.forEach(g => {
527
+ const items = FIXTURES.filter(f => groupOf(f) === g && match(f));
528
+ if (!items.length) return;
529
+ html += '<div class="popover-group">' + esc(g) + '</div>';
530
+ html += items.map(f => {
531
+ const sel = selected.includes(f.id);
532
+ const tag = f.task ? '<span class="itask">' + esc(f.task) + '</span>' : '';
533
+ return '<div class="popover-item ' + (sel ? 'is-selected' : '') + '" data-pick="' + esc(f.id) + '">'
534
+ + tag + '<span class="iname">' + esc(f.name) + '</span></div>';
535
+ }).join('');
536
+ });
537
+ if (!html) html = '<div class="popover-empty">No fixtures match \\u201c' + esc(popoverQuery) + '\\u201d.</div>';
538
+ list.innerHTML = html;
539
+ // At the cap, show a note rather than silently dropping a pick.
540
+ const existingCap = document.getElementById('popoverCap');
541
+ if (existingCap) existingCap.remove();
542
+ if (selected.length >= MAX_FIXTURES) {
543
+ list.insertAdjacentHTML('afterend',
544
+ '<div class="popover-cap" id="popoverCap">Max ' + MAX_FIXTURES + ' fixtures - remove one to add another.</div>');
545
+ }
546
+ list.querySelectorAll('.popover-item').forEach(it => {
547
+ it.onclick = () => {
548
+ const id = it.dataset.pick;
549
  if (selected.includes(id)) {
550
+ if (selected.length <= 1) return; // keep at least 1
551
  selected = selected.filter(x => x !== id);
552
  } else {
553
+ if (selected.length >= MAX_FIXTURES) return; // hard cap; user removes to add
554
  selected.push(id);
555
  }
556
+ refreshPicker(); buildGallery(); syncURL();
557
  };
558
  });
559
  }
560
 
561
+ function refreshPicker() {
562
+ renderPills();
563
+ renderPopoverList();
564
+ const add = document.getElementById('addFixtureBtn');
565
+ if (add) add.disabled = !FIXTURES.length;
566
+ }
567
+
568
+ function openPopover() {
569
+ const pop = document.getElementById('popover');
570
+ pop.hidden = false;
571
+ popoverQuery = '';
572
+ document.getElementById('popoverSearch').value = '';
573
+ renderPopoverList();
574
+ document.getElementById('popoverSearch').focus();
575
+ }
576
+ function closePopover() { document.getElementById('popover').hidden = true; }
577
+
578
+ function wirePicker() {
579
+ document.getElementById('addFixtureBtn').onclick = (e) => {
580
+ e.stopPropagation();
581
+ const pop = document.getElementById('popover');
582
+ pop.hidden ? openPopover() : closePopover();
583
+ };
584
+ document.getElementById('popoverSearch').oninput = (e) => { popoverQuery = e.target.value; renderPopoverList(); };
585
+ document.getElementById('resetFixtures').onclick = () => {
586
+ selected = DEFAULT_FIXTURES.slice(); refreshPicker(); buildGallery(); syncURL(); closePopover();
587
+ };
588
+ // click-outside closes the popover
589
+ document.addEventListener('click', (e) => {
590
+ const anchor = document.querySelector('.picker-anchor');
591
+ if (anchor && !anchor.contains(e.target)) closePopover();
592
+ });
593
+ }
594
+
595
+ // --- Gallery render -------------------------------------------------------
596
  function buildHead() {
597
  const head = document.getElementById('gridHead');
598
  let h = '<div>#</div><div>Submission</div><div>Score</div>';
 
604
  head.innerHTML = h;
605
  }
606
 
607
+ // Fall back to the dashed cell if a render URL 404s (a fixture marked valid
608
+ // whose render upload is missing) instead of showing a broken image.
609
+ function imgFail(img) {
610
+ const cell = img.closest('.thumb-cell');
611
+ if (cell) cell.innerHTML = '<div class="thumb failed"><span class="ftag">invalid<br>generation</span></div>';
612
+ }
613
+
614
  function thumbHTML(url, attrs, clickable) {
615
  if (!url) {
616
  return '<div class="thumb failed"><span class="ftag">invalid<br>generation</span></div>';
617
  }
618
  const hint = clickable ? '<span class="open-hint">open</span>' : '';
619
+ return '<div class="thumb" ' + attrs + '>'
620
+ + '<img loading="lazy" decoding="async" src="' + url + '" alt="" onerror="imgFail(this)">'
621
+ + hint + '</div>';
622
  }
623
 
624
  function buildGallery() {
625
  const g = document.getElementById('gallery');
626
+ g.style.setProperty('--ncol', Math.max(selected.length, 1));
627
  buildHead();
628
  g.querySelectorAll('.grow').forEach(n => n.remove());
629
 
 
731
  if (head) document.documentElement.style.setProperty('--head-h', head.offsetHeight + 'px');
732
  }
733
 
734
+ loadSelectedFromURL();
735
+ wirePicker();
736
+ refreshPicker();
737
  buildGallery();
738
+ syncURL();
739
  window.addEventListener('resize', syncHeadHeight);
740
  if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncHeadHeight);
741
  """
tests/test_proxy.py CHANGED
@@ -263,7 +263,7 @@ def test_admin_delete_refreshes_gallery(monkeypatch):
263
  monkeypatch.setattr(app, "_gallery_iframe_html", lambda: "<iframe>empty</iframe>")
264
  monkeypatch.setattr(app.gr, "Info", lambda *a, **k: None)
265
 
266
- admin_df, validated, unvalidated, gallery_html, _confirm, _delete_btn = (
267
  app._admin_delete(table_df, True, types.SimpleNamespace(username="michaelr27"))
268
  )
269
 
 
263
  monkeypatch.setattr(app, "_gallery_iframe_html", lambda: "<iframe>empty</iframe>")
264
  monkeypatch.setattr(app.gr, "Info", lambda *a, **k: None)
265
 
266
+ admin_df, validated, unvalidated, gallery_html, _confirm, _delete_btn, _stop_btn = (
267
  app._admin_delete(table_df, True, types.SimpleNamespace(username="michaelr27"))
268
  )
269