From 155f999e6158852d51500f09c39723620cd14568 Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:14:41 -0400 Subject: [PATCH 1/7] feat(gallery): add a full-screen wall of every spinner /wall shows one bare 64px tile per spinner on a viewport-filling scroller. Hover or focus shows a tooltip with the name, namespace and description, click opens the spinner's page, Esc or the close button returns to the overview. Linked from the header, navbar and search. Wall tiles reuse the overview previews; WALL_SCALE and a fixed size for the react-spinners dot rows keep every spinner inside its tile, which the new test checks. Signed-off-by: Evan Rees --- assets/gallery.css | 68 ++++++++++++++++++++++++++++++++++ assets/gallery.js | 26 +++++++++++++ assets/icons/close.svg | 1 + assets/icons/wall.svg | 1 + gallery.py | 63 ++++++++++++++++++++++++++++++- tests/test_gallery_overview.py | 38 ++++++++++++++++++- 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 assets/icons/close.svg create mode 100644 assets/icons/wall.svg diff --git a/assets/gallery.css b/assets/gallery.css index 3350188..559fb48 100644 --- a/assets/gallery.css +++ b/assets/gallery.css @@ -44,6 +44,9 @@ 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; } .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; } @@ -287,6 +290,71 @@ 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; +} +.dlc-wall-tile .preview { + flex: 1; min-width: 0; + display: flex; align-items: center; justify-content: center; + overflow: hidden; position: relative; +} +/* 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..8529fe6 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -250,6 +250,32 @@ root.addEventListener('mouseleave', start); }; + // 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'); + document.addEventListener('mouseover', (event) => wallTip(tileOf(event))); + document.addEventListener('focusin', (event) => wallTip(tileOf(event))); + document.addEventListener('focusout', (event) => { if (tileOf(event)) wallTip(null); }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !document.querySelector('.dlc-wall')) return; + history.pushState({}, '', '/'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + let markStarted = false; new MutationObserver(() => { if (!markStarted && document.querySelector('.dlc-brand-mark .dlc-mark')) { markStarted = true; cycleMark(); } 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..039e490 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) @@ -1119,6 +1122,7 @@ def search_index() -> list[dict[str, str]]: ] entries += [ {"title": "Overview", "group": "Page", "href": "/"}, + {"title": "Full-screen wall", "group": "Page", "href": "/wall"}, {"title": "Credits & Licenses", "group": "Page", "href": "/credits"}, ] return entries @@ -1174,6 +1178,11 @@ def build_header() -> dmc.AppShellHeader: ], className="dlc-search", ), + dmc.Anchor( + dmc.ActionIcon(icon("wall", "dlc-action-icon"), variant="default", size="lg", + **{"aria-label": "Full-screen wall of every spinner"}), + href="/wall", + ), external_link( # Iconify's line-md set: the octocat draws itself in, the # theme icon morphs sun <-> moon on every switch. @@ -1206,6 +1215,7 @@ def build_navbar() -> dmc.AppShellNavbar: """Static navbar; NavLink `active="exact"` follows the URL on its own.""" sections = [ dmc.NavLink(label="Overview", href="/", leftSection=icon("overview"), active="exact"), + dmc.NavLink(label="Full-screen wall", href="/wall", leftSection=icon("wall"), active="exact"), # Straight to the GitHub issue form: the template asks for the package, # license and React peer range, which is what deciding a wrap needs. dmc.NavLink(label="Request a library", href=ISSUE_REQUEST_URL, target="_blank", @@ -1370,6 +1380,55 @@ 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 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-*.""" + 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})"}) + label = FAMILY_LOOKUP[family][0] + return dmc.Anchor( + html.Div(node, className="preview"), + href=detail_path(family, name), + underline="never", + className="dlc-wall-tile", + **{"data-name": name, "data-family": family, "data-label": label, + "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 +1880,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..2b19735 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]) @@ -117,6 +118,39 @@ 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; a click opens the page. + tile = tiles[0] + dash_duo.driver.execute_script("arguments[0].focus()", tile) + tip = dash_duo.find_element('#wall-tip') + 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_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 From 7b4842f4badd8f743358027928ade7486bf9ae6b Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:19:32 -0400 Subject: [PATCH 2/7] feat(gallery): delay the wall tooltip until the pointer settles Hover waits 500ms before the tooltip shows, so scrolling or sweeping across the wall stays quiet, and any scroll hides it. Keyboard focus still shows it at once. Signed-off-by: Evan Rees --- assets/gallery.js | 20 +++++++++++++++++--- tests/test_gallery_overview.py | 15 +++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/assets/gallery.js b/assets/gallery.js index 8529fe6..8114ebd 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -267,9 +267,23 @@ 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'); - document.addEventListener('mouseover', (event) => wallTip(tileOf(event))); - document.addEventListener('focusin', (event) => wallTip(tileOf(event))); - document.addEventListener('focusout', (event) => { if (tileOf(event)) wallTip(null); }); + // 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. Any scroll hides it 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(); }); + document.addEventListener('scroll', (event) => { if (event.target.classList?.contains('dlc-wall')) hideTip(); }, true); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !document.querySelector('.dlc-wall')) return; history.pushState({}, '', '/'); diff --git a/tests/test_gallery_overview.py b/tests/test_gallery_overview.py index 2b19735..45711c5 100644 --- a/tests/test_gallery_overview.py +++ b/tests/test_gallery_overview.py @@ -141,10 +141,21 @@ def test_wall_has_every_spinner_and_none_clips(dash_duo): } assert not clipped, f'wall tiles clip their spinner: {clipped}. Give them a gallery.WALL_SCALE entry.' - # Hover details come from one floating tooltip; a click opens the page. + # 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) - tip = dash_duo.find_element('#wall-tip') 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]) From 395b461f2a0815cfde7140d3cd641e9b2b34153e Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:20:21 -0400 Subject: [PATCH 3/7] fix(gallery): keep the wall's focus tooltip when focus scrolls its tile Signed-off-by: Evan Rees --- assets/gallery.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/gallery.js b/assets/gallery.js index 8114ebd..4b7d311 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -269,7 +269,7 @@ 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. Any scroll hides it again. + // 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); }; @@ -283,7 +283,11 @@ }); document.addEventListener('focusin', (event) => { const tile = tileOf(event); if (tile) { hideTip(); wallTip(tile); } }); document.addEventListener('focusout', (event) => { if (tileOf(event)) hideTip(); }); - document.addEventListener('scroll', (event) => { if (event.target.classList?.contains('dlc-wall')) hideTip(); }, true); + // 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({}, '', '/'); From 75bca0b5fc69ae20c47012a8ef76ff04d1788ed4 Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:23:44 -0400 Subject: [PATCH 4/7] feat(gallery): drop the wall navbar link, add a View Gallery tooltip The header icon is the one entry point to the wall. It gets a CSS tooltip (Mantine's Tooltip needs the ref React 19 does not hand it) that shows after a short delay. Signed-off-by: Evan Rees --- assets/gallery.css | 13 +++++++++++++ gallery.py | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/assets/gallery.css b/assets/gallery.css index 559fb48..271c61b 100644 --- a/assets/gallery.css +++ b/assets/gallery.css @@ -47,6 +47,19 @@ p { text-wrap: pretty; } .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; } diff --git a/gallery.py b/gallery.py index 039e490..b21a225 100644 --- a/gallery.py +++ b/gallery.py @@ -1122,7 +1122,7 @@ def search_index() -> list[dict[str, str]]: ] entries += [ {"title": "Overview", "group": "Page", "href": "/"}, - {"title": "Full-screen wall", "group": "Page", "href": "/wall"}, + {"title": "View Gallery", "group": "Page", "href": "/wall"}, {"title": "Credits & Licenses", "group": "Page", "href": "/credits"}, ] return entries @@ -1178,10 +1178,12 @@ 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": "Full-screen wall of every spinner"}), - href="/wall", + **{"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 @@ -1215,7 +1217,6 @@ def build_navbar() -> dmc.AppShellNavbar: """Static navbar; NavLink `active="exact"` follows the URL on its own.""" sections = [ dmc.NavLink(label="Overview", href="/", leftSection=icon("overview"), active="exact"), - dmc.NavLink(label="Full-screen wall", href="/wall", leftSection=icon("wall"), active="exact"), # Straight to the GitHub issue form: the template asks for the package, # license and React peer range, which is what deciding a wrap needs. dmc.NavLink(label="Request a library", href=ISSUE_REQUEST_URL, target="_blank", From d84c03fbbdea983fabaacd560644dbb17af4c9e0 Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:25:47 -0400 Subject: [PATCH 5/7] perf(gallery): render only the wall tiles near the viewport 474 live spinners meant thousands of animated layers and 160 canvas rAF loops, over a gigabyte in the tab. Tiles start parked (no preview, loadersz paused) and an IntersectionObserver wakes those within 300px of the viewport and parks them again on scroll-out. Signed-off-by: Evan Rees --- assets/gallery.css | 4 ++++ assets/gallery.js | 17 +++++++++++++++++ gallery.py | 3 ++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/assets/gallery.css b/assets/gallery.css index 271c61b..c72a4d6 100644 --- a/assets/gallery.css +++ b/assets/gallery.css @@ -340,6 +340,10 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none display: flex; align-items: center; justify-content: center; overflow: hidden; 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 { diff --git a/assets/gallery.js b/assets/gallery.js index 4b7d311..04d2cf2 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -294,9 +294,26 @@ window.dispatchEvent(new PopStateEvent('popstate')); }); + // Wall: render only the tiles near the viewport. Parked ones lose their + // preview (display: none drops its animations and layers), and loadersz + // canvases, which run their own rAF loop, are paused. + const wallPark = () => { + const wall = document.querySelector('.dlc-wall'); + if (!wall || wall.dataset.ready) return; + wall.dataset.ready = '1'; + const io = 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: wall, rootMargin: '300px 0px'}); + wall.querySelectorAll('.dlc-wall-tile').forEach((tile) => io.observe(tile)); + }; + let markStarted = false; new MutationObserver(() => { if (!markStarted && document.querySelector('.dlc-brand-mark .dlc-mark')) { markStarted = true; cycleMark(); } carousel(); + wallPark(); }).observe(document.documentElement, {childList: true, subtree: true}); })(); diff --git a/gallery.py b/gallery.py index b21a225..1b990b9 100644 --- a/gallery.py +++ b/gallery.py @@ -1412,7 +1412,8 @@ def make_wall_tile(family: str, name: str) -> dmc.Anchor: href=detail_path(family, name), underline="never", className="dlc-wall-tile", - **{"data-name": name, "data-family": family, "data-label": label, + # 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}"}, ) From 9390a6b21d827908cd22500a7f12d9b1f5ea87fa Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 15:57:53 -0400 Subject: [PATCH 6/7] perf(gallery): drop the per-tile clip and wake tiles closer to the viewport Profiled in headless Chrome: a clip per animated tile cost about 40% of the frame time while scrolling, and frame time scales with the number of live tiles. Removing the overflow clip (WALL_SCALE already keeps every spinner inside its tile) and waking tiles within 120px instead of 300px takes the average scroll frame from about 143ms to about 63ms. Signed-off-by: Evan Rees --- assets/gallery.css | 4 +++- assets/gallery.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/gallery.css b/assets/gallery.css index c72a4d6..b2853ad 100644 --- a/assets/gallery.css +++ b/assets/gallery.css @@ -335,10 +335,12 @@ html[data-mac] .dlc-mod-other, html:not([data-mac]) .dlc-mod-mac { display: none 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; - overflow: hidden; position: relative; + 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 diff --git a/assets/gallery.js b/assets/gallery.js index 04d2cf2..cec91f5 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -306,7 +306,7 @@ target.toggleAttribute('data-off', !isIntersecting); target.querySelectorAll('loadersz-loader').forEach((el) => el.toggleAttribute('paused', !isIntersecting)); } - }, {root: wall, rootMargin: '300px 0px'}); + }, {root: wall, rootMargin: '120px 0px'}); wall.querySelectorAll('.dlc-wall-tile').forEach((tile) => io.observe(tile)); }; From 0c5898d5cde77ca3c035d1c41a8d32bcd4f43c5c Mon Sep 17 00:00:00 2001 From: Evan Rees Date: Fri, 16 Oct 2026 21:46:15 -0400 Subject: [PATCH 7/7] feat(gallery): replace the hero with a library carousel, park offscreen overview cards The landing page leads with one card per library instead of one featured spinner. A library with few spinners shows them all; more revolve in even pages. Libraries with sections (flicker, gridora, loadersz) get a row per section, each turning on its own. Only the active card's visible page renders. The overview below it was the heavy part of the page (every card live, over a gigabyte after scrolling), so cards now park until they are near the viewport, the way the wall's tiles do: running animations on the landing page go from about 2900 to under 100. Signed-off-by: Evan Rees --- assets/gallery.css | 94 +++++++++++------ assets/gallery.js | 105 +++++++++++++------ gallery.py | 183 +++++++++++++++++++++------------ tests/test_gallery_overview.py | 31 ++++++ 4 files changed, 283 insertions(+), 130 deletions(-) diff --git a/assets/gallery.css b/assets/gallery.css index b2853ad..606c51e 100644 --- a/assets/gallery.css +++ b/assets/gallery.css @@ -206,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; @@ -253,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 { @@ -296,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); diff --git a/assets/gallery.js b/assets/gallery.js index cec91f5..ec13e75 100644 --- a/assets/gallery.js +++ b/assets/gallery.js @@ -226,28 +226,62 @@ }, 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 @@ -294,26 +328,37 @@ window.dispatchEvent(new PopStateEvent('popstate')); }); - // Wall: render only the tiles near the viewport. Parked ones lose their - // preview (display: none drops its animations and layers), and loadersz - // canvases, which run their own rAF loop, are paused. - const wallPark = () => { + // 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) return; - wall.dataset.ready = '1'; - const io = 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: wall, rootMargin: '120px 0px'}); - wall.querySelectorAll('.dlc-wall-tile').forEach((tile) => io.observe(tile)); + 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(); - wallPark(); + libraries(); + park(); }).observe(document.documentElement, {childList: true, subtree: true}); })(); diff --git a/gallery.py b/gallery.py index 1b990b9..ea913a9 100644 --- a/gallery.py +++ b/gallery.py @@ -1009,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)]) @@ -1295,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": ""}, ) @@ -1331,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: @@ -1370,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", ), @@ -1397,8 +1441,8 @@ def build_overview() -> html.Div: } -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-*.""" +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 @@ -1406,9 +1450,14 @@ def make_wall_tile(family: str, name: str) -> dmc.Anchor: 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( - html.Div(node, className="preview"), + tile_preview(family, name), href=detail_path(family, name), underline="never", className="dlc-wall-tile", diff --git a/tests/test_gallery_overview.py b/tests/test_gallery_overview.py index 45711c5..df6ec0c 100644 --- a/tests/test_gallery_overview.py +++ b/tests/test_gallery_overview.py @@ -105,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 = { @@ -162,6 +166,33 @@ def test_wall_has_every_spinner_and_none_clips(dash_duo): 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