diff --git a/assets/gallery.css b/assets/gallery.css
index 3350188..606c51e 100644
--- a/assets/gallery.css
+++ b/assets/gallery.css
@@ -44,6 +44,22 @@ p { text-wrap: pretty; }
.dlc-icon-thinking_orbs { --dlc-icon: url(icons/thinking_orbs.svg); }
.dlc-icon-lab { --dlc-icon: url(icons/lab.svg); }
.dlc-icon-overview { --dlc-icon: url(icons/overview.svg); }
+.dlc-icon-wall { --dlc-icon: url(icons/wall.svg); }
+.dlc-icon-close { --dlc-icon: url(icons/close.svg); }
+.dlc-action-icon { width: 18px; height: 18px; vertical-align: 0; }
+/* Small tooltip for header icons, after a beat so passing over stays quiet. */
+.dlc-tip-host { position: relative; display: inline-flex; }
+.dlc-tip-host::after {
+ content: attr(data-tip);
+ position: absolute; top: calc(100% + 8px); left: 50%; translate: -50% 0;
+ padding: 4px 8px; white-space: nowrap; pointer-events: none;
+ background: var(--dlc-surface); border: 1px solid var(--dlc-border);
+ border-radius: var(--mantine-radius-sm);
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.12);
+ font-size: 12px; color: var(--mantine-color-text);
+ opacity: 0; transition: opacity 120ms ease-out;
+}
+.dlc-tip-host:hover::after, .dlc-tip-host:focus-visible::after { opacity: 1; transition-delay: 400ms; }
.dlc-icon-credits { --dlc-icon: url(icons/credits.svg); }
.dlc-icon-request { --dlc-icon: url(icons/request.svg); }
.dlc-nav-ext { width: 0.85em; height: 0.85em; opacity: 0.6; }
@@ -190,37 +206,66 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
.dlc-install, .dlc-hero-code {
border: 1px solid var(--dlc-border); border-radius: var(--mantine-radius-md);
}
-/* Hero carousel: snippet beside the loader it builds, one featured loader
- at a time; slides are stacked and swapped by assets/gallery.js. */
-.dlc-hero-carousel { max-width: 920px; }
-.dlc-carousel { position: relative; overflow: hidden; }
-.dlc-slide {
- display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 12px; align-items: stretch;
- min-height: 176px;
-}
-.dlc-slide[hidden] { display: none; }
-.dlc-slide.is-entering { animation: dlc-slide-in-x 420ms cubic-bezier(0.2, 0, 0, 1); }
-/* Exits softer than enters: shorter travel, shorter time. */
-.dlc-slide.is-leaving { position: absolute; inset: 0; animation: dlc-slide-out-x 300ms cubic-bezier(0.2, 0, 0, 1) forwards; }
-@keyframes dlc-slide-in-x { from { translate: 40px 0; opacity: 0; } to { translate: 0 0; opacity: 1; } }
-@keyframes dlc-slide-out-x { to { translate: -24px 0; opacity: 0; } }
-.dlc-slide .dlc-hero-code { min-width: 0; }
-/* Centre the one-line call in the row: the snippet is shorter than the stage. */
-.dlc-slide .mantine-CodeHighlight-codeHighlight { position: relative; display: grid; align-content: center; height: 100%; }
-.dlc-hero-stage {
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
- padding: 20px; color: var(--mantine-color-text); text-decoration: none;
- border: 1px solid var(--dlc-border); border-radius: var(--mantine-radius-md);
- background:
- radial-gradient(circle, light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.06)) 1px, transparent 1px)
- 0 0 / 16px 16px,
- var(--dlc-surface-sunken);
- transition-property: border-color; transition-duration: 150ms;
+/* Library carousel: one card per library, stacked in one grid cell so the
+ cards fade in place. Inactive cards keep their size (rows have a fixed
+ height) but their tiles are parked, so they cost DOM only. Tiles are swapped by assets/gallery.js. */
+.dlc-libs { max-width: 1116px; }
+/* assets/gallery.js sets the track's height to the active card's own, so a
+ one-row library is not as tall as a three-row one. */
+.dlc-libs-track { display: grid; overflow: clip; transition: height 260ms cubic-bezier(0.2, 0, 0, 1); }
+.dlc-lib {
+ grid-area: 1 / 1; min-width: 0; align-self: start;
+ padding: 20px 22px 22px;
+ background: var(--dlc-surface);
+ border: 1px solid var(--dlc-border); border-radius: var(--mantine-radius-lg);
+ opacity: 0; visibility: hidden;
+ transition: opacity 260ms ease-out, visibility 0s 260ms;
+}
+.dlc-lib.is-active { opacity: 1; visibility: visible; transition-delay: 0s; }
+.dlc-lib-more { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; }
+.dlc-lib-more .dlc-icon { width: 0.9em; height: 0.9em; vertical-align: 0; }
+.dlc-lib-rows { display: grid; gap: 14px; }
+/* A library with sections: they sit side by side, each row turning on its own. */
+.dlc-lib-rows.is-sections { grid-template-columns: repeat(var(--sec-cols), minmax(0, 1fr)); gap: 18px 22px; }
+.dlc-lib-section { min-width: 0; }
+.dlc-lib-section-title { margin-bottom: 6px; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dlc-lib-row.is-compact { --tile-h: 104px; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 4px; }
+.dlc-lib-row {
+ --tile-h: 112px; --rows: 1;
+ display: grid; gap: 8px; align-content: start;
+ grid-template-columns: repeat(8, minmax(0, 1fr)); grid-auto-rows: var(--tile-h);
+ min-height: calc(var(--rows) * var(--tile-h) + (var(--rows) - 1) * 8px);
+}
+.dlc-lib-tile {
+ display: flex; flex-direction: column; min-width: 0;
+ border: 1px solid transparent; border-radius: var(--mantine-radius-md);
+ color: var(--mantine-color-text);
+ transition-property: background-color, border-color; transition-duration: 150ms;
}
-.dlc-hero-stage:hover { border-color: var(--dlc-accent); }
-.dlc-hero-loader { display: flex; align-items: center; justify-content: center; height: 96px; }
-.dlc-hero-name { font-family: var(--mantine-font-family-monospace); font-size: 13px; }
-.dlc-hero-ns { color: var(--mantine-color-dimmed); }
+.dlc-lib-tile[data-off] { display: none; }
+.dlc-lib-tile:hover, .dlc-lib-tile:focus-visible {
+ background: var(--dlc-surface-sunken); border-color: var(--dlc-accent); outline: none;
+}
+.dlc-lib-row.is-turning .dlc-lib-tile { animation: dlc-lib-in 360ms cubic-bezier(0.2, 0, 0, 1) both; }
+@keyframes dlc-lib-in { from { opacity: 0; translate: 0 6px; } }
+.dlc-lib-tile .preview {
+ flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; position: relative;
+}
+.dlc-lib-tile .preview > * { scale: 0.8; }
+.dlc-lib-tile .name {
+ padding: 0 6px 8px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ font-size: 12px; color: var(--mantine-color-dimmed);
+}
+.dlc-lib-tile:hover .name { color: var(--mantine-color-text); }
+@media (max-width: 64em) {
+ .dlc-lib-row:not(.is-compact) { grid-template-columns: repeat(4, minmax(0, 1fr)); --rows: 2; }
+ .dlc-lib-rows.is-sections { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (max-width: 40em) {
+ .dlc-lib-row:not(.is-compact) { grid-template-columns: repeat(3, minmax(0, 1fr)); --rows: 3; }
+ .dlc-lib-rows.is-sections { grid-template-columns: 1fr; }
+}
+@media (prefers-reduced-motion: reduce) { .dlc-lib, .dlc-lib-row.is-turning .dlc-lib-tile { transition: none; animation: none; } }
.dlc-carousel-nav { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 10px; }
.dlc-carousel-btn {
display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px;
@@ -237,9 +282,6 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
transition-property: background-color, width; transition-duration: 200ms;
}
.dlc-carousel-dots i.is-active { background: var(--dlc-accent); width: 16px; border-radius: 3px; }
-@media (max-width: 48em) {
- .dlc-slide { grid-template-columns: 1fr; }
-}
.dlc-family { padding: 36px 0 8px; scroll-margin-top: 80px; }
.dlc-family-title {
@@ -280,6 +322,8 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
padding: 12px 8px;
overflow: hidden; position: relative;
}
+/* Parked until near the viewport (assets/gallery.js); the card keeps its size. */
+.dlc-card[data-off] .preview { display: none; }
.dlc-card .name {
padding: 0 10px 14px; text-align: center;
font-size: 12.5px; font-weight: 500; color: var(--mantine-color-dimmed);
@@ -287,6 +331,77 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none
}
.dlc-card:hover .name { color: var(--mantine-color-text); }
+/* ---------- Wall: every spinner, full viewport ---------- */
+/* Fixed over the app shell, header and navbar included, so nothing but
+ spinners is on screen. The tiles are the overview's own previews, minus
+ the labels; assets/gallery.js adds the hover tooltip and Esc to close. */
+.dlc-wall {
+ position: fixed; inset: 0; z-index: 300;
+ overflow-y: auto; overscroll-behavior: contain;
+ padding: 20px;
+ background:
+ radial-gradient(circle, light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.06)) 1px, transparent 1px)
+ 0 0 / 16px 16px,
+ var(--dlc-surface-sunken);
+}
+.dlc-wall-grid {
+ display: grid; gap: 4px;
+ grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
+}
+/* An action icon: bare until hovered. */
+.dlc-wall-tile {
+ display: flex; aspect-ratio: 1; min-width: 0;
+ border: 1px solid transparent;
+ border-radius: var(--mantine-radius-md);
+ color: var(--mantine-color-text);
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms; transition-timing-function: ease-out;
+}
+.dlc-wall-tile:hover, .dlc-wall-tile:focus-visible {
+ background: var(--dlc-surface);
+ border-color: var(--dlc-accent);
+ box-shadow: 0 0 0 3px light-dark(rgb(253 126 20 / 0.12), rgb(253 126 20 / 0.18));
+ outline: none;
+}
+/* No overflow clip: a clip per tile roughly doubled the frame time while
+ scrolling (profiled), and WALL_SCALE keeps every spinner inside its tile. */
+.dlc-wall-tile .preview {
+ flex: 1; min-width: 0;
+ display: flex; align-items: center; justify-content: center;
+ position: relative;
+}
+/* Only tiles near the viewport render (assets/gallery.js): 474 live spinners
+ are thousands of animated layers, over a gigabyte in the tab. The tile keeps
+ its size, so the grid does not move. */
+.dlc-wall-tile[data-off] .preview { display: none; }
+/* The overview's previews are sized for 150px cards; fit them to the icon. */
+.dlc-wall-tile .preview > * { scale: 0.65; }
+.dlc-wall-close {
+ position: fixed; top: 16px; right: 16px; z-index: 2;
+ display: grid; place-items: center; width: 40px; height: 40px;
+ border: 1px solid var(--dlc-border); border-radius: 50%;
+ background: var(--dlc-surface); color: var(--mantine-color-text);
+ font-size: 18px;
+ transition: color 150ms ease-out, border-color 150ms ease-out;
+}
+.dlc-wall-close:hover, .dlc-wall-close:focus-visible { color: var(--dlc-accent); border-color: var(--dlc-accent); outline: none; }
+.dlc-wall-tip {
+ position: fixed; z-index: 3; max-width: 260px; padding: 10px 12px;
+ pointer-events: none;
+ background: var(--dlc-surface); border: 1px solid var(--dlc-border);
+ border-radius: var(--mantine-radius-md);
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
+ font-size: 12.5px; line-height: 1.4; color: var(--mantine-color-dimmed);
+}
+.dlc-wall-tip[hidden] { display: none; }
+.dlc-wall-tip b { display: block; font-size: 14px; font-weight: 650; color: var(--mantine-color-text); }
+.dlc-wall-tip code { font-family: var(--mantine-font-family-monospace); font-size: 11.5px; color: var(--dlc-accent); }
+.dlc-wall-tip span { display: block; margin-top: 4px; }
+@media (max-width: 48em) {
+ .dlc-wall { padding: 12px; }
+ .dlc-wall-grid { grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); }
+}
+
/* ---------- Component page ---------- */
.dlc-detail-page {
max-width: 1280px;
diff --git a/assets/gallery.js b/assets/gallery.js
index 091de90..ec13e75 100644
--- a/assets/gallery.js
+++ b/assets/gallery.js
@@ -226,33 +226,139 @@
}, 4000);
};
- const carousel = () => {
- const root = document.querySelector('.dlc-hero-carousel');
+ // Library carousel: one card at a time. A row with more than one page turns
+ // by itself while its card is showing; tiles off the current page are parked
+ // (data-off), and a card that is not showing parks all of them. Hover or
+ // focus holds everything still; reduced motion turns nothing on its own.
+ const libraries = () => {
+ const root = document.querySelector('.dlc-libs');
if (!root || root.dataset.ready) return;
root.dataset.ready = '1';
- const slides = root.querySelectorAll('.dlc-slide');
+ const cards = [...root.querySelectorAll('.dlc-lib')];
const dots = root.querySelector('.dlc-carousel-dots');
- dots.replaceChildren(...[...slides].map(() => document.createElement('i')));
- let current = 0, timer;
+ dots.replaceChildren(...cards.map(() => document.createElement('i')));
+ const track = root.querySelector('.dlc-libs-track');
+ let current = 0, held = false, timers = [];
+ const fit = () => { track.style.height = `${cards[current].offsetHeight}px`; };
+ new ResizeObserver(fit).observe(cards[0]); // width changes re-wrap the rows
+ window.addEventListener('resize', fit);
+ const showPage = (row, page) => {
+ row.dataset.page = page;
+ row.querySelectorAll('.dlc-lib-tile').forEach((t) => t.toggleAttribute('data-off', +t.dataset.page !== page));
+ row.classList.remove('is-turning');
+ void row.offsetWidth; // restart the entrance
+ row.classList.add('is-turning');
+ };
+ const park = (card) => card.querySelectorAll('.dlc-lib-tile').forEach((t) => t.setAttribute('data-off', ''));
+ const run = () => {
+ timers.forEach(clearInterval);
+ timers = [];
+ cards[current].querySelectorAll('.dlc-lib-row').forEach((row, i) => {
+ const pages = +row.dataset.pages;
+ if (reduced || pages < 2) return;
+ // Staggered, so sections of one card do not all turn at once.
+ timers.push(setInterval(() => { if (!held) showPage(row, ((+row.dataset.page || 0) + 1) % pages); }, 3200 + i * 700));
+ });
+ if (!reduced) timers.push(setInterval(() => { if (!held) show(current + 1); }, 14000));
+ };
const show = (i) => {
- const next = (i + slides.length) % slides.length;
- swap(slides, current, next);
- current = next;
+ const next = (i + cards.length) % cards.length;
+ if (next !== current) {
+ cards[current].classList.remove('is-active');
+ park(cards[current]);
+ current = next;
+ cards[current].classList.add('is-active');
+ }
+ cards[current].querySelectorAll('.dlc-lib-row').forEach((row) => showPage(row, 0));
dots.querySelectorAll('i').forEach((d, k) => d.classList.toggle('is-active', k === current));
+ fit();
+ run();
};
- // Under reduced motion the arrows still work; the page just stops moving on its own.
- const start = () => { clearInterval(timer); if (!reduced) timer = setInterval(() => show(current + 1), 5000); };
- slides[0].hidden = false;
show(0);
- start();
- root.querySelectorAll('.dlc-carousel-btn').forEach((b) => b.addEventListener('click', () => { show(current + +b.dataset.step); start(); }));
- root.addEventListener('mouseenter', () => clearInterval(timer));
- root.addEventListener('mouseleave', start);
+ root.querySelectorAll('.dlc-carousel-btn').forEach((b) => b.addEventListener('click', () => show(current + +b.dataset.step)));
+ dots.querySelectorAll('i').forEach((d, k) => d.addEventListener('click', () => show(k)));
+ // A mouse click leaves focus on its button; only keyboard focus holds.
+ root.addEventListener('mouseenter', () => { held = true; });
+ root.addEventListener('mouseleave', () => { held = false; });
+ root.addEventListener('focusin', (e) => { held = e.target.matches(':focus-visible'); });
+ root.addEventListener('focusout', () => { held = false; });
+ };
+
+ // Wall: one floating tooltip for every tile (a per-tile one would be clipped
+ // by the scroller at the edges), and Esc closes back to the overview.
+ const wallTip = (tile) => {
+ const tip = document.getElementById('wall-tip');
+ if (!tip) return;
+ if (!tile) { tip.hidden = true; return; }
+ const d = tile.dataset;
+ const row = (tag, text) => { const el = document.createElement(tag); el.textContent = text; return el; };
+ tip.replaceChildren(row('b', d.name), row('code', `dlc.${d.family}`), row('span', d.desc));
+ tip.hidden = false;
+ const r = tile.getBoundingClientRect(), t = tip.getBoundingClientRect();
+ const left = Math.min(Math.max(8, r.left + r.width / 2 - t.width / 2), innerWidth - t.width - 8);
+ const below = r.bottom + 8 + t.height <= innerHeight;
+ tip.style.left = `${left}px`;
+ tip.style.top = `${below ? r.bottom + 8 : r.top - 8 - t.height}px`;
+ };
+ const tileOf = (event) => event.target.closest && event.target.closest('.dlc-wall-tile');
+ // Hover waits a beat, so scrolling or sweeping across the wall stays quiet;
+ // only a pointer that settles on a tile gets its tooltip. Keyboard focus is
+ // deliberate, so it shows at once. Scrolling hides a hover tooltip again.
+ const WALL_TIP_DELAY = 500;
+ let tipTimer, tipTile = null;
+ const hideTip = () => { clearTimeout(tipTimer); tipTile = null; wallTip(null); };
+ document.addEventListener('mouseover', (event) => {
+ const tile = tileOf(event);
+ if (tile === tipTile) return;
+ hideTip();
+ if (!tile) return;
+ tipTile = tile;
+ tipTimer = setTimeout(() => wallTip(tile), WALL_TIP_DELAY);
+ });
+ document.addEventListener('focusin', (event) => { const tile = tileOf(event); if (tile) { hideTip(); wallTip(tile); } });
+ document.addEventListener('focusout', (event) => { if (tileOf(event)) hideTip(); });
+ // Focus can scroll its tile into view; that must not hide its own tooltip, so
+ // only a hover one (tipTile set) goes on scroll.
+ document.addEventListener('scroll', (event) => {
+ if (tipTile && event.target.classList?.contains('dlc-wall')) hideTip();
+ }, true);
+ document.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape' || !document.querySelector('.dlc-wall')) return;
+ history.pushState({}, '', '/');
+ window.dispatchEvent(new PopStateEvent('popstate'));
+ });
+
+ // Render only what is near the viewport, on the wall and on the overview:
+ // 474 live spinners are thousands of animated layers, over a gigabyte in the
+ // tab. A parked tile loses its preview (display: none drops its animations
+ // and layers) and its loadersz canvases, which run their own rAF loop, are
+ // paused. Tiles are observed once, and the observer lives as long as they do.
+ const parker = (root, margin) => new IntersectionObserver((entries) => {
+ for (const {target, isIntersecting} of entries) {
+ target.toggleAttribute('data-off', !isIntersecting);
+ target.querySelectorAll('loadersz-loader').forEach((el) => el.toggleAttribute('paused', !isIntersecting));
+ }
+ }, {root, rootMargin: margin});
+ const park = () => {
+ const wall = document.querySelector('.dlc-wall');
+ if (wall && !wall.dataset.ready) {
+ wall.dataset.ready = '1';
+ const io = parker(wall, '120px 0px');
+ wall.querySelectorAll('.dlc-wall-tile').forEach((tile) => io.observe(tile));
+ }
+ if (document.querySelector('.dlc-page .dlc-card:not([data-seen])')) {
+ const io = parker(null, '200px 0px');
+ document.querySelectorAll('.dlc-page .dlc-card:not([data-seen])').forEach((card) => {
+ card.dataset.seen = '1';
+ io.observe(card);
+ });
+ }
};
let markStarted = false;
new MutationObserver(() => {
if (!markStarted && document.querySelector('.dlc-brand-mark .dlc-mark')) { markStarted = true; cycleMark(); }
- carousel();
+ libraries();
+ park();
}).observe(document.documentElement, {childList: true, subtree: true});
})();
diff --git a/assets/icons/close.svg b/assets/icons/close.svg
new file mode 100644
index 0000000..3ef0461
--- /dev/null
+++ b/assets/icons/close.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/wall.svg b/assets/icons/wall.svg
new file mode 100644
index 0000000..edac118
--- /dev/null
+++ b/assets/icons/wall.svg
@@ -0,0 +1 @@
+
diff --git a/gallery.py b/gallery.py
index 32582c2..ea913a9 100644
--- a/gallery.py
+++ b/gallery.py
@@ -2,6 +2,7 @@
/ overview: every family, one card per spinner
/c// component page: live demo, controls, snippets, props
+ /wall full-screen matrix of every spinner, tiles link to their page
/credits upstream packages and their licenses
Run locally with `pixi run gallery`. Deployed behind gunicorn as
@@ -751,10 +752,12 @@ def detail_path(family: str, name: str) -> str:
def parse_pathname(pathname: Optional[str]) -> tuple[str, Optional[str], Optional[str]]:
- """Return (mode, family, name). mode is 'overview' | 'detail' | 'credits' | 'unknown'."""
+ """Return (mode, family, name). mode is 'overview' | 'wall' | 'detail' | 'credits' | 'unknown'."""
if not pathname or pathname == "/":
return "overview", None, None
pathname = unquote(pathname.rstrip("/") or "/")
+ if pathname == "/wall":
+ return "wall", None, None
if pathname in ("/credits", "/licenses"):
return "credits", None, None
m = re.match(r"^/c/([a-z0-9_]+)/([A-Za-z0-9_-]+)$", pathname)
@@ -1006,68 +1009,6 @@ def build_brand_mark() -> html.Span:
return html.Span(marks, className="dlc-brand-mark", **{"aria-hidden": "true"})
-# Hero carousel: one slide per featured loader, snippet beside the loader.
-# A curated handful, not the catalog: every highlighted snippet is DOM.
-FEATURED: list[tuple[str, str, dict[str, Any]]] = [
- ("loading_dev", "Dual", {"rate": 1.5}),
- ("ldrs", "Helix", {}),
- ("premium", "OrbitRings", {"ring_count": 4}),
- ("loading_dev", "Cascade", {"cap": "flat"}),
- ("spinners", "HashLoader", {}),
- ("indicators", "Mosaic", {}),
- ("m3", "LoadingIndicator", {}),
- ("loading_dev", "Blocks", {"sweep": "columns"}),
- ("spinners_react", "SpinnerDiamond", {"thickness": 140}),
- ("epic", "TrinityRingsSpinner", {}),
- ("loader_spinner", "DNA", {}),
- ("ldrs", "Mirage", {}),
- ("flicker", "Mycelium", {}),
- ("gridora", "orbit", {}),
-]
-
-
-def build_hero_carousel() -> html.Div:
- slides = []
- for family, name, extras in FEATURED:
- values = {"size": 48, "color": DEFAULT_COLOR, **extras}
- slides.append(html.Div(
- [
- # Wide enough that every featured call stays on one line, so
- # the slides are all the same height and nothing below jumps.
- dmc.CodeHighlight(code=build_snippet(family, name, values, 88).rstrip(),
- language="python", className="dlc-hero-code"),
- dcc.Link(
- [
- html.Div(instantiate(family, name, {**values, "size": 64}), className="dlc-hero-loader"),
- html.Div([html.Span(f"dlc.{family}.", className="dlc-hero-ns"),
- html.Strong(call_signature(family, name)[0].rsplit(".", 1)[1])],
- className="dlc-hero-name"),
- ],
- href=detail_path(family, name),
- className="dlc-hero-stage",
- ),
- ],
- className="dlc-slide",
- hidden=True,
- ))
- return html.Div(
- [
- html.Div(slides, className="dlc-carousel", id="hero-carousel"),
- html.Div(
- [
- html.Button(icon("chevron", "dlc-flip"), className="dlc-carousel-btn", type="button",
- **{"data-step": "-1", "aria-label": "Previous loader"}),
- html.Span(className="dlc-carousel-dots", **{"aria-hidden": "true"}), # indicator only
- html.Button(icon("chevron"), className="dlc-carousel-btn", type="button",
- **{"data-step": "1", "aria-label": "Next loader"}),
- ],
- className="dlc-carousel-nav",
- ),
- ],
- className="dlc-hero-carousel",
- )
-
-
def overview_values(family: str, name: str) -> dict[str, Any]:
"""Exactly what the component page starts with, sized for the card."""
props = configurable_props(family, name, COMPONENT_LOOKUP[(family, name)])
@@ -1119,6 +1060,7 @@ def search_index() -> list[dict[str, str]]:
]
entries += [
{"title": "Overview", "group": "Page", "href": "/"},
+ {"title": "View Gallery", "group": "Page", "href": "/wall"},
{"title": "Credits & Licenses", "group": "Page", "href": "/credits"},
]
return entries
@@ -1174,6 +1116,13 @@ def build_header() -> dmc.AppShellHeader:
],
className="dlc-search",
),
+ # CSS tooltip (data-tip): Mantine's Tooltip positions through the
+ # ref React 19 does not hand it, like the popovers above.
+ dmc.Anchor(
+ dmc.ActionIcon(icon("wall", "dlc-action-icon"), variant="default", size="lg",
+ **{"aria-label": "View Gallery"}),
+ href="/wall", className="dlc-tip-host", **{"data-tip": "View Gallery"},
+ ),
external_link(
# Iconify's line-md set: the octocat draws itself in, the
# theme icon morphs sun <-> moon on every switch.
@@ -1284,7 +1233,8 @@ def make_overview_card(family: str, name: str) -> dmc.Anchor:
href=detail_path(family, name),
underline="never",
className="dlc-card",
- **{"data-family": family, "data-name": name},
+ # data-off: parked until assets/gallery.js sees the card near the viewport.
+ **{"data-family": family, "data-name": name, "data-off": ""},
)
@@ -1320,6 +1270,111 @@ def family_grids(key: str, items: list[tuple[str, Callable]]) -> list:
return grids
+# Library carousel: one card per library, a few of its spinners at a time.
+# Few spinners show whole; more revolve in even pages. A library with
+# sections (flicker, gridora, loadersz) gets a row per section, each turning
+# on its own. Only the active card's visible page renders; assets/gallery.js
+# wakes and parks the tiles, so the cost is the DOM, not the animations.
+LIB_PAGE_MAX = 8
+# Sections sit side by side, so their rows are narrower: three columns of four,
+# or four columns of three once a library has more sections than fit two rows.
+LIB_SECTION_COLUMNS = {False: (3, 4), True: (4, 3)} # many sections -> (columns, tiles per page)
+LIB_MANY_SECTIONS = 6
+
+
+def lib_pages(names: list[str], most: int = LIB_PAGE_MAX) -> list[list[str]]:
+ """Even pages of at most `most` (9 becomes 5 + 4, not 8 + 1)."""
+ count = -(-len(names) // most)
+ size = -(-len(names) // count)
+ return [names[i:i + size] for i in range(0, len(names), size)]
+
+
+def make_lib_tile(family: str, name: str, page: int, off: bool) -> dmc.Anchor:
+ extra = {"data-off": ""} if off else {}
+ return dmc.Anchor(
+ [tile_preview(family, name), html.Div(name, className="name")],
+ href=detail_path(family, name),
+ underline="never",
+ className="dlc-lib-tile",
+ **{"data-page": str(page), **extra},
+ )
+
+
+def build_lib_row(family: str, names: list[str], active: bool, per_page: int = LIB_PAGE_MAX,
+ compact: bool = False) -> html.Div:
+ pages = lib_pages(names, per_page)
+ return html.Div(
+ [make_lib_tile(family, n, p, off=not (active and p == 0))
+ for p, page in enumerate(pages) for n in page],
+ className="dlc-lib-row is-compact" if compact else "dlc-lib-row",
+ style={"--cols": per_page} if compact else None,
+ **{"data-pages": str(len(pages))},
+ )
+
+
+def build_lib_card(key: str, label: str, items: list[tuple[str, Callable]], active: bool) -> html.Section:
+ credit = CREDITS_BY_FAMILY[key]
+ categories = family_categories(key, items)
+ if categories:
+ columns, per_page = LIB_SECTION_COLUMNS[len(categories) > LIB_MANY_SECTIONS]
+ rows = [
+ html.Div(
+ # The note is the section's tooltip: there is no room beside a narrow row.
+ [html.Div(title, className="dlc-lib-section-title", title=note),
+ build_lib_row(key, names, active, per_page, compact=True)],
+ className="dlc-lib-section",
+ )
+ for title, note, names in categories
+ ]
+ else:
+ rows = [build_lib_row(key, [n for n, _c in items], active)]
+ return html.Section(
+ [
+ dmc.Group(
+ [
+ dmc.Group(
+ [dmc.Title([icon(key), label], order=3, className="dlc-family-title"),
+ dmc.Code(f"dlc.{key}"),
+ dmc.Badge(str(len(items)), variant="light", color="gray", radius="sm",
+ className="dlc-tabular")],
+ gap="sm", wrap="wrap",
+ ),
+ dmc.Anchor(["Browse all ", icon("chevron")], href=f"/#family-{key}", size="sm",
+ className="dlc-lib-more"),
+ ],
+ justify="space-between", align="center", wrap="wrap",
+ ),
+ dmc.Text(credit["blurb"], c="dimmed", size="sm", mt=4, mb="md"),
+ html.Div(rows, className="dlc-lib-rows is-sections" if categories else "dlc-lib-rows",
+ style={"--sec-cols": columns} if categories else None),
+ ],
+ className="dlc-lib is-active" if active else "dlc-lib",
+ **{"aria-roledescription": "slide", "aria-label": label},
+ )
+
+
+def build_library_carousel() -> html.Div:
+ return html.Div(
+ [
+ html.Div([build_lib_card(key, label, items, active=i == 0)
+ for i, (key, label, items) in enumerate(FAMILIES)],
+ className="dlc-libs-track"),
+ html.Div(
+ [
+ html.Button(icon("chevron", "dlc-flip"), className="dlc-carousel-btn", type="button",
+ **{"data-step": "-1", "aria-label": "Previous library"}),
+ html.Span(className="dlc-carousel-dots", **{"aria-hidden": "true"}), # indicator only
+ html.Button(icon("chevron"), className="dlc-carousel-btn", type="button",
+ **{"data-step": "1", "aria-label": "Next library"}),
+ ],
+ className="dlc-carousel-nav",
+ ),
+ ],
+ className="dlc-libs",
+ **{"aria-roledescription": "carousel", "aria-label": "Libraries"},
+ )
+
+
def build_overview() -> html.Div:
sections = []
for key, label, items in FAMILIES:
@@ -1359,7 +1414,7 @@ def build_overview() -> html.Div:
),
dmc.CodeHighlight(code="pip install dash-loading-components", language="bash",
className="dlc-install", maw=560, mb="sm"),
- build_hero_carousel(),
+ build_library_carousel(),
],
className="dlc-hero",
),
@@ -1370,6 +1425,61 @@ def build_overview() -> html.Div:
)
+# Wall tiles are 64px, so a few loaders whose motion spills past their box need
+# a further shrink on top of OVERVIEW_SCALE (and the wall's own CSS scale).
+# react-spinners' dot rows wrap into a column when three dots at the card size
+# do not fit the tile, and a transform cannot undo a wrap. Give them a size that
+# fits one row and cancel the wall's CSS scale.
+WALL_SIZE_FIT = ("BeatLoader", "PulseLoader", "SyncLoader")
+WALL_SCALE: dict[tuple[str, str], float] = {
+ ("epic", "HollowDotsSpinner"): 0.78,
+ ("indicators", "BlinkBlur"): 0.64,
+ ("indicators", "LifeLine"): 0.7,
+ **{("spinners", n): 1.5 for n in WALL_SIZE_FIT},
+ ("spinners", "GridLoader"): 0.88,
+ ("spinners", "PropagateLoader"): 0.65,
+}
+
+
+def tile_preview(family: str, name: str) -> html.Div:
+ """The overview's preview, fitted to a compact tile (the wall and the library carousel)."""
+ values = overview_values(family, name)
+ if family == "spinners" and name in WALL_SIZE_FIT:
+ values["size"] = 16
+ node = instantiate(family, name, values)
+ scale = OVERVIEW_SCALE.get((family, name), 1) * WALL_SCALE.get((family, name), 1)
+ if scale != 1:
+ node = html.Div(node, style={"transform": f"scale({scale})"})
+ return html.Div(node, className="preview")
+
+
+def make_wall_tile(family: str, name: str) -> dmc.Anchor:
+ """A bare spinner linking to its page; the tooltip in assets/gallery.js reads the data-*."""
+ label = FAMILY_LOOKUP[family][0]
+ return dmc.Anchor(
+ tile_preview(family, name),
+ href=detail_path(family, name),
+ underline="never",
+ className="dlc-wall-tile",
+ # data-off: parked until assets/gallery.js sees the tile near the viewport.
+ **{"data-name": name, "data-family": family, "data-label": label, "data-off": "",
+ "data-desc": description_for(family, name, label), "aria-label": f"{name}, {label}"},
+ )
+
+
+def build_wall() -> html.Div:
+ """Every spinner as a compact tile on a page-less, full-viewport scroller."""
+ return html.Div(
+ [
+ dmc.Anchor(icon("close"), href="/", className="dlc-wall-close",
+ **{"aria-label": "Close the wall"}),
+ html.Div([make_wall_tile(f, n) for f, n in CATALOG], className="dlc-wall-grid"),
+ html.Div(className="dlc-wall-tip", id="wall-tip", role="tooltip", hidden=True),
+ ],
+ className="dlc-wall",
+ )
+
+
def section_title(prop: str) -> str:
return PROP_SECTION_TITLES.get(prop, prop)
@@ -1821,6 +1931,8 @@ def render_page(pathname):
mode, family, name = parse_pathname(pathname)
if mode == "overview":
return build_overview()
+ if mode == "wall":
+ return build_wall()
if mode == "detail":
return build_detail(family, name)
if mode == "credits":
diff --git a/tests/test_gallery_overview.py b/tests/test_gallery_overview.py
index a87495e..df6ec0c 100644
--- a/tests/test_gallery_overview.py
+++ b/tests/test_gallery_overview.py
@@ -83,12 +83,13 @@
TOLERANCE_PX = 2.0
-def _worst_overflow(dash_duo, seconds: float) -> dict[str, list[float]]:
+def _worst_overflow(dash_duo, seconds: float, selector: str = '.dlc-card') -> dict[str, list[float]]:
"""Sample CLIP_SCAN for `seconds` and keep each card's widest overflow."""
+ scan = CLIP_SCAN.replace("'.dlc-card'", repr(selector))
worst: dict[str, list[float]] = {}
deadline = time.time() + seconds
while time.time() < deadline:
- for key, over in dash_duo.driver.execute_script(CLIP_SCAN).items():
+ for key, over in dash_duo.driver.execute_script(scan).items():
prev = worst.setdefault(key, [0.0, 0.0, 0.0, 0.0])
prev[0] = min(prev[0], over[0])
prev[1] = min(prev[1], over[1])
@@ -104,6 +105,10 @@ def test_no_overview_card_clips_its_spinner(dash_duo):
dash_duo.start_server(app)
dash_duo.driver.set_window_size(1440, 1200)
dash_duo.wait_for_element('.dlc-card')
+ # Cards far from the viewport are parked (no preview); once the observer
+ # has settled, wake them all so every one is measured.
+ time.sleep(1)
+ dash_duo.driver.execute_script("document.querySelectorAll('.dlc-card').forEach((c) => c.removeAttribute('data-off'))")
worst = _worst_overflow(dash_duo, seconds=3.0)
clipped = {
@@ -117,6 +122,77 @@ def test_no_overview_card_clips_its_spinner(dash_duo):
)
+def test_wall_has_every_spinner_and_none_clips(dash_duo):
+ """/wall is one bare tile per spinner, each linking to its page, and (as on
+ the overview) none may clip its spinner at the tile's size."""
+ import gallery
+
+ app = import_app('gallery')
+ dash_duo.start_server(app)
+ dash_duo.driver.set_window_size(1440, 1200)
+ dash_duo.driver.get(dash_duo.server_url + '/wall')
+ dash_duo.wait_for_element('.dlc-wall-tile')
+
+ tiles = dash_duo.driver.find_elements('css selector', '.dlc-wall-tile')
+ assert len(tiles) == len(gallery.CATALOG)
+ assert not dash_duo.driver.find_elements('css selector', '.dlc-wall h1, .dlc-wall .dlc-card')
+
+ worst = _worst_overflow(dash_duo, seconds=3.0, selector='.dlc-wall-tile')
+ clipped = {
+ key: [round(v, 1) for v in over]
+ for key, over in worst.items()
+ if min(over[0], over[1]) < -TOLERANCE_PX or max(over[2], over[3]) > TOLERANCE_PX
+ }
+ assert not clipped, f'wall tiles clip their spinner: {clipped}. Give them a gallery.WALL_SCALE entry.'
+
+ # Hover details come from one floating tooltip, and only once the pointer
+ # settles: not on arrival, and not while the wall scrolls.
+ tile = tiles[0]
+ tip = dash_duo.driver.find_element('css selector', '#wall-tip')
+ hover = "arguments[0].dispatchEvent(new MouseEvent('mouseover', {bubbles: true}))"
+ dash_duo.driver.execute_script(hover, tile)
+ assert not tip.is_displayed()
+ time.sleep(0.2)
+ assert not tip.is_displayed()
+ dash_duo.wait_for_element('#wall-tip:not([hidden])', timeout=3)
+ dash_duo.driver.execute_script("document.querySelector('.dlc-wall').scrollTop = 40")
+ dash_duo.wait_for_element('#wall-tip[hidden]', timeout=3)
+
+ # Keyboard focus is deliberate, so it shows at once.
+ dash_duo.driver.execute_script("arguments[0].focus()", tile)
+ assert gallery.CATALOG[0][1] in tip.text and 'dlc.' + gallery.CATALOG[0][0] in tip.text
+ tile.click()
+ dash_duo.wait_for_text_to_equal('h1', gallery.CATALOG[0][1])
+ assert dash_duo.driver.current_url.endswith(gallery.detail_path(*gallery.CATALOG[0]))
+
+
+def test_library_carousel_shows_a_few_of_one_library_at_a_time(dash_duo):
+ """The landing page carries one card per library. Only the active card has
+ live tiles, never more than a page of them per row, and the arrows move
+ between libraries."""
+ import gallery
+
+ app = import_app('gallery')
+ dash_duo.start_server(app)
+ dash_duo.driver.set_window_size(1440, 1200)
+ dash_duo.wait_for_element('.dlc-lib.is-active .dlc-lib-tile:not([data-off])')
+
+ cards = dash_duo.driver.find_elements('css selector', '.dlc-lib')
+ assert len(cards) == len(gallery.FAMILIES)
+
+ live = "return document.querySelectorAll('.dlc-lib:not(.is-active) .dlc-lib-tile:not([data-off])').length"
+ assert dash_duo.driver.execute_script(live) == 0
+ first = dash_duo.find_element('.dlc-lib.is-active').get_attribute('aria-label')
+ dash_duo.find_element('.dlc-carousel-btn[data-step="1"]').click()
+ dash_duo.wait_for_element('.dlc-lib.is-active .dlc-lib-tile:not([data-off])')
+ assert dash_duo.find_element('.dlc-lib.is-active').get_attribute('aria-label') != first
+ assert dash_duo.driver.execute_script(live) == 0
+
+ # A library of nine is two even pages, not eight and one.
+ assert [len(p) for p in gallery.lib_pages(list('abcdefghi'))] == [5, 4]
+ assert [len(p) for p in gallery.lib_pages(list('abcdefgh'))] == [8]
+
+
def test_brand_marks_do_not_clip(dash_duo):
"""The header mark cycles through every loader in a 22px slot with
overflow hidden (the slide needs it). A loader whose motion spills past