openvibers/openvibe.live Commits

The Rundown: one bar at the top, and the streamer's own controls (who sees it, Share, delete) (#639)

OP OpenVibers committed 6 days ago · ff159a3f72 · 13 files, +1787 −98

* The Rundown: audiences, the recording's rule, deleting

Who sees a streamer's Rundown is one setting, not four: Everyone, Anyone with the link, Specific people or Only me. It is the sharing system's own setting (openvibe-sdk/share, share.v1, kind rundown-episode), so the Share sheet is the only place it is set. A segment follows its episode.

The recording decides first: an episode is the stricter of its own setting and its VOD's visibility. Changes made through Live's own pages apply in the same request; changes made elsewhere arrive with Media's media.object.visibility_changed event; until it lands, the episode page re-checks through media-client (60 s cache). The gathered pages show Everyone only and count only Everyone; the streamer sees all their own items everywhere with an audience chip; a take that names another streamer never reaches their page.

Deleting is deleting: the rows go, topics recount, a topic left with nothing is removed, the sharing row goes with the episode, the VOD stays, and the desk does not write that stream up again unless the streamer asks. Staff hiding stays as it is.

* Live subscribes to Media's visibility changes

media.object.* carries media.object.visibility_changed: Media's own change of an object's visibility. The Rundown needs it: a recording that is unlisted elsewhere must unlist its episode, its takes and its after-show report at once. The test learns the new topic.

* The Network client is on the reviewed outbound-request list

