commit 2f573c384410fcb536adc29ef8fb89e7eaea0764
parent 2dae56843a37e7160ffcb05d1b5861c91985bb21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 18:54:40 -0400
plans: release 14 slice HP as shipped, with the review and its fixes; the plan's HP, H1 and H2; STATE; changelogs
- plans/release-14.md (new): slice HP's rulings (the review's: no vendor file,
the icon check hardened on save and at render, the shared dialog unchanged);
what shipped; the header layout with the width × pointer rule and its
measurements from 320 px; the cards' treatment (the tint) and both
treatments' screenshots; the five commits (the branch's history rewritten
once); the gates with numbers; what bites; the review, finding by finding;
what was found and left; the merge after fix/stats-cache-key; an empty
"Rollout".
- plans/export-header-first-search.md: the rulings; slice HP as built; H1 on
HP's SocialLinks with its narrow steps; H2 adopts HP's OptionsDialog and
ThemeRadios; the parked tip-link branch no longer a dependency.
- plans/STATE.md: release 14's line.
- [Unreleased]: homepage (the group in the header, Options, Changelog in the
footer, the cards' wordmark names, the larger keys, the e2e fixtures);
editor, at the end of the list (the size rule and "Show in header"; the icon
check with reasons).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 482 insertions(+), 61 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -3,6 +3,8 @@
## [Unreleased]
- **Building the homepage now publishes the source: a read-only git mirror, its raw tree and a fresh tarball, behind a gate.** `archilyzer build homepage`, the `/sites` Homepage jobs and `pnpm ops build-homepage` run `archilyzer source publish` between compose and `next build`. It makes a fresh clone of the private `main` (the repository itself is never rewritten), rewrites that copy with git-filter-repo using your scrub rules (file contents and commit messages; your home directory becomes `/home/user` without a rule), and publishes it under `homepage/public` for `git clone https://archilyzer.pages.dev/source/archilyzer.git`, beside `/source/tree/` and the Downloads tarball. Before anything is written, every object of the rewritten history and every file about to be published is searched for every string you have denied; **one hit refuses the build**, and its log names the string only by where you wrote it (`denylist line 3 (len 5)`) and each hit by its object, field and byte offset — never a byte of the object. **A refusal withdraws the source**: the last publish is removed from `homepage/public` and the last build's copy from `homepage/out`, and **Deploy homepage refuses** a build whose source was not audited under today's rules and today's `main` ("run `archilyzer build homepage`, then deploy"). The rules live outside the repo, in `~/.config/archilyzer/source-scrub.txt` and `source-denylist.txt` (`ARCHILYZER_CONFIG_DIR`, `SOURCE_SCRUB_FILE`, `SOURCE_DENYLIST_FILE`); **without them the build refuses**, naming the missing file. **Put everything private in the denylist before any deploy, a preview included**: previews are public, and every deployment stays reachable at its own address until you delete it. Install git-filter-repo once (`pipx install git-filter-repo`; the editor's process needs `~/.local/bin` on its `PATH` to find it) — without it the build fetches it through `pipx run`, which needs the network — and gitleaks if you want its secret scan too. An unchanged `main` with unchanged rules is skipped, so a rebuild costs about 20 seconds only when something moved. A checkout with no git repository (the docker image, a tarball install) builds with the /source page's empty state. `archilyzer source publish --check` audits without writing, `archilyzer source audit <clone>/.git` checks any clone, `archilyzer build homepage --no-source` removes the published source instead, and `archilyzer doctor` reports the tools, the two files (rule counts and permissions, never their contents) and the last publish. `create-archives.sh` is gone. See PUBLISH.md, "The source mirror (homepage)".
- **umtool reads the corpus from its checkout (or `TRANSCRIPTS_DIR`), and the song project's data defaults to `~/.local/share/archilyzer/song`.** If yours is elsewhere, link it there before restarting umtool: `mkdir -p ~/.local/share/archilyzer && ln -s <where the data is> ~/.local/share/archilyzer/song` (the data stays where it is). With no `CHANNELS_DIR`, umtool reads the corpus at `$TRANSCRIPTS_DIR/channels`, else the checkout's own `transcripts/channels`; it used to fall back to an absolute path that existed on one machine only. The song project's videos default to `~/reports/quartering-uh-song/videos`; `SONG_DIR` and `VIDEO_ROOT` still win. The song project's tracked manifests record their paths relative to the song folders, and the twenty one-off `umtool/song/*.sh` run logs, which only ever ran on the machine that wrote them, are gone.
+- **A social icon pasted with only a width and height is accepted, and each social link can be shown in a header.** The social-link editors (Settings, a site's form) refused an SVG with no `viewBox`, so a vendor's logo file as downloaded, which often carries only its size, was refused. On save, a root with a numeric width and height (unitless or px) and no viewBox is now given `viewBox="0 0 W H"`; a percentage, `em`, or a missing or zero side is still refused. Each link has a **Show in header** checkbox, stored as `featured: true` only when checked, with the hint "With none checked, the header shows the last four.": a header shows at most four links, the checked ones when any is checked, else the last four (the homepage's header reads it). A file with neither is read and rendered as before. `SETTINGS.md` and `SITE.md` list `featured`.
+- **A social icon is checked by what it may contain, on save and every time it is shown, and a refused one says why.** An icon must be one well-formed `<svg>` of shapes, groups, gradients, clips, masks, filters, text and simple animation, with SVG presentation attributes: no script, `style` block, `foreignObject`, link, embedded image or HTML element; no event handler, however it is written; a reference only to something inside the icon, even when it is spelled with character references. Comments, a leading XML declaration and a plain DOCTYPE are removed. A refused save ends with the reason ("… has an invalid SVG: it has an event handler attribute.", "… it links to something outside the icon.", "… it has an element an icon has no use for (style).") and never repeats the markup. A stored icon that fails the check — a file edited by hand, or saved by an older build — is not shown: the homepage and every site's footer show the link's label instead. A file saved from Illustrator or Inkscape may need its `<style>` block, `<metadata>` or `inkscape:` attributes removed first.
## [0.10.0] - 2026-09-28
- **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage.
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -2,6 +2,12 @@
## [Unreleased]
+- **The social links are in the header, at every width, beside one Options gear.** 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 gear, all four spaced alike. From 768 px wide the bar is wordmark, nav, icons, gear; below 768 px it is wordmark, icons, gear, and the nav has the rule below to itself, where its four links fit without scrolling. The header shows at most four links: the ones marked **Show in header** when any is, else the last four. A narrower bar shows the last of those that fit: with a mouse three from 360 px and two below; on a touch screen three from 390 px, two from 340 px and one below. No width from 320 px up scrolls sideways. The footer shows them all. Each is an icon named by its label, with no text beside it.
+- **One Options button in place of the two theme buttons.** The header's theme menu and base toggle are one gear, **Options**, which opens a dialog with **Base** (System, Light, Sepia, Dark) and **Accent** (the seven named accents, Signal tagged *default*) as radio lists. It opens on the ground in force; a choice applies at once and the dialog stays open; Escape, Close or a click outside closes it.
+- **Changelog is in the footer only.** The header's nav is Docs, Source, Downloads and Stats; the footer's Sections list and the 404 page keep Changelog.
+- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light, Sepia and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5).
+- **Larger social links, with a focus ring.** Each icon, in the header and the footer, is a 36 px target around its 20 px glyph (44 px on a touch screen), in the muted text colour and the text colour on hover; the footer's were 20 px, in the faint colour, with no ring. Keyboard focus draws a 2 px ring in the accent, and in high-contrast mode the browser's own focus outline. An icon of two or more colours keeps its colours.
+- **The e2e no longer reads the checkout's `settings.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient, one colour, and a two-colour disc pasted with only its size), put through the same check a save runs, and `SITES_DIR` points at an empty directory, so no `homepage.json` in the checkout can override them. `e2e/social.spec.ts` covers the header and the footer rows (and that a stored icon that would run script is shown as its label and never runs), `e2e/options.spec.ts` the Options dialog, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the theme through one helper, `chooseTheme` (`e2e/helpers.ts`).
- **The source is on the site, with its history: `/source/`.** A new **Source** page (and nav entry) gives `git clone https://archilyzer.pages.dev/source/archilyzer.git`, a read-only mirror of the main branch regenerated with every deploy, with its head, the private commit it reflects, a link to browse every file raw at `/source/tree/`, and the tarball with its size and sha256. Commit ids differ from the private repository's, because machine paths are scrubbed on the way out, and the page says so. A build without a published source says "No source published in this build." instead of offering a clone. The Downloads tarball is now regenerated by every build (its commit is the mirror's), and the page points at the mirror for history. The docs that said there is no public repository (*Install*, the FAQ, *What is Archilyzer*) now say how to clone. Below `md` the header's nav drops to its own row, as it did below `sm`, because five labels no longer fit beside the wordmark. `_headers` serves the raw tree as plain text.
- **The docs' *Building several sites at once* page says what Build all does.** It called the container pipeline opt-in, turned on in the settings. Build all sites builds every site in parallel in containers whenever a container engine is available, and one after another when none is; there is nothing to switch on.
- **A single-colour social icon shows on every ground.** The footer's social icons are the operator's (`homepage.json`'s, else `settings.socialLinks`), normalized when they are saved (`normalizeSocialSvg`, release 11 slice O1). An icon drawn in one colour now takes the footer's colour throughout; before, a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted. "No fill", gradients, masks, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma follows the footer too. It applies when the settings are next saved, then needs a rebuild and deploy of the homepage.
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -146,10 +146,20 @@ changed at integration. Nothing was deployed, cut, pushed or restarted; :3001 st
8. Optional: rebuild + restart umtool on :3050. Its app changed only in `lib/tools.mjs` (the
shared tool probe) and two test-only variable names; O5's render changes already reach it from
disk (the live :3050 spawns `report-to-video/*`), and an unbranded render is byte-identical.
-- **Planned, not started (2026-09-28): release 14** — the social icons in the export header, one
- Options button for the theme, the Sites dropdown replaced by a link to the Archilyzer home, and a
- clear screen until the first Search (`plans/export-header-first-search.md`; slices H3, H1, H2, S1,
- and S2 as a candidate). Waits on `export/gumroad-tip` merging.
+- **Release 14 (2026-09-28): slice HP built, not merged** — the social icons in the export header,
+ one Options button for the theme, the Sites dropdown replaced by a link to the Archilyzer home,
+ and a clear screen until the first Search (`plans/export-header-first-search.md`; slices HP, H3,
+ H1, H2, S1, and S2 as a candidate). It waits on nothing: a link the operator adds is an entry in
+ `settings.json` `socialLinks`, and the earlier tip-link branch is parked, not merged. **HP**
+ (`homepage/social-visible`, record in `release-14.md`) puts the social row and one "Options"
+ gear in the homepage's header at every width through shared `common/components/SocialLinks.tsx`
+ and `OptionsDialog.tsx` + `ThemeRadios.tsx` (H1 and H2 adopt them), moves Changelog to the
+ footer, names each Official Instances card with the site's wordmark (lead tinted in its accent;
+ `wordmarkLead` in the summary), checks a social icon by an allowlist on save and at render
+ (`common/lib/socialSvg.ts`), gives a sized SVG with no viewBox its viewBox, and adds `featured`
+ ("Show in header") to a social link. Reviewed SHIP AFTER FIXES; the fixes are in, and the
+ branch's history was rewritten once so no vendor file entered it. Merge order:
+ `fix/stats-cache-key` → HP → H3 → H1+H2 → S1.
- **Next candidates:** one-core Phase 5 (projects join the core, `plans/one-core.md`); the Diagnostics
cards keeping their retry log (O3's found-and-left); `ChartView.tsx`'s five-slot cycle reaching
`--chart-6` (O2); O5's two wording lows in `svg-faces.mjs` / the README (kerning is not
diff --git a/plans/export-header-first-search.md b/plans/export-header-first-search.md
@@ -24,7 +24,16 @@ main...<branch>` is empty for the search and header components on all five).
- 2026-09-28: "I want the social icons (particularly gumroad) to be more accessible, I'm thinking
we move changelog link to footer, theme and dark/light behind a single options modal button, and
instead of sites dropdown just link to archilyzer home which lists official instances."
-- Standing: **no copy** beside the tip link; the mark is vendored unmodified (`gumroad-tip-link.md`).
+- 2026-09-28: a link the operator adds is an entry in `settings.json` `socialLinks`, not code; no
+ vendor file goes into the repository, not even as a test fixture.
+- 2026-09-28: the homepage's social links more visible; a change to the social-link schema is
+ allowed where it has a reason (built as slice HP, below).
+- 2026-09-28, on the homepage (slice HP): the Changelog link moves to the footer; Base and Accent
+ move behind a single options button with a gear icon that opens a modal; the gear sits in the
+ social icons' rhythm, and the narrow header carries the icons and the gear in its bar; on
+ Official Instances each site's name uses the bold-lead effect of the sites' own headings, with a
+ slight tint or underline in the site's accent.
+- Standing: **no copy** beside any social link: icons with accessible names only.
## Decisions and assumptions
@@ -42,20 +51,22 @@ ASSUMED by the planner (2026-09-28) — each is one line to reverse, and the ope
| A6 | The footer's social row | Stays, as well as the header's — nothing disappears for a reader who looks there. |
| A7 | Phones | The icons are visible in the header at every width, not inside the slide-out menu. |
| A8 | The options modal | Holds Base and Accent and nothing else. |
-| A9 | The homepage app's own header | Unchanged, except the anchor of H3. Its icons stay in the footer's Elsewhere row. |
+| A9 | The homepage app's own header | Carries the social row and the Options gear since slice HP (built), as well as the footer's Elsewhere row; H3 adds only the anchor. |
| A10 | Deferring the summaries fetch until the first Search | NOT in S1. Measured and written up as S2, a candidate, because `/ask` reads the same data. |
## Dependency graph
```
-export/gumroad-tip (built 1c76cd95, in review) ──► H1 ──► H2
+HP (homepage social row, Options, cards + schema, BUILT) ──► H1 ──► H2
H3 (homepage anchor) ── independent; H1's link needs it DEPLOYED to land on the list
S1 (clear screen) ── independent of H*; touches no header file
S2 (defer summaries) ── after S1, only on the operator's word
```
-H1 then H2 are stacked on one branch (both rewrite `Header.tsx` and `MobileMenu.tsx`). S1 and H3
-run in parallel with them on their own branches. Merge order: gumroad → H3 → H1+H2 → S1.
+H1 then H2 are stacked on one branch (both rewrite `Header.tsx` and `MobileMenu.tsx`), branched
+after HP merges: H1 adopts HP's `SocialLinks` component. S1 and H3 run in parallel with them on
+their own branches. Merge order: HP → H3 → H1+H2 → S1. HP and H3 share only
+`homepage/CHANGELOG.md` (`[Unreleased]`).
## Verified facts the implementer must not re-derive
@@ -89,8 +100,7 @@ run in parallel with them on their own branches. Merge order: gumroad → H3 →
**The footer** (`export/app/components/Footer.tsx`): row 1 (`:32-75`) eyebrow links Downloads /
Offline / Use with AI (`:66-74`) — where Changelog goes; row 2 (`:76-114`) credit + social `<ul>`
(`:97-113`). Social links come from `resolveSocialLinks(site, getSettings())` (`:22`), inlined SVG
-strings sized by `sizeSocialSvg` (`:108`); the Gumroad branch appends its `<li>` last,
-unconditionally.
+strings sized by `sizeSocialSvg` (`:108`).
**Accessibility, measured in code:** social icons are `w-5 h-5` (20 px) with no padding on the
link (`Footer.tsx:107`) — under WCAG 2.5.8's 24 px minimum; the links have no focus ring of their
@@ -131,13 +141,63 @@ trigger, `ui/button.tsx:28`).
**Specs on the header controls:** `theme-accent.spec.ts` (`:32` the trigger by name, `:45-72`
`menuitemradio`), `theme.spec.ts` (`:64-73` the toggle by `/switch to/i`), `responsive.spec.ts:85-97`
(the Sheet's `radio` "Sepia"), `brand.spec.ts:81` (`onBase`). No spec opens the Sites dropdown; no
-spec names the Changelog link. On the Gumroad branch `expectGumroadMarkLast`
-(`export/e2e/helpers.ts`) asserts the mark is the last `<li>` of the FOOTER's row.
+spec names the Changelog link.
UNVERIFIED: whether `HubHome` renders `SearchResults` (`export/app/(workspace)/page.tsx:12-14`
branches to it and bypasses `SiteWorkspace`); Back-button scroll restoration into the virtualized
list; the summaries payload per site (FACTS and the 2026-09-25 session give different figures).
+## Slice HP — the homepage shows the social links where they are seen (branch `homepage/social-visible`, BUILT)
+
+Added 2026-09-28 on the operator's ruling above; the record is `release-14.md`, "Slice HP, as
+shipped". What it built, so H1 does not re-derive it:
+
+1. **`common/components/SocialLinks.tsx`** — one row for any header or footer. Props: `links`,
+ `placement: "header" | "footer"`, `className` (on the `<ul>`); nothing site-specific inside.
+ The `<ul>` carries `data-social-links="<placement>"`. Each link is a 36 px key (`size-9`) around
+ the 20 px glyph, 44 px under a coarse pointer (`pointer-coarse:size-11`, a built-in Tailwind v4
+ variant), `text-muted-foreground` → `hover:text-foreground`, a `focus-visible:ring-2
+ focus-visible:ring-ring` ring, and `not-forced-colors:focus-visible:outline-none`, so in forced
+ colours the browser's own outline stays (a box-shadow is not drawn there). `aria-label` and
+ `title` are the link's label; there is no text. Every icon passes the save-time check again
+ before it is inlined (`safeSocialSvg`); one that fails shows the link's label as text.
+2. **The header bound, `headerSocialLinks`** (`common/lib/socialLinks.ts`, pure): at most FOUR —
+ the links marked `featured` when any is marked, else all of them; of those, the last four. The
+ `"header"` placement applies it; the footer shows every link.
+3. **Ids are scoped per copy** (`scopeSvgIds`): an id resolves to the first element carrying it,
+ and a gradient defined inside a `display: none` copy of an icon does not paint in the visible
+ copy. Any header that renders the row twice (one copy per breakpoint) needs this; the
+ component does it, so H1 gets it for free.
+4. **Schema:** the icon's SVG is checked by an allowlist, tag by tag, on save AND at render
+ (`common/lib/socialSvg.ts`; `safeSocialSvg` in `lib/socialLinks.ts` is the render half, and the
+ export footer already goes through it). A refused save names the reason class. A root with a
+ numeric `width`/`height` (unitless or px) and no `viewBox` gets `viewBox="0 0 W H"`.
+ `SocialLink.featured?: boolean` (stored only when true), with a "Show in header" checkbox in the
+ editor's `SocialLinksField`. `sizeSocialSvg` moved to `lib/socialLinks.ts` (re-exported from
+ `settingsSchema`).
+5. **`common/components/OptionsDialog.tsx`** — one gear button, accessible name "Options",
+ dressed exactly as a social key (36 px, 44 px under a coarse pointer, no border, the same hover
+ square and ring). It opens a modal on the shared `common/components/ui/dialog.tsx`, used as it
+ is (the editor's command palette renders it too; OptionsDialog styles its own panel border and
+ its own 36 px Close), titled "Options", opening on the checked Base radio, holding
+ **`common/components/ThemeRadios.tsx`**: the Base and Accent native radiogroups, extracted from
+ the export's `MobileMenu` unchanged — `MobileMenu` renders it now (`namePrefix="mobile-theme"`,
+ `groupClassName="mt-4 px-2"`), and the export's `theme`, `theme-accent`, `responsive` and
+ `brand` specs pass. A pick applies at once and the dialog stays open; Escape, Close and a click
+ outside close it; focus is trapped and returns to the gear; the page does not scroll.
+ `ThemeMenu` and `ThemeToggle` are unchanged and still rendered by the export and the editor.
+6. **The homepage's header:** one group — the social row, then the gear, boxes touching (glyph to
+ glyph 16 px) — in the bar at every width. ≥ `md`: wordmark · nav · group, the nav 32 px before
+ the group's first box. < `md`: wordmark · group, and the four nav links alone on the rule below
+ (they fit 360 px; no fade). The bar shows the last n of the header's links by width and
+ pointer — mouse: 4 from 768, 3 from 360, else 2; touch: 4 from 768, 3 from 390, 2 from 340,
+ else 1 — one CSS rule per step on the row (`[&>li:nth-last-child(n)]:hidden`); nothing from
+ 320 px up scrolls sideways. Changelog is in the footer only (`homepage/app/lib/nav.ts`:
+ `HEADER_NAV`, `FOOTER_NAV`).
+7. **The Official Instances cards** set each site's title with the shared `Wordmark` when the
+ summary carries its `wordmarkLead` (new, optional, still v5), the lead tinted in the site's own
+ accent (`siteAccentColor`). The hub's cards are not touched.
+
## Slice H3 — the homepage's instances anchor (branch `r14/home-anchor`)
Owns `homepage/app/page.tsx`, `homepage/e2e/marketing.spec.ts`, `common/lib/project.ts` (one
@@ -149,24 +209,28 @@ constant), `homepage/CHANGELOG.md`.
link lands on the top of the page; that is accepted and said in the record.
4. Gates: tsc, homepage unit, homepage e2e, `archilyzer build homepage --no-source`.
-## Slice H1 — the header carries the social row (branch `r14/header`, after gumroad merges)
-
-Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`, a NEW
-`common/components/SocialRow.tsx`, `export/e2e/{helpers.ts,site-branding.spec.ts,brand.spec.ts,
-responsive.spec.ts}`, a NEW `export/e2e/header.spec.ts`, `export/e2e-hub/official-instances.spec.ts`,
-`export/CHANGELOG.md`, `plans/export-responsive-redesign.md` (the header contract, `:258-260`).
-
-1. **One component, two places.** `SocialRow` renders the operator's links and then the Gumroad
- mark, last. Props: `placement: "header" | "footer"`. The footer uses it as it renders today.
-2. **Tap area and focus.** Each link is a 36 px box (`size-9`, the menu trigger's size) around the
- 20 px glyph; on coarse pointers 44 px (`pointer-coarse:` literal classes). A
- `focus-visible:ring-2 focus-visible:ring-ring` ring. The Gumroad ring and forced-colours outline
- move with the mark, unchanged.
-3. **The header, wide:** brand · nav · `SocialRow` · the Archilyzer link · the theme controls (H2).
- **Narrow:** brand · `SocialRow` · the theme controls · the menu trigger. With three icons at
- 36 px the narrow header is about 108 px of icons; if the operator configures more links than fit
- at 360 px, the row keeps the LAST three (the mark is last) and the rest stay in the footer.
- State the rule in a comment and test it with five links.
+## Slice H1 — the header carries the social row (branch `r14/header`, after HP merges)
+
+Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`,
+`export/e2e/{helpers.ts,site-branding.spec.ts,brand.spec.ts,responsive.spec.ts}`, a NEW
+`export/e2e/header.spec.ts`, `export/e2e-hub/official-instances.spec.ts`, `export/CHANGELOG.md`,
+`plans/export-responsive-redesign.md` (the header contract, `:258-260`).
+
+1. **Adopt HP's `SocialLinks`** (`common/components/SocialLinks.tsx`) in the export header
+ (`placement="header"`) and footer (`placement="footer"`), replacing the footer's hand-written
+ `<ul>`. The component is used as it is; a change it needs goes into HP's component, with its
+ homepage spec re-run.
+2. **Tap area and focus** come with it: 36 px keys, 44 px under a coarse pointer, the ring in
+ `--ring`, the browser's outline in forced colours. The export footer's icons change from
+ `hover:text-brand` to the component's `hover:text-foreground`; say so in the changelog.
+3. **The header, wide:** brand · nav · `SocialLinks` · the Archilyzer link · the theme controls
+ (H2). **Narrow:** the row visible without scrolling at 320 px (the reflow width, 400 % zoom).
+ HP's measurement: in the homepage's bar beside a 148–152 px wordmark, three 36 px keys and the
+ gear need 348–352 px, four do not fit at 360, and three at 44 px need 380 px — hence HP's
+ width × pointer steps (mouse: 4 from 768, 3 from 360, else 2; touch: 4 from 768, 3 from 390,
+ 2 from 340, else 1). Measure this header (a long `headerTitle`, the Options button, the menu
+ trigger) and choose its own steps; record the numbers. The bound is HP's: at most four, the
+ `featured` ones when any is marked, else the last four; the rest stay in the footer.
4. **Sites dropdown → one link.** `SiblingSwitcher` is deleted. In its place a text link
"Archilyzer" to `INSTANCES_URL`, opening in the same tab, with an accessible name that says
where it goes ("Archilyzer — official instances"). Also in the slide-out menu, replacing the
@@ -174,45 +238,44 @@ responsive.spec.ts}`, a NEW `export/e2e/header.spec.ts`, `export/e2e-hub/officia
5. **Hub backlink removed** from the header and the slide-out menu (A5). `hubUrl` and
`resolveHubUrl` stay; an old `site.json` with the key still loads.
6. **Changelog** leaves both header places and joins the footer's row-1 links after Use with AI.
-7. **Tests.** `expectGumroadMarkLast` takes the row as a parameter; assert it for the header row
- and the footer row on a site page and on the hub. New `header.spec.ts`: the header row is
- visible without scrolling at 360, 768 and 1280 px; each link's box is ≥ 24 px (and ≥ 44 px under
- a coarse pointer emulation); the focus ring shows; the Archilyzer link's `href`; no `Sites`
- button; Changelog is in the footer and not in the header; five configured links → three in the
- header, five in the footer. `responsive.spec.ts`: the slide-out menu's contents as changed.
-8. **No copy.** No text beside any icon; the accessible names are the services' names.
+7. **Tests.** New `header.spec.ts`: the header row is visible without scrolling at 360, 390, 768
+ and 1280 px; each link's box is ≥ 24 px (and ≥ 44 px under a coarse pointer emulation,
+ `hasTouch: true`); the focus ring shows; the Archilyzer link's `href`; no `Sites` button;
+ Changelog is in the footer and not in the header; six configured links → four in the header,
+ six in the footer; two marked `featured` → those two. The export e2e's social links come from
+ its own fixture, never the operator's settings. `responsive.spec.ts`: the slide-out menu's
+ contents as changed.
+8. **No copy.** No text beside any icon; the accessible names are the links' labels.
9. Gates: tsc; common tests; `pnpm --filter export exec next build` (site, fixture site, hub);
export e2e — the specs above plus `theme.spec.ts`, `theme-accent.spec.ts`,
`related-sites.spec.ts`; hub e2e `official-instances.spec.ts`; screenshots of the header at
- 360 / 768 / 1280 px on Light, Sepia and Dark to `~/reports/release-14/shots/`.
+ 360 / 390 / 768 / 1280 px on Light, Sepia and Dark to `~/reports/release-14/shots/`.
## Slice H2 — one options button (same branch, stacked on H1)
-Owns `common/components/{ThemeMenu,ThemeToggle}.tsx`, a NEW `common/components/OptionsDialog.tsx`,
-`export/app/components/{Header,MobileMenu}.tsx`, `export/e2e/{theme,theme-accent,responsive,
-brand}.spec.ts`, `export/CHANGELOG.md`. **Check first who else renders `ThemeMenu`/`ThemeToggle`**
-(`homepage/app/components/Header.tsx:62-65` does; the editor may): those keep the old controls.
-The new dialog is the export header's only.
+Owns `export/app/components/{Header,MobileMenu}.tsx`, `export/e2e/{theme,theme-accent,responsive,
+brand}.spec.ts`, `export/CHANGELOG.md`. The editor keeps `ThemeMenu`/`ThemeToggle`
+(`editor/app/layout.tsx`).
-1. One icon button, accessible name **"Options"**, 36 px, visible at every width, where the toggle
- is today. It opens `OptionsDialog` on `common/components/ui/dialog.tsx` (its first importer):
- title "Options", two native radiogroups — **Base** and **Accent** — the markup `MobileMenu`
- already uses (`:124-147`), extracted into one `ThemeRadios` component used by both.
+1. **Adopt HP's `OptionsDialog` and `ThemeRadios` in the export header** (both in
+ `common/components/`, built by slice HP): one gear button, accessible name **"Options"**, 36 px,
+ visible at every width, where the toggle is today, opening the "Options" dialog with the Base
+ and Accent radiogroups. `MobileMenu` already renders `ThemeRadios` (HP). A change the export
+ needs goes into the shared component, with the homepage's `options.spec.ts` re-run.
2. `ThemeMenu` and `ThemeToggle` leave the export header. The slide-out menu keeps its radiogroups
(one tap there is cheaper than opening a dialog from inside a sheet).
-3. A choice applies at once and the dialog stays open; Escape, the close button and a click
- outside close it; focus returns to the trigger.
+3. As built: a choice applies at once and the dialog stays open; Escape, the close button and a
+ click outside close it; focus is trapped and returns to the trigger.
4. **The cost, recorded:** changing the ground is two actions where the toggle made it one. The
fallback, if the operator asks, is the toggle kept beside the Options button — one line in
`Header.tsx`.
5. **Labels are contracts.** `theme.spec.ts` and `brand.spec.ts` drive the toggle by
`/switch to/i`; `theme-accent.spec.ts` drives `Choose theme` and `menuitemradio`. Rewrite them
to open Options and pick a `radio` by name; keep one helper, `chooseTheme(page, {base, accent})`,
- in `export/e2e/helpers.ts`, and use it everywhere a spec changes theme through the UI. Specs that
- set `localStorage` directly are untouched. The no-flash assertions (`data-theme-ready`) are
- untouched.
-6. Gates: as H1, plus an axe-style check that the dialog traps focus and restores it, and the
- homepage e2e `theme.spec.ts` to prove the homepage's controls did not move.
+ in `export/e2e/helpers.ts` (the homepage's is `homepage/e2e/helpers.ts`), and use it everywhere a
+ spec changes theme through the UI. Specs that set `localStorage` directly are untouched. The
+ no-flash assertions (`data-theme-ready`) are untouched.
+6. Gates: as H1, plus the homepage e2e `options.spec.ts` and `theme.spec.ts`.
## Slice S1 — a clear screen until the first Search (branch `r14/first-search`)
@@ -271,9 +334,10 @@ until the user hits search") and needs the operator's word, because it changes `
Nothing here deploys inside a slice. After the merges: one export release cut, a rebuild and
deploy of every site and the hub (the header is in every build), and the homepage deployed FIRST
-so `#instances` exists before any site links to it. The homepage deploy runs the source publish
-step of release 12 — its denylist must be complete (`release-12.md`, "Rollout", step 0). Live
-checks: the header row visible at 390 px on one site; the mark's `href`; the Archilyzer link lands
+so `#instances` exists before any site links to it. HP adds an editor cut and a :3001 rebuild and
+restart, for the "Show in header" checkbox and the normalizer's viewBox rule, which are in the
+editor's save path. The homepage deploy runs the source publish step of release 12 — its
+denylist must be complete (`release-12.md`, "Rollout", step 0). Live checks: the header row visible at 390 px on one site; each icon's `href`; the Archilyzer link lands
on Official Instances; a plain visit shows the footer without scrolling; a `qt=` link still shows
results. An HTML runbook at `~/reports/release-14/RUNBOOK.html`.
@@ -283,9 +347,9 @@ results. An HTML runbook at `~/reports/release-14/RUNBOOK.html`.
- **A first-time visitor sees no videos.** The site's front page becomes a search box over a
count. That is the operator's choice; the hint line is the only instruction.
- **Icons in a 360 px header** compete with the brand's title: a long `headerTitle` wraps
- (`min-h-14` allows it). The screenshots at 360 px are the check; the rule of three keeps the row
+ (`min-h-14` allows it). The screenshots at 360 px are the check; the bound of four keeps the row
bounded.
-- **A trademark in the header** is more prominent than one in the footer. The mark stays
- unmodified, unlabelled and only a link.
+- **An operator's icons in the header** are more prominent than in the footer. Each stays as the
+ operator pasted it (normalized, never redrawn), unlabelled and only a link.
- **Specs outside the known three** may lean on the listing at load through a hydration wait; the
full-suite run in S1's gate is how they are found.
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -0,0 +1,339 @@
+# Release 14 — the social icons within reach, and a clear screen until the first Search
+
+`main` at `ac438bbc` (release 12 merged and not rolled out; release 13 is a parallel session's).
+Plan: [`export-header-first-search.md`](export-header-first-search.md), written 2026-09-28, with
+slice HP added to it on the operator's ruling of the same day. Rules:
+`plans/tools/implementer-rules.md`, with the commit trailer this release's prompts give.
+
+**The operator's standing choices** (the plan, "The operator's words"; not re-opened):
+- **No copy** beside any social link: icons with accessible names only.
+- **A link the operator adds is an entry in `settings.json` `socialLinks`, never code.** The earlier
+ tip-link branch is parked and not merged; nothing is taken from it.
+- **No vendor file in the repository**, not even as a test fixture: the tracked tree is published
+ by the source mirror.
+- **Nothing is edited in the primary checkout**; each slice has its own worktree, and the parent
+ merges with `git merge --no-ff` only on a clean tree.
+
+## The slices
+
+| Slice | Branch | What | Owns |
+|---|---|---|---|
+| HP | `homepage/social-visible` | The homepage's social row and one Options gear in the header at every width, one shared `SocialLinks` component, larger keys with a focus ring; Changelog in the footer only; the instance cards' names as the site's wordmark; the social icon checked by an allowlist on save and at render; a sized SVG with no viewBox gets one; `featured` ("Show in header") on a social link | `common/components/{SocialLinks,OptionsDialog,ThemeRadios,Wordmark}.tsx`, `common/lib/{socialSvg,socialLinks}.ts` + tests, `common/lib/settingsSchema.ts` (the social-link type, parser, docs; the normalizer moved to `socialSvg.ts`), `common/lib/normalizeSocialSvg.test.ts`, `common/lib/{settings,site,homepage}.ts` (the save errors), `common/lib/{homepageSummary,siteColor}.ts`, `homepage/app/components/{Header,Footer,ArchiveCards}.tsx`, `homepage/app/lib/{nav,summary}.ts`, `homepage/app/not-found.tsx`, `homepage/e2e/**` (the fixtures, `helpers.ts`, the new and the rewritten specs), `homepage/playwright.config.ts`, `export/app/components/{MobileMenu,Footer}.tsx` (the `ThemeRadios` swap; the footer's read path), `editor/app/components/SocialLinksField.tsx` + `socialLinksJson{,.test}.ts`, `editor/app/{settings,sites}/actions.ts` (the save errors), `editor/e2e/settings.spec.ts`, `SETTINGS.md`, `SITE.md` |
+| H3, H1, H2, S1 | per the plan | per the plan | per the plan |
+
+**Order:** HP → H3 → H1+H2 → S1. The shared files are `editor/CHANGELOG.md`,
+`homepage/CHANGELOG.md` (`[Unreleased]`) and this record.
+
+## Record
+
+### Slice HP, as shipped — the homepage shows the social links where they are seen (2026-09-28)
+
+Branch `homepage/social-visible` off `main` `ac438bbc`, worktree `~/Projects/homepage-social-visible`
+(block #3: editor 3301, test 3311, homepage e2e 3340, homepage static 3331), one Opus implementer.
+Scratch files `hp-*` in the job's `tmp`. The rulings, all 2026-09-28, built in this order on one
+branch:
+1. The homepage's social links more visible; a change to the social-link schema where it has a
+ reason.
+2. The Changelog link moves to the footer; Base and Accent move behind a single options button with
+ a gear icon that opens a modal.
+3. The gear sits in the social icons' rhythm, with no gap of its own; the narrow header carries the
+ icons and the gear in its bar.
+4. On Official Instances, each site's name uses the bold-lead effect the sites' own headings use,
+ with a slight tint or underline in the site's accent colour.
+5. (The review, ruled by the parent.) No vendor file in the repository; the icon check hardened on
+ save and at render; the shared dialog wrapper unchanged.
+
+**The branch's history was rewritten once** (after the review, ruling 5): its first nine commits,
+one of which added a vendor file as a test fixture, were replaced by the five commits below,
+re-committed from the same tree in the same logical steps, each tsc-clean. No vendor file is in
+any commit of `main..HEAD`.
+
+**What shipped.**
+- **One row, `common/components/SocialLinks.tsx`.**
+ - Props `links`, `placement: "header" | "footer"`, `className` (on the `<ul>`, which carries
+ `data-social-links="<placement>"`); nothing site-specific.
+ - Each link is a 36 px key (`size-9`) around the 20 px glyph, 44 px under a coarse pointer
+ (`pointer-coarse:size-11`, Tailwind v4's own variant). The glyph is `text-muted-foreground`,
+ `hover:text-foreground`, with a `hover:bg-muted` key, so a multi-colour icon has a hover state
+ too. Focus is `focus-visible:ring-2 focus-visible:ring-ring` with
+ `not-forced-colors:focus-visible:outline-none`: in forced colours a box-shadow is not drawn,
+ and the browser's own outline is left in place. Nothing is drawn at rest.
+ - `aria-label` and `title` are the label, `target="_blank" rel="noopener noreferrer"`, and the
+ link has no text.
+ - The header placement shows at most four (`headerSocialLinks`, below); the footer every link.
+ - Every icon passes the save-time check AGAIN before it is inlined (`safeSocialSvg`, below). One
+ that fails is not injected: the link shows its label as text.
+ - The ids inside each inlined icon are scoped per row and per link (`scopeSvgIds`, below).
+ - It uses only `useId`, so it works in a server or a client tree.
+- **The header bound, `headerSocialLinks`** (`common/lib/socialLinks.ts`, pure). At most four: the
+ links marked `featured` when any is marked, else all of them; of those, the last four. Marking is
+ choosing: one marked of three shows one.
+- **The homepage header** (`homepage/app/components/Header.tsx`): one group — the social row, then
+ the Options gear — in the bar at every width. The gear is dressed as a key and its box sits
+ directly after the last link's, so every glyph is 16 px from the next. From `md` (768 px) the bar
+ is wordmark · nav · group, the nav's last link 32 px before the group's first box (40 px before
+ its first glyph; the nav's own links are 24 px apart). Below `md` it is wordmark · group, and the
+ four nav links have the rule below to themselves, where they fit at 360 px: no fade, nothing
+ scrolls (`overflow-x-auto` stays as a guard for larger text). The bar shows the LAST n of the
+ header's links, by width and pointer (see "The header layout").
+- **Changelog is in the footer only** (ruling 2). `homepage/app/lib/nav.ts` declares `HEADER_NAV`
+ (Docs, Source, Downloads, Stats) and `FOOTER_NAV` (the same, then Changelog). The footer's
+ Sections and the 404 page read `FOOTER_NAV`.
+- **One Options gear** (ruling 2): `common/components/OptionsDialog.tsx`, built to slice H2 so the
+ export can adopt it.
+ - The trigger is named "Options", 36 px (44 px under a coarse pointer), dressed as a social key.
+ - It opens a modal on the shared `common/components/ui/dialog.tsx`, used exactly as it is on
+ `main` (the editor's command palette renders it too). OptionsDialog styles its own panel
+ (`border-border`, because no app sets a base border colour and Tailwind v4's bare `border` is
+ currentColor) and its own Close (a 36 px key with the ring; the wrapper's is turned off).
+ - The dialog, titled "Options", holds `common/components/ThemeRadios.tsx`: the Base and Accent
+ native radiogroups, extracted from the export's `MobileMenu` unchanged. `MobileMenu` renders
+ them now; the swap kept every name, role and class.
+ - It opens with focus on the CHECKED Base radio. A pick applies at once and the dialog stays
+ open. Escape, Close and a click outside close it; focus is trapped and returns to the gear; the
+ page does not scroll (Radix).
+ - `ThemeMenu` and `ThemeToggle` are unchanged, and the export and the editor still render them.
+- **The instance cards' names** (ruling 4).
+ - `homepage-summary.json` `sites[]` gains an optional `wordmarkLead`: site.json's, resolved
+ against `siteTitle` by `lib/brand.ts` `wordmarkLeadFor`, the resolver the sites' header and
+ `siteSchema` use. It is still version 5: nothing reads the version to accept a file.
+ - The homepage's loader keeps a lead only when it is a proper prefix of the title
+ (`withCheckedLead`), so an older or hand-edited summary shows the plain title.
+ - `ArchiveCards` sets the title with the shared `Wordmark` (lead 720, suffix 380) at the card
+ title's size, and tints the lead in the site's own accent: `siteAccentColor`, `siteColor`'s
+ accent half. A site with a lead and no accent keeps the foreground. `Wordmark` gains
+ `leadStyle`.
+ - `headerTitle` is not carried: the card's accessible name is `siteTitle`, and every live site
+ has the two equal.
+- **The social icon's SVG, checked by an allowlist** (`common/lib/socialSvg.ts`, pure;
+ `settingsSchema.ts` re-exports `normalizeSocialSvg` and `socialSvgProblem`). The allowlist was
+ chosen over the denylist: the element and attribute lists stay small, and every shape the
+ existing tests and icons use passes.
+ - The input is read tag by tag. It must be ONE well-formed `<svg>`: tags closed in order,
+ attributes separated by HTML whitespace and quoted, no `<!…>`, CDATA or processing
+ instruction. Comments, a leading XML declaration and a leading DOCTYPE with no internal subset
+ are removed first.
+ - Elements: shapes, groups, `defs`, `symbol`, `use`, gradients, `stop`, `pattern`, `clipPath`,
+ `mask`, filters, `text`/`tspan`, `title`/`desc`, and `animate`/`animateTransform`/`set`. No
+ `script`, `style`, `foreignObject`, `a`, `image` or HTML element.
+ - Attributes: the SVG presentation, geometry, filter and animation set, plus `aria-*`, `data-*`
+ and `xmlns:*`. No event handler, whatever separates it.
+ - Values: character references (numeric and named, with or without `;`) are decoded and the
+ whitespace a browser ignores in a URL dropped before the checks. No `javascript:` or
+ `vbscript:`. `href`/`xlink:href` only `#id`; every `url(…)` only to `#id`; a `style` with no
+ escape, `@import`, `expression(`, `behavior` or binding; an animation never targets `href` or a
+ handler; ids are plain names (`^[A-Za-z_][\w.:-]*$`).
+ - The normalized OUTPUT is checked again, so no transform can assemble what the input check
+ refused.
+ - `socialSvgProblem` names the reason class. The editor's save error and the writers' errors
+ append it ("… has an invalid SVG: it has an event handler attribute."); none echoes the markup.
+ - `SETTINGS.md` and `SITE.md` (generated) say what an icon may contain.
+- **The read path.** `safeSocialSvg` (`common/lib/socialLinks.ts`) runs the check again at render.
+ `SocialLinks` and the export footer inline only what passes; a link whose icon fails shows its
+ label as text. Every `dangerouslySetInnerHTML` of a social SVG goes through it (there are two).
+- **The size rule** (ruling 1): a root with no viewBox but a numeric `width` and `height`
+ (unitless or px, decimals, either quote) gets `viewBox="0 0 W H"`, read from the root's own
+ attributes (a size spelled inside another attribute's value does not count). A percentage,
+ `em`, `auto`, a negative, or a missing or zero side is still refused, and a viewBox already
+ there is never replaced.
+- **`featured`**: optional on `SocialLink`, parsed only when exactly `true` and stored only when
+ true, through `parseSocialLinks`, so `settings.json`, `site.json` and `homepage.json` all read
+ it. The editor's `SocialLinksField` has a **Show in header** checkbox per row (Settings and a
+ site's form), and beside it "With none checked, the header shows the last four." An old file
+ with neither change parses byte-identically. `featured` also reaches each site's public
+ `/site.json` (`buildSiteDescriptor` passes the link through): accepted, additive, and the hub
+ does not render federated sites' links.
+
+**The header layout** (built and screenshotted before each choice). Measured: the wordmark link is
+148 px (152 px at 1× device scale), the four-link nav 276 px, a key 36 px (44 px under a coarse
+pointer), the gear the same.
+- **The rule** (one CSS rule per step on the row, stated in `Header.tsx`; the classes' `max-[Npx]`
+ mean "width < N"):
+
+ | Width | Mouse | Touch (coarse pointer) | Why the step |
+ |---|---|---|---|
+ | ≥ 768 | 4 | 4 | the nav, four keys and the gear fit at 768 (348 px free with touch) |
+ | 390–767 | 3 | 3 | four keys and the gear overflowed a 360 px bar by 20 px |
+ | 360–389 | 3 | 2 | three 44 px keys and the gear need 380 px |
+ | 340–359 | 2 | 2 | three 36 px keys and the gear need 348–352 px |
+ | < 340 | 2 | 1 | two 44 px keys and the gear need 336 px |
+
+- **Measured with the rule** (the fixture's three links; the gap between the wordmark and the
+ group, of which 12 px is the minimum): mouse — 320: 20 px, 339: 39, 359: 59, 360: 24, 390: 54;
+ touch — 320: 40, 340: 16, 360: 36, 389: 65, 390: 22. No width from 320 up scrolls sideways.
+- **Two rows at 360 px** (a mouse): the wordmark (20–168), 28 px, the group (196–340: three keys
+ and the gear), the 20 px gutter; below, the four nav links (20–291), 49 px to spare.
+- **How it got here:**
+ - First build, with two 32 px theme buttons and five nav links: three keys in the bar made a
+ 360 and a 390 px page scroll sideways by 36 px, so the keys were pinned at the end of the nav's
+ rule, the nav in that rule below `lg`, and the row rendered twice.
+ - Rulings 2 and 3 freed the bar: one gear, four links, and the group in the bar at every width.
+ The nav is back in the bar from `md`.
+ - The review found 320 px with a mouse scrolled sideways by 8 px (the gear cut). The 340/360
+ steps and the touch step below 340 came from that; the classes now say `max-[390px]`, as the
+ prose says "below 390".
+
+**The cards' accent treatment** (ruling 4). Both treatments were built and screenshotted on the
+three grounds at 390 and 1280 px, from a family-like summary: the six live titles, their site.json
+leads (Jer, Hasan, Ani, Bonnell, Rekieta, Jaso) and their named accents; the numbers are synthetic.
+- **Shipped: the tint.** The lead is in the site's accent, the suffix in the muted foreground.
+ - The accent is one signal on the name and matches the card's stripe.
+ - The underline sat under the card link's own hover underline, and on hover the lead drew two
+ rules. An accent rule also reads as a link state.
+- **Contrast of the lead against the card** (`--surface`): every named accent is at least 4.24:1 on
+ Light, 4.21:1 on Sepia and 6.75:1 on Dark. A custom hex fitted to 4.5:1 on the ground is about
+ 4.1:1 on the card. The e2e checks ≥ 3:1 from the rendered colours.
+- **The swap**, if asked: replace `{ color: accent }` in `ArchiveCards`' `SiteName` with a 2 px
+ accent `text-decoration` on the lead. It is one line.
+
+**Beyond the prompt.**
+- **Icon ids are scoped per copy (`scopeSvgIds`).** An id resolves to the first element carrying
+ it, and a gradient defined inside a `display: none` copy does not paint. The first build rendered
+ the header's row twice; with the ids unscoped, the gradient icon at 360 px lost its body and kept
+ only its themed dot. The header's row is one element now, and the scoping stays: the footer
+ inlines the same icons, and the export's header (H1) may render a row per breakpoint. Only plain
+ ids are prefixed; references in `url(#…)`, `url('#…')`, `url("#…")`, `href` and
+ `xlink:href` are rewritten, case-insensitively for the attribute names. A scoped, sized icon
+ passes the checker again (unit-tested).
+- **`sizeSocialSvg` moved** to `lib/socialLinks.ts` and is re-exported from `settingsSchema`.
+- **The homepage e2e read the checkout's own `settings.json` and `homepage.json`.** The dev server
+ now reads `e2e/.e2e-settings.json` through `SETTINGS_FILE` and an empty `e2e/.e2e-sites/` through
+ `SITES_DIR` (both declared variables). The icons are synthetic: a gradient with one solid colour,
+ one colour drawn white, and a two-colour disc with a letter pasted with only its size. Specs that
+ need six links, none, or hostile icons rewrite the file (hostile ones raw, as a hand-edited file
+ holds them), and an `afterEach` restores the trio. `marketing.spec.ts`'s "no link to ko-fi.com"
+ runs against the fixture's links and still catches a link in code.
+- **The footer's icons are `--muted-foreground`, no longer `--faint`**, the same as the header's.
+ On the page ground that is 5.63 / 5.51 / 7.08:1 (Light / Sepia / Dark) against `--faint`'s
+ 3.45 / 3.59 / 3.53:1.
+
+**Corrections to the prompt.**
+- `NAV` had five entries at `ac438bbc`, not six.
+- The homepage e2e baseline at `ac438bbc` was 36 passed, 0 skipped (1.0 min) in the empty-source
+ state. The parent later copied a published source into the worktree, so the later runs are in
+ the published-source state.
+
+**What each icon looks like, per ground** (the fixture's synthetic icons, which behave as an
+operator's pasted icons do):
+- **The gradient icon:** the gradient keeps its colours on every ground. Its one solid part follows
+ the link colour: slate on Light, brown on Sepia, warm grey on Dark, and the foreground on hover.
+- **The one-colour icon:** the muted foreground on every ground (`#55646e` / `#6b5c43` /
+ `#a39a86`), and the foreground on hover.
+- **A two-colour disc with a dark outline and a dark offset disc:** on Light and Sepia the outline
+ and the offset crescent show; on Dark both fall into the `#0c0a08` ground, and the disc reads flat.
+ This is accepted, as ruled: no per-icon ring.
+- **In forced colours:** single-colour parts take the link colour; the disc and the gradient keep
+ theirs.
+
+| sha | what |
+|---|---|
+| `518dd272` | `common:` `lib/socialSvg.ts`, the allowlist checker on save and at render with reasons; the size rule; `featured`; `lib/socialLinks.ts` (header rule, `safeSocialSvg`, id scoping, `sizeSocialSvg`); the writers' errors; the tests; `SETTINGS.md` / `SITE.md` |
+| `a9dfa3de` | `editor:` "Show in header" with its hint; `socialLinksJson` + unit test; the save errors name the reason; the settings spec reads `featured` back |
+| `4d93dc84` | `common:` `SocialLinks`, `OptionsDialog` (its own border and Close, focus on the checked radio), `ThemeRadios`; `export:` `MobileMenu` renders `ThemeRadios`, the footer inlines only through `safeSocialSvg` |
+| `6c500818` | `homepage:` the social row and the gear in the header at every width, the width × pointer rule; Changelog in the footer only; the e2e's own settings and sites; `social`, `options`, nav and theme specs |
+| `88d61908` | `homepage:` the cards' names as the site's wordmark, the lead tinted; `wordmarkLead` in the summary and its check on read; `siteAccentColor`; `Wordmark` `leadStyle`; `instance-wordmark.spec.ts` |
+| _this_ | `plans:` this record; the plan's slice HP, H1 and H2; STATE; the changelogs |
+
+**Gates** (at `88d61908`; logs `$T/hp5-*.log`):
+- **tsc** was clean at each of the five commits (the rewrite ran it on each commit's own tree:
+ 34–44 s).
+- **Unit:** common **2,179/2,179**; editor unit **86/86**; homepage unit **8/8**;
+ `test:scripts` **185 + 1 skipped**; mcp **269/269**.
+- **Docs:** `settings example --check`, `docs files --check` and `docs env --check` all exit **0**.
+- **Builds:** homepage **ok** (14 s, with the fixture icons), export **ok** (26 s), editor **ok**
+ (40 s). The homepage gate is `pnpm --filter homepage exec next build`, not `archilyzer build
+ homepage --no-source`, which would withdraw the published source the parent put in this worktree.
+- **`git diff main...HEAD -- common/components/ui/`** is empty: the editor's command palette is
+ untouched.
+- **Homepage e2e, full suite:** **68 passed, 0 failed, 0 skipped (1.9 min)**, in the
+ published-source state. That is 63 before the review plus 5 (the 320/339/359 px widths, six
+ links under touch at 360, the hostile icons); the touch, Options and card specs were tightened in
+ place.
+- **Export e2e** (`site-branding brand theme theme-accent responsive related-sites archives-off`,
+ the specs on the header's theme controls, the slide-out menu and the footer's social row):
+ **35 passed, 0 failed (1.5 min)**.
+- **Editor e2e** (`settings sites-crud export-search`, the specs on the social-links field in both
+ forms and the export footer's links; there is no command-palette spec): **45 passed, 0 failed
+ (2.1 min)**. The spec-order failure below did not arise: this worktree's
+ `editor/test-settings.json` already existed.
+- **Earlier rounds** (the same checks, before the review): homepage e2e 36 → 50 → 59 → 63 passed;
+ export theme/responsive/brand 23 passed after the `MobileMenu` swap; the first editor run 44
+ passed and 1 failed on spec order (see "Found and left").
+- **Screenshots:** `~/reports/release-14/shots/`, from a production build of the worktree with the
+ fixture icons (`SETTINGS_FILE`), served on 3331. **The worktree's `homepage/out` is that fixture
+ build: never deploy it; rebuild first.**
+ - `hp2/`: `header-{320,340,360,390,768,1024,1280}-{light,sepia,dark}.png`,
+ `dialog-{360,1280}-{…}.png` (360×640 and 1280×800), `footer-{390,1280}-{…}.png` (with
+ Changelog), `gear-focus-ring-{…}.png`, `forced-gear-{unfocused,focused}-{light,dark}.png`.
+ - `hp3/`: `tint-` and `underline-{390,1280}-{light,sepia,dark}.png`, retaken with the current
+ header.
+ - `hp/`: the first round's shots, superseded.
+- **Numbers tool:** none.
+
+**They bite:**
+- `social.spec.ts` with the id scoping, the header bound and `pointer-coarse:size-11` removed by
+ hand: 5 failed (the per-copy ids test, both coarse-pointer tests, both six-link tests).
+- The hostile-icon spec fails without the read path: a stored `<svg/onload=…>` inlined as is runs
+ when the page parses it.
+- The loader test fails without `withCheckedLead` (a whole-title lead is kept).
+- `instance-wordmark.spec.ts`'s "Fix" + "ture Three" fails if the wordmark's spans stop being
+ adjacent inline text.
+
+#### Review (verdict SHIP AFTER FIXES; `$T/hp-review.md`)
+
+| Finding | Fix |
+|---|---|
+| M1: 320 px with a mouse scrolled sideways by 8 px | `6c500818`: the width × pointer rule (a mouse shows two below 360, touch one below 340); 320/339/359 and the touch steps in `social.spec.ts`; `max-[390px]` to match "below 390" |
+| M2: the `ui/dialog.tsx` change restyled the editor's command palette | `4d93dc84`: the wrapper is `main`'s, byte for byte; OptionsDialog styles its own panel and Close. The records' "first importer" is corrected |
+| M3: a vendor file as a test fixture would be published by the source mirror | ruled: no vendor file. The branch was rewritten so the file and its notice never entered it; D1 is tested with synthetic sized SVGs; the e2e disc is synthetic |
+| M4: five inputs passed the normalizer and ran script | `518dd272` (the allowlist checker, the output re-check, the reasons), `4d93dc84` (the read path in both renderers); the review's five inputs and each class tested at the normalizer, the read path and in the browser |
+| L1, L2: id scoping | `518dd272`: plain ids only (the checker refuses others; comments are removed), entity-quoted and case-varied references |
+| L3: tests that could pass broken | the loader checks the lead; a mid-word lead; the gear 36/44 px exactly; the glyph's own colour; six links under touch at 360; 320 px |
+| L4: "below 390" vs `max-[389px]` | `max-[390px]` |
+| L5: `homepage.json` could win in the e2e | `SITES_DIR` → an empty directory. The reused dev server is pre-existing and left |
+| L6: the dialog opened on the first radio | it opens on the checked Base radio (spec) |
+| L7: `hp3` at 390 showed an old header | retaken |
+| L8: `featured` in `/site.json` | accepted, recorded above |
+| L9: the fixture build in `homepage/out` | recorded above: never deploy it |
+| L10: the service's name in tracked files | gone from every file this branch adds or changes |
+| L11: the size read from another attribute's value | the size comes from the root's own attributes |
+| L12: the fixture echoed an operator icon's colours | the fixture's colours changed; the pre-existing test file is outside this branch |
+| L13: `label` said "Visible name" | reworded; `SETTINGS.md` / `SITE.md` regenerated |
+
+The answers, as ruled: the touch rule is kept, with the 320 step; the tint is kept; the footer
+colour `--muted-foreground` is kept; marking is choosing, kept, and said beside the checkbox.
+
+**Found and left:**
+- **The export's header and the rest of its footer** are slices H1 and H2.
+- **The Settings form's hint** still says the default links show "in every site's footer"; true of
+ the sites until H1. Operator-facing; left alone.
+- **`export-search.spec.ts`'s "a site's own social links win over the global default" depends on
+ the order of the specs.** It reads `editor/test-settings.json`, which only a settings save
+ creates, and fails when it runs first in a fresh worktree. The fix is a `.catch(() => ({}))` on
+ the read, as `auto-queue.spec.ts` does.
+- **`next dev` (16.2.3) refuses a second dev server in the same app directory.** A hand-started
+ homepage dev server must be stopped before the suite runs; `reuseExistingServer` would otherwise
+ reuse one started without the fixture's environment (pre-existing).
+- **A pasted Illustrator or Inkscape file** with a `<style>` block, `<metadata>` or `inkscape:*`
+ attributes is now refused, with the element or attribute named in the error. Stripping those is
+ the operator's step.
+- **The hub's official cards** still show plain titles; ruling 4 names the homepage only.
+- **Merging after `fix/stats-cache-key`:** its `homepageSummary.ts` hunks (the `SiteMetricStat`
+ comment, `withUndated`, the loop, `transcribed: withUndated(…)`) do not touch this branch's
+ (the version comment, the `wordmarkLead` field at the end of `HomepageSummarySite`, the spread
+ after `accentIdOf`), and its test is inserted mid-file where this branch's is appended: the merge
+ is clean there. Both add an `editor/CHANGELOG.md` bullet: this branch's are at the END of
+ `[Unreleased]`, theirs at the top, so that merges cleanly too.
+
+**Changelog.**
+- `homepage/CHANGELOG.md` `[Unreleased]`: the row and the gear in the header; one Options button;
+ Changelog in the footer only; the cards' wordmark names; the larger keys with a focus ring; the
+ e2e's fixtures and helper.
+- `editor/CHANGELOG.md` `[Unreleased]`, at the end of the list: the size rule and "Show in header";
+ the icon check with reasons, on save and at render.
+- `export/CHANGELOG.md` has no `[Unreleased]` heading on `main`; `fix/stats-cache-key` creates it.
+ After that merges, one bullet is owed there: "**A social icon that fails the check is shown as
+ its label.** The footer inlines a social link's SVG only if it passes the same check as a save
+ (release 14); otherwise the link shows its label as text."
+
+## Rollout