pixelkiln 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -440,3 +440,29 @@ td.col-on { background:rgba(255,107,53,.08); }
440
440
  .frames-bar .count { font:11.5px/1 var(--mono); color:var(--dim); min-width:44px; }
441
441
  .frames-bar select { font:11.5px/1 var(--mono); padding:5px 6px; background:var(--panel-deep); border:1px solid var(--line); }
442
442
  .frames-bar .chip { font-size:12px; color:var(--dim); display:inline-flex; align-items:center; gap:4px; }
443
+
444
+ /* ---- families on the grid, selection ---- */
445
+ .fam-group { margin:0 0 18px; }
446
+ .fam-group + .fam-group { border-top:1px dashed var(--line); padding-top:14px; }
447
+ .fam-head { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0 0 10px; }
448
+ .fam-head > b { font:650 13px/1.3 var(--mono); }
449
+ .fam-tally { display:inline-flex; flex-wrap:wrap; gap:10px; color:var(--dim); font-size:12px; }
450
+ .fam-tally .st { display:inline-flex; align-items:center; gap:5px; font-family:var(--mono); font-size:11.5px; }
451
+ .fam-head .tools { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
452
+ .fam-head .tools button { font-size:12.5px; padding:5px 10px; }
453
+ #select.on { border-color:var(--accent); color:var(--accent-soft); }
454
+ .card .sel { position:absolute; top:6px; left:6px; width:20px; height:20px; display:grid; place-items:center;
455
+ font:700 12px/1 var(--mono); color:transparent; background:rgba(16,15,12,.75); border:1px solid var(--line-strong); }
456
+ .card.selected .sel { color:var(--bg); background:var(--accent); border-color:var(--accent); }
457
+ .card.selected { outline:2px solid var(--accent); outline-offset:-2px; }
458
+ .selbar { position:sticky; bottom:0; z-index:12; display:flex; flex-wrap:wrap; align-items:center; gap:10px 14px;
459
+ padding:10px 22px; background:var(--panel); border-top:1px solid var(--line-strong); box-shadow:0 -6px 18px rgba(0,0,0,.35); }
460
+ .selbar[hidden] { display:none; }
461
+ .selbar > b { font:650 12.5px/1 var(--mono); }
462
+ .selbar .acts { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
463
+ .selbar .acts button { font-size:12.5px; padding:5px 10px; }
464
+ .selbar .tagbox { display:inline-flex; gap:4px; }
465
+ .selbar .tagbox input { width:120px; padding:5px 8px; background:var(--panel-deep); border:1px solid var(--line); }
466
+ .selbar .msg { font-size:12px; color:var(--dim); }
467
+ .selbar .msg.bad { color:var(--bad); } .selbar .msg.ok { color:var(--ok); }
468
+ @media (max-width: 640px) { .selbar { padding:10px 12px; } }
@@ -44,7 +44,9 @@
44
44
  backdrop: "checker",
45
45
  notify: false,
46
46
  fps: null,
47
- onion: false
47
+ onion: false,
48
+ selecting: false,
49
+ selected: /* @__PURE__ */ new Set()
48
50
  };
49
51
  var PREFS_KEY = "pixelkiln.gallery.prefs";
50
52
  var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion"];
@@ -1012,6 +1014,180 @@
1012
1014
  if (!document.hidden) wake();
1013
1015
  });
1014
1016
 