server/net/network-json.js (the one client the sharing system uses for Network's identity lookups) makes an outbound request of its own: it is on the reviewed list with the reason.

* The Rundown: one bar at the top

Search and filters for The Rundown (design 2026-11-06, section 4), on top of the
audience filtering of sections 1-3.

The bar is sticky under the navbar on every Rundown page: a search box that finds a
quote, a take, an episode or a topic (PostgreSQL full text, a tsvector column and a
GIN index, English configuration, a phrase in quotes is a phrase, a streamer's name
matched by prefix), and the filter chips - Streamer (several may be picked), Topic,
When, Show, Sort, With video, Following and Mentions me.

Every filter is in the URL (/rundown?streamer=goosely&topic=drake-and-objectives&when=week&q=baron),
so a view can be shared and the back button works. The results are compact cards: a
take with its streamer, topic chip, title, quote, moment and reactions; an episode with
its title, logline and its takes folded to the top three, with a counted "More takes (N)"
that opens in place. A count line says how many takes from how many streamers, and an empty
result suggests dropping the filter that costs the most and shows the closest topics by
name.

What moved: the four explainer tiles are one line under the title; the Latest | Topics
tabs are the Show chip (the old URLs still work); trending topics and who's been talking
stay in the side column and every name or topic in them is a filter link, and on the
phone they are two rails under the bar. The pages that exist stay (/rundown/<name>,
/rundown/t/<slug>, /rundown/e/<id>, and /rundown/s/<name>), each with the bar pre-filled.

On the phone the bar is the search box and one "Filters (N)" button, which opens a
sheet with the same sections; the sheet keeps its scroll, every control is 44 px and
nothing is wider than 390 px.

Indexing: only the plain front page, a single streamer's page and a single topic's page
are indexable (the topic and the streamer pages were noindex before); every filter
combination and every search is noindex,follow with a canonical to the nearest of those,
so a crawler never gets an endless filter space. The topics page is a filter now, so it
leaves the sitemap.

The search index is an expand-only migration (0030_rundown_search.sql): the columns are
nullable, the index is created on them, and the words are filled by a trigger so a topic
renamed later changes the search with it.

Tests: test/rundown-search.test.js (the search finds a quote, a take and a streamer by
prefix, a phrase is a phrase, every filter narrows and the counts follow, and an empty
result says what to drop) and test/rundown-bar.test.js (the state of a view is its URL,
the sheet has the same sections and 44 px controls, and a combination is noindex with a
canonical). test/rundown-uses.test.js follows the explainer line. All ten Rundown tests
pass; the home page budgets are unchanged.

* Rundown bar: one bar, a sheet that sits over the page, no empty pills

- The browse view draws its own bar; the bar is added to other Rundown pages only when there is none.
- The phone's filter sheet is wrapped in .rd-sheet-wrap again (fixed, above the Shared app bar), so it
  covers the page, closes, and redraws its lists when the streamers and topics arrive.
- The 'Who's been talking' rail used a linked avatar inside a link, which the browser closes early
  (an empty pill before each face); the rail uses a face that is not a link.

* Rundown: the streamer's own controls (who sees it, Share, delete)

On their own episode a streamer sees who it is for (Everyone, Anyone with the link, Specific people, Only me, or
that it follows a stricter recording), a Share button that opens the Shared Share sheet with "Who can open it"
(kind rundown-episode), and Delete episode; after deleting, the receipt and "Write this stream up again". Their
takes get a delete button. Their own Rundown page opens with "Your Rundown": the audience new write-ups start
with, Keep me out, and Delete my Rundown (optionally keeping out afterwards). Every delete asks first, in the page
(an alertdialog: a sheet on the phone, a card on a wide screen), and the how-line links a signed-in person to
their own Rundown.

The episode and streamer reads say "mine", so the tools do not wait for currentUser. An episode written up before
the sharing system has no share row (the sheet answers 404), so its owner's first visit registers it as Everyone,
what it already was; nobody else's visit registers anything.

* Rundown owner controls: 44 px targets on touch screens (delete, buttons, the Keep me out switch)

* Merge remote-tracking branch 'origin/main' into review-639

# Conflicts:
#	docs/rundown.md
#	public/css/features/rundown.css
#	server/rundown/read.js
#	server/rundown/routes.js

* Migrations after the merge of main: the Rundown's audiences 0030 (as #644 does), its search 0031

* rundown.css: close the owner controls' touch block (lost in the merge of main)

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex f62e1f3f..c9c585fd 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -174,3 +174,33 @@ When an episode is written within 6 hours of its stream ending, it is announced
| `ai_timeline_enabled` | `false` | the speech-to-text the desk reads: must be on |
Tests: `test/rundown.test.js` (filters, the desk on a stubbed AI, topics, episodes, opt-outs, the API and the redirects) and `test/rundown-audiences.test.js` (the four audiences and who may see each, the gathered pages and their counts, a non-public take never reaching the streamer it names, the recording's rule, and deleting). `test/recap-audience.test.js` covers the after-show report's setting.
++
++## The bar: search and filters
++
++The state of a view is its URL: `/rundown?streamer=goosely&topic=drake-and-objectives&when=week&q=baron`. Every
++filter is in it, so a view can be shared and the back button works.
++
++| Filter | In the URL | What it does |
++|---|---|---|
++| the search | `q` | the words, over the take titles, what was said, the desk's quips, the quotes, the episode titles and loglines and the topic names (a phrase in quotes is a phrase) |
++| Streamer | `streamer` | one or more names, comma separated |
++| Topic | `topic` | one topic's slug |
++| When | `when` | `day`, `week` or `month` |
++| Show | `show` | `takes`, `episodes` or `topics` (no `show` is the Latest view: takes and episodes) |
++| Sort | `sort` | `newest`, `reacted` or `takes` |
++| With video | `video=1` | only the takes that jump to a moment |
++| Following | `following=1` | only the streamers the reader follows (signed in) |
++| Mentions me | `mentions=1` | only the takes that name the reader (signed in, a streamer) |
++
++- `GET /api/rundown/browse?q=&streamer=&topic=&when=&show=&sort=&video=&following=&mentions=` — the results the
++ filters ask for, the counts (`"34 takes from 6 streamers"`), and, when nothing matches, the filter to drop and
++ the closest topics by name.
++- `GET /api/rundown/options` — the lists the pickers show: the streamers who have been talking, the topics (trending
++ first).
++
++The words come from the `search_tsv` columns migration `0031_rundown_search.sql` fills (a tsvector per row and a
++GIN index, English configuration). A streamer's name is matched by prefix, not by word.
++
++Only the plain front page, a single streamer's page and a single topic's page are indexable. Every filter
++combination and every search is `noindex,follow`, with a canonical to the nearest of those, so a crawler never gets
++an endless filter space.
ddiff --git a/migrations/0031_rundown_search.sql b/migrations/0031_rundown_search.sql
nnew file mode 100644
iindex 00000000..50b8a933
---- /dev/null
++++ b/migrations/0031_rundown_search.sql
@@@ -0,0 +1,87 @@
++-- phase: expand
++-- The Rundown's bar: search and filters (design 2026-11-06, section 4; plan-state-2026-10-22/
++-- RUNDOWN-CONTROLS-AND-BROWSE-DESIGN-2026-11-06.md).
++--
++-- One tsvector per row with a GIN index, so a single query finds a take by its title, by what was said, by the
++-- desk's quip, by one of its quotes or by the topic it is on; an episode by its title, logline, recap or chapters;
++-- a topic by its name, headline or summary. English configuration, so the words are stemmed ("takes" finds "took")
++-- and a phrase in quotes is matched as a phrase (websearch_to_tsquery turns "two words" into two words <-> two).
++--
++-- The column is filled by a trigger, not by the desk: a topic's name is part of every take on it, and a name that
++-- changes later must change the search with it. Expand only: the columns are nullable and the old release never
++-- writes them, so it keeps working; the backfill below fills what is already written.
++ALTER TABLE rundown_segments ADD COLUMN IF NOT EXISTS search_tsv tsvector;
++ALTER TABLE rundown_episodes ADD COLUMN IF NOT EXISTS search_tsv tsvector;
++ALTER TABLE rundown_topics ADD COLUMN IF NOT EXISTS search_tsv tsvector;
++
++-- A take: its own words, the desk's quip, the quotes it carries and the name of the topic it is on.
++CREATE OR REPLACE FUNCTION rundown_segments_search_tsv() RETURNS trigger LANGUAGE plpgsql AS $fn$
++DECLARE
++ topic_title text;
++BEGIN
++ SELECT t.title INTO topic_title FROM rundown_topics t WHERE t.id = NEW.topic_id;
++ NEW.search_tsv := to_tsvector('english',
++ coalesce(NEW.title, '') || ' ' || coalesce(NEW.what, '') || ' ' || coalesce(NEW.take, '') || ' ' ||
++ coalesce(NEW.quip, '') || ' ' || coalesce(NEW.quotes_json, '') || ' ' || coalesce(topic_title, ''));
++ RETURN NEW;
++END;
++$fn$;
++
++-- An episode: its title, its logline, its recap and its chapters.
++CREATE OR REPLACE FUNCTION rundown_episodes_search_tsv() RETURNS trigger LANGUAGE plpgsql AS $fn$
++BEGIN
++ NEW.search_tsv := to_tsvector('english',
++ coalesce(NEW.title, '') || ' ' || coalesce(NEW.logline, '') || ' ' || coalesce(NEW.recap, '') || ' ' ||
++ coalesce(NEW.chapters_json, ''));
++ RETURN NEW;
++END;
++$fn$;
++
++-- A topic: its name, its headline and the summary the desk writes for it.
++CREATE OR REPLACE FUNCTION rundown_topics_search_tsv() RETURNS trigger LANGUAGE plpgsql AS $fn$
++BEGIN
++ NEW.search_tsv := to_tsvector('english',
++ coalesce(NEW.title, '') || ' ' || coalesce(NEW.headline, '') || ' ' || coalesce(NEW.summary, ''));
++ RETURN NEW;
++END;
++$fn$;
++
++-- A topic that is renamed changes the search of every take on it.
++CREATE OR REPLACE FUNCTION rundown_topics_search_refresh() RETURNS trigger LANGUAGE plpgsql AS $fn$
++BEGIN
++ IF NEW.title IS DISTINCT FROM OLD.title THEN
++ UPDATE rundown_segments SET search_tsv = NULL WHERE topic_id = NEW.id;
++ END IF;
++ RETURN NEW;
++END;
++$fn$;
++
++DO $do$
++BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'rundown_segments_search_tsv_trg') THEN
++ CREATE TRIGGER rundown_segments_search_tsv_trg BEFORE INSERT OR UPDATE ON rundown_segments
++ FOR EACH ROW EXECUTE FUNCTION rundown_segments_search_tsv();
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'rundown_episodes_search_tsv_trg') THEN
++ CREATE TRIGGER rundown_episodes_search_tsv_trg BEFORE INSERT OR UPDATE ON rundown_episodes
++ FOR EACH ROW EXECUTE FUNCTION rundown_episodes_search_tsv();
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'rundown_topics_search_tsv_trg') THEN
++ CREATE TRIGGER rundown_topics_search_tsv_trg BEFORE INSERT OR UPDATE ON rundown_topics
++ FOR EACH ROW EXECUTE FUNCTION rundown_topics_search_tsv();
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'rundown_topics_search_refresh_trg') THEN
++ CREATE TRIGGER rundown_topics_search_refresh_trg AFTER UPDATE ON rundown_topics
++ FOR EACH ROW EXECUTE FUNCTION rundown_topics_search_refresh();
++ END IF;
++END;
++$do$;
++
++-- What is already written gets its words (the triggers above fill the rows as they are touched).
++UPDATE rundown_segments SET search_tsv = NULL;
++UPDATE rundown_episodes SET search_tsv = NULL;
++UPDATE rundown_topics SET search_tsv = NULL;
++
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_search ON rundown_segments USING gin (search_tsv);
++CREATE INDEX IF NOT EXISTS idx_rundown_episodes_search ON rundown_episodes USING gin (search_tsv);
++CREATE INDEX IF NOT EXISTS idx_rundown_topics_search ON rundown_topics USING gin (search_tsv);
ddiff --git a/public/css/features/rundown.css b/public/css/features/rundown.css
iindex 6c7a6309..bfb51e99 100644
---- a/public/css/features/rundown.css
++++ b/public/css/features/rundown.css
@@@ -239,6 +239,209 @@ a.rd-use:hover b { color: var(--accent); }
.rd-more[open] > summary::before { transform: translateY(-4px) rotate(45deg); }
.rd-more > .rd-topic-grid { padding: 4px 12px 14px; }
@media (prefers-reduced-motion: reduce) { .rd-more > summary::before { transition: none; } }
++
++/* ── The bar: search and filters (public/js/rundown.js) ──────────────────────────────────
++ Sticky under the navbar on every Rundown page. The phone gets the search box and one
++ "Filters (N)" button, which opens a sheet with the same sections. */
++.rd-bar { position: sticky; top: var(--navbar-h); z-index: 30; margin: 0 0 10px; padding: 10px 0;
++ background: var(--bg-primary, var(--bg-secondary)); border-bottom: 1px solid var(--border); }
++.rd-bar-row { display: flex; gap: 10px; align-items: center; }
++.rd-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; height: 44px; padding: 0 12px;
++ border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
++.rd-search:focus-within { border-color: var(--accent); }
++.rd-search i { flex: none; color: var(--text-muted); }
++.rd-search-input { flex: 1; min-width: 0; height: 100%; border: 0; background: none; outline: none;
++ color: var(--text-primary); font: inherit; font-size: 0.98rem; }
++.rd-search-input::placeholder { color: var(--text-muted); }
++.rd-search-clear { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0;
++ border-radius: 50%; background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; }
++.rd-chipbar { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
++.rd-pick-btn, .rd-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 5px 12px;
++ border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary);
++ font: inherit; font-size: 0.88rem; font-weight: 700; cursor: pointer; }
++.rd-pick-btn:hover, .rd-toggle:hover { color: var(--text-primary); border-color: var(--accent); }
++.rd-pick-btn b { color: var(--accent); }
++.rd-toggle.is-on { color: var(--text-primary); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
++.rd-sheet-btn { display: none; min-height: 44px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border);
++ background: var(--bg-card); color: var(--text-primary); font: inherit; font-weight: 700; cursor: pointer; white-space: nowrap; }
++
++/* The pickers: a small list of choices under the bar. */
++.rd-pickers { margin-top: 8px; }
++.rd-picker { padding: 12px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.rd-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
++.rd-picker-head .rd-h3 { margin: 0; font-size: 1rem; }
++.rd-icon-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border);
++ border-radius: 50%; background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; }
++.rd-picker-search { width: 100%; height: 40px; margin-bottom: 8px; padding: 0 12px; border-radius: 10px;
++ border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; }
++.rd-picker-list { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow-y: auto; }
++.rd-picker-list li + li { border-top: 1px solid var(--border); }
++.rd-pick-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 8px 6px; border: 0;
++ background: none; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }
++.rd-pick-item:hover { background: var(--bg-tertiary); }
++.rd-pick-item.is-on { color: var(--accent-light, var(--accent)); font-weight: 700; }
++.rd-pick-item.is-on::after { content: '\2713'; margin-left: auto; }
++.rd-pick-name { flex: 1; min-width: 0; }
++.rd-pick-count { flex: none; color: var(--text-muted); font-size: 0.8rem; }
++
++/* The filters that are on, and the way out of all of them. */
++.rd-active { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 12px; }
++.rd-active-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px 4px 10px; border-radius: 999px;
++ background: color-mix(in srgb, var(--accent) 16%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
++ color: var(--text-primary); font-size: 0.85rem; font-weight: 700; }
++.rd-active-x { display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%;
++ background: none; color: var(--text-secondary); cursor: pointer; font-size: 0.72rem; }
++.rd-active-x:hover { color: var(--text-primary); background: var(--bg-tertiary); }
++.rd-clear-all { font-size: 0.85rem; }
++
++/* The count line and the one line under the title. */
++.rd-count { margin: 4px 0 12px; color: var(--text-secondary); font-size: 0.9rem; font-weight: 700; }
++.rd-how { margin: 0 0 12px; color: var(--text-secondary); font-size: 0.94rem; }
++.rd-how-link { color: var(--accent-light, var(--accent)); font-weight: 700; text-decoration: none; }
++.rd-how-link:hover { text-decoration: underline; }
++
++/* Compact cards: a take, and an episode folded to its top takes. */
++.rd-compact-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
++.rd-compact-who { font-weight: 700; font-size: 0.9rem; }
++.rd-compact-top time { margin-left: auto; color: var(--text-muted); font-size: 0.8rem; }
++.rd-take .rd-quote { margin: 0 0 4px; }
++.rd-take .rd-quote p { font-size: 1rem; }
++.rd-mini { display: flex; align-items: center; gap: 8px; padding: 7px 0; border-top: 1px dashed var(--border); }
++.rd-mini:first-child { border-top: 0; }
++.rd-mini-title { flex: 1; min-width: 0; font-size: 0.94rem; font-weight: 700; }
++.rd-fold { margin: 6px 0 0; }
++.rd-fold-btn { display: block; width: 100%; margin-top: 6px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border);
++ background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-size: 0.88rem; font-weight: 700; cursor: pointer; }
++.rd-fold-btn:hover { border-color: var(--accent); }
++.rd-ep-count { color: var(--text-secondary); font-size: 0.82rem; font-weight: 700; }
++.rd-kinds { margin: 0 0 12px; }
++
++/* The phone's sheet: the same sections as the pickers, one under the other. */
++/* Over the Shared app bar (9990): the sheet is modal, so nothing of the page shows through it. */
++.rd-sheet-wrap { position: fixed; inset: 0; z-index: 10000; }
++.rd-sheet-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.55); }
++.rd-sheet { position: absolute; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; max-height: 88vh;
++ border-radius: 18px 18px 0 0; background: var(--bg-primary, var(--bg-secondary)); border-top: 1px solid var(--border); }
++.rd-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px;
++ border-bottom: 1px solid var(--border); }
++.rd-sheet-head .rd-h2 { margin: 0; }
++.rd-sheet-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 14px 14px; -webkit-overflow-scrolling: touch; }
++.rd-sheet-sec { padding: 10px 0; border-bottom: 1px solid var(--border); }
++.rd-sheet-sec:last-child { border-bottom: 0; }
++.rd-sheet-sec .rd-h3 { margin: 0 0 8px; font-size: 0.95rem; }
++.rd-sheet-list { display: flex; flex-direction: column; gap: 4px; }
++.rd-sheet-btn-row { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 8px 10px;
++ border-radius: 10px; border: 1px solid transparent; background: none; color: var(--text-primary); font: inherit;
++ font-size: 0.95rem; text-align: left; cursor: pointer; }
++.rd-sheet-btn-row:hover { background: var(--bg-tertiary); }
++.rd-sheet-btn-row.is-on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); font-weight: 700; }
++.rd-sheet-foot { display: flex; gap: 10px; padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
++.rd-sheet-foot .btn { flex: 1; min-height: 44px; }
++body.rd-sheet-open { overflow: hidden; }
++
++/* Trending and who's been talking: two rails under the bar on the phone. */
++.rd-rails { display: none; }
++.rd-rail .rd-h2 { margin: 0 0 4px; }
++.rd-rail-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: thin; padding-bottom: 4px; }
++.rd-rail-track .rd-chip { flex: none; }
++.rd-rail-item { display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 4px 10px 4px 4px; border-radius: 999px;
++ background: var(--bg-card); border: 1px solid var(--border); color: var(--text-primary); text-decoration: none;
++ font-size: 0.85rem; font-weight: 700; }
++
++@media (max-width: 960px) {
++ .rd-rails { display: flex; flex-direction: column; gap: 10px; margin: 0 0 14px; }
++ .rd-side .rd-trend-box, .rd-side .rd-voices-box { display: none; }
++}
++@media (max-width: 720px) {
++ /* The phone: the search box and one button that opens the sheet with the same sections. */
++ .rd-chipbar { display: none; }
++ .rd-sheet-btn { display: inline-flex; align-items: center; }
++ .rd-pickers { display: none; }
++}
++@media (max-width: 560px) {
++ .rd-bar { padding: 8px 0; }
++ .rd-search { height: 44px; }
++ .rd-pick-btn, .rd-toggle, .rd-search-clear, .rd-icon-btn { min-height: 44px; min-width: 44px; }
++ .rd-active-chip { padding: 6px 6px 6px 12px; }
++ .rd-active-x { width: 32px; height: 32px; }
++ .rd-sheet { max-height: 92vh; }
++ .rd-compact-top time { margin-left: 0; }
++ .rd-mini { flex-wrap: wrap; }
++}
++
++/* ── Yours: who sees it, and deleting (RUNDOWN-CONTROLS 2026-11-06) ── */
++.rd-aud-chip { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; padding: 2px 8px; border-radius: 999px;
++ border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.76rem; font-weight: 700; white-space: nowrap; }
++.rd-aud-chip i { font-size: 0.7rem; }
++.rd-card-top .rd-aud-chip { margin-left: 0; margin-right: auto; }
++.rd-own-icon { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px;
++ background: var(--bg-tertiary); color: var(--accent-light, var(--accent)); }
++.rd-own { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px;
++ padding: 12px 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); }
++.rd-own-aud { display: flex; align-items: flex-start; gap: 10px; min-width: 0; flex: 1 1 260px; }
++.rd-own-who { margin: 0; font-size: 0.95rem; }
++.rd-own-line { margin: 2px 0 0; color: var(--text-secondary); font-size: 0.86rem; }
++.rd-own-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++.rd-own-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 14px; border-radius: 10px;
++ border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-size: 0.9rem;
++ font-weight: 700; text-decoration: none; cursor: pointer; }
++.rd-own-btn:hover { border-color: var(--accent); }
++.rd-own-btn.is-danger { color: var(--danger); }
++.rd-own-btn.is-danger:hover { border-color: var(--danger); }
++.rd-own-btn:disabled { opacity: 0.6; cursor: default; }
++.rd-own-x { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 10px; border: 1px solid transparent;
++ background: none; color: var(--text-muted); cursor: pointer; }
++.rd-own-x:hover { color: var(--danger); border-color: var(--border); }
++.rd-card-foot:has(.rd-own-x) { justify-content: flex-start; }
++.rd-card-foot:has(.rd-own-x) > .rd-watch { margin-left: auto; }
++.rd-mine { display: flex; flex-direction: column; gap: 14px; margin: 0 0 18px; scroll-margin-top: 150px; }
++.rd-aud-pick { margin: 0; padding: 0; border: 0; min-width: 0; }
++.rd-aud-pick legend { padding: 0; margin: 0 0 8px; font-weight: 800; }
++.rd-aud-pick[disabled] { opacity: 0.55; }
++.rd-aud-opts { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-bottom: 8px; }
++.rd-aud-opt { position: relative; display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: 12px;
++ border: 1px solid var(--border); background: var(--bg-tertiary); cursor: pointer; }
++.rd-aud-opt input { position: absolute; opacity: 0; width: 1px; height: 1px; }
++.rd-aud-opt:has(input:checked) { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
++.rd-aud-opt:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
++.rd-aud-text { display: flex; flex-direction: column; min-width: 0; }
++.rd-aud-text small { color: var(--text-secondary); font-size: 0.82rem; }
++.rd-mine-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
++.rd-mine-row > div { min-width: 0; }
++.rd-mine-t { margin: 0 0 2px; font-weight: 800; }
++.rd-switch { position: relative; flex: none; width: 50px; height: 30px; padding: 0; border-radius: 999px; border: 1px solid var(--border);
++ background: var(--bg-tertiary); cursor: pointer; }
++.rd-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%;
++ background: var(--text-secondary); transition: transform 0.15s ease; }
++.rd-switch[aria-checked="true"] { background: var(--accent); border-color: var(--accent); }
++.rd-switch[aria-checked="true"]::after { transform: translateX(20px); background: #fff; }
++.rd-gone { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 28px 0; }
++.rd-gone .rd-h2 { margin: 4px 0 0; }
++.rd-gone p { margin: 0; color: var(--text-secondary); max-width: 60ch; }
++.rd-gone-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px !important; }
++/* The confirmation: a sheet on the phone, a card in the middle of the screen on a wide one. Over the app bar too. */
++.rd-dlg-wrap { position: fixed; inset: 0; z-index: 10001; }
++.rd-dlg-body { padding: 18px 16px 8px; }
++.rd-dlg-title { margin: 0 0 8px; font-size: 1.15rem; font-weight: 800; letter-spacing: 0; text-transform: none; color: var(--text-primary); }
++.rd-dlg-body p { margin: 0; color: var(--text-secondary); }
++.rd-dlg-check { display: flex; align-items: center; gap: 10px; margin-top: 14px; min-height: 44px; cursor: pointer; }
++.rd-dlg-check input { width: 18px; height: 18px; accent-color: var(--accent); }
++@media (min-width: 700px) {
++ .rd-dlg { left: 50%; right: auto; top: 50%; bottom: auto; width: min(440px, calc(100vw - 32px)); transform: translate(-50%, -50%);
++ border-radius: 18px; border: 1px solid var(--border); }
++}
++@media (max-width: 520px) {
++ .rd-own-actions { width: 100%; }
++ .rd-own-actions > * { flex: 1 1 auto; justify-content: center; }
++}
++/* The owner's controls on a touch screen: every one a 44 px target (the switch keeps its look; its hit area grows). */
++@media (pointer: coarse) {
++ .rd-own-btn { min-height: 44px; }
++ .rd-own-x { width: 44px; height: 44px; }
++ .rd-switch::before { content: ""; position: absolute; inset: -7px -2px; }
++ .rd-gone-actions .rd-own-btn, .rd-mine-row .rd-own-btn { min-height: 44px; }
++}
++
/* ── Touch (2026-11-06 phone pass, round two) ─────────────────────────────────
Every control of the Rundown carries a 44 px hit area on the element itself: the tabs, the avatars, the names,
the watch and read links, the topic chips, the reaction buttons and the speaker buttons. Nothing is hidden to
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex 975b7dc4..94dd324a 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -45,6 +45,12 @@ function _rdAv(u, size = 36) {
const inner = u.avatar_url ? `<img src="${_rdEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_rdEsc(_rdName(u).trim().charAt(0).toUpperCase() || '?')}</span>`;
return _rdA(_rdUserHref(u), inner, 'rd-av', _rdName(u)).replace('class="rd-av"', `class="rd-av" style="--rd-av:${size}px;--rd-c:${_rdEsc(u.profile_color || '#64748b')}"`);
}
++// The same face, not a link: for inside another link (a link in a link is closed early by the browser).
++function _rdFace(u, size = 36) {
++ if (!u) return '';
++ const inner = u.avatar_url ? `<img src="${_rdEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_rdEsc(_rdName(u).trim().charAt(0).toUpperCase() || '?')}</span>`;
++ return `<span class="rd-av" aria-hidden="true" style="--rd-av:${size}px;--rd-c:${_rdEsc(u.profile_color || '#64748b')}">${inner}</span>`;
++}
function _rdUser(u) { return u ? _rdA(_rdUserHref(u), _rdEsc(_rdName(u)), 'rd-user') : ''; }
function _rdTopicChip(t, extra = '') {
if (!t) return '';
@@@ -96,14 +102,14 @@ function _rdTake(s, { topic = true, user = true } = {}) {
${mentions ? `<p class="rd-cross"><i class="fa-solid fa-arrows-left-right" aria-hidden="true"></i> Brought up ${mentions}</p>` : ''}
</div>
</div>
-- <div class="rd-card-foot">${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}${_rdWatch(s.vod_id, s.sec)}</div>
++ <div class="rd-card-foot">${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}${_rdWatch(s.vod_id, s.sec)}${_rdOwnX(s)}</div>
</article>`;
}
function _rdEpisodeCard(e, { user = true } = {}) {
const href = `/rundown/e/${e.stream_id}`;
const chapters = (e.chapters || []).slice(0, 4);
return `<article class="rd-card rd-episode">
-- <div class="rd-card-top"><span class="rd-kicker">Episode</span><time datetime="${_rdEsc(e.aired_at)}">${_rdEsc(_rdDay(e.aired_at))}${e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</time></div>
++ <div class="rd-card-top"><span class="rd-kicker">Episode</span>${_rdAudChip(e)}<time datetime="${_rdEsc(e.aired_at)}">${_rdEsc(_rdDay(e.aired_at))}${e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</time></div>
<div class="rd-take-body">
${user ? `<div class="rd-take-who">${_rdAv(e.user, 48)}</div>` : ''}
<div class="rd-take-text">
@@@ -166,31 +172,19 @@ function _rdIssue(g) {
}
// ── The front page ───────────────────────────────────────────
--function _rdMast(counts, active = 'latest') {
++function _rdMast(counts) {
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const tally = counts ? [[counts.topics, 'topic'], [counts.takes, 'take'], [counts.episodes, 'episode']].filter(([n]) => n).map(([n, w]) => `${n} ${w}${n === 1 ? '' : 's'}`).join(' · ') : '';
-- const tab = (id, href, label) => _rdA(href, label, `rd-tab${active === id ? ' is-on' : ''}`).replace('<a ', `<a${active === id ? ' aria-current="page"' : ''} `);
return `<header class="rd-mast">
<div class="rd-mast-line"><span>${_rdEsc(today)}</span>${tally ? `<span>${_rdEsc(tally)}</span>` : ''}</div>
<h1 class="rd-title">The Rundown</h1>
<p class="rd-tagline">What streamers talked about, written up: the topics, their takes in their own words, and every stream as an episode.</p>
-- <nav class="rd-tabs" aria-label="The Rundown">${tab('latest', '/rundown', 'Latest')}${tab('topics', '/rundown/topics', 'Topics')}</nav>
</header>`;
}
// What a visitor can do here, under the masthead of the front page. The page said what it is and never what it is for
// ("still don't rly know wtf to do with this feature", a viewer, 2026-10-20). "Jump to the moment" opens the top take's.
--function _rdUses(lead) {
-- const item = (icon, title, text, href) => {
-- const body = `<i class="fa-solid ${icon}" aria-hidden="true"></i><span><b>${title}</b><small>${text}</small></span>`;
-- if (!href) return `<li><div class="rd-use">${body}</div></li>`;
-- return `<li>${href.startsWith('#') ? `<a class="rd-use" href="${href}">${body}</a>` : _rdA(href, body, 'rd-use')}</li>`;
-- };
-- return `<ul class="rd-uses" aria-label="What you can do here">
-- ${item('fa-forward', 'Catch up on a stream', 'Every stream becomes an episode: a recap, chapters and what was said.', '#rd-latest-h')}
-- ${item('fa-hashtag', 'Follow a topic', 'See every streamer who talked about it this week, side by side.', '/rundown/topics')}
-- ${item('fa-play', 'Jump to the moment', 'A take with a recording links to the second it was said.', lead && lead.vod_id ? videoHref('vod', lead.vod_id, Math.max(0, Math.floor(((lead.quotes || [])[0] || lead).sec || 0))) : null)}
-- ${item('fa-tower-broadcast', 'Get written up', 'Go live, and the desk turns your stream into an episode here.', '/broadcast')}
-- </ul>`;
++function _rdHow() {
++ return `<p class="rd-how">Catch up, follow a topic, jump to the moment, get written up: <a class="rd-how-link" href="#rd-about-h">how it works <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>${_rdMe() && _rdMe().username ? ` · <a class="rd-how-link" href="/rundown/${encodeURIComponent(_rdMe().username)}#mine">your Rundown</a>` : ''}</p>`;
}
function _rdOnAir(list) {
if (!list.length) return '';
@@@ -198,8 +192,8 @@ function _rdOnAir(list) {
<ul class="rd-onair-list">${list.map((x) => `<li>${_rdA(_rdChannelHref(x.user), `${x.user.avatar_url ? `<img src="${_rdEsc(x.user.avatar_url)}" alt="" width="32" height="32" loading="lazy">` : `<span class="rd-onair-i">${_rdEsc(_rdName(x.user).charAt(0).toUpperCase())}</span>`}<span class="rd-onair-t"><b>${_rdEsc(_rdName(x.user))}</b><small>${x.talking_about ? `talking about ${_rdEsc(x.talking_about)}` : _rdEsc(x.stream_title || 'live now')}</small></span>`, 'rd-onair-item')}</li>`).join('')}</ul></section>`;
}
function _rdTrending(list) {
-- return `<section class="rd-box" aria-labelledby="rd-trend-h"><h2 id="rd-trend-h" class="rd-h2">Trending topics <small>this week</small></h2>
-- ${list.length ? `<ol class="rd-trend">${list.map((t, i) => `<li><span class="rd-trend-n" aria-hidden="true">${i + 1}</span><div>${_rdTopicChip(t)}
++ return `<section class="rd-box rd-trend-box" aria-labelledby="rd-trend-h"><h2 id="rd-trend-h" class="rd-h2">Trending topics <small>this week</small></h2>
++ ${list.length ? `<ol class="rd-trend">${list.map((t, i) => `<li><span class="rd-trend-n" aria-hidden="true">${i + 1}</span><div>${_rdTopicFilter(t)}
<p class="rd-trend-meta"><span class="rd-stack" aria-hidden="true">${(t.people || []).map((p) => `<i style="--rd-c:${_rdEsc(p.profile_color || '#64748b')}">${p.avatar_url ? `<img src="${_rdEsc(p.avatar_url)}" alt="" width="20" height="20" loading="lazy">` : _rdEsc(_rdName(p).charAt(0).toUpperCase())}</i>`).join('')}</span>
${t.streamers} streamer${t.streamers === 1 ? '' : 's'} · ${t.takes} take${t.takes === 1 ? '' : 's'}</p></div></li>`).join('')}</ol>` : '<p class="rd-quiet">No topics yet this week.</p>'}
${_rdA('/rundown/topics', 'Every topic <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-more-link')}
@@@ -207,9 +201,9 @@ function _rdTrending(list) {
}
function _rdVoices(list) {
if (!list.length) return '';
-- return `<section class="rd-box" aria-labelledby="rd-voices-h"><h2 id="rd-voices-h" class="rd-h2">Who's been talking</h2>
-- <ul class="rd-voices">${list.map((v) => `<li>${_rdAv(v.user, 36)}<div><p class="rd-voice-name">${_rdUser(v.user)} <small>${_rdEsc(_rdAgo(v.last_seen))}</small></p>
-- ${(v.top_topics || []).length ? `<div class="rd-chips rd-chips-sm">${v.top_topics.map((t) => _rdTopicChip(t)).join('')}</div>` : ''}</div></li>`).join('')}</ul></section>`;
++ return `<section class="rd-box rd-voices-box" aria-labelledby="rd-voices-h"><h2 id="rd-voices-h" class="rd-h2">Who's been talking</h2>
++ <ul class="rd-voices">${list.map((v) => `<li>${_rdAv(v.user, 36)}<div><p class="rd-voice-name">${_rdA('/rundown?streamer=' + encodeURIComponent(v.user.username), _rdEsc(_rdName(v.user)), 'rd-user')} <small>${_rdEsc(_rdAgo(v.last_seen))}</small></p>
++ ${(v.top_topics || []).length ? `<div class="rd-chips rd-chips-sm">${v.top_topics.map((t) => _rdTopicFilter(t)).join('')}</div>` : ''}</div></li>`).join('')}</ul></section>`;
}
function _rdAbout() {
const me = _rdMe();
@@@ -218,23 +212,6 @@ function _rdAbout() {
${me ? `<p>${_rdA(`/rundown/${encodeURIComponent(me.username)}`, 'Your page')} · <button type="button" class="rd-link" data-rd-optout="1">Keep me out of The Rundown</button></p>` : ''}
</section>`;
}
--async function _rdHome(root) {
-- const d = await api('/rundown');
-- _rdNext = d.streams_next;
-- const feed = d.streams || [];
-- root.innerHTML = `${_rdMast(d.counts, 'latest')}${_rdUses(d.lead)}${_rdOnAir(d.on_air || [])}
-- <div class="rd-grid">
-- <div class="rd-main">
-- ${d.lead ? _rdLead(d.lead) : ''}
-- <section aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">Stream by stream</h2>
-- <div id="rd-feed">${feed.length ? feed.map(_rdIssue).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>
-- ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More streams</button>' : ''}
-- </section>
-- </div>
-- <aside class="rd-side">${_rdTrending(d.trending || [])}${_rdVoices(d.voices || [])}${_rdAbout()}</aside>
-- </div>`;
-- document.title = 'The Rundown · OpenVibe.Live';
--}
async function _rdMore(btn) {
if (!_rdNext) return;
btn.disabled = true;
@@@ -260,21 +237,12 @@ function _rdTopicCard(t) {
</article>`;
}
async function _rdTopics(root, query) {
-- const sort = query.get('sort') === 'most' ? 'most' : 'recent';
-- const kind = RD_KIND[query.get('kind')] ? query.get('kind') : '';
-- const d = await api(`/rundown/topics?sort=${sort}${kind ? `&kind=${kind}` : ''}`);
-- const href = (o) => { const p = new URLSearchParams({ ...(sort === 'most' ? { sort } : {}), ...(kind ? { kind } : {}), ...o }); for (const [k, v] of [...p]) if (!v) p.delete(k); const s = p.toString(); return `/rundown/topics${s ? `?${s}` : ''}`; };
-- root.innerHTML = `${_rdMast(null, 'topics')}
-- <div class="rd-filters">
-- <div class="rd-seg" role="group" aria-label="Sort">${_rdA(href({ sort: '' }), 'Most recent', `rd-seg-btn${sort === 'recent' ? ' is-on' : ''}`)}${_rdA(href({ sort: 'most' }), 'Most talked about', `rd-seg-btn${sort === 'most' ? ' is-on' : ''}`)}</div>
-- <div class="rd-chips" role="group" aria-label="Kind">${_rdA(href({ kind: '' }), 'All', `rd-chip${kind ? '' : ' is-on'}`)}${Object.entries(RD_KIND).map(([k, [label, color]]) => _rdA(href({ kind: k }), label, `rd-chip${kind === k ? ' is-on' : ''}`).replace('class="rd-chip', `style="--rd-k:${color}" class="rd-chip`)).join('')}</div>
-- </div>
-- ${d.topics.length ? `<div class="rd-topic-grid">${d.topics.slice(0, RD_FIRST).map(_rdTopicCard).join('')}</div>${d.topics.length > RD_FIRST ? `<details class="rd-more"><summary>${d.topics.length - RD_FIRST} more topic${d.topics.length - RD_FIRST === 1 ? '' : 's'}</summary><div class="rd-topic-grid">${d.topics.slice(RD_FIRST).map(_rdTopicCard).join('')}</div></details>` : ''}` : _rdEmpty('fa-comments', kind ? 'No topics of that kind yet.' : 'No topics yet: the desk is reading this week\'s streams.')}`;
-- document.title = 'Topics · The Rundown · OpenVibe.Live';
++ return await _rdBrowse(root, _rdPathState('/rundown/topics', new URLSearchParams(location.search)));
}
async function _rdTopic(root, slug) {
const d = await api(`/rundown/t/${encodeURIComponent(slug)}`);
const t = d.topic;
++ _rdPreset = { key: 'topic', value: t.slug, label: t.title };
const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
root.innerHTML = `<article class="rd-page" style="--rd-k:${color}">
<p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')} / ${_rdA('/rundown/topics', 'Topics')}</p>
@@@ -293,14 +261,17 @@ async function _rdTopic(root, slug) {
async function _rdEpisode(root, id) {
const d = await api(`/rundown/e/${encodeURIComponent(id)}`);
const e = d.episode, s = d.stream, u = s.user;
++ _rdOwner = d.mine && u ? String(u.username).toLowerCase() : null;
++ _rdPreset = u ? { key: 'streamer', value: u.username, label: _rdName(u) } : null;
const title = e ? e.title : (s.is_live ? `${_rdName(u)} is live` : `${_rdName(u)}'s stream`);
root.innerHTML = `<article class="rd-page">
<p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')} / ${u ? _rdA(_rdUserHref(u), _rdEsc(_rdName(u))) : ''}</p>
<header class="rd-page-head rd-ep-head">
-- <div class="rd-ep-who">${_rdAv(u, 56)}<div><span class="rd-kicker">${s.is_live ? '<span class="rd-dot" aria-hidden="true"></span> On air now' : 'Episode'}</span><p class="rd-by">${_rdUser(u)} · ${_rdEsc(_rdDay(s.started_at))}${e && e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</p></div></div>
++ <div class="rd-ep-who">${_rdAv(u, 56)}<div><span class="rd-kicker">${s.is_live ? '<span class="rd-dot" aria-hidden="true"></span> On air now' : 'Episode'}</span>${e && !d.mine ? _rdAudChip(e) : ''}<p class="rd-by">${_rdUser(u)} · ${_rdEsc(_rdDay(s.started_at))}${e && e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</p></div></div>
<h1 class="rd-h1">${_rdEsc(title)}</h1>
${e && e.logline ? `<p class="rd-deck">${_rdEsc(e.logline)}</p>` : ''}
<p class="rd-actions">${e ? _rdWatch(e.vod_id, 0, 'Watch the stream') : ''}${s.is_live && u ? _rdA(_rdChannelHref(u), '<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Watch live', 'rd-watch') : ''}</p>
++ ${e && d.mine ? _rdOwnTools(e) : ''}
</header>
${e && e.recap ? `<div class="rd-recap">${_rdParas(e.recap)}</div>` : (s.is_live ? '<p class="rd-quiet">The episode is written when the stream ends. Here is what the desk has heard so far.</p>' : '')}
${e && e.chapters.length ? `<section aria-labelledby="rd-ch-h"><h2 id="rd-ch-h" class="rd-h2">Chapters</h2><ol class="rd-chapters rd-chapters-big">${e.chapters.map((c) => `<li>${e.vod_id ? _rdA(videoHref('vod', e.vod_id, c.sec), `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${c.segment_id ? `<a href="#rd-take-${c.segment_id}">${_rdEsc(c.title)}</a>` : _rdEsc(c.title)}</li>`).join('')}</ol></section>` : ''}
@@@ -316,32 +287,655 @@ async function _rdEpisode(root, id) {
async function _rdStreamer(root, username) {
const d = await api(`/rundown/s/${encodeURIComponent(username)}`);
const u = d.user;
-- const mine = _rdMe() && _rdMe().username && _rdMe().username.toLowerCase() === u.username.toLowerCase();
++ _rdPreset = { key: 'streamer', value: u.username, label: _rdName(u) };
++ const mine = !!d.mine || _rdIsMine(u);
++ _rdOwner = mine ? String(u.username).toLowerCase() : null;
const head = `<p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')}</p>
<header class="rd-page-head rd-streamer-head">${_rdAv(u, 72)}<div><h1 class="rd-h1">${_rdEsc(_rdName(u))}</h1>
<p class="rd-meta">${d.opted_out ? 'Kept out of The Rundown' : `${d.episodes.length} episode${d.episodes.length === 1 ? '' : 's'} · ${d.topics.length} topic${d.topics.length === 1 ? '' : 's'}`} · ${_rdA(_rdChannelHref(u), 'Channel')}</p>
-- ${mine ? `<p><button type="button" class="rd-link" data-rd-optout="${d.opted_out ? '0' : '1'}">${d.opted_out ? 'Come back to The Rundown' : 'Keep me out of The Rundown'}</button></p>` : ''}</div></header>`;
-- if (d.opted_out) { root.innerHTML = `<article class="rd-page">${head}${_rdEmpty('fa-eye-slash', `${_rdEsc(_rdName(u))} keeps themselves out of The Rundown.`)}</article>`; return; }
-- root.innerHTML = `<article class="rd-page">${head}
++ </div></header>`;
++ let box = '';
++ if (mine) {
++ const st = await api('/rundown/settings').catch(() => null);
++ if (st) box = _rdMineBox(st);
++ }
++ if (d.opted_out) { root.innerHTML = `<article class="rd-page">${head}${box}${_rdEmpty('fa-eye-slash', `${_rdEsc(_rdName(u))} keeps themselves out of The Rundown.`)}</article>`; _rdToMine(); return; }
++ root.innerHTML = `<article class="rd-page">${head}${box}
${d.topics.length ? `<section aria-labelledby="rd-st-t"><h2 id="rd-st-t" class="rd-h2">What they talk about</h2><div class="rd-chips">${d.topics.map((t) => _rdTopicChip(t, t.takes > 1 ? ` <b>${t.takes}</b>` : '')).join('')}</div></section>` : ''}
${d.lines.length ? `<section aria-labelledby="rd-st-l"><h2 id="rd-st-l" class="rd-h2">Best lines</h2><div class="rd-lines">${d.lines.flatMap((s) => (s.quotes || []).slice(0, 1).map((q) => `<figure class="rd-line"><blockquote>“${_rdEsc(q.text)}”</blockquote>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}<figcaption>${_rdTopicChip(s.topic)} ${_rdSpeakBtn(q.text, u)} ${_rdWatch(s.vod_id, q.sec, 'Hear it')}</figcaption></figure>`)).join('')}</div></section>` : ''}
<section aria-labelledby="rd-st-e"><h2 id="rd-st-e" class="rd-h2">Episodes</h2>${d.episodes.map((e) => _rdEpisodeCard(e, { user: false })).join('') || _rdEmpty('fa-film', 'No episodes yet. Every stream with talk in it becomes one.')}</section>
${d.crossovers.length ? `<section aria-labelledby="rd-st-c"><h2 id="rd-st-c" class="rd-h2">Crossovers</h2>${d.crossovers.map((s) => _rdTake(s)).join('')}</section>` : ''}
</article>`;
document.title = `${_rdName(u)} · The Rundown · OpenVibe.Live`;
++ _rdToMine();
++}
++
++// ── Yours: who sees it, and deleting (RUNDOWN-CONTROLS design 2026-11-06) ─────────────────────────────────────
++// Who sees an episode is the sharing system's setting (Everyone / Anyone with the link / Specific people / Only me),
++// changed in the Shared Share sheet on the episode's page; the streamer's default decides what new write-ups start as.
++// A recording that is stricter wins (the episode follows it). Deleting removes the rows; the recording always stays.
++const RD_AUD = {
++ public: ['Everyone', 'fa-earth-americas', 'Anyone can find it on The Rundown.'],
++ link: ['Anyone with the link', 'fa-link', 'Left off The Rundown’s pages; its link opens it.'],
++ people: ['Specific people', 'fa-user-group', 'Only the people you add on each episode.'],
++ private: ['Only me', 'fa-lock', 'Nobody else sees it.'],
++};
++function _rdToast(msg, kind) { if (typeof toast === 'function') toast(msg, kind); }
++// The page's owner as the server said (a read answers `mine`), so the owner's tools do not wait for currentUser.
++let _rdOwner = null;
++function _rdIsMine(u) {
++ if (!u || !u.username) return false;
++ const name = String(u.username).toLowerCase();
++ if (_rdOwner && _rdOwner === name) return true;
++ const me = _rdMe();
++ return !!(me && me.username && String(me.username).toLowerCase() === name);
++}
++/** What a non-public item is, next to its kicker. Lists show those only to their owner; the page shows everyone. */
++function _rdAudChip(item) {
++ if (!item || !item.audience || item.audience === 'public') return '';
++ const [label, icon] = RD_AUD[item.audience] || RD_AUD.public;
++ return `<span class="rd-aud-chip"><i class="fa-solid ${icon}" aria-hidden="true"></i>${_rdEsc(item.audience_chip || label)}</span>`;
++}
++/** A take's delete button, for its streamer only. */
++function _rdOwnX(s) {
++ if (!s || !_rdIsMine(s.user)) return '';
++ return `<button type="button" class="rd-own-x" data-rd-del="take:${Number(s.id)}" aria-label="Delete this take" title="Delete this take"><i class="fa-solid fa-trash-can" aria-hidden="true"></i></button>`;
++}
++/** The episode page's tools for its streamer: who sees it, Share (which changes that), and Delete. */
++function _rdOwnTools(e) {
++ const [label, icon, line] = RD_AUD[e.audience] || RD_AUD.public;
++ const why = e.following_vod
++ ? `The recording is ${_rdEsc(e.following_vod_label || 'private')}, so this episode is too. Open the recording up on openvibe.video to change it.`
++ : `${_rdEsc(line)} Share changes who sees it.`;
++ // The Shared Share sheet (the navbar opens it for any [data-ov-share-sheet]); kind and id add "Who can open it".
++ const share = `<button type="button" class="rd-own-btn" data-ov-share-sheet data-kind="rundown-episode" data-id="${Number(e.stream_id)}"
++ data-url="${_rdEsc(`${location.origin}/rundown/e/${e.stream_id}`)}" data-title="${_rdEsc(e.title)}"${e.logline ? ` data-text="${_rdEsc(e.logline)}"` : ''}><i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i> Share</button>`;
++ return `<section class="rd-own" aria-label="Your episode">
++ <div class="rd-own-aud"><span class="rd-own-icon"><i class="fa-solid ${icon}" aria-hidden="true"></i></span>
++ <div><p class="rd-own-who">Who sees it: <b>${_rdEsc(e.audience_chip || label)}</b></p><p class="rd-own-line">${why}</p></div></div>
++ <div class="rd-own-actions">${share}<button type="button" class="rd-own-btn is-danger" data-rd-del="episode:${Number(e.stream_id)}"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete episode</button></div>
++ </section>`;
++}
++/** "Your Rundown", on the streamer's own page: the default for new write-ups, keeping out, and deleting it all. */
++function _rdMineBox(st) {
++ const opts = ['public', 'link', 'private'].concat(st.default_audience === 'people' ? ['people'] : []);
++ const radios = opts.map((a) => {
++ const [label, icon, line] = RD_AUD[a];
++ return `<label class="rd-aud-opt"><input type="radio" name="rd-default-aud" value="${a}"${st.default_audience === a ? ' checked' : ''} data-rd-default-aud>
++ <span class="rd-own-icon"><i class="fa-solid ${icon}" aria-hidden="true"></i></span><span class="rd-aud-text"><b>${_rdEsc(label)}</b><small>${_rdEsc(line)}</small></span></label>`;
++ }).join('');
++ return `<section class="rd-box rd-mine" id="mine" aria-labelledby="rd-mine-h">
++ <h2 id="rd-mine-h" class="rd-h2">Your Rundown</h2>
++ <fieldset class="rd-aud-pick"${st.opted_out ? ' disabled' : ''}><legend>Who sees what the desk writes up next</legend>
++ <div class="rd-aud-opts">${radios}</div>
++ <p class="rd-quiet">Episodes already written keep their own setting: change one with Share on its page.</p>
++ </fieldset>
++ <div class="rd-mine-row"><div><p class="rd-mine-t" id="rd-out-l">Keep me out of The Rundown</p><p class="rd-quiet">${st.opted_out ? 'On: nothing about your streams is shown, and the desk does not read them.' : 'Hides everything written about your streams and stops the desk reading them. You can come back any time.'}</p></div>
++ <button type="button" class="rd-switch" role="switch" aria-checked="${st.opted_out ? 'true' : 'false'}" aria-labelledby="rd-out-l" data-rd-optout="${st.opted_out ? '0' : '1'}"></button></div>
++ <div class="rd-mine-row"><div><p class="rd-mine-t">Delete my Rundown</p><p class="rd-quiet">Every episode and take written about your streams, for everyone. Your recordings stay.</p></div>
++ <button type="button" class="rd-own-btn is-danger" data-rd-del="all">Delete…</button></div>
++ </section>`;
++}
++/** Arriving at /rundown/<me>#mine scrolls to the box. */
++function _rdToMine() {
++ if (location.hash !== '#mine') return;
++ const box = document.getElementById('mine');
++ if (box) box.scrollIntoView({ block: 'start' });
++}
++/** Draw the current Rundown page again, in place. */
++function _rdReload() { loadRundownPage(location.pathname.split('/').filter(Boolean)); }
++
++/** An in-page confirmation (role alertdialog): resolves { ok, checked }. Escape and the backdrop cancel. */
++function _rdConfirm({ title, body, confirm, check = '', danger = true }) {
++ return new Promise((resolve) => {
++ const before = document.activeElement;
++ document.body.insertAdjacentHTML('beforeend', `<div class="rd-dlg-wrap"><div class="rd-sheet-backdrop" data-rd-dlg="no"></div>
++ <div class="rd-sheet rd-dlg" role="alertdialog" aria-modal="true" aria-labelledby="rd-dlg-h" aria-describedby="rd-dlg-b">
++ <div class="rd-dlg-body"><h2 id="rd-dlg-h" class="rd-dlg-title">${_rdEsc(title)}</h2><p id="rd-dlg-b">${_rdEsc(body)}</p>
++ ${check ? `<label class="rd-dlg-check"><input type="checkbox" id="rd-dlg-check"><span>${_rdEsc(check)}</span></label>` : ''}</div>
++ <div class="rd-sheet-foot"><button type="button" class="btn btn-secondary" data-rd-dlg="no">Cancel</button><button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'}" data-rd-dlg="yes">${_rdEsc(confirm)}</button></div>
++ </div></div>`);
++ const wrap = document.body.lastElementChild;
++ const buttons = [...wrap.querySelectorAll('button, input')];
++ const key = (ev) => {
++ if (ev.key === 'Escape') { ev.preventDefault(); done(false); return; }
++ if (ev.key !== 'Tab' || !buttons.length) return;
++ const first = buttons[0], last = buttons[buttons.length - 1];
++ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
++ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
++ };
++ function done(ok) {
++ const c = wrap.querySelector('#rd-dlg-check');
++ const checked = !!(c && c.checked);
++ wrap.remove();
++ document.removeEventListener('keydown', key, true);
++ document.body.classList.remove('rd-sheet-open');
++ if (before && typeof before.focus === 'function') before.focus();
++ resolve({ ok, checked });
++ }
++ document.addEventListener('keydown', key, true);
++ wrap.addEventListener('click', (ev) => { const b = ev.target.closest('[data-rd-dlg]'); if (b) done(b.dataset.rdDlg === 'yes'); });
++ document.body.classList.add('rd-sheet-open');
++ wrap.querySelector('.btn[data-rd-dlg="no"]').focus();
++ });
++}
++
++const RD_DELETE_ASK = {
++ take: { title: 'Delete this take?', body: 'It is removed for everyone, and its topic is recounted. The recording stays.', confirm: 'Delete take' },
++ episode: { title: 'Delete this episode?', body: 'The write-up and all its takes are removed for everyone, and the desk will not write this stream up again unless you ask. The recording stays.', confirm: 'Delete episode' },
++ all: { title: 'Delete your whole Rundown?', body: 'Every episode and take written about your streams is removed for everyone. This cannot be undone. Your recordings stay.', confirm: 'Delete everything', check: 'Keep me out of The Rundown from now on, too' },
++};
++async function _rdDelete(spec) {
++ const [what, rawId] = String(spec || '').split(':');
++ const ask = RD_DELETE_ASK[what];
++ if (!ask) return;
++ const answer = await _rdConfirm(ask);
++ if (!answer.ok) return;
++ const id = Number(rawId);
++ try {
++ if (what === 'take') {
++ const r = await api('/rundown/delete/take', { method: 'POST', body: { id } });
++ const card = document.getElementById(`rd-take-${id}`);
++ if (card) card.remove();
++ _rdToast(r.message || 'Deleted', 'success');
++ } else if (what === 'episode') {
++ const r = await api('/rundown/delete/episode', { method: 'POST', body: { stream_id: id } });
++ const page = document.querySelector('#rundown-root .rd-page');
++ if (page) page.innerHTML = _rdGone(r, id);
++ _rdToast(r.message || 'Deleted', 'success');
++ } else {
++ const r = await api('/rundown/delete/all', { method: 'POST', body: { keep_out: answer.checked } });
++ _rdToast(r.message || 'Deleted', 'success');
++ _rdReload();
++ }
++ } catch (err) { _rdToast(err.message || 'That did not work', 'error'); }
++}
++/** What an episode page says once its streamer deleted it: the receipt, and the way back. */
++function _rdGone(r, streamId) {
++ const me = _rdMe();
++ return `<div class="rd-gone" role="status">
++ <span class="rd-own-icon"><i class="fa-solid fa-check" aria-hidden="true"></i></span>
++ <h1 class="rd-h2">${_rdEsc(r.message || 'Deleted')}</h1>
++ <p>The recording is still yours on openvibe.video. The desk will not write this stream up again unless you ask it to.</p>
++ <p class="rd-gone-actions"><button type="button" class="rd-own-btn" data-rd-restore="${Number(streamId)}"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Write this stream up again</button>
++ ${me && me.username ? _rdA(`/rundown/${encodeURIComponent(me.username)}#mine`, 'Your Rundown', 'rd-own-btn') : ''}</p>
++ </div>`;
++}
++async function _rdRestore(btn) {
++ btn.disabled = true;
++ try {
++ await api('/rundown/restore', { method: 'POST', body: { stream_id: Number(btn.dataset.rdRestore) } });
++ const p = btn.closest('.rd-gone');
++ if (p) p.innerHTML = `<span class="rd-own-icon"><i class="fa-solid fa-pen-nib" aria-hidden="true"></i></span><h1 class="rd-h2">The desk will write it up again</h1><p>It reads the stream once more and the episode comes back here when it is done, with your default audience.</p>`;
++ } catch (err) { btn.disabled = false; _rdToast(err.message || 'That did not work', 'error'); }
++}
++// The default for new write-ups saves as soon as it is picked.
++document.addEventListener('change', async (e) => {
++ const radio = e.target.closest && e.target.closest('[data-rd-default-aud]');
++ if (!radio) return;
++ try {
++ const st = await api('/rundown/settings', { method: 'PUT', body: { default_audience: radio.value } });
++ _rdToast(`New write-ups: ${(RD_AUD[st.default_audience] || RD_AUD.public)[0]}`, 'success');
++ } catch (err) { _rdToast(err.message || 'That did not save', 'error'); _rdReload(); }
++});
++// After the Share sheet changes who sees an episode, the page says the new setting.
++document.addEventListener('ov-share:change', (e) => {
++ const d = e.detail || {};
++ if (d.kind === 'rundown-episode' && location.pathname.startsWith('/rundown/e/')) _rdReload();
++});
++
++// ── The bar: search and filters ────────────────────────────────────────────────
++// The state of a view is its URL: /rundown?streamer=goosely&topic=drake-and-objectives&when=week&q=baron. Every
++// filter is in it, so a view can be shared, the back button works and the page never keeps a state of its own.
++const RD_WHEN = [['day', 'Today'], ['week', 'This week'], ['month', 'This month'], ['', 'Any time']];
++const RD_SHOW = [['', 'Latest'], ['takes', 'Takes'], ['episodes', 'Episodes'], ['topics', 'Topics']];
++const RD_SORT = [['newest', 'Newest'], ['reacted', 'Most reacted'], ['takes', 'Most takes']];
++const RD_FILTER_NAMES = { q: 'search', streamer: 'Streamer', topic: 'Topic', when: 'When', show: 'Show', sort: 'Sort', video: 'With video', following: 'Following', mentions: 'Mentions me', kind: 'Kind' };
++
++function _rdState(query) {
++ const p = query instanceof URLSearchParams ? query : new URLSearchParams(query || '');
++ const one = (k) => (p.get(k) || '').trim();
++ return {
++ q: one('q').slice(0, 120),
++ streamer: one('streamer').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 8),
++ topic: one('topic').slice(0, 80),
++ when: ['day', 'week', 'month'].includes(one('when')) ? one('when') : '',
++ show: ['takes', 'episodes', 'topics'].includes(one('show')) ? one('show') : '',
++ sort: ['newest', 'reacted', 'takes'].includes(one('sort')) ? one('sort') : '',
++ video: one('video') === '1',
++ following: one('following') === '1',
++ mentions: one('mentions') === '1',
++ kind: one('kind').slice(0, 20),
++ };
++}
++/** The state as a query string: the same filters, the same order, every time. */
++function _rdQuery(state) {
++ const p = new URLSearchParams();
++ if (state.q) p.set('q', state.q);
++ if (state.streamer.length) p.set('streamer', state.streamer.join(','));
++ if (state.topic) p.set('topic', state.topic);
++ if (state.when) p.set('when', state.when);
++ if (state.show) p.set('show', state.show);
++ if (state.sort) p.set('sort', state.sort);
++ if (state.video) p.set('video', '1');
++ if (state.following) p.set('following', '1');
++ if (state.mentions) p.set('mentions', '1');
++ if (state.kind) p.set('kind', state.kind);
++ const s = p.toString();
++ return s ? '?' + s : '';
++}
++const _rdUrl = (state) => '/rundown' + _rdQuery(state);
++
++// The pickers' lists (the streamers who have been talking, the topics). They are fetched once, when a picker is
++// opened for the first time.
++let _rdOptions = null;
++let _rdOptionsBusy = false;
++let _rdPicker = '';
++let _rdPickQueries = { streamer: '', topic: '' };
++let _rdSheetScroll = 0;
++let _rdSearchTimer = null;
++let _rdPreset = null;
++const _rdTopicNames = new Map();
++
++function _rdTopicLabel(slug) {
++ return _rdTopicNames.get(slug) || String(slug || '').replace(/-/g, ' ');
++}
++function _rdRememberTopics(list) {
++ for (const t of list || []) if (t && t.slug && t.title) _rdTopicNames.set(t.slug, t.title);
++}
++/** A topic as a filter: the bar's way of asking for it. */
++function _rdTopicFilter(t) {
++ if (!t) return '';
++ const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
++ return _rdA('/rundown?topic=' + encodeURIComponent(t.slug), _rdEsc(t.title), 'rd-chip', label + ': ' + t.title)
++ .replace('class="rd-chip"', 'class="rd-chip" style="--rd-k:' + color + '"');
++}
++function _rdStreamerFilter(user) {
++ if (!user) return '';
++ return _rdA('/rundown?streamer=' + encodeURIComponent(user.username), _rdEsc(_rdName(user)), 'rd-chip');
++}
++/** How many filters are on: the phone's button says how many. */
++function _rdActiveCount(state, preset) {
++ let n = 0;
++ if (state.q) n++;
++ n += state.streamer.length;
++ if (state.topic) n++;
++ if (state.when) n++;
++ if (state.show) n++;
++ if (state.sort) n++;
++ if (state.video) n++;
++ if (state.following) n++;
++ if (state.mentions) n++;
++ if (preset) n++;
++ return n;
++}
++
++/** The bar: the search box and the filter chips. It is sticky under the navbar on every Rundown page. */
++function _rdBar(state, preset) {
++ const me = _rdMe();
++ const n = _rdActiveCount(state, preset);
++ const pick = (key, label, count) => '<button type="button" class="rd-pick-btn" data-rd-pick="' + key + '" aria-expanded="' + (_rdPicker === key ? 'true' : 'false') + '">' + label + (count ? '<b>' + count + '</b>' : '') + '</button>';
++ const toggle = (key, label, on) => '<button type="button" class="rd-toggle' + (on ? ' is-on' : '') + '" data-rd-toggle="' + key + '" aria-pressed="' + (on ? 'true' : 'false') + '">' + label + '</button>';
++ return '<div class="rd-bar" role="search" aria-label="Search and filter The Rundown">' +
++ '<div class="rd-bar-row">' +
++ '<div class="rd-search">' +
++ '<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>' +
++ '<input id="rd-search" class="rd-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="Search the Rundown: a topic, a streamer, something they said" value="' + _rdEsc(state.q) + '" aria-label="Search the Rundown">' +
++ (state.q ? '<button type="button" class="rd-search-clear" data-rd-clear="q" aria-label="Clear the search"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>' : '') +
++ '</div>' +
++ '<button type="button" class="rd-sheet-btn" data-rd-sheet="open" aria-haspopup="dialog">Filters' + (n ? ' (' + n + ')' : '') + '</button>' +
++ '</div>' +
++ '<div class="rd-chipbar" role="group" aria-label="Filters">' +
++ pick('streamer', 'Streamer', state.streamer.length) +
++ pick('topic', 'Topic', state.topic ? 1 : 0) +
++ pick('when', 'When', state.when ? 1 : 0) +
++ pick('show', 'Show', state.show ? 1 : 0) +
++ pick('sort', 'Sort', state.sort ? 1 : 0) +
++ toggle('video', 'With video', state.video) +
++ (me ? toggle('following', 'Following', state.following) : '') +
++ (me ? toggle('mentions', 'Mentions me', state.mentions) : '') +
++ '</div>' +
++ '<div class="rd-pickers" id="rd-pickers"' + (_rdPicker ? '' : ' hidden') + '>' + (_rdPicker ? _rdPickerPanel(state) : '') + '</div>' +
++ '</div>';
++}
++
++/** The filters that are on, as chips with a cross, and a way out of all of them. */
++function _rdActiveChips(state, preset) {
++ const chips = [];
++ if (state.q) chips.push({ key: 'q', value: '', label: state.q });
++ for (const s of state.streamer) chips.push({ key: 'streamer', value: s, label: s });
++ if (state.topic) chips.push({ key: 'topic', value: state.topic, label: _rdTopicLabel(state.topic) });
++ if (state.when) chips.push({ key: 'when', value: '', label: (RD_WHEN.find((w) => w[0] === state.when) || [])[1] });
++ if (state.show) chips.push({ key: 'show', value: '', label: (RD_SHOW.find((s) => s[0] === state.show) || [])[1] });
++ if (state.sort) chips.push({ key: 'sort', value: '', label: (RD_SORT.find((s) => s[0] === state.sort) || [])[1] });
++ if (state.video) chips.push({ key: 'video', value: '', label: 'With video' });
++ if (state.following) chips.push({ key: 'following', value: '', label: 'Following' });
++ if (state.mentions) chips.push({ key: 'mentions', value: '', label: 'Mentions me' });
++ if (preset) chips.push({ key: preset.key, value: preset.value, label: preset.label, preset: true });
++ if (!chips.length) return '';
++ const chip = (c) => '<span class="rd-active-chip">' + _rdEsc(c.label) +
++ '<button type="button" class="rd-active-x" data-rd-clear="' + _rdEsc(c.key) + (c.value ? ':' + _rdEsc(c.value) : '') + '" aria-label="Remove ' + _rdEsc(c.label) + '"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></span>';
++ return '<div class="rd-active" aria-label="Active filters">' + chips.map(chip).join('') +
++ '<button type="button" class="rd-link rd-clear-all" data-rd-clear="all">Clear all</button></div>';
++}
++
++/** One picker's panel: a small list of choices, with a search box where the list is long. */
++function _rdPickerPanel(state) {
++ const o = _rdOptions || { streamers: [], topics: [] };
++ const head = (title) => '<div class="rd-picker-head"><h3 class="rd-h3">' + title + '</h3><button type="button" class="rd-icon-btn" data-rd-pick-close="1" aria-label="Close"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>';
++ const list = (rows) => rows.length ? '<ul class="rd-picker-list">' + rows.join('') + '</ul>' : '<p class="rd-quiet">Nothing here yet.</p>';
++ const item = (key, value, on, body) => '<li><button type="button" class="rd-pick-item' + (on ? ' is-on' : '') + '" data-rd-pick-value="' + _rdEsc(key + ':' + value) + '" aria-pressed="' + (on ? 'true' : 'false') + '">' + body + '</button></li>';
++ if (_rdPicker === 'streamer') {
++ const q = _rdPickQueries.streamer.toLowerCase();
++ const rows = (o.streamers || []).filter((s) => !q || ((s.user.username || '') + ' ' + (s.user.display_name || '')).toLowerCase().includes(q));
++ return '<div class="rd-picker" role="dialog" aria-label="Streamer">' + head('Streamer') +
++ '<input class="rd-picker-search" type="search" data-rd-pick-search="streamer" placeholder="Search streamers" value="' + _rdEsc(_rdPickQueries.streamer) + '" aria-label="Search streamers">' +
++ list(rows.map((s) => item('streamer', s.user.username, state.streamer.includes(s.user.username),
++ _rdAv(s.user, 26) + '<span class="rd-pick-name">' + _rdEsc(_rdName(s.user)) + '</span><span class="rd-pick-count">' + s.takes + ' take' + (s.takes === 1 ? '' : 's') + '</span>'))) +
++ '</div>';
++ }
++ if (_rdPicker === 'topic') {
++ const q = _rdPickQueries.topic.toLowerCase();
++ const rows = (o.topics || []).filter((t) => !q || String(t.title || '').toLowerCase().includes(q));
++ return '<div class="rd-picker" role="dialog" aria-label="Topic">' + head('Topic') +
++ '<input class="rd-picker-search" type="search" data-rd-pick-search="topic" placeholder="Search topics" value="' + _rdEsc(_rdPickQueries.topic) + '" aria-label="Search topics">' +
++ list(rows.map((t) => item('topic', t.slug, state.topic === t.slug,
++ '<span class="rd-pick-name">' + _rdEsc(t.title) + '</span><span class="rd-pick-count">' + t.takes + ' take' + (t.takes === 1 ? '' : 's') + '</span>'))) +
++ '</div>';
++ }
++ const options = _rdPicker === 'when' ? RD_WHEN : (_rdPicker === 'show' ? RD_SHOW : RD_SORT);
++ const current = _rdPicker === 'when' ? state.when : (_rdPicker === 'show' ? state.show : state.sort);
++ const key = _rdPicker;
++ return '<div class="rd-picker" role="dialog" aria-label="' + key + '">' + head(key.charAt(0).toUpperCase() + key.slice(1)) +
++ list(options.map((o2) => item(key, o2[0], current === o2[0] || (!current && !o2[0]), '<span class="rd-pick-name">' + _rdEsc(o2[1]) + '</span>'))) +
++ '</div>';
++}
++
++/** The phone's sheet: the same sections as the bar's pickers, one under the other. */
++function _rdSheetPanel(state) {
++ const o = _rdOptions || { streamers: [], topics: [] };
++ const section = (title, body) => '<section class="rd-sheet-sec"><h3 class="rd-h3">' + title + '</h3>' + body + '</section>';
++ const me = _rdMe();
++ const when = RD_WHEN.map((w) => '<button type="button" class="rd-sheet-btn-row' + (state.when === w[0] ? ' is-on' : '') + '" data-rd-pick-value="when:' + w[0] + '">' + _rdEsc(w[1]) + '</button>').join('');
++ const show = RD_SHOW.map((w) => '<button type="button" class="rd-sheet-btn-row' + (state.show === w[0] ? ' is-on' : '') + '" data-rd-pick-value="show:' + w[0] + '">' + _rdEsc(w[1]) + '</button>').join('');
++ const sort = RD_SORT.map((w) => '<button type="button" class="rd-sheet-btn-row' + (state.sort === w[0] ? ' is-on' : '') + '" data-rd-pick-value="sort:' + w[0] + '">' + _rdEsc(w[1]) + '</button>').join('');
++ const toggles = [
++ '<button type="button" class="rd-sheet-btn-row' + (state.video ? ' is-on' : '') + '" data-rd-toggle="video" aria-pressed="' + (state.video ? 'true' : 'false') + '">With video</button>',
++ me ? '<button type="button" class="rd-sheet-btn-row' + (state.following ? ' is-on' : '') + '" data-rd-toggle="following" aria-pressed="' + (state.following ? 'true' : 'false') + '">Following</button>' : '',
++ me ? '<button type="button" class="rd-sheet-btn-row' + (state.mentions ? ' is-on' : '') + '" data-rd-toggle="mentions" aria-pressed="' + (state.mentions ? 'true' : 'false') + '">Mentions me</button>' : '',
++ ].join('');
++ const streamers = (o.streamers || []).map((s) => '<button type="button" class="rd-sheet-btn-row' + (state.streamer.includes(s.user.username) ? ' is-on' : '') + '" data-rd-pick-value="streamer:' + _rdEsc(s.user.username) + '">' + _rdAv(s.user, 24) + '<span>' + _rdEsc(_rdName(s.user)) + '</span></button>').join('');
++ const topics = (o.topics || []).map((t) => '<button type="button" class="rd-sheet-btn-row' + (state.topic === t.slug ? ' is-on' : '') + '" data-rd-pick-value="topic:' + _rdEsc(t.slug) + '">' + _rdEsc(t.title) + '</button>').join('');
++ return '<div class="rd-sheet-wrap"><div class="rd-sheet-backdrop" data-rd-sheet="close"></div>' +
++ '<div class="rd-sheet" role="dialog" aria-modal="true" aria-label="Filters" data-rd-sheet="panel">' +
++ '<div class="rd-sheet-head"><h2 class="rd-h2">Filters</h2><button type="button" class="rd-icon-btn" data-rd-sheet="close" aria-label="Close the filters"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button></div>' +
++ '<div class="rd-sheet-body" id="rd-sheet-body">' +
++ section('Streamer', '<input class="rd-picker-search" type="search" data-rd-pick-search="streamer" placeholder="Search streamers" value="' + _rdEsc(_rdPickQueries.streamer) + '" aria-label="Search streamers"><div class="rd-sheet-list">' + (streamers || '<p class="rd-quiet">No streamers yet.</p>') + '</div>') +
++ section('Topic', '<input class="rd-picker-search" type="search" data-rd-pick-search="topic" placeholder="Search topics" value="' + _rdEsc(_rdPickQueries.topic) + '" aria-label="Search topics"><div class="rd-sheet-list">' + (topics || '<p class="rd-quiet">No topics yet.</p>') + '</div>') +
++ section('When', '<div class="rd-sheet-list">' + when + '</div>') +
++ section('Show', '<div class="rd-sheet-list">' + show + '</div>') +
++ section('Sort', '<div class="rd-sheet-list">' + sort + '</div>') +
++ section('More', '<div class="rd-sheet-list">' + toggles + '</div>') +
++ '</div>' +
++ '<div class="rd-sheet-foot"><button type="button" class="btn btn-secondary" data-rd-clear="all">Clear all</button><button type="button" class="btn btn-primary" data-rd-sheet="close">Show results</button></div>' +
++ '</div></div>';
++}
++
++// ── Results ─────────────────────────────────────────────────────────────────
++// A take card: the streamer, the topic chip, the title, the quote, the moment and the reactions.
++function _rdTakeResult(s) {
++ const q = (s.quotes || [])[0];
++ return '<article class="rd-card rd-take" id="rd-take-' + s.id + '">' +
++ '<div class="rd-compact-top">' + _rdAv(s.user, 26) + '<span class="rd-compact-who">' + _rdUser(s.user) + '</span>' +
++ (s.topic ? _rdTopicFilter(s.topic) : '') + '<time datetime="' + _rdEsc(s.said_at) + '">' + _rdEsc(_rdAgo(s.said_at)) + '</time></div>' +
++ '<h3 class="rd-h3">' + _rdEsc(s.title) + '</h3>' +
++ (q ? '<blockquote class="rd-quote"><p>“' + _rdEsc(q.text) + '”</p></blockquote>' : '<p class="rd-what">' + _rdEsc(s.what) + '</p>') +
++ '<div class="rd-card-foot">' + _rdReactions('segment', s.id, s.reactions, s.my_reaction) + _rdWatch(s.vod_id, s.sec) + _rdOwnX(s) + '</div>' +
++ '</article>';
++}
++// One take inside an episode: a line, with the moment it was said.
++function _rdTakeMini(s) {
++ return '<div class="rd-mini">' + _rdAv(s.user, 22) + '<span class="rd-mini-title">' + _rdEsc(s.title) + '</span>' +
++ (s.topic ? _rdTopicFilter(s.topic) : '') +
++ (s.vod_id ? _rdA(videoHref('vod', s.vod_id, s.sec), '<i class="fa-solid fa-play" aria-hidden="true"></i> <span class="rd-stamp">' + _rdStamp(s.sec) + '</span>', 'rd-brief-play') : '') +
++ '</div>';
++}
++// An episode card: the title, the logline, its takes folded to the top ones, and how many are folded away.
++function _rdEpisodeResult(e) {
++ const takes = e.takes || [];
++ const folded = takes.slice(0, e.folded || 3);
++ const rest = takes.slice(e.folded || 3);
++ const hidden = Math.max(0, (e.takes_total || takes.length) - folded.length);
++ const more = rest.length
++ ? '<button type="button" class="rd-fold-btn" data-rd-fold="' + e.stream_id + '" data-rd-count="' + hidden + '">More takes (' + hidden + ')</button>' +
++ '<div class="rd-fold-more" data-rd-fold-body="' + e.stream_id + '" hidden>' + rest.map(_rdTakeMini).join('') + '</div>'
++ : '';
++ return '<article class="rd-card rd-episode" id="rd-episode-' + e.stream_id + '">' +
++ '<div class="rd-card-top"><span class="rd-kicker">Episode</span>' + _rdAudChip(e) + '<span class="rd-ep-count">' + (e.takes_total || takes.length) + ' take' + ((e.takes_total || takes.length) === 1 ? '' : 's') + '</span>' +
++ '<time datetime="' + _rdEsc(e.aired_at) + '">' + _rdEsc(_rdDay(e.aired_at)) + '</time></div>' +
++ '<h3 class="rd-h3">' + _rdA('/rundown/e/' + e.stream_id, _rdEsc(e.title)) + '</h3>' +
++ (e.logline ? '<p class="rd-logline">' + _rdEsc(e.logline) + '</p>' : '') +
++ (folded.length ? '<div class="rd-fold">' + folded.map(_rdTakeMini).join('') + '</div>' : '') +
++ more +
++ '</article>';
++}
++// The count line: "34 takes from 6 streamers".
++function _rdCountLine(d, state) {
++ const c = d.counts || {};
++ const one = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
++ const people = (n) => 'from ' + n + ' streamer' + (n === 1 ? '' : 's');
++ if (state.show === 'topics') return one(c.topics || 0, 'topic') + ' ' + people(c.topic_people || 0);
++ if (state.show === 'episodes') return one(c.episodes || 0, 'episode') + ' ' + people(c.episode_people || 0);
++ if (state.show === 'takes') return one(c.takes || 0, 'take') + ' ' + people(c.take_people || 0);
++ return one(c.takes || 0, 'take') + ' · ' + one(c.episodes || 0, 'episode') + ' ' + people(c.take_people || 0);
++}
++// Nothing matches: the way out is to drop the filter that costs the most, and the closest topics by name.
++function _rdNoResults(d) {
++ const s = d.suggestion || {};
++ const drop = s.drop ? '<p><button type="button" class="btn btn-secondary" data-rd-drop="' + _rdEsc(s.drop) + '">Drop the ' + _rdEsc(RD_FILTER_NAMES[s.drop] || s.drop) + ' filter</button></p>' : '';
++ const topics = (s.topics || []).length ? '<p class="rd-quiet">Closest topics:</p><div class="rd-chips">' + s.topics.map(_rdTopicFilter).join('') + '</div>' : '';
++ return '<div class="rd-empty"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><p>Nothing matches those filters.</p>' + drop + topics + '</div>';
++}
++function _rdResultsHtml(d, state) {
++ if (state.show === 'topics') return (d.topics || []).length ? d.topics.map(_rdTopicCard).join('') : _rdNoResults(d);
++ if (state.show === 'episodes') return (d.episodes || []).length ? d.episodes.map(_rdEpisodeResult).join('') : _rdNoResults(d);
++ if (state.show === 'takes') return (d.takes || []).length ? d.takes.map(_rdTakeResult).join('') : _rdNoResults(d);
++ const items = [];
++ for (const t of d.takes || []) items.push({ at: String(t.said_at || ''), html: _rdTakeResult(t) });
++ for (const e of d.episodes || []) items.push({ at: String(e.aired_at || ''), html: _rdEpisodeResult(e) });
++ items.sort((a, b) => b.at.localeCompare(a.at));
++ return items.length ? items.map((x) => x.html).join('') : _rdNoResults(d);
++}
++// The kinds, for the topics view (the old topics page had them).
++function _rdKindChips(state) {
++ const href = (k) => _rdUrl({ ...state, show: 'topics', kind: k });
++ const chip = (k, label, color) => {
++ const a = _rdA(href(k), _rdEsc(label), 'rd-chip' + (state.kind === k ? ' is-on' : ''));
++ return color ? a.replace('class="rd-chip', 'style="--rd-k:' + color + '" class="rd-chip') : a;
++ };
++ return '<div class="rd-chips rd-kinds" role="group" aria-label="Kind">' + chip('', 'All', '') +
++ Object.entries(RD_KIND).map(([k, v]) => chip(k, v[0], v[1])).join('') + '</div>';
++}
++// Trending topics and who's been talking, as two rails under the bar (on the phone) and in the side column.
++function _rdRails(d) {
++ const trending = (d.trending || []).map(_rdTopicFilter).join('');
++ const voices = (d.voices || []).map((v) => '<a class="rd-rail-item" href="/rundown?streamer=' + encodeURIComponent(v.user.username) + '">' + _rdFace(v.user, 24) + '<span>' + _rdEsc(_rdName(v.user)) + '</span></a>').join('');
++ return '<div class="rd-rails">' +
++ '<section class="rd-rail"><h2 class="rd-h2">Trending topics</h2><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +
++ '<section class="rd-rail"><h2 class="rd-h2">Who\'s been talking</h2><div class="rd-rail-track">' + (voices || '<span class="rd-quiet">Nobody yet.</span>') + '</div></section>' +
++ '</div>';
++}
++
++// ── The page the bar is on ──────────────────────────────────────────────────
++/** The state of the page, with the old topics page asking for the topics. */
++function _rdPathState(path, query) {
++ const st = _rdState(query);
++ if (path === '/rundown/topics' && !st.show) st.show = 'topics';
++ return st;
++}
++async function _rdBrowse(root, state) {
++ const d = await api('/rundown/browse' + _rdQuery(state));
++ _rdRememberTopics(d.topics);
++ _rdRememberTopics(d.trending);
++ root.innerHTML = _rdBar(state, null) +
++ '<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +
++ _rdMast(d.counts) + _rdHow() +
++ '<div id="rd-rails-host">' + _rdRails(d) + '</div>' +
++ '<div class="rd-grid"><div class="rd-main">' +
++ _rdOnAir(d.on_air || []) +
++ (state.show === 'topics' ? _rdKindChips(state) : '') +
++ '<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +
++ '<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' +
++ '</div><aside class="rd-side">' + _rdTrending(d.trending || []) + _rdVoices(d.voices || []) + _rdAbout() + '</aside></div>';
++ document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';
++}
++/** Only the results change while the words are typed: the page is not redrawn under the reader. */
++async function _rdRefresh() {
++ const state = _rdPathState(location.pathname, new URLSearchParams(location.search));
++ const d = await api('/rundown/browse' + _rdQuery(state));
++ _rdRememberTopics(d.topics);
++ const results = document.getElementById('rd-results');
++ if (results) results.innerHTML = _rdResultsHtml(d, state);
++ const count = document.getElementById('rd-count');
++ if (count) count.textContent = _rdCountLine(d, state);
++ const active = document.getElementById('rd-active-host');
++ if (active) active.innerHTML = _rdActiveChips(state, null);
++ document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';
++}
++
++// ── What the bar does ──────────────────────────────────────────────────────
++/** A new view: the filters in the URL, and the back button gets a step. */
++function _rdGo(state) {
++ const url = _rdUrl(state);
++ if (url === location.pathname + location.search) return;
++ navigate(url);
++}
++function _rdPickValue(value) {
++ const parts = String(value || '').split(':');
++ const key = parts[0], val = parts.slice(1).join(':');
++ const state = _rdState(new URLSearchParams(location.search));
++ if (key === 'streamer') {
++ const list = state.streamer.slice();
++ const at = list.indexOf(val);
++ if (at >= 0) list.splice(at, 1); else list.push(val);
++ state.streamer = list;
++ } else if (key === 'topic') state.topic = state.topic === val ? '' : val;
++ else if (key === 'when') state.when = state.when === val ? '' : val;
++ else if (key === 'show') state.show = state.show === val ? '' : val;
++ else if (key === 'sort') state.sort = state.sort === val ? '' : val;
++ _rdPicker = '';
++ _rdGo(state);
++}
++function _rdToggleFilter(key) {
++ const state = _rdState(new URLSearchParams(location.search));
++ if (key === 'video') state.video = !state.video;
++ else if (key === 'following') state.following = !state.following;
++ else if (key === 'mentions') state.mentions = !state.mentions;
++ _rdGo(state);
++}
++/** A cross on a filter, or "Clear all". */
++function _rdClear(spec) {
++ const parts = String(spec || '').split(':');
++ const key = parts[0], val = parts.slice(1).join(':');
++ if (key === 'all') { navigate('/rundown'); return; }
++ const state = _rdState(new URLSearchParams(location.search));
++ if (key === 'q') state.q = '';
++ else if (key === 'streamer') state.streamer = val ? state.streamer.filter((s) => s !== val) : [];
++ else if (key === 'topic') state.topic = '';
++ else if (key === 'when') state.when = '';
++ else if (key === 'show') state.show = '';
++ else if (key === 'sort') state.sort = '';
++ else if (key === 'video') state.video = false;
++ else if (key === 'following') state.following = false;
++ else if (key === 'mentions') state.mentions = false;
++ else if (key === 'kind') state.kind = '';
++ _rdGo(state);
++}
++async function _rdEnsureOptions() {
++ if (_rdOptions || _rdOptionsBusy) return;
++ _rdOptionsBusy = true;
++ try { _rdOptions = await api('/rundown/options'); _rdRememberTopics(_rdOptions.topics); }
++ catch { _rdOptions = { streamers: [], topics: [] }; }
++ _rdOptionsBusy = false;
++ _rdRenderPickers();
++ _rdRenderSheet();
++}
++async function _rdOpenPicker(name) {
++ _rdPicker = _rdPicker === name ? '' : name;
++ _rdRenderPickers();
++ if (_rdPicker && !_rdOptions) await _rdEnsureOptions();
++}
++function _rdRenderPickers() {
++ const host = document.getElementById('rd-pickers');
++ if (!host) return;
++ host.hidden = !_rdPicker;
++ host.innerHTML = _rdPicker ? _rdPickerPanel(_rdState(new URLSearchParams(location.search))) : '';
++}
++/** The phone's sheet: it opens over the page, and it keeps its scroll. */
++function _rdSheetOpen(what) {
++ const wrap = document.querySelector('.rd-sheet-wrap');
++ if (what === 'open') {
++ if (wrap) return;
++ document.body.insertAdjacentHTML('beforeend', _rdSheetPanel(_rdState(new URLSearchParams(location.search))));
++ const body = document.getElementById('rd-sheet-body');
++ if (body) body.scrollTop = _rdSheetScroll;
++ document.body.classList.add('rd-sheet-open');
++ if (!_rdOptions) _rdEnsureOptions();
++ return;
++ }
++ const body = document.getElementById('rd-sheet-body');
++ if (body) _rdSheetScroll = body.scrollTop;
++ if (wrap) wrap.remove();
++ document.body.classList.remove('rd-sheet-open');
++}
++function _rdRenderSheet() {
++ const wrap = document.querySelector('.rd-sheet-wrap');
++ if (!wrap) return;
++ wrap.outerHTML = _rdSheetPanel(_rdState(new URLSearchParams(location.search)));
++ const body = document.getElementById('rd-sheet-body');
++ if (body) body.scrollTop = _rdSheetScroll;
++}
++/** "More takes (9)" opens the folded ones in place. */
++function _rdFold(btn) {
++ const body = document.querySelector('[data-rd-fold-body="' + btn.dataset.rdFold + '"]');
++ if (!body) return;
++ const open = body.hasAttribute('hidden');
++ if (open) body.removeAttribute('hidden'); else body.setAttribute('hidden', '');
++ btn.textContent = open ? 'Show fewer takes' : 'More takes (' + (btn.dataset.rdCount || '0') + ')';
++}
++/** The words: results as they are typed, the URL behind them, and the back button left alone. */
++async function _rdSearch(value) {
++ const state = _rdState(new URLSearchParams(location.search));
++ state.q = String(value || '').trim();
++ const url = _rdUrl(state);
++ if (location.pathname !== '/rundown' && location.pathname !== '/rundown/topics') { navigate(url); return; }
++ if (url === location.pathname + location.search) return;
++ history.replaceState(null, '', url);
++ await _rdRefresh();
}
// ── Router and events ────────────────────────────────────────
++/** The bar, on every Rundown page. The page's own filter fills it in, so a streamer's page is the Rundown
++ * narrowed to them, and the cross on that chip is the way out of it. */
++function _rdAddBar(root) {
++ const state = _rdState(new URLSearchParams(location.search));
++ root.insertAdjacentHTML('afterbegin', _rdBar(state, _rdPreset) + '<div id="rd-active-host">' + _rdActiveChips(state, _rdPreset) + '</div>');
++}
async function loadRundownPage(segments = []) {
const root = document.getElementById('rundown-root');
if (!root) return;
const [, first, second] = segments;
++ _rdPreset = null;
++ _rdOwner = null;
try {
-- if (first === 'topics') return await _rdTopics(root, new URLSearchParams(location.search));
-- if (first === 't' && second) return await _rdTopic(root, decodeURIComponent(second));
-- if (first === 'e' && second) return await _rdEpisode(root, second);
-- if (first) return await _rdStreamer(root, decodeURIComponent(first));
-- return await _rdHome(root);
++ if (first === 'topics') await _rdTopics(root, new URLSearchParams(location.search));
++ else if (first === 't' && second) await _rdTopic(root, decodeURIComponent(second));
++ else if (first === 'e' && second) await _rdEpisode(root, second);
++ else if (first === 's' && second) await _rdStreamer(root, decodeURIComponent(second));
++ else if (first) await _rdStreamer(root, decodeURIComponent(first));
++ else await _rdBrowse(root, _rdPathState('/rundown', new URLSearchParams(location.search)));
++ // The browse view draws its own bar; every other Rundown page gets it added on top (once).
++ if (!root.querySelector('.rd-bar')) _rdAddBar(root);
} catch (err) {
const missing = err && err.status === 404;
root.innerHTML = _rdEmpty(missing ? 'fa-magnifying-glass' : 'fa-plug-circle-xmark', _rdEsc(missing ? 'Nothing here. It may have been taken down, or it never existed.' : (err && err.message) || 'The Rundown could not load.'), _rdA('/rundown', 'Back to The Rundown', 'btn btn-primary'));
@@@ -370,8 +964,68 @@ document.addEventListener('click', async (e) => {
if (opt) {
e.preventDefault();
const out = opt.dataset.rdOptout === '1';
-- if (out && !confirm('Keep yourself out of The Rundown? Everything written about your streams is hidden and the desk stops reading them. You can come back any time.')) return;
-- try { await api('/rundown/opt-out', { method: 'PUT', body: { out } }); if (typeof toast === 'function') toast(out ? 'You are out of The Rundown.' : 'Welcome back to The Rundown.', 'success'); loadRundownPage(location.pathname.split('/').filter(Boolean)); }
-- catch (err) { if (typeof toast === 'function') toast(err.message || 'That did not work', 'error'); }
++ if (out) {
++ const ok = await _rdConfirm({ title: 'Keep yourself out of The Rundown?', body: 'Everything written about your streams is hidden from everyone, and the desk stops reading them. You can come back any time.', confirm: 'Keep me out', danger: false });
++ if (!ok.ok) return;
++ }
++ try { await api('/rundown/opt-out', { method: 'PUT', body: { out } }); _rdToast(out ? 'You are out of The Rundown.' : 'Welcome back to The Rundown.', 'success'); _rdReload(); }
++ catch (err) { _rdToast(err.message || 'That did not work', 'error'); }
++ return;
++ }
++ const del = e.target.closest('[data-rd-del]');
++ if (del) { e.preventDefault(); _rdDelete(del.dataset.rdDel); return; }
++ const restore = e.target.closest('[data-rd-restore]');
++ if (restore) { e.preventDefault(); _rdRestore(restore); }
++});
++
++// ── The bar's events ────────────────────────────────────────────────────────
++document.addEventListener('click', (e) => {
++ const pick = e.target.closest('[data-rd-pick]');
++ if (pick) { e.preventDefault(); _rdOpenPicker(pick.dataset.rdPick); return; }
++ const close = e.target.closest('[data-rd-pick-close]');
++ if (close) { e.preventDefault(); _rdPicker = ''; _rdRenderPickers(); return; }
++ const value = e.target.closest('[data-rd-pick-value]');
++ if (value) { e.preventDefault(); _rdPickValue(value.dataset.rdPickValue); return; }
++ const toggle = e.target.closest('[data-rd-toggle]');
++ if (toggle) { e.preventDefault(); _rdToggleFilter(toggle.dataset.rdToggle); return; }
++ const clear = e.target.closest('[data-rd-clear]');
++ if (clear) { e.preventDefault(); _rdClear(clear.dataset.rdClear); return; }
++ const drop = e.target.closest('[data-rd-drop]');
++ if (drop) { e.preventDefault(); _rdClear(drop.dataset.rdDrop); return; }
++ const sheet = e.target.closest('[data-rd-sheet]');
++ if (sheet) { e.preventDefault(); _rdSheetOpen(sheet.dataset.rdSheet); return; }
++ const fold = e.target.closest('[data-rd-fold]');
++ if (fold) { e.preventDefault(); _rdFold(fold); return; }
++});
++
++// The words: the results follow the typing, 250 ms later.
++document.addEventListener('input', (e) => {
++ const search = e.target.closest('#rd-search');
++ if (search) {
++ clearTimeout(_rdSearchTimer);
++ const value = search.value;
++ _rdSearchTimer = setTimeout(() => _rdSearch(value), 250);
++ return;
}
++ const pickSearch = e.target.closest('[data-rd-pick-search]');
++ if (pickSearch) {
++ const kind = pickSearch.dataset.rdPickSearch;
++ _rdPickQueries[kind] = pickSearch.value;
++ _rdRenderPickers();
++ _rdRenderSheet();
++ const again = document.querySelector('[data-rd-pick-search="' + kind + '"]');
++ if (again) { again.focus(); try { again.setSelectionRange(again.value.length, again.value.length); } catch { /* */ } }
++ }
++});
++
++// "/" focuses the search, wherever the reader is on the page.
++document.addEventListener('keydown', (e) => {
++ if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
++ const t = e.target;
++ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
++ const input = document.getElementById('rd-search');
++ if (!input) return;
++ e.preventDefault();
++ input.focus();
++ input.select();
});
ddiff --git a/server/rundown/browse.js b/server/rundown/browse.js
nnew file mode 100644
iindex 00000000..d6e5de96
---- /dev/null
++++ b/server/rundown/browse.js
@@@ -0,0 +1,332 @@
++'use strict';
++/**
++ * The Rundown's bar: what you can search and narrow (design 2026-11-06, section 4; plan-state-2026-10-22/
++ * RUNDOWN-CONTROLS-AND-BROWSE-DESIGN-2026-11-06.md).
++ *
++ * One endpoint answers every view the bar can be in, so the state of a view is one thing (the query string) and the
++ * page never has to piece several answers together:
++ *
++ * browse(query, { viewer }) the results the filters ask for, the counts, the closest topics when nothing matches
++ * options() the lists the pickers show: the streamers who've been talking, the topics
++ *
++ * The words come from the tsvector columns the search migration fills (migrations/0031_rundown_search.sql): a take's
++ * title, what was said, the desk's quip, its quotes and its topic; an episode's title, logline, recap and chapters; a
++ * topic's name, headline and summary. A streamer's name is matched by prefix, not by word, so "goos" finds them.
++ *
++ * Who may see what is the same rule the gathered pages use (server/rundown/read.js): the reader's own items, or items
++ * everyone may see. A search never reaches past it.
++ */
++const db = require('../db/database');
++const read = require('./read');
++
++const { VISIBLE, AUDIENCE_JOINS, SEGMENT_COLS, EPISODE_COLS } = read;
++
++const MAX_TAKES = 40; // how many takes one page of results holds
++const MAX_EPISODES = 20; // how many episodes one page holds
++const MAX_TOPICS = 40; // how many topics one page holds
++const EPISODE_TAKES = 24; // how many takes of an episode are sent, so "More takes (N)" can open in place
++const FOLDED = 3; // how many takes an episode card shows before it folds
++const WHENS = { day: 1, week: 7, month: 30 };
++const SHOWS = ['takes', 'episodes', 'topics'];
++const SORTS = ['newest', 'reacted', 'takes'];
++const ORDER = ['q', 'streamer', 'topic', 'when', 'show', 'sort', 'video', 'following', 'mentions'];
++
++const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 19).replace('T', ' ');
++const truthy = (v) => v === '1' || v === 'true' || v === 'yes';
++
++/**
++ * The bar's state, read from the query string: what was searched, which streamers, which topic, when, what the
++ * results are, how they are sorted, and the three switches. Anything unknown is dropped, so a hand-made URL never
++ * reaches the database as SQL.
++ */
++function parse(query = {}) {
++ const one = (v) => String(v == null ? '' : v).trim();
++ const q = one(query.q).slice(0, 120);
++ const streamer = one(query.streamer).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean).slice(0, 8);
++ const topic = one(query.topic).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 80);
++ const when = Object.prototype.hasOwnProperty.call(WHENS, one(query.when).toLowerCase()) ? one(query.when).toLowerCase() : null;
++ const show = SHOWS.includes(one(query.show).toLowerCase()) ? one(query.show).toLowerCase() : null;
++ const sort = SORTS.includes(one(query.sort).toLowerCase()) ? one(query.sort).toLowerCase() : 'newest';
++ return {
++ q, streamer, topic, when, show, sort,
++ video: truthy(one(query.video)),
++ following: truthy(one(query.following)),
++ mentions: truthy(one(query.mentions)),
++ kind: one(query.kind).toLowerCase().slice(0, 20),
++ };
++}
++
++/** Is anything narrowed? An empty bar is the plain page, and it is the only one a crawler should keep. */
++function isFiltered(st) {
++ return !!(st.q || st.streamer.length || st.topic || st.when || st.show || st.sort !== 'newest' || st.video || st.following || st.mentions || st.kind);
++}
++
++/** The names of the filters that are on, in the order the bar shows them. */
++function activeKeys(st) {
++ const keys = [];
++ if (st.q) keys.push('q');
++ if (st.streamer.length) keys.push('streamer');
++ if (st.topic) keys.push('topic');
++ if (st.when) keys.push('when');
++ if (st.show) keys.push('show');
++ if (st.sort !== 'newest') keys.push('sort');
++ if (st.video) keys.push('video');
++ if (st.following) keys.push('following');
++ if (st.mentions) keys.push('mentions');
++ if (st.kind) keys.push('kind');
++ return keys;
++}
++
++/** LIKE with the user's own % and _ kept as text. */
++const likeArg = (s) => String(s || '').toLowerCase().replace(/[\\%_]/g, (c) => '\\' + c);
++
++/** The accounts a search names by prefix ("goos" finds goosely). */
++async function prefixUsers(q) {
++ if (!q) return [];
++ const like = likeArg(q) + '%';
++ const rows = await db.all('SELECT id FROM users WHERE lower(username) LIKE ? ESCAPE \'\\\' OR lower(display_name) LIKE ? ESCAPE \'\\\' LIMIT 12', [like, like]);
++ return rows.map((r) => Number(r.id));
++}
++
++/** The accounts the streamer filter names. */
++async function userIds(usernames) {
++ if (!usernames || !usernames.length) return [];
++ const rows = await db.all('SELECT id FROM users WHERE lower(username) = ANY(?)', [usernames.map((u) => String(u).toLowerCase())]);
++ return rows.map((r) => Number(r.id));
++}
++
++/** The topic the topic filter names. */
++async function topicIdOf(slug) {
++ if (!slug) return null;
++ const row = await db.get("SELECT id FROM rundown_topics WHERE slug = ? AND status = 'live'", [slug]);
++ return row ? Number(row.id) : null;
++}
++
++/**
++ * The filters, as SQL, for the takes. Every part is a named parameter; the only text that reaches the database as
++ * SQL is the sort, which is one of three fixed words.
++ */
++function takeFilters(st, { users, topicId, prefix, viewer }) {
++ const where = ["s.status = 'live'", VISIBLE('s')];
++ const params = { viewer: viewer == null ? null : Number(viewer) };
++ if (st.q) {
++ where.push("(s.search_tsv @@ websearch_to_tsquery('english', @q) OR s.user_id = ANY(@prefix))");
++ params.q = st.q;
++ params.prefix = prefix;
++ }
++ if (users && users.length) { where.push('s.user_id = ANY(@users)'); params.users = users; }
++ if (topicId) { where.push('s.topic_id = @topic'); params.topic = topicId; }
++ if (st.when) { where.push('s.said_at >= @since'); params.since = daysAgo(WHENS[st.when]); }
++ if (st.video) where.push('s.vod_id IS NOT NULL');
++ if (st.mentions && params.viewer != null) where.push('@viewer = ANY(s.mentions)');
++ if (st.following && params.viewer != null) where.push('s.user_id IN (SELECT streamer_id FROM follows WHERE follower_id = @viewer)');
++ return { where: where.join(' AND '), params };
++}
++
++/** The filters, as SQL, for the episodes. A topic filter asks for the episodes that have a take on it. */
++function episodeFilters(st, { users, topicId, prefix, viewer }) {
++ const where = ["r.status = 'live'", VISIBLE('r')];
++ const params = { viewer: viewer == null ? null : Number(viewer) };
++ if (st.q) {
++ where.push("(r.search_tsv @@ websearch_to_tsquery('english', @q) OR r.user_id = ANY(@prefix))");
++ params.q = st.q;
++ params.prefix = prefix;
++ }
++ if (users && users.length) { where.push('r.user_id = ANY(@users)'); params.users = users; }
++ if (topicId) { where.push('EXISTS (SELECT 1 FROM rundown_segments s2 WHERE s2.stream_id = r.stream_id AND s2.topic_id = @topic AND s2.status = \'live\')'); params.topic = topicId; }
++ if (st.when) { where.push('r.aired_at >= @since'); params.since = daysAgo(WHENS[st.when]); }
++ if (st.video) where.push('r.vod_id IS NOT NULL');
++ if (st.following && params.viewer != null) where.push('r.user_id IN (SELECT streamer_id FROM follows WHERE follower_id = @viewer)');
++ return { where: where.join(' AND '), params };
++}
++
++/** The filters, as SQL, for the topics. A topic is only there when a take everyone may see is on it. */
++function topicFilters(st, { users, prefix, viewer }) {
++ const where = ["t.status = 'live'", VISIBLE('s')];
++ const params = { viewer: viewer == null ? null : Number(viewer) };
++ if (st.q) {
++ where.push("(t.search_tsv @@ websearch_to_tsquery('english', @q) OR s.user_id = ANY(@prefix))");
++ params.q = st.q;
++ params.prefix = prefix;
++ }
++ if (users && users.length) { where.push('s.user_id = ANY(@users)'); params.users = users; }
++ if (st.when) { where.push('s.said_at >= @since'); params.since = daysAgo(WHENS[st.when]); }
++ if (st.kind) { where.push('t.kind = @kind'); params.kind = st.kind; }
++ if (st.following && params.viewer != null) where.push('s.user_id IN (SELECT streamer_id FROM follows WHERE follower_id = @viewer)');
++ return { where: where.join(' AND '), params };
++}
++
++/** The takes, the episodes and the topics the filters ask for, and how many of each there are. */
++async function results(st, { users, topicId, prefix, viewer }) {
++ const tf = takeFilters(st, { users, topicId, prefix, viewer });
++ const ef = episodeFilters(st, { users, topicId, prefix, viewer });
++ const topf = topicFilters(st, { users, prefix, viewer });
++
++ const takeOrder = st.sort === 'reacted' ? 'reacts DESC, s.said_at DESC' : 's.said_at DESC, s.id DESC';
++ const takeRows = await db.all(`SELECT ${SEGMENT_COLS}, COALESCE(rx.n, 0) AS reacts FROM rundown_segments s
++ ${AUDIENCE_JOINS('s')} LEFT JOIN (SELECT item_id, COUNT(*) AS n FROM rundown_reactions WHERE item_type = 'segment' GROUP BY item_id) rx ON rx.item_id = s.id
++ WHERE ${tf.where} ORDER BY ${takeOrder} LIMIT ${MAX_TAKES}`, tf.params);
++
++ const epOrder = st.sort === 'reacted' ? 'reacts DESC, r.aired_at DESC' : 'r.aired_at DESC';
++ const epRows = await db.all(`SELECT ${EPISODE_COLS}, COALESCE(rx.n, 0) AS reacts FROM rundown_episodes r
++ ${AUDIENCE_JOINS('r')} LEFT JOIN (SELECT item_id, COUNT(*) AS n FROM rundown_reactions WHERE item_type = 'episode' GROUP BY item_id) rx ON rx.item_id = r.stream_id
++ WHERE ${ef.where} ORDER BY ${epOrder} LIMIT ${MAX_EPISODES}`, ef.params);
++
++ const topOrder = st.sort === 'takes' || st.sort === 'reacted' ? 'takes DESC, last_seen_at DESC' : 'last_seen_at DESC';
++ const topRows = await db.all(`SELECT t.id, t.slug, t.title, t.kind, t.headline, t.summary, t.first_seen_at, t.last_seen_at,
++ COUNT(s.id) AS takes, COUNT(DISTINCT s.user_id) AS streamers, array_agg(DISTINCT s.user_id) AS users
++ FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' ${AUDIENCE_JOINS('s')}
++ WHERE ${topf.where} GROUP BY t.id ORDER BY ${topOrder} LIMIT ${MAX_TOPICS}`, topf.params);
++
++ return { takeRows, epRows, topRows, tf, ef, topf };
++}
++
++/** How many of each there are: the count line says "34 takes from 6 streamers". */
++async function counts(st, { users, topicId, prefix, viewer }) {
++ const tf = takeFilters(st, { users, topicId, prefix, viewer });
++ const ef = episodeFilters(st, { users, topicId, prefix, viewer });
++ const topf = topicFilters(st, { users, prefix, viewer });
++ const t = await db.get(`SELECT COUNT(*) AS n, COUNT(DISTINCT s.user_id) AS people FROM rundown_segments s ${AUDIENCE_JOINS('s')} WHERE ${tf.where}`, tf.params);
++ const e = await db.get(`SELECT COUNT(*) AS n, COUNT(DISTINCT r.user_id) AS people FROM rundown_episodes r ${AUDIENCE_JOINS('r')} WHERE ${ef.where}`, ef.params);
++ const p = await db.get(`SELECT COUNT(DISTINCT t.id) AS n FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' ${AUDIENCE_JOINS('s')} WHERE ${topf.where}`, topf.params);
++ return { takes: Number(t.n), take_people: Number(t.people), episodes: Number(e.n), episode_people: Number(e.people), topics: Number(p.n) };
++}
++
++/** The topics whose names are closest to what was searched: the way out of an empty search. */
++async function closestTopics(st, { topicId }) {
++ const rows = await db.all("SELECT slug, title, kind, takes FROM rundown_topics WHERE status = 'live' ORDER BY last_seen_at DESC LIMIT 200");
++ const words = new Set(String(st.q || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2));
++ const scored = rows.map((r) => {
++ const title = String(r.title || '').toLowerCase();
++ let score = 0;
++ for (const w of words) if (title.includes(w)) score += 1;
++ if (st.topic && r.slug === st.topic) score += 10;
++ return { ...r, score };
++ }).filter((r) => r.score > 0);
++ const best = (scored.length ? scored : rows.map((r) => ({ ...r, score: 0 })))
++ .sort((a, b) => b.score - a.score || Number(b.takes) - Number(a.takes))
++ .slice(0, 6);
++ return best.map((r) => ({ slug: r.slug, title: r.title, kind: r.kind, takes: Number(r.takes) }));
++}
++
++/**
++ * The bar's answer: what the filters ask for. The counts are always there (the count line), the results are what the
++ * Show chip asks for, and when nothing matches the page is told which filter to drop and which topics are closest.
++ */
++async function browse(query = {}, { viewer = null } = {}) {
++ const st = parse(query);
++ const v = read.who(viewer);
++ const users = await userIds(st.streamer);
++ const topicId = await topicIdOf(st.topic);
++ const prefix = await prefixUsers(st.q);
++
++ const r = await results(st, { users, topicId, prefix, viewer: v.userId });
++ const c = await counts(st, { users, topicId, prefix, viewer: v.userId });
++
++ const takes = await read.segmentsOut(r.takeRows, v.key);
++ const episodes = await read.episodesOut(r.epRows, v.key);
++ const topicPeople = await read.people(r.topRows.flatMap((t) => (t.users || []).slice(0, 4)));
++ const topics = r.topRows.map((t) => ({
++ slug: t.slug, title: t.title, kind: t.kind, headline: t.headline || null, summary: t.summary || null,
++ takes: Number(t.takes), streamers: Number(t.streamers),
++ first_seen_at: t.first_seen_at, last_seen_at: t.last_seen_at,
++ people: (t.users || []).slice(0, 4).map((id) => topicPeople.get(Number(id))).filter(Boolean),
++ }));
++
++ // The takes of the episodes on this page: an episode card is folded to its top ones, and the rest is sent so
++ // "More takes (N)" can open in place, without asking the server again.
++ const epIds = r.epRows.map((e) => Number(e.stream_id));
++ let epTakes = new Map();
++ if (epIds.length) {
++ const rows = await db.all(`SELECT ${SEGMENT_COLS}, ROW_NUMBER() OVER (PARTITION BY s.stream_id ORDER BY s.score DESC, s.said_at DESC) AS rn
++ FROM rundown_segments s WHERE s.stream_id = ANY(@ids) AND s.status = 'live' ORDER BY s.stream_id, s.score DESC, s.said_at DESC`, { ids: epIds });
++ const keep = rows.filter((x) => Number(x.rn) <= EPISODE_TAKES);
++ const shaped = await read.segmentsOut(keep, v.key);
++ for (const s of shaped) { const l = epTakes.get(s.stream_id) || []; l.push(s); epTakes.set(s.stream_id, l); }
++ }
++ const totals = new Map();
++ if (epIds.length) {
++ for (const row of await db.all("SELECT stream_id, COUNT(*) AS n FROM rundown_segments WHERE stream_id = ANY(?) AND status = 'live' GROUP BY stream_id", [epIds])) totals.set(Number(row.stream_id), Number(row.n));
++ }
++
++ // The people a search named by prefix: the streamers the words could have meant.
++ const named = prefix.length ? await read.people(prefix) : new Map();
++
++ const empty = st.show === 'topics' ? c.topics === 0 : (st.show === 'episodes' ? c.episodes === 0 : (st.show === 'takes' ? c.takes === 0 : (c.takes === 0 && c.episodes === 0)));
++ let suggestion = null;
++ if (empty) {
++ const drop = await bestDrop(st, { prefix, viewer: v.userId });
++ suggestion = { drop, topics: await closestTopics(st, { topicId }) };
++ }
++
++ // The Show chip asks for one kind of result: the others are counted (the count line needs them) but not sent.
++ const show = st.show;
++ const withTakes = (e) => ({ ...e, takes: epTakes.get(e.stream_id) || [], takes_total: totals.get(e.stream_id) || 0, folded: FOLDED });
++ return {
++ filters: st,
++ active: activeKeys(st),
++ counts: c,
++ takes: show === 'episodes' || show === 'topics' ? [] : takes,
++ episodes: show === 'takes' || show === 'topics' ? [] : episodes.map(withTakes),
++ topics: show === 'topics' ? topics : [],
++ streamers: [...named.values()].filter(Boolean),
++ suggestion,
++ on_air: await read.onAir(),
++ trending: await read.trending(),
++ voices: await read.voices(),
++ };
++}
++
++/**
++ * Which filter to drop when nothing matches: the one whose removal brings the most back. The page offers it as
++ * "drop the last filter", because that is the one a reader least wants to keep.
++ */
++async function bestDrop(st, { prefix, viewer }) {
++ const keys = activeKeys(st);
++ if (!keys.length) return null;
++ let best = null;
++ for (const key of keys) {
++ const without = { ...st };
++ if (key === 'q') without.q = '';
++ else if (key === 'streamer') without.streamer = [];
++ else if (key === 'topic') without.topic = '';
++ else if (key === 'when') without.when = null;
++ else if (key === 'show') without.show = null;
++ else if (key === 'sort') without.sort = 'newest';
++ else if (key === 'video') without.video = false;
++ else if (key === 'following') without.following = false;
++ else if (key === 'mentions') without.mentions = false;
++ else if (key === 'kind') without.kind = '';
++ // The names and the topic of the state without this filter are the state's own: dropping a streamer
++ // filter means the takes of every streamer again.
++ const users = await userIds(without.streamer);
++ const topicId = await topicIdOf(without.topic);
++ const c = await counts(without, { users, topicId, prefix, viewer });
++ const n = without.show === 'topics' ? c.topics : (without.show === 'episodes' ? c.episodes : c.takes + c.episodes);
++ if (!best || n > best.n) best = { key, n };
++ }
++ return best && best.n > 0 ? best.key : null;
++}
++
++/**
++ * The lists the pickers show: the streamers who have been talking (with their avatars) and the topics, trending
++ * first. The pickers search inside these, so they are one answer and not a question per keystroke.
++ */
++async function options() {
++ const rows = await db.all(`SELECT s.user_id, COUNT(*) AS takes, MAX(s.said_at) AS last_at FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE s.status = 'live' AND s.said_at >= @since AND ${VISIBLE('s')} GROUP BY s.user_id ORDER BY COUNT(*) DESC, MAX(s.said_at) DESC LIMIT 60`,
++ { since: daysAgo(30), viewer: null });
++ const ppl = await read.people(rows.map((r) => r.user_id));
++ const trending = await read.trending(12, 7);
++ const rest = await read.topics({ limit: 120 });
++ const seen = new Set(trending.map((t) => t.slug));
++ const topics = [...trending.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: t.takes, trending: true })),
++ ...rest.topics.filter((t) => !seen.has(t.slug)).map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: t.takes, trending: false }))];
++ return {
++ streamers: rows.map((r) => ({ user: ppl.get(Number(r.user_id)) || null, takes: Number(r.takes), last_at: r.last_at })).filter((s) => s.user),
++ topics,
++ kinds: read.KINDS,
++ };
++}
++
++module.exports = { browse, options, parse, isFiltered, activeKeys, ORDER };
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
iindex 6d774281..4357cdcb 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -354,6 +354,14 @@ async function episode(streamId, { viewer = null } = {}) {
const eff = await visibility.effective(id);
const ownerUserId = e ? e.user_id : stream.user_id;
if (!sharing.maySee(eff, { ownerUserId, viewer: v })) return null;
++ const mine = v.userId != null && Number(v.userId) === Number(ownerUserId);
++ // An episode written up before the sharing system has no share row, and the Share sheet answers 404 for it. Its
++ // audience was Everyone, so the owner's first visit registers it as Everyone: nothing changes for anybody else.
++ if (mine && e) {
++ let subject = v.subject;
++ if (!subject) { try { subject = await require('../auth/identity-sync').subjectOf(ownerUserId); } catch { subject = null; } }
++ await sharing.register(id, subject, 'public');
++ }
const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s WHERE s.stream_id = @id AND s.status = 'live' ORDER BY s.start_sec ASC LIMIT 60`, { id });
if (!e && !segs.length) return null;
const others = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
@@@ -367,7 +375,7 @@ async function episode(streamId, { viewer = null } = {}) {
episodeOut.audience = eff.audience;
episodeOut.audience_chip = eff.audience === 'public' ? null : visibility.label(eff.audience, eff.member_count);
}
-- return { episode: episodeOut, stream: { id: Number(stream.id), title: stream.title || null, is_live: !!Number(stream.is_live), started_at: stream.started_at, ended_at: stream.ended_at, user: ppl.get(Number(stream.user_id)) || null },
++ return { mine, episode: episodeOut, stream: { id: Number(stream.id), title: stream.title || null, is_live: !!Number(stream.is_live), started_at: stream.started_at, ended_at: stream.ended_at, user: ppl.get(Number(stream.user_id)) || null },
segments: await segmentsOut(segs, v.key), more: await episodesOut(others, v.key) };
}
@@@ -377,7 +385,8 @@ async function streamer(username, { viewer = null } = {}) {
const u = await db.get('SELECT id, username, display_name, avatar_url, profile_color FROM users WHERE lower(username) = lower(?)', [String(username || '').replace(/^@/, '').slice(0, 64)]);
if (!u) return null;
const out = !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [u.id]);
-- if (out) return { user: person(u), opted_out: true, episodes: [], topics: [], lines: [], crossovers: [] };
++ const mine = v.userId != null && Number(v.userId) === Number(u.id);
++ if (out) return { mine, user: person(u), opted_out: true, episodes: [], topics: [], lines: [], crossovers: [] };
const eps = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
WHERE r.user_id = @user AND r.status = 'live' AND ${VISIBLE('r')} ORDER BY r.aired_at DESC LIMIT 30`, { user: u.id, viewer: v.userId });
const tops = await db.all(`SELECT t.slug, t.title, t.kind, COUNT(*) AS n, MAX(s.said_at) AS last FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
@@@ -389,7 +398,7 @@ async function streamer(username, { viewer = null } = {}) {
// another streamer's page and never tells them they were named.
const cross = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments s ${AUDIENCE_JOINS('s')}
WHERE s.status = 'live' AND (@user = ANY(s.mentions) OR (s.user_id = @user AND cardinality(s.mentions) > 0)) AND ${VISIBLE('s')} ORDER BY s.said_at DESC LIMIT 12`, { user: u.id, viewer: v.userId });
-- return { user: person(u), opted_out: false, episodes: await episodesOut(eps, v.key), topics: tops.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: Number(t.n), last_seen_at: t.last })),
++ return { mine, user: person(u), opted_out: false, episodes: await episodesOut(eps, v.key), topics: tops.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: Number(t.n), last_seen_at: t.last })),
lines: await segmentsOut(lines, v.key), crossovers: await segmentsOut(cross, v.key) };
}
@@@ -431,4 +440,7 @@ async function setOptOut(userId, out) {
return { opted_out: !!out };
}
--module.exports = { home, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, audiencesFor, who, REACTIONS, KINDS, person };
++// The bar's own module (server/rundown/browse.js) reads with the same rules as the pages: the same visibility
++// helpers, the same columns and the same people.
++module.exports = { home, lead, streamsFeed, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, audiencesFor, who, REACTIONS, KINDS, person,
++ VISIBLE, AUDIENCE_JOINS, SEGMENT_COLS, EPISODE_COLS, people, segmentsOut, episodesOut };
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
iindex ddd8d5fe..b0c9f6c5 100644
---- a/server/rundown/routes.js
++++ b/server/rundown/routes.js
@@@ -3,6 +3,9 @@
* /api/rundown — The Rundown (docs/rundown.md): what streamers talked about, written up by the desk (desk.js).
*
* GET / on air now, the latest (episodes + the best segments), trending topics, who's been talking
++ * GET /browse?q=&streamer=&topic=&when=&show=&sort=&video=&following=&mentions=
++ * the bar: the results the filters ask for, with the counts (server/rundown/browse.js)
++ * GET /options the lists the filter pickers show: the streamers, the topics
* GET /latest?before= the next page of the latest
* GET /topics?sort=&kind= every topic (sort recent | most)
* GET /t/:slug one topic and every take on it
@@@ -24,6 +27,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const db = require('../db/database');
const read = require('./read');
++const browse = require('./browse');
const settings = require('./settings');
const del = require('./delete');
@@@ -57,6 +61,14 @@ const fresh = (res) => res.set('Cache-Control', 'no-store');
router.get('/', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await read.home({ viewer: await viewerOf(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
});
++router.get('/browse', optionalAuth, async (req, res) => {
++ try { fresh(res); res.json(await browse.browse(req.query || {}, { viewer: await viewerOf(req) })); }
++ catch (err) { fail(res, err, 'The search could not be run'); }
++});
++router.get('/options', optionalAuth, async (req, res) => {
++ try { fresh(res); res.json(await browse.options()); }
++ catch (err) { fail(res, err, 'The filters could not be loaded'); }
++});
router.get('/streams', optionalAuth, async (req, res) => {
try { fresh(res); res.json(await read.streamsFeed({ before: req.query.before ? String(req.query.before) : null, viewer: await viewerOf(req) })); } catch (err) { fail(res, err, 'Failed to load the streams'); }
});
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex 367389cc..62dddadd 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -36,7 +36,7 @@ async function buildSitemap(liveStreams = []) {
}
return names.get(id);
};
-- const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/rundown', 'hourly', 0.7], ['/rundown/topics', 'hourly', 0.6]];
++ const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/rundown', 'hourly', 0.7]];
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
try {
for (const f of fs.readdirSync(DOCS_DIR)) {
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 7801eda3..6aa046e9 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -195,7 +195,7 @@ function iso8601Duration(sec) {
// Returns { title, description, canonicalPath, image, ogType, robots, jsonLd:[...], snapshot }
// or null to fall through (unknown / private / not found).
--async function _pageMeta(routePath, { page = 1 } = {}) {
++async function _pageMeta(routePath, { page = 1, query = null } = {}) {
const p = routePath.replace(/\/+$/, '') || '/';
const bu = baseUrl();
@@@ -214,7 +214,7 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return await _pasteMeta(m[1]);
if ((m = p.match(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
-- if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p);
++ if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p, query);
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
@@@ -387,11 +387,50 @@ async function _clipperOf(c) { return c.display_name || c.username || _nameOf(aw
// The Rundown (server/rundown/): what streamers talked about, written up by the desk. The front page and the topic index
// are collection pages people browse, so they are indexed; a topic, an episode and a streamer's page are AI-written text
// about real people, so they are noindex,follow, like the AI recap and the Wiki's AI pages until a person reviews them.
--async function _rundownMeta(p) {
++/**
++ * A filtered or searched view of The Rundown is never indexed: the words a reader typed and the filters they
++ * picked would give a crawler an endless space of the same page. The canonical points at the nearest page a
++ * crawler may keep: the plain front page, the one streamer's page or the one topic's page (design 2026-11-06).
++ */
++function _rundownCanonical(p, query) {
++ const q = query || {};
++ const has = (k) => String(q[k] || '').trim() !== '';
++ const filters = ['q', 'streamer', 'topic', 'when', 'show', 'sort', 'video', 'following', 'mentions', 'kind'].filter(has);
++ if (!filters.length) return null;
++ let m;
++ if ((m = p.match(/^\/rundown\/t\/([a-z0-9-]{1,80})$/))) return '/rundown/t/' + m[1];
++ if ((m = p.match(/^\/rundown\/(?:s\/)?([A-Za-z0-9_.-]{1,64})$/))) return '/rundown/' + m[1];
++ if (filters.length === 1 && filters[0] === 'topic') {
++ const slug = String(q.topic || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 80);
++ if (slug) return '/rundown/t/' + slug;
++ }
++ if (filters.length === 1 && filters[0] === 'streamer') {
++ const name = String(q.streamer || '').split(',')[0].trim().replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 64);
++ if (name) return '/rundown/' + name;
++ }
++ return '/rundown';
++}
++
++/** The parts of a query that change a Rundown page's head. The words of a search do not: every search has the
++ * same head (noindex, and the canonical of the plain page). */
++function _rundownKey(query) {
++ const q = query || {};
++ const parts = [];
++ for (const k of ['topic', 'streamer', 'show', 'kind']) if (String(q[k] || '').trim()) parts.push(k + '=' + String(q[k]).trim().slice(0, 80));
++ if (String(q.q || '').trim()) parts.push('q');
++ return parts.join('&');
++}
++async function _rundownMeta(p, query) {
const read = require('../rundown/read');
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
const base = { image: shareCard('live-rundown'), ogType: 'website' };
let m;
++ const combo = _rundownCanonical(p, query);
++ if (combo) {
++ const plain = await _rundownMeta(p, null);
++ if (!plain) return null;
++ return { ...plain, canonicalPath: combo, robots: 'noindex,follow', jsonLd: [], snapshot: null };
++ }
if (p === '/rundown') {
let home = null;
try { home = await read.home({}); } catch { home = null; }
@@@ -406,6 +445,8 @@ async function _rundownMeta(p) {
snapshot: _detailSnapshot({ title: 'The Rundown', byline: topics.length ? `Trending: ${topics.slice(0, 6).join(', ')}` : null, desc, overview: eps.slice(0, 6).map((e) => e.title).join(' · ') || null, transcript: null, canonicalPath: '/rundown', watchLabel: 'Open The Rundown' }) };
}
if (p === '/rundown/topics') {
++ const plain = await _rundownMeta('/rundown', null);
++ if (plain) return { ...plain, canonicalPath: '/rundown', robots: 'noindex,follow', jsonLd: [], snapshot: null };
let list = [];
try { list = (await read.topics({})).topics; } catch { list = []; }
const desc = clean(`Every topic streamers on OpenVibe.Live talked about, with each streamer's take.${list.length ? ` Lately: ${list.slice(0, 5).map((t) => t.title).join(', ')}.` : ''}`, 200);
@@@ -418,7 +459,7 @@ async function _rundownMeta(p) {
if (!d) return null;
const t = d.topic;
const desc = clean(t.summary || t.headline || `${t.streamers} streamer${t.streamers === 1 ? '' : 's'} on ${t.title}.`, 200);
-- return { ...base, title: `${clean(t.title, 80)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/t/${t.slug}`, robots: 'noindex,follow',
++ return { ...base, title: `${clean(t.title, 80)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/t/${t.slug}`, robots: 'index,follow',
jsonLd: [_breadcrumb([...crumbs, { name: 'Topics', url: '/rundown/topics' }, { name: t.title, url: `/rundown/t/${t.slug}` }])] };
}
if ((m = p.match(/^\/rundown\/e\/(\d{1,12})$/))) {
@@@ -431,13 +472,13 @@ async function _rundownMeta(p) {
return { ...base, title: `${clean(title, 80)} — ${clean(who, 40)} | The Rundown`, description: desc, canonicalPath: `/rundown/e/${d.stream.id}`, robots: 'noindex,follow',
jsonLd: [_breadcrumb([...crumbs, { name: title, url: `/rundown/e/${d.stream.id}` }])] };
}
-- if ((m = p.match(/^\/rundown\/([A-Za-z0-9_.-]{1,64})$/))) {
++ if ((m = p.match(/^\/rundown\/(?:s\/)?([A-Za-z0-9_.-]{1,64})$/))) {
let d = null;
try { d = await read.streamer(m[1]); } catch { d = null; }
if (!d) return null;
const who = d.user.display_name || d.user.username;
const desc = clean(d.opted_out ? `${who} keeps themselves out of The Rundown.` : `What ${who} talks about on stream: ${d.topics.slice(0, 5).map((t) => t.title).join(', ') || 'episodes and takes'}.`, 200);
-- return { ...base, title: `${clean(who, 40)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/${encodeURIComponent(d.user.username)}`, robots: 'noindex,follow',
++ return { ...base, title: `${clean(who, 40)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/${encodeURIComponent(d.user.username)}`, robots: 'index,follow',
jsonLd: [_breadcrumb([...crumbs, { name: who, url: `/rundown/${encodeURIComponent(d.user.username)}` }])] };
}
return null;
@@@ -922,7 +963,7 @@ function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
_cache.set(key, { html, at: Date.now(), ttl });
}
--const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|rundown(?:\/(?:topics|t\/[a-z0-9-]{1,80}|e\/\d{1,12}|[A-Za-z0-9_.-]{1,64}))?$|chat$|search$|documentation$|updates$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|rundown(?:\/(?:topics|s\/[A-Za-z0-9_.-]{1,64}|t\/[a-z0-9-]{1,80}|e\/\d{1,12}|[A-Za-z0-9_.-]{1,64}))?$|chat$|search$|documentation$|updates$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
function _pageParam(p, query) {
@@@ -946,7 +987,8 @@ async function middleware(req, res, next) {
// */* got the home page's title and canonical for every channel, VOD and clip.) Nothing fetches
// these paths for data: the API is under /api.
const page = _pageParam(p, req.query);
-- const key = page > 1 ? `${p}?page=${page}` : p;
++ const combo = p.startsWith('/rundown') ? _rundownKey(req.query) : '';
++ const key = combo ? `${p}?${combo}` : (page > 1 ? `${p}?page=${page}` : p);
// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
@@@ -967,7 +1009,7 @@ async function middleware(req, res, next) {
if (!cached.html.includes('"isLiveBroadcast":true')) return send(cached.html, cached.ttl);
_cache.delete(key);
}
-- const meta = await _pageMeta(p, { page });
++ const meta = await _pageMeta(p, { page, query: req.query });
if (!meta) return next(); // unknown, private or not found: the SPA fallback answers (404 when it names nothing)
const html = render(meta, p);
if (!html) return next();
ddiff --git a/test/rundown-bar.test.js b/test/rundown-bar.test.js
nnew file mode 100644
iindex 00000000..0c42b4b8
---- /dev/null
++++ b/test/rundown-bar.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * The Rundown's bar: the state of a view is its URL (public/js/rundown.js), and a filtered view is never indexed
++ * (server/seo/pages.js).
++ *
++ * the URL every filter is in it, and a view can be shared and the back button restores it
++ * the sheet the phone's sheet has the same sections, 44 px controls and no width past 390 px
++ * indexing a combination is noindex, with a canonical to the nearest page a crawler may keep
++ *
++ * node test/rundown-bar.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const js = fs.readFileSync(path.join(__dirname, '../public/js/rundown.js'), 'utf8');
++const css = fs.readFileSync(path.join(__dirname, '../public/css/features/rundown.css'), 'utf8');
++const pick = (name) => {
++ const start = js.indexOf(`function ${name}(`);
++ assert.ok(start >= 0, `${name} is in rundown.js`);
++ const next = js.indexOf('\nfunction ', start + 1);
++ return js.slice(start, next < 0 ? undefined : next);
++};
++const ctx = { URLSearchParams, console };
++vm.createContext(ctx);
++vm.runInContext([pick('_rdState'), pick('_rdQuery')].join('\n'), ctx);
++
++// ── The state of a view is its URL ──
++const state = ctx._rdState(new URLSearchParams('streamer=goosely&topic=drake-and-objectives&when=week&q=baron'));
++assert.deepStrictEqual(state.q, 'baron');
++assert.deepStrictEqual([...state.streamer], ['goosely']);
++assert.strictEqual(state.topic, 'drake-and-objectives');
++assert.strictEqual(state.when, 'week');
++assert.strictEqual(ctx._rdQuery(state), '?q=baron&streamer=goosely&topic=drake-and-objectives&when=week',
++ 'every filter is in the URL, in one order');
++
++const all = ctx._rdState(new URLSearchParams('streamer=a,b&topic=t&when=day&show=episodes&sort=reacted&video=1&following=1&mentions=1&q=x'));
++assert.deepStrictEqual([...all.streamer], ['a', 'b'], 'several streamers may be picked');
++assert.strictEqual(ctx._rdQuery(all), '?q=x&streamer=a%2Cb&topic=t&when=day&show=episodes&sort=reacted&video=1&following=1&mentions=1',
++ 'and they all live in the URL together');
++
++const empty = ctx._rdState(new URLSearchParams(''));
++assert.strictEqual(ctx._rdQuery(empty), '', 'an empty bar is the plain page');
++assert.strictEqual(ctx._rdState(new URLSearchParams('show=everything&when=century')).show, '', 'an unknown filter is dropped');
++assert.strictEqual(ctx._rdState(new URLSearchParams('q=baron')).q, 'baron', 'the words survive the round trip');
++
++// The back button: the old URL is the old view, because nothing else keeps the state.
++const before = ctx._rdState(new URLSearchParams('streamer=goosely'));
++const after = ctx._rdState(new URLSearchParams('streamer=baron'));
++assert.notStrictEqual(ctx._rdQuery(before), ctx._rdQuery(after), 'two views are two URLs');
++assert.strictEqual(ctx._rdQuery(ctx._rdState(new URLSearchParams(ctx._rdQuery(before)))), ctx._rdQuery(before),
++ 'and the old URL restores the old view');
++
++// ── The bar: the search, the chips, the keyboard ──
++assert.match(js, /placeholder="Search the Rundown: a topic, a streamer, something they said"/, 'the search says what it searches');
++assert.match(js, /setTimeout\(\(\) => _rdSearch\(value\), 250\)/, 'the results follow the typing, 250 ms later');
++assert.match(js, /e\.key !== '\/'/, '"/" focuses the search');
++assert.match(js, /data-rd-sheet="open"/, 'the phone has one Filters button');
++assert.match(js, /class="rd-sheet" role="dialog" aria-modal="true"/, 'the sheet is a dialog');
++for (const section of ['Streamer', 'Topic', 'When', 'Show', 'Sort']) {
++ assert.ok(js.includes(`section('${section}'`), `the sheet has the ${section} section`);
++}
++assert.match(js, /data-rd-clear="all">Clear all/, 'the way out of all the filters');
++assert.match(js, /More takes \(/, 'the folded takes are counted');
++
++// ── The phone: 44 px controls, and nothing past 390 px ──
++assert.match(css, /\.rd-sheet-btn-row \{[^}]*min-height: 44px/, 'the sheet\'s controls are 44 px');
++assert.match(css, /\.rd-search \{[^}]*height: 44px/, 'the search box is 44 px');
++assert.match(css, /@media \(max-width: 720px\) \{[\s\S]*?\.rd-chipbar \{ display: none; \}[\s\S]*?\.rd-sheet-btn \{ display: inline-flex/, 'the phone shows the search box and the Filters button');
++assert.match(css, /\.rd-sheet \{[^}]*left: 0; right: 0;/, 'the sheet is as wide as the phone');
++assert.match(css, /\.rd-rails \{ display: none; \}[\s\S]*?@media \(max-width: 960px\) \{[\s\S]*?\.rd-rails \{ display: flex/, 'the rails are under the bar on the phone');
++
++// ── Indexing: a combination is noindex, with a canonical ──
++process.env.NODE_ENV = 'test';
++const pages = require('../server/seo/pages');
++(async () => {
++const plain = await pages._pageMeta('/rundown', {});
++assert.strictEqual(plain.robots, 'index,follow', 'the plain page is indexable');
++
++const searched = await pages._pageMeta('/rundown', { query: { q: 'baron' } });
++assert.strictEqual(searched.robots, 'noindex,follow', 'a search is never indexed');
++assert.strictEqual(searched.canonicalPath, '/rundown', 'and points at the plain page');
++
++const oneStreamer = await pages._pageMeta('/rundown', { query: { streamer: 'goosely' } });
++assert.strictEqual(oneStreamer.robots, 'noindex,follow', 'a combination is never indexed');
++assert.strictEqual(oneStreamer.canonicalPath, '/rundown/goosely', 'a single streamer points at that streamer\'s page');
++
++const oneTopic = await pages._pageMeta('/rundown', { query: { topic: 'drake-and-objectives' } });
++assert.strictEqual(oneTopic.canonicalPath, '/rundown/t/drake-and-objectives', 'a single topic points at that topic\'s page');
++
++const many = await pages._pageMeta('/rundown', { query: { streamer: 'goosely', topic: 'drake', when: 'week' } });
++assert.strictEqual(many.robots, 'noindex,follow', 'three filters are still one page to a crawler');
++assert.strictEqual(many.canonicalPath, '/rundown', 'and point at the plain page');
++
++// A topic page that exists keeps its own canonical; one that does not is a 404, as before.
++const missing = await pages._pageMeta('/rundown/t/no-such-topic-here', { query: { when: 'week' } });
++assert.strictEqual(missing, null, 'a topic that is not there is still a 404');
++
++console.log('rundown bar: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/rundown-owner.test.js b/test/rundown-owner.test.js
nnew file mode 100644
iindex 00000000..5aebda0f
---- /dev/null
++++ b/test/rundown-owner.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * The owner's tools on The Rundown (RUNDOWN-CONTROLS design 2026-11-06): the episode and streamer reads say whether
++ * the reader owns the page (`mine`), so the page draws Share and Delete for the streamer only, and an episode written
++ * up before the sharing system existed is registered as Everyone the first time its owner opens it, so the Share sheet
++ * has a row to change. Nobody else's visit registers anything.
++ */
++const assert = require('assert');
++const db = require('../server/db/database');
++
++(async () => {
++ await db.initDb();
++ const read = require('../server/rundown/read');
++ const sharing = require('../server/rundown/sharing');
++
++ const SUB_OWNER = 'usr_01HQZX9K3M5N7P8R9T0V1W2XQW';
++ const SUB_OTHER = 'usr_01HQZX9K3M5N7P8R9T0V1W2XQT';
++ const mk = async (username) => {
++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') });
++ const id = Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
++ await db.ensureChannel(id);
++ return id;
++ };
++ const owner = await mk('ownerly');
++ const other = await mk('otherly');
++ const NOW = new Date().toISOString().slice(0, 19).replace('T', ' ');
++ const streamId = Number((await db.createStream({ user_id: owner, title: 'Old one', category: 'Just Chatting', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run("UPDATE streams SET is_live = 0, started_at = datetime('now', '-120 minutes'), ended_at = datetime('now', '-30 minutes'), duration_seconds = 5400 WHERE id = ?", [streamId]);
++ // Written up before the sharing system: an episode with no share row.
++ await db.run("INSERT INTO rundown_episodes (stream_id, user_id, title, logline, vod_id, minutes, aired_at) VALUES (?, ?, 'Before sharing', 'An old one.', 5151, 90, ?)", [streamId, owner, NOW]);
++ const shareRow = () => db.get("SELECT audience FROM shares WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);
++ assert.strictEqual(await shareRow(), undefined, 'the old episode starts with no share row');
++
++ const asOther = await read.episode(streamId, { viewer: { key: 'user:other', userId: other, subject: SUB_OTHER } });
++ assert.ok(asOther, 'anybody opens an Everyone episode');
++ assert.strictEqual(asOther.mine, false, 'another reader does not own it');
++ assert.strictEqual(await shareRow(), undefined, 'another reader\'s visit registers nothing');
++ const anon = await read.episode(streamId, { viewer: null });
++ assert.strictEqual(anon.mine, false, 'nor does a signed-out reader');
++
++ const asOwner = await read.episode(streamId, { viewer: { key: 'user:owner', userId: owner, subject: SUB_OWNER } });
++ assert.strictEqual(asOwner.mine, true, 'the streamer owns their episode');
++ assert.strictEqual(asOwner.episode.audience, 'public', 'it is Everyone');
++ if (sharing.available()) {
++ const row = await shareRow();
++ assert.ok(row, 'the owner\'s first visit registers the episode');
++ assert.strictEqual(row.audience, 'public', 'as Everyone, what it already was');
++ // A second visit keeps whatever the owner chose since.
++ await db.run("UPDATE shares SET audience = 'private' WHERE service = 'live' AND kind = 'rundown-episode' AND resource_id = ?", [String(streamId)]);
++ await read.episode(streamId, { viewer: { key: 'user:owner', userId: owner, subject: SUB_OWNER } });
++ assert.strictEqual((await shareRow()).audience, 'private', 'registering again never overrules the owner\'s choice');
++ assert.strictEqual(await read.episode(streamId, { viewer: { key: 'user:other', userId: other, subject: SUB_OTHER } }), null, 'and Only me is closed to others');
++ }
++
++ const page = await read.streamer('ownerly', { viewer: { key: 'user:owner', userId: owner, subject: SUB_OWNER } });
++ assert.strictEqual(page.mine, true, 'the streamer owns their Rundown page');
++ const seen = await read.streamer('ownerly', { viewer: { key: 'user:other', userId: other, subject: SUB_OTHER } });
++ assert.strictEqual(seen.mine, false, 'another reader does not');
++ await db.run('INSERT INTO arena_opt_outs (user_id) VALUES (?) ON CONFLICT DO NOTHING', [owner]);
++ const out = await read.streamer('ownerly', { viewer: { key: 'user:owner', userId: owner, subject: SUB_OWNER } });
++ assert.strictEqual(out.opted_out, true, 'a streamer who keeps out of The Rundown');
++ assert.strictEqual(out.mine, true, 'still gets their own page, to come back');
++
++ console.log('rundown owner: mine on the episode and streamer pages; an old episode registers as Everyone on its owner\'s first visit');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/rundown-search.test.js b/test/rundown-search.test.js
nnew file mode 100644
iindex 00000000..1a5f7419
---- /dev/null
++++ b/test/rundown-search.test.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * The Rundown's bar: search and filters (design 2026-11-06, section 4; server/rundown/browse.js).
++ *
++ * search finds a quote, a take and a streamer by prefix; a phrase in quotes is a phrase
++ * filters every one of them narrows the results, and the counts follow
++ * no results the page is told which filter to drop, and which topics are closest
++ * the words come from the tsvector columns the search migration fills (0031_rundown_search.sql)
++ *
++ * node test/rundown-search.test.js
++ */
++const assert = require('assert');
++process.env.NODE_ENV = 'test';
++const db = require('../server/db/database');
++
++(async () => {
++await db.initDb();
++const browse = require('../server/rundown/browse');
++
++const ago = (mins) => new Date(Date.now() - mins * 60000).toISOString().slice(0, 19).replace('T', ' ');
++const mk = async (username, display) => {
++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: display || username, stream_key: username.padEnd(32, '0') });
++ const id = Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
++ await db.ensureChannel(id);
++ return id;
++};
++const stream = async (uid, title) => {
++ const id = Number((await db.createStream({ user_id: uid, title, category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run("UPDATE streams SET is_live = 0, started_at = datetime('now', '-180 minutes'), ended_at = datetime('now', '-60 minutes'), duration_seconds = 3600 WHERE id = ?", [id]);
++ return id;
++};
++const topic = async (slug, title, kind = 'tech') => {
++ await db.run("INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?, ?)", [slug, title, kind, ago(600), ago(10)]);
++ return Number((await db.get('SELECT id FROM rundown_topics WHERE slug = ?', [slug])).id);
++};
++const take = async (o) => {
++ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [o.stream, o.user, o.topic, o.sec || 10, 20, o.title, o.what || 'what they said', o.take || null, o.quip || null, o.score || 4,
++ JSON.stringify(o.quotes || []), o.mentions || [], o.vod || null, o.said]);
++ return Number((await db.get('SELECT MAX(id) AS id FROM rundown_segments')).id);
++};
++
++const goose = await mk('goosely', 'goosely');
++const baron = await mk('baron_k', 'Baron');
++const izor = await mk('izor', 'iZoR_One');
++const sA = await stream(goose, 'Fridge night');
++const sB = await stream(baron, 'Matchmaking night');
++const sC = await stream(izor, 'Quiet night');
++const tFridge = await topic('french-fridges-hacked', 'French fridges hacked');
++const tLeague = await topic('league-ranked-matchmaking', 'League ranked matchmaking', 'games');
++
++const s1 = await take({ stream: sA, user: goose, topic: tFridge, title: 'The fridge of the future', what: 'they talked about fridges and hacking', take: 'fridges will be hacked by the state', quip: 'the desk had thoughts', score: 5,
++ quotes: [{ text: 'the fridge of the future is now', en: null, sec: 12 }], vod: 555, said: ago(30) });
++const s2 = await take({ stream: sA, user: izor, topic: tLeague, title: 'Ranked matchmaking is broken again', what: 'they talked about ranked', said: ago(60 * 24 * 3), said_at: ago(60 * 24 * 3) });
++await db.run('UPDATE rundown_segments SET said_at = ? WHERE id = ?', [ago(60 * 24 * 3), s2]);
++const s3 = await take({ stream: sB, user: baron, topic: tFridge, title: 'Someone else broke a fridge', what: 'a fridge story', mentions: [goose], said: ago(120) });
++await db.run('INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, minutes, aired_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
++ [sA, goose, 'Fridge night, written up', 'all about fridges and the state', 'a recap', 60, ago(90)]);
++
++// ── The words are in the index ──
++const vec = await db.get('SELECT search_tsv IS NOT NULL AS ok FROM rundown_segments WHERE id = ?', [s1]);
++assert.ok(vec.ok, 'the search migration filled the take\'s words');
++const found = await db.get("SELECT id FROM rundown_segments WHERE id = ? AND search_tsv @@ websearch_to_tsquery('english', 'fridge')", [s1]);
++assert.ok(found, 'a take is found by its topic\'s name');
++
++// ── Search finds a quote, a take and a streamer by prefix ──
++let r = await browse.browse({ q: 'fridge' });
++assert.ok(r.takes.some((x) => x.id === s1), 'search finds a take by the quote it carries');
++assert.ok(r.takes.some((x) => x.id === s3), 'and a take on the same topic by its name');
++assert.ok(r.episodes.some((e) => e.title === 'Fridge night, written up'), 'and the episode by its logline');
++
++r = await browse.browse({ q: 'fridge of the future' });
++assert.ok(r.takes.some((x) => x.id === s1), 'the whole phrase is in the quote');
++
++r = await browse.browse({ q: 'fridge of the future is now' });
++assert.ok(r.takes.some((x) => x.id === s1), 'a phrase in quotes is a phrase: the whole line matches');
++
++r = await browse.browse({ q: '"fridge future"' });
++assert.ok(!r.takes.some((x) => x.id === s1), 'a phrase is a phrase: the words in another order do not match');
++
++r = await browse.browse({ q: '"future fridge"' });
++assert.ok(!r.takes.some((x) => x.id === s1), 'and not the other way round either');
++
++r = await browse.browse({ q: 'now fridge of the future' });
++assert.ok(r.takes.some((x) => x.id === s1), 'words without quotes are all of them, in any order');
++
++r = await browse.browse({ q: 'ranked' });
++assert.ok(r.takes.some((x) => x.id === s2), 'search finds a take by its title');
++assert.ok(!r.takes.some((x) => x.id === s1), 'and only that one');
++
++r = await browse.browse({ q: 'goos' });
++assert.ok(r.streamers.some((p) => p.username === 'goosely'), 'a streamer is found by prefix');
++assert.ok(r.takes.some((x) => x.id === s1), 'and their takes come with them');
++
++r = await browse.browse({ q: 'baron_k' });
++assert.ok(r.streamers.some((p) => p.username === 'baron_k'), 'the exact name works too');
++
++// ── Every filter narrows ──
++const ids = (d) => d.takes.map((x) => x.id).sort((a, b) => a - b);
++
++r = await browse.browse({});
++assert.deepStrictEqual(ids(r), [s1, s2, s3].sort((a, b) => a - b), 'no filter: everything');
++assert.strictEqual(r.counts.takes, 3, 'the count line knows how many takes there are');
++assert.strictEqual(r.counts.take_people, 3, 'and how many streamers said them');
++
++r = await browse.browse({ streamer: 'goosely' });
++assert.deepStrictEqual(ids(r), [s1], 'the streamer filter narrows to their takes');
++
++r = await browse.browse({ topic: 'french-fridges-hacked' });
++assert.deepStrictEqual(ids(r), [s1, s3].sort((a, b) => a - b), 'the topic filter narrows to its takes');
++
++r = await browse.browse({ when: 'day' });
++assert.deepStrictEqual(ids(r), [s1, s3].sort((a, b) => a - b), 'the when filter narrows to today');
++r = await browse.browse({ when: 'week' });
++assert.deepStrictEqual(ids(r), [s1, s2, s3].sort((a, b) => a - b), 'and to the week');
++
++r = await browse.browse({ video: '1' });
++assert.deepStrictEqual(ids(r), [s1], 'with video: only the takes that jump to a moment');
++
++r = await browse.browse({ q: 'fridge', show: 'episodes' });
++assert.ok(r.episodes.some((e) => e.title === 'Fridge night, written up'), 'the show chip: episodes');
++assert.strictEqual(r.takes.length, 0, 'and no takes with it');
++r = await browse.browse({ q: 'fridge', show: 'topics' });
++assert.ok(r.topics.some((t) => t.slug === 'french-fridges-hacked'), 'the show chip: topics');
++assert.strictEqual(r.takes.length, 0, 'and no takes with it');
++assert.strictEqual(r.episodes.length, 0, 'and no episodes with it');
++
++r = await browse.browse({ show: 'episodes' });
++assert.strictEqual(r.episodes.length, 1, 'one episode');
++assert.strictEqual(r.episodes[0].takes_total, 2, 'the episode counts its takes');
++assert.ok(r.episodes[0].takes.length >= 1, 'and sends them, so the fold can open in place');
++
++r = await browse.browse({ mentions: '1' }, { viewer: { key: 'user:1', userId: goose } });
++assert.deepStrictEqual(ids(r), [s3], 'mentions me: the takes that name you');
++
++// ── Nothing matches: the page is told which filter to drop, and which topics are closest ──
++r = await browse.browse({ q: 'fridge', streamer: 'izor' });
++assert.strictEqual(r.takes.length, 0, 'a streamer and a word that do not meet: nothing');
++assert.ok(r.suggestion, 'the page is given a way out');
++assert.strictEqual(r.suggestion.drop, 'streamer', 'the filter to drop is the one that costs the most');
++assert.ok(r.suggestion.topics.some((t) => t.slug === 'french-fridges-hacked'), 'and the closest topics by name');
++
++// ── The state is read from the query string, and nothing else reaches the database ──
++assert.deepStrictEqual(browse.parse({ q: 'x', streamer: 'a,b', topic: 'T', when: 'week', show: 'takes', sort: 'reacted', video: '1' }),
++ { q: 'x', streamer: ['a', 'b'], topic: 't', when: 'week', show: 'takes', sort: 'reacted', video: true, following: false, mentions: false, kind: '' });
++assert.deepStrictEqual(browse.parse({ when: 'century', show: 'everything', sort: 'random' }).when, null, 'an unknown when is dropped');
++assert.deepStrictEqual(browse.parse({ show: 'everything' }).show, null, 'an unknown show is dropped');
++assert.deepStrictEqual(browse.parse({ sort: 'random' }).sort, 'newest', 'an unknown sort falls back to the newest');
++assert.strictEqual(browse.parse({ q: "'; drop table users; --" }).q, "'; drop table users; --", 'the words are words, not SQL');
++assert.ok(browse.isFiltered(browse.parse({ q: 'x' })), 'a search is a filter');
++assert.ok(!browse.isFiltered(browse.parse({})), 'an empty bar is not a filter');
++
++console.log('rundown search: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/rundown-uses.test.js b/test/rundown-uses.test.js
iindex baa5977e..bd324e14 100644
---- a/test/rundown-uses.test.js
++++ b/test/rundown-uses.test.js
@@@ -1,8 +1,9 @@
++'use strict';
/**
-- * The Rundown's front page says what a visitor can do there (public/js/rundown.js _rdUses): catch up on a stream (the
-- * stream-by-stream list), follow a topic (/rundown/topics), jump to the moment (the top take's second on OpenVibe.Video)
-- * and get written up (/broadcast). A viewer said of the page "still don't rly know wtf to do with this feature"
-- * (2026-10-20): it said what it is, never what it is for.
++ * The Rundown's front page says what a visitor can do there (public/js/rundown.js _rdHow): catch up on a stream,
++ * follow a topic, jump to the moment, get written up. A viewer said of the page "still don't rly know wtf to do
++ * with this feature" (2026-10-20): it said what it is, never what it is for. The four tiles it used to be became
++ * one line under the title (design 2026-11-06): on a phone they took a whole screen.
*
* node test/rundown-uses.test.js
*/
@@@ -21,24 +22,18 @@ const pick = (name) => {
return js.slice(start, next < 0 ? undefined : next);
};
--const ctx = { videoHref: (kind, id, t) => `https://openvibe.video/${kind === 'clip' ? 'c' : 'v'}/${id}${t ? `?t=${t}` : ''}` };
++const ctx = { console };
vm.createContext(ctx);
--vm.runInContext([pick('_rdEsc'), pick('_rdA'), pick('_rdUses')].join('\n'), ctx);
--
--const links = (html) => [...html.matchAll(/<a class="rd-use" href="([^"]+)"/g)].map((m) => m[1]);
--const titles = (html) => [...html.matchAll(/<b>([^<]+)<\/b>/g)].map((m) => m[1]);
--
--const withLead = ctx._rdUses({ id: 7, vod_id: 5855, sec: 40, quotes: [{ text: 'x', sec: 95.6 }] });
--assert.deepStrictEqual(titles(withLead), ['Catch up on a stream', 'Follow a topic', 'Jump to the moment', 'Get written up']);
--assert.deepStrictEqual(links(withLead), ['#rd-latest-h', '/rundown/topics', 'https://openvibe.video/v/5855?t=95', '/broadcast'],
-- 'each entry goes somewhere: the list, the topics, the top take\'s moment (its quote\'s second), going live');
--assert.ok(withLead.includes('aria-label="What you can do here"'));
--
--const noLead = ctx._rdUses(null);
--assert.deepStrictEqual(links(noLead), ['#rd-latest-h', '/rundown/topics', '/broadcast'], 'no recording to jump into: that entry is text, not a dead link');
--assert.ok(noLead.includes('<div class="rd-use"><i class="fa-solid fa-play"'));
++vm.runInContext(pick('_rdEsc') + pick('_rdMe') + pick('_rdHow'), ctx);
--assert.match(js, /root\.innerHTML = `\$\{_rdMast\(d\.counts, 'latest'\)\}\$\{_rdUses\(d\.lead\)\}/, 'the front page shows it under the masthead');
--assert.match(css, /@media \(max-width: 560px\) \{[^}]*\.rd-uses \{[^}]*\}[\s\S]*?\.rd-use small \{ display: none; \}/, 'phones show titles only, two by two');
++const line = ctx._rdHow();
++assert.match(line, /Catch up, follow a topic, jump to the moment, get written up/, 'the line says what the page is for');
++assert.match(line, /class="rd-how-link" href="#rd-about-h"/, 'and how it works points at the explanation');
++assert.ok(!line.includes('rd-use'), 'the four tiles are one line now');
++assert.ok(!js.includes('class="rd-uses"'), 'and the tiles are gone from the page');
++assert.match(css, /\.rd-how \{/, 'the line has a style of its own');
++assert.ok(!line.includes('your Rundown'), 'a signed-out visitor has no Rundown of their own');
++vm.runInContext("var currentUser = { username: 'goosely' };", ctx);
++assert.match(ctx._rdHow(), /href="\/rundown\/goosely#mine">your Rundown<\/a>/, 'a signed-in person reaches their own Rundown from the line');
console.log('rundown uses: all checks passed');