shadok-ai 0.15.52 → 0.15.54
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.
- package/package.json +1 -1
- package/public/index.html +193 -19
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -2306,6 +2306,71 @@
|
|
|
2306
2306
|
#composer { background: transparent; padding: 8px 20px calc(16px + env(safe-area-inset-bottom, 0px)); }
|
|
2307
2307
|
#composer textarea { background: var(--bg-raised); border: 1px solid var(--line); border-bottom: 2px solid var(--amber); border-radius: 6px; }
|
|
2308
2308
|
#composer textarea:focus-visible { box-shadow: none; border-color: var(--line); border-bottom-color: var(--amber); }
|
|
2309
|
+
|
|
2310
|
+
/* ── Motion: switching views, and what loading looks like ──────────────
|
|
2311
|
+
Short on purpose (≤ 220ms, staggers capped): a transition that makes you
|
|
2312
|
+
wait for the list you just asked for is worse than none. Everything here
|
|
2313
|
+
is decoration — the content is in place from the first frame, the
|
|
2314
|
+
animation only eases it in — and all of it is off under reduced motion. */
|
|
2315
|
+
:root { --ease-out: cubic-bezier(.2, .8, .2, 1); }
|
|
2316
|
+
@keyframes view-in-down { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
2317
|
+
@keyframes view-in-up { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
|
|
2318
|
+
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
|
2319
|
+
@keyframes chat-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
|
2320
|
+
/* Picking a group: its agents slide in from the side the group sits on in
|
|
2321
|
+
the rail (below → up, above → down), one after the other. */
|
|
2322
|
+
#tabbar.view-in-down :is(#ungrouped, #groups) .tab { animation: view-in-down 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
|
|
2323
|
+
#tabbar.view-in-up :is(#ungrouped, #groups) .tab { animation: view-in-up 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
|
|
2324
|
+
#tabbar.view-in-down .side-label, #tabbar.view-in-up .side-label,
|
|
2325
|
+
#tabbar.view-in-down #viewEmpty, #tabbar.view-in-up #viewEmpty { animation: fade-in 160ms ease-out both; }
|
|
2326
|
+
/* Picking an agent: its conversation eases in instead of snapping. */
|
|
2327
|
+
.transcript.chat-in { animation: chat-in 170ms var(--ease-out) both; }
|
|
2328
|
+
/* The rail's selection is ONE pill that glides to the picked group, instead
|
|
2329
|
+
of a highlight that blinks from one row to the next. */
|
|
2330
|
+
#railList { position: relative; }
|
|
2331
|
+
#railList .rail-item { z-index: 1; transition: color 160ms ease; }
|
|
2332
|
+
#railList.has-pill .rail-item.active { background: transparent; border-color: transparent; }
|
|
2333
|
+
.rail-pill {
|
|
2334
|
+
position: absolute; left: 0; right: 0; top: 0; z-index: 0; pointer-events: none;
|
|
2335
|
+
background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px;
|
|
2336
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
|
|
2337
|
+
transition: transform 200ms var(--ease-out), height 200ms var(--ease-out);
|
|
2338
|
+
}
|
|
2339
|
+
.rail-pill.no-anim { transition: none; }
|
|
2340
|
+
|
|
2341
|
+
/* Skeletons: the SHAPE of what is coming, shimmering, in place of a bare
|
|
2342
|
+
"loading…" — the layout does not jump when the real content lands. */
|
|
2343
|
+
@keyframes skel-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
|
2344
|
+
.skel {
|
|
2345
|
+
display: block; border-radius: 6px; height: 12px;
|
|
2346
|
+
/* Fallback first: a browser without color-mix() drops the gradient below
|
|
2347
|
+
and would otherwise draw no skeleton at all. */
|
|
2348
|
+
background: linear-gradient(90deg, rgba(128,128,128,.13) 25%, rgba(128,128,128,.26) 50%, rgba(128,128,128,.13) 75%);
|
|
2349
|
+
background: linear-gradient(90deg,
|
|
2350
|
+
color-mix(in srgb, var(--text-dim) 12%, transparent) 25%,
|
|
2351
|
+
color-mix(in srgb, var(--text-dim) 24%, transparent) 50%,
|
|
2352
|
+
color-mix(in srgb, var(--text-dim) 12%, transparent) 75%);
|
|
2353
|
+
background-size: 300% 100%;
|
|
2354
|
+
animation: skel-shimmer 1.3s linear infinite;
|
|
2355
|
+
}
|
|
2356
|
+
.skel-chat { display: flex; flex-direction: column; gap: 18px; padding-top: 4px; animation: fade-in 200ms ease-out both; }
|
|
2357
|
+
.skel-turn { display: flex; flex-direction: column; gap: 7px; max-width: 70%; }
|
|
2358
|
+
.skel-turn.mine { align-self: flex-end; align-items: flex-end; width: 42%; }
|
|
2359
|
+
.skel-turn .skel-label { width: 64px; height: 9px; }
|
|
2360
|
+
.skel-turn .skel-bubble { width: 100%; height: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: rgba(128,128,128,.07); background: color-mix(in srgb, var(--text-dim) 7%, transparent); animation: none; border-radius: 10px; }
|
|
2361
|
+
.skel-turn .skel-bubble .skel { height: 10px; }
|
|
2362
|
+
.skel-row { display: flex; flex-direction: column; gap: 7px; padding: 10px 8px; border-bottom: 1px solid var(--line); }
|
|
2363
|
+
.skel-row:last-child { border-bottom: none; }
|
|
2364
|
+
.skel-card { height: 92px; border-radius: 8px; }
|
|
2365
|
+
.skel-diff { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
|
|
2366
|
+
.skel-diff .skel { height: 10px; }
|
|
2367
|
+
|
|
2368
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2369
|
+
#tabbar.view-in-down .tab, #tabbar.view-in-up .tab, #tabbar .side-label, #tabbar #viewEmpty,
|
|
2370
|
+
.transcript.chat-in, .skel-chat { animation: none !important; }
|
|
2371
|
+
.rail-pill { transition: none; }
|
|
2372
|
+
.skel { animation: none; }
|
|
2373
|
+
}
|
|
2309
2374
|
</style>
|
|
2310
2375
|
<!-- Light/dark mode is a per-browser choice (localStorage). Set it BEFORE the
|
|
2311
2376
|
body paints — here in <head> — so a light-preferring viewer never sees a
|
|
@@ -2548,7 +2613,7 @@
|
|
|
2548
2613
|
<span class="label">Which agent?</span>
|
|
2549
2614
|
<button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
|
|
2550
2615
|
</div>
|
|
2551
|
-
<div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
|
|
2616
|
+
<div id="profileGrid" role="radiogroup" aria-label="Agent profile" aria-busy="true"><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span></div>
|
|
2552
2617
|
<span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
|
|
2553
2618
|
</div>
|
|
2554
2619
|
<!-- The model this ONE agent runs on. Its default sends nothing at all,
|
|
@@ -3387,6 +3452,37 @@
|
|
|
3387
3452
|
if (label.textContent !== text) label.textContent = text;
|
|
3388
3453
|
}
|
|
3389
3454
|
|
|
3455
|
+
// ── Skeletons: the shape of what is loading ─────────────
|
|
3456
|
+
// Static markup only (no user data), so innerHTML is safe here.
|
|
3457
|
+
const skelLine = (w) => `<span class="skel" style="width:${w}%"></span>`;
|
|
3458
|
+
function skeletonHTML(kind, n = 3) {
|
|
3459
|
+
if (kind === "chat") {
|
|
3460
|
+
const turn = (mine, widths) =>
|
|
3461
|
+
`<div class="skel-turn${mine ? " mine" : ""}"><span class="skel skel-label"></span>` +
|
|
3462
|
+
`<div class="skel skel-bubble">${widths.map(skelLine).join("")}</div></div>`;
|
|
3463
|
+
return `<div class="skel-chat" aria-busy="true" aria-label="Loading the conversation">` +
|
|
3464
|
+
turn(true, [80]) + turn(false, [92, 78, 54]) + turn(true, [64]) + turn(false, [88, 60]) + `</div>`;
|
|
3465
|
+
}
|
|
3466
|
+
if (kind === "cards") return Array.from({ length: n }, () => `<span class="skel skel-card"></span>`).join("");
|
|
3467
|
+
if (kind === "diff") {
|
|
3468
|
+
const w = [38, 72, 64, 85, 30, 58, 77, 46, 69, 52];
|
|
3469
|
+
return `<div class="skel-diff" aria-busy="true">${w.map(skelLine).join("")}</div>`;
|
|
3470
|
+
}
|
|
3471
|
+
// "rows": a list of two-line entries (sessions, ledger).
|
|
3472
|
+
return Array.from({ length: n }, (_, i) =>
|
|
3473
|
+
`<div class="skel-row" aria-busy="true">${skelLine(30 + (i * 17) % 25)}${skelLine(60 + (i * 23) % 35)}</div>`).join("");
|
|
3474
|
+
}
|
|
3475
|
+
/** A tab waiting for its history shows a chat skeleton — only when it has
|
|
3476
|
+
* nothing at all yet (a reconnect keeps what is already on screen). */
|
|
3477
|
+
function showChatSkeleton(t) {
|
|
3478
|
+
if (t.transcriptEl.childElementCount) return;
|
|
3479
|
+
t.transcriptEl.innerHTML = skeletonHTML("chat");
|
|
3480
|
+
}
|
|
3481
|
+
function clearChatSkeleton(t) {
|
|
3482
|
+
const s = t.transcriptEl.querySelector(":scope > .skel-chat");
|
|
3483
|
+
if (s) s.remove();
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3390
3486
|
function createTab(groupId, { focus = true } = {}) {
|
|
3391
3487
|
seq++;
|
|
3392
3488
|
const el = document.createElement("div");
|
|
@@ -4594,10 +4690,29 @@
|
|
|
4594
4690
|
const tabsInView = (v) =>
|
|
4595
4691
|
tabs.filter((t) => !t.btnEl.closest("#tweakSlot") && viewOfTab(t) === v);
|
|
4596
4692
|
|
|
4693
|
+
/** Eases the newly shown agents in, from the side the picked group sits on
|
|
4694
|
+
* in the rail. Purely cosmetic: the list is already correct underneath. */
|
|
4695
|
+
let viewAnimTimer = 0;
|
|
4696
|
+
function animateViewSwitch(from, to) {
|
|
4697
|
+
if (from === to || !railShown()) return;
|
|
4698
|
+
const order = [...railList.querySelectorAll(".rail-item")].map((i) => i.dataset.view);
|
|
4699
|
+
const dir = order.indexOf(to) >= order.indexOf(from) ? "view-in-down" : "view-in-up";
|
|
4700
|
+
const shown = [...tabbar.querySelectorAll("#ungrouped .tab, #groups .group.in-view .tab")];
|
|
4701
|
+
// Cap the stagger: past ~8 agents the tail would only make you wait.
|
|
4702
|
+
shown.forEach((el, i) => el.style.setProperty("--i", Math.min(i, 8)));
|
|
4703
|
+
tabbar.classList.remove("view-in-down", "view-in-up");
|
|
4704
|
+
void tabbar.offsetWidth; // restart the animation on a quick re-click
|
|
4705
|
+
tabbar.classList.add(dir);
|
|
4706
|
+
clearTimeout(viewAnimTimer);
|
|
4707
|
+
viewAnimTimer = setTimeout(() => tabbar.classList.remove("view-in-down", "view-in-up"), 420);
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4597
4710
|
function setView(v, { follow = true } = {}) {
|
|
4711
|
+
const from = currentView();
|
|
4598
4712
|
groupView = String(v);
|
|
4599
4713
|
localStorage.setItem(VIEW_KEY(), groupView);
|
|
4600
4714
|
applyView();
|
|
4715
|
+
animateViewSwitch(from, currentView());
|
|
4601
4716
|
// Picking a group shows one of ITS agents: staying on an agent that is no
|
|
4602
4717
|
// longer in the list would leave the chat and the list disagreeing.
|
|
4603
4718
|
if (follow && active && !active.btnEl.closest("#tweakSlot") && viewOfTab(active) !== currentView()) {
|
|
@@ -4633,12 +4748,18 @@
|
|
|
4633
4748
|
|
|
4634
4749
|
// A single click on a group's icon SELECTS the group (when the rail is
|
|
4635
4750
|
// collapsed to icons the icon is almost the whole row, so this is the primary
|
|
4636
|
-
// action); a double-click opens the icon picker
|
|
4637
|
-
//
|
|
4638
|
-
//
|
|
4639
|
-
//
|
|
4640
|
-
//
|
|
4641
|
-
|
|
4751
|
+
// action); a double-click opens the icon picker, and a double-click on the
|
|
4752
|
+
// name renames it. Neither may rely on a native `dblclick` or on state kept on
|
|
4753
|
+
// the element: the rail is REBUILT under the pointer — by the first click
|
|
4754
|
+
// itself (selecting a group changes the active view) and, with live agents,
|
|
4755
|
+
// by any mood change in between — and a dblclick whose two clicks land on two
|
|
4756
|
+
// different nodes never fires. The browser's own click count (`e.detail`)
|
|
4757
|
+
// follows the pointer, not the node, and the OS double-click speed with it.
|
|
4758
|
+
const isSecondClick = (e) => e.detail >= 2;
|
|
4759
|
+
// While a click is under way, a rebuild that only repaints moods waits: it
|
|
4760
|
+
// would swap the node between mousedown and mouseup and swallow the click.
|
|
4761
|
+
let railHoldUntil = 0;
|
|
4762
|
+
railList.addEventListener("pointerdown", () => { railHoldUntil = Date.now() + 700; }, true);
|
|
4642
4763
|
|
|
4643
4764
|
function railItem({ view, icon, name, group }) {
|
|
4644
4765
|
const members = tabsInView(view);
|
|
@@ -4688,16 +4809,13 @@
|
|
|
4688
4809
|
// and so the document-level close-on-click doesn't shut the picker we
|
|
4689
4810
|
// may be about to open with this very click.
|
|
4690
4811
|
e.stopPropagation();
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
lastIconClick = { view: null, t: 0 };
|
|
4694
|
-
openIconPicker(group, ic);
|
|
4695
|
-
} else {
|
|
4696
|
-
lastIconClick = { view, t: now };
|
|
4697
|
-
setView(view);
|
|
4698
|
-
}
|
|
4812
|
+
if (isSecondClick(e)) openIconPicker(group, ic);
|
|
4813
|
+
else setView(view);
|
|
4699
4814
|
});
|
|
4700
|
-
nm.addEventListener("
|
|
4815
|
+
nm.addEventListener("click", (e) => {
|
|
4816
|
+
if (!isSecondClick(e)) return; // the item's own click selects the group
|
|
4817
|
+
// Not to the item: its setView could move the focus to the composer
|
|
4818
|
+
// and blur (= close) the field we are opening.
|
|
4701
4819
|
e.stopPropagation();
|
|
4702
4820
|
inlineRename(item, nm, (v) => {
|
|
4703
4821
|
group.nameEl.textContent = v;
|
|
@@ -4750,6 +4868,33 @@
|
|
|
4750
4868
|
return item;
|
|
4751
4869
|
}
|
|
4752
4870
|
|
|
4871
|
+
// The selection pill: one element that GLIDES to the active group (the rows
|
|
4872
|
+
// themselves are rebuilt on every change, so they cannot transition).
|
|
4873
|
+
const railPill = document.createElement("div");
|
|
4874
|
+
railPill.className = "rail-pill";
|
|
4875
|
+
railPill.setAttribute("aria-hidden", "true");
|
|
4876
|
+
let pillView = null;
|
|
4877
|
+
function placeRailPill({ animate = true } = {}) {
|
|
4878
|
+
const item = railList.querySelector(".rail-item.active");
|
|
4879
|
+
if (!item || !item.offsetHeight) {
|
|
4880
|
+
railList.classList.remove("has-pill");
|
|
4881
|
+
railPill.hidden = true;
|
|
4882
|
+
pillView = null;
|
|
4883
|
+
return;
|
|
4884
|
+
}
|
|
4885
|
+
// First placement (or a layout change) jumps; only a change of group glides.
|
|
4886
|
+
const glide = animate && pillView !== null && pillView !== item.dataset.view;
|
|
4887
|
+
railPill.classList.toggle("no-anim", !glide);
|
|
4888
|
+
railPill.hidden = false;
|
|
4889
|
+
railPill.style.height = item.offsetHeight + "px";
|
|
4890
|
+
railPill.style.transform = `translateY(${item.offsetTop}px)`;
|
|
4891
|
+
railList.classList.add("has-pill");
|
|
4892
|
+
pillView = item.dataset.view;
|
|
4893
|
+
}
|
|
4894
|
+
// Collapsing the rail, resizing the window, or a group renamed to a longer
|
|
4895
|
+
// name moves the rows: follow without gliding.
|
|
4896
|
+
if (window.ResizeObserver) new ResizeObserver(() => placeRailPill({ animate: false })).observe(railList);
|
|
4897
|
+
|
|
4753
4898
|
let railFrame = 0;
|
|
4754
4899
|
let railSig = "";
|
|
4755
4900
|
function renderRail() {
|
|
@@ -4770,9 +4915,17 @@
|
|
|
4770
4915
|
return [e.view, e.icon, e.name, members.length, members.map(moodOf).join(",")].join("/");
|
|
4771
4916
|
}).join("|");
|
|
4772
4917
|
if (sig !== railSig) {
|
|
4918
|
+
// Same view → nothing the user did; it can wait for the click to finish.
|
|
4919
|
+
const wait = railHoldUntil - Date.now();
|
|
4920
|
+
if (wait > 0 && railSig.startsWith(v + "|")) {
|
|
4921
|
+
clearTimeout(renderRail.retry);
|
|
4922
|
+
renderRail.retry = setTimeout(renderRail, wait);
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4773
4925
|
railSig = sig;
|
|
4774
|
-
railList.replaceChildren(...entries.map(railItem));
|
|
4926
|
+
railList.replaceChildren(...entries.map(railItem), railPill);
|
|
4775
4927
|
}
|
|
4928
|
+
placeRailPill();
|
|
4776
4929
|
if (groupIconsMinted && !restoring) {
|
|
4777
4930
|
groupIconsMinted = false;
|
|
4778
4931
|
persistGroups();
|
|
@@ -5111,6 +5264,7 @@
|
|
|
5111
5264
|
}
|
|
5112
5265
|
|
|
5113
5266
|
function activate(tab) {
|
|
5267
|
+
const prev = active;
|
|
5114
5268
|
if (active && active !== tab) {
|
|
5115
5269
|
active.draft = promptInput.value;
|
|
5116
5270
|
persistDraft(active);
|
|
@@ -5122,10 +5276,19 @@
|
|
|
5122
5276
|
if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
|
|
5123
5277
|
// Viewing the channel clears its "unread response" highlight.
|
|
5124
5278
|
if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
|
|
5279
|
+
const switched = !!prev && prev !== tab && !restoring;
|
|
5125
5280
|
tabs.forEach((t) => {
|
|
5126
5281
|
t.btnEl.classList.toggle("active", t === tab);
|
|
5127
5282
|
t.transcriptEl.hidden = t !== tab;
|
|
5128
5283
|
});
|
|
5284
|
+
// Ease the new conversation in rather than snapping (only on a real switch).
|
|
5285
|
+
if (switched) {
|
|
5286
|
+
const el = tab.transcriptEl;
|
|
5287
|
+
el.classList.remove("chat-in");
|
|
5288
|
+
void el.offsetWidth;
|
|
5289
|
+
el.classList.add("chat-in");
|
|
5290
|
+
el.addEventListener("animationend", () => el.classList.remove("chat-in"), { once: true });
|
|
5291
|
+
}
|
|
5129
5292
|
promptInput.value = tab.draft;
|
|
5130
5293
|
autosizePrompt();
|
|
5131
5294
|
renderAttachments(tab); // each tab keeps its own pending chips
|
|
@@ -5983,6 +6146,7 @@
|
|
|
5983
6146
|
window.addEventListener("shadok-bridge-ready", resideAllPrompts);
|
|
5984
6147
|
|
|
5985
6148
|
function addTurn(tab, role, who, text, extraClass, at) {
|
|
6149
|
+
clearChatSkeleton(tab);
|
|
5986
6150
|
// Time of the last message in the channel list, relative ("3m ago"). Every
|
|
5987
6151
|
// message (replayed history or live) carries `at`; the last one wins.
|
|
5988
6152
|
if (Number.isFinite(at)) {
|
|
@@ -6772,6 +6936,7 @@
|
|
|
6772
6936
|
// Reattach to the SAME session once we know its id; a brand-new tab still
|
|
6773
6937
|
// starts fresh (no sessionId yet).
|
|
6774
6938
|
const start = t.sessionId ? { ...t.launchMsg, resume: t.sessionId } : t.launchMsg;
|
|
6939
|
+
if (t.sessionId) showChatSkeleton(t); // a resume has history on its way
|
|
6775
6940
|
ws.send(JSON.stringify({ type: "start", origin: "web", ...start }));
|
|
6776
6941
|
});
|
|
6777
6942
|
ws.addEventListener("close", () => {
|
|
@@ -7455,6 +7620,9 @@
|
|
|
7455
7620
|
}
|
|
7456
7621
|
|
|
7457
7622
|
function handleMessage(t, msg) {
|
|
7623
|
+
// `history` comes BEFORE `ready` when there is one, so by `ready` (or any
|
|
7624
|
+
// ending) the skeleton has nothing left to stand in for.
|
|
7625
|
+
if (["ready", "error", "exited", "gone", "stopped"].includes(msg.type)) clearChatSkeleton(t);
|
|
7458
7626
|
switch (msg.type) {
|
|
7459
7627
|
case "version":
|
|
7460
7628
|
handleVersion(msg);
|
|
@@ -7889,7 +8057,7 @@
|
|
|
7889
8057
|
/** Lists the directory's resumable sessions; a click fills the id field. */
|
|
7890
8058
|
async function refreshSessionList() {
|
|
7891
8059
|
const listEl = $("sessionList");
|
|
7892
|
-
listEl.innerHTML =
|
|
8060
|
+
listEl.innerHTML = skeletonHTML("rows", 4);
|
|
7893
8061
|
let sessions = [];
|
|
7894
8062
|
try {
|
|
7895
8063
|
const cwd = $("cwdInput").value.trim();
|
|
@@ -8595,7 +8763,7 @@
|
|
|
8595
8763
|
if (!t || !t.sessionId) return;
|
|
8596
8764
|
$("diffBranch").textContent = t.branch || "working tree";
|
|
8597
8765
|
$("diffStatus").textContent = "loading…";
|
|
8598
|
-
$("diffBody").innerHTML = "";
|
|
8766
|
+
$("diffBody").innerHTML = skeletonHTML("diff");
|
|
8599
8767
|
try {
|
|
8600
8768
|
const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
|
|
8601
8769
|
$("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
|
|
@@ -8951,6 +9119,11 @@
|
|
|
8951
9119
|
applyLedgerFilters();
|
|
8952
9120
|
});
|
|
8953
9121
|
$("ledgerBtn").addEventListener("click", async () => {
|
|
9122
|
+
// Open at once on a skeleton: the table can be hundreds of rows, and a
|
|
9123
|
+
// click that does nothing until it lands reads as a dead button.
|
|
9124
|
+
$("ledgerList").innerHTML = skeletonHTML("rows", 6);
|
|
9125
|
+
$("ledgerCount").textContent = "";
|
|
9126
|
+
$("ledgerOverlay").hidden = false;
|
|
8954
9127
|
try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
|
|
8955
9128
|
// The option lists come from the module, so the control and the filter it
|
|
8956
9129
|
// drives cannot drift apart.
|
|
@@ -9227,6 +9400,7 @@
|
|
|
9227
9400
|
* keyboard) lives here only, in a single copy. */
|
|
9228
9401
|
function renderProfileCards(container, selected, onPick, failed) {
|
|
9229
9402
|
container.innerHTML = "";
|
|
9403
|
+
container.removeAttribute("aria-busy"); // the boot skeleton, if still there
|
|
9230
9404
|
if (failed) {
|
|
9231
9405
|
const note = document.createElement("div");
|
|
9232
9406
|
note.className = "grid-note";
|