1017
+ // src/gallery/client/src/selection.ts
1018
+ var selectedItems = () => S.snap.items.filter((i) => ui.selected.has(i.id));
1019
+ var regenerable = (i) => i.declared && i.currentSpecHash !== null && i.state === "ok";
1020
+ function setSelecting(on) {
1021
+ ui.selecting = on;
1022
+ if (!on) ui.selected.clear();
1023
+ $("select").textContent = on ? "Done selecting" : "Select";
1024
+ $("select").classList.toggle("on", on);
1025
+ render();
1026
+ }
1027
+ function toggleSelected(id) {
1028
+ if (!ui.selecting) {
1029
+ ui.selecting = true;
1030
+ $("select").textContent = "Done selecting";
1031
+ $("select").classList.add("on");
1032
+ }
1033
+ if (ui.selected.has(id)) ui.selected.delete(id);
1034
+ else ui.selected.add(id);
1035
+ const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
1036
+ if (c) {
1037
+ c.classList.toggle("selected", ui.selected.has(id));
1038
+ c.setAttribute("aria-pressed", String(ui.selected.has(id)));
1039
+ }
1040
+ if (!document.querySelector(".card .sel")) render();
1041
+ else {
1042
+ refreshSelectAll();
1043
+ renderSelectionBar();
1044
+ }
1045
+ }
1046
+ function selectAllButton(items) {
1047
+ const b = el("button", "select-all");
1048
+ b.type = "button";
1049
+ const ids = items.map((i) => i.id);
1050
+ b.__ids = ids;
1051
+ const label = () => {
1052
+ b.textContent = ids.every((id) => ui.selected.has(id)) ? "Clear " + ids.length : "Select all " + ids.length;
1053
+ };
1054
+ b.__label = label;
1055
+ label();
1056
+ b.onclick = () => {
1057
+ const all = ids.every((id) => ui.selected.has(id));
1058
+ for (const id of ids) {
1059
+ if (all) ui.selected.delete(id);
1060
+ else ui.selected.add(id);
1061
+ }
1062
+ for (const id of ids) {
1063
+ const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
1064
+ if (c) {
1065
+ c.classList.toggle("selected", ui.selected.has(id));
1066
+ c.setAttribute("aria-pressed", String(ui.selected.has(id)));
1067
+ }
1068
+ }
1069
+ refreshSelectAll();
1070
+ renderSelectionBar();
1071
+ };
1072
+ return b;
1073
+ }
1074
+ function refreshSelectAll() {
1075
+ for (const b of document.querySelectorAll(".select-all")) b.__label?.();
1076
+ }
1077
+ function renderSelectionBar() {
1078
+ const bar = $("selbar");
1079
+ bar.textContent = "";
1080
+ for (const id of [...ui.selected]) if (!S.snap.items.some((i) => i.id === id)) ui.selected.delete(id);
1081
+ bar.hidden = !ui.selecting;
1082
+ if (!ui.selecting) return;
1083
+ const items = selectedItems();
1084
+ const projects = new Set(items.map((i) => i.project));
1085
+ const oneProject = projects.size <= 1;
1086
+ const project = items.length ? items[0].project : null;
1087
+ bar.append(el("b", null, items.length ? items.length + " selected" : "Click cards to select them"));
1088
+ const acts = el("div", "acts");
1089
+ const add = (label, run, opts = {}) => {
1090
+ const b = el("button", opts.primary ? "primary" : null, label);
1091
+ b.type = "button";
1092
+ b.disabled = Boolean(opts.disabled);
1093
+ if (opts.title) b.title = opts.title;
1094
+ b.onclick = run;
1095
+ acts.append(b);
1096
+ return b;
1097
+ };
1098
+ const crossProject = "Select within one project: a job and an edit each belong to one";
1099
+ if (GENERATION) {
1100
+ const todo = items.filter(actionable);
1101
+ if (todo.length) add("Generate " + todo.length + " · " + priceOf(todo), () => generateDialog(todo, { project }), { primary: true, disabled: !oneProject, title: oneProject ? "One job: parents first, then everything drawn from them" : crossProject });
1102
+ const redo = items.filter(regenerable);
1103
+ if (redo.length) add("Regenerate " + redo.length + " · " + priceOf(redo), () => generateDialog(redo, { project, force: true }), { disabled: !oneProject, title: oneProject ? "Replace these with new generations; each old one stays in its history" : crossProject });
1104
+ }
1105
+ const shown = items.filter((i) => i.outputs.some((o) => o.url));
1106
+ if (shown.length >= 2) {
1107
+ add("Compare" + (shown.length > 4 ? " first 4" : ""), () => {
1108
+ ui.compare = shown.slice(0, 4).map((i) => i.id);
1109
+ openCompare();
1110
+ });
1111
+ }
1112
+ if (EDITABLE && items.length) {
1113
+ const taggable = items.filter((i) => i.declaredAs && projectOf(i)?.manifestSha256);
1114
+ const tag = el("input");
1115
+ tag.type = "text";
1116
+ tag.placeholder = "tag";
1117
+ tag.setAttribute("aria-label", "Tag to add or remove");
1118
+ tag.pattern = "[^,]+";
1119
+ const addTag = el("button", null, "Add tag");
1120
+ addTag.type = "button";
1121
+ const dropTag = el("button", null, "Remove tag");
1122
+ dropTag.type = "button";
1123
+ addTag.disabled = dropTag.disabled = !taggable.length || !oneProject;
1124
+ addTag.title = dropTag.title = !oneProject ? crossProject : taggable.length < items.length ? "Undeclared records have no manifest entry to tag" : "One manifest write for every selected asset";
1125
+ const run = (adding) => retag(taggable, tag.value.trim(), adding, tag);
1126
+ addTag.onclick = () => run(true);
1127
+ dropTag.onclick = () => run(false);
1128
+ tag.onkeydown = (e) => {
1129
+ if (e.key === "Enter") {
1130
+ e.preventDefault();
1131
+ run(true);
1132
+ }
1133
+ };
1134
+ const box = el("span", "tagbox");
1135
+ box.append(tag, addTag, dropTag);
1136
+ acts.append(box);
1137
+ }
1138
+ add("Clear", () => {
1139
+ ui.selected.clear();
1140
+ render();
1141
+ }, { disabled: !items.length });
1142
+ add("Done", () => setSelecting(false));
1143
+ bar.append(acts);
1144
+ const msg = el("span", "msg");
1145
+ msg.id = "selmsg";
1146
+ bar.append(msg);
1147
+ }
1148
+ async function retag(items, tag, adding, input) {
1149
+ const msg = $("selmsg");
1150
+ if (!tag || tag.includes(",")) {
1151
+ msg.className = "msg bad";
1152
+ msg.textContent = "Type one tag (no commas).";
1153
+ input.focus();
1154
+ return;
1155
+ }
1156
+ const byId = /* @__PURE__ */ new Map();
1157
+ for (const i of items) {
1158
+ if (byId.has(i.declaredAs)) continue;
1159
+ const now = i.asset && i.asset.tags || [];
1160
+ const next = adding ? now.includes(tag) ? now : [...now, tag] : now.filter((t) => t !== tag);
1161
+ if (next.length !== now.length) byId.set(i.declaredAs, next);
1162
+ }
1163
+ if (!byId.size) {
1164
+ msg.className = "msg";
1165
+ msg.textContent = adding ? "They all have it already." : "None of them has it.";
1166
+ return;
1167
+ }
1168
+ const pr = projectOf(items[0]);
1169
+ const body = {
1170
+ action: "batch",
1171
+ expectedSha256: pr.manifestSha256,
1172
+ edits: [...byId].map(([assetId, tags]) => ({ action: "patch-asset", assetId, patch: { tags } }))
1173
+ };
1174
+ if (items[0].project) body.project = items[0].project;
1175
+ msg.className = "msg";
1176
+ msg.textContent = "saving…";
1177
+ try {
1178
+ S.snap = await postEdit(body);
1179
+ render();
1180
+ const after = $("selmsg");
1181
+ if (after) {
1182
+ after.className = "msg ok";
1183
+ after.textContent = (adding ? "Tagged " : "Untagged ") + byId.size + (byId.size === 1 ? " asset" : " assets") + " “" + tag + "”.";
1184
+ }
1185
+ } catch (err) {
1186
+ msg.className = "msg bad";
1187
+ msg.textContent = err.status === 409 ? "The manifest changed on disk; refresh and try again." : err.message;
1188
+ }
1189
+ }
1190
+
1015
1191
  // src/gallery/client/src/grid.ts
