library: a long skill list is drawn near the view first and then filled in while the page is idle (requestIdleCallback, or 20 rows every 24ms in WebKit), every row kept once drawn so a scroll neither builds nor removes rows (a scroll that outruns the filling finishes it at once), each row is contained as a skipped one was and shows its buttons without the fade while its list scrolls (in WebKit each fade under a passing pointer was a long frame), and a chip's grey icon is made once rather than filtered on every paint (a drawing with its colours greyed by grayscale's own sum, a picture greyed pixel by pixel, one in the text's colour through a relative colour) with its cut-out ring an outline instead of a spread shadow, so scrolling 300 skills in WebKit has a median of 0–8 long frames where main had 11–37 and 8767eb6 94–152

This commit is contained in:
yetone committed 2026-09-28 20:28:08 +08:00
1 parent 372eb2cc07
commit ef247c1889
2 files changed
+175 -51

No files matched your search

+31 -9
View File
@@ -135,6 +135,16 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-ag:hover .ic { opacity: .7; }
.lib-ag.on { background: var(--card); border-color: var(--line); color: var(--fg); box-shadow: 0 1px 1px rgba(0, 0, 0, .05); }
.lib-ag.on .ic { filter: none; opacity: 1; }
/* an icon made grey once rather than through the filter on every paint
(library.js, greyIcon): a picture's grey copy stands in for it, and one
drawn in the text's colour takes that colour grey — the same pixels */
.lib-ag .ic > img.grey { display: none; }
.lib-ag:not(.on) .ic.baked { filter: none; }
.lib-ag:not(.on) .ic.baked > img:not(.grey) { display: none; }
.lib-ag:not(.on) .ic.baked > img.grey { display: block; }
@supports (color: rgb(from currentColor r g b)) {
.lib-ag:not(.on) .ic.flat { filter: none; color: rgb(from currentColor calc(r * .2126 + g * .7152 + b * .0722) calc(r * .2126 + g * .7152 + b * .0722) calc(r * .2126 + g * .7152 + b * .0722)); }
}
.lib-ag.via { border: 1px dashed var(--line); }
.lib-ag.via .ic { opacity: .6; }
.lib-ag.warn::after { content: ""; position: absolute; top: -2px; right: -2px; width: 7px; height: 7px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 2px var(--card); }
@@ -149,16 +159,22 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
/* in a list row they sit stacked like a hand of cards, on one line, and fan
out when the pointer or focus comes to them */
.lib-row > .lib-agents { flex-wrap: nowrap; gap: 0; max-width: none; padding-left: 4px; }
.lib-row > .lib-agents .lib-ag { flex: none; width: 24px; min-width: 0; height: 24px; padding: 0 0 0 4px; justify-content: flex-start; background: var(--card); border-color: var(--line-2); box-shadow: 0 0 0 1.5px var(--card); transition: margin .22s var(--ease), background .14s, border-color .14s, transform .14s var(--ease); }
.lib-row > .lib-agents .lib-ag { flex: none; width: 24px; min-width: 0; height: 24px; padding: 0 0 0 4px; justify-content: flex-start; background: var(--card); border-color: var(--line-2); box-shadow: none; transition: margin .22s var(--ease), background .14s, border-color .14s, transform .14s var(--ease); }
.lib-row > .lib-agents .lib-ag .ic { width: 14px; height: 14px; }
/* the ring in the card's colour that cuts each chip out of the one under
it: an outline rather than a spread shadow, the same ring cheaper to
paint; a chip with the focus shows the focus ring instead */
.lib-row > .lib-agents .lib-ag:not(:focus-visible) { outline: 1.5px solid var(--card); outline-offset: 0; }
.lib-row > .lib-agents .lib-ag + .lib-ag { margin-left: -7px; }
.lib-row > .lib-agents .lib-ag.on { border-color: var(--line); box-shadow: 0 0 0 1.5px var(--card), 0 1px 2px rgba(0, 0, 0, .08); }
.lib-row > .lib-agents .lib-ag.on { border-color: var(--line); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); }
.lib-row > .lib-agents .lib-ag.via { border-style: dashed; }
.lib-row > .lib-agents:hover .lib-ag + .lib-ag, .lib-row > .lib-agents:focus-within .lib-ag + .lib-ag { margin-left: 3px; }
.lib-row > .lib-agents:hover .lib-ag, .lib-row > .lib-agents:focus-within .lib-ag { box-shadow: none; }
.lib-row > .lib-agents:hover .lib-ag:not(:focus-visible), .lib-row > .lib-agents:focus-within .lib-ag:not(:focus-visible) { outline-color: transparent; }
.lib-row > .lib-agents .lib-ag:hover { background: var(--pill); z-index: 1; }
.lib-row.click:hover > .lib-agents .lib-ag { box-shadow: 0 0 0 1.5px var(--card-2); }
.lib-row.click:hover > .lib-agents:hover .lib-ag { box-shadow: none; }
.lib-row.click:hover > .lib-agents .lib-ag { box-shadow: none; }
.lib-row.click:hover > .lib-agents .lib-ag:not(:focus-visible) { outline-color: var(--card-2); }
.lib-row.click:hover > .lib-agents:hover .lib-ag:not(:focus-visible) { outline-color: transparent; }
/* agents that have one of their own, not in the library */
.lib-have { display: flex; gap: 4px; flex: none; }
@@ -324,12 +340,18 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
/* a project's skills out of view are neither laid out nor painted until they near it */
.lib-projskill { content-visibility: auto; contain-intrinsic-size: auto 76px; }
.lib-grouphead .lib-rowacts { min-width: 26px; }
/* A group's skills are a window on its list (library.js, windowed): the
rows near the view are drawn, room is kept for the others. Every row is
held to the height the list counts on — its description one line, the
whole of it in its tooltip — so what's kept is exactly what's missing. */
/* A group's skills are drawn near the view first (library.js, windowed),
room kept for the rest, which are drawn in while nothing else is going
on and then stay. Every row is held to the height the list counts on —
its description one line, the whole of it in its tooltip — so the room
kept is exactly what's missing. */
.lib-vl { flex: none; }
.lib-vl > .row { border-top: 1px solid var(--line-2); overflow: hidden; flex-wrap: nowrap; }
/* each row kept to itself, as a skipped one was; and a row the pointer
passes over in a scroll shows its buttons at once rather than fading them
in — in WebKit each fade, given a layer of its own while it ran, cost a
long frame (library.js marks a list .scrolling while it scrolls) */
.lib-vl > .row { border-top: 1px solid var(--line-2); overflow: hidden; flex-wrap: nowrap; contain: layout paint style; }
.lib-vl.scrolling > .row .lib-rowacts { transition: none; }
.lib-vl.bare > .row.first { border-top: 0; }
.lib-vl > .row.lib-skill { height: 51px; min-height: 0; padding: 7px 10px 7px 12px; gap: 11px; }
.lib-vl > .row.lib-skill.src { height: 67px; }
+144 -42
View File
@@ -99,10 +99,94 @@
const icons = new Map();
function agentIcon(name) {
let i = icons.get(name);
if (!i) icons.set(name, (i = icon(name)));
if (!i) { icons.set(name, (i = icon(name))); greyIcon(i); }
return i.cloneNode(true);
}
// A chip's icon is grey while its agent hasn't the item. A filter made it
// so on every paint of every chip — most of what a scroll through
// hundreds of skills painted — so it's made grey once instead: a picture
// gets a grey copy (by the filter's own sum), which stands in for
// it once ready, and an icon drawn in the text's colour takes that colour
// grey (library.css). Until then, or if it can't be, the filter does it.
function greyIcon(i) {
if (i.querySelector(":scope > .mask, :scope > svg")) { i.classList.add("flat"); return; }
const src = i.querySelector(":scope > img")?.getAttribute("src");
if (!src) return;
greyCopy(src).then((url) => {
if (!url) return;
const add = (ic) => {
if (ic.classList.contains("baked")) return;
const g = el("img", "grey");
g.src = url;
g.alt = "";
g.draggable = false;
ic.append(g);
ic.classList.add("baked");
};
add(i);
for (const x of document.querySelectorAll(".lib-ag > .ic:not(.baked) > img")) if (x.getAttribute("src") === src) add(x.parentElement);
}, () => {});
}
// grayscale(1)'s own sum: each colour's red, green and blue become this
const lum = (r, g, b) => Math.round(r * .2126 + g * .7152 + b * .0722);
let tint;
function greyColour(v) {
tint ||= document.createElement("canvas").getContext("2d");
// a colour in any spelling, read back as #rrggbb or rgba(…); anything
// else (none, url(#…), currentColor) is left as it is
if (/^\s*(none|currentcolor|inherit|transparent|url\()/i.test(v)) return v;
tint.fillStyle = "#010203";
tint.fillStyle = v;
const c = tint.fillStyle;
let m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(c);
if (m) {
if (c === "#010203" && !/010203/.test(v)) return v;
const y = lum(parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)).toString(16).padStart(2, "0");
return "#" + y + y + y;
}
m = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(c);
if (!m) return v;
const y = lum(+m[1], +m[2], +m[3]);
return `rgba(${y}, ${y}, ${y}, ${m[4] ?? 1})`;
}
async function greyCopy(src) {
const r = await fetch(src);
if (!r.ok) return null;
let blob;
if (/svg/.test(r.headers.get("content-type") || "") || /\.svg$/.test(src)) {
// the drawing with each of its colours made grey — the same as the
// filter, which is a sum over each colour (blending and gradients
// mix colours in sRGB, as the filter does, so they come out the same)
// and drawn as sharp as the drawing itself
const s = (await r.text()).replace(/(?<![\w-])(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (_, k, eq, q, v) => k + eq + q + greyColour(v) + q)
.replace(/(?<![\w-])(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (_, k, eq, v) => k + eq + greyColour(v));
if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach
blob = new Blob([s], { type: "image/svg+xml" });
} else {
// a picture made grey pixel by pixel
const im = new Image();
im.src = URL.createObjectURL(await r.blob());
await im.decode();
const c = document.createElement("canvas");
c.width = im.naturalWidth;
c.height = im.naturalHeight;
const x = c.getContext("2d");
x.drawImage(im, 0, 0);
URL.revokeObjectURL(im.src);
const d = x.getImageData(0, 0, c.width, c.height), p = d.data;
for (let k = 0; k < p.length; k += 4) p[k] = p[k + 1] = p[k + 2] = p[k] * .2126 + p[k + 1] * .7152 + p[k + 2] * .0722;
x.putImageData(d, 0, 0);
blob = await new Promise((done) => c.toBlob(done));
if (!blob) return null;
}
const url = URL.createObjectURL(blob);
const t = new Image();
t.src = url;
await t.decode(); // ready before it stands in, so nothing blinks
return url;
}
// Agent chips for a server or a skill: each agent that could have it, lit
// when it does. A chip whose agent couldn't be given it says why.
function agentChips(all, on, onChange, opts = {}) {
@@ -1305,33 +1389,61 @@
return card;
}
// ---------- a long list, drawn near the view only ----------
// ---------- a long list, drawn near the view first ----------
// A window on a list of items of known heights ({ key, h, make }): the
// rows near the page's view are drawn, with room kept above and below
// for the rest. A scroll draws more once what's drawn ahead is less than
// a view, and then a few rows a frame until half a view more is, so no
// frame builds many rows and most touch nothing; a row still wanted
// after a change (a filter, a fold) is kept as it is, not drawn again.
// A list of items of known heights ({ key, h, make }) drawn in two goes:
// the rows near the page's view at once, with room kept above and below
// for the rest, and then the rest while the page is idle, a few rows at
// a time, each kept once drawn. A scroll builds nothing and takes nothing
// away, so it only paints what's there — unless it outruns the filling,
// when what's left is drawn at once rather than a view a frame. A row
// still wanted after a change (a filter, a fold) is kept as it is.
const lists = new Set();
let listFrame = 0;
function syncLists() {
cancelAnimationFrame(listFrame);
listFrame = 0;
if (page.hidden) return;
const vp = page.getBoundingClientRect();
let more = false;
let vp = null;
for (const w of lists) {
if (w.el.isConnected) more = w.sync(vp) || more;
else if (w.shown) lists.delete(w); // its page is gone; one drawn again comes back
if (!w.el.isConnected) { if (w.shown) lists.delete(w); continue; } // its page is gone; one drawn again comes back
if (!w.full()) w.sync(vp ||= page.getBoundingClientRect());
}
if (more) queueLists();
}
const STEP = 4; // rows a frame, beyond those in view
const queueLists = () => { if (!listFrame) listFrame = requestAnimationFrame(syncLists); };
page.addEventListener("scroll", queueLists, { passive: true });
// and while the page scrolls, its lists say so (library.css, .scrolling)
let still = 0;
const settled = () => { still = 0; for (const w of lists) w.el.classList.remove("scrolling"); };
page.addEventListener("scroll", () => {
queueLists();
if (!still) for (const w of lists) w.el.classList.add("scrolling");
clearTimeout(still);
still = setTimeout(settled, 150);
}, { passive: true });
window.addEventListener("resize", queueLists);
// The filling: one list's next few rows per idle moment, the lists in turn.
const FILL = 20;
const filling = new Set();
let fillTask = 0;
const idle = window.requestIdleCallback
? (f) => requestIdleCallback(f, { timeout: 200 })
: (f) => setTimeout(f, 24);
function queueFill(w) {
filling.add(w);
if (!fillTask) fillTask = idle(fillSome);
}
function fillSome() {
fillTask = 0;
for (const w of filling) {
if (!w.el.isConnected || w.full()) { filling.delete(w); continue; }
w.grow(FILL);
if (w.full()) filling.delete(w);
break;
}
if (filling.size) fillTask = idle(fillSome);
}
function windowed() {
const box = el("div", "lib-vl");
const above = el("div", "lib-vpad"), below = el("div", "lib-vpad");
@@ -1356,14 +1468,11 @@
above.style.height = tops[from] + "px";
below.style.height = tops[items.length] - tops[to] + "px";
};
// draw items a…b, keeping the rows of those already drawn
// draw items a…b around those drawn (a ≤ from, b ≥ to), or afresh,
// keeping the rows of those already drawn
function show(a, b, fresh) {
if (!fresh && a === from && b === to) return;
if (!fresh && a < to && b > from) {
// the same list moved along: rows are taken off the ends and added
// at them, the rest untouched
for (let i = from; i < a; i++) { live.get(items[i].key)?.remove(); live.delete(items[i].key); }
for (let i = b; i < to; i++) { live.get(items[i].key)?.remove(); live.delete(items[i].key); }
if (!fresh && a <= from && b >= to && from < to) {
if (a < from) {
const f = document.createDocumentFragment();
const made = [];
@@ -1391,13 +1500,20 @@
from = a; to = b;
pads();
}
w.full = () => from === 0 && to === items.length;
// n more rows, below what's drawn first, then above it
w.grow = (n) => {
const b = Math.min(items.length, to + n);
show(Math.max(0, from - (n - (b - to))), b);
};
w.set = (next) => {
items = next;
tops = [0];
for (const it of items) tops.push(tops[tops.length - 1] + it.h);
// placed, what's near the view; until then the first rows, enough for one
if (box.isConnected && !page.hidden) { if (w.sync(page.getBoundingClientRect(), true)) queueLists(); }
if (box.isConnected && !page.hidden) w.sync(page.getBoundingClientRect(), true);
else show(0, Math.min(items.length, 12), true);
if (!w.full()) queueFill(w);
};
w.sync = (vp, fresh) => {
w.shown = true;
@@ -1408,26 +1524,12 @@
const k = r.height ? r.height / total : 1; // the page may be zoomed
const view = vp.height / k;
const top = (vp.top - r.top) / k, bottom = (vp.bottom - r.top) / k;
// what must be drawn: the view and a view about it; what is drawn
// when that runs out: a view and a half about it — the rows in view
// at once, the others a few a frame, so no frame builds a whole view
const need = (m) => [top - view * m > total ? items.length : at(Math.max(0, top - view * m)),
bottom + view * m < 0 ? 0 : Math.min(items.length, at(bottom + view * m) + 1)];
const [a, b] = need(1);
if (!fresh && from <= a && to >= b && (a < b || from === to)) return false;
let [na, nb] = need(1.5);
if (nb < na) nb = na;
if (fresh || na >= to || nb <= from) {
// drawn anew, or a jump past what's drawn: the view and a little
// about it now, the rest as above
const [ja, jb] = need(0.25);
show(ja, Math.max(ja, jb), !!fresh);
return ja > na || jb < nb;
}
const [va, vb] = need(0);
const sa = Math.max(na, Math.min(va, from - STEP)), sb = Math.min(nb, Math.max(vb, to + STEP));
show(sa, sb);
return sa > na || sb < nb; // more to come next frame
// the rows in view and half a view about it
const a = top - view / 2 > total ? items.length : at(Math.max(0, top - view / 2));
const b = bottom + view / 2 < 0 ? 0 : Math.min(items.length, at(bottom + view / 2) + 1);
if (fresh) { show(a, Math.max(a, b), true); return; }
// a scroll past what's filled in: the rest, now
if (from === to || (a < b && (a < from || b > to))) show(0, items.length, from === to);
};
lists.add(w);
return w;