Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit acd24a1833e782bccb6f921ee7bca74527aad59d
parent 75ac8bd01ae8199c81e738e07c71938e4b732557
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 22:36:54 -0400

Merge main into brand/themes — S0 + S1 (the Found-line mark, icon routes, Wordmark) and release 9 C2/C3 under the base × accent themes

Conflicts, all resolved by keeping both sides:
- export/app/layout.tsx: the import block — S2's accent/brand/themeConfig
  imports plus S1's ICON_METADATA;
- editor/, export/ and homepage/CHANGELOG.md [Unreleased]: every bullet, S2's
  then S1's;
- plans/brand-and-themes.md: under "As shipped", S0's record, then S1's, then
  S2's; "Proposed — Archilyzer Media" stays at the end.

common/lib/accent.ts merged cleanly: main's comment on accentHex, and S2's
deletion of siteAccentVars. Nothing on main calls it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/components/BrandMark.tsx | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/SearchDataContext.tsx | 407++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcommon/components/SearchResults.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/SearchSessionContext.tsx | 18++++++++++++++++++
Acommon/components/Wordmark.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/accent.ts | 8+++++---
Acommon/lib/archive/serviceWorkerRouting.test.ts | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIconFiles.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIcons.test.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brandIcons.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 2++
Aeditor/app/icon.svg | 1+
Meditor/app/layout.tsx | 28+++++++++++++++++++---------
Aeditor/app/lib/brandIcon.test.ts | 18++++++++++++++++++
Meditor/e2e/branding.spec.ts | 29+++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 1+
Mexport/app/components/Footer.tsx | 24+++++++++++++++---------
Mexport/app/components/Header.tsx | 27+++++++++++++++++----------
Mexport/app/components/hub/HubHome.tsx | 20++++++++++++++++----
Aexport/app/components/hub/HubScope.tsx | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/hub/HubStats.tsx | 15++++++++++-----
Aexport/app/components/hub/useHubScope.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dexport/app/favicon.ico | 0
Aexport/app/favicon.ico/route.ts | 11+++++++++++
Aexport/app/icons/[file]/route.ts | 24++++++++++++++++++++++++
Mexport/app/layout.tsx | 11++++-------
Aexport/app/lib/brand.ts | 26++++++++++++++++++++++++++
Mexport/app/manifest.ts | 30+++++++++++++++++-------------
Aexport/e2e-hub/brand.spec.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e-hub/federated-search.spec.ts | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e-hub/federation.spec.ts | 4++--
Aexport/e2e/brand.spec.ts | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/fixtures/sites/testsite/site.json | 1+
Mexport/e2e/pwa.spec.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Dexport/public/icons/apple-touch-icon.png | 0
Dexport/public/icons/icon-192.png | 0
Dexport/public/icons/icon-512.png | 0
Dexport/public/icons/icon.svg | 6------
Dexport/public/icons/maskable-512.png | 0
Dexport/public/icons/maskable.svg | 6------
Mexport/service-worker/site-sw.js | 25+++++++++++++++++++++----
Mexport/service-worker/sw-hub.js | 20++++++++++++++++++--
Mhomepage/CHANGELOG.md | 6++++++
Mhomepage/app/components/Header.tsx | 27+++++++++++++++++----------
Dhomepage/app/favicon.ico | 0
Ahomepage/app/favicon.ico/route.ts | 10++++++++++
Ahomepage/app/icons/[file]/route.ts | 24++++++++++++++++++++++++
Mhomepage/app/layout.tsx | 11+++--------
Mhomepage/app/page.tsx | 8++++----
Ahomepage/e2e/brand.spec.ts | 38++++++++++++++++++++++++++++++++++++++
Dhomepage/public/icons/apple-touch-icon.png | 0
Dhomepage/public/icons/icon-192.png | 0
Dhomepage/public/icons/icon-512.png | 0
Dhomepage/public/icons/icon.svg | 10----------
Dhomepage/public/icons/maskable-512.png | 0
Dhomepage/public/icons/maskable.svg | 7-------
Mplans/STATE.md | 30++++++++++++++++++++++--------
Mplans/brand-and-themes.md | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/release-9.md | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
59 files changed, 2490 insertions(+), 209 deletions(-)

