commit 70f60b6f7ef6bad890ee3eeca12c32d29c3e2857
parent edb870090027d8bb9da3f7352281768e37117ce5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 16:11:29 -0400
plans: release 14 — the social icons in the export header, one Options button, the Sites dropdown as a link to the Archilyzer home, and a clear screen until the first Search
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 295 insertions(+), 0 deletions(-)
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -146,6 +146,10 @@ changed at integration. Nothing was deployed, cut, pushed or restarted; :3001 st
8. Optional: rebuild + restart umtool on :3050. Its app changed only in `lib/tools.mjs` (the
shared tool probe) and two test-only variable names; O5's render changes already reach it from
disk (the live :3050 spawns `report-to-video/*`), and an unbranded render is byte-identical.
+- **Planned, not started (2026-09-28): release 14** — the social icons in the export header, one
+ Options button for the theme, the Sites dropdown replaced by a link to the Archilyzer home, and a
+ clear screen until the first Search (`plans/export-header-first-search.md`; slices H3, H1, H2, S1,
+ and S2 as a candidate). Waits on `export/gumroad-tip` merging.
- **Next candidates:** one-core Phase 5 (projects join the core, `plans/one-core.md`); the Diagnostics
cards keeping their retry log (O3's found-and-left); `ChartView.tsx`'s five-slot cycle reaching
`--chart-6` (O2); O5's two wording lows in `svg-faces.mjs` / the README (kerning is not
diff --git a/plans/export-header-first-search.md b/plans/export-header-first-search.md
@@ -0,0 +1,291 @@
+# Release 14 — the social icons within reach, and a clear screen until the first Search
+
+Written 2026-09-28 against `main` `6f03805b`. Self-contained for a fresh session. Rules:
+`plans/tools/implementer-rules.md` (one Opus implementer per slice in a worktree, one read-only
+Opus review, the parent merges; no cut, no deploy and no restart inside a slice). Record file:
+`plans/release-14.md` (NEW, created by the first slice to start, the shape of `release-12.md`).
+Facts below were gathered by two read-only passes on 2026-09-28 and carry `file:line` anchors;
+anything marked UNVERIFIED is a first step for the implementer, not a fact.
+
+**Why 14:** release 12 (the source mirror) is merged and release 13 (`release-13.md`) is a parallel
+session's plan in flight, with its own slice table, merge order and integration gate. These slices
+are not added to it. No `r13/*` branch touches any file this plan owns (checked: `git diff --stat
+main...<branch>` is empty for the search and header components on all five).
+
+## The operator's words
+
+- 2026-09-25: "What if on the export we didn't load all search items until the user hits search,
+ preventing the possibility of an unexpected system overload on re-visit" — shipped in part as
+ release 8 slice E (`00fb567b`): a query restored from localStorage is held, not run.
+- 2026-09-28: "the export feature … where the search doesn't fire / show results until the first
+ time hitting search after load", then: "I was expecting a clear screen (easy to see the footer)
+ when it's actually the default state of viewing an index of all videos like when you hit search
+ with no items."
+- 2026-09-28: "I want the social icons (particularly gumroad) to be more accessible, I'm thinking
+ we move changelog link to footer, theme and dark/light behind a single options modal button, and
+ instead of sites dropdown just link to archilyzer home which lists official instances."
+- Standing: **no copy** beside the tip link; the mark is vendored unmodified (`gumroad-tip-link.md`).
+
+## Decisions and assumptions
+
+Decided by the operator's words: the four header moves; the clear screen on load.
+
+ASSUMED by the planner (2026-09-28) — each is one line to reverse, and the operator has been told:
+
+| # | Question | Assumed |
+|---|---|---|
+| A1 | A link that carries a query (`qt=`, legacy `q=`) | Still runs on load and shows its results — "a shared link is the visitor asking" (release 8's rule, kept). |
+| A2 | Leaving the search page and coming back within one visit | The listing is still there: the gate is once per page life, not once per mount. |
+| A3 | Which pages get the clear screen | The single-site search page. The hub only if its home renders the same results component (S1 step 0). |
+| A4 | A per-site switch | None. Every export site behaves the same. |
+| A5 | The header's **Hub** link | Dropped with the Sites dropdown; the link to the Archilyzer home covers it. `hubUrl` still parses. |
+| A6 | The footer's social row | Stays, as well as the header's — nothing disappears for a reader who looks there. |
+| A7 | Phones | The icons are visible in the header at every width, not inside the slide-out menu. |
+| A8 | The options modal | Holds Base and Accent and nothing else. |
+| A9 | The homepage app's own header | Unchanged, except the anchor of H3. Its icons stay in the footer's Elsewhere row. |
+| A10 | Deferring the summaries fetch until the first Search | NOT in S1. Measured and written up as S2, a candidate, because `/ask` reads the same data. |
+
+## Dependency graph
+
+```
+export/gumroad-tip (built 1c76cd95, in review) ──► H1 ──► H2
+H3 (homepage anchor) ── independent; H1's link needs it DEPLOYED to land on the list
+S1 (clear screen) ── independent of H*; touches no header file
+S2 (defer summaries) ── after S1, only on the operator's word
+```
+
+H1 then H2 are stacked on one branch (both rewrite `Header.tsx` and `MobileMenu.tsx`). S1 and H3
+run in parallel with them on their own branches. Merge order: gumroad → H3 → H1+H2 → S1.
+
+## Verified facts the implementer must not re-derive
+
+**The header** (`export/app/components/Header.tsx`):
+- Sticky, `min-h-14` (56 px), one breakpoint at `md` (768 px) (`:68-72`;
+ `plans/export-responsive-redesign.md:55-62`).
+- Wide: brand (`:73-80`), nav `hidden md:flex` from `navLinks` (`:50-56`, `:82-92`), then the right
+ cluster `hidden md:flex` (`:94-114`): `SiblingSwitcher` (`:96`), Hub backlink (`:97-106`, shown
+ when `isSite && resolvedHub !== site.siteUrl`, `:35-40`), Changelog (`:107-112`), `ThemeMenu`
+ (`:113`); then, at EVERY width, `ThemeToggle` (`:117`) and the `MobileMenu` trigger (`:118`).
+- `MobileMenu.tsx` is a Radix `Sheet` from the right carrying nav + Offline + Changelog
+ (`menuLinks`, `Header.tsx:61-65`), the Hub backlink (`MobileMenu.tsx:83-92`), the Sites groups,
+ and Base/Accent as native radiogroups (`:124-147`) — it deliberately does not nest `ThemeMenu`'s
+ dropdown ("a focus-trap fight", `:30-35`).
+- The Sites dropdown is **related sites, not official instances**: `SiblingSwitcher.tsx:21-62` over
+ `resolveRelatedSites(site, listSites())` (`common/lib/site.ts:146,203`). The footer renders the
+ same groups in `<nav aria-label="Related sites">` (`Footer.tsx:116-148`), so removing the
+ dropdown loses nothing and orphans no config.
+- Theme: `ThemeMenu.tsx:28-86` — trigger `aria-label="Choose theme"` (`:34`), `menuitemradio`
+ items, two groups: Base (System/Light/Sepia/Dark, `themeConfig.ts:49-54`) and Accent (seven named
+ + the site's own, `siteSchema.ts:116`). `ThemeToggle.tsx:20-39` cycles Base only,
+ `aria-label="Switch to {next}"`. Keys `ytdlp-tb:base`, `ytdlp-tb:accent`; DOM `data-base`,
+ `.dark`, `data-accent` (`ThemeProvider.tsx:100-108`).
+- The no-flash script (`ThemeScript.tsx`, `themeConfig.ts:197-228`) and `ThemeProvider` are
+ untouched by where the controls render: every control reads `useTheme()`.
+- A full Radix dialog wrapper exists and has **no importer**: `common/components/ui/dialog.tsx`.
+ `radix-ui ^1.6.0` is already a dependency of `common`.
+- Changelog route: `export/app/changelog/page.tsx:15-20`. Linked from the header twice (wide
+ cluster and `menuLinks`), from nowhere else.
+
+**The footer** (`export/app/components/Footer.tsx`): row 1 (`:32-75`) eyebrow links Downloads /
+Offline / Use with AI (`:66-74`) — where Changelog goes; row 2 (`:76-114`) credit + social `<ul>`
+(`:97-113`). Social links come from `resolveSocialLinks(site, getSettings())` (`:22`), inlined SVG
+strings sized by `sizeSocialSvg` (`:108`); the Gumroad branch appends its `<li>` last,
+unconditionally.
+
+**Accessibility, measured in code:** social icons are `w-5 h-5` (20 px) with no padding on the
+link (`Footer.tsx:107`) — under WCAG 2.5.8's 24 px minimum; the links have no focus ring of their
+own; header icon buttons are 32 px (`ThemeMenu.tsx:37`, `ThemeToggle.tsx:31`) and 36 px (the menu
+trigger, `ui/button.tsx:28`).
+
+**The homepage:** Official Instances is `homepage/app/page.tsx:112-122`, a `<section>` with no
+`id` (`:114`), rendered only when `sites.length > 0`. `PROJECT_URL` is `common/lib/project.ts:31`.
+
+**The results area:**
+- `export/app/(workspace)/SiteWorkspace.tsx:22-57` mounts the providers, the search bar and
+ `WorkspaceView` once, above both `/` and `/ask`. `WorkspaceView.tsx:132` renders
+ `<SearchResults/>`, latched once mounted (`:113-116`).
+- `common/components/SearchResults.tsx`: header text branches on `hasActiveQuery` (`:124-137`,
+ "Matching videos (N)" / "All videos (N)"); view toggle (`:155-177`), Copy for AI (`:178-189`),
+ selection toolbar (`:195-247`), chart view (`:249-273`), `browse-hint` (`:276-280`), zero states
+ (`:281-292`), the virtualized list (`:293-305`) whenever `resultGroups.length > 0`.
+- `resultGroups` in browse mode lists every video passing the committed filters whenever
+ `!hasActiveQuery` (`SearchSessionContext.tsx:890-902`) — nothing asks whether the visitor searched.
+- `ranThisPageLife` is a MODULE-level `let` (`SearchSessionContext.tsx:206`): set on a URL query at
+ hydrate (`:1277`), in `commitSearch` unconditionally — an EMPTY commit included (`:1393`) — and
+ in `applySnapshot` (`:1457`). Read once (`:1266`). It survives client-side navigation and resets
+ on reload. It is not reactive.
+- `searchExecuted` is dead: the getter is discarded (`:463-471`), nothing reads it.
+- `commitSearch` (`:1390-1426`) is the one choke point for Enter and the Search button
+ (`SearchBar.tsx:82-96,118-122`).
+- The footer is a sibling of `<main>` in a column (`export/app/layout.tsx:123-138`): the list's
+ height is what pushes it off screen.
+- The listing is client-rendered only (`SearchResults.tsx:1` `"use client"`; 0 rows in
+ `out/index.html`); the sitemap has four URLs and no per-video pages. The clear screen costs
+ nothing in static HTML or crawl.
+- The editor imports none of these components.
+- Specs that load `/` with no query and expect rows at once: `export/e2e/browse-all.spec.ts:32-44`,
+ `workspace-shell.spec.ts:37-39,57,67`, `charts.spec.ts:64-67,96-99,112-115,140-143`. There is no
+ shared "go home and wait for the list" helper. Specs that load with `qt=` or press Search first
+ are unaffected.
+
+**Specs on the header controls:** `theme-accent.spec.ts` (`:32` the trigger by name, `:45-72`
+`menuitemradio`), `theme.spec.ts` (`:64-73` the toggle by `/switch to/i`), `responsive.spec.ts:85-97`
+(the Sheet's `radio` "Sepia"), `brand.spec.ts:81` (`onBase`). No spec opens the Sites dropdown; no
+spec names the Changelog link. On the Gumroad branch `expectGumroadMarkLast`
+(`export/e2e/helpers.ts`) asserts the mark is the last `<li>` of the FOOTER's row.
+
+UNVERIFIED: whether `HubHome` renders `SearchResults` (`export/app/(workspace)/page.tsx:12-14`
+branches to it and bypasses `SiteWorkspace`); Back-button scroll restoration into the virtualized
+list; the summaries payload per site (FACTS and the 2026-09-25 session give different figures).
+
+## Slice H3 — the homepage's instances anchor (branch `r14/home-anchor`)
+
+Owns `homepage/app/page.tsx`, `homepage/e2e/marketing.spec.ts`, `common/lib/project.ts` (one
+constant), `homepage/CHANGELOG.md`.
+
+1. `id="instances"` on the Official Instances `<section>`, with `scroll-mt` for the sticky header.
+2. `INSTANCES_URL = ${PROJECT_URL}/#instances` beside `PROJECT_URL`.
+3. e2e: `/#instances` scrolls the heading into view. When the section is absent (no sites) the
+ link lands on the top of the page; that is accepted and said in the record.
+4. Gates: tsc, homepage unit, homepage e2e, `archilyzer build homepage --no-source`.
+
+## Slice H1 — the header carries the social row (branch `r14/header`, after gumroad merges)
+
+Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`, a NEW
+`common/components/SocialRow.tsx`, `export/e2e/{helpers.ts,site-branding.spec.ts,brand.spec.ts,
+responsive.spec.ts}`, a NEW `export/e2e/header.spec.ts`, `export/e2e-hub/official-instances.spec.ts`,
+`export/CHANGELOG.md`, `plans/export-responsive-redesign.md` (the header contract, `:258-260`).
+
+1. **One component, two places.** `SocialRow` renders the operator's links and then the Gumroad
+ mark, last. Props: `placement: "header" | "footer"`. The footer uses it as it renders today.
+2. **Tap area and focus.** Each link is a 36 px box (`size-9`, the menu trigger's size) around the
+ 20 px glyph; on coarse pointers 44 px (`pointer-coarse:` literal classes). A
+ `focus-visible:ring-2 focus-visible:ring-ring` ring. The Gumroad ring and forced-colours outline
+ move with the mark, unchanged.
+3. **The header, wide:** brand · nav · `SocialRow` · the Archilyzer link · the theme controls (H2).
+ **Narrow:** brand · `SocialRow` · the theme controls · the menu trigger. With three icons at
+ 36 px the narrow header is about 108 px of icons; if the operator configures more links than fit
+ at 360 px, the row keeps the LAST three (the mark is last) and the rest stay in the footer.
+ State the rule in a comment and test it with five links.
+4. **Sites dropdown → one link.** `SiblingSwitcher` is deleted. In its place a text link
+ "Archilyzer" to `INSTANCES_URL`, opening in the same tab, with an accessible name that says
+ where it goes ("Archilyzer — official instances"). Also in the slide-out menu, replacing the
+ Sites groups there. On the hub build the link is omitted (the hub lists the instances itself).
+5. **Hub backlink removed** from the header and the slide-out menu (A5). `hubUrl` and
+ `resolveHubUrl` stay; an old `site.json` with the key still loads.
+6. **Changelog** leaves both header places and joins the footer's row-1 links after Use with AI.
+7. **Tests.** `expectGumroadMarkLast` takes the row as a parameter; assert it for the header row
+ and the footer row on a site page and on the hub. New `header.spec.ts`: the header row is
+ visible without scrolling at 360, 768 and 1280 px; each link's box is ≥ 24 px (and ≥ 44 px under
+ a coarse pointer emulation); the focus ring shows; the Archilyzer link's `href`; no `Sites`
+ button; Changelog is in the footer and not in the header; five configured links → three in the
+ header, five in the footer. `responsive.spec.ts`: the slide-out menu's contents as changed.
+8. **No copy.** No text beside any icon; the accessible names are the services' names.
+9. Gates: tsc; common tests; `pnpm --filter export exec next build` (site, fixture site, hub);
+ export e2e — the specs above plus `theme.spec.ts`, `theme-accent.spec.ts`,
+ `related-sites.spec.ts`; hub e2e `official-instances.spec.ts`; screenshots of the header at
+ 360 / 768 / 1280 px on Light, Sepia and Dark to `~/reports/release-14/shots/`.
+
+## Slice H2 — one options button (same branch, stacked on H1)
+
+Owns `common/components/{ThemeMenu,ThemeToggle}.tsx`, a NEW `common/components/OptionsDialog.tsx`,
+`export/app/components/{Header,MobileMenu}.tsx`, `export/e2e/{theme,theme-accent,responsive,
+brand}.spec.ts`, `export/CHANGELOG.md`. **Check first who else renders `ThemeMenu`/`ThemeToggle`**
+(`homepage/app/components/Header.tsx:62-65` does; the editor may): those keep the old controls.
+The new dialog is the export header's only.
+
+1. One icon button, accessible name **"Options"**, 36 px, visible at every width, where the toggle
+ is today. It opens `OptionsDialog` on `common/components/ui/dialog.tsx` (its first importer):
+ title "Options", two native radiogroups — **Base** and **Accent** — the markup `MobileMenu`
+ already uses (`:124-147`), extracted into one `ThemeRadios` component used by both.
+2. `ThemeMenu` and `ThemeToggle` leave the export header. The slide-out menu keeps its radiogroups
+ (one tap there is cheaper than opening a dialog from inside a sheet).
+3. A choice applies at once and the dialog stays open; Escape, the close button and a click
+ outside close it; focus returns to the trigger.
+4. **The cost, recorded:** changing the ground is two actions where the toggle made it one. The
+ fallback, if the operator asks, is the toggle kept beside the Options button — one line in
+ `Header.tsx`.
+5. **Labels are contracts.** `theme.spec.ts` and `brand.spec.ts` drive the toggle by
+ `/switch to/i`; `theme-accent.spec.ts` drives `Choose theme` and `menuitemradio`. Rewrite them
+ to open Options and pick a `radio` by name; keep one helper, `chooseTheme(page, {base, accent})`,
+ in `export/e2e/helpers.ts`, and use it everywhere a spec changes theme through the UI. Specs that
+ set `localStorage` directly are untouched. The no-flash assertions (`data-theme-ready`) are
+ untouched.
+6. Gates: as H1, plus an axe-style check that the dialog traps focus and restores it, and the
+ homepage e2e `theme.spec.ts` to prove the homepage's controls did not move.
+
+## Slice S1 — a clear screen until the first Search (branch `r14/first-search`)
+
+Owns `common/components/{SearchSessionContext,SearchResults,SearchBar}.tsx`,
+`export/app/(workspace)/WorkspaceView.tsx`, `export/e2e/{browse-all,workspace-shell,charts,
+restore-no-refire}.spec.ts`, a NEW `export/e2e/first-search.spec.ts`, `export/e2e/helpers.ts`
+(one helper), `export/CHANGELOG.md`.
+
+0. **First, settle the two unknowns** and write them in the record: does `HubHome` render
+ `SearchResults`; what does the Back button restore into the list. If the hub shares the
+ component, the behaviour applies there too and `e2e-hub` joins the gate; if not, the hub is
+ left and said so.
+1. **One reactive flag.** `searchedThisPageLife` in the session context, initialised from the
+ module-level `ranThisPageLife` and set wherever it is set (`:1277`, `:1393`, `:1457`). Delete
+ the dead `searchExecuted` state. The module variable stays the source of truth across remounts
+ (A2).
+2. **The gate is in the view, not the data.** `SearchResults` renders NOTHING until the flag is
+ true: no header count, no view toggle, no Copy for AI, no selection toolbar, no chart, no list,
+ no `browse-hint`. `resultGroups` is still computed (S2 is where the work is saved). The page's
+ own intro (`export/app/(workspace)/page.tsx:7-37`: the transcript count and New since last
+ visit) stays.
+3. **What the visitor sees:** the search bar, the intro, the footer. The bar's existing line
+ "Press Enter or click Search to apply" is shown in this state too, so the screen says what to
+ do in words that already exist. No new copy.
+4. **An empty Search** shows "All videos (N)" and the listing, exactly as today. A `qt=` URL runs
+ and shows on load (A1). A restored query stays held (release 8) — and now shows a clear screen
+ instead of the browse listing under it.
+5. **Filters before the first Search:** changing a filter does not reveal the listing; the hint
+ line already covers it (`filtersDirty`).
+6. **Tests.** New `first-search.spec.ts`: on load no `results-summary`, no `[data-card-header]`,
+ the footer in the viewport at 1280×800 and 390×844; Enter on an empty box shows "All videos";
+ the Search button does too; `/` → `/ask` → `/` keeps the listing; a reload clears it; `qt=`
+ shows results on load; a restored query shows the clear screen and the filled form. The three
+ affected specs gain `showAll(page)` (one helper: press the Search button, wait for
+ `results-summary`) where they relied on the listing at load; `browse-all.spec.ts`'s first test
+ is rewritten to assert the clear screen and then the listing.
+7. Gates: tsc; common tests; `pnpm --filter export exec next build`; the FULL export e2e suite
+ (the blast radius is a shared component — three specs are known, the suite finds the rest);
+ hub e2e if step 0 says so; `e2e:2origin` from the worktree only.
+
+## Slice S2 — CANDIDATE: fetch the summaries on the first Search
+
+Not scheduled. It is the other half of the 2026-09-25 request ("didn't load all search items
+until the user hits search") and needs the operator's word, because it changes `/ask`.
+
+- `SingleSiteDataProvider` calls `useSummaries("")` on mount (`SearchDataContext.tsx:148-155`), and
+ `useSummaries` fans out EVERY page eagerly (`summariesCache.ts:19-71`, 1,000 records a page).
+- The cheap manifest feeds the Filters channel list (`SearchDataContext.tsx:165-183`) and must stay
+ eager; the fallback at `:176-183` derives channel names from the pages when the manifest has none.
+- `/ask` grounds on the same summaries (`useAskChat.ts:149-151`) under the same shell, so an
+ `/ask`-first visitor needs them without ever pressing Search.
+- Step one of S2 is a measurement, per site, of the bytes and requests a plain visit costs today
+ and would cost after — the figures on record disagree.
+
+## Rollout (parent)
+
+Nothing here deploys inside a slice. After the merges: one export release cut, a rebuild and
+deploy of every site and the hub (the header is in every build), and the homepage deployed FIRST
+so `#instances` exists before any site links to it. The homepage deploy runs the source publish
+step of release 12 — its denylist must be complete (`release-12.md`, "Rollout", step 0). Live
+checks: the header row visible at 390 px on one site; the mark's `href`; the Archilyzer link lands
+on Official Instances; a plain visit shows the footer without scrolling; a `qt=` link still shows
+results. An HTML runbook at `~/reports/release-14/RUNBOOK.html`.
+
+## Risks
+
+- **Two taps for the ground** (H2.4). The fallback is named.
+- **A first-time visitor sees no videos.** The site's front page becomes a search box over a
+ count. That is the operator's choice; the hint line is the only instruction.
+- **Icons in a 360 px header** compete with the brand's title: a long `headerTitle` wraps
+ (`min-h-14` allows it). The screenshots at 360 px are the check; the rule of three keeps the row
+ bounded.
+- **A trademark in the header** is more prominent than one in the footer. The mark stays
+ unmodified, unlabelled and only a link.
+- **Specs outside the known three** may lean on the listing at load through a hydration wait; the
+ full-suite run in S1's gate is how they are found.