pixelkiln 0.77.0 → 0.79.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,38 @@ 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; } }
469
+ .lg-add { width:76px; height:76px; padding:0; font:400 22px/1 var(--mono); color:var(--dim); border:1px dashed var(--line-strong); background:transparent; }
470
+ .lg-add:hover, .lg-add.on { color:var(--accent-soft); border-color:var(--accent); }
471
+ .gap-panel { margin-top:10px; border:1px solid var(--accent); padding:12px; display:grid; gap:8px; max-width:720px; }
472
+ .gap-panel h4 { margin:0; font:650 12.5px/1.2 var(--mono); color:var(--accent-soft); }
473
+ .gap-options { display:grid; gap:4px; }
474
+ .gap-options .field.check { display:flex; align-items:baseline; gap:6px; font-size:12.5px; }
475
+ .gap-price { font:11.5px/1.45 var(--mono); }
476
+ .gap-panel .actions { display:flex; gap:6px; flex-wrap:wrap; }
477
+ @media (max-width: 640px) { .lg-add { width:56px; height:56px; } }
@@ -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");
@@ -2374,7 +2645,8 @@
2374
2645
  panel: null,
2375
2646
  anims: [],
2376
2647
  columns: /* @__PURE__ */ new Map(),
2377
- turn: null
2648
+ turn: null,
2649
+ gap: null
2378
2650
  };
2379
2651
  renderFamily();
2380
2652
  }
@@ -2667,7 +2939,20 @@
2667
2939
  const td = el("td");
2668
2940
  column(d, td);
2669
2941
  const item = cells.find((l) => l.character.direction === d);
2670
- td.append(item ? loopCell(item, parent, d) : el("span", "lg-none", "·"));
2942
+ if (item) td.append(loopCell(item, parent, d));
2943
+ else if (fillable(root, parent, cells, d)) {
2944
+ const open = F.gap && F.gap.row === k && F.gap.direction === d;
2945
+ const add = el("button", "lg-add" + (open ? " on" : ""), "+");
2946
+ add.type = "button";
2947
+ add.title = "Add " + name + " facing " + d;
2948
+ add.setAttribute("aria-label", add.title);
2949
+ add.onclick = () => {
2950
+ F.gap = open ? null : { row: k, direction: d };
2951
+ F.drawn = null;
2952
+ renderFamily();
2953
+ };
2954
+ td.append(add);
2955
+ } else td.append(el("span", "lg-none", "·"));
2671
2956
  tr.append(td);
2672
2957
  }
2673
2958
  tbody.append(tr);
@@ -2676,9 +2961,167 @@
2676
2961
  const scroller = el("div", "lg-scroll");
2677
2962
  scroller.append(table);
2678
2963
  wrap.append(scroller);
2964
+ const gap = F.gap && rows.get(F.gap.row);
2965
+ if (gap) {
2966
+ wrap.append(gapPanel(root, gap, F.gap.direction));
2967
+ } else F.gap = null;
2679
2968
  wrap.append(el("small", "state-dim", "Every loop plays on one clock. ⇋ marks a mirror, flipped locally at no cost. A dimmed cell is the parent's rotation, standing in until that loop is generated."));
2680
2969
  return wrap;
2681
2970
  }
