gallery: searchable fixture picker, URL-persisted picks, cached render proxies
Browse filesReplace 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>
- app.py +31 -81
- gallery.py +243 -64
- tests/test_proxy.py +1 -1
|
@@ -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
|
|
|
|
| 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 |
-
|
| 733 |
-
|
| 734 |
-
The gallery only calls this for fixtures whose
|
| 735 |
-
``valid``; an absolute path resolves against
|
| 736 |
-
inside the iframe ``srcdoc``. A render that
|
| 737 |
-
missing upload) degrades to the dashed cell
|
| 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 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 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
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
failure degrades to an empty gallery rather than crashing the
|
|
|
|
| 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 |
-
|
| 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`
|
| 822 |
-
#
|
| 823 |
-
#
|
| 824 |
-
#
|
| 825 |
-
#
|
| 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
|
| 1155 |
-
#
|
| 1156 |
-
#
|
| 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,
|
|
@@ -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
|
|
|
|
| 29 |
|
| 30 |
-
- ``renderFor(sub, fixtureId)`` -> ``/render/<id>/<fixture>.png``
|
| 31 |
-
|
| 32 |
-
|
| 33 |
- ``gtRenderFor(fixtureId)`` -> ``/gt-render/<fixture>.png``.
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 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
|
| 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
|
| 158 |
-
:func:`build_gallery_payload`)
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
| 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: '
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
gap: 8px;
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
.
|
| 220 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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:
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 388 |
|
| 389 |
// --- Render hooks. ---------------------------------------------------------
|
| 390 |
-
// The image sources are injected by the server
|
| 391 |
-
//
|
| 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
| 405 |
function fixtureMeta(id) { return FIXTURES.find(f => f.id === id); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
wrap
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
}).join('');
|
| 414 |
-
wrap.querySelectorAll('.
|
| 415 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
if (selected.includes(id)) {
|
| 418 |
-
if (selected.length <= 1) return;
|
| 419 |
selected = selected.filter(x => x !== id);
|
| 420 |
} else {
|
| 421 |
-
if (selected.length >=
|
| 422 |
selected.push(id);
|
| 423 |
}
|
| 424 |
-
|
| 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 + '>
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 |
"""
|
|
@@ -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 |
|