commit 399329b7066e1a634d6fa8fcb71f39db3099cce0
parent 989eb7bbb734c7b391d33d2e17fc331b693e41a3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 21:40:05 -0400
homepage, common: Official Instances has an anchor, /#instances, and INSTANCES_URL names it (release 14 slice H1, the planned H3 folded in)
id="instances" on the section, scroll-mt the sticky header's height (the bar,
and below md the nav's rule), so the section's top edge lands under it.
INSTANCES_URL = PROJECT_URL/#instances beside PROJECT_URL. marketing.spec:
/#instances brings the heading into view below the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 32 insertions(+), 143 deletions(-)
diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx
@@ -1,80 +0,0 @@
-"use client";
-
-import { useTheme } from "./ThemeProvider";
-import { THEME_BASES, isThemeBase } from "./themeConfig";
-
-// The reader's base as a NATIVE radio group, "Base" (System / Light / Dark).
-// Used where a dropdown inside another layer would be a focus-trap fight: the
-// export's slide-out menu. A pick applies at once (attributes on <html>;
-// tokens.css does the rest) and nothing closes. There is no accent group: each
-// site shows its own.
-//
-// The radiogroup is named "Base", and each radio by its label — an e2e
-// contract. `namePrefix` keeps two instances on one page from sharing a radio
-// group. Must render inside a <ThemeProvider/>.
-export function ThemeRadios({
- namePrefix,
- groupClassName,
-}: {
- namePrefix: string;
- groupClassName?: string;
-}) {
- const { base, setBase } = useTheme();
- return (
- <div className={groupClassName}>
- <GroupHeading>Base</GroupHeading>
- <RadioList
- name={`${namePrefix}-base`}
- label="Base"
- value={base}
- options={THEME_BASES}
- onPick={(v) => {
- if (isThemeBase(v)) setBase(v);
- }}
- />
- </div>
- );
-}
-
-function GroupHeading({ children }: { children: React.ReactNode }) {
- return (
- <p className="px-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- {children}
- </p>
- );
-}
-
-function RadioList({
- name,
- label,
- value,
- options,
- onPick,
-}: {
- name: string;
- label: string;
- value: string;
- options: ReadonlyArray<{ id: string; label: string }>;
- onPick: (id: string) => void;
-}) {
- return (
- <div role="radiogroup" aria-label={label} className="mt-1 flex flex-col">
- {options.map((o) => (
- <label
- key={o.id}
- className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent"
- >
- <input
- type="radio"
- name={name}
- value={o.id}
- checked={value === o.id}
- onChange={() => onPick(o.id)}
- className="size-4 accent-[var(--brand)]"
- />
- {o.label}
- </label>
- ))}
- </div>
- );
-}
diff --git a/common/lib/project.ts b/common/lib/project.ts
@@ -32,6 +32,11 @@ export const PROJECT_WORDMARK_LEAD = "Archi";
// valid. Only then is changing this string a cosmetic follow-up.
export const PROJECT_URL = "https://archilyzer.pages.dev";
+// The homepage's Official Instances section, where every archive's header
+// links in place of the old sites dropdown (homepage/app/page.tsx,
+// `id="instances"`).
+export const INSTANCES_URL = `${PROJECT_URL}/#instances`;
+
export const PROJECT_TAGLINE = "Self-hosted, searchable video-transcript archives.";
// Where a visitor gets the source as a tarball. The canonical public copy is
diff --git a/export/app/components/SiblingSwitcher.tsx b/export/app/components/SiblingSwitcher.tsx
@@ -1,62 +0,0 @@
-"use client";
-
-import { ChevronDown } from "lucide-react";
-import { Button } from "yt-dlp-transcript-common/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "yt-dlp-transcript-common/components/ui/dropdown-menu";
-
-// A sibling-site switcher for the export header: the "family" navigation across
-// the other public sites in the pool. Fed by the same resolveRelatedSites() data
-// the footer uses (groups of { siteId, title, url }), resolved server-side and
-// passed in as plain props so this stays a thin client shell over the kit.
-export type SwitcherSite = { siteId: string; title: string; url: string };
-export type SwitcherGroup = { label?: string; sites: SwitcherSite[] };
-
-export default function SiblingSwitcher({
- groups,
-}: {
- groups: SwitcherGroup[];
-}) {
- const total = groups.reduce((n, g) => n + g.sites.length, 0);
- if (total === 0) return null;
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground"
- >
- Sites
- <ChevronDown className="size-3.5" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="min-w-48">
- {groups.map((group, gi) => (
- <div key={group.label ?? `group-${gi}`}>
- {gi > 0 && <DropdownMenuSeparator />}
- {group.label && (
- <DropdownMenuLabel className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
- {group.label}
- </DropdownMenuLabel>
- )}
- {group.sites.map((s) => (
- <DropdownMenuItem key={s.siteId} asChild>
- <a href={s.url} rel="noopener noreferrer">
- {s.title}
- </a>
- </DropdownMenuItem>
- ))}
- </div>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -1,6 +1,7 @@
# Homepage Changelog
## [Unreleased]
+- **`/#instances` goes straight to Official Instances.** The section carries `id="instances"`, clear of the sticky header, and every archive's header now links there (`INSTANCES_URL` in `common/lib/project.ts`). With no sites the link lands on the top of the page.
- **The social links are in the header, at every width, beside the theme toggle.** The operator's social icons (`homepage.json`'s, else `settings.json`'s) now sit in the header's bar as well as in the footer's Elsewhere column, followed by the theme toggle, all spaced alike. From 768 px wide the bar is wordmark, nav, icons, toggle; below 768 px it is wordmark, icons, toggle, and the nav has the rule below to itself, where its four links fit. The header shows at most four links: the ones marked **Show in header** when any is, else the last four; the footer shows them all. No link is hidden on a small screen: when the icons and the full wordmark do not fit side by side, the wordmark's text is dropped and its mark stays (with three links, the full wordmark shows from 348 px wide with a mouse and from 380 px on a touch screen; with four, from 384 and 424 px). Only on a screen narrower than that for the mark too, or at a much larger text size, do the icons scroll sideways in their own box, the last one in view first. Each is an icon named by its label, with no text beside it.
- **One theme toggle in place of the two theme buttons.** The header's theme menu (Base and Accent) and its base toggle are one toggle, the last of the header's icons, that cycles the ground (System, Light, Dark) and is named for the next one. There is no accent to pick: the homepage's is its own, Signal, and an accent stored by an earlier build is ignored here, with no flash.
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -110,8 +110,13 @@ export default function Home() {
)}
{/* ── Official Instances ───────────────────────────────────────────── */}
+ {/* `#instances` is where every archive's header links (common/lib/
+ project.ts INSTANCES_URL); `scroll-mt` is the sticky header's height
+ (the bar, and below md the nav's rule under it), so the section's top
+ edge lands just under it. With no sites the section is absent and the
+ link lands on the top of the page. */}
{sites.length > 0 && (
- <section className="border-t border-[var(--border)]">
+ <section id="instances" className="scroll-mt-24 md:scroll-mt-14 border-t border-[var(--border)]">
<div className={`${CONTAINER} py-14 sm:py-20`}>
<h2 className="mb-8 font-display text-2xl font-semibold text-[var(--foreground)]">
Official Instances
diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "@playwright/test";
import { FIXTURE_SUMMARY_NAME } from "./fixture-summary";
+import { INSTANCES_URL, PROJECT_URL } from "../../common/lib/project";
// The home page's job is to say what Archilyzer is and offer the download.
// Nothing asserted here names a count, a site or a headline number. The numeric
@@ -148,6 +149,25 @@ test("official instances link out, and the numbers carry their build date", asyn
}
});
+// Every archive's header links here (common/lib/project.ts INSTANCES_URL):
+// `/#instances` brings the section's heading into view, clear of the sticky
+// header.
+test("/#instances brings Official Instances into view below the sticky header", async ({
+ page,
+}) => {
+ expect(INSTANCES_URL).toBe(`${PROJECT_URL}/#instances`);
+ await page.setViewportSize({ width: 390, height: 700 });
+ await page.goto("/#instances");
+ const heading = page.getByRole("heading", { level: 2, name: "Official Instances", exact: true });
+ await expect(heading).toBeInViewport();
+ const [h, bar] = await Promise.all([
+ heading.boundingBox(),
+ page.locator("header").first().boundingBox(),
+ ]);
+ expect(h!.y).toBeGreaterThanOrEqual(bar!.y + bar!.height);
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
+});
+
test("the headings after the H1 are in Title Case", async ({ page }) => {
await expect(
page.getByRole("heading", { level: 2, name: "What It Does", exact: true }),