1016
1192
  function visibleItems() {
1017
1193
  const q = ui.q.trim().toLowerCase();
@@ -1187,7 +1363,9 @@
1187
1363
  }
1188
1364
  function card(item) {
1189
1365
  const slot = ui.compare.indexOf(item.id);
1190
- const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : ""));
1366
+ const picked = ui.selected.has(item.id);
1367
+ const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : "") + (picked ? " selected" : ""));
1368
+ if (ui.selecting) c.setAttribute("aria-pressed", String(picked));
1191
1369
  c.type = "button";
1192
1370
  c.dataset.key = item.id;
1193
1371
  c.setAttribute("aria-label", item.id + ", " + item.state + (slot >= 0 ? ", in comparison" : ""));
@@ -1216,8 +1394,10 @@
1216
1394
  cell.append(badge);
1217
1395
  }
1218
1396
  c.append(cell, body);
1397
+ if (ui.selecting) cell.append(el("span", "sel", "✓"));
1219
1398
  c.onclick = (e) => {
1220
1399
  if (e.shiftKey) toggleCompare(item.id);
1400
+ else if (ui.selecting || e.metaKey || e.ctrlKey) toggleSelected(item.id);
1221
1401
  else openItem(item.id);
1222
1402
  };
1223
1403
  const still = cell.querySelector(":scope > img");
