Michael Rabinovich Cursor commited on
Commit
95f3ee8
·
1 Parent(s): c48c18f

leaderboard: rebuild Tasks tab as grouped thumbnail grid

Browse files

Replace 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>

Files changed (2) hide show
  1. tasks.py +242 -41
  2. 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 _render_summary_table(tasks: list[dict]) -> str:
165
- rows = [
166
- '<table class="summary-table" id="summary-table">',
167
- "<thead><tr><th>Sample</th><th>Type</th></tr></thead><tbody>",
168
- ]
169
- for i, t in enumerate(tasks):
170
- rows.append(
171
- f'<tr onclick="showDetail({i})" style="cursor:pointer">'
172
- f'<td>{html.escape(t["name"])}</td>'
173
- f"<td>{_type_pill(t['task_type'])}</td>"
174
- f"</tr>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  )
176
- rows.append("</tbody></table>")
177
- return "\n".join(rows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="run-stats">'
186
- f"<span>{n} tasks</span>"
187
- f"<span>generation: <b>{n_gen}</b></span>"
188
- f"<span>editing: <b>{n_edit}</b></span>"
 
 
 
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 page mirrors the report's summary-table ->
198
- detail-card navigation exactly, minus scores and ground truth.
 
 
 
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(_render_summary_table(tasks))
 
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: #f8f9fa; }
 
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
- .run-stats { margin-top: 8px; font-size: 0.95em; }
272
- .run-stats span { margin-right: 20px; font-weight: 500; }
273
-
274
- .summary-table { width: 100%; border-collapse: collapse; background: white;
275
- border-radius: 8px; overflow: hidden;
276
- box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
277
- .summary-table th { background: #37474f; color: white; padding: 10px 12px;
278
- text-align: left; font-size: 0.85em; text-transform: uppercase;
279
- letter-spacing: 0.05em; }
280
- .summary-table td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 0.9em; }
281
- .summary-table tr:hover { filter: brightness(0.97); background: #f5f5f5; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">&#8981;</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