---
name: adom-screensaver-billboard
description: >
  How the Adom Screensaver's Wiki billboard pipeline works: where it pulls content, how it caches,
  how often it re-checks, how it minimizes hits to wiki.adom.inc, how it minimizes downloads to the
  user's machine, and how it scores which drop to show. Read this before touching the Wiki loader,
  the cache, the scoring, or the refresh cadence in adom-screensaver.cs / billboard.html.
  Triggers: screensaver caching, billboard refresh, hero cache, manifest, delta skip, scoring,
  freshness, why is the screensaver showing an old hero, minimize wiki hits, screensaver bandwidth.
---

# Adom Screensaver - billboard caching, scoring & refresh

The screensaver plays Adom Wiki pages as billboards. This documents the data pipeline so it stays
honest about server load and bandwidth. Source of truth: `Wiki` class in `adom-screensaver.cs` +
`billboard.html`. Everything below is verified behavior, not aspiration.

## One source only

It pulls from exactly **one** wiki: `VW = https://wiki.adom.inc` (the canonical Adom Wiki). The old
`V1` / `V2` constants are **dead** (no code path queries them). All requests are authenticated with
the user's bearer token (`wiki-token.txt`), so the billboards are the user's filtered view (public +
their private/org pages, each badged).

## When it refreshes

`Wiki.Start()` → spawns `Wiki.Run()` **once per screensaver launch** (every idle-fire, or Win+Shift+L).
There is **no** in-session wiki re-poll. `billboard.html` re-reads the **local** `manifest.json` every 3 s
(never touches the wiki). Last run's billboards are shown instantly from the cached manifest while the
fresh `Run()` reconciles in the background.

