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