openvibers/openvibe.live Commits

Home rails: per-row density control, bigger Load more, and a consistent paste grid

OP OpenVibers committed 16 Sept 2026 · 5ae4dd2adb · 5 files, +259 −17

Each feed section gets a small control that changes how many cards sit in a row. The available
counts are computed, not hard-coded: the container is measured and every count that still leaves
a card at least 148px wide is offered, capped at six. On a 412px phone that is one or two; at
1400px it is up to six. Nobody is offered a choice that produces unreadable cards, so there is no
separate mobile rule — the phone case falls out of the same arithmetic.

The choice is remembered per section and clamped rather than lost when the viewport shrinks, so
rotating a phone to portrait and back restores what you picked. It follows the container with a
ResizeObserver rather than a window-width check, so a resize, an orientation change and a sidebar
opening are all handled by the same code path. Pressing the active count returns the section to
automatic. Each button draws its own count as bars so it reads without a legend.

Load more now brings in eight rather than repeating the first page's size. The first page is
deliberately small because it is a taste of the section; once somebody presses Load more they
have said they want to browse. The offset arithmetic accounts for a first page that differs in
size from the rest — verified contiguous with no gaps or overlaps for both the four- and six-card
first pages.

The paste rail used a 260px minimum track against the VOD and clip rails' 300px, so it collapsed
to one column at a different width and read as an unrelated component. All three now share track
sizing, go two-up on a phone, and clamp their titles to two lines.

Also: opening the Star card dropped it back to the original centred feature layout — a different
avatar size, centred text and the actions somewhere new. The expanded state now keeps the compact
card's shape and reveals detail beneath it, with the disclosure body in its own quiet panel, so
the toggle reads as "more detail" rather than "a different component".

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/i18n-star.css b/public/css/i18n-star.css
iindex ceb690b6..05c9b5ec 100644
---- a/public/css/i18n-star.css
++++ b/public/css/i18n-star.css
@@@ -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 the
actions drop to their own row rather than squeezing. */
@media (min-width: 820px) {
ddiff --git a/public/css/style.css b/public/css/style.css
iindex aca726f6..13f846db 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -15285,3 +15285,18 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
the 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; } }
ddiff --git a/public/index.html b/public/index.html
iindex a6512757..bb3e1ba2 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,10 +88,10 @@
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. -->
<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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 2553f816..41b1e5c3 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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, it
function 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));
};
ddiff --git a/public/js/ov-density.js b/public/js/ov-density.js
nnew file mode 100644
iindex 00000000..98eda95e
---- /dev/null
++++ b/public/js/ov-density.js
@@@ -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 };
++})();