**In-session in-place refresh (the one-launch-lag fix).** `loadManifest` doesn't just append new slugs -
it also **refreshes a slide already on screen** when its `media`/`updated` changes (repainting the current
card if it's the one showing). Without this, the page kept showing the CACHED hero from launch even after
the fresh `Run()` downloaded an updated one into the manifest, so a hero you updated would only appear on
the NEXT launch. Now it appears within ~3 s of the background fetch, same session, no reordering.

**Where heroes live now (wiki change, 2026-06).** Hero images are **no longer committed repo files** - the
page's `/files` listing returns NO images. The hero is in a hero store, exposed via the page DETAIL's
`hero_path` (e.g. `screenshots/hero.png`) + `hero_type`, and served at the usual
`/blob/<type>/<slug>/<hero_path>`. So discovery MUST read `hero_path` from the **detail** (the LIST omits
it - it comes back empty there) and the `RepoFilesAll` fallback is now mostly dead weight (kept for older
pages). The canonical `/blob/.../screenshots/hero.png` URL still works, so updated heroes are fetched
correctly (verified: the cached `-hid` hero is byte-identical to live).

## Minimizing wiki-server load - the DELTA SKIP (the important part)

Per launch, `Run()` issues just **2 list calls**: `GET /api/v1/pages?type=app&limit=100` and `…type=skill…`.
Those list items already carry each page's `updated_at`.

Then for each page, `ProcessItem` does a **delta skip**: if last run's manifest already has that slug
**at the same `updated_at`** and its cached hero file is still on disk, it **reuses that entry verbatim**
- no per-page detail fetch, no file-list, no hero probe, no download. Only **new or changed** pages do
the full network work (detail + file-list + ranged-GET hero probes + maybe a hero download).

Net: a re-fire where nothing changed costs **2 requests total** (verified: `WIKI: run done, 60 reused
(delta-skip), 0 fetched, 60 total`). Before this, every launch re-fetched all ~60 pages' detail +
files + probes - hundreds of requests per idle-fire.

## The cache (what's on disk)

`%LOCALAPPDATA%\adom-screensaver\cache\`:
- **`manifest.json`** - the slide list (slug, title, brief, prompt, media ref, vis, owner, `updated`, …).
  Rewritten each run from the fresh+reused set; a page removed from the wiki simply drops out.
- **Hero images** (image/SVG) - downloaded and served locally, filename **keyed on the page's update
  stamp**: `SafeName(slug)__<compacted updated_at>.png`. So:
  - page unchanged → same filename → served from disk, **no re-download**;
  - page's hero updated → new `updated_at` → new filename → **re-downloaded once**, and the old
    stamped file (+ the pre-stamp legacy `slug.png`) is **pruned**.
  - The download URL is cache-busted (`?v=<stamp>`) so no proxy serves stale bytes.
- **`showcounts.json`** - per-slug lifetime view counts (host-written synchronously on each show), used
  by the scorer. Survives hard kills (unlike browser localStorage).

## Minimizing downloads / bandwidth

- Heroes are downloaded **at most once per content change** (the stamp key above). Unchanged heroes
  are never re-fetched.
- **Videos are STREAMED**, never downloaded (they can be tens of MB); the manifest carries the remote
  URL and the WebView2 streams it.
- The delta skip means unchanged pages download **nothing**.

## Repo type (App / Skill / Component) - capsule + components opt-in

The wiki's `type` field is first-class (`app` / `skill` / `component` / `bootstrap`) - it's what the header
tabs filter on, NOT tags. The fetch reads it per page and the manifest carries `s.type`. Each billboard
shows a color-coded **type capsule** leading its meta row: App = blue (square dot), Skill = purple (diamond),
Component = pink (round). `IsHeroType` decides what's eligible: **app + skill always; component only when
opted in; bootstrap never.**

**Components are opt-in** (`Prefs.ShowComponents()`, default OFF; config toggle `compToggle` ->
`components:on`/`components:off`). When ON, `AddWork` also fetches `type=component` and `Start()` widens
`Target` 200 -> 360 so components survive the per-launch collection cap (app+skill alone already exceed 200).
Takes effect on the next saver launch.

## Component heroes - raster product visuals only, never a symbol

Most `type=component` pages are jellybean passive parts (0402 caps/resistors) whose only image is a
part-number-named **svg, i.e. a schematic symbol**, and they rarely set `hero_path`. Showing those
full-screen is a joke. So `RepoFilesAll` is component-strict:
- **Skip ALL svg for components** (svg == symbol / schematic / pcb diagram / part-number symbol). Real
  product visuals (3D renders, photos) are raster (png/jpg). Also skip any `schematic`/`symbol`/`footprint`
  file for every type.
- **Prefer a clean 3D hero angle**: `hero` files first, then 3D renders ranked `home`/`iso`/`persp` (clean
  product shots) > plain `front`/`board` (often a viewer screenshot or a dimensioned view) > edge/underside
  `back`/`bottom`/`left`/`right`/`top`/`side`, then screenshots, then `pcb` layout last.
- Components show ONLY with a genuine hero / 3D render / photo: the assembled candidate list for a component
  is `hero + 3D + screenshot` only (NO "rest" random-raster or bare-PCB fallback). A component with no real
  product visual gets **no hero and is dropped** - better no card than a junk card.
- Net (verified on ALL 200 live components): **38 billboard, 162 dropped, 0 junk** - and all 38 have a real
  visual (32 resolve to a clean `3d-home.png` render, the rest to `*-hero.png` / `product-photo.jpg`).
  Reference: `cosmocoil` -> `3d-home.png`.

## No network (offline) - never dead-stop, never burn in

The saver must survive a launch with **no wifi** (e.g. a fresh Airbnb) without stranding a bright
static splash on the panel. Three guarantees, none of which depend on the wiki being reachable:

1. **Don't clobber the cache.** `AddWork` returns whether the list call got a real HTTP response. If the
   wiki was unreachable, `Run()` **skips `WriteManifest()`** so last run's good `manifest.json` stays on
   disk, and re-publishes the cached entries into `Count`. (Previously `Run()` always wrote the manifest
   from an empty `published` list, **wiping** last run's billboards - the hero files stayed on disk but the
   manifest that indexed them was zeroed, so the page had 0 slides and froze on the splash.)
2. **Play cached drops offline.** The page reads the preserved `manifest.json` via the virtual host and
   rotates the cached billboards normally. The splash says `Offline: playing your N saved drops from last
   time.` (or `Offline: no saved drops yet; dimming to protect your screen.` on a true first run).
3. **Burn-in backstops the network can't defeat** (host `OnStatusTick`): dim **early (45s)** when the run
   finished/failed with **nothing to show** (`Wiki.Done && Wiki.Count==0`), and an **absolute 8-min ceiling**
   (`HardRestCapMs`) so even `RestAfter=0` ("never") can't hold a lit screen all night. The normal
   `RestAfterSec` timer (default 300s) still applies on top.

## Scoring - what plays first (`billboard.html shuffleSlides`)

Higher score plays sooner; the goal is to lead with NEW + UNSEEN, sink OLD + over-seen:
- **Freshness** (from `updated`): `<7d` +100, `<30d` +60, `<90d` +35, `<365d` +15, older/unknown +5.
- **Novelty** (from `showcounts.json`): `max(0, 60 - 20 × timesShown)` (never-seen +60; gone after ~3 views).
- **Jitter**: +0..12 so the order varies run-to-run and ties never lock.
Each billboard prints its own `priority N (fresh F + new V)` in the footer for transparency.

## Public vs private background (so the two never get confused)

Each billboard's **full background** is tinted by visibility (set per slide in `showSlide` via a
`viz-pub`/`viz-priv` body class driving `#bgwash`): PUBLIC = the clean dark screensaver (no wash);
PRIVATE / org-only = an Adom TEAL wash + a teal screen frame (brand #00B8B1). This makes a private page (e.g. `adom-gchat-adom`, `adom-google-adom`,
or any `-dev` source page) unmistakable next to its public twin.

**Private pages only appear if the saver is authenticated.** The list/detail/blob calls all send the
`wiki-token.txt` bearer. The hero-probe (`UrlExists`) MUST send it too: a private page's hero blob
401/404s anonymously, so without the token every private page was silently dropped (the bug that made
private `-adom`/`-dev` pages never show). Public heroes probe fine anonymously, which masked it.

## Freshness display

Each slide shows a chip from `updated`: "New today" / "Updated 3 days ago" / "Updated 2 weeks ago" …,
teal for recent, dim for old.

## Exclude list

`exclude.txt` (beside the .scr) holds slugs kept OUT of the rotation, one per line. `Skip()` also
hard-excludes `adom-screensaver`, `test-`/`smoke-`/`shared-` prefixes, `adom-desktop`/`hd-`, and
auth pages. On startup `FilterCachedManifestByExclude()` drops now-excluded slugs from the cached
manifest so a just-excluded card never flashes.

## Correctness invariants (don't regress)

- **One entry per slug.** `AddWork` dedups by slug within the work list; each launch is a fresh process
  so `published` never accumulates across runs. (If you ever see two of the same slug, that's the bug.)
  Note: two *different* slugs about the same topic (e.g. `adom-gchat` the CLI + `adom-gchat-adom` the
  Org Config page) are two real pages, not a duplicate.
- A reused (delta-skip) entry is only trusted if its hero file still exists (`CachedMediaOk`); else it
  falls through to full processing.
- The manifest is rebuilt from the current wiki list each run, so removed pages drop and the set is
  never stale.

## Known gaps → asks filed for Colby (see colby-feedback.md, 2026-06-19)

- `/blob/...` sends `no-cache` with **no ETag/Last-Modified** and ignores conditional GET, so the only
  way to know a hero changed without a page edit is to download it. Want ETag + `304`.
- `last_commit_hash` is only in the page **detail**, not the **list** - with it in the list we'd have a
  perfect per-page/per-hero delta key in one call.
- No bulk popularity/trending endpoint (stars are per-page only) - want `star_count` in the list and/or
  `/api/v1/trending` to factor popularity into the score.

## Quick reference

| Question | Answer |
|---|---|
| Which wiki? | `wiki.adom.inc` only |
| Re-check cadence | once per saver launch (no in-session poll) |
| Requests when nothing changed | 2 (the two list calls); 0 per-page |
| Hero re-download | only when `updated_at` changes |
| Videos | streamed, never downloaded |
| Order | freshness + novelty + jitter (highest first) |