@@ -1253,7 +1433,7 @@
1253
1433
  const sections = [];
1254
1434
  for (const pr of projects) {
1255
1435
  const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
1256
- const styleSections = ui.group === "style" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
1436
+ const styleSections = ui.group === "style" || ui.group === "family" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
1257
1437
  if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
1258
1438
  sections.push(...styleSections);
1259
1439
  }
@@ -1310,6 +1490,7 @@
1310
1490
  r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
1311
1491
  tools.append(r);
1312
1492
  }
1493
+ if (ui.selecting && ui.group !== "family") tools.append(selectAllButton(sec.items));
1313
1494
  const styleKey = "style:" + (s.project || "") + ":" + s.id;
1314
1495
  if (EDITABLE && s.outDir) {
1315
1496
  const es = el("button", null, ui.editing === styleKey ? "Cancel" : "Edit style");
@@ -1334,9 +1515,12 @@
1334
1515
  if (ui.editing === styleKey) wrap.append(styleForm(s));
1335
1516
  if (ui.editing === addKey) wrap.append(addAssetForm(s));
1336
1517
  }
1337
- const grid = el("div", "grid");
1338
- for (const item of sec.items) grid.append(card(item));
1339
- wrap.append(grid);
1518
+ if (ui.group === "family") wrap.append(...familyGroups(sec.items));
1519
+ else {
1520
+ const grid = el("div", "grid");
1521
+ for (const item of sec.items) grid.append(card(item));
1522
+ wrap.append(grid);
1523
+ }
1340
1524
  root.append(wrap);
1341
1525
  }
1342
1526
  if (hidden) {
@@ -1352,6 +1536,93 @@
1352
1536
  root.append(more);
1353
1537
  }
1354
1538
  }