diff --git a/common/components/BrandMark.tsx b/common/components/BrandMark.tsx @@ -0,0 +1,60 @@ +import type { CSSProperties } from "react"; +import { MARK, MARK_VIEWBOX, type MarkTone } from "../lib/brand"; + +// A palette whose slots are any CSS colour — a hex, or a `var()` expression +// such as the export header's `var(--brand-mark, var(--brand))`, which is why +// this is not lib/brand.ts's hex-only IconPalette. +export type BrandMarkPalette = Readonly<Record<MarkTone, string>>; + +// The Found-line mark, inline — drawn from the same MARK shape list as +// markSvg (the icon files), so the two cannot drift. Decorative: the text +// beside it names the link. Colours go through `style`, not a `fill="var()"` +// attribute, because a presentation attribute does not resolve custom +// properties everywhere a style declaration does. Size it with `className` +// (e.g. `size-7`) or `size`. +export function BrandMark({ + palette, + size, + className, + style, +}: { + palette: BrandMarkPalette; + size?: number; + className?: string; + style?: CSSProperties; +}) { + return ( + <svg + viewBox={`0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}`} + width={size} + height={size} + aria-hidden="true" + focusable="false" + className={className} + style={style} + data-brand-mark="" + > + {MARK.map((s) => + s.kind === "rect" ? ( + <rect + key={s.part} + x={s.x || undefined} + y={s.y || undefined} + width={s.width} + height={s.height} + rx={s.rx} + data-tone={s.tone} + style={{ fill: palette[s.tone] }} + /> + ) : ( + <polygon + key={s.part} + points={s.points.map(([x, y]) => `${x},${y}`).join(" ")} + data-tone={s.tone} + style={{ fill: palette[s.tone] }} + /> + ), + )} + </svg> + ); +} diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -14,7 +14,7 @@ import { useMemo, type ReactNode, } from "react"; -import { useQueries } from "@tanstack/react-query"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; import { useSummaries, type SummariesState } from "./summariesCache"; import { useSubsManifest } from "./subsCache"; import { usePostsManifest } from "./postsCache"; @@ -77,6 +77,41 @@ export type SearchDataValue = { // both modes: a hub's counts are the hub's, and summing member documents here // would put a number in front of someone that no site can reproduce. curatedTags: PublishedTag[]; + // Hub mode only (absent single-site): the per-archive state of the + // federation — which archives are in scope, loading, ready or failed — and a + // Retry per archive. Drives the scope chips and the "N of M archives + // answered." line. + federation?: FederationState; + // Hub mode only: the source archive's title for a content origin, so a result + // card can name where it came from in text, not by its accent alone. + siteTitleOf?: (origin: string) => string | undefined; +}; + +// One federated archive's state, as the hub shows it on its scope chip. +// off — the visitor took it out of the search; nothing is fetched. +// loading — its manifest or pages are still arriving (or being retried). +// ready — every summaries page arrived; its records are in the search. +// failed — its manifest or a summaries page could not be read; its records +// are NOT in the search until a Retry succeeds. +export type FederatedSiteStatus = "loading" | "ready" | "failed" | "off"; + +export type FederatedSiteState = { + origin: string; + siteTitle: string; + accent?: string; + status: FederatedSiteStatus; + // The archive's transcript count (its summaries manifest's totalCount), once + // the manifest has arrived. + count?: number; + // Why it failed, when it did. + error?: string; +}; + +export type FederationState = { + // In registry order, off archives included. + sites: FederatedSiteState[]; + // Refetch everything of this archive that failed. + retry: (origin: string) => void; }; // Single-site mode has no provenance accents; a module constant keeps the @@ -172,13 +207,60 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) { // A federated origin the hub reads from. `origin` is "" for the hub's own // same-origin pool, else a full origin ("https://x.com"). `siteTitle` labels -// its channel group; `accent` is carried for provenance in the UI. +// its channel group; `accent` is carried for provenance in the UI. `enabled` +// false takes it out of the search: none of its feeds are fetched and nothing +// already cached from it is merged (default true). export type FederatedSite = { origin: string; siteTitle: string; accent?: string; + enabled?: boolean; }; +// At most this many summaries-page fetches in flight per archive, so one big +// member (Jeralyzer: dozens of pages) cannot take every connection the browser +// has and starve the small ones. Per ORIGIN, not global: different origins do +// not share a connection pool, so a global cap would only slow the whole hub. +const PAGE_FETCHES_PER_SITE = 6; + +// The per-archive feeds this provider fetches — what a Retry refetches. +const FEDERATED_KEYS = new Set([ + "manifest", + "summaries-page", + "subs-manifest", + "posts-manifest", + "search-aliases", +]); + +type Gate = { active: number; queue: Array<() => void> }; +const pageGates = new Map<string, Gate>(); + +async function gated<T>(origin: string, fn: () => Promise<T>): Promise<T> { + let g = pageGates.get(origin); + if (!g) { + g = { active: 0, queue: [] }; + pageGates.set(origin, g); + } + if (g.active >= PAGE_FETCHES_PER_SITE) { + // The releasing fetch hands its slot straight to us (active unchanged). + await new Promise<void>((resolve) => g.queue.push(resolve)); + } else { + g.active++; + } + try { + return await fn(); + } finally { + const next = g.queue.shift(); + if (next) next(); + else g.active--; + } +} + +function errorText(e: unknown): string | undefined { + if (!e) return undefined; + return e instanceof Error ? e.message : String(e); +} + // Multi-site data source for the hub: fans out the same summaries/subs feeds // across many origins and merges them into ONE origin-qualified view, so // TranscriptSearch stays mode-agnostic (it reads exactly the same context shape @@ -189,56 +271,176 @@ export type FederatedSite = { // model, and downstream fetches (fetchTranscript decodes the origin back out) // never collide across sites. Grouping falls out of the existing grouped- // checkbox UI: one ChannelGroup per site (id = origin, label = siteTitle). +// +// Per archive, in order: its summaries manifest, then — only once that has +// arrived, and only while the archive is in scope — its pages, at most +// PAGE_FETCHES_PER_SITE at a time. An archive's records join the merged list +// only when ALL its pages are in (status "ready"), so the list grows one whole +// archive at a time and a search re-runs once per archive, not once per page. +// A failed archive contributes nothing and says so (`federation`). +// +// `progressive`: summariesReady turns true as soon as ONE in-scope archive is +// ready (the hub's search page, where results should not wait for the slowest +// member). Without it, summariesReady waits until every in-scope archive has +// settled, ready or failed (the /ask chat, which grounds in what it was given). export function MultiSiteDataProvider({ sites, + progressive = false, children, }: { sites: FederatedSite[]; + progressive?: boolean; children: ReactNode; }) { + const queryClient = useQueryClient(); + const inScope = (s: FederatedSite) => s.enabled !== false; + // 1. Per-origin summaries manifests (channels/groups/pageCount/freshness). const manifestQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["manifest", s.origin], queryFn: () => readerFor(s.origin).readSummariesManifest(), + enabled: inScope(s), })), }); - const manifestsSettled = manifestQueries.every( - (q) => q.isSuccess || q.isError, - ); - // 2. Flat page descriptors across every origin whose manifest loaded, so a - // single useQueries can fan out all pages regardless of per-site counts. + // 2. Page descriptors for every in-scope origin whose manifest has arrived. + // Keyed by a string of (origin, pageCount) so the list's identity moves + // only when an archive's manifest lands or the scope changes. + const pagePlanKey = sites + .map((s, i) => { + const m = manifestQueries[i]?.data; + return inScope(s) && m ? `${s.origin}\n${m.pageCount}` : ""; + }) + .join("\u0000"); const pageDescriptors = useMemo<{ origin: string; index: number }[]>(() => { const out: { origin: string; index: number }[] = []; sites.forEach((s, i) => { + if (!inScope(s)) return; const pc = manifestQueries[i]?.data?.pageCount ?? 0; for (let p = 0; p < pc; p++) out.push({ origin: s.origin, index: p }); }); return out; - // manifestQueries identity churns; key off settled + the site set. + // manifestQueries identity churns; pagePlanKey is its content. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sites, manifestsSettled]); + }, [pagePlanKey]); const pageQueries = useQueries({ queries: pageDescriptors.map((d) => ({ queryKey: ["summaries-page", d.origin, d.index], - queryFn: () => readerFor(d.origin).readSummariesPage(d.index), + // Consuming `signal` lets TanStack cancel a page whose archive was + // switched off mid-load; a page still waiting at the gate then bails + // before it is fetched. + queryFn: ({ signal }: { signal: AbortSignal }) => + gated(d.origin, () => { + signal.throwIfAborted(); + return readerFor(d.origin).readSummariesPage(d.index); + }), })), }); + // Each archive's subs + posts manifests. Fetched alongside its summaries, + // merged below only once the archive is ready, so all of an archive's + // contributions land in the search in one re-run. A posts 404 (no posts + // corpus) resolves to an empty manifest, not a failure. + const subsQueries = useQueries({ + queries: sites.map((s) => ({ + queryKey: ["subs-manifest", s.origin], + queryFn: () => readerFor(s.origin).readSubsSiteManifest(), + enabled: inScope(s), + })), + }); + const postsQueries = useQueries({ + queries: sites.map((s) => ({ + queryKey: ["posts-manifest", s.origin], + queryFn: () => + readerFor(s.origin) + .readPostsSiteManifest() + .catch( + (): PostsManifest => ({ + version: 0, + channels: [], + totalCount: 0, + generatedAt: "", + }), + ), + enabled: inScope(s), + })), + }); + // 3. Each archive's state, from its manifest, page, subs and posts queries. + const pagesByOrigin = new Map<string, (typeof pageQueries)[number][]>(); + pageQueries.forEach((q, i) => { + const origin = pageDescriptors[i]?.origin; + if (origin === undefined) return; + const list = pagesByOrigin.get(origin); + if (list) list.push(q); + else pagesByOrigin.set(origin, [q]); + }); + const rawStates: FederatedSiteState[] = sites.map((s, i) => { + const base = { + origin: s.origin, + siteTitle: s.siteTitle, + ...(s.accent ? { accent: s.accent } : {}), + }; + if (!inScope(s)) return { ...base, status: "off" }; + const m = manifestQueries[i]; + const pages = pagesByOrigin.get(s.origin) ?? []; + const count = m?.data?.totalCount; + const withCount = count === undefined ? base : { ...base, count }; + // A subs manifest that errored still counts as settled (the archive just + // has no live chat in the search); posts never error (404 → empty). + const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) => + !!q && (q.isSuccess || (q.isError && !q.isFetching)); + if ( + m?.isSuccess && + pages.length === m.data.pageCount && + pages.every((p) => p.isSuccess) && + settled(subsQueries[i]) && + settled(postsQueries[i]) + ) { + return { ...withCount, status: "ready" }; + } + const fetching = !!m?.isFetching || pages.some((p) => p.isFetching); + const failed = m?.isError ? m : pages.find((p) => p.isError); + if (failed && !fetching) { + return { + ...withCount, + status: "failed", + ...(errorText(failed.error) ? { error: errorText(failed.error) } : {}), + }; + } + return { ...withCount, status: "loading" }; + }); + const statusKey = rawStates + .map((s) => `${s.origin}|${s.siteTitle}|${s.accent ?? ""}|${s.status}|${s.count ?? ""}|${s.error ?? ""}`) + .join("\n"); + const siteStates = useMemo( + () => rawStates, + // eslint-disable-next-line react-hooks/exhaustive-deps + [statusKey], + ); + + const readyOrigins = useMemo( + () => + new Set(siteStates.filter((s) => s.status === "ready").map((s) => s.origin)), + [siteStates], + ); + const readyKey = Array.from(readyOrigins).join("\u0000"); + const scoped = siteStates.filter((s) => s.status !== "off"); + const allSettled = scoped.every( + (s) => s.status === "ready" || s.status === "failed", + ); + const summariesReady = allSettled || (progressive && readyOrigins.size > 0); + const loadedPages = pageQueries.filter((q) => q.data).length; - const pagesSettled = pageQueries.every((q) => q.isSuccess || q.isError); - // Ready once every manifest and every page has settled (a failing origin - // resolves to error rather than blocking the rest of the shelf). - const summariesReady = manifestsSettled && pagesSettled; - // 3. Merge summaries, rewriting ids to be origin-qualified. + // 4. Merge the READY archives' summaries, rewriting ids to be + // origin-qualified, newest first. const summaries = useMemo<DisplaySummary[]>(() => { const out: DisplaySummary[] = []; pageQueries.forEach((q, i) => { const origin = pageDescriptors[i]?.origin ?? ""; - if (!q.data) return; + if (!q.data || !readyOrigins.has(origin)) return; for (const t of q.data) { out.push( origin @@ -251,35 +453,40 @@ export function MultiSiteDataProvider({ ); } }); - if (summariesReady) { - out.sort( - (a, b) => - b.uploadDate.localeCompare(a.uploadDate) || - a.channelSlug.localeCompare(b.channelSlug) || - a.id.localeCompare(b.id), - ); - } + out.sort( + (a, b) => + b.uploadDate.localeCompare(a.uploadDate) || + a.channelSlug.localeCompare(b.channelSlug) || + a.id.localeCompare(b.id), + ); return out; + // Only the ready set: a ready archive's pages never change, so a manifest + // landing elsewhere (a new pageDescriptors) must not hand the session a new + // list — every new identity re-runs the current search. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadedPages, summariesReady, pageDescriptors]); + }, [readyKey]); - // 4. One channel group per site; channels keyed by makeId(origin, slug). + // 5. One channel group per in-scope site; channels keyed by + // makeId(origin, slug), from every in-scope manifest that has arrived. const groups = useMemo<ChannelGroup[]>( () => - sites.map((s, i) => ({ + sites.filter(inScope).map((s, i) => ({ id: s.origin, name: s.siteTitle, selectedByDefault: true, order: i, ...(s.accent ? { accent: s.accent } : {}), })), + // eslint-disable-next-line react-hooks/exhaustive-deps [sites], ); - const defaultGroupId = sites[0]?.origin ?? DEFAULT_GROUP_FALLBACK_ID; + const defaultGroupId = + sites.find(inScope)?.origin ?? sites[0]?.origin ?? DEFAULT_GROUP_FALLBACK_ID; const channels = useMemo<ChannelOption[]>(() => { const out: ChannelOption[] = []; sites.forEach((s, i) => { + if (!inScope(s)) return; const manifest = manifestQueries[i]?.data; if (!manifest) return; for (const c of manifest.channels) { @@ -295,20 +502,20 @@ export function MultiSiteDataProvider({ (a, b) => a.groupId.localeCompare(b.groupId) || a.name.localeCompare(b.name), ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sites, manifestsSettled]); + }, [sites, pagePlanKey]); - // 5. Merge subs manifests: concat channels (slug → origin-qualified) so the - // chat scope resolves per-origin; sum the live-chat/total counts. - const subsQueries = useQueries({ - queries: sites.map((s) => ({ - queryKey: ["subs-manifest", s.origin], - queryFn: () => readerFor(s.origin).readSubsSiteManifest(), - })), - }); - const subsSettled = subsQueries.every((q) => q.isSuccess || q.isError); + // 6. Merge subs manifests: concat channels (slug → origin-qualified) so the + // chat scope resolves per-origin; sum the live-chat/total counts. Merged + // over the READY archives only (a failed archive contributes nothing). + const subsKey = sites + .map((s, i) => (readyOrigins.has(s.origin) && subsQueries[i]?.data ? s.origin : "")) + .join("\u0000"); const subsManifest = useMemo<SubsManifest | null>(() => { const loaded = sites - .map((s, i) => ({ origin: s.origin, data: subsQueries[i]?.data })) + .map((s, i) => ({ + origin: s.origin, + data: readyOrigins.has(s.origin) ? subsQueries[i]?.data : undefined, + })) .filter((e): e is { origin: string; data: SubsManifest } => !!e.data); if (loaded.length === 0) return null; const channelsOut: SubsManifest["channels"] = []; @@ -331,31 +538,21 @@ export function MultiSiteDataProvider({ generatedAt, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sites, subsSettled]); + }, [subsKey]); - // 5b. Merge posts manifests, same origin-qualification as subs. A member site + // 6b. Merge posts manifests, same origin-qualification as subs. A member site // with no posts corpus 404s; treat that as an empty contribution so one - // video-only origin can't blank the hub's posts scope. - const postsQueries = useQueries({ - queries: sites.map((s) => ({ - queryKey: ["posts-manifest", s.origin], - queryFn: () => - readerFor(s.origin) - .readPostsSiteManifest() - .catch( - (): PostsManifest => ({ - version: 0, - channels: [], - totalCount: 0, - generatedAt: "", - }), - ), - })), - }); - const postsSettled = postsQueries.every((q) => q.isSuccess || q.isError); + // video-only origin can't blank the hub's posts scope (and it is not a + // failure of that archive). + const postsKey = sites + .map((s, i) => (readyOrigins.has(s.origin) && postsQueries[i]?.data ? s.origin : "")) + .join("\u0000"); const postsManifest = useMemo<PostsManifest | null>(() => { const loaded = sites - .map((s, i) => ({ origin: s.origin, data: postsQueries[i]?.data })) + .map((s, i) => ({ + origin: s.origin, + data: readyOrigins.has(s.origin) ? postsQueries[i]?.data : undefined, + })) .filter((e): e is { origin: string; data: PostsManifest } => !!e.data); if (loaded.length === 0) return null; const channelsOut: PostsManifest["channels"] = []; @@ -375,7 +572,7 @@ export function MultiSiteDataProvider({ generatedAt, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sites, postsSettled]); + }, [postsKey]); // Alias dictionaries per federated origin, merged into one list (later origins // shadow earlier ones by id). Missing files resolve to [] — additive only. @@ -384,28 +581,36 @@ export function MultiSiteDataProvider({ queryKey: ["search-aliases", s.origin], queryFn: () => fetchAliases(s.origin), staleTime: Infinity, + enabled: inScope(s), })), }); - const aliasesSettled = aliasQueries.every((q) => q.isSuccess || q.isError); + const aliasesKey = sites + .map((s, i) => (readyOrigins.has(s.origin) && aliasQueries[i]?.data ? s.origin : "")) + .join("\u0000"); const aliases = useMemo<SearchAlias[]>(() => { let merged: SearchAlias[] = []; - for (const q of aliasQueries) if (q.data) merged = mergeAliases(merged, q.data); + sites.forEach((s, i) => { + const data = readyOrigins.has(s.origin) ? aliasQueries[i]?.data : undefined; + if (data) merged = mergeAliases(merged, data); + }); return merged; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [aliasesSettled]); + }, [aliasesKey]); // The hub's OWN /tags.json, not a merge of its members'. See the field note // on SearchDataValue: a federated count nobody can reproduce is worse than no // chip, so until a hub build writes one, hub mode offers no tag chips. const curatedTags = useCuratedTags(""); - // Synthetic merged summaries manifest. TranscriptSearch reads channels/groups - // from the context (above), not from here, but the field is part of the - // SummariesState contract, so provide a coherent merged view. + // Synthetic merged summaries manifest over the READY archives — what the + // search actually covers. TranscriptSearch reads channels/groups from the + // context (above), not from here, but the field is part of the SummariesState + // contract, so provide a coherent merged view. const mergedManifest = useMemo<Manifest | null>(() => { - if (!manifestsSettled) return null; - const loaded = manifestQueries - .map((q) => q.data) + const loaded = sites + .map((s, i) => + readyOrigins.has(s.origin) ? manifestQueries[i]?.data : undefined, + ) .filter((m): m is Manifest => !!m); if (loaded.length === 0) return null; return { @@ -422,7 +627,24 @@ export function MultiSiteDataProvider({ defaultGroupId, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [manifestsSettled, groups, defaultGroupId, pageDescriptors]); + }, [readyKey, groups, defaultGroupId, pageDescriptors]); + + // Every in-scope archive failed: say so to the consumers that read `error` + // (the /ask chat). One failure among several is the federation line's job. + const allFailed = + scoped.length > 0 && scoped.every((s) => s.status === "failed"); + const summariesError = useMemo<Error | null>( + () => + allFailed + ? new Error( + scoped.length === 1 + ? `${scoped[0].siteTitle} did not answer.` + : "None of the archives answered.", + ) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [allFailed, statusKey], + ); const summariesState = useMemo<SummariesState>( () => ({ @@ -431,12 +653,19 @@ export function MultiSiteDataProvider({ loadedPages, pageCount: pageDescriptors.length, summariesReady, - error: null, + error: summariesError, }), - [mergedManifest, summaries, loadedPages, pageDescriptors, summariesReady], + [ + mergedManifest, + summaries, + loadedPages, + pageDescriptors, + summariesReady, + summariesError, + ], ); - // origin → accent lookup for provenance in results. + // origin → accent / title lookups for provenance in results. const accentByOrigin = useMemo(() => { const m = new Map<string, string>(); for (const s of sites) if (s.accent) m.set(s.origin, s.accent); @@ -446,6 +675,32 @@ export function MultiSiteDataProvider({ (origin: string) => accentByOrigin.get(origin), [accentByOrigin], ); + const titleByOrigin = useMemo( + () => new Map(sites.map((s) => [s.origin, s.siteTitle])), + [sites], + ); + const siteTitleOf = useCallback( + (origin: string) => titleByOrigin.get(origin), + [titleByOrigin], + ); + + // Retry: refetch every query of this origin that ended in error — its + // manifest, failed pages, and any other feed of it that failed. + const retry = useCallback( + (origin: string) => { + void queryClient.refetchQueries({ + predicate: (q) => + FEDERATED_KEYS.has(String(q.queryKey[0])) && + q.queryKey[1] === origin && + q.state.status === "error", + }); + }, + [queryClient], + ); + const federation = useMemo<FederationState>( + () => ({ sites: siteStates, retry }), + [siteStates, retry], + ); const value = useMemo<SearchDataValue>( () => ({ @@ -461,6 +716,8 @@ export function MultiSiteDataProvider({ accentOf, aliases, curatedTags, + federation, + siteTitleOf, }), [ summariesState, @@ -472,6 +729,8 @@ export function MultiSiteDataProvider({ accentOf, aliases, curatedTags, + federation, + siteTitleOf, ], ); diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -190,6 +190,8 @@ export default function SearchResults() { </div> </div> + <FederationStatus /> + {view !== "chart" && resultGroups.length > 0 && ( <div data-testid="selection-toolbar" @@ -318,6 +320,54 @@ export default function SearchResults() { ); } +// Hub mode only: when an archive in scope failed to answer, one plain line +// above the results says how many did, names the ones that did not, and offers +// a Retry for them. The results from the archives that answered render as +// usual underneath — one member failing never empties the page. Nothing in +// single-site mode (no `federation`), nothing while every archive is fine, and +// nothing while any archive in scope is still loading (the chips show that), so +// "N of M" always names every archive it did not count. +function FederationStatus() { + const { federation } = useSearchData(); + if (!federation) return null; + const scoped = federation.sites.filter((s) => s.status !== "off"); + const failed = scoped.filter((s) => s.status === "failed"); + // Only once every archive in scope has settled, so the count is final and + // every archive not counted is named (the chips show what is still loading). + if (failed.length === 0 || scoped.some((s) => s.status === "loading")) { + return null; + } + const answered = scoped.filter((s) => s.status === "ready").length; + const names = failed.map((s) => s.siteTitle); + const list = + names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + return ( + <p + role="status" + data-testid="hub-scope-status" + className="mb-2 flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground" + > + <span> + {answered} of {scoped.length}{" "} + {scoped.length === 1 ? "archive" : "archives"} answered. {list}{" "} + did not, so{" "} + {names.length === 1 ? "its" : "their"} videos are not in these results. + </span> + <button + type="button" + onClick={() => { + for (const s of failed) federation.retry(s.origin); + }} + className="text-brand transition-colors hover:underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" + > + Retry + </button> + </p> + ); +} + // ─── Result rendering ──────────────────────────────────────────────────────── type ResultRow = | { kind: "card"; cardIndex: number; group: ResultGroup } @@ -633,6 +683,15 @@ const ResultCard = memo(function ResultCard({ </> )} <span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0"> + {/* Hub mode: the archive this came from, in text. */} + {group.source && ( + <> + <span data-testid="result-source" className="text-foreground/80"> + {group.source} + </span> + {" · "} + </> + )} {group.channel && `${group.channel} · `} {group.date} {group.hits.length > 0 && diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -158,6 +158,9 @@ export type ResultGroup = { // Provenance accent of the source origin (hub mode only); undefined // single-site, so no marker renders. accent?: string; + // The source archive's title (hub mode only), named on the card beside the + // channel so provenance is not carried by colour alone. Absent single-site. + source?: string; // Set on rows from the social-post corpus. A post is not a video: it has no // timeline to seek, no livestream/age state and no VOD expiry, so the result // card renders a distinct variant rather than a degraded video card. @@ -486,6 +489,7 @@ function useSearchSessionState() { groups: manifestGroups, channelKeyOf, accentOf, + siteTitleOf, } = useSearchData(); const { summaries, loadedPages, pageCount, summariesReady } = summariesState; @@ -873,6 +877,16 @@ function useSearchSessionState() { // those slugs that survived the query tree. For each, attach the per-leaf // hits collected by searchEval. Hits are already sorted by start time // within each video. + // `{ source }` for a hub result, `{}` single-site — so a single-site group + // carries no `source` key at all. + const sourceOf = useCallback( + (slug: string): { source?: string } => { + const title = siteTitleOf?.(splitId(slug).origin); + return title ? { source: title } : {}; + }, + [siteTitleOf], + ); + const resultGroups = useMemo<ResultGroup[]>(() => { if (!transcripts) return []; if (!hasActiveQuery) { @@ -897,6 +911,7 @@ function useSearchSessionState() { ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), + ...sourceOf(t.slug), }); } return out; @@ -924,6 +939,7 @@ function useSearchSessionState() { ? { curatedTags: t.curatedTags } : {}), accent: accentOf(splitId(t.slug).origin), + ...sourceOf(t.slug), }); } // Matched POST slugs. They are not in `transcripts` (a disjoint namespace), @@ -958,6 +974,7 @@ function useSearchSessionState() { uploadDate: post.uploadDate, hits: hitsBySlug.get(slug) ?? [], accent: accentOf(splitId(slug).origin), + ...sourceOf(slug), post, }); } @@ -973,6 +990,7 @@ function useSearchSessionState() { treeProgress, passesFilter, accentOf, + sourceOf, committedDateFrom, committedDateTo, committedStates, diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx @@ -0,0 +1,53 @@ +import { splitWordmark } from "../lib/brand"; + +// The two-weight wordmark: Archivo at `font-stretch: 118%`, the lead (the +// SUBJECT's name — "Jer", "Rekieta", "Archi") heavy in the foreground, the +// suffix light and muted. The split is configured (site.json `wordmarkLead`, +// PROJECT_WORDMARK_LEAD), never guessed; with no usable lead the whole title +// is the lead. +// +// The two spans are adjacent with NO whitespace between them and stay inline, +// so the enclosing link's accessible name is the title exactly ("Jeralyzer", +// not "Jer alyzer"). Do not make this element `flex`: that would blockify the +// spans and the browser would put a space between them in the name. +// +// Size, leading and truncation come from `className`. The face is Archivo +// wherever it is loaded: `--font-grotesk` names it until the themes slice makes +// Archivo the display face everywhere, after which the fallback is the same +// face and the first term can go. +export function Wordmark({ + title, + lead, + className, +}: { + title: string; + lead?: string; + className?: string; +}) { + const parts = splitWordmark(title, lead); + return ( + <span + className={className} + data-wordmark="" + style={{ + fontFamily: "var(--font-grotesk, var(--font-display))", + fontStretch: "118%", + }} + > + <span + data-wordmark-lead="" + style={{ fontWeight: 720, color: "var(--foreground)" }} + > + {parts.lead} + </span> + {parts.suffix ? ( + <span + data-wordmark-suffix="" + style={{ fontWeight: 380, color: "var(--muted-foreground)" }} + > + {parts.suffix} + </span> + ) : null} + </span> + ); +} diff --git a/common/lib/accent.ts b/common/lib/accent.ts @@ -99,9 +99,11 @@ export function resolveAccent(input: unknown): ResolvedAccent { } // The PUBLISHED accent: always a hex. An id becomes its on-dark value (the -// family's hub and homepage are dark, and the value is also the lit line of the -// site's icon); a custom hex is published as stored. Absent or malformed → -// undefined, so a publisher omits the key exactly as before. +// family's hub and homepage are dark, and for a NAMED accent that value is also +// the lit line of the site's icon); a custom hex is published as stored — its +// icon is lit with the dark-fitted resolveAccent().dark instead, which can +// differ. Absent or malformed → undefined, so a publisher omits the key exactly +// as before. export function accentHex(input: unknown): string | undefined { const setting = parseAccentSetting(input); if (!setting) return undefined; diff --git a/common/lib/archive/serviceWorkerRouting.test.ts b/common/lib/archive/serviceWorkerRouting.test.ts @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +// The service workers' CACHING STRATEGY, run for real: each worker file is +// evaluated in a node:vm context with a fake `self` / `caches` / `fetch`, and +// its fetch handler is driven with synthetic events. contract.test.ts pins the +// URL families these workers match; this pins what they DO with a match. +// +// The case that matters: /icons/* keeps its URL across a redeploy that changes +// what it draws (a site's icons are lit with its accent), so it must be +// network-first — a cache-first icon would outlive every accent change. +// /_next/static/* is content-hashed and stays cache-first. + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const ORIGIN = "https://archive.example"; + +type FakeResponse = { body: string; ok: boolean; clone(): FakeResponse }; + +function res(body: string): FakeResponse { + return { body, ok: true, clone: () => res(body) }; +} + +// One worker, loaded fresh: returns a `request(path, {online})` that dispatches a +// GET fetch event and resolves to the served body, plus what the network saw +// and a way to seed the caches. +function loadWorker(file: string) { + const handlers: Record<string, (e: unknown) => void> = {}; + const stores = new Map<string, Map<string, FakeResponse>>(); + const store = (name: string) => { + if (!stores.has(name)) stores.set(name, new Map()); + return stores.get(name)!; + }; + let online = true; + let network = ""; + const fetched: string[] = []; + const keyOf = (r: string | { url: string }) => + new URL(typeof r === "string" ? r : r.url, ORIGIN).href; + const context = { + self: { + addEventListener: (type: string, fn: (e: unknown) => void) => { + handlers[type] = fn; + }, + location: { origin: ORIGIN }, + skipWaiting: () => {}, + clients: { claim: async () => {} }, + }, + caches: { + open: async (name: string) => ({ + match: async (r: string | { url: string }) => store(name).get(keyOf(r)), + put: async (r: string | { url: string }, v: FakeResponse) => { + store(name).set(keyOf(r), v); + }, + }), + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + }, + fetch: async (r: { url: string }) => { + fetched.push(new URL(r.url).pathname); + if (!online) throw new TypeError("Failed to fetch"); + return res(network); + }, + Response: { error: () => ({ body: "<error>", ok: false }) }, + URL, + console, + }; + vm.runInNewContext(readFileSync(path.join(REPO, file), "utf8"), context, { + filename: file, + }); + assert.equal(typeof handlers.fetch, "function", `${file} registers no fetch handler`); + + return { + fetched, + seed: (cacheName: string, p: string, body: string) => + store(cacheName).set(new URL(p, ORIGIN).href, res(body)), + cached: (cacheName: string, p: string) => store(cacheName).get(new URL(p, ORIGIN).href)?.body, + request: async (p: string, opts: { online: boolean; network?: string }) => { + online = opts.online; + network = opts.network ?? ""; + let served: Promise<FakeResponse> | undefined; + handlers.fetch({ + request: { method: "GET", url: new URL(p, ORIGIN).href, mode: "no-cors", destination: "image" }, + respondWith: (p2: Promise<FakeResponse>) => { + served = p2; + }, + }); + assert.ok(served, `${p}: the worker did not answer`); + return (await served).body; + }, + }; +} + +for (const file of ["export/service-worker/site-sw.js", "export/service-worker/sw-hub.js"]) { + test(`${file}: /icons/ is network-first in shell-v2 — a redeployed accent reaches readers`, async () => { + const sw = loadWorker(file); + sw.seed("shell-v2", "/icons/icon.svg", "old mark"); + // Online: the fresh icon wins over the cached one, and replaces it. + assert.equal(await sw.request("/icons/icon.svg", { online: true, network: "new mark" }), "new mark"); + assert.deepEqual(sw.fetched, ["/icons/icon.svg"]); + assert.equal(sw.cached("shell-v2", "/icons/icon.svg"), "new mark"); + // Offline: the cached icon is still served. + assert.equal(await sw.request("/icons/icon.svg", { online: false }), "new mark"); + }); + + test(`${file}: /_next/static/ stays cache-first`, async () => { + const sw = loadWorker(file); + sw.seed("shell-v2", "/_next/static/chunks/app.js", "cached chunk"); + assert.equal( + await sw.request("/_next/static/chunks/app.js", { online: true, network: "network chunk" }), + "cached chunk", + ); + assert.deepEqual(sw.fetched, []); + }); +} diff --git a/common/lib/brandIconFiles.ts b/common/lib/brandIconFiles.ts @@ -0,0 +1,59 @@ +// THE ICON SET, AS DATA — which files exist under /icons/, how <head> links +// them, which sizes /favicon.ico packs, and which palette a site's icons are lit +// with. PURE (no `next/og`, no I/O), so the layouts, the export header and the +// manifest can read it without pulling the renderer into every page's server +// graph. The renderers live in lib/brandIcons.ts, which only the icon and +// favicon route handlers import. + +import { childIconPalette, type IconPalette, type MarkVariant } from "./brand"; +import { resolveAccent } from "./accent"; + +export type IconFile = + | { file: string; format: "svg"; variant: MarkVariant; contentType: "image/svg+xml" } + | { file: string; format: "png"; variant: MarkVariant; size: number; contentType: "image/png" }; + +// Every file under /icons/, in the order the manifest and <head> want them. +// The URLs are a contract: the manifest, both layouts' `icons` metadata, the +// service workers' `/icons/` network-first rule, the homepage's OG image and +// installed PWAs all name them. +export const ICON_FILES: ReadonlyArray<IconFile> = [ + { file: "icon.svg", format: "svg", variant: "any", contentType: "image/svg+xml" }, + { file: "maskable.svg", format: "svg", variant: "maskable", contentType: "image/svg+xml" }, + { file: "icon-32.png", format: "png", variant: "any", size: 32, contentType: "image/png" }, + { file: "icon-192.png", format: "png", variant: "any", size: 192, contentType: "image/png" }, + { file: "icon-512.png", format: "png", variant: "any", size: 512, contentType: "image/png" }, + { file: "maskable-512.png", format: "png", variant: "maskable", size: 512, contentType: "image/png" }, + { file: "apple-touch-icon.png", format: "png", variant: "apple", size: 180, contentType: "image/png" }, +]; + +// The <head> icon links, shared by the export and homepage layouts: the SVG +// first (every current browser takes it, and it stays sharp at any size), the +// 32 px PNG for tabs that will not, the two app sizes, and the touch icon. +// /favicon.ico is served too, but is not linked. (Not typed with Next's +// `Metadata`: importing the `next` root types into common adds Next's global +// ProcessEnv augmentation to every common test's program.) +export const ICON_METADATA = { + icon: [ + { url: "/icons/icon.svg", type: "image/svg+xml" }, + { url: "/icons/icon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], +}; + +export function iconFile(name: string): IconFile | undefined { + return ICON_FILES.find((f) => f.file === name); +} + +// The sizes packed into /favicon.ico. Browsers that still ask for it pick the +// entry nearest their tab size. +export const FAVICON_SIZES = [16, 32, 48] as const; + +// The palette an archive site's icons are lit with: the child ground, and its +// accent's on-dark value (an absent accent reads as Signal; a custom hex is +// fitted to the dark ground). The hub, homepage and editor use +// ICON_PALETTES.archilyzer instead. +export function siteIconPalette(accent: unknown): IconPalette { + return childIconPalette(resolveAccent(accent).dark); +} diff --git a/common/lib/brandIcons.test.ts b/common/lib/brandIcons.test.ts @@ -0,0 +1,201 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { inflateSync } from "node:zlib"; +import { ACCENTS, ICON_PALETTES, markSvg } from "./brand"; +import { resolveAccent } from "./accent"; +import { + FAVICON_SIZES, + ICON_FILES, + ICON_METADATA, + iconFile, + siteIconPalette, +} from "./brandIconFiles"; +import { pngToIco, renderFaviconIco, renderIconFile, renderIconPng } from "./brandIcons"; + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +// A PNG's first chunk is IHDR: length(4) "IHDR"(4) width(4) height(4), big-endian. +function ihdr(png: Uint8Array): { width: number; height: number } { + assert.deepEqual([...png.subarray(0, 8)], PNG_MAGIC, "PNG magic"); + assert.equal(Buffer.from(png.subarray(12, 16)).toString("latin1"), "IHDR"); + const v = new DataView(png.buffer, png.byteOffset, png.byteLength); + return { width: v.getUint32(16), height: v.getUint32(20) }; +} + +// The RGBA of pixel (0, 0). Every PNG filter predicts the first pixel of the +// first scanline from zeros, so after inflating the IDAT stream its bytes are +// the pixel as stored, whichever filter the encoder chose — no unfiltering. +function topLeftPixel(png: Uint8Array): number[] { + const b = Buffer.from(png); + assert.equal(b[24], 8, "bit depth 8"); + assert.equal(b[25], 6, "colour type 6 (RGBA)"); + const idat: Buffer[] = []; + for (let off = 8; off < b.length; ) { + const len = b.readUInt32BE(off); + if (b.toString("latin1", off + 4, off + 8) === "IDAT") idat.push(b.subarray(off + 8, off + 8 + len)); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + return [...raw.subarray(1, 5)]; // byte 0 is the scanline's filter type +} + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +test("ICON_FILES: the seven URLs the manifest, <head>, OG image and PWAs name", () => { + assert.deepEqual( + ICON_FILES.map((f) => f.file), + [ + "icon.svg", + "maskable.svg", + "icon-32.png", + "icon-192.png", + "icon-512.png", + "maskable-512.png", + "apple-touch-icon.png", + ], + ); + const sizes = Object.fromEntries( + ICON_FILES.flatMap((f) => (f.format === "png" ? [[f.file, f.size]] : [])), + ); + assert.deepEqual(sizes, { + "icon-32.png": 32, + "icon-192.png": 192, + "icon-512.png": 512, + "maskable-512.png": 512, + "apple-touch-icon.png": 180, + }); + assert.equal(iconFile("maskable-512.png")?.variant, "maskable"); + assert.equal(iconFile("apple-touch-icon.png")?.variant, "apple"); + assert.equal(iconFile("favicon.ico"), undefined); +}); + +test("ICON_METADATA links only files the routes serve, SVG first", () => { + const served = new Set(ICON_FILES.map((f) => `/icons/${f.file}`)); + const links = [...ICON_METADATA.icon, ...ICON_METADATA.apple]; + for (const l of links) assert.ok(served.has(l.url), l.url); + assert.deepEqual(ICON_METADATA.icon[0], { url: "/icons/icon.svg", type: "image/svg+xml" }); + for (const l of ICON_METADATA.icon.slice(1)) { + const f = iconFile(l.url.slice("/icons/".length)); + assert.ok(f && f.format === "png"); + assert.equal(l.sizes, `${f.size}x${f.size}`); + } +}); + +test("siteIconPalette: the child ground, lit by the accent's on-dark value", () => { + assert.deepEqual(siteIconPalette("brass"), { + ground: ICON_PALETTES.child.ground, + dim: ICON_PALETTES.child.dim, + lit: ACCENTS.brass.onDark, + }); + // Absent or malformed reads as Signal. + assert.equal(siteIconPalette(undefined).lit, ACCENTS.signal.onDark); + assert.equal(siteIconPalette("not-a-colour").lit, ACCENTS.signal.onDark); + // A custom hex is lit with its dark-ground fit, the value the header uses. + assert.equal(siteIconPalette("#cc3366").lit, resolveAccent("#cc3366").dark); + assert.equal(siteIconPalette("#cc3366").lit, "#d14574"); +}); + +test("pngToIco: ICONDIR, one ICONDIRENTRY per image, then the payloads verbatim", () => { + const a = Uint8Array.from([1, 2, 3]); + const b = Uint8Array.from([4, 5, 6, 7, 8]); + const ico = pngToIco([ + { size: 16, png: a }, + { size: 256, png: b }, + ]); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + // 00 00 01 00: reserved, type 1 (icon); then the count. + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), 2); + assert.equal(ico.byteLength, 6 + 16 * 2 + a.byteLength + b.byteLength); + // Entry 1: 16×16, 1 plane, 32 bpp, 3 bytes at offset 38. + assert.deepEqual([ico[6], ico[7], ico[8], ico[9]], [16, 16, 0, 0]); + assert.equal(v.getUint16(10, true), 1); + assert.equal(v.getUint16(12, true), 32); + assert.equal(v.getUint32(14, true), 3); + assert.equal(v.getUint32(18, true), 38); + // Entry 2: 256 is written as 0; its payload follows the first. + assert.deepEqual([ico[22], ico[23]], [0, 0]); + assert.equal(v.getUint32(30, true), 5); + assert.equal(v.getUint32(34, true), 41); + assert.deepEqual([...ico.subarray(38, 41)], [...a]); + assert.deepEqual([...ico.subarray(41)], [...b]); + assert.throws(() => pngToIco([{ size: 0, png: a }]), /1\.\.256/); + assert.throws(() => pngToIco([{ size: 257, png: a }]), /1\.\.256/); +}); + +test("renderIconPng: a PNG of the requested size, per variant", async () => { + for (const [variant, size] of [ + ["any", 32], + ["any", 192], + ["maskable", 512], + ["apple", 180], + ] as const) { + const png = await renderIconPng(ICON_PALETTES.archilyzer, { variant, size }); + assert.deepEqual(ihdr(png), { width: size, height: size }, `${variant} ${size}`); + } +}); + +test("renderIconPng draws the variant: any has transparent corners, maskable and apple are full-bleed", async () => { + const p = ICON_PALETTES.archilyzer; + const ground = [0x15, 0x1b, 0x20, 255]; // #151b20, opaque + const any = await renderIconPng(p, { variant: "any", size: 512 }); + assert.equal(topLeftPixel(any)[3], 0, "any: the rounded corner is transparent"); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "maskable", size: 512 })), ground); + assert.deepEqual(topLeftPixel(await renderIconPng(p, { variant: "apple", size: 180 })), ground); +}); + +test("renderFaviconIco: 16/32/48 PNG entries whose IHDR matches the directory", async () => { + const ico = await renderFaviconIco(siteIconPalette("violet")); + const v = new DataView(ico.buffer, ico.byteOffset, ico.byteLength); + assert.deepEqual([...ico.subarray(0, 4)], [0, 0, 1, 0]); + assert.equal(v.getUint16(4, true), FAVICON_SIZES.length); + FAVICON_SIZES.forEach((size, i) => { + const e = 6 + 16 * i; + assert.equal(ico[e], size); + assert.equal(ico[e + 1], size); + const len = v.getUint32(e + 8, true); + const off = v.getUint32(e + 12, true); + assert.deepEqual(ihdr(ico.subarray(off, off + len)), { width: size, height: size }); + }); +}); + +test("renderIconFile: SVGs are markSvg verbatim, PNGs are PNGs, anything else is null", async () => { + const palette = siteIconPalette("green"); + const svg = await renderIconFile("icon.svg", palette); + assert.deepEqual(svg, { body: markSvg(palette), contentType: "image/svg+xml" }); + const maskable = await renderIconFile("maskable.svg", palette); + assert.equal(maskable?.body, markSvg(palette, { variant: "maskable" })); + const apple = await renderIconFile("apple-touch-icon.png", palette); + assert.equal(apple?.contentType, "image/png"); + assert.ok(apple && typeof apple.body !== "string"); + assert.deepEqual(ihdr(apple.body as Uint8Array), { width: 180, height: 180 }); + assert.equal(await renderIconFile("icon-64.png", palette), null); + assert.equal(await renderIconFile("../site.json", palette), null); +}); + +// next/og belongs to the icon and favicon routes alone: a page, layout or +// header that imported lib/brandIcons would put the renderer in every page's +// server graph. The pure data is lib/brandIconFiles.ts, which must not import +// next at all. +test("only the icon and favicon route handlers import lib/brandIcons; brandIconFiles imports no next", () => { + const importers: string[] = []; + for (const app of ["export/app", "homepage/app", "editor/app"]) { + for (const rel of readdirSync(path.join(REPO, app), { recursive: true }) as string[]) { + if (!/\.tsx?$/.test(rel)) continue; + const src = readFileSync(path.join(REPO, app, rel), "utf8"); + if (src.includes('lib/brandIcons"')) importers.push(`${app}/${rel}`); + } + } + assert.deepEqual(importers.sort(), [ + "export/app/favicon.ico/route.ts", + "export/app/icons/[file]/route.ts", + "homepage/app/favicon.ico/route.ts", + "homepage/app/icons/[file]/route.ts", + ]); + const pure = readFileSync(path.join(REPO, "common/lib/brandIconFiles.ts"), "utf8"); + assert.doesNotMatch(pure, /from "next/); + assert.doesNotMatch(pure, /from "\.\/brandIcons"/); +}); diff --git a/common/lib/brandIcons.ts b/common/lib/brandIcons.ts @@ -0,0 +1,108 @@ +// THE ICON FILES — every favicon, app icon and touch icon the family ships, +// rendered from the one mark (lib/brand.ts markSvg) at build time instead of +// committed as binaries. A site's icons are lit with its own accent, so they +// cannot be committed once for everyone. +// +// Served by a static GET route handler in each app (export/app/icons/[file], +// homepage/app/icons/[file], and a favicon.ico route beside them). Under +// `output: "export"` Next copies such a handler's body byte-for-byte to its +// exact path (next/dist/export/index.js, the `.body` branch), so +// `out/icons/icon-192.png` is a real PNG at a stable URL — `app/icon.tsx` +// cannot do this: its URLs are always hashed. +// +// SERVER-ONLY, and imported ONLY by those route handlers: `next/og` (satori + +// resvg, bundled with Next) renders the PNGs on the node runtime. The SVG goes +// in as an <img> data URI, so the raster is exactly markSvg's geometry — no +// second drawing of the mark to drift. The icon set as data (ICON_FILES, +// ICON_METADATA, FAVICON_SIZES, siteIconPalette) is lib/brandIconFiles.ts, which +// is pure, so the layouts and the header never load this module. + +import { createElement } from "react"; +import { ImageResponse } from "next/og"; +import { markSvg, type IconPalette, type MarkVariant } from "./brand"; +import { FAVICON_SIZES, iconFile } from "./brandIconFiles"; + +export function svgDataUri(svg: string): string { + return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; +} + +// The mark as a PNG of `size` × `size`. satori lays out one <img> of the SVG; +// resvg rasterises it. +export async function renderIconPng( + palette: IconPalette, + opts: { variant?: MarkVariant; size: number }, +): Promise<Uint8Array> { + const { size } = opts; + const src = svgDataUri(markSvg(palette, { variant: opts.variant ?? "any" })); + const res = new ImageResponse( + createElement("img", { src, width: size, height: size, alt: "" }), + { width: size, height: size }, + ); + return new Uint8Array(await res.arrayBuffer()); +} + +// A PNG-payload ICO: the 6-byte ICONDIR, one 16-byte ICONDIRENTRY per image, +// then the PNG files verbatim (Vista+ and every current browser read PNG +// entries). A width or height of 256 is written as 0, per the format. +export function pngToIco(images: ReadonlyArray<{ size: number; png: Uint8Array }>): Uint8Array { + const HEADER = 6; + const ENTRY = 16; + const total = + HEADER + ENTRY * images.length + images.reduce((n, i) => n + i.png.byteLength, 0); + const out = new Uint8Array(total); + const view = new DataView(out.buffer); + view.setUint16(0, 0, true); // reserved + view.setUint16(2, 1, true); // type 1 = icon + view.setUint16(4, images.length, true); + let offset = HEADER + ENTRY * images.length; + images.forEach(({ size, png }, i) => { + if (!Number.isInteger(size) || size < 1 || size > 256) { + throw new Error(`pngToIco: size must be 1..256, got ${size}`); + } + const e = HEADER + ENTRY * i; + view.setUint8(e, size === 256 ? 0 : size); // width + view.setUint8(e + 1, size === 256 ? 0 : size); // height + view.setUint8(e + 2, 0); // palette colours (none) + view.setUint8(e + 3, 0); // reserved + view.setUint16(e + 4, 1, true); // colour planes + view.setUint16(e + 6, 32, true); // bits per pixel + view.setUint32(e + 8, png.byteLength, true); // payload size + view.setUint32(e + 12, offset, true); // payload offset + out.set(png, offset); + offset += png.byteLength; + }); + return out; +} + +export async function renderFaviconIco(palette: IconPalette): Promise<Uint8Array> { + const images = await Promise.all( + FAVICON_SIZES.map(async (size) => ({ size, png: await renderIconPng(palette, { size }) })), + ); + return pngToIco(images); +} + +// One /icons/<file>'s bytes and type, or null for a name that is not in +// ICON_FILES. +export async function renderIconFile( + name: string, + palette: IconPalette, +): Promise<{ body: Uint8Array | string; contentType: string } | null> { + const f = iconFile(name); + if (!f) return null; + if (f.format === "svg") { + return { body: markSvg(palette, { variant: f.variant }), contentType: f.contentType }; + } + return { + body: await renderIconPng(palette, { variant: f.variant, size: f.size }), + contentType: f.contentType, + }; +} + +// A route handler's Response for a rendered body. Revalidating, because a +// site's accent (and so its icons) can change between deploys at the same URL. +// In a static export only the body survives; the host sets the real headers. +export function iconResponse(body: Uint8Array | string, contentType: string): Response { + return new Response(typeof body === "string" ? body : new Blob([body as BlobPart]), { + headers: { "content-type": contentType, "cache-control": "public, max-age=0, must-revalidate" }, + }); +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -2,7 +2,9 @@ ## [Unreleased] - **Every page now has a ground and an accent to choose, and the five theme families are gone.** The theme menu (the palette button beside the quick toggle, in the editor's sidebar and in the header of every published site, the hub and the homepage) has two groups. **Base** is System, Light, Sepia or Dark; Sepia is new, a warm paper ground for long reading. **Accent** is Signal, Brass, Vermilion, Violet, Sakura, Blue or Green, with the site's own tagged *default*; a site with a custom hex offers it first as *Site colour*. The quick toggle cycles System → Light → Sepia → Dark. A published site opens on the reader's system setting, in the accent its site form sets. The hub and the homepage open on Dark, in Signal, and the editor follows the system, in Signal. Each accent has a value for each ground that reads at 4.5:1, and a custom hex is darkened or lightened per ground to match. A reader's accent is remembered only while it differs from the site's: picking the site's own again forgets it, so the reader follows the site if its accent changes later. Base, Archive, Selenized, Swiss and Archilyzer are gone. A choice made before this update carries over once: light stays light (Archive light becomes Sepia), dark stays dark and system stays system; the family itself is dropped. Headings are Archivo, text is IBM Plex Sans and figures are IBM Plex Mono everywhere, with one corner radius. Chart colours are fixed per ground and never follow the accent; the third is a violet, well clear of the red that marks a recording as gone. The phone's browser bar takes the page's ground, not the accent. Needs a rebuild and deploy of every site, the hub and the homepage. +- **Every site, the hub, the homepage and the editor wear the new Found-line mark, and a site's header splits its wordmark.** The mark is four transcript lines on a rounded square, the second lit and carrying a play head. The favicon, app icons and touch icon are no longer committed files: each build draws them from the mark and writes `/icons/icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`, `maskable-512.png`, `apple-touch-icon.png` and `/favicon.ico` (16, 32 and 48 px). A site's icons are an ink tile lit with its own accent (Signal when it sets none; a custom colour is lightened until it reads on the tile). The hub, the homepage and the editor use the parent mark, bone on slate. The site header shows the mark and the header title split at the site's **Wordmark lead**, the lead heavy and the rest light (Jer|alyzer); with no lead the whole title is heavy. The header mark's lit line will follow the reader's accent once the theme picker lands; the favicon keeps the site's. The footer's "Built with Archilyzer" has the small parent mark in front of it, outside the link. The homepage header shows the parent mark and "Archi|lyzer", no longer in spaced capitals. An installed site's title bar is the dark theme's ground (`#0c0a08`) and its splash the icon's tile, where it used to fall back to blue for a site with a named accent. The service workers' shell cache is renamed to `shell-v2`, so an installed app drops the old icons; readers' offline channel downloads are kept. The editor's sidebar shows the parent mark beside the admin title, and the editor now has a favicon. Needs a rebuild and deploy of every site, the hub and the homepage. - **A site's accent is one of seven named colours or a custom one, and a site can split its wordmark.** The site form's **Brand accent** is now a row of swatches: Signal (the family default), Brass, Vermilion, Violet, Sakura, Blue and Green, plus **Custom**, whose colour goes in the **Custom hex** field (typing there picks Custom). `site.json` stores the accent's id (`"accent": "brass"`) or the hex. Picking Signal stores no key, because no key means Signal. A custom colour is darkened or lightened for each reading theme until it reads at 4.5:1; the seven named colours already do. A new **Wordmark lead** field names the heavy first part of the header wordmark, e.g. `Jer` for Jeralyzer. The form refuses a lead that is not how the header title starts, and `site.json` keeps `wordmarkLead` only when it is. What other hubs read does not change: `/site.json`, the hub's `hub-sites.json` and the homepage summary still carry a hex, and an id goes out as its colour on dark. `SITE.md` lists both keys. The header, icons and theme picker that use them come with the rest of the brand work; until then a site with a named accent is tinted with that colour, as a custom hex is today. +- **The hub's search shows each archive's state, lets you choose which archives to search, and no longer waits for the slowest.** Under the line "Searching N archives …" is a row of chips, one per archive on the hub (official and added). Each says whether that archive is loading, how many videos it has in the search once it is in, or that it failed, with a Retry beside it. Pressing a chip takes that archive out of the search: nothing more is fetched from it and its results disappear. The choice is kept in this browser, and an archive it has never seen is searched. The search now runs as soon as one archive has loaded and runs again as each further one arrives, where it used to wait for all of them. When an archive does not answer, a line above the results says so once the others have loaded ("4 of 5 archives answered. Hasanalyzer did not, so its videos are not in these results.") with a Retry, and the other archives' results show as usual. None of that archive's videos, posts or live chat are searched until a Retry succeeds; before, it dropped out silently or in part. Each result names its archive in text before the channel ("Jeralyzer · TheQuartering · 2026-09-25"). The hub now loads at most six summaries pages at a time from each archive, so a large archive does not hold up the small ones. The line under the archives now counts "videos", as the chips do. `/ask` on the hub is unchanged: it searches every archive and waits for all of them. Published sites are unchanged. Needs a rebuild and deploy of the hub. - **The hub and the homepage drop their subtitles, and both carry a Ko-fi link.** The headings are in Title Case on both pages: **Official Instances**, **Archives You Added** (hub) and **What It Does** (homepage). The line "The archives I run. Anyone can run their own." is gone from both, and the official-instance cards no longer show the site's description under its name; the four figures stay. The footer of the homepage and of the hub has a plain link reading "Ko-fi" to `https://ko-fi.com/archilyzer`. A published site's footer does not: an archive someone else hosts never carries it. Needs a rebuild and deploy of the hub and the homepage. - **A job that waited in a queue no longer ends `failed` after doing its work.** A sync, download or other job queued behind another on the same platform ran its final page refresh outside any request, where Next refuses it, so the job read `failed` and `pnpm ops … --wait` exited 1 even though the work was done (the teamrcn sync on 2026-09-25). The refresh is now skipped there with one warning in the server log; the pages re-read disk on their next load anyway. - **Downloads no longer sleep after a video the download filter declined.** The "sleep between downloads" (30 s by default) ran after every video, including each one the channel's download filter declined before fetching anything. A filtered channel's download-missing slept 193 times for 14 downloads on 2026-09-25. It still sleeps after every real fetch and after every failure, per-video ones included. diff --git a/editor/app/icon.svg b/editor/app/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#151b20"/><rect x="112" y="128" width="288" height="44" rx="22" fill="#3f4c56"/><polygon points="112,210 172,234 112,258" fill="#e7edf1"/><rect x="188" y="212" width="212" height="44" rx="22" fill="#e7edf1"/><rect x="112" y="296" width="232" height="44" rx="22" fill="#3f4c56"/><rect x="112" y="380" width="152" height="44" rx="22" fill="#3f4c56"/></svg> diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -12,6 +12,8 @@ import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript"; import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { AppFrame } from "./components/AppFrame"; import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; @@ -131,15 +133,23 @@ export default async function RootLayout({ sidebar={ <aside className="md:w-56 md:shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-border bg-card flex flex-col"> <div className="px-3 py-2.5 md:py-3 md:border-b md:border-border flex items-start justify-between gap-2"> - <div className="min-w-0"> - <Link - href="/" - className="font-display text-base font-semibold tracking-tight block leading-tight" - > - {headerLabel} - </Link> - <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground mt-0.5"> - editor + <div className="min-w-0 flex items-center gap-2.5"> + {/* The parent mark, beside the operator's title — which is + NOT split: adminTitle is free text, not a wordmark. */} + <BrandMark + palette={ICON_PALETTES.archilyzer} + className="size-8 shrink-0" + /> + <div className="min-w-0"> + <Link + href="/" + className="font-display text-base font-semibold tracking-tight block leading-tight" + > + {headerLabel} + </Link> + <div className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground mt-0.5"> + editor + </div> </div> </div> <div className="flex items-center gap-1.5 shrink-0"> diff --git a/editor/app/lib/brandIcon.test.ts b/editor/app/lib/brandIcon.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { ICON_PALETTES, markSvg } from "yt-dlp-transcript-common/lib/brand"; + +// editor/app/icon.svg is the editor's favicon (Next's app/icon.svg file +// convention links it from every page). It is a static file because the +// editor's icon never changes per install — it is the parent mark — but it +// must stay the mark: markSvg is the geometry, this file a copy of it. +// Regenerate with markSvg(ICON_PALETTES.archilyzer) if this fails. +test("app/icon.svg is markSvg(ICON_PALETTES.archilyzer), byte for byte", () => { + const file = fileURLToPath(new URL("../icon.svg", import.meta.url)); + assert.equal( + readFileSync(file, "utf8").trimEnd(), + markSvg(ICON_PALETTES.archilyzer), + ); +}); diff --git a/editor/e2e/branding.spec.ts b/editor/e2e/branding.spec.ts @@ -35,3 +35,32 @@ test("reflects sidebar header after saving via the settings form", async ({ page.locator("aside").getByRole("link", { name: "Saved Admin" }), ).toBeVisible(); }); + +test("the sidebar carries the parent mark beside adminTitle, and the favicon is the mark", async ({ + page, + request, +}) => { + await writeSettings({ + adminTitle: "Cypress HQ", + maxTranscriptPageBytes: 8388608, + sleepBetweenDownloadsSeconds: 0, + }); + await page.goto("/"); + const aside = page.locator("aside"); + // Decorative, and NOT inside the title link: its name stays adminTitle. + const mark = aside.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + await expect(mark).toBeVisible(); + await expect( + aside.getByRole("link", { name: "Cypress HQ", exact: true }), + ).toBeVisible(); + // app/icon.svg (Next's file convention) is linked from <head>. + const href = await page + .locator('head link[rel="icon"][type="image/svg+xml"]') + .getAttribute("href"); + expect(href).toMatch(/^\/icon\.svg/); + const svg = await (await request.get(href as string)).text(); + expect(svg).toContain('fill="#151b20"'); + expect(svg).toContain('fill="#e7edf1"'); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - **A reader picks a ground and an accent; the five theme families are gone.** The header's theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (Signal, Brass, Vermilion, Violet, Sakura, Blue, Green, with the site's own tagged *default*, and *Site colour* first on a site with a custom hex). The toggle beside it cycles System → Light → Sepia → Dark; on a phone both lists are in the menu sheet. A site opens on the reader's system setting in the accent from its `site.json`, rendered as `<html data-accent>` so it is right before any script runs; the hub opens on Dark in Signal. A reader's accent is stored only while it differs from the site's. A stored theme from before carries over once (Archive light becomes Sepia) and the old keys are deleted. Type is Archivo, IBM Plex Sans and IBM Plex Mono; charts have fixed colours per ground; the browser bar follows the ground (a light/dark pair for a site, dark for the hub). +- **The Found-line mark, and a split wordmark.** The site header's rotated square is now the family mark — four transcript lines on an ink tile, the second lit with the site's accent and carrying a play head — and the header title splits at the site's `wordmarkLead` (heavy lead, light rest: Jer|alyzer). The hub wears the parent mark, bone on slate, and splits as Archi|lyzer. The footer credit has the small parent mark before "Built with"; the link's name is still exactly "Archilyzer". The icons are no longer committed: `app/icons/[file]/route.ts` and `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build (static export writes `out/icons/*` and `out/favicon.ico`), lit with the site's accent. `<head>` links `icon.svg` first, then `icon-32.png`, 192, 512 and the touch icon. The manifest lists `icon.svg` (sizes "any"); its `theme_color` is the dark base's ground `#0c0a08` and its `background_color` the icon's tile. Both service workers rename only their shell cache (`shell-v2`) so installed apps drop the old icons; the data caches, and readers' offline downloads, are kept. ## [0.8.7] - 2026-08-12 - **Every archive now says what built it, and stopped shipping its own copy of the source.** The footer carried a `code.tar.gz` link on every site — `create-archives.sh` wrote a tarball into `export/public/` and each deployed archive served its own duplicate of the whole workspace (verified live: `200 application/gzip` on jeralyzer). That link is **gone**, replaced by **"Built with [Archilyzer](https://archilyzer.pages.dev)"** beside the social links. The source is now published once, centrally, with a checksum and a commit — rather than N times, undated, with no way to tell two copies apart. The back-link is deliberately **ungated by instance mode** (a hub is Archilyzer too, and a credit that appeared on sites but not hubs would make them look like different software), **independent of any configured hub URL** (that is the operator's family link, a different relationship), and opens **in the same tab**, matching the header's hub link — only social icons open new ones. *"Built with"* sits outside the anchor so the accessible name is exactly `Archilyzer`. One knock-on: the **Downloads** eyebrow used to be unconditional because that link always followed it; it now appears only when the transcript-archive or offline link is actually there, instead of captioning empty space. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -12,6 +12,8 @@ import { PROJECT_NAME, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; @@ -79,15 +81,19 @@ export default function Footer() { different thing), and NOT target="_blank": the header's hub link navigates in the same tab, and only the social icons open new ones. "Built with" sits OUTSIDE the anchor so the accessible name is - exactly "Archilyzer". */} - <span className="text-xs"> - Built with{" "} - <a - href={PROJECT_URL} - className="underline underline-offset-2 hover:text-foreground transition-colors" - > - {PROJECT_NAME} - </a> + exactly "Archilyzer" — and so does the parent mark before it, + which is decorative (aria-hidden) and the same on every site. */} + <span className="inline-flex items-center gap-1.5 text-xs"> + <BrandMark palette={ICON_PALETTES.archilyzer} className="size-4 shrink-0" /> + <span> + Built with{" "} + <a + href={PROJECT_URL} + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + {PROJECT_NAME} + </a> + </span> </span> {/* Ko-fi: a bare link, no copy — and on the HUB only. The hub is the project's own deployment; a site build is an archive whoever runs diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -8,18 +8,23 @@ import { } from "yt-dlp-transcript-common/lib/site"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { currentSite } from "../lib/site"; +import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; import { hasDuplicates } from "../lib/duplicates"; import SiblingSwitcher from "./SiblingSwitcher"; import MobileMenu from "./MobileMenu"; -// The export site's masthead: a brand-colored mark + wordmark, calm sans nav, -// and the cross-site "family" chrome (hub backlink + sibling switcher). The -// wordmark and mark track the active theme — sans + blue under the default base -// family, serif + brass under Archive. All cross-site data is resolved at build -// time from the pool, so single-site installs simply render neither. +// The export site's masthead: the Found-line mark + the split wordmark, calm +// sans nav, and the cross-site "family" chrome (hub backlink + sibling +// switcher). The mark's lit line follows the reader's accent (lib/brand.ts +// headerMarkPalette); the hub wears the parent mark. The link's accessible name +// is the header title exactly — the mark is decorative and the wordmark's two +// spans join without a space. All cross-site data is resolved at build time +// from the pool, so single-site installs simply render neither. export default function Header() { const site = currentSite(); const settings = getSettings(); @@ -65,11 +70,13 @@ export default function Header() { put the overflow rows OUTSIDE the sticky header's background at phone widths, and the page scrolled through them. */} <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap"> - <Link href="/" className="group flex min-w-0 items-center gap-2.5"> - <span className="h-2.5 w-2.5 shrink-0 rotate-45 rounded-[3px] bg-brand shadow-[0_0_14px_var(--brand)] transition-colors group-hover:bg-brand-strong" /> - <span className="truncate font-display text-[1.35rem] font-semibold leading-none tracking-tight text-foreground"> - {site.headerTitle} - </span> + <Link href="/" className="flex min-w-0 items-center gap-2.5"> + <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" /> + <Wordmark + title={site.headerTitle} + lead={site.wordmarkLead} + className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]" + /> </Link> <nav className="hidden md:flex items-center gap-4 text-sm font-medium"> diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx @@ -19,6 +19,8 @@ import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry"; import ArchiveShelf from "./ArchiveShelf"; import HubStats from "./HubStats"; import HubOfflineManager from "./HubOfflineManager"; +import HubScope from "./HubScope"; +import { useHubScope } from "./useHubScope"; // `transcriptDownloads` comes from the server parent's currentSite() (a client // component cannot read site.json): false hides the modal's per-video export @@ -29,24 +31,34 @@ export default function HubHome({ transcriptDownloads?: boolean; }) { const { sites } = useRegistry(); + // Which archives the search covers (every one unless this browser switched + // it off with its chip). + const { isOn, toggle } = useHubScope(); - // Carry each site's accent through to the merged search for provenance. + // Carry each site's accent through to the merged search for provenance, and + // its scope: an archive switched off is not fetched at all. const federated = useMemo<FederatedSite[]>( () => sites.map((s) => ({ origin: s.origin, siteTitle: s.siteTitle, accent: s.accent, + enabled: isOn(s.origin), })), - [sites], + [sites, isOn], ); return ( <PlayerProvider features={{ transcriptDownloads }}> <div className="flex flex-col gap-6"> <ArchiveShelf /> - <MultiSiteDataProvider sites={federated}> - <HubStats /> + {/* progressive: the search runs as soon as one archive is in, and + re-runs as each further archive arrives. */} + <MultiSiteDataProvider sites={federated} progressive> + <div className="flex flex-col gap-3"> + <HubStats /> + <HubScope onToggle={toggle} /> + </div> <TranscriptSearch /> <HubOfflineManager /> </MultiSiteDataProvider> diff --git a/export/app/components/hub/HubScope.tsx b/export/app/components/hub/HubScope.tsx @@ -0,0 +1,104 @@ +"use client"; + +// The row of scope chips above the query builder: one per archive on the hub, +// official and added. Pressing a chip takes that archive in or out of the +// search (useHubScope remembers it per browser). Each chip says what its +// archive is doing — loading, its record count once ready (its summaries +// manifest's totalCount, the number the results header counts), or failed with +// a Retry beside it — so a slow or dead member is visible, not a silent gap. + +import { cn } from "yt-dlp-transcript-common/lib/utils"; +import { + useSearchData, + type FederatedSiteState, +} from "yt-dlp-transcript-common/components/SearchDataContext"; + +function detail(s: FederatedSiteState): string { + switch (s.status) { + case "off": + return "off"; + case "loading": + return "loading…"; + case "failed": + return "failed"; + case "ready": + return s.count === undefined ? "ready" : s.count.toLocaleString(); + } +} + +export default function HubScope({ + onToggle, +}: { + onToggle: (origin: string) => void; +}) { + const { federation } = useSearchData(); + if (!federation || federation.sites.length === 0) return null; + const { sites, retry } = federation; + + return ( + <div + role="group" + aria-label="Archives to search" + className="flex flex-wrap items-center gap-2" + > + {sites.map((s) => { + const on = s.status !== "off"; + return ( + <span + key={s.origin} + data-testid={`hub-scope-chip-${s.origin}`} + data-status={s.status} + className={cn( + "inline-flex min-w-0 items-stretch overflow-hidden rounded border text-sm transition-colors", + on ? "border-border-strong bg-card" : "border-border bg-transparent", + s.status === "failed" && "border-destructive/60", + )} + > + <button + type="button" + aria-pressed={on} + title={ + on + ? `${s.status === "ready" && s.count !== undefined ? `${s.count.toLocaleString()} videos from ${s.siteTitle} are in the search. ` : ""}Press to leave ${s.siteTitle} out.` + : `Press to search ${s.siteTitle} again.` + } + onClick={() => onToggle(s.origin)} + className={cn( + "flex min-w-0 items-center gap-1.5 px-2.5 py-1.5 select-none hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", + on ? "text-foreground" : "text-muted-foreground", + )} + > + {s.accent && ( + <span + aria-hidden="true" + className="inline-block size-2 shrink-0 rounded-full" + style={{ background: s.accent }} + /> + )} + <span className="truncate">{s.siteTitle}</span> + <span + className={cn( + "shrink-0 text-xs tabular-nums", + s.status === "failed" ? "text-destructive" : "text-muted-foreground", + )} + > + {detail(s)} + </span> + </button> + {s.status === "failed" && ( + <button + type="button" + onClick={() => retry(s.origin)} + aria-label={`Retry ${s.siteTitle}`} + title={s.error ? `${s.error} — try again` : "Try again"} + className="border-l border-border px-2.5 py-1.5 text-xs text-brand hover:bg-accent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" + > + Retry + </button> + )} + </span> + ); + })} + </div> + ); +} diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx @@ -5,7 +5,7 @@ // archive on the hub, the visitor's added ones included — so they are not the // cards' numbers and are not meant to match them. The official cards show the // build-time figures the homepage prints (hub-summary.json); this line shows -// what actually loaded. +// what actually loaded, from the archives this browser has in scope. import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry"; @@ -21,18 +21,23 @@ function part(n: number, one: string, many: string) { export default function HubStats() { const { sites } = useRegistry(); - const { summariesState, channels } = useSearchData(); - const transcripts = summariesState.manifest?.totalCount ?? 0; + const { summariesState, channels, federation } = useSearchData(); + // The archives in scope (the visitor's chips), and what has arrived from them: + // channels as each manifest lands, videos as each archive is ready. + const archives = + federation?.sites.filter((s) => s.status !== "off").length ?? sites.length; + // The ready archives' record counts — the same figures the chips show. + const videos = summariesState.manifest?.totalCount ?? 0; if (sites.length === 0) return null; return ( <p className="text-sm text-muted-foreground"> - Searching {part(sites.length, "archive", "archives")} + Searching {part(archives, "archive", "archives")} <span className="mx-2 text-muted-foreground/40">·</span> {part(channels.length, "channel", "channels")} <span className="mx-2 text-muted-foreground/40">·</span> - {part(transcripts, "transcript", "transcripts")} right now. + {part(videos, "video", "videos")} right now. </p> ); } diff --git a/export/app/components/hub/useHubScope.ts b/export/app/components/hub/useHubScope.ts @@ -0,0 +1,65 @@ +"use client"; + +// Which archives the hub's search covers, per browser. Every archive is in by +// default; the visitor takes one out with its scope chip. Stored as the set of +// origins switched OFF, so an archive the browser has never seen (a new +// official instance, a site added later) is searched until someone says not to. +// localStorage is a convenience here: when it is unavailable every archive is +// simply searched. + +import { useCallback, useEffect, useRef, useState } from "react"; + +const STORAGE_KEY = "ytdlp-tb:hub-scope"; + +function readOff(): ReadonlySet<string> { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : null; + const off = + parsed && typeof parsed === "object" && Array.isArray((parsed as { off?: unknown }).off) + ? (parsed as { off: unknown[] }).off + : []; + return new Set(off.filter((o): o is string => typeof o === "string")); + } catch { + return new Set(); + } +} + +function writeOff(off: ReadonlySet<string>) { + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ off: Array.from(off) }), + ); + } catch { + // Storage full / disabled — the choice still holds for this visit. + } +} + +export function useHubScope() { + // Read on the client's first render. The registry's server snapshot is empty, + // so nothing that depends on this renders during hydration. + const [off, setOff] = useState<ReadonlySet<string>>(readOff); + const first = useRef(true); + useEffect(() => { + if (first.current) { + first.current = false; + return; + } + writeOff(off); + }, [off]); + + const toggle = useCallback((origin: string) => { + setOff((prev) => { + const next = new Set(prev); + if (next.has(origin)) next.delete(origin); + else next.add(origin); + return next; + }); + }, []); + + const isOn = useCallback((origin: string) => !off.has(origin), [off]); + + return { isOn, toggle }; +} diff --git a/export/app/favicon.ico b/export/app/favicon.ico Binary files differ. diff --git a/export/app/favicon.ico/route.ts b/export/app/favicon.ico/route.ts @@ -0,0 +1,11 @@ +import { iconResponse, renderFaviconIco } from "yt-dlp-transcript-common/lib/brandIcons"; +import { iconPalette } from "../lib/brand"; + +// /favicon.ico: the mark at 16/32/48 in one PNG-payload ICO, for the browsers +// and crawlers that ask for it by name whatever <head> says. Static; written to +// out/favicon.ico at build. +export const dynamic = "force-static"; + +export async function GET() { + return iconResponse(await renderFaviconIco(iconPalette()), "image/x-icon"); +} diff --git a/export/app/icons/[file]/route.ts b/export/app/icons/[file]/route.ts @@ -0,0 +1,24 @@ +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; +import { iconPalette } from "../../lib/brand"; + +// /icons/<file>: the site's icon set, rendered from the one mark at build time +// (common/lib/brandIcons.ts). Static: under `output: "export"` each listed file +// is written to out/icons/<file> byte-for-byte, and nothing else resolves. +// Never reads the request. +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return ICON_FILES.map((f) => ({ file: f.file })); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ file: string }> }, +) { + const { file } = await params; + const out = await renderIconFile(file, iconPalette()); + if (!out) return new Response("Not found", { status: 404 }); + return iconResponse(out.body, out.contentType); +} diff --git a/export/app/layout.tsx b/export/app/layout.tsx @@ -8,6 +8,7 @@ import { resolveAccent, } from "yt-dlp-transcript-common/lib/accent"; import { BASE_GROUNDS, DEFAULT_ACCENT } from "yt-dlp-transcript-common/lib/brand"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import type { ThemeAccent, ThemeBase, @@ -56,13 +57,9 @@ export function generateMetadata(): Metadata { // (and registers the SW below). A dumb instance omits both — it stays // federatable JSON, but not independently installable. ...(shipsPwa() ? { manifest: "/manifest.webmanifest" } : {}), - icons: { - icon: [ - { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], - }, + // Rendered per build by app/icons/[file]/route.ts: the site's accent, or + // the parent mark on the hub. + icons: ICON_METADATA, appleWebApp: { capable: true, statusBarStyle: "black-translucent", diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts @@ -0,0 +1,26 @@ +import { ICON_PALETTES, type IconPalette } from "yt-dlp-transcript-common/lib/brand"; +import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import type { BrandMarkPalette } from "yt-dlp-transcript-common/components/BrandMark"; +import { currentSite } from "./site"; +import { instanceMode } from "./mode"; + +// The palette this build's icons are drawn in. The hub is the family's own +// tool, so it wears the parent mark — achromatic, like the homepage and the +// editor. Every archive site is lit with its own accent's on-dark value. +// Server-only, like currentSite(). +export function iconPalette(): IconPalette { + return instanceMode() === "hub" + ? ICON_PALETTES.archilyzer + : siteIconPalette(currentSite().accent); +} + +// The header mark's palette. On a site it is the icon's ink tile, but its lit +// line follows the READER's accent — `--brand-mark` is the picked accent's +// on-dark value (the tile is always ink) — while the favicon keeps the site's +// default. Until the base × accent tokens define `--brand-mark`, it falls back +// to `--brand`. The hub's mark is the parent mark and does not follow. +export function headerMarkPalette(): BrandMarkPalette { + return instanceMode() === "hub" + ? ICON_PALETTES.archilyzer + : { ...ICON_PALETTES.child, lit: "var(--brand-mark, var(--brand))" }; +} diff --git a/export/app/manifest.ts b/export/app/manifest.ts @@ -1,22 +1,17 @@ import type { MetadataRoute } from "next"; -import { parseAccent } from "yt-dlp-transcript-common/lib/accent"; +import { BASE_GROUNDS } from "yt-dlp-transcript-common/lib/brand"; import { currentSite } from "./lib/site"; +import { iconPalette } from "./lib/brand"; // Web App Manifest → statically emitted as /manifest.webmanifest at build time -// (output: "export"). Per-site: name/description from currentSite(), theme_color -// from the site's brand accent (falls back to the base brand blue). Makes each -// deployed export site installable as a standalone PWA. Icons are the shared -// mark under public/icons (a per-site accent-tinted icon is a later -// refinement — theme_color already differentiates the browser chrome). +// (output: "export"). Per-site: name/description from currentSite(). Makes each +// deployed export site installable as a standalone PWA. The icons are this +// build's own — the site's accent, or the parent mark on the hub — rendered by +// app/icons/[file]/route.ts. export const dynamic = "force-static"; -// Base brand blue (tokens.css --brand), used when a site sets no accent — the -// export default family is base. -const FALLBACK_THEME_COLOR = "#2563eb"; - export default function manifest(): MetadataRoute.Manifest { const site = currentSite(); - const themeColor = parseAccent(site.accent) ?? FALLBACK_THEME_COLOR; return { name: site.siteTitle, short_name: site.headerTitle || site.siteTitle, @@ -26,10 +21,19 @@ export default function manifest(): MetadataRoute.Manifest { display: "standalone", // No `orientation` lock: the archive is mostly video, and landscape is the // right shape for the player view. - background_color: "#0a0a0a", - theme_color: themeColor, + // The splash is the icon's own ground, so the mark sits on its tile's + // colour; the installed app's chrome is the dark base's ground. Neither is + // the accent: a reader may pick another, and the manifest cannot follow. + background_color: iconPalette().ground, + theme_color: BASE_GROUNDS.dark, icons: [ { + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }, + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts @@ -0,0 +1,50 @@ +import { expect, test, type Route } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES, markSvg } from "../../common/lib/brand"; + +// The hub is the family's own tool, so it wears the PARENT mark — bone on +// slate, achromatic — in its icons and its header, like the homepage and the +// editor; never an archive's accent (plans/brand-and-themes.md, slice S1). + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }); +} + +test("the hub's icons are the parent mark", async ({ request }) => { + const svg = await (await request.get("/icons/icon.svg")).text(); + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.ground}"`); // #151b20 + expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.lit}"`); // #e7edf1 + expect(svg).toBe(markSvg(ICON_PALETTES.archilyzer)); + + const png = await request.get("/icons/icon-192.png"); + expect(png.headers()["content-type"]).toBe("image/png"); + const ico = await request.get("/favicon.ico"); + expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]); + + const m = await (await request.get("/manifest.webmanifest")).json(); + expect(m.background_color).toBe(ICON_PALETTES.archilyzer.ground); + expect(m.theme_color).toBe(BASE_GROUNDS.dark); +}); + +test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => { + // No members: the shelf stays empty and nothing reaches a real origin. + await page.route("**/hub-sites.json", (r) => fulfillJson(r, [])); + await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Archilyzer", exact: true }); + await expect(home).toBeVisible(); + await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi"); + await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer"); + const fills = await home.locator("svg[data-brand-mark]").evaluate((svg) => + ["ground", "dim", "lit"].map( + (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill, + ), + ); + expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]); +}); diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts @@ -0,0 +1,275 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +// The hub's federated search, per archive: two official members (hub-sites.json) +// served by route mocks WITH CORS, each with one video. Proves the scope chips +// (take an archive out; it is not fetched, and the choice survives a reload), +// per-archive state (a member whose pages cannot be read is "failed", the page +// says "1 of 2 archives answered" with a Retry, and the healthy member's results +// still render), Retry, progressive readiness (a slow member does not hold the +// fast one's results back), and a card naming its source archive in text. + +const ORIGIN_A = "http://localhost:4598"; +const ORIGIN_B = "http://localhost:4599"; +const CORS = { "access-control-allow-origin": "*" }; + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: CORS, + body: JSON.stringify(body), + }); +} + +type Member = { origin: string; title: string; channel: string; video: string }; +const A: Member = { origin: ORIGIN_A, title: "Origin A", channel: "Channel A", video: "Alpha Video" }; +const B: Member = { origin: ORIGIN_B, title: "Origin B", channel: "Channel B", video: "Bravo Video" }; +const C: Member = { origin: "http://localhost:4597", title: "Origin C", channel: "Channel C", video: "Charlie Video" }; + +function manifestOf(m: Member) { + return { + version: 3, + totalCount: 1, + pageSize: 1000, + pageCount: 1, + generatedAt: "2026-01-01T00:00:00.000Z", + channels: [{ name: m.channel, count: 1, slug: "chan", groupId: "g" }], + groups: [{ id: "g", name: m.title, selectedByDefault: true }], + defaultGroupId: "g", + }; +} + +function pageOf(m: Member) { + const id = m === A ? "vida1" : m === B ? "vidb1" : "vidc1"; + return [ + { + slug: `chan/${id}`, + id, + channelSlug: "chan", + title: m.video, + uploadDate: m === A ? "20260102" : "20260101", + date: m === A ? "2026-01-02" : "2026-01-01", + duration: "5:00", + channel: m.channel, + isLivestream: false, + ageRestricted: false, + isDeleted: false, + isUnlisted: false, + platform: "youtube" as const, + webpageUrl: `${m.origin}/${id}`, + }, + ]; +} + +// Every request to a member origin is counted; `pages` decides how its +// summaries page is answered. +type PageMode = "ok" | "abort" | "hold"; +type MemberMock = { + requests: string[]; + pages: PageMode; + held: Route[]; +}; + +async function mockMember(page: Page, m: Member): Promise<MemberMock> { + const mock: MemberMock = { requests: [], pages: "ok", held: [] }; + await page.route(`${m.origin}/**`, async (route) => { + const url = new URL(route.request().url()); + mock.requests.push(url.pathname); + if (url.pathname === "/summaries/manifest.json") { + return fulfillJson(route, manifestOf(m)); + } + if (/^\/summaries\/page-\d+\.json$/.test(url.pathname)) { + if (mock.pages === "abort") return route.abort(); + if (mock.pages === "hold") { + mock.held.push(route); + return; + } + return fulfillJson(route, pageOf(m)); + } + if (url.pathname === "/subs/manifest.json") { + return fulfillJson(route, { + version: 4, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + } + // No posts, no aliases: a clean 404 (a member without a posts corpus is + // not a failure). + return route.fulfill({ status: 404, headers: CORS, body: "" }); + }); + return mock; +} + +async function setup(page: Page, members: Member[] = [A, B]) { + await page.route("**/hub-sites.json", (r) => + fulfillJson( + r, + members.map((m, i) => ({ + siteId: `origin${i}`, + siteTitle: m.title, + siteUrl: m.origin, + pwa: false, + contract: 1, + })), + ), + ); + await page.route("**/hub-summary.json", (r) => + r.fulfill({ status: 404, body: "" }), + ); + const a = await mockMember(page, A); + const b = await mockMember(page, B); + const c = members.includes(C) ? await mockMember(page, C) : null; + return { a, b, c }; +} + +const chip = (page: Page, m: Member) => + page.getByTestId(`hub-scope-chip-${m.origin}`); +const resultFrom = (page: Page, m: Member) => + page.locator(`[data-result-slug^="${m.origin}"]`); +const status = (page: Page) => page.getByTestId("hub-scope-status"); + +test.describe("hub federated search — scope, per-archive state, attribution", () => { + test("both official archives are searched by default and each card names its archive", async ({ + page, + }) => { + await setup(page); + await page.goto("/"); + await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(chip(page, B)).toContainText("Origin B"); + await expect(chip(page, B).getByRole("button", { name: /Origin B/ })).toHaveAttribute( + "aria-pressed", + "true", + ); + // Browse listing: one card per archive, each naming its source in text. + const b = resultFrom(page, B); + await expect(b).toHaveCount(1); + await expect(b.getByTestId("result-source")).toHaveText("Origin B"); + await expect(b).toContainText("Channel B"); + await expect(resultFrom(page, A).getByTestId("result-source")).toHaveText( + "Origin A", + ); + // Nothing failed, so no "N of M" line. + await expect(status(page)).toHaveCount(0); + }); + + test("toggling an archive off removes its results and stops its fetches, across a reload", async ({ + page, + }) => { + const mocks = await setup(page); + await page.goto("/"); + await expect(resultFrom(page, B)).toHaveCount(1); + await expect(resultFrom(page, A)).toHaveCount(1); + + await chip(page, B).getByRole("button", { name: /Origin B/ }).click(); + await expect(chip(page, B)).toHaveAttribute("data-status", "off"); + await expect( + chip(page, B).getByRole("button", { name: /Origin B/ }), + ).toHaveAttribute("aria-pressed", "false"); + await expect(resultFrom(page, B)).toHaveCount(0); + await expect(resultFrom(page, A)).toHaveCount(1); + await expect(page.getByText(/Searching 1 archive\b/)).toBeVisible(); + + // The choice is this browser's: after a reload Origin B stays off and is + // not fetched at all. + mocks.b.requests.length = 0; + await page.reload(); + await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); + await expect(chip(page, B)).toHaveAttribute("data-status", "off"); + await expect(resultFrom(page, A)).toHaveCount(1); + await expect(resultFrom(page, B)).toHaveCount(0); + expect(mocks.b.requests).toEqual([]); + + // Back on: fetched, and its results return. + await chip(page, B).getByRole("button", { name: /Origin B/ }).click(); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(resultFrom(page, B)).toHaveCount(1); + expect(mocks.b.requests).toContain("/summaries/manifest.json"); + }); + + test("a member that cannot be read fails alone; Retry brings it back", async ({ + page, + }) => { + const mocks = await setup(page); + mocks.b.pages = "abort"; + await page.goto("/"); + + await expect(chip(page, B)).toHaveAttribute("data-status", "failed"); + await expect(chip(page, B)).toContainText("failed"); + await expect( + chip(page, B).getByRole("button", { name: "Retry Origin B" }), + ).toBeVisible(); + await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); + await expect(status(page)).toHaveAttribute("role", "status"); + await expect(status(page)).toContainText("1 of 2 archives answered."); + await expect(status(page)).toContainText("Origin B did not"); + // The healthy member's results still render. + await expect(resultFrom(page, A)).toHaveCount(1); + await expect(resultFrom(page, B)).toHaveCount(0); + + // Fix the member, then Retry from the line. + mocks.b.pages = "ok"; + await status(page).getByRole("button", { name: "Retry", exact: true }).click(); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(status(page)).toHaveCount(0); + await expect(resultFrom(page, B)).toHaveCount(1); + await expect(resultFrom(page, A)).toHaveCount(1); + }); + + test("a slow member does not hold back a fast one, and a search re-runs as it arrives", async ({ + page, + }) => { + const mocks = await setup(page); + mocks.b.pages = "hold"; + await page.goto("/"); + + await expect(chip(page, A)).toHaveAttribute("data-status", "ready"); + await expect(chip(page, B)).toHaveAttribute("data-status", "loading"); + await expect(chip(page, B)).toContainText("loading"); + + // Search while Origin B is still loading: Origin A answers now. + await page.getByTestId("query-builder").waitFor(); + await page + .locator('[data-testid^="leaf-scope-"]') + .first() + .selectOption({ label: "Title / channel" }); + const input = page.locator('[data-testid^="leaf-query-"]').first(); + await input.click(); + await input.fill("video"); + await input.press("Enter"); + await page.waitForURL(/[?&]qt=/); + await expect(resultFrom(page, A)).toHaveCount(1); + await expect(resultFrom(page, B)).toHaveCount(0); + + // Origin B arrives: the same search now covers it. + await expect.poll(() => mocks.b.held.length).toBeGreaterThan(0); + for (const r of mocks.b.held.splice(0)) await fulfillJson(r, pageOf(B)); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(resultFrom(page, B)).toHaveCount(1); + await expect(resultFrom(page, A)).toHaveCount(1); + }); + + test("the answered line waits until every archive in scope has settled", async ({ + page, + }) => { + const mocks = await setup(page, [A, B, C]); + mocks.b.pages = "abort"; + mocks.c!.pages = "hold"; + await page.goto("/"); + + await expect(chip(page, B)).toHaveAttribute("data-status", "failed"); + await expect(chip(page, C)).toHaveAttribute("data-status", "loading"); + // Origin C is neither answered nor failed yet: no count that leaves it out. + await expect(status(page)).toHaveCount(0); + await expect(resultFrom(page, A)).toHaveCount(1); + + await expect.poll(() => mocks.c!.held.length).toBeGreaterThan(0); + for (const r of mocks.c!.held.splice(0)) await fulfillJson(r, pageOf(C)); + await expect(chip(page, C)).toHaveAttribute("data-status", "ready"); + await expect(status(page)).toContainText("2 of 3 archives answered."); + await expect(status(page)).toContainText("Origin B did not"); + await expect(resultFrom(page, C)).toHaveCount(1); + }); +}); diff --git a/export/e2e-hub/federation.spec.ts b/export/e2e-hub/federation.spec.ts @@ -139,7 +139,7 @@ test.describe("hub federation — cross-origin browse + search", () => { await expect(builtWith).toHaveAttribute("href", PROJECT_URL); }); - test("adds an archive by URL and shows it under Archives you added", async ({ page }) => { + test("adds an archive by URL and shows it under Archives You Added", async ({ page }) => { await mockOriginB(page); await addArchive(page, ORIGIN_B); // Spine carries the descriptor's siteTitle → the site was read cross-origin. @@ -148,7 +148,7 @@ test.describe("hub federation — cross-origin browse + search", () => { ).toBeVisible(); // An added archive gets its own section, and its own Remove control. await expect( - page.getByRole("heading", { level: 2, name: "Archives you added" }), + page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Remove Origin B" }), diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type Locator } from "@playwright/test"; +import { ICON_PALETTES, childIconPalette, markSvg } from "../../common/lib/brand"; +import { resolveAccent } from "../../common/lib/accent"; +import { installRoutes } from "./helpers"; + +// The family brand on an archive site (plans/brand-and-themes.md, slice S1): +// the Found-line mark and the two-weight wordmark in the header, the parent +// mark beside the footer credit, and the icon set rendered per build from the +// site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is +// headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366. + +// The computed fill of each part of a BrandMark, plus what the reader's accent +// resolves to — so the lit line can be checked against the token it follows +// without pinning a colour the themes slice will change. +async function markFills(mark: Locator) { + return mark.evaluate((svg) => { + const fill = (tone: string) => + getComputedStyle(svg.querySelector(`[data-tone="${tone}"]`) as Element).fill; + const probe = document.createElement("span"); + probe.style.color = "var(--brand-mark, var(--brand))"; + document.body.append(probe); + const accent = getComputedStyle(probe).color; + probe.remove(); + return { ground: fill("ground"), dim: fill("dim"), lit: fill("lit"), accent }; + }); +} + +test("the header link is the mark + the split wordmark, named by the header title", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const home = page + .getByRole("banner") + .getByRole("link", { name: "Fixture Header", exact: true }); + await expect(home).toBeVisible(); + await expect(home).toHaveAttribute("href", "/"); + + // Two adjacent spans, no whitespace node between them: the lead is the + // configured prefix, the suffix is the rest (its leading space included). + const wordmark = home.locator("[data-wordmark]"); + const parts = await wordmark.evaluate((w) => + [...w.childNodes].map((n) => ({ + tag: n.nodeName, + text: n.textContent, + weight: n instanceof Element ? getComputedStyle(n).fontWeight : null, + display: n instanceof Element ? getComputedStyle(n).display : null, + })), + ); + expect(parts).toEqual([ + { tag: "SPAN", text: "Fixture", weight: "720", display: "inline" }, + { tag: "SPAN", text: " Header", weight: "380", display: "inline" }, + ]); + + const mark = home.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + await expect(mark).toHaveAttribute("focusable", "false"); + await expect(mark).toBeVisible(); +}); + +test("the header mark is the ink tile, its lit line following the reader's accent", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const mark = page.getByRole("banner").locator("svg[data-brand-mark]"); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(12, 10, 8)"); // ICON_PALETTES.child.ground + expect(f.dim).toBe("rgb(59, 51, 39)"); // ICON_PALETTES.child.dim + expect(f.lit).toBe(f.accent); + expect(f.lit).not.toBe(f.dim); +}); + +test("the footer carries the parent mark before the credit, outside the link", async ({ + page, +}) => { + await installRoutes(page); + await page.goto("/"); + const footer = page.locator("footer"); + const credit = footer.getByRole("link", { name: "Archilyzer", exact: true }); + await expect(credit).toBeVisible(); + await expect(credit.locator("svg")).toHaveCount(0); + + const mark = footer.locator("svg[data-brand-mark]"); + await expect(mark).toHaveCount(1); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + // The mark's next sibling holds "Built with" and the credit. + await expect( + footer.locator("svg[data-brand-mark] + span").getByRole("link", { + name: "Archilyzer", + exact: true, + }), + ).toHaveCount(1); + const f = await markFills(mark); + expect(f.ground).toBe("rgb(21, 27, 32)"); // #151b20 + expect(f.lit).toBe("rgb(231, 237, 241)"); // #e7edf1, bone: the parent mark +}); + +test("<head> links the rendered icon set, SVG first", async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + const icons = await page + .locator('head link[rel="icon"]') + .evaluateAll((ls) => + ls.map((l) => [l.getAttribute("href"), l.getAttribute("type"), l.getAttribute("sizes")]), + ); + expect(icons).toEqual([ + ["/icons/icon.svg", "image/svg+xml", null], + ["/icons/icon-32.png", "image/png", "32x32"], + ["/icons/icon-192.png", "image/png", "192x192"], + ["/icons/icon-512.png", "image/png", "512x512"], + ]); + await expect(page.locator('head link[rel="apple-touch-icon"]')).toHaveAttribute( + "href", + "/icons/apple-touch-icon.png", + ); +}); + +test("the site's icons are lit with its own accent, fitted to the ink tile", async ({ + request, +}) => { + const lit = resolveAccent("#cc3366").dark; + const res = await request.get("/icons/icon.svg"); + expect(res.ok()).toBeTruthy(); + expect(await res.text()).toBe(markSvg(childIconPalette(lit))); + const maskable = await request.get("/icons/maskable.svg"); + expect(await maskable.text()).toBe( + markSvg(childIconPalette(lit), { variant: "maskable" }), + ); + // Not the parent mark: that is the hub's, the homepage's and the editor's. + expect(await res.text()).not.toContain(ICON_PALETTES.archilyzer.ground); +}); diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json @@ -3,6 +3,7 @@ "siteTitle": "Fixture Site Title", "siteDescription": "Fixture site description", "headerTitle": "Fixture Header", + "wordmarkLead": "Fixture", "homeTagline": "Fixture tagline here", "accent": "#cc3366", "socialLinks": [ diff --git a/export/e2e/pwa.spec.ts b/export/e2e/pwa.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { BASE_GROUNDS, ICON_PALETTES } from "../../common/lib/brand"; // PWA surface for the export (Archive) sites: an installable web app manifest, // a registered service worker, and the /offline management page. The transcript @@ -23,22 +24,61 @@ test("serves a valid web app manifest with per-site fields", async ({ expect( m.icons.some((i: { purpose?: string }) => i.purpose === "maskable"), ).toBe(true); - // theme_color is a hex string (from the site accent or the family brass). - expect(m.theme_color).toMatch(/^#[0-9a-f]{6}$/i); + // The scalable mark, for launchers that take one. + expect(m.icons).toContainEqual({ + src: "/icons/icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }); + // The chrome is the dark base's ground and the splash the icon's own tile — + // never the accent, which a reader may change and the manifest cannot follow. + expect(m.theme_color).toBe(BASE_GROUNDS.dark); + expect(m.background_color).toBe(ICON_PALETTES.child.ground); }); +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + test("serves the service worker and the icon set", async ({ request }) => { const sw = await request.get("/sw.js"); expect(sw.ok()).toBeTruthy(); - for (const icon of [ - "/icons/icon-192.png", - "/icons/icon-512.png", - "/icons/maskable-512.png", - "/icons/apple-touch-icon.png", - ]) { + // Rendered by app/icons/[file]/route.ts, not committed: each is a real PNG + // of its size (IHDR width/height at bytes 16 and 20). + for (const [icon, size] of [ + ["/icons/icon-32.png", 32], + ["/icons/icon-192.png", 192], + ["/icons/icon-512.png", 512], + ["/icons/maskable-512.png", 512], + ["/icons/apple-touch-icon.png", 180], + ] as const) { + const r = await request.get(icon); + expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/png"); + const body = await r.body(); + expect([...body.subarray(0, 8)], icon).toEqual(PNG_MAGIC); + expect([body.readUInt32BE(16), body.readUInt32BE(20)], icon).toEqual([size, size]); + } + for (const icon of ["/icons/icon.svg", "/icons/maskable.svg"]) { const r = await request.get(icon); expect(r.ok(), icon).toBeTruthy(); + expect(r.headers()["content-type"], icon).toBe("image/svg+xml"); + expect(await r.text(), icon).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/); } + // /favicon.ico: an ICO directory (00 00 01 00) of PNG entries. + const ico = await request.get("/favicon.ico"); + expect(ico.ok()).toBeTruthy(); + expect(ico.headers()["content-type"]).toBe("image/x-icon"); + const b = await ico.body(); + expect([...b.subarray(0, 4)]).toEqual([0, 0, 1, 0]); + expect(b.readUInt16LE(4)).toBeGreaterThan(0); + const first = b.readUInt32LE(6 + 12); + expect([...b.subarray(first, first + 8)]).toEqual(PNG_MAGIC); + // Anything not in the set is not an icon. EXPECTED in the dev server's log: + // "⨯ Failed to generate static paths for /icons/[file] … missing param" — + // Next's dev message when a name outside generateStaticParams meets + // dynamicParams=false under output: "export". The answer is still the 404 + // asserted here; it is not a failure. + expect((await request.get("/icons/icon-64.png")).status()).toBe(404); }); // Note: the service worker registers in PRODUCTION builds only (it caches diff --git a/export/public/icons/apple-touch-icon.png b/export/public/icons/apple-touch-icon.png Binary files differ. diff --git a/export/public/icons/icon-192.png b/export/public/icons/icon-192.png Binary files differ. diff --git a/export/public/icons/icon-512.png b/export/public/icons/icon-512.png Binary files differ. diff --git a/export/public/icons/icon.svg b/export/public/icons/icon.svg @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> - <rect width="512" height="512" rx="112" fill="#0c0a08"/> - <polygon points="256,92 420,256 256,420 92,256" fill="#e3b15c"/> - <polygon points="256,150 362,256 256,362 150,256" fill="#0c0a08"/> - <polygon points="256,196 316,256 256,316 196,256" fill="#e3b15c"/> -</svg> diff --git a/export/public/icons/maskable-512.png b/export/public/icons/maskable-512.png Binary files differ. diff --git a/export/public/icons/maskable.svg b/export/public/icons/maskable.svg @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archive"> - <rect width="512" height="512" fill="#0c0a08"/> - <polygon points="256,140 372,256 256,372 140,256" fill="#e3b15c"/> - <polygon points="256,182 330,256 256,330 182,256" fill="#0c0a08"/> - <polygon points="256,214 298,256 256,298 214,256" fill="#e3b15c"/> -</svg> diff --git a/export/service-worker/site-sw.js b/export/service-worker/site-sw.js @@ -7,7 +7,8 @@ * * Caches: * SHELL — app shell: hashed /_next/static/* (immutable, cache-first) + HTML - * navigations (network-first, cache fallback) + icons/manifest. + * navigations (network-first, cache fallback) + /icons/* (network- + * first: they follow the site's accent, which a redeploy can change). * PAGES — every published JSON document: the per-channel shard trees * (manifest.json + page-NNNN.json), the site-wide flat trees * (summaries, stats) and the root documents (corpus.json, site.json, @@ -24,8 +25,17 @@ * (avoids IndexedDB inside the SW). */ +// VERSION names the DATA caches, and activate deletes every cache it does not +// keep — so bumping it throws away every reader's offline channel downloads. +// Never bump it for a shell change. SHELL is named on its own: "shell-v2" +// retired the pre-brand icons (the old worker served /icons/ cache-first, so an +// installed app would have kept the old mark forever) while PAGES and META +// survive. The rename has one cost: activate also deletes shell-v1's cached +// HTML and /_next/static chunks, so right after the update the installed app +// does not open OFFLINE until the reader has made one more online visit, which +// refills shell-v2. Their channel downloads are not touched. const VERSION = "v1"; -const SHELL = `shell-${VERSION}`; +const SHELL = "shell-v2"; const PAGES = `pages-${VERSION}`; const META = `meta-${VERSION}`; @@ -86,11 +96,18 @@ self.addEventListener("fetch", (event) => { return; } - // App shell. - if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + // App shell. /_next/static/* is content-hashed, so a URL never changes what + // it names: cache-first. /icons/* is NOT: the icons are lit with the site's + // accent, a site.json setting that can change between deploys at the same + // URL, so they are network-first — fresh online, still there offline. + if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(req, SHELL)); return; } + if (url.pathname.startsWith("/icons/")) { + event.respondWith(networkFirst(req, SHELL)); + return; + } if (req.mode === "navigate" || req.destination === "document") { event.respondWith(networkFirstDoc(req)); return; diff --git a/export/service-worker/sw-hub.js b/export/service-worker/sw-hub.js @@ -16,8 +16,17 @@ * - the page's message API carries `origin` + absolute shard URLs. */ +// VERSION names the DATA caches, and activate deletes every cache it does not +// keep — so bumping it throws away every reader's offline channel downloads. +// Never bump it for a shell change. SHELL is named on its own: "shell-v2" +// retired the pre-brand icons (the old worker served /icons/ cache-first, so an +// installed app would have kept the old mark forever) while PAGES and META +// survive. The rename has one cost: activate also deletes shell-v1's cached +// HTML and /_next/static chunks, so right after the update the installed app +// does not open OFFLINE until the reader has made one more online visit, which +// refills shell-v2. Their channel downloads are not touched. const VERSION = "v1"; -const SHELL = `shell-${VERSION}`; +const SHELL = "shell-v2"; const PAGES = `pages-${VERSION}`; const META = `meta-${VERSION}`; @@ -78,10 +87,17 @@ self.addEventListener("fetch", (event) => { // App shell is same-origin only (the hub's own bundle). if (url.origin !== self.location.origin) return; - if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + // /_next/static/* is content-hashed: cache-first. /icons/* keeps its URL + // across deploys that change what it draws (the site SW's reason; the hub's + // parent mark changes less, but it is the same rule): network-first. + if (url.pathname.startsWith("/_next/static/")) { event.respondWith(cacheFirst(req, SHELL)); return; } + if (url.pathname.startsWith("/icons/")) { + event.respondWith(networkFirst(req, SHELL)); + return; + } if (req.mode === "navigate" || req.destination === "document") { event.respondWith(networkFirstDoc(req)); return; diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -9,6 +9,12 @@ the archives used. Type is Archivo, IBM Plex Sans and IBM Plex Mono. The chart's third colour is a violet, well clear of the "gone" red. A stored theme from before carries over once. The docs' *Operate* page describes the accent and the reader's menu. +- **The Found-line mark.** The header's CSS triangle is now the family's parent mark (bone + on slate) and "ARCHILYZER" is the split wordmark "Archi|lyzer" — heavy lead, light + suffix, no longer tracked uppercase; the link is still named "Archilyzer home". The + icons and `/favicon.ico` are no longer committed: `app/icons/[file]/route.ts` and + `app/favicon.ico/route.ts` render them from `common/lib/brand.ts` at build, and + `<head>` gains `icon-32.png`. The OG image is still `/icons/icon-512.png`. ## 2026-08-12 diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -1,5 +1,11 @@ import Link from "next/link"; -import { PROJECT_NAME } from "yt-dlp-transcript-common/lib/project"; +import { + PROJECT_NAME, + PROJECT_WORDMARK_LEAD, +} from "yt-dlp-transcript-common/lib/project"; +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu"; import { NAV } from "../lib/nav"; @@ -30,24 +36,25 @@ function NavList({ className }: { className?: string }) { // is the same string on every install. The operator's own naming still governs // /stats/ and the social links in the footer. // -// The mark is a solid triangle — a play head, the thing every recording here -// began as. Bone, not brass: this family spends no colour on chrome. +// The mark is the family's Found-line mark in its parent colours — bone on +// slate, no accent: the project spends no colour on its own chrome. The +// wordmark splits on the subject ("Archi" + "lyzer"); the link keeps its +// explicit name, "Archilyzer home". export default function Header() { return ( <header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md"> <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-6"> <Link href="/" - className="group flex items-center gap-2.5 shrink-0" + className="flex items-center gap-2.5 shrink-0" aria-label={`${PROJECT_NAME} home`} > - <span - aria-hidden="true" - className="h-0 w-0 border-y-[6px] border-y-transparent border-l-[9px] border-l-[var(--foreground)] transition-colors group-hover:border-l-[var(--brand)]" + <BrandMark palette={ICON_PALETTES.archilyzer} className="size-7 shrink-0" /> + <Wordmark + title={PROJECT_NAME} + lead={PROJECT_WORDMARK_LEAD} + className="text-[1.3rem] leading-none tracking-[-0.01em]" /> - <span className="font-display text-[1.05rem] font-semibold uppercase leading-none tracking-[0.11em] text-[var(--foreground)]"> - {PROJECT_NAME} - </span> </Link> <nav aria-label="Main" className="ml-auto hidden sm:block"> <NavList className="gap-6" /> diff --git a/homepage/app/favicon.ico b/homepage/app/favicon.ico Binary files differ. diff --git a/homepage/app/favicon.ico/route.ts b/homepage/app/favicon.ico/route.ts @@ -0,0 +1,10 @@ +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { iconResponse, renderFaviconIco } from "yt-dlp-transcript-common/lib/brandIcons"; + +// /favicon.ico: the parent mark at 16/32/48 in one PNG-payload ICO. Static; +// written to out/favicon.ico at build. +export const dynamic = "force-static"; + +export async function GET() { + return iconResponse(await renderFaviconIco(ICON_PALETTES.archilyzer), "image/x-icon"); +} diff --git a/homepage/app/icons/[file]/route.ts b/homepage/app/icons/[file]/route.ts @@ -0,0 +1,24 @@ +import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; +import { ICON_FILES } from "yt-dlp-transcript-common/lib/brandIconFiles"; +import { iconResponse, renderIconFile } from "yt-dlp-transcript-common/lib/brandIcons"; + +// /icons/<file>: the project site's icon set — the parent mark, achromatic +// (bone on slate), rendered at build time from common/lib/brand.ts. The twin +// of export/app/icons/[file]/route.ts; under `output: "export"` each listed +// file lands at out/icons/<file>. The OG image names /icons/icon-512.png. +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return ICON_FILES.map((f) => ({ file: f.file })); +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ file: string }> }, +) { + const { file } = await params; + const out = await renderIconFile(file, ICON_PALETTES.archilyzer); + if (!out) return new Response("Not found", { status: 404 }); + return iconResponse(out.body, out.contentType); +} diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -8,6 +8,7 @@ import { PROJECT_TAGLINE, PROJECT_URL, } from "yt-dlp-transcript-common/lib/project"; +import { ICON_METADATA } from "yt-dlp-transcript-common/lib/brandIconFiles"; import Header from "./components/Header"; import Footer from "./components/Footer"; import "./globals.css"; @@ -27,14 +28,8 @@ export const metadata: Metadata = { description: "Archilyzer downloads a channel's back catalogue, transcribes it on your " + "own machine, and builds a static, searchable site you host yourself.", - icons: { - icon: [ - { url: "/icons/icon.svg", type: "image/svg+xml" }, - { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180" }], - }, + // Rendered at build by app/icons/[file]/route.ts — the parent mark. + icons: ICON_METADATA, openGraph: { type: "website", siteName: PROJECT_NAME, diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx @@ -25,10 +25,10 @@ export default function Home() { const hours = official?.hoursArchived ?? summary?.totals.hoursArchived ?? null; const sites = summary?.sites ?? []; const months = summary?.monthly ?? []; - // Operator decision 2026-09-25: do not link the hub from the homepage until it - // is more polished. The URL stays in homepage.json (the hub's own build reads - // it); only the hero button is withheld. Flip this to true to show it again. - const HUB_LINK_ENABLED = false; + // The hero links the hub. It was withheld on 2026-09-25 until the hub was + // polished (release 9 slices C1, C1b, C2) and re-enabled the same evening. + // The URL comes from homepage.json (the hub's own build reads it too). + const HUB_LINK_ENABLED = true; const hubUrl = HUB_LINK_ENABLED ? (currentHomepage().siteUrl ?? null) : null; return ( diff --git a/homepage/e2e/brand.spec.ts b/homepage/e2e/brand.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { ICON_PALETTES, markSvg } from "../../common/lib/brand"; + +// The project site wears the PARENT mark (plans/brand-and-themes.md, slice +// S1): bone on slate in its icons and its header, and the wordmark split on the +// subject — "Archi" heavy, "lyzer" light — no longer tracked uppercase. + +test("the header is the parent mark + Archi|lyzer, still named 'Archilyzer home'", async ({ + page, +}) => { + await page.goto("/"); + const home = page.getByRole("link", { name: "Archilyzer home", exact: true }); + await expect(home).toBeVisible(); + await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi"); + await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer"); + await expect(home.locator("[data-wordmark]")).not.toHaveCSS("text-transform", "uppercase"); + const mark = home.locator("svg[data-brand-mark]"); + await expect(mark).toHaveAttribute("aria-hidden", "true"); + const fills = await mark.evaluate((svg) => + ["ground", "dim", "lit"].map( + (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill, + ), + ); + expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]); +}); + +test("the icon set is rendered from the parent mark", async ({ request }) => { + const svg = await request.get("/icons/icon.svg"); + expect(svg.headers()["content-type"]).toBe("image/svg+xml"); + expect(await svg.text()).toBe(markSvg(ICON_PALETTES.archilyzer)); + // The OG image names this one. + const png = await request.get("/icons/icon-512.png"); + expect(png.headers()["content-type"]).toBe("image/png"); + const b = await png.body(); + expect([b.readUInt32BE(16), b.readUInt32BE(20)]).toEqual([512, 512]); + const ico = await request.get("/favicon.ico"); + expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]); +}); diff --git a/homepage/public/icons/apple-touch-icon.png b/homepage/public/icons/apple-touch-icon.png Binary files differ. diff --git a/homepage/public/icons/icon-192.png b/homepage/public/icons/icon-192.png Binary files differ. diff --git a/homepage/public/icons/icon-512.png b/homepage/public/icons/icon-512.png Binary files differ. diff --git a/homepage/public/icons/icon.svg b/homepage/public/icons/icon.svg @@ -1,10 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archilyzer"> - <!-- The tool's mark, not an archive's. The export sites wear a brass diamond - on warm black; this is a bone play head on graphite — the instrument that - builds them, in the instrument family's palette (see tokens.css, - [data-theme="archilyzer"]). The bar beneath is the transcript: the - recording resolved into a line of text. --> - <rect width="512" height="512" rx="96" fill="#151b20"/> - <polygon points="188,132 372,236 188,340" fill="#e7edf1"/> - <rect x="140" y="384" width="232" height="20" rx="4" fill="#8496a2"/> -</svg> diff --git a/homepage/public/icons/maskable-512.png b/homepage/public/icons/maskable-512.png Binary files differ. diff --git a/homepage/public/icons/maskable.svg b/homepage/public/icons/maskable.svg @@ -1,7 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Archilyzer"> - <!-- Maskable variant: same mark, pulled into the safe zone (the inner 80% - circle) because a launcher may crop this to any shape. --> - <rect width="512" height="512" fill="#151b20"/> - <polygon points="206,164 350,246 206,328" fill="#e7edf1"/> - <rect x="176" y="356" width="160" height="16" rx="4" fill="#8496a2"/> -</svg> diff --git a/plans/STATE.md b/plans/STATE.md @@ -3,14 +3,28 @@ The working memory for the local-AI derived-corpus work. Rewritten at the end of every session, before context is cleared. See [`README.md`](README.md) for the protocol. -**Live on :3001 (2026-09-25, 19:40):** `0213f6c8` (release 9 = slice F four fixes + hub slice C1, on -top of release 8's `0e72ef73`), `BUILD_ID` `P0VMdKX7gbdsaiS5GvorF`. umtool on :3050 untouched, -`BUILD_ID` `a9YAe_dwhuM6DiCPzIwMD`. Live sites: -- https://archilyzer-hub.pages.dev — C1 (archilyzer theme, "Official instances" with the homepage's - figures from `hub-summary.json`), deploy `6e7115f9`; -- https://archilyzer.pages.dev — the release-8 refresh (slice H) since 18:30, rebuilt 19:45 from - `0213f6c8` (deploy `37fc10cc`); its hero does NOT link the hub until C2 lands (C3); -- the five official sites at release 8's slices Z + E (17:45 / 18:10 deploys). +**Live on :3001 (2026-09-25, 19:40):** `0213f6c8` (release 9 slice F + hub C1), `BUILD_ID` +`P0VMdKX7gbdsaiS5GvorF`; umtool untouched (`a9YAe_dwhuM6DiCPzIwMD`). `main` is `eebcaa90` (+ C1b, +C2, C3 — export/homepage only, no editor restart needed). Live sites: +- https://archilyzer-hub.pages.dev — C1 + C1b + C2 (archilyzer theme, "Official Instances" with + the homepage's figures, scope chips, per-archive state, "N of M archives answered", archive named + on every card, bare Ko-fi footer link), deploy `6ef7f472` (20:50); +- https://archilyzer.pages.dev — the refresh with the copy rulings and the hub link ON (C3), + deploy `e09900af` (20:51); +- the five official sites at release 8's slices Z + E (17:45 / 18:10 deploys); they carry no Ko-fi. + +**The 2026-09-25 evening plan is complete through step C.** Next = the release 10 candidates below +(plus: `retry: false` on the hub's subs query — C2 re-read LOW), then Phase 4 slice 3. + +**Release 10 candidates (found 2026-09-25 evening, none started):** `retry: false` on the hub's subs +query (a member with no subs corpus is ready ~1 s late); `/ask` on the hub honours the scope chips +(`useHubScope().isOn` in `AskHub`); official-site accents on the hub (shared `seriesColor` fallback +for result stripes + chip dots, needs `hub-summary.json`); the hub orders its cards alphabetically, +the homepage by transcripts; `export/playwright.config.ts` must unlink `public/sw.js` before copying +(a worktree run overwrites the primary's hub SW); YouTube's soft block "try again later" classifies +`deleted`/per_video so it never backs off; the boot pass's wait on the storage pass has no timeout; +`/jobs` does not show `cancelReason`; the safeRevalidate warning is once per bundle. Then Phase 4 +slice 3 (`plans/one-core.md`), the LM chat-only tier config, the `Dockerfile.build` image refresh. See the [release-9 rollout record](release-9.md#rollout-2026-09-25-evening--0213f6c8-live-on-3001-third-restart-of-the-day). diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md @@ -557,6 +557,240 @@ operator picks an accent or a lead. - The worktree is port block **#1** (editor 3101, test 3111, export 3110), not #2: `pnpm wt list` sorts, and `diet-series` is #2. +### Slice S1, as shipped — mark, icons, wordmark (2026-09-25) + +Branch `brand/mark` off S0's tip `02295a94` (worktree `../brand-mark`, port block #2). S1 draws the +Found-line mark everywhere the family shows itself: every icon file is rendered from `markSvg` at +build instead of committed, the site header is the mark plus the split wordmark, the hub, homepage +and editor wear the parent mark, and the footer credit carries it. No `common/styles/**`, theme +component, `MobileMenu`, theme spec, `site-branding` spec or C2 file was touched. In the two layouts +only the `icons` metadata changed. + +**The spike passed on the plan's primary path in about 15 minutes; no fallback was taken** +(`$T/s1-spike.md`). +- `next/og` runs under tsx, so the unit tests check the PNG IHDR as well as the ICO directory. A + probe script needs `.mts`: tsx refuses top-level await in a `.ts` it compiles to CJS. +- `app/icons/[file]/route.ts` (force-static, `dynamicParams = false`, `generateStaticParams` over + `ICON_FILES`) and `app/favicon.ico/route.ts` (force-static) emit the right files for both export + and homepage: + - `out/icons/*`: icon-192 is `8950 4e47 0d0a 1a0a … 4948 4452 0000 00c0 0000 00c0`, a 192×192 + IHDR; + - `out/favicon.ico`: starts `0000 0100 0300`, three PNG entries. + - Both were looked at. +- The favicon route is missing from the build's route table, because Next files `/favicon.ico` as a + metadata route, but it IS emitted. +- `next dev`, which every e2e server runs, serves all of them too. `/icons/nope.png` is a 404. + +**`common/lib/brandIcons.ts` (new, server-only).** +- `ICON_FILES`: the seven `/icons/` names, each with its format, variant, size and content type. + `iconFile(name)` looks one up. +- `ICON_METADATA`: the one `<head>` link list both layouts use: `icon.svg` first, then `icon-32.png`, + 192, 512, and apple. +- `FAVICON_SIZES`: 16, 32 and 48. +- `siteIconPalette(accent)` = `childIconPalette(resolveAccent(accent).dark)`. +- `renderIconPng(palette, {variant, size})`: an `ImageResponse` of a single `<img>` whose `src` is a + base64 data URI of `markSvg`, built with `createElement`, so the raster IS the SVG's geometry. +- `pngToIco(images)`: a 6-byte ICONDIR, 16-byte entries (256 is written as 0), then the PNG payloads + verbatim. `renderFaviconIco(palette)` packs the three sizes. +- `renderIconFile(name, palette)` returns null for any name not in the set; `iconResponse(body, type)` + wraps the result. + +**Routes.** +- Export: `app/icons/[file]/route.ts` + `app/favicon.ico/route.ts`. The palette is `iconPalette()` + (`export/app/lib/brand.ts`): `ICON_PALETTES.archilyzer` when `instanceMode() === "hub"`, else + `siteIconPalette(currentSite().accent)`. +- Homepage: the same two routes, always the parent palette. +- Neither reads the request. +- Deleted: `export/public/icons/*`, `homepage/public/icons/*`, and both `app/favicon.ico`. +- Next no longer adds its own `/favicon.ico` link, which it did for the static file, so `<head>` is + exactly `ICON_METADATA`. +- The homepage OG image is still `/icons/icon-512.png`. + +**Manifest** (`export/app/manifest.ts`): +- `theme_color` = `BASE_GROUNDS.dark` (`#0c0a08`) on sites and the hub; +- `background_color` = `iconPalette().ground` (`#0c0a08` on a site, `#151b20` on the hub); +- a new first icon `{src:"/icons/icon.svg", sizes:"any", type:"image/svg+xml", purpose:"any"}`; +- the 192/512/maskable PNG entries kept. + +The hex-only `parseAccent` fallback, which S0 flagged at `:19`, is gone. + +**Service workers.** `site-sw.js` and `sw-hub.js`: `const SHELL = "shell-v2"`, with a comment on why. +`VERSION` stays `"v1"`, so `pages-v1` / `meta-v1`, the readers' offline downloads, survive activate. +`contract.test.ts` still reads both files green. + +**Components.** +- `common/components/BrandMark.tsx`: + - maps `MARK` (the list `markSvg` walks) to `<rect>`/`<polygon>`; + - `aria-hidden`, `focusable="false"`, `data-brand-mark`, and `data-tone` on each shape; + - every fill goes in `style`, so a slot can be `var(…)`; + - its palette type is `BrandMarkPalette` (any CSS colour). +- `common/components/Wordmark.tsx`: + - `splitWordmark(title, lead)`: the lead at weight 720 in `var(--foreground)`, the suffix at 380 in + `var(--muted-foreground)`, `font-stretch: 118%`; + - two adjacent INLINE spans with no whitespace node, so the link's name is the title exactly; + - `data-wordmark(-lead|-suffix)` hooks; + - the face is `var(--font-grotesk, var(--font-display))`, which is Archivo both before S2 (the + grotesk variable) and after (S2 makes Archivo `--font-display`). S3 can drop the first term. +- Export `Header.tsx`: + - `<BrandMark palette={headerMarkPalette()} className="size-7">` + `<Wordmark title={site.headerTitle} + lead={site.wordmarkLead}>`, truncating as before; + - `headerMarkPalette()` is the child ink tile with `lit: "var(--brand-mark, var(--brand))"` on a + site, and the parent mark on the hub. +- Export `Footer.tsx`: `<span class="inline-flex …"><BrandMark archilyzer size-4/><span>Built with + <a>Archilyzer</a></span></span>`. The mark is outside the link. +- Homepage `Header.tsx`: the parent mark + `Archi|lyzer` (`PROJECT_WORDMARK_LEAD`), no uppercase or + tracking; `aria-label="Archilyzer home"` kept. +- Editor sidebar (`editor/app/layout.tsx`): the parent mark (`size-8`) before the title block, + outside the title link. The title is not split. +- **Editor favicon:** `editor/app/icon.svg` = `markSvg(ICON_PALETTES.archilyzer)`. Next links it as + `/icon.svg?icon.<hash>.svg`. `editor/app/lib/brandIcon.test.ts` pins it byte for byte. + **There was no `editor/app/favicon.ico`** (the editor had no favicon at all), so nothing was + deleted. + +**Fixture:** `"wordmarkLead": "Fixture"` in `export/e2e/fixtures/sites/testsite/site.json`. + +**Tests.** +- New `export/e2e/brand.spec.ts` (5 tests): + - the header link `{name:"Fixture Header", exact}` → `href="/"`, exactly two inline spans + `Fixture`/` Header` at 720/380, and an aria-hidden, non-focusable mark; + - the header mark's ground and dim are the ink tile, and its lit fill equals whatever + `var(--brand-mark, var(--brand))` resolves to, so it survives S2; + - the footer mark is the parent mark, the credit link has no svg, and `svg + span` holds the credit; + - `<head>` icon links in order; + - `/icons/icon.svg` and `maskable.svg` = `markSvg(childIconPalette(resolveAccent("#cc3366").dark))` + (`#d14574`). +- `pwa.spec.ts`: + - the manifest carries the `icon.svg` entry, `theme_color` = `BASE_GROUNDS.dark` and + `background_color` = the child ground; + - each of the five PNGs has `content-type: image/png`, the PNG magic and its IHDR size; + - both SVGs have `image/svg+xml`; + - `favicon.ico` is `image/x-icon`, starts `00 00 01 00`, and its first entry is a PNG; + - `/icons/icon-64.png` is a 404. +- New `export/e2e-hub/brand.spec.ts` (2 tests): + - `/icons/icon.svg` contains `fill="#151b20"` and `fill="#e7edf1"` and equals the parent markSvg; + - the hub PNG, ICO and manifest (`background_color` `#151b20`); + - the header `Archi|lyzer`, with mark fills slate / slate-dim / bone. +- New `homepage/e2e/brand.spec.ts` (2 tests): the header (name, split, no uppercase, fills) and the + icon set. +- Editor `branding.spec.ts` +1: the sidebar mark sits outside the "Cypress HQ" link, and the linked + `/icon.svg…` carries the parent colours. +- Unit tests: + - `common/lib/brandIcons.test.ts` (7): ICON_FILES, ICON_METADATA, siteIconPalette, the pngToIco + header, entries and offsets, 256→0 and range errors, the renderIconPng IHDR for all three + variants, the renderFaviconIco directory ↔ IHDR, renderIconFile; + - `editor/app/lib/brandIcon.test.ts` (1). + +| sha | what | +|---|---| +| `446be496` | `common/lib/brandIcons.ts` (+ test): ICON_FILES, ICON_METADATA, siteIconPalette, renderIconPng (next/og), pngToIco, renderFaviconIco, renderIconFile | +| `d69cf1b1` | export `app/icons/[file]` + `app/favicon.ico` routes, `lib/brand.ts` iconPalette, layout `icons: ICON_METADATA`, manifest (BASE_GROUNDS.dark / icon ground / icon.svg); committed icons + favicon.ico deleted | +| `cee051f2` | homepage twin routes (parent palette), layout `icons: ICON_METADATA`; committed icons + favicon.ico deleted | +| `fa7823fe` | `site-sw.js`, `sw-hub.js`: `SHELL = "shell-v2"`, VERSION unchanged | +| `fee7394c` | `common/components/BrandMark.tsx`, `Wordmark.tsx` | +| `4c90c7fc` | export Header (mark + Wordmark, `headerMarkPalette`), Footer (parent mark before the credit), fixture `wordmarkLead`, new `e2e/brand.spec.ts` + `e2e-hub/brand.spec.ts`, `pwa.spec.ts` extended | +| `e1df9815` | homepage Header (parent mark + the Archi · lyzer split), new `e2e/brand.spec.ts` | +| `adcd9482` | editor sidebar mark, `app/icon.svg` + parity test, `branding.spec` +1 | + +**Gates.** +- tsc: clean before each code commit (runs 2 and 3; run 1 is under "Found and left"), and run 4 on the final tree. +- Unit and script tests: + - common **1,881/1,881** (S0's 1,874 + 7); + - editor unit **79/79** (+1); + - `test:scripts` **162 + 1 skip**; + - mcp **219/219**. +- Builds, all with `export/public` seeded (`sw.js` a copy, no dangling links) and `homepage/public` + data copied: + - `pnpm --filter editor exec next build`: ok, 46 s. `/icon.svg` is emitted and linked. + - `pnpm --filter export exec next build` (site mode, the worktree's default site): ok, 27 s. The + seven `out/icons` files plus `out/favicon.ico`, PNG magic and IHDR per file (180, 192, 32, 512, + 512), ICO `0000 0100 0300 1010`, icon.svg lit Signal `#5fa8a0`, manifest as above. + - `pnpm --filter homepage exec next build`: + - run 1 failed in 8 s, 24 Turbopack errors in `next/font/google` for IBM Plex Sans ("queries have + exactly one entry") from the untouched `fonts.ts`, while another worktree built at the same + time; + - the retry was ok, 16 s. Same file set and magic; icon.svg is the parent mark; og:image still + `/icons/icon-512.png`. + - Hub mode (`INSTANCE_MODE=hub pnpm --filter export exec next build`, the build step of + `build:hub` without compose): ok, 31 s. `out/icons/icon.svg` holds `#151b20` / `#3f4c56` / + `#e7edf1`; the manifest's `background_color` is `#151b20`. + - A demo site (`SITES_DIR=$T/s1-demo-sites`, "Jeralyzer", lead "Jer", accent brass): ok, 33 s; + icon.svg lit `#e3b15c`. Used for the shots. +- e2e (`$T/s1-e2e.sh`, one run, 21:37–21:56): + - export full: **199 passed, 1 failed**, 11.2 min. The failure is `theme-family.spec.ts:18`, see + "Found and left". Rerun alone, `theme-family.spec.ts theme.spec.ts --repeat-each 3`: **6 passed**, 32.6 s (after a ~7.5 min queue wait behind `brand-themes`). + - `e2e:hub`: **14 passed**, 32 s (12 + the new 2). + - homepage full: **26 passed**, 1.0 min. + - editor `branding auto-refresh dashboard navigation widget`: **48 passed**, 4.0 min. + - `e2e:2origin` (`TWO_ORIGIN_REBUILD=1`): **3 passed**, 48 s. Before it, the seven entries compose + writes (`hub-sites.json site.json corpus.json llms.txt robots.txt sitemap.xml _headers`) were + swapped for copies; afterwards they and `hub-summary.json` were relinked and the seed re-run. +- **The primary's `export/public` was never written:** + - `sw.js` is 10,027 B, mtime 20:47:37, before and after; + - `corpus.json`, `hub-sites.json`, `llms.txt`, `robots.txt`, `_headers`, `hub-summary.json` and + `sitemap.xml` kept their pre-run mtimes. +- Screenshots (`$T/s1-*.png`, from served `out/` on localhost, all looked at): + - `site-header-{390,1280}-{light,dark}`: brass Jer|alyzer; + - `site-footer-light`, `site-footer-390-dark`; + - `hub-header-{1280,390}`, `hub-footer`; + - `homepage-header-{1280,390}`. + + They match the canvas's *In context* board. On light the ink tile is a real icon tile; on the + dark base it coincides with the page ground and the lines carry the mark, as the canvas draws + Jeralyzer on dark and the parent mark on slate. +- Numbers tools: none. + +**Found and left.** +- **Importing the `next` root types into `common/` breaks common's tsc.** tsc run 1 put 10 errors in + existing tests (`bin/_cli.test.ts`, `publish/build.test.ts`: "Property 'NODE_ENV' is missing … + ProcessEnv"). The cause was `import type { Metadata } from "next"` in `brandIcons.ts`: `next/index.d.ts` + references `types/global`, which makes `NODE_ENV` required on every `ProcessEnv` in the program. + So `ICON_METADATA` is untyped; the layouts type-check it against `Metadata`. `next/og` does not + carry that reference. +- **`theme-family.spec.ts:18` timed out** waiting for `menuitemradio "Swiss"`, in the second + `pickFamily`, right after `page.reload({waitUntil:"commit"})` + `themeReady`. The shot shows + Selenized applied and the menu closed: the click landed before hydration. `themeReady` is set by + the pre-paint script, not by React. + - It is the same reload timing `plans/deflake-e2e.md` §2 fixed for the read, which still races the + click after it. + - S1 touches no theme code. + - S2 deletes this spec. +- **`export/playwright.config.ts:29` still says "exactly like the committed public/icons/*".** The + plan lists that comment under S2's leftover copy, so it was left for S2/S3. +- **`export/.2origin/hubA`** is a hub build (gitignored, about 2.5 GB, because `out/` copies the + linked public data). It stays in the worktree for the next 2origin run. + +**Review fixes** (review verdict SHIP, `$T/s1-review.md`; review nit 5, the `layout.tsx` import +conflict with S2, is left for the merge). +- `541a46e0`, should-fix 1 + nit 2: + - both service workers serve `/icons/` network-first into shell-v2 (fresh online, cached + offline), so a redeployed accent reaches readers; + - `/_next/static/` stays cache-first; `SHELL` stays `"shell-v2"` and `VERSION` `"v1"`; + - the `SHELL` comment now says the rename also drops the cached HTML and chunks, so the installed + app opens offline again only after one more online visit. + - No test covered the strategy (`contract.test.ts` pins only the URL families). The new + `lib/archive/serviceWorkerRouting.test.ts` runs each worker in `node:vm` with a fake + `self`/`caches`/`fetch` and drives its fetch handler. Reverting site-sw's routing turns its icon + test red. +- `405fccd5`, nits 3 + 7: + - `ICON_FILES`, `iconFile`, `ICON_METADATA`, `FAVICON_SIZES` and `siteIconPalette` moved to the pure + `common/lib/brandIconFiles.ts` (no `next` import); + - `lib/brandIcons.ts` keeps the renderers beside `next/og`, and its only importers are the four + icon/favicon routes; the layouts and `export/app/lib/brand.ts` read the pure module. A test pins + the importer set and the purity. + - The variant test: pixel (0,0) is transparent on the `any` 512 and the opaque ground on the + maskable 512 and the apple 180. A variant-ignoring `renderIconPng` turns it red. +- `951a0ff5`, nit 6: the `accentHex` comment says the published hex is the icon's lit line only for a + named accent. +- `4a7641af`, nit 4: `pwa.spec`'s 404 probe names the dev log line it causes as expected. +- Gates: + - tsc clean before each commit; + - common **1,887** (+6), editor unit **79**; + - export build ok (23 s), homepage build ok (14 s), with the same seven `out/icons` files + favicon + and the same PNG/ICO magic; + - export `pwa.spec.ts brand.spec.ts`: **8 passed**, 15 s; `e2e:hub`: **14 passed**, 24 s; + - the worktree's `sw.js` stayed a copy, and the primary's `sw.js` is unchanged (10,027 B, + 20:47:37). + ### Slice S2, as shipped — base × accent (2026-09-25) Branch `brand/themes` off S0's tip `02295a94`. S2 replaces the five theme families with two independent @@ -763,3 +997,60 @@ describes the committed icons, which is S1's, so it was left. 4. Build and deploy every site: `pnpm ops build-deploy --json '{"siteIds":[…]}' --wait`; then the hub (`pnpm ops build-hub --json '{"deploy":true}' --wait`) and the homepage (`archilyzer deploy homepage`). + +## Proposed — Archilyzer Media, the YouTube channel (awaiting the operator's picks) + +Asked 2026-09-25: branding for **Archilyzer Media**, the operator's YouTube channel for the +videos. It is a new row on the canvas, "Archilyzer Media · the YouTube channel" (canvas +version 10), with three boards: *Media · mark + lockup*, *Media · the channel* and *Media · in +the video*. The generators are `gen.py` and `gen_media.py` beside the staging dir +(`node_modules/.brand-canvas/`); `gen_media.py` takes the live `canvas.json` it read as its +argument. + +**Open choices** (recommendation first): +- **Mark.** + - **M2 · Signal:** the parent mark, graphite and dim, with the found line and play head lit in + Signal `#5fa8a0`. + - **M1 · Bone:** the parent mark unchanged. + + At 40 px in a subscription list, bone on graphite reads as a blank grey avatar. Signal is + Archilyzer's own accent, so the channel speaks as the project rather than as any one site, + and it does not fight YouTube's red. +- **Lockup.** + - **Imprint:** the `Archi|lyzer` wordmark over a `MEDIA` tag (Plex Mono 500, tracked + 0.24em, in the accent). + - **Inline:** `Archi|lyzer Media`, with "Media" in the suffix weight. + +**The channel assets.** YouTube's specs were checked 2026-09-25. + +| Asset | Size | Design | +|---|---|---| +| Profile picture | 800×800 PNG, rendered as a 98 px circle | `markSvg` maskable (scale 0.8 keeps the farthest corner at 69% of the circle's radius) | +| Banner | 2560×1440 PNG ≤ 6 MB. Every device shows only the centre 1546×423; desktop shows 2560×423, tablet 1855×423 | A field of dim transcript lines wrapping around a clear safe area, the lockup centred in it, and one lit found line in the desktop strip | +| Watermark (Studio) | 150×150 PNG | `markSvg` any | +| Thumbnail template | 1280×720 | The still on the left; the headline (Archivo 118%, 800) and a found line on the right; the mark top-left; the bottom-right kept clear for YouTube's duration badge | + +**In the video** (`umtool/report-to-video`). +- **Today:** + - The cards are drawn with ImageMagick + Pango (`render-cards.mjs`), and the clip header + with ffmpeg `drawtext` (`build-video.mjs`). + - Colours come from the manifest's `render.palette`; fonts come from `render.fontRegular` / + `render.fontBold`. + - "Fira Sans" is the hardcoded default in `span()`. + - Nothing is burned in. +- **Proposed:** + - A `brand` preset: ground `#151b20`, fg `#e7edf1`, muted `#8496a2`, accent Signal. + - The mark leads the existing `Channel · title · date @ time` header. It is burned in, so + reposts keep it. + - A title card and an end card that carry the lockup, the end card with slots for the + end-screen elements. +- **Fonts:** this needs Archivo and IBM Plex installed as system fonts; neither is on this + machine. `ttf-ibm-plex` is in Arch's extra repo. Archivo's variable TTF comes from Google + Fonts, into `~/.local/share/fonts`. Pango takes `@wdth=118` variations. + +**Slice S4, after S1** (it reuses `markSvg` and S1's PNG renderer): +- `common/bin/brand-icons.ts --youtube <dir>` writes the profile picture, banner and watermark. + The banner's text needs Archivo at exactly 118%: a static instance (fontTools `instancer`) + for satori, or installed fonts for `rsvg-convert`. +- The umtool preset. +- The operator claims the handle and uploads the assets. diff --git a/plans/release-9.md b/plans/release-9.md @@ -463,6 +463,126 @@ editor unit, test:scripts and mcp not run: nothing they build or test changed be - Needs a rebuild + deploy of the hub AND the homepage; this slice did not run build-hub, deploy-hub or deploy homepage. +### Slice C2, as shipped — federated search (2026-09-25) + +Branch `one-core/c2-federated-search` off `main` `6820bd21`; merged `main` (`8eb55add`, slice C1b) as +`9d20d3fc`. **The operator-approved target (2026-09-25):** the hub searches every official instance +by default, a visitor can take any archive out, each archive's state is visible, the search runs as +soon as one archive is in, a failed member is named and retryable, a result names its archive in text, +and pages are fetched per archive behind its manifest. Scoping: `$T/r9-hub-scope.md` §3. + +**The provider** (`common/components/SearchDataContext.tsx`, `MultiSiteDataProvider`). `FederatedSite` +gains `enabled` (default true): false means none of that archive's feeds are fetched (`enabled: false` +on its manifest, subs, posts and alias queries; no page descriptors) and nothing already cached from it +is merged — TanStack returns cached `data` for a disabled query, so every merge checks scope itself. +Per archive, its state is derived from its manifest, page, subs and posts queries: `ready` (manifest +in, every page in, and its subs + posts manifests settled — a posts 404 is an empty manifest, a subs +error counts as settled), `failed` (manifest or a page errored and nothing of it is fetching), `loading` +(otherwise, including while a Retry is in flight), `off`. `SearchDataValue` gains two OPTIONAL fields, +set only by the multi-site provider: `federation: {sites: FederatedSiteState[], retry(origin)}` +(`{origin, siteTitle, accent?, status, count?, error?}`, `count` = the manifest's `totalCount`) and +`siteTitleOf(origin)`. `retry` is `refetchQueries` over this provider's five feeds (`manifest`, `summaries-page`, +`subs-manifest`, `posts-manifest`, `search-aliases`) of that origin whose status is `error`. `summariesState.error` is no longer always null: it is set when +EVERY in-scope archive failed (read by `/ask`'s corpus-error line). `SingleSiteDataProvider` is not +touched. +- **Merge.** An archive's records join the merged list only when it is `ready`, so the list grows one + whole archive at a time; the merged list's identity is keyed on the ready set alone, so the search + session (which re-runs its pipeline on every new `summaries` identity) re-runs once per archive, not + per page or per manifest. The subs, posts and alias merges are keyed on the same ready set, so every contribution of an +archive lands in one re-run and a failed archive contributes nothing — no videos, posts or chat +(before: whatever pages had loaded, and its posts regardless). + The synthetic merged manifest (HubStats' transcript figure) covers the ready archives. Channel groups + and channels cover the in-scope archives whose manifest has arrived. +- **Progressive.** A new `progressive` prop: `summariesReady` turns true at the first ready in-scope + archive. Without it (the `/ask` hub, `AskHub.tsx`, unchanged), it waits until every in-scope archive + has settled, ready or failed. HubHome passes it. The release-8 slice E rule holds unchanged: a + restored query is `runHeld` in `SearchSessionContext`, independent of readiness, so it still waits + for the visitor; a `qt=` link runs over the first archive and re-runs as each further one arrives. +- **Speed.** Pages are requested per archive only once its manifest has settled and only while it is in + scope (as before for the manifest gate; new for scope), and each page fetch goes through a per-origin + gate of **6 in flight** (`PAGE_FETCHES_PER_SITE`), so a big member cannot take the browser's + connections from the small ones. The page `queryFn` consumes TanStack's `signal`, so switching an + archive off mid-load cancels its queries and a page still waiting at the gate bails unfetched. No new build-time data. + +**The page** (`export/app/components/hub/`). `useHubScope.ts` keeps the set of origins switched OFF in +`localStorage["ytdlp-tb:hub-scope"]` (`{off: [...]}`, every access in try/catch), so an archive the +browser has not seen — a new official instance, an added one — is searched. `HubScope.tsx` is the row +of chips under the live line, above the query builder: one per archive on the page (official and +added), `data-testid="hub-scope-chip-<origin>"` + `data-status`, a toggle button (`aria-pressed`, the +archive's title and `loading…` / its record count / `failed` / `off`) and, when failed, a `Retry +<title>` button. `HubStats` counts the archives in scope and says "videos" (the chips' word for the same figure; was +"transcripts"). Every chip button and the line's Retry carry a literal `focus-visible:outline-2 +focus-visible:-outline-offset-2 focus-visible:outline-ring` (the chip's `overflow-hidden` clipped the +default outline). `common/components/SearchResults.tsx`: one line +above the results when an in-scope archive failed, `role="status"`, `data-testid="hub-scope-status"`: +"N of M archives answered. X did not, so its videos are not in these results. Retry" ("archive" when +M = 1) — only once NOTHING in scope is loading, so M = ready + failed and every archive not counted is +named; nothing while every archive is fine (the chips show loading). A result card's meta line reads +`<archive> · <channel> · <date>` (`data-testid="result-source"`, from `ResultGroup.source`, set via +`siteTitleOf` in `SearchSessionContext`); single-site groups carry no `source` key. The accent stripe +and the `data-result-slug` contract are unchanged. + +**Specs.** New `export/e2e-hub/federated-search.spec.ts` (+5), two official members route-mocked with +CORS: (1) both searched by default, both chips `ready`, each card names its archive, no status line; +(2) toggling Origin B off removes its card and after a reload it stays off with **zero requests** to its +origin, and back on it is fetched and returns; (3) Origin B's pages aborted → chip `failed` with `Retry +Origin B`, "1 of 2 archives answered." / "Origin B did not", Origin A's card renders; fixed + the line's +Retry → chip `ready`, line gone, both cards; (4) Origin B's page held → Origin A's search result renders +while B is `loading`, and the same search covers B once it is released. (5, review) three members, B failed and C +held → no line; C released → "2 of 3 archives answered." naming B. `federation.spec.ts`: "Archives +You Added" exact (C1b's Title Case). Every existing hub test id / accessible name is unchanged. + +| sha | what | +|---|---| +| `af8b30ed` | provider: scope (`enabled`), per-archive state + Retry (`federation`), ready-only merge, `progressive`, per-origin page gate of 6, `error` when all failed, `siteTitleOf` | +| `f7f7482f` | hub: `useHubScope`, `HubScope` chips, HubHome wiring (`progressive`), HubStats counts the archives in scope | +| `d0225a20` | a result names its archive (`ResultGroup.source`); the "N of M archives answered." line with Retry | +| `0b891b8f` | `e2e-hub/federated-search.spec.ts` (+4) | +| `9d20d3fc` | merge `main` (`8eb55add`, C1b) — no conflicts | +| `6a69c034` | `federation.spec.ts`: "Archives You Added", exact | +| `a0bd7b02` | a chip's title says what its number counts and what pressing it does | +| `40818bbb` | the merged list's identity keys on the ready set alone (no re-run when another manifest lands) | +| `d54e6814` | review fixes: ready needs subs + posts settled and their merges key on the ready set; the answered line waits for every archive (+ `role="status"`, singular); focus rings; page `signal`; "videos"; Retry scoped to the five feeds; spec +1 | + +**Gates.** tsc clean (per commit; full workspace on `9d20d3fc`+ and on `40818bbb`). Common tests +**1,845/1,845** (no new unit tests: the provider is a hook, covered by the e2e). test:scripts **162 passed ++ 1 skipped**. `pnpm --filter export exec next build` (site) ok, 29 s; `compose:hub` (worktree, no +corpus: "0 built-in pool site(s) …; hub-summary.json skipped") + `INSTANCE_MODE=hub … next build` ok, +32 s. e2e, queued, all on the merged tree: **`e2e:hub` 15 passed** (pre-merge, 48.9 s), **16 passed** +(merged, 53.2 s), **16 passed** (`40818bbb`, 47.0 s); **export full suite 195 passed**, 8.5 min (single +site unchanged); **`e2e:2origin`** (`TWO_ORIGIN_REBUILD=1`) **3 passed**, 46.9 s on `a0bd7b02`, and **3 passed**, +1.0 min on `40818bbb`. + +**Review (SHIP AFTER FIXES) re-gate** on `d54e6814`: tsc clean; **`e2e:hub` 17 passed** (16 + 1), 52.9 s. The seven compose-hub outputs + `sw.js` in the worktree's `export/public` were +swapped for copies before every build/e2e and relinked after; the primary's copies were not written by +this slice (one change seen, `hub-summary.json` at 20:18:20, is the parent's build-hub, which wrote the +other six at 20:17:53). Editor build/unit and mcp not run: nothing they build changed. **Numbers tools: +none.** + +**Seen against the live members** (the built hub served locally with the real five-site +`hub-sites.json`, service workers blocked so routes apply): first result card at 1.0–2.2 s; chips ready +Hasanalyzer/Rekietalyzer ~1.6–2.0 s, Bonnellyzer ~2–2.9 s, Anilyzer ~2–4.5 s, Jeralyzer ~3.1–5.1 s. +With Hasanalyzer's pages aborted: "4 of 5 archives answered. Hasanalyzer did not, …", its chip failed +with Retry, 72,360 records from the other four listed. Checked at 1280 dark and 390 dark. + +**Found and left:** +- The chip's number is the member's summaries `totalCount` — its record (video) count, the number the + results header counts ("All videos (N)") — not the card's "transcripts" figure, which is the + homepage's build-time `transcribed.total`. The chip's title says "N videos from X are in the search"; + the live line now says "videos" too. +- A member whose manifest loaded but whose pages failed keeps its channel group in the filter panel + (the channels are known); its records are not searched. Hiding the group on failure would churn the + groups on every Retry. +- **Follow-up:** the `/ask` hub has no scope chips and searches every archive on the page, as before; + the scope is the front page's. Honouring it is `useHubScope().isOn` in `AskHub`. It waits for every archive to settle (not progressive), so a chat never grounds in + a half-loaded federation silently. +- **Follow-up:** official cards have no `accent` (C1: they wear `seriesColor` on the page), so result + stripes and chip dots appear only for an archive that sets one — the text attribution is what names + the source. The fix is one shared fallback (a `shelfAccent(site, summary, i)` out of `ArchiveShelf`) + used by HubHome and AskHub too. +- `route()` does not see requests a service worker makes; a manual check of the built hub needs + `serviceWorkers: "block"`. The e2e-hub suite runs `next dev`, where no SW is registered. + ## Rollout ## Rollout 2026-09-25 (evening) — `0213f6c8` live on :3001 (third restart of the day) @@ -546,3 +666,18 @@ h2 "Official Instances", no "The archives I run", no card descriptions, footer ` https://ko-fi.com/archilyzer; homepage the same (two "Official Instances", "What It Does", one Ko-fi link, still no hub link); Jeralyzer carries no Ko-fi link. Summaries: hub 75,880 / homepage 75,884 recordings (the lane again), the rest identical. + +**C2 + C3 deployed (20:47 → 20:51, from `eebcaa90`).** C2 review SHIP AFTER FIXES → fix `d54e6814` +(readiness = summaries + subs + posts settled; status line hidden while loading, M = ready + failed, +`role="status"`; focus rings; page fetches honour the abort signal; "videos"; Retry predicate scoped) +→ re-read SHIP (one LOW left: `retry: false` on the subs query would make a member with no subs +corpus ready ~1 s sooner). Merged as `eebcaa90`. C3 = `e6fed1a0` (`HUB_LINK_ENABLED = true`). +`build-hub` 71 s (covers 5) + `deploy-hub` 29 s → deploy `6ef7f472`; `build homepage` 59 s + +`deploy homepage` 37 s → deploy `e09900af`. Live (Playwright, service worker blocked): first result +at 1.6 s, all five archives ready at 3.5 s, chips `Anilyzer 29,751 | Bonnellyzer 8,079 | Hasanalyzer +3,425 | Jeralyzer 31,605 | Rekietalyzer 2,925`, no status line, a card reads `Jeralyzer · +TheQuartering (Rumble) · 2026-09-25`, "All videos (75785)" → toggling Anilyzer off → 46,034 +(exactly minus 29,751). Homepage: "Search all archives" → https://archilyzer-hub.pages.dev, Ko-fi +link present. Summaries 75,945 (hub) / 75,947 (homepage) recordings — the lane, again. + +The 2026-09-25 evening plan is complete through step C. Worktrees for r9, c1, c1b, c2 removed.