2971
+ var MIRRORED = {
2972
+ south: "south",
2973
+ north: "north",
2974
+ east: "west",
2975
+ west: "east",
2976
+ "south-east": "south-west",
2977
+ "south-west": "south-east",
2978
+ "north-east": "north-west",
2979
+ "north-west": "north-east"
2980
+ };
2981
+ var ANIMATION_FIELDS = ["of", "template", "frames", "fps", "mode", "subject", "enhancePrompt"];
2982
+ function fillable(root, parent, cells, direction) {
2983
+ if (!EDITABLE || !projectOf(root)?.manifestSha256) return false;
2984
+ if (!turnOrder(parent).includes(direction)) return false;
2985
+ return cells.some((l) => !l.mirrorOfKey && l.declaredAs && l.asset);
2986
+ }
2987
+ function gapOptions(cells, direction) {
2988
+ const drawn = cells.filter((l) => !l.mirrorOfKey);
2989
+ const sample = drawn[0];
2990
+ const name = loopName(sample);
2991
+ const shorthand = sample.declaredAs !== sample.assetId ? sample.declaredAs : null;
2992
+ const flip = MIRRORED[direction];
2993
+ const options = [];
2994
+ if (shorthand) {
2995
+ const now = drawn.map((l) => l.character.direction);
2996
+ const next = COMPASS.filter((d) => d === direction || now.includes(d));
2997
+ const freeFlip = flip !== direction && !cells.some((l) => l.character.direction === flip);
2998
+ options.push({
2999
+ id: "draw",
3000
+ label: "Draw it",
3001
+ note: "adds " + direction + " to " + shorthand + "'s directions" + (freeFlip ? "; " + flip + " comes with it as a free mirror" : ""),
3002
+ edit: { action: "patch-asset", assetId: shorthand, patch: { loopDirections: next } }
3003
+ });
3004
+ return options;
3005
+ }
3006
+ const newId = name + "." + direction;
3007
+ const flipped = drawn.find((l) => l.character.direction === flip && flip !== direction);
3008
+ if (flipped) {
3009
+ const mirror = { mirror: flipped.declaredAs };
3010
+ if (flipped.asset?.styles) mirror.styles = flipped.asset.styles;
3011
+ options.push({ id: "mirror", label: "Mirror " + flip, note: "flips " + flipped.assetId + " locally; free", edit: { action: "add-asset", assetId: newId, asset: mirror } });
3012
+ }
3013
+ const base = sample.asset || {};
3014
+ const animation = { direction };
3015
+ for (const key of ANIMATION_FIELDS) if (base.animation && base.animation[key] !== void 0) animation[key] = base.animation[key];
3016
+ const asset = { animation };
3017
+ for (const key of ["prompt", "styles", "tags", "category"]) if (base[key] !== void 0) asset[key] = base[key];
3018
+ options.push({ id: "draw", label: "Draw it", note: "a copy of " + sample.assetId + " facing " + direction, edit: { action: "add-asset", assetId: newId, asset } });
3019
+ return options;
3020
+ }
3021
+ function gapPanel(root, cells, direction) {
3022
+ const panel = el("div", "gap-panel");
3023
+ const name = loopName(cells[0]);
3024
+ panel.append(el("h4", null, "Add " + (name.startsWith(root.assetId + ".") ? name.slice(root.assetId.length + 1) : name) + " facing " + direction));
3025
+ const options = gapOptions(cells, direction);
3026
+ let chosen = options[0];
3027
+ const list = el("div", "gap-options");
3028
+ const priceLine = el("div", "gap-price state-dim", "pricing…");
3029
+ const msg = el("div", "msg");
3030
+ for (const option of options) {
3031
+ const label = el("label", "field check");
3032
+ const radio = el("input");
3033
+ radio.type = "radio";
3034
+ radio.name = "gap-option";
3035
+ radio.checked = option === chosen;
3036
+ radio.onchange = () => {
3037
+ chosen = option;
3038
+ price();
3039
+ };
3040
+ label.append(radio, el("b", null, " " + option.label), el("span", "state-dim", " — " + option.note));
3041
+ list.append(label);
3042
+ }
3043
+ const actions = el("div", "actions");
3044
+ const add = el("button", null, "Add");
3045
+ add.type = "button";
3046
+ const addGen = el("button", "primary", "Add & generate");
3047
+ addGen.type = "button";
3048
+ const cancel = el("button", null, "Cancel");
3049
+ cancel.type = "button";
3050
+ cancel.onclick = () => {
3051
+ F.gap = null;
3052
+ F.drawn = null;
3053
+ renderFamily();
3054
+ };
3055
+ actions.append(add);
3056
+ if (GENERATION) actions.append(addGen);
3057
+ actions.append(cancel);
3058
+ panel.append(list, priceLine, actions, msg);
3059
+ const pr = projectOf(root);
3060
+ const request = () => {
3061
+ const body = { ...chosen.edit, expectedSha256: pr.manifestSha256 };
3062
+ if (root.project) body.project = root.project;
3063
+ return body;
3064
+ };
3065
+ let quote = null;
3066
+ let seq = 0;
3067
+ async function price() {
3068
+ const mine = ++seq;
3069
+ quote = null;
3070
+ priceLine.textContent = "pricing…";
3071
+ add.disabled = addGen.disabled = true;
3072
+ try {
3073
+ const res = await fetch("/api/price", {
3074
+ method: "POST",
3075
+ headers: { "Content-Type": "application/json", "X-Pixelkiln-Session": SESSION ?? "" },
3076
+ body: JSON.stringify(request())
3077
+ });
3078
+ if (mine !== seq) return;
3079
+ if (!res.ok) throw new Error(await res.text());
3080
+ quote = await res.json();
3081
+ const rows = quote.items.map((i) => i.key.split("/").slice(1).join("/") + " " + (i.cost ? fmtCost(i.costUnit, i.cost) : "free"));
3082
+ const totals = Object.entries(quote.totals).filter(([, n]) => n);
3083
+ priceLine.textContent = (rows.length ? rows.join(" · ") : "nothing new") + " — total " + (totals.map(([u, n]) => fmtCost(u, Math.round(n * 100) / 100)).join(" + ") || "free");
3084
+ const over = totals.some(([unit, n]) => {
3085
+ const provider = quote.items.find((i) => i.costUnit === unit)?.provider;
3086
+ const left = provider ? remainingBudget(provider) : null;
3087
+ return left !== null && n > left;
3088
+ });
3089
+ add.disabled = !quote.items.length;
3090
+ addGen.disabled = !quote.items.length || over;
3091
+ addGen.textContent = "Add & generate" + (totals.length ? " · " + totals.map(([u, n]) => fmtCost(u, Math.round(n * 100) / 100)).join(" + ") : "");
3092
+ if (over) addGen.title = "More than the session budget has left";
3093
+ } catch (err) {
3094
+ if (mine !== seq) return;
3095
+ priceLine.className = "gap-price msg bad";
3096
+ priceLine.textContent = err.message;
3097
+ }
3098
+ }
3099
+ const save = async (generate) => {
3100
+ if (!quote) return;
3101
+ const keys = quote.items.map((i) => i.key);
3102
+ add.disabled = addGen.disabled = true;
3103
+ msg.className = "msg";
3104
+ msg.textContent = "saving…";
3105
+ try {
3106
+ S.snap = await postEdit(request());
3107
+ if (generate) {
3108
+ await postGenerate({ keys, ...root.project ? { project: root.project } : {} });
3109
+ pollJobs();
3110
+ }
3111
+ F.gap = null;
3112
+ F.drawn = null;
3113
+ render();
3114
+ } catch (err) {
3115
+ msg.className = "msg bad";
3116
+ msg.textContent = err.status === 409 ? "The manifest changed on disk; refresh and try again." : err.message;
3117
+ add.disabled = addGen.disabled = false;
3118
+ }
3119
+ };
3120
+ add.onclick = () => save(false);
3121
+ addGen.onclick = () => save(true);
3122
+ price();
3123
+ return panel;
3124
+ }
2682
3125
  function column(direction, node) {
2683
3126
  if (!F.columns.has(direction)) F.columns.set(direction, []);
2684
3127
  F.columns.get(direction).push(node);
@@ -3978,7 +4421,7 @@
3978
4421
  ui.projects = new Set(list("project"));
3979
4422
  ui.compare = list("compare").slice(0, 4);
3980
4423
  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");
4424
+ if (["style", "family", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
3982
4425
  }
3983
4426
  function writeUrlState() {
3984
4427
  const params = new URLSearchParams();
@@ -4051,6 +4494,7 @@
4051
4494
  renderMain(visibleItems());
4052
4495
  renderDrawer();
4053
4496
  renderTray();
4497
+ renderSelectionBar();
4054
4498
  refreshFamily();
4055
4499
  }
4056
4500
  function renderQuietly() {
@@ -4060,6 +4504,7 @@
4060
4504
  if (region !== "main") renderMain(visibleItems());
4061
4505
  if (region !== "drawer") renderDrawer();
4062
4506
  renderTray();
4507
+ renderSelectionBar();
4063
4508
  refreshFamily();
4064
4509
  }
4065
4510
 
@@ -4548,6 +4993,7 @@
4548
4993
  // src/gallery/client/src/main.ts
4549
4994
  loadPrefs();
4550
4995
  $("refresh").onclick = refresh;
4996
+ $("select").onclick = () => setSelecting(!ui.selecting);
4551
4997
  $("playloops").checked = ui.playLoops;
4552
4998
  $("playloops").onchange = (e) => {
4553
4999
  setPlayLoops(e.target.checked);
@@ -4610,6 +5056,11 @@
4610
5056
  if (ui.open) {
4611
5057
  e.preventDefault();
4612
5058
  closeItem();
5059
+ return;
5060
+ }
5061
+ if (ui.selecting) {
5062
+ e.preventDefault();
5063
+ setSelecting(false);
4613
5064
  }
4614
5065
  return;
4615
5066
  }