1539
+ var KIND_ORDER = { base: 0, state: 1, animation: 2, portrait: 3, outfit: 4 };
1540
+ var TURN = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
1541
+ function familyOrder(a, b) {
1542
+ const ka = KIND_ORDER[a.character.kind] ?? 9, kb = KIND_ORDER[b.character.kind] ?? 9;
1543
+ if (ka !== kb) return ka - kb;
1544
+ if (a.character.kind === "animation") {
1545
+ const la = loopName(a), lb = loopName(b);
1546
+ if (la !== lb) return la < lb ? -1 : 1;
1547
+ return TURN.indexOf(a.character.direction) - TURN.indexOf(b.character.direction);
1548
+ }
1549
+ return a.assetId < b.assetId ? -1 : a.assetId > b.assetId ? 1 : 0;
1550
+ }
1551
+ function familyGroups(items) {
1552
+ const families = /* @__PURE__ */ new Map();
1553
+ const loose = [];
1554
+ for (const item of items) {
1555
+ const root = item.character ? familyRoot(item) : null;
1556
+ if (!root) {
1557
+ loose.push(item);
1558
+ continue;
1559
+ }
1560
+ if (!families.has(root.id)) families.set(root.id, { root, members: [] });
1561
+ families.get(root.id).members.push(item);
1562
+ }
1563
+ const out = [];
1564
+ for (const { root, members } of families.values()) {
1565
+ members.sort(familyOrder);
1566
+ const group = el("div", "fam-group");
1567
+ const head = el("div", "fam-head");
1568
+ head.append(el("b", "mono", root.assetId));
1569
+ const counts = {};
1570
+ for (const m of members) counts[m.state] = (counts[m.state] || 0) + 1;
1571
+ const tally = el("span", "fam-tally");
1572
+ const loops2 = new Set(members.filter((m) => m.character.kind === "animation").map(loopName)).size;
1573
+ tally.append(document.createTextNode(members.length + (members.length === 1 ? " asset" : " assets") + (loops2 ? " · " + loops2 + (loops2 === 1 ? " loop" : " loops") : "")));
1574
+ for (const [state, n] of Object.entries(counts)) {
1575
+ const t = el("span", "st");
1576
+ t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
1577
+ tally.append(t);
1578
+ }
1579
+ head.append(tally);
1580
+ const tools = el("div", "tools");
1581
+ if (ui.selecting) tools.append(selectAllButton(members));
1582
+ const view = el("button", null, "Family view");
1583
+ view.type = "button";
1584
+ view.onclick = () => openFamily(root);
1585
+ tools.append(view);
1586
+ const everything = familyMembers(root);
1587
+ const todo = everything.filter(actionable);
1588
+ if (GENERATION && todo.length) {
1589
+ const g = el("button", "primary", "Generate " + todo.length + " · " + priceOf(todo));
1590
+ g.type = "button";
1591
+ g.title = "One job for the family: parents first, then everything drawn from them";
1592
+ g.onclick = () => generateDialog(todo, { project: root.project });
1593
+ tools.append(g);
1594
+ }
1595
+ head.append(tools);
1596
+ const grid = el("div", "grid");
1597
+ for (const m of members) grid.append(card(m));
1598
+ group.append(head, grid);
1599
+ out.push(group);
1600
+ }
1601
+ if (loose.length) {
1602
+ const group = el("div", "fam-group loose");
1603
+ if (families.size) {
1604
+ const head = el("div", "fam-head");
1605
+ head.append(el("b", null, "Not in a family"), el("span", "fam-tally", loose.length + (loose.length === 1 ? " asset" : " assets")));
1606
+ if (ui.selecting) {
1607
+ const tools = el("div", "tools");
1608
+ tools.append(selectAllButton(loose));
1609
+ head.append(tools);
1610
+ }
1611
+ group.append(head);
1612
+ }
1613
+ const grid = el("div", "grid");
1614
+ for (const item of loose) grid.append(card(item));
1615
+ group.append(grid);
1616
+ out.push(group);
1617
+ }
1618
+ return out;
1619
+ }
1620
+ var actionable = (item) => item.declared && item.currentSpecHash !== null && ["missing", "stale", "failed"].includes(item.state);
1621
+ function priceOf(items) {
1622
+ const totals = {};
1623
+ for (const i of items) if (i.estimatedCost !== null) totals[i.costUnit] = (totals[i.costUnit] || 0) + i.estimatedCost;
1624
+ return Object.entries(totals).map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(" + ") || "free";
1625
+ }
1355
1626
  function projectHeader(pr, count) {
1356
1627
  const head = el("section", "project");
1357
1628
  const line = el("div", "phead");
@@ -3978,7 +4249,7 @@
3978
4249
  ui.projects = new Set(list("project"));
3979
4250
  ui.compare = list("compare").slice(0, 4);
3980
4251
  if (["key", "newest", "oldest", "cost", "size"].includes(params.get("sort") ?? "")) ui.sort = params.get("sort");
3981
- if (["style", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
4252
+ if (["style", "family", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
3982
4253
  }
3983
4254
  function writeUrlState() {
3984
4255
  const params = new URLSearchParams();
@@ -4051,6 +4322,7 @@
4051
4322
  renderMain(visibleItems());
4052
4323
  renderDrawer();
4053
4324
  renderTray();
4325
+ renderSelectionBar();
4054
4326
  refreshFamily();
4055
4327
  }
4056
4328
  function renderQuietly() {
@@ -4060,6 +4332,7 @@
4060
4332
  if (region !== "main") renderMain(visibleItems());
4061
4333
  if (region !== "drawer") renderDrawer();
4062
4334
  renderTray();
4335
+ renderSelectionBar();
4063
4336
  refreshFamily();
4064
4337
  }
4065
4338
 
@@ -4548,6 +4821,7 @@
4548
4821
  // src/gallery/client/src/main.ts
4549
4822
  loadPrefs();
4550
4823
  $("refresh").onclick = refresh;
4824
+ $("select").onclick = () => setSelecting(!ui.selecting);
4551
4825
  $("playloops").checked = ui.playLoops;
4552
4826
  $("playloops").onchange = (e) => {
4553
4827
  setPlayLoops(e.target.checked);
@@ -4610,6 +4884,11 @@
4610
4884
  if (ui.open) {
4611
4885
  e.preventDefault();
4612
4886
  closeItem();
4887
+ return;
4888
+ }
4889
+ if (ui.selecting) {
4890
+ e.preventDefault();
4891
+ setSelecting(false);
4613
4892
  }
4614
4893
  return;
4615
4894
  }
package/dist/index.cjs CHANGED
@@ -16660,6 +16660,7 @@ async function buildGallerySnapshot(opts) {
16660
16660
  styleId: spec.styleId,
16661
16661
  assetId: spec.assetId,
16662
16662
  declared: true,
16663
+ declaredAs: loopShorthandOf(spec.assetId, loaded.loopFamilies) ?? spec.assetId,
16663
16664
  state: planItem.state,
16664
16665
  reason: planItem.reason,
16665
16666
  status: entry?.status ?? null,
@@ -16729,6 +16730,7 @@ async function buildGallerySnapshot(opts) {
16729
16730
  styleId: entry.styleId,
16730
16731
  assetId: entry.assetId,
16731
16732
  declared: false,
16733
+ declaredAs: null,
16732
16734
  state: "undeclared",
16733
16735
  reason: "not declared by the current manifest; `pixelkiln prune` removes the entry",
16734
16736
  status: entry.status,
@@ -17058,9 +17060,11 @@ ${css}</style>
17058
17060
  </select>
17059
17061
  <select id="group" aria-label="Group">
17060
17062
  <option value="style">Group: by style</option>
17063
+ <option value="family">Group: by family</option>
17061
17064
  <option value="none">Group: none</option>
17062
17065
  </select>
17063
17066
  <span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
17067
+ <button id="select" type="button" title="Pick several records to generate, regenerate, compare, or tag together (or ctrl/\u2318-click a card)">Select</button>
17064
17068
  <button id="studio" type="button" hidden title="Draft a new character: its style, base, loops, and portrait, priced as you go">+ New character</button>
17065
17069
  <button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
17066
17070
  <label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
@@ -17074,6 +17078,7 @@ ${css}</style>
17074
17078
  <div id="tools"></div>
17075
17079
  </header>
17076
17080
  <main id="root"></main>
17081
+ <div id="selbar" class="selbar" hidden></div>
17077
17082
  <footer>
17078
17083
  Click a sprite for its full record; <kbd>shift</kbd>-click adds it to a side-by-side comparison. <kbd>\u2190</kbd>/<kbd>\u2192</kbd> step through the visible set while a record
17079
17084
  is open, <kbd>,</kbd>/<kbd>.</kbd> step a loop's frames and <kbd>space</kbd> plays it, <kbd>Esc</kbd> closes it, and <kbd>/</kbd> jumps to search. This page reads the manifest, lockfile,