commit c0e6bbbabb2c835d10460b6f93ed333ac16a2a3d
parent 9e1f99900dc3daa83b7a6b069003f021cf4959e7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:11:53 -0400
plans: brand + themes — slice S0 as shipped (palette + data); editor CHANGELOG
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 168 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **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 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/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -390,6 +390,173 @@ canvas's *In context* and *Themes* boards.
(Each slice appends its "### Slice S<n>, as shipped" record here.)
+### Slice S0, as shipped — palette + data (2026-09-25)
+
+Branch `brand/found-line` off the plan commit `d8dd98b8`. S0 lays the data S1 (mark, icons, wordmark)
+and S2 (base × accent tokens, runtime, picker) build on: the palette and mark as pure data, the
+`site.json` keys, the one mapping that keeps every *published* accent a hex, and the editor form that
+writes both keys. No `common/styles/**`, `common/components/**`, layout, manifest, header, footer,
+icon, service-worker or theme-spec file was touched, and nothing a reader sees changes until an
+operator picks an accent or a lead.
+
+**`common/lib/brand.ts` (new, pure: zero imports).**
+- `ACCENT_IDS` (picker order) and `ACCENTS: Record<AccentId, {id, name, onDark, onLight, onSepia}>`,
+ the plan's table verbatim; `DEFAULT_ACCENT = "signal"`; `isAccentId`.
+- `BASE_GROUNDS` (+ `BASE_GROUND_IDS`), `ACCENT_INK` (`#ffffff` on light and sepia, the dark ground on
+ dark), `MIN_ACCENT_CONTRAST = 4.5`, and the WCAG `relativeLuminance` / `contrastRatio` the rule and
+ its tests use. **The rule as coded:** an accent's value on a base reaches 4.5:1 against that base's
+ ground AND its ink. On dark the ink is the ground, so it is one check there. Every value in the
+ plan's table passes: the tightest are Signal and Brass on light and on sepia (4.60–4.61) and Green on
+ sepia (4.61). `brand.test.ts` makes 42 checks (7 accents × 3 bases × 2).
+- `MARK`: six shapes back to front, the ground first (`part`, `kind`, geometry, `tone` =
+ ground | dim | lit), plus `MARK_VIEWBOX`, `MARK_GROUND_RX` and `MARK_MASKABLE_SCALE`.
+ `markSvg(palette, {variant})` returns a standalone SVG string:
+ - `any`: ground `rx=112`;
+ - `maskable`: full-bleed ground (no rx), the lines inside `<g transform="translate(51.2 51.2) scale(0.8)">`,
+ which is 0.8 about (256,256);
+ - `apple`: full-bleed ground at scale 1.
+
+ Every palette colour must be `#rrggbb` or it throws, so a stored value cannot inject markup into an
+ icon.
+- `ICON_PALETTES` has `child` (ground `#0c0a08`, dim `#3b3327`) and `archilyzer` (`#151b20` / `#3f4c56`
+ / `#e7edf1`). `childIconPalette(lit)` adds the lit value: **S1 passes
+ `resolveAccent(site.accent).dark`.** brand.ts cannot import accent.ts; accent.ts imports brand.ts.
+- `wordmarkLeadFor(title, lead)` returns the trimmed lead when it is a PROPER prefix of the title
+ (case-sensitive, non-empty, shorter than it), else undefined. `splitWordmark(title, lead?)` returns
+ `{lead, suffix}`, and without a usable lead that is `{lead: title, suffix: ""}`. There is no
+ heuristic: `splitWordmark("Rekietalyzer")` is the whole title, and the test pins that. The test
+ splits all seven family titles.
+- `common/lib/project.ts`: `PROJECT_WORDMARK_LEAD = "Archi"`.
+
+**`common/lib/accent.ts`.**
+- `parseAccent` is unchanged and stays hex-only.
+- `parseAccentSetting(v)` takes an id (trimmed, any case, stored lowercase) or `parseAccent`'s hex, else
+ undefined. A hex equal to a named accent's value stays a custom hex and is never mapped to an id.
+- `resolveAccent(v)` returns `{id | "custom", light, sepia, dark}`:
+ - a named id reads its row;
+ - absent or malformed reads Signal;
+ - a custom hex is fitted per base. It is kept if it already meets the rule; otherwise it is mixed in
+ sRGB toward black (light, sepia) or white (dark) in 1 % steps until it does. Full black or white
+ always passes, so the loop ends.
+
+ The fixture's `#cc3366` resolves to light `#cc3366` (kept, 4.57), sepia `#c43162` (4.50) and dark
+ `#d14574` (4.53). **So S2's `site-branding` expectation, `--brand` = `#cc3366` on light, holds.**
+- `accentHex(v)` is the published value: an id becomes its `onDark`, a custom hex goes out as stored
+ (normalized, not fitted), and absent or malformed gives undefined.
+- `customAccentVars(v)` returns `{--accent-custom-light|sepia|dark}` for a custom hex, and null for an
+ id or no accent. It takes the setting as its argument; the plan wrote `customAccentVars()`.
+- **`siteAccentVars` (legacy, which S2 deletes) now goes through `accentHex`.** A hex gives the same
+ output as before (the `site-branding` spec's `--brand: #cc3366` passes). An id paints its on-dark
+ hex in both modes, the old one-colour contract.
+
+**`site.json` (`common/lib/siteSchema.ts`).**
+- `accent` parses through `parseAccentSetting`; the doc text is new.
+- `wordmarkLead?` sits after `headerTitle` in the type, `SITE_FIELD_DOCS`, the schema (trim, else
+ undefined) and `siteToDisk`.
+- The object step resolves it with `wordmarkLeadFor(headerTitle, …)`, beside `defaultGroupId`, so a
+ non-prefix lead reads as undefined. `siteToDisk` re-checks it, so a caller's stale lead after a
+ retitle is not written.
+- `SITE.md` was regenerated and `--check` is clean.
+- A stored `"signal"` round-trips as `"signal"`. The schema keeps any valid setting; only the FORM
+ stores the default as absent.
+
+**Published accents stay hex.** `siteDescriptor.ts` (public `/site.json`), `compose-hub.ts`
+(`hub-sites.json`) and `homepageSummary.ts` (homepage summary, and through `toHubSummary` the hub's
+`hub-summary.json`) emit `accentHex(site.accent)`, omitting the key when there is no accent, as before.
+`wordmarkLead` is not added to the public descriptor, so the contract is unchanged. **Every reader of an
+`accent`, and what it now receives:**
+
+| Reader | Receives |
+|---|---|
+| `common/lib/siteDescriptor.ts:~89` | hex (`accentHex`) |
+| `common/bin/compose-hub.ts:~96` → `hub-sites.json` | hex (`accentHex`) |
+| `common/lib/homepageSummary.ts:~433` → `homepage-summary.json` | hex (`accentHex`) |
+| `common/lib/hubSummary.ts:67,122` | the homepage summary's value → hex |
+| `common/components/siteRegistry.ts:144,206,305` | `hub-sites.json` / a member's `/site.json` → hex |
+| `common/components/SearchDataContext.tsx:274,442`, `SearchSessionContext.tsx:899,926,960` | registry → hex |
+| `common/components/SearchResults.tsx:573`, `FiltersPanel.tsx:324` (C2's files, untouched) | `group.accent` from the registry → hex |
+| `export/app/components/hub/ArchiveShelf.tsx:69` (C2's, untouched), `HubHome.tsx:39`, `HubOfflineManager.tsx:97`, `export/app/ask/AskHub.tsx:33` | registry / hub summary → hex |
+| `export/app/layout.tsx:86` `siteAccentVars(currentSite().accent)` | id or hex → hex via `accentHex` (legacy, S2 deletes) |
+| **`export/app/layout.tsx:64`** `generateViewport` themeColor | `parseAccent(id)` = undefined → falls back to `#2563eb` (S2's file; S2 replaces it with `BASE_GROUNDS`) |
+| **`export/app/manifest.ts:19`** `theme_color` | `parseAccent(id)` = undefined → falls back to `#2563eb` (S1's file; S1 sets `BASE_GROUNDS.dark`) |
+| `editor/app/sites/actions.ts`, `SiteForm.tsx` | the setting (id or hex), by design |
+| `common/lib/channelGroups.ts:28` | a hub-mode group's provenance accent from the registry → hex; not a `site.json` read |
+| homepage `ArchiveCards.tsx` | does not read it (its comment says so) |
+| mcp, umtool | none (umtool's `pal.accent` is a video palette, unrelated) |
+
+**Editor form** (`editor/app/sites/components/SiteForm.tsx`, `actions.ts`):
+- **Brand accent** is a `<fieldset>`/`<legend>` group of 8 native radios named `accent`: the 7 named
+ (each with a dot of its on-light / on-dark value, and "Signal (default)") plus **Custom**. A
+ **Custom hex** text field (`accentCustom`) sits below; typing in it selects Custom. The radios are
+ controlled, so the choice survives the reset React does after the action.
+- The action stores the chosen id, but **Signal is stored as absent** (the two read the same). Custom
+ requires a valid hex. A bare hex posted in `accent` (an old tab from before the restart) is taken as
+ custom.
+- **Wordmark lead** is a `Field` after Header title. The action REFUSES a non-prefix lead with an error
+ rather than letting the schema drop it silently.
+- `writeSite` is still the one writer.
+- No new label or hint contains "header title", "site title", "site id" or "public url"; the lead's
+ hint says "header text".
+- Shots: `$T/s0-accent-{light,dark}.png`, `$T/s0-form-{light,dark}.png`.
+
+**Changed locators: none.** No editor spec referenced "Brand accent" or the accent field. New test
+`sites-crud.spec.ts` "brand accent radio group + wordmark lead round-trip to site.json" covers:
+- the default checked, and 8 radios in the "Brand accent" group;
+- Brass + lead `Brandy` → `"accent":"brass","wordmarkLead":"Brandy"`;
+- typing `#CC3366` selects Custom and stores `#cc3366`;
+- a lowercase lead is refused (alert) with the file unchanged;
+- Signal + blank lead → both keys absent.
+
+**`hubSite()`** (`export/app/lib/site.ts`) sets `wordmarkLead: PROJECT_WORDMARK_LEAD`. The live
+`homepage.json` header title is "Archilyzer", so it is kept.
+
+| sha | what |
+|---|---|
+| `e85c1fd2` | `common/lib/brand.ts` (+ test) and `accent.ts` (+ test): the palette, rule, mark, wordmark split; `parseAccentSetting` / `resolveAccent` / `accentHex` / `customAccentVars`; `siteAccentVars` through `accentHex`; `PROJECT_WORDMARK_LEAD` |
+| `3b634248` | `siteSchema.ts`: `accent` id-or-hex, `wordmarkLead` (proper prefix only) + tests; `SITE.md` regenerated |
+| `31ff873a` | `siteDescriptor` / `compose-hub` / `homepageSummary` publish `accentHex` + tests (new `siteDescriptor.test.ts`) |
+| `17c874ca` | `export/app/lib/site.ts` `hubSite()` carries `wordmarkLead: "Archi"` |
+| `4fce889d` | editor site form: the accent radio group + Custom hex, the Wordmark lead field, the action; the sites-crud test |
+| `9775bd22` | sites-crud's new test filters its alert from Next's route announcer |
+
+**Gates.**
+- tsc clean before each code commit (four runs), plus a fifth on the final tree, which covers `9775bd22`, a spec-only change.
+- Unit and script tests:
+ - common **1,874/1,874**: C1b's 1,845 + 29 new — brand 12, accent 9, siteSchema 3, siteDescriptor 3,
+ homepageSummary 1, compose-hub 1;
+ - editor unit **78/78**;
+ - `test:scripts` **162 + 1 skip**;
+ - mcp **219/219**.
+- `file-schemas-docs.ts --check` clean.
+- Builds:
+ - `pnpm --filter editor exec next build` ok (50 s);
+ - `pnpm --filter export exec next build` ok (31 s, the known Turbopack warning), with the
+ `export/public` links seeded, `sw.js` a plain copy, and no dangling links;
+ - `pnpm --filter homepage exec next build` ok (16 s), with `homepage/public`'s four data entries
+ COPIED from the primary.
+- e2e, no queue wait:
+ - editor `sites-crud.spec.ts branding.spec.ts`: run 1 **15 passed, 1 failed** (1.2 min). The failure
+ was the new test's own `getByRole("alert")`: Next's route announcer is a second, empty
+ `role=alert`. Run 2 on `9775bd22`: **16 passed, 0 failed**, 58.7 s.
+ - export `site-branding.spec.ts`: **7 passed**, 16.1 s.
+ - `e2e:hub`: **12 passed**, 28.4 s.
+- The primary's `export/public/sw.js` was untouched: 10,027 B, mtime 20:47:37, before and after.
+- Numbers tools: none.
+
+**Found and left.**
+- **The browser-chrome colour falls back for a named accent until S1/S2.**
+ - `export/app/layout.tsx:64` and `export/app/manifest.ts:19` call the hex-only `parseAccent`, so a
+ site set to an id gets `#2563eb` there instead of its colour.
+ - Both files belong to S1/S2, which replace those lines with `BASE_GROUNDS`. They were left alone.
+ - No live site sets an accent (all six read `null`), so nothing live changes.
+- **The legacy `siteAccentVars` paints an id's on-dark value in both modes.** On the light base that is
+ below 4.5:1 (Brass `#e3b15c` on `#f3f6f7` ≈ 1.9). This is transitional: S2 deletes it, and S0 is not
+ rolled out alone.
+- **"Signal (default)" is only literally true once S2 lands.** At S0's tip an absent accent is still
+ the base family's blue.
+- The worktree is port block **#1** (editor 3101, test 3111, export 3110), not #2:
+ `pnpm wt list` sorts, and `diet-series` is #2.
+
## Operator rollout (after merge)
1. Restart the live :3001 editor on the new `main`; it needs S0's form.