openvibers/openvibe.live Commits
Home rails: per-row density control, bigger Load more, and a consistent paste grid
Changes
@@@ -279,6 +279,10 @@.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }++/* Open state: the label is one short word, so give it the same presence as the buttons beside it++ instead of letting it collapse to a cramped chip. */++.star-expand.is-open { padding-right: 16px; }++.star-expand.is-open span { font-weight: 800; }/* The disclosure. Fades up as the card grows — see animateHeightChange().[hidden] is a UA rule, so any author `display` here silently beats it and the panel never@@@ -333,6 +337,39 @@.star-card.is-compact ~ .star-card-border::before,.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }++/* ── Expanded card ───────────────────────────────────────────────────────────++ Opening the card used to drop it back to the original feature layout: centred text, a big++ stacked avatar and the actions in a different place from where they were a moment ago. The++ expanded state now keeps the compact card's shape and simply reveals more underneath it, so++ the toggle reads as "more detail" rather than "a different component". */++.star-card:not(.is-compact) {++ display: grid; grid-template-columns: auto minmax(0, 1fr);++ align-items: center; gap: 12px 16px; text-align: left;++ padding: 16px 18px 18px;++}++.star-card:not(.is-compact) .star-avatar-wrap,++.star-card:not(.is-compact) .star-avatar { width: 76px; height: 76px; }++.star-card:not(.is-compact) .star-avatar { font-size: 1.7rem; }++.star-card:not(.is-compact) .star-body {++ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0;++}++.star-card:not(.is-compact) .star-name { margin: 0; }++.star-card:not(.is-compact) .star-chips { margin: 4px 0 0; }++.star-card:not(.is-compact) .star-badge { display: none; }++.star-card:not(.is-compact) .star-more { text-align: left; }++.star-card:not(.is-compact) .star-more > * { margin-left: 0; margin-right: 0; }++.star-card:not(.is-compact) .star-actions { justify-content: flex-start; }++++/* The disclosure body: its own quiet panel so the reason, bio and overview read as one block++ rather than three loose paragraphs stacked on the card background. */++.star-more {++ margin-top: 12px; padding: 13px 15px; border-radius: 13px;++ background: color-mix(in srgb, var(--bg-primary) 45%, transparent);++ border: 1px solid color-mix(in srgb, #fbbf24 13%, var(--border));++}++.star-more > *:first-child { margin-top: 0; }++.star-more > *:last-child { margin-bottom: 0; }++/* Wide enough for one line: avatar, who they are, and the actions on the right. Below this theactions drop to their own row rather than squeezing. */@media (min-width: 820px) {@@@ -15285,3 +15285,18 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgthe content above. */.home-intro-demoted { margin-top: 26px; }.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }++++/* The paste rail was a different grid to the VOD and clip rails (a 260px minimum against their++ 300px), so it broke to one column at a different width and felt unrelated to them. It uses the++ same track sizing now, and all three accept the per-row control. */++.home-pastes-list { grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; }++@media (max-width: 640px) {++ .home-pastes-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }++ .home-pastes-list .paste-card-title, .home-pastes-list .stream-card-title {++ font-size: 0.82rem; line-height: 1.25;++ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;++ }++}++/* The density control sits at the end of a section header, after the "View all" link. */++.section-header .ovd { order: 4; margin-left: 8px; }++@media (max-width: 420px) { .section-header .ovd { display: none; } }@@@ -88,10 +88,10 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=194">++ <link rel="stylesheet" href="/css/style.css?v=196"><link rel="stylesheet" href="/css/home-fx.css?v=13"><link rel="stylesheet" href="/css/home-tour.css?v=8">-- <link rel="stylesheet" href="/css/i18n-star.css?v=10">++ <link rel="stylesheet" href="/css/i18n-star.css?v=11"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"><link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">@@@ -559,7 +559,7 @@<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div><!-- Recent VODs -->-- <div class="section-header" style="--sc:#a78bfa" id="home-recent-vods-header" style="display:none">++ <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header"><h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2><a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div>@@@ -567,7 +567,7 @@<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div><!-- Recent Clips -->-- <div class="section-header" style="--sc:#f472b6" id="home-clips-header" style="display:none">++ <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header"><h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2><a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div>@@@ -575,7 +575,7 @@<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div><!-- Recent Pastes -->-- <div class="section-header" style="--sc:#2dd4bf" id="home-pastes-header" style="display:none">++ <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header"><h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2><a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div>@@@ -5085,7 +5085,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/live-notify.js?v=3" defer></script><script src="/js/ov-skeleton.js?v=1" defer></script><script src="/js/ov-odometer.js?v=3" defer></script>--<script src="/js/app.js?v=167" defer></script>++<script src="/js/ov-density.js?v=1" defer></script>++<script src="/js/app.js?v=169" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -2457,10 +2457,14 @@ async function loadHomeDigest() {async function loadHomeRecentOnline(page, append) {if (page !== undefined) _homeRecentOnlinePage = page;-- const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;++ const { offset, limit } = homePageRange(_homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE);try {-- const recentData = await api(`/streams/recently-online?limit=${HOME_RECENT_ONLINE_PAGE_SIZE}&offset=${offset}`);++ const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);++ if (window.OVDensity) {++ OVDensity.attach(document.getElementById('stream-grid-recent'),++ { key: 'recent', header: document.getElementById('stream-grid-recent')?.previousElementSibling });++ }renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');} catch { /* silent */ }}@@@ -2519,7 +2523,7 @@ function renderRecentlyOnline(containerId, streamers, append) {async function loadHomeRecentVods(page, append) {if (page !== undefined) _homeRecentVodsPage = page;-- const offset = (_homeRecentVodsPage - 1) * HOME_RECENT_VODS_PAGE_SIZE;++ const { offset, limit } = homePageRange(_homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE);try {const render = (data) => {const vods = data.vods || [];@@@ -2550,15 +2554,16 @@ async function loadHomeRecentVods(page, append) {</a>`;}).join(''));++ if (window.OVDensity) OVDensity.attach(grid, { key: 'vods', header: header });renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');};-- await apiSWR(`/streams/recent-vods?limit=${HOME_RECENT_VODS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });++ await apiSWR(`/streams/recent-vods?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });} catch { /* silent */ }}async function loadHomeClips(page, append) {if (page !== undefined) _homeClipsPage = page;-- const offset = (_homeClipsPage - 1) * HOME_CLIPS_PAGE_SIZE;++ const { offset, limit } = homePageRange(_homeClipsPage, HOME_CLIPS_PAGE_SIZE);try {const render = (data) => {const clips = data.clips || [];@@@ -2585,15 +2590,16 @@ async function loadHomeClips(page, append) {</div></a>`).join(''));++ if (window.OVDensity) OVDensity.attach(grid, { key: 'clips', header: header });renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');};-- await apiSWR(`/clips?limit=${HOME_CLIPS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });++ await apiSWR(`/clips?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });} catch { /* silent */ }}async function loadHomePastes(page, append) {if (page !== undefined) _homePastesPage = page;-- const offset = (_homePastesPage - 1) * HOME_PASTES_PAGE_SIZE;++ const { offset, limit } = homePageRange(_homePastesPage, HOME_PASTES_PAGE_SIZE);try {const render = (data) => {const pastes = data.pastes || [];@@@ -2624,9 +2630,10 @@ async function loadHomePastes(page, append) {</div></a>`;}).join(''));++ if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');};-- await apiSWR(`/pastes?limit=${HOME_PASTES_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });++ await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });} catch { /* silent */ }}@@@ -2999,6 +3006,24 @@ const HOME_RECENT_ONLINE_PAGE_SIZE = _narrow ? 4 : 6;const HOME_RECENT_VODS_PAGE_SIZE = _narrow ? 4 : 6;const HOME_CLIPS_PAGE_SIZE = _narrow ? 4 : 6;const HOME_PASTES_PAGE_SIZE = _narrow ? 4 : 5;++/**++ * How many more a "Load more" press brings in.++ *++ * The first page is deliberately small — it is a taste of the section, not a browse view. Once++ * somebody has pressed Load more they have said they want to browse, so subsequent pages are++ * larger: fewer presses to get anywhere, and the cost is paid only by people who asked for it.++ */++const HOME_LOAD_MORE = 8;++/** Offset and limit for a given page, given a first page that is a different size to the rest. */++function homePageRange(page, firstSize) {++ if (page <= 1) return { offset: 0, limit: firstSize };++ return { offset: firstSize + (page - 2) * HOME_LOAD_MORE, limit: HOME_LOAD_MORE };++}++/** How many rows are on screen once `page` has loaded. */++function homeShownCount(page, firstSize) {++ const r = homePageRange(page, firstSize);++ return r.offset + r.limit;++}// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name// taking 'newest'|'oldest'.@@@ -3054,13 +3079,15 @@ function renderVodsPagination(containerId, page, total, pageSize, setterName, itfunction renderHomePagination(containerId, total, page, pageSize, setterName) {const el = document.getElementById(containerId);if (!el) return;-- const shown = page * pageSize;++ // The first page is a different size to the ones after it, so "how many are on screen" is not++ // page * pageSize.++ const shown = homeShownCount(page, pageSize);const left = Math.max(0, (total || 0) - shown);// These containers ship with an inline display:none — the old pager unhid them itself, and// this one has to as well or the button renders into a hidden box and nobody ever sees it.if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }el.style.display = 'block';-- const next = Math.min(left, pageSize);++ const next = Math.min(left, HOME_LOAD_MORE);el.innerHTML = `<button type="button" class="home-load-more" onclick="${setterName}(${page + 1}, true)"><i class="fa-solid fa-arrow-down"></i><span>Load ${next} more</span>@@@ -9686,7 +9713,7 @@ async function loadHomeStar() {if (card && btn && more) {card.classList.add('is-compact');const label = () => {-- btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Less';++ btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';btn.classList.toggle('is-open', !more.hidden);btn.setAttribute('aria-expanded', String(!more.hidden));};nnew file mode 100644@@@ -0,0 +1,162 @@++/**++ * ov-density.js — per-section "how many cards per row" control.++ *++ * The number of columns a feed should use is not a fixed design decision; it depends on the++ * viewport, the reader's eyesight and what they are doing. So rather than hard-coding counts per++ * breakpoint, this measures the container and offers every column count that still leaves a card++ * wide enough to be worth looking at.++ *++ * The range is computed, not guessed:++ * max columns = floor(containerWidth / MIN_CARD) capped at HARD_MAX++ * min columns = 1++ * MIN_CARD is the narrowest a 16:9 card can be and still show a legible title and avatar. On a++ * 412px phone that yields 1-2 columns; at 1400px it yields up to 6. Nobody is offered a choice++ * that would produce unreadable cards, which is why there is no separate mobile special case.++ *++ * The choice is remembered per section. If the viewport later cannot honour it — rotate to++ * portrait, shrink the window — the applied value is clamped to what fits while the stored++ * preference is kept, so rotating back restores it.++ *++ * OVDensity.attach(grid, { key: 'vods', header: headerEl })++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OVDensity) return;++++ const MIN_CARD = 148; // px — below this a 16:9 card's title is unreadable++ const HARD_MAX = 6; // more than six across stops being a grid and starts being a filmstrip++ const STORE = 'ov_density_v1';++++ const readStore = () => { try { return JSON.parse(localStorage.getItem(STORE) || '{}') || {}; } catch { return {}; } };++ const writeStore = (o) => { try { localStorage.setItem(STORE, JSON.stringify(o)); } catch { /* */ } };++++ const CSS = `++.ovd {++ display: inline-flex; align-items: center; gap: 3px; padding: 3px;++ border-radius: 10px; flex: none; order: 2; margin-left: auto;++ background: color-mix(in srgb, var(--sc, var(--accent)) 8%, transparent);++ border: 1px solid color-mix(in srgb, var(--sc, var(--accent)) 20%, transparent);++}++.ovd-btn {++ display: inline-flex; align-items: center; justify-content: center; gap: 2px;++ width: 26px; height: 24px; padding: 0; cursor: pointer; border: 0; border-radius: 7px;++ background: none; color: var(--text-muted); font: inherit; line-height: 1;++ transition: background .16s, color .16s, transform .16s;++}++.ovd-btn:hover { color: var(--text-primary); background: color-mix(in srgb, var(--sc, var(--accent)) 16%, transparent); }++.ovd-btn:active { transform: scale(.93); }++.ovd-btn[aria-pressed="true"] {++ color: color-mix(in srgb, var(--sc, var(--accent)) 92%, #fff);++ background: color-mix(in srgb, var(--sc, var(--accent)) 24%, transparent);++ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sc, var(--accent)) 42%, transparent);++}++.ovd-btn:focus-visible { outline: 2px solid var(--sc, var(--accent)); outline-offset: 2px; }++/* Each button draws its own count as bars, so it reads at a glance without a legend. */++.ovd-bar { display: block; width: 2.5px; border-radius: 1px; background: currentColor; height: 11px; }++.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }++.ovd-btn[data-n="2"] .ovd-bar { width: 5px; }++@media (prefers-reduced-motion: reduce) { .ovd-btn { transition: none; } }++/* The grid honours the chosen count; without a choice it keeps its own auto-fit behaviour. */++[data-ovd-cols] { grid-template-columns: repeat(var(--ovd-cols), minmax(0, 1fr)) !important; }++`;++++ function ensureCss() {++ if (document.getElementById('ovd-css')) return;++ const st = document.createElement('style'); st.id = 'ovd-css'; st.textContent = CSS;++ document.head.appendChild(st);++ }++++ /** How many columns this container can hold without cards becoming unreadable. */++ function maxColumns(el) {++ const w = el.getBoundingClientRect().width || el.clientWidth || 0;++ if (!w) return 1;++ return Math.max(1, Math.min(HARD_MAX, Math.floor(w / MIN_CARD)));++ }++++ function attach(grid, { key, header } = {}) {++ if (!grid || !key) return;++ if (grid.dataset.ovdBound) { apply(grid, key); return; }++ grid.dataset.ovdBound = '1';++ ensureCss();++++ const host = header || grid.previousElementSibling;++ let ctrl = host && host.querySelector(`.ovd[data-key="${key}"]`);++ if (host && !ctrl) {++ ctrl = document.createElement('div');++ ctrl.className = 'ovd';++ ctrl.dataset.key = key;++ ctrl.setAttribute('role', 'group');++ ctrl.setAttribute('aria-label', 'Cards per row');++ host.appendChild(ctrl);++ }++++ const render = () => {++ const max = maxColumns(grid);++ const store = readStore();++ const wanted = Number(store[key]) || 0;++ // Honour the preference where possible, clamp where not, and keep it either way.++ const active = wanted ? Math.min(wanted, max) : 0;++++ if (active) {++ grid.dataset.ovdCols = String(active);++ grid.style.setProperty('--ovd-cols', String(active));++ } else {++ delete grid.dataset.ovdCols;++ grid.style.removeProperty('--ovd-cols');++ }++ if (!ctrl) return;++++ // One button per achievable count. A container that only fits one column offers no++ // choice at all, so the control hides rather than showing a single dead button.++ if (max < 2) { ctrl.hidden = true; return; }++ ctrl.hidden = false;++ const want = [];++ for (let n = 1; n <= max; n++) want.push(n);++ const have = [...ctrl.querySelectorAll('.ovd-btn')].map(b => Number(b.dataset.n));++ if (have.join(',') !== want.join(',')) {++ ctrl.textContent = '';++ for (const n of want) {++ const b = document.createElement('button');++ b.type = 'button';++ b.className = 'ovd-btn';++ b.dataset.n = String(n);++ b.title = `${n} per row`;++ b.setAttribute('aria-label', `${n} per row`);++ for (let i = 0; i < Math.min(n, 3); i++) {++ const bar = document.createElement('span'); bar.className = 'ovd-bar'; b.appendChild(bar);++ }++ b.addEventListener('click', () => {++ const s = readStore();++ // Clicking the active count clears the preference and returns to auto.++ s[key] = (Number(s[key]) === n) ? 0 : n;++ writeStore(s);++ render();++ });++ ctrl.appendChild(b);++ }++ }++ const effective = active || autoColumns(grid, max);++ for (const b of ctrl.querySelectorAll('.ovd-btn')) {++ b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));++ }++ };++++ // Follow the container, not the window: a resize, an orientation change and a sidebar++ // opening are all the same event as far as "how many cards fit" is concerned.++ try { new ResizeObserver(() => render()).observe(grid); } catch { window.addEventListener('resize', render, { passive: true }); }++ render();++ grid._ovdRender = render;++ }++++ /** What the browser's own auto-fit is currently producing, so the control can show it. */++ function autoColumns(grid, max) {++ const cs = getComputedStyle(grid);++ const n = (cs.gridTemplateColumns || '').split(' ').filter(Boolean).length;++ return Math.max(1, Math.min(max, n || 1));++ }++++ /** Re-apply after a re-render replaced the grid's children. */++ function apply(grid, key) { if (grid && typeof grid._ovdRender === 'function') grid._ovdRender(); }++++ window.OVDensity = { attach, apply, MIN_CARD, HARD_MAX };++})();
@@ -279,6 +279,10 @@
@@ -279,6 +279,10 @@
.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }
.star-card.is-compact .star-btn-watch { flex: 1 1 auto; min-width: 140px; justify-content: center; }
.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }
.star-card.is-compact .star-actions .btn-outline { flex: 0 1 auto; }
.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }
.star-card.is-compact .star-expand { flex: 0 0 auto; margin-left: auto; padding-left: 8px; }
+/* Open state: the label is one short word, so give it the same presence as the buttons beside it
+ instead of letting it collapse to a cramped chip. */
+.star-expand.is-open { padding-right: 16px; }
+.star-expand.is-open span { font-weight: 800; }
/* The disclosure. Fades up as the card grows — see animateHeightChange().
/* The disclosure. Fades up as the card grows — see animateHeightChange().
[hidden] is a UA rule, so any author `display` here silently beats it and the panel never
[hidden] is a UA rule, so any author `display` here silently beats it and the panel never
@@ -333,6 +337,39 @@
@@ -333,6 +337,39 @@
.star-card.is-compact ~ .star-card-border::before,
.star-card.is-compact ~ .star-card-border::before,
.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }
.home-star-section:has(.star-card.is-compact) .star-card-border::before { opacity: 0.55; }
+/* ── Expanded card ───────────────────────────────────────────────────────────
+ Opening the card used to drop it back to the original feature layout: centred text, a big
+ stacked avatar and the actions in a different place from where they were a moment ago. The
+ expanded state now keeps the compact card's shape and simply reveals more underneath it, so
+ the toggle reads as "more detail" rather than "a different component". */
+.star-card:not(.is-compact) {
+ display: grid; grid-template-columns: auto minmax(0, 1fr);
+ align-items: center; gap: 12px 16px; text-align: left;
+ padding: 16px 18px 18px;
+}
+.star-card:not(.is-compact) .star-avatar-wrap,
+.star-card:not(.is-compact) .star-avatar { width: 76px; height: 76px; }
+.star-card:not(.is-compact) .star-avatar { font-size: 1.7rem; }
+.star-card:not(.is-compact) .star-body {
+ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0;
+}
+.star-card:not(.is-compact) .star-name { margin: 0; }
+.star-card:not(.is-compact) .star-chips { margin: 4px 0 0; }
+.star-card:not(.is-compact) .star-badge { display: none; }
+.star-card:not(.is-compact) .star-more { text-align: left; }
+.star-card:not(.is-compact) .star-more > * { margin-left: 0; margin-right: 0; }
+.star-card:not(.is-compact) .star-actions { justify-content: flex-start; }
+
+/* The disclosure body: its own quiet panel so the reason, bio and overview read as one block
+ rather than three loose paragraphs stacked on the card background. */
+.star-more {
+ margin-top: 12px; padding: 13px 15px; border-radius: 13px;
+ background: color-mix(in srgb, var(--bg-primary) 45%, transparent);
+ border: 1px solid color-mix(in srgb, #fbbf24 13%, var(--border));
+}
+.star-more > *:first-child { margin-top: 0; }
+.star-more > *:last-child { margin-bottom: 0; }
+
/* Wide enough for one line: avatar, who they are, and the actions on the right. Below this the
/* Wide enough for one line: avatar, who they are, and the actions on the right. Below this the
actions drop to their own row rather than squeezing. */
actions drop to their own row rather than squeezing. */
@media (min-width: 820px) {
@media (min-width: 820px) {
@@ -15285,3 +15285,18 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15285,3 +15285,18 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
the content above. */
the content above. */
.home-intro-demoted { margin-top: 26px; }
.home-intro-demoted { margin-top: 26px; }
.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }
.home-intro-demoted:first-of-type { border-top: 1px solid var(--border); padding-top: 26px; }
+
+/* The paste rail was a different grid to the VOD and clip rails (a 260px minimum against their
+ 300px), so it broke to one column at a different width and felt unrelated to them. It uses the
+ same track sizing now, and all three accept the per-row control. */
+.home-pastes-list { grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; }
+@media (max-width: 640px) {
+ .home-pastes-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+ .home-pastes-list .paste-card-title, .home-pastes-list .stream-card-title {
+ font-size: 0.82rem; line-height: 1.25;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+ }
+}
+/* The density control sits at the end of a section header, after the "View all" link. */
+.section-header .ovd { order: 4; margin-left: 8px; }
+@media (max-width: 420px) { .section-header .ovd { display: none; } }
@@ -88,10 +88,10 @@
@@ -88,10 +88,10 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=194">
+ <link rel="stylesheet" href="/css/style.css?v=196">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
<link rel="stylesheet" href="/css/home-tour.css?v=8">
<link rel="stylesheet" href="/css/home-tour.css?v=8">
- <link rel="stylesheet" href="/css/i18n-star.css?v=10">
+ <link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
@@ -559,7 +559,7 @@
@@ -559,7 +559,7 @@
<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
<div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent VODs -->
<!-- Recent VODs -->
- <div class="section-header" style="--sc:#a78bfa" id="home-recent-vods-header" style="display:none">
+ <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
<a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
@@ -567,7 +567,7 @@
@@ -567,7 +567,7 @@
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Clips -->
<!-- Recent Clips -->
- <div class="section-header" style="--sc:#f472b6" id="home-clips-header" style="display:none">
+ <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
<a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
@@ -575,7 +575,7 @@
@@ -575,7 +575,7 @@
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Recent Pastes -->
<!-- Recent Pastes -->
- <div class="section-header" style="--sc:#2dd4bf" id="home-pastes-header" style="display:none">
+ <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
<a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
@@ -5085,7 +5085,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5085,7 +5085,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
-<script src="/js/app.js?v=167" defer></script>
+<script src="/js/ov-density.js?v=1" defer></script>
+<script src="/js/app.js?v=169" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -2457,10 +2457,14 @@ async function loadHomeDigest() {
@@ -2457,10 +2457,14 @@ async function loadHomeDigest() {
async function loadHomeRecentOnline(page, append) {
async function loadHomeRecentOnline(page, append) {
if (page !== undefined) _homeRecentOnlinePage = page;
if (page !== undefined) _homeRecentOnlinePage = page;
- const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
+ const { offset, limit } = homePageRange(_homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE);
try {
try {
- const recentData = await api(`/streams/recently-online?limit=${HOME_RECENT_ONLINE_PAGE_SIZE}&offset=${offset}`);
+ const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
+ if (window.OVDensity) {
+ OVDensity.attach(document.getElementById('stream-grid-recent'),
+ { key: 'recent', header: document.getElementById('stream-grid-recent')?.previousElementSibling });
+ }
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
} catch { /* silent */ }
} catch { /* silent */ }
}
}
@@ -2519,7 +2523,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -2519,7 +2523,7 @@ function renderRecentlyOnline(containerId, streamers, append) {
async function loadHomeRecentVods(page, append) {
async function loadHomeRecentVods(page, append) {
if (page !== undefined) _homeRecentVodsPage = page;
if (page !== undefined) _homeRecentVodsPage = page;
- const offset = (_homeRecentVodsPage - 1) * HOME_RECENT_VODS_PAGE_SIZE;
+ const { offset, limit } = homePageRange(_homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE);
try {
try {
const render = (data) => {
const render = (data) => {
const vods = data.vods || [];
const vods = data.vods || [];
@@ -2550,15 +2554,16 @@ async function loadHomeRecentVods(page, append) {
@@ -2550,15 +2554,16 @@ async function loadHomeRecentVods(page, append) {
</a>
</a>
`;
`;
}).join(''));
}).join(''));
+ if (window.OVDensity) OVDensity.attach(grid, { key: 'vods', header: header });
renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');
renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');
};
};
- await apiSWR(`/streams/recent-vods?limit=${HOME_RECENT_VODS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
+ await apiSWR(`/streams/recent-vods?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
} catch { /* silent */ }
}
}
async function loadHomeClips(page, append) {
async function loadHomeClips(page, append) {
if (page !== undefined) _homeClipsPage = page;
if (page !== undefined) _homeClipsPage = page;
- const offset = (_homeClipsPage - 1) * HOME_CLIPS_PAGE_SIZE;
+ const { offset, limit } = homePageRange(_homeClipsPage, HOME_CLIPS_PAGE_SIZE);
try {
try {
const render = (data) => {
const render = (data) => {
const clips = data.clips || [];
const clips = data.clips || [];
@@ -2585,15 +2590,16 @@ async function loadHomeClips(page, append) {
@@ -2585,15 +2590,16 @@ async function loadHomeClips(page, append) {
</div>
</div>
</a>
</a>
`).join(''));
`).join(''));
+ if (window.OVDensity) OVDensity.attach(grid, { key: 'clips', header: header });
renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');
renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');
};
};
- await apiSWR(`/clips?limit=${HOME_CLIPS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
+ await apiSWR(`/clips?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
} catch { /* silent */ }
}
}
async function loadHomePastes(page, append) {
async function loadHomePastes(page, append) {
if (page !== undefined) _homePastesPage = page;
if (page !== undefined) _homePastesPage = page;
- const offset = (_homePastesPage - 1) * HOME_PASTES_PAGE_SIZE;
+ const { offset, limit } = homePageRange(_homePastesPage, HOME_PASTES_PAGE_SIZE);
try {
try {
const render = (data) => {
const render = (data) => {
const pastes = data.pastes || [];
const pastes = data.pastes || [];
@@ -2624,9 +2630,10 @@ async function loadHomePastes(page, append) {
@@ -2624,9 +2630,10 @@ async function loadHomePastes(page, append) {
</div>
</div>
</a>`;
</a>`;
}).join(''));
}).join(''));
+ if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });
renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');
renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');
};
};
- await apiSWR(`/pastes?limit=${HOME_PASTES_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
+ await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
} catch { /* silent */ }
}
}
@@ -2999,6 +3006,24 @@ const HOME_RECENT_ONLINE_PAGE_SIZE = _narrow ? 4 : 6;
@@ -2999,6 +3006,24 @@ const HOME_RECENT_ONLINE_PAGE_SIZE = _narrow ? 4 : 6;
const HOME_RECENT_VODS_PAGE_SIZE = _narrow ? 4 : 6;
const HOME_RECENT_VODS_PAGE_SIZE = _narrow ? 4 : 6;
const HOME_CLIPS_PAGE_SIZE = _narrow ? 4 : 6;
const HOME_CLIPS_PAGE_SIZE = _narrow ? 4 : 6;
const HOME_PASTES_PAGE_SIZE = _narrow ? 4 : 5;
const HOME_PASTES_PAGE_SIZE = _narrow ? 4 : 5;
+/**
+ * How many more a "Load more" press brings in.
+ *
+ * The first page is deliberately small — it is a taste of the section, not a browse view. Once
+ * somebody has pressed Load more they have said they want to browse, so subsequent pages are
+ * larger: fewer presses to get anywhere, and the cost is paid only by people who asked for it.
+ */
+const HOME_LOAD_MORE = 8;
+/** Offset and limit for a given page, given a first page that is a different size to the rest. */
+function homePageRange(page, firstSize) {
+ if (page <= 1) return { offset: 0, limit: firstSize };
+ return { offset: firstSize + (page - 2) * HOME_LOAD_MORE, limit: HOME_LOAD_MORE };
+}
+/** How many rows are on screen once `page` has loaded. */
+function homeShownCount(page, firstSize) {
+ const r = homePageRange(page, firstSize);
+ return r.offset + r.limit;
+}
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// taking 'newest'|'oldest'.
// taking 'newest'|'oldest'.
@@ -3054,13 +3079,15 @@ function renderVodsPagination(containerId, page, total, pageSize, setterName, it
@@ -3054,13 +3079,15 @@ function renderVodsPagination(containerId, page, total, pageSize, setterName, it
function renderHomePagination(containerId, total, page, pageSize, setterName) {
function renderHomePagination(containerId, total, page, pageSize, setterName) {
const el = document.getElementById(containerId);
const el = document.getElementById(containerId);
if (!el) return;
if (!el) return;
- const shown = page * pageSize;
+ // The first page is a different size to the ones after it, so "how many are on screen" is not
+ // page * pageSize.
+ const shown = homeShownCount(page, pageSize);
const left = Math.max(0, (total || 0) - shown);
const left = Math.max(0, (total || 0) - shown);
// These containers ship with an inline display:none — the old pager unhid them itself, and
// These containers ship with an inline display:none — the old pager unhid them itself, and
// this one has to as well or the button renders into a hidden box and nobody ever sees it.
// this one has to as well or the button renders into a hidden box and nobody ever sees it.
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
el.style.display = 'block';
el.style.display = 'block';
- const next = Math.min(left, pageSize);
+ const next = Math.min(left, HOME_LOAD_MORE);
el.innerHTML = `<button type="button" class="home-load-more" onclick="${setterName}(${page + 1}, true)">
el.innerHTML = `<button type="button" class="home-load-more" onclick="${setterName}(${page + 1}, true)">
<i class="fa-solid fa-arrow-down"></i>
<i class="fa-solid fa-arrow-down"></i>
<span>Load ${next} more</span>
<span>Load ${next} more</span>
@@ -9686,7 +9713,7 @@ async function loadHomeStar() {
@@ -9686,7 +9713,7 @@ async function loadHomeStar() {
if (card && btn && more) {
if (card && btn && more) {
card.classList.add('is-compact');
card.classList.add('is-compact');
const label = () => {
const label = () => {
- btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Less';
+ btn.querySelector('span').textContent = more.hidden ? `More about ${s.display_name || s.username}` : 'Show less';
btn.classList.toggle('is-open', !more.hidden);
btn.classList.toggle('is-open', !more.hidden);
btn.setAttribute('aria-expanded', String(!more.hidden));
btn.setAttribute('aria-expanded', String(!more.hidden));
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+/**
+ * ov-density.js — per-section "how many cards per row" control.
+ *
+ * The number of columns a feed should use is not a fixed design decision; it depends on the
+ * viewport, the reader's eyesight and what they are doing. So rather than hard-coding counts per
+ * breakpoint, this measures the container and offers every column count that still leaves a card
+ * wide enough to be worth looking at.
+ *
+ * The range is computed, not guessed:
+ * max columns = floor(containerWidth / MIN_CARD) capped at HARD_MAX
+ * min columns = 1
+ * MIN_CARD is the narrowest a 16:9 card can be and still show a legible title and avatar. On a
+ * 412px phone that yields 1-2 columns; at 1400px it yields up to 6. Nobody is offered a choice
+ * that would produce unreadable cards, which is why there is no separate mobile special case.
+ *
+ * The choice is remembered per section. If the viewport later cannot honour it — rotate to
+ * portrait, shrink the window — the applied value is clamped to what fits while the stored
+ * preference is kept, so rotating back restores it.
+ *
+ * OVDensity.attach(grid, { key: 'vods', header: headerEl })
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OVDensity) return;
+
+ const MIN_CARD = 148; // px — below this a 16:9 card's title is unreadable
+ const HARD_MAX = 6; // more than six across stops being a grid and starts being a filmstrip
+ const STORE = 'ov_density_v1';
+
+ const readStore = () => { try { return JSON.parse(localStorage.getItem(STORE) || '{}') || {}; } catch { return {}; } };
+ const writeStore = (o) => { try { localStorage.setItem(STORE, JSON.stringify(o)); } catch { /* */ } };
+
+ const CSS = `
+.ovd {
+ display: inline-flex; align-items: center; gap: 3px; padding: 3px;
+ border-radius: 10px; flex: none; order: 2; margin-left: auto;
+ background: color-mix(in srgb, var(--sc, var(--accent)) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--sc, var(--accent)) 20%, transparent);
+}
+.ovd-btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 2px;
+ width: 26px; height: 24px; padding: 0; cursor: pointer; border: 0; border-radius: 7px;
+ background: none; color: var(--text-muted); font: inherit; line-height: 1;
+ transition: background .16s, color .16s, transform .16s;
+}
+.ovd-btn:hover { color: var(--text-primary); background: color-mix(in srgb, var(--sc, var(--accent)) 16%, transparent); }
+.ovd-btn:active { transform: scale(.93); }
+.ovd-btn[aria-pressed="true"] {
+ color: color-mix(in srgb, var(--sc, var(--accent)) 92%, #fff);
+ background: color-mix(in srgb, var(--sc, var(--accent)) 24%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sc, var(--accent)) 42%, transparent);
+}
+.ovd-btn:focus-visible { outline: 2px solid var(--sc, var(--accent)); outline-offset: 2px; }
+/* Each button draws its own count as bars, so it reads at a glance without a legend. */
+.ovd-bar { display: block; width: 2.5px; border-radius: 1px; background: currentColor; height: 11px; }
+.ovd-btn[data-n="1"] .ovd-bar { width: 9px; }
+.ovd-btn[data-n="2"] .ovd-bar { width: 5px; }
+@media (prefers-reduced-motion: reduce) { .ovd-btn { transition: none; } }
+/* The grid honours the chosen count; without a choice it keeps its own auto-fit behaviour. */
+[data-ovd-cols] { grid-template-columns: repeat(var(--ovd-cols), minmax(0, 1fr)) !important; }
+`;
+
+ function ensureCss() {
+ if (document.getElementById('ovd-css')) return;
+ const st = document.createElement('style'); st.id = 'ovd-css'; st.textContent = CSS;
+ document.head.appendChild(st);
+ }
+
+ /** How many columns this container can hold without cards becoming unreadable. */
+ function maxColumns(el) {
+ const w = el.getBoundingClientRect().width || el.clientWidth || 0;
+ if (!w) return 1;
+ return Math.max(1, Math.min(HARD_MAX, Math.floor(w / MIN_CARD)));
+ }
+
+ function attach(grid, { key, header } = {}) {
+ if (!grid || !key) return;
+ if (grid.dataset.ovdBound) { apply(grid, key); return; }
+ grid.dataset.ovdBound = '1';
+ ensureCss();
+
+ const host = header || grid.previousElementSibling;
+ let ctrl = host && host.querySelector(`.ovd[data-key="${key}"]`);
+ if (host && !ctrl) {
+ ctrl = document.createElement('div');
+ ctrl.className = 'ovd';
+ ctrl.dataset.key = key;
+ ctrl.setAttribute('role', 'group');
+ ctrl.setAttribute('aria-label', 'Cards per row');
+ host.appendChild(ctrl);
+ }
+
+ const render = () => {
+ const max = maxColumns(grid);
+ const store = readStore();
+ const wanted = Number(store[key]) || 0;
+ // Honour the preference where possible, clamp where not, and keep it either way.
+ const active = wanted ? Math.min(wanted, max) : 0;
+
+ if (active) {
+ grid.dataset.ovdCols = String(active);
+ grid.style.setProperty('--ovd-cols', String(active));
+ } else {
+ delete grid.dataset.ovdCols;
+ grid.style.removeProperty('--ovd-cols');
+ }
+ if (!ctrl) return;
+
+ // One button per achievable count. A container that only fits one column offers no
+ // choice at all, so the control hides rather than showing a single dead button.
+ if (max < 2) { ctrl.hidden = true; return; }
+ ctrl.hidden = false;
+ const want = [];
+ for (let n = 1; n <= max; n++) want.push(n);
+ const have = [...ctrl.querySelectorAll('.ovd-btn')].map(b => Number(b.dataset.n));
+ if (have.join(',') !== want.join(',')) {
+ ctrl.textContent = '';
+ for (const n of want) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'ovd-btn';
+ b.dataset.n = String(n);
+ b.title = `${n} per row`;
+ b.setAttribute('aria-label', `${n} per row`);
+ for (let i = 0; i < Math.min(n, 3); i++) {
+ const bar = document.createElement('span'); bar.className = 'ovd-bar'; b.appendChild(bar);
+ }
+ b.addEventListener('click', () => {
+ const s = readStore();
+ // Clicking the active count clears the preference and returns to auto.
+ s[key] = (Number(s[key]) === n) ? 0 : n;
+ writeStore(s);
+ render();
+ });
+ ctrl.appendChild(b);
+ }
+ }
+ const effective = active || autoColumns(grid, max);
+ for (const b of ctrl.querySelectorAll('.ovd-btn')) {
+ b.setAttribute('aria-pressed', String(Number(b.dataset.n) === effective));
+ }
+ };
+
+ // Follow the container, not the window: a resize, an orientation change and a sidebar
+ // opening are all the same event as far as "how many cards fit" is concerned.
+ try { new ResizeObserver(() => render()).observe(grid); } catch { window.addEventListener('resize', render, { passive: true }); }
+ render();
+ grid._ovdRender = render;
+ }
+
+ /** What the browser's own auto-fit is currently producing, so the control can show it. */
+ function autoColumns(grid, max) {
+ const cs = getComputedStyle(grid);
+ const n = (cs.gridTemplateColumns || '').split(' ').filter(Boolean).length;
+ return Math.max(1, Math.min(max, n || 1));
+ }
+
+ /** Re-apply after a re-render replaced the grid's children. */
+ function apply(grid, key) { if (grid && typeof grid._ovdRender === 'function') grid._ovdRender(); }
+
+ window.OVDensity = { attach, apply, MIN_CARD, HARD_MAX };
+})();