Michael Rabinovich Cursor commited on
Commit ·
95f3ee8
1
Parent(s): c48c18f
leaderboard: rebuild Tasks tab as grouped thumbnail grid
Browse filesReplace the flat 84-row Tasks list with a responsive card grid grouped
by type (Generation / Editing) with live count badges, a number search,
and an All/Generation/Editing segmented filter. Each card shows the
task's input geometry thumbnail (generation -> input.png; editing ->
renders/iso.png, the iso render of input.step) plus the sample number
and type tag. Thumbnails use loading="lazy" + decoding="async" with
reserved 4:3 dimensions and CSS-constrained width so the 84 images don't
all fetch at once. Card click opens the task's existing detail card
(prompt + input), keeping j/k/Esc navigation. System fonts only.
Co-authored-by: Cursor <cursoragent@cursor.com>
- tasks.py +242 -41
- tests/test_tasks.py +53 -0
tasks.py
CHANGED
|
@@ -161,20 +161,115 @@ def _render_task_card(task: dict, idx: int, asset_url) -> str:
|
|
| 161 |
return "\n".join(p)
|
| 162 |
|
| 163 |
|
| 164 |
-
def
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
|
| 180 |
def _render_header(tasks: list[dict]) -> str:
|
|
@@ -182,10 +277,13 @@ def _render_header(tasks: list[dict]) -> str:
|
|
| 182 |
n_gen = sum(1 for t in tasks if t["task_type"] != "editing")
|
| 183 |
n_edit = n - n_gen
|
| 184 |
return (
|
| 185 |
-
'<div class="
|
| 186 |
-
f
|
| 187 |
-
|
| 188 |
-
f"
|
|
|
|
|
|
|
|
|
|
| 189 |
"</div>"
|
| 190 |
)
|
| 191 |
|
|
@@ -194,8 +292,11 @@ def render_tasks_page(tasks: list[dict], asset_url) -> str:
|
|
| 194 |
"""Build the full standalone task-browser HTML document.
|
| 195 |
|
| 196 |
``asset_url(fixture, relpath)`` supplies the input image URLs (see
|
| 197 |
-
module docstring). The
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
| 199 |
"""
|
| 200 |
fixture_names_js = json.dumps([t["name"] for t in tasks])
|
| 201 |
p = [
|
|
@@ -212,17 +313,11 @@ def render_tasks_page(tasks: list[dict], asset_url) -> str:
|
|
| 212 |
p.append(_render_header(tasks))
|
| 213 |
p.append("</div>")
|
| 214 |
|
| 215 |
-
# Summary view
|
| 216 |
p.append('<div id="summary-view">')
|
| 217 |
-
p.append(
|
| 218 |
-
'<p style="color:#888;font-size:0.85em">'
|
| 219 |
-
"Click a row to view the task. "
|
| 220 |
-
'<span class="kbd">j</span>/<span class="kbd">k</span> '
|
| 221 |
-
"to navigate, "
|
| 222 |
-
'<span class="kbd">Esc</span> to return.</p>'
|
| 223 |
-
)
|
| 224 |
if tasks:
|
| 225 |
-
p.append(
|
|
|
|
| 226 |
else:
|
| 227 |
p.append(
|
| 228 |
'<p class="note">No tasks found in the sample inputs dataset.</p>'
|
|
@@ -258,9 +353,18 @@ def render_tasks_page(tasks: list[dict], asset_url) -> str:
|
|
| 258 |
# ---------------------------------------------------------------------------
|
| 259 |
|
| 260 |
_CSS = """\
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
* { box-sizing: border-box; }
|
| 262 |
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 263 |
-
max-width: 1600px; margin: 0 auto; padding: 20px; background:
|
|
|
|
| 264 |
h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
|
| 265 |
h2 { margin-top: 0; }
|
| 266 |
.tag { font-size: 0.6em; color: #666; font-weight: normal; font-family: monospace;
|
|
@@ -268,17 +372,69 @@ h2 { margin-top: 0; }
|
|
| 268 |
|
| 269 |
.run-header { background: white; border-radius: 8px; padding: 16px 20px;
|
| 270 |
margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
.summary
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
.summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
.nav-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
| 284 |
background: white; border-radius: 8px; margin-bottom: 16px;
|
|
@@ -329,6 +485,7 @@ h2 { margin-top: 0; }
|
|
| 329 |
_JS = """\
|
| 330 |
let currentIdx = -1;
|
| 331 |
const total = document.querySelectorAll('.fixture-card').length;
|
|
|
|
| 332 |
|
| 333 |
function taskImgFail(img) {
|
| 334 |
const view = img.closest('.view');
|
|
@@ -336,6 +493,50 @@ function taskImgFail(img) {
|
|
| 336 |
img.style.display = 'none';
|
| 337 |
}
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
function showSummary() {
|
| 340 |
document.getElementById('summary-view').style.display = '';
|
| 341 |
document.getElementById('detail-view').style.display = 'none';
|
|
|
|
| 161 |
return "\n".join(p)
|
| 162 |
|
| 163 |
|
| 164 |
+
def _thumb_url(task: dict, asset_url) -> str:
|
| 165 |
+
"""Resolve a task's grid thumbnail to an existing input asset.
|
| 166 |
+
|
| 167 |
+
The thumbnail is always the task's *input geometry*, but the source
|
| 168 |
+
differs by type:
|
| 169 |
+
|
| 170 |
+
- generation -> the input drawing ``input.png`` (the first listed
|
| 171 |
+
input image, which defaults to ``input.png``).
|
| 172 |
+
- editing -> the isometric render of the input STEP, i.e.
|
| 173 |
+
``renders/iso.png`` (same render style/camera used by the detail
|
| 174 |
+
card's render grid and elsewhere; generated once in the pipeline
|
| 175 |
+
and served cached, never rendered on the fly).
|
| 176 |
+
|
| 177 |
+
A missing file degrades client-side via the ``onerror`` hook, which
|
| 178 |
+
hides the broken image and leaves the placeholder thumb background.
|
| 179 |
+
"""
|
| 180 |
+
name = task["name"]
|
| 181 |
+
if task["task_type"] == "editing":
|
| 182 |
+
return asset_url(name, "renders/iso.png")
|
| 183 |
+
images = task.get("image_inputs") or ["input.png"]
|
| 184 |
+
return asset_url(name, images[0])
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _render_card(task: dict, idx: int, asset_url) -> str:
|
| 188 |
+
"""One grid card: lazy thumbnail + sample number + type tag.
|
| 189 |
+
|
| 190 |
+
``idx`` is the task's global index, used so a card click jumps to the
|
| 191 |
+
matching detail card via ``showDetail(idx)``. ``data-type`` /
|
| 192 |
+
``data-name`` drive the client-side type filter and number search.
|
| 193 |
+
|
| 194 |
+
The ``<img>`` carries ``loading="lazy"`` + ``decoding="async"`` so
|
| 195 |
+
only the cards in view fetch on first paint, and intrinsic
|
| 196 |
+
``width``/``height`` (4:3) plus the ``aspect-ratio`` thumb container
|
| 197 |
+
reserve space to avoid layout shift. The card column caps display
|
| 198 |
+
width, so the browser downscales the cached input into the slot.
|
| 199 |
+
"""
|
| 200 |
+
name = task["name"]
|
| 201 |
+
ttype = task["task_type"]
|
| 202 |
+
cls = "editing" if ttype == "editing" else "generation"
|
| 203 |
+
url = _thumb_url(task, asset_url)
|
| 204 |
+
return (
|
| 205 |
+
f'<button class="card" type="button" data-idx="{idx}" '
|
| 206 |
+
f'data-type="{cls}" data-name="{html.escape(name, quote=True)}" '
|
| 207 |
+
f'onclick="showDetail({idx})">'
|
| 208 |
+
'<span class="thumb">'
|
| 209 |
+
f'<img loading="lazy" decoding="async" width="180" height="135" '
|
| 210 |
+
f'src="{html.escape(url, quote=True)}" '
|
| 211 |
+
f'alt="{html.escape(name, quote=True)}" onerror="taskImgFail(this)">'
|
| 212 |
+
"</span>"
|
| 213 |
+
'<span class="meta">'
|
| 214 |
+
f'<span class="sample">{html.escape(name)}</span>'
|
| 215 |
+
f'<span class="type {cls}">{html.escape(ttype)}</span>'
|
| 216 |
+
"</span>"
|
| 217 |
+
"</button>"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# Display order + header label/color class for the two task groups.
|
| 222 |
+
_GROUPS = (("generation", "Generation", "gen"), ("editing", "Editing", "edit"))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _render_grid(tasks: list[dict], asset_url) -> str:
|
| 226 |
+
"""Grouped responsive card grid (Generation, then Editing).
|
| 227 |
+
|
| 228 |
+
Each group renders a header with a live count badge and a grid of
|
| 229 |
+
cards; the count and visibility are kept in sync client-side as the
|
| 230 |
+
search/filter change. A hidden empty-state shows when nothing
|
| 231 |
+
matches.
|
| 232 |
+
"""
|
| 233 |
+
out = ['<div id="groups">']
|
| 234 |
+
for key, label, cls in _GROUPS:
|
| 235 |
+
items = [
|
| 236 |
+
(i, t)
|
| 237 |
+
for i, t in enumerate(tasks)
|
| 238 |
+
if (t["task_type"] == "editing") == (key == "editing")
|
| 239 |
+
]
|
| 240 |
+
if not items:
|
| 241 |
+
continue
|
| 242 |
+
cards = "".join(_render_card(t, i, asset_url) for i, t in items)
|
| 243 |
+
out.append(
|
| 244 |
+
f'<section class="group" data-group="{key}">'
|
| 245 |
+
f'<div class="group-head {cls}"><span class="glabel">{label}</span> '
|
| 246 |
+
f'<span class="gcount" data-count-for="{key}">{len(items)}</span></div>'
|
| 247 |
+
f'<div class="grid">{cards}</div>'
|
| 248 |
+
"</section>"
|
| 249 |
)
|
| 250 |
+
out.append(
|
| 251 |
+
'<div class="empty" id="empty-state" style="display:none">'
|
| 252 |
+
"No tasks match your search.</div>"
|
| 253 |
+
)
|
| 254 |
+
out.append("</div>")
|
| 255 |
+
return "\n".join(out)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _render_controls() -> str:
|
| 259 |
+
"""Search box + All/Generation/Editing segmented filter."""
|
| 260 |
+
return (
|
| 261 |
+
'<div class="controls">'
|
| 262 |
+
'<div class="search"><span class="mag">⌕</span>'
|
| 263 |
+
'<input type="text" id="search" placeholder="Search tasks by number\u2026" '
|
| 264 |
+
'autocomplete="off"></div>'
|
| 265 |
+
'<div class="seg" id="typeSeg">'
|
| 266 |
+
'<button type="button" class="on" data-type="all">All</button>'
|
| 267 |
+
'<button type="button" data-type="generation">Generation</button>'
|
| 268 |
+
'<button type="button" data-type="editing">Editing</button>'
|
| 269 |
+
"</div>"
|
| 270 |
+
'<span class="count-note" id="countNote"></span>'
|
| 271 |
+
"</div>"
|
| 272 |
+
)
|
| 273 |
|
| 274 |
|
| 275 |
def _render_header(tasks: list[dict]) -> str:
|
|
|
|
| 277 |
n_gen = sum(1 for t in tasks if t["task_type"] != "editing")
|
| 278 |
n_edit = n - n_gen
|
| 279 |
return (
|
| 280 |
+
'<div class="summary">'
|
| 281 |
+
f'<span class="big">{n} tasks</span>'
|
| 282 |
+
'<span class="stat"><span class="swatch gen"></span>'
|
| 283 |
+
f"generation: <b>{n_gen}</b></span>"
|
| 284 |
+
'<span class="stat"><span class="swatch edit"></span>'
|
| 285 |
+
f"editing: <b>{n_edit}</b></span>"
|
| 286 |
+
'<span class="stat hint">Click a card to open the task.</span>'
|
| 287 |
"</div>"
|
| 288 |
)
|
| 289 |
|
|
|
|
| 292 |
"""Build the full standalone task-browser HTML document.
|
| 293 |
|
| 294 |
``asset_url(fixture, relpath)`` supplies the input image URLs (see
|
| 295 |
+
module docstring). The summary view is a grouped thumbnail grid
|
| 296 |
+
(Generation / Editing) with search + type filter; clicking a card
|
| 297 |
+
opens that task's detail card (prompt + input), reusing the report's
|
| 298 |
+
detail-card navigation (``j``/``k`` / arrow keys, ``Esc`` to return),
|
| 299 |
+
minus scores and ground truth.
|
| 300 |
"""
|
| 301 |
fixture_names_js = json.dumps([t["name"] for t in tasks])
|
| 302 |
p = [
|
|
|
|
| 313 |
p.append(_render_header(tasks))
|
| 314 |
p.append("</div>")
|
| 315 |
|
| 316 |
+
# Summary view: grouped thumbnail grid + search/type controls.
|
| 317 |
p.append('<div id="summary-view">')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
if tasks:
|
| 319 |
+
p.append(_render_controls())
|
| 320 |
+
p.append(_render_grid(tasks, asset_url))
|
| 321 |
else:
|
| 322 |
p.append(
|
| 323 |
'<p class="note">No tasks found in the sample inputs dataset.</p>'
|
|
|
|
| 353 |
# ---------------------------------------------------------------------------
|
| 354 |
|
| 355 |
_CSS = """\
|
| 356 |
+
:root {
|
| 357 |
+
--bg: #f8f9fa; --panel: #ffffff; --ink: #14161c; --ink-soft: #5b6170;
|
| 358 |
+
--ink-faint: #9aa0ad; --line: #e3e5ea; --line-strong: #d2d5dd;
|
| 359 |
+
--accent: #4338ca; --accent-soft: #eef0ff;
|
| 360 |
+
--gen: #1565c0; --gen-soft: #e3f2fd; --edit: #6a1b9a; --edit-soft: #f3e5f5;
|
| 361 |
+
--thumb-bg: #eceef2;
|
| 362 |
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
| 363 |
+
}
|
| 364 |
* { box-sizing: border-box; }
|
| 365 |
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 366 |
+
max-width: 1600px; margin: 0 auto; padding: 20px; background: var(--bg);
|
| 367 |
+
color: var(--ink); -webkit-font-smoothing: antialiased; }
|
| 368 |
h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
|
| 369 |
h2 { margin-top: 0; }
|
| 370 |
.tag { font-size: 0.6em; color: #666; font-weight: normal; font-family: monospace;
|
|
|
|
| 372 |
|
| 373 |
.run-header { background: white; border-radius: 8px; padding: 16px 20px;
|
| 374 |
margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
| 375 |
+
|
| 376 |
+
/* summary strip */
|
| 377 |
+
.summary { display: flex; align-items: baseline; gap: 24px; flex-wrap: wrap;
|
| 378 |
+
margin-top: 10px; font-size: 0.95em; }
|
| 379 |
+
.summary .big { font-size: 1.35em; font-weight: 800; letter-spacing: -.01em; }
|
| 380 |
+
.summary .stat { color: var(--ink-soft); }
|
| 381 |
+
.summary .stat b { color: var(--ink); font-weight: 700; }
|
| 382 |
+
.summary .stat.hint { color: var(--ink-faint); }
|
| 383 |
+
.summary .swatch { display: inline-block; width: 9px; height: 9px; border-radius: 3px;
|
| 384 |
+
margin-right: 6px; }
|
| 385 |
+
.summary .swatch.gen { background: var(--gen); }
|
| 386 |
+
.summary .swatch.edit { background: var(--edit); }
|
| 387 |
+
|
| 388 |
+
/* controls: search + segmented type filter */
|
| 389 |
+
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
| 390 |
+
margin-bottom: 8px; }
|
| 391 |
+
.search { flex: 1; min-width: 240px; position: relative; }
|
| 392 |
+
.search input { width: 100%; padding: 11px 14px 11px 36px; border: 1px solid var(--line-strong);
|
| 393 |
+
border-radius: 11px; font-family: inherit; font-size: 14.5px;
|
| 394 |
+
background: var(--panel); outline: none; }
|
| 395 |
+
.search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
| 396 |
+
.search .mag { position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
| 397 |
+
color: var(--ink-faint); font-size: 15px; }
|
| 398 |
+
.seg { display: flex; gap: 4px; background: var(--panel); border: 1px solid var(--line-strong);
|
| 399 |
+
border-radius: 11px; padding: 4px; }
|
| 400 |
+
.seg button { font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
|
| 401 |
+
border: none; background: none; color: var(--ink-soft); padding: 7px 14px;
|
| 402 |
+
border-radius: 8px; }
|
| 403 |
+
.seg button.on { background: var(--accent); color: #fff; }
|
| 404 |
+
.count-note { font-size: 13px; color: var(--ink-faint); margin-left: auto; }
|
| 405 |
+
|
| 406 |
+
/* group header */
|
| 407 |
+
.group-head { display: flex; align-items: center; gap: 10px; margin: 26px 0 12px;
|
| 408 |
+
font-size: 13px; font-weight: 700; text-transform: uppercase;
|
| 409 |
+
letter-spacing: .05em; }
|
| 410 |
+
.group-head.gen { color: var(--gen); }
|
| 411 |
+
.group-head.edit { color: var(--edit); }
|
| 412 |
+
.group-head .gcount { font-family: var(--mono); font-size: 11px; padding: 3px 9px;
|
| 413 |
+
border-radius: 999px; }
|
| 414 |
+
.group-head.gen .gcount { background: var(--gen-soft); }
|
| 415 |
+
.group-head.edit .gcount { background: var(--edit-soft); }
|
| 416 |
+
|
| 417 |
+
/* responsive card grid */
|
| 418 |
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
| 419 |
+
gap: 16px; }
|
| 420 |
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
| 421 |
+
overflow: hidden; cursor: pointer; padding: 0; font-family: inherit;
|
| 422 |
+
text-align: left; transition: transform .14s ease, box-shadow .14s ease,
|
| 423 |
+
border-color .14s ease; }
|
| 424 |
+
.card:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(20,22,28,.13);
|
| 425 |
+
border-color: var(--accent); }
|
| 426 |
+
.card .thumb { width: 100%; aspect-ratio: 4 / 3; background: var(--thumb-bg); display: block;
|
| 427 |
+
border-bottom: 1px solid var(--line); overflow: hidden; }
|
| 428 |
+
.card .thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
| 429 |
+
.card .meta { padding: 11px 13px; display: flex; align-items: center;
|
| 430 |
+
justify-content: space-between; gap: 8px; }
|
| 431 |
+
.card .sample { font-family: var(--mono); font-weight: 700; font-size: 15px; }
|
| 432 |
+
.card .type { font-family: var(--mono); font-size: 9.5px; font-weight: 700;
|
| 433 |
+
text-transform: uppercase; letter-spacing: .03em; padding: 3px 8px;
|
| 434 |
+
border-radius: 6px; }
|
| 435 |
+
.card .type.generation { color: var(--gen); background: var(--gen-soft); }
|
| 436 |
+
.card .type.editing { color: var(--edit); background: var(--edit-soft); }
|
| 437 |
+
.empty { padding: 50px; text-align: center; color: var(--ink-faint); }
|
| 438 |
|
| 439 |
.nav-bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
| 440 |
background: white; border-radius: 8px; margin-bottom: 16px;
|
|
|
|
| 485 |
_JS = """\
|
| 486 |
let currentIdx = -1;
|
| 487 |
const total = document.querySelectorAll('.fixture-card').length;
|
| 488 |
+
let query = '', typeFilter = 'all';
|
| 489 |
|
| 490 |
function taskImgFail(img) {
|
| 491 |
const view = img.closest('.view');
|
|
|
|
| 493 |
img.style.display = 'none';
|
| 494 |
}
|
| 495 |
|
| 496 |
+
// Live grid filter: the segmented control filters by type, the search
|
| 497 |
+
// box filters by sample number. Group counts + visibility and the
|
| 498 |
+
// empty-state stay in sync; cards stay rendered (so showDetail indices
|
| 499 |
+
// are stable) and are just shown/hidden.
|
| 500 |
+
function applyFilter() {
|
| 501 |
+
const q = query.trim().toLowerCase();
|
| 502 |
+
let shown = 0;
|
| 503 |
+
document.querySelectorAll('#groups .group').forEach(g => {
|
| 504 |
+
const key = g.dataset.group;
|
| 505 |
+
let vis = 0;
|
| 506 |
+
g.querySelectorAll('.card').forEach(c => {
|
| 507 |
+
const ok = (typeFilter === 'all' || c.dataset.type === typeFilter) &&
|
| 508 |
+
(!q || c.dataset.name.toLowerCase().includes(q));
|
| 509 |
+
c.style.display = ok ? '' : 'none';
|
| 510 |
+
if (ok) vis++;
|
| 511 |
+
});
|
| 512 |
+
g.style.display = vis ? '' : 'none';
|
| 513 |
+
const badge = g.querySelector('[data-count-for="' + key + '"]');
|
| 514 |
+
if (badge) badge.textContent = vis;
|
| 515 |
+
shown += vis;
|
| 516 |
+
});
|
| 517 |
+
const empty = document.getElementById('empty-state');
|
| 518 |
+
if (empty) empty.style.display = shown ? 'none' : '';
|
| 519 |
+
const note = document.getElementById('countNote');
|
| 520 |
+
if (note) note.textContent = shown + ' of ' + total + ' shown';
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
(function wireControls() {
|
| 524 |
+
const searchEl = document.getElementById('search');
|
| 525 |
+
if (searchEl) searchEl.addEventListener('input', e => {
|
| 526 |
+
query = e.target.value; applyFilter();
|
| 527 |
+
});
|
| 528 |
+
const seg = document.getElementById('typeSeg');
|
| 529 |
+
if (seg) seg.querySelectorAll('button').forEach(b => {
|
| 530 |
+
b.addEventListener('click', () => {
|
| 531 |
+
seg.querySelectorAll('button').forEach(x => x.classList.remove('on'));
|
| 532 |
+
b.classList.add('on');
|
| 533 |
+
typeFilter = b.dataset.type;
|
| 534 |
+
applyFilter();
|
| 535 |
+
});
|
| 536 |
+
});
|
| 537 |
+
applyFilter();
|
| 538 |
+
})();
|
| 539 |
+
|
| 540 |
function showSummary() {
|
| 541 |
document.getElementById('summary-view').style.display = '';
|
| 542 |
document.getElementById('detail-view').style.display = 'none';
|
tests/test_tasks.py
CHANGED
|
@@ -123,6 +123,17 @@ def test_render_tasks_page_structure_and_urls(tmp_path: Path) -> None:
|
|
| 123 |
assert "showDetail(" in doc
|
| 124 |
assert "window._fixtureNames" in doc
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# Editing fixture references its starting-shape renders; generation
|
| 127 |
# multi-image fixture references both drawings.
|
| 128 |
assert ("201", "renders/iso.png") in calls
|
|
@@ -135,6 +146,48 @@ def test_render_tasks_page_structure_and_urls(tmp_path: Path) -> None:
|
|
| 135 |
assert "CAD Score" not in doc
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def test_render_tasks_page_empty(tmp_path: Path) -> None:
|
| 139 |
doc = render_tasks_page([], lambda f, r: "")
|
| 140 |
assert "No tasks found" in doc
|
|
|
|
| 123 |
assert "showDetail(" in doc
|
| 124 |
assert "window._fixtureNames" in doc
|
| 125 |
|
| 126 |
+
# Grid summary view: grouped sections with live count badges,
|
| 127 |
+
# search + type segmented filter, and a card per task (clicking a
|
| 128 |
+
# card jumps to its detail via showDetail(idx)).
|
| 129 |
+
assert 'class="grid"' in doc
|
| 130 |
+
assert 'data-group="generation"' in doc
|
| 131 |
+
assert 'data-group="editing"' in doc
|
| 132 |
+
assert 'data-count-for="generation"' in doc
|
| 133 |
+
assert 'id="typeSeg"' in doc
|
| 134 |
+
assert 'id="search"' in doc
|
| 135 |
+
assert 'class="card"' in doc
|
| 136 |
+
|
| 137 |
# Editing fixture references its starting-shape renders; generation
|
| 138 |
# multi-image fixture references both drawings.
|
| 139 |
assert ("201", "renders/iso.png") in calls
|
|
|
|
| 146 |
assert "CAD Score" not in doc
|
| 147 |
|
| 148 |
|
| 149 |
+
def test_thumbnails_are_lazy_and_typed(tmp_path: Path) -> None:
|
| 150 |
+
"""Every grid thumbnail lazy-loads with reserved dimensions, and the
|
| 151 |
+
thumbnail source differs by task type: generation uses ``input.png``,
|
| 152 |
+
editing uses the ``renders/iso.png`` render of the input STEP."""
|
| 153 |
+
_write_fixture(
|
| 154 |
+
tmp_path, "108",
|
| 155 |
+
"""
|
| 156 |
+
description: Reproduce the bracket.
|
| 157 |
+
input_files:
|
| 158 |
+
- input.png
|
| 159 |
+
""",
|
| 160 |
+
)
|
| 161 |
+
_write_fixture(
|
| 162 |
+
tmp_path, "204",
|
| 163 |
+
"""
|
| 164 |
+
description: Widen the slot.
|
| 165 |
+
task_type: editing
|
| 166 |
+
input_files:
|
| 167 |
+
- input.step
|
| 168 |
+
""",
|
| 169 |
+
)
|
| 170 |
+
tasks = load_tasks_from_dir(tmp_path)
|
| 171 |
+
|
| 172 |
+
def asset_url(fixture: str, relpath: str) -> str:
|
| 173 |
+
return f"/task-input/{fixture}/{relpath}"
|
| 174 |
+
|
| 175 |
+
doc = render_tasks_page(tasks, asset_url)
|
| 176 |
+
|
| 177 |
+
# Generation card thumbnail -> input.png; editing -> iso render.
|
| 178 |
+
assert (
|
| 179 |
+
'<img loading="lazy" decoding="async" width="180" height="135" '
|
| 180 |
+
'src="/task-input/108/input.png"' in doc
|
| 181 |
+
)
|
| 182 |
+
assert (
|
| 183 |
+
'<img loading="lazy" decoding="async" width="180" height="135" '
|
| 184 |
+
'src="/task-input/204/renders/iso.png"' in doc
|
| 185 |
+
)
|
| 186 |
+
# No thumbnail escapes lazy/async loading.
|
| 187 |
+
assert doc.count('class="card"') == 2
|
| 188 |
+
assert doc.count('loading="lazy" decoding="async"', ) >= 2
|
| 189 |
+
|
| 190 |
+
|
| 191 |
def test_render_tasks_page_empty(tmp_path: Path) -> None:
|
| 192 |
doc = render_tasks_page([], lambda f, r: "")
|
| 193 |
assert "No tasks found" in doc
|