commit 252a133c0b0123cc77b8f70956d4363cf8ae6530
parent ec414bf6b0750b4894eb8f0f656e10f6c2398ba4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 09:24:09 -0400
Merge r11/hub-lows (follow-up O1c) — the operator's rulings: the four hub copy lines ship as written, each carrying its link in the line (copy`…${link()}…`, the [bracket] regex gone); --chart-6's rust accepted near --state-gone; Vermilion keeps slot 6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 270 insertions(+), 99 deletions(-)
diff --git a/export/app/ask/LinkedCopy.tsx b/export/app/ask/LinkedCopy.tsx
@@ -1,22 +1,21 @@
"use client";
-// A copy line from hubScopeCopy.ts as a reader sees it: its one [bracketed]
-// phrase, when it has one, a link to the hub's front page.
+// A copy line from hubScopeCopy.ts as a reader sees it: its words, and its
+// ${link("…")} phrase, when it has one, a link to the hub's front page.
import Link from "next/link";
-import { copyWithLink } from "./hubScopeCopy";
+import type { Copy } from "./hubScopeCopy";
-export function LinkedCopy({ copy }: { copy: string }) {
- const { before, link, after } = copyWithLink(copy);
+export function LinkedCopy({ copy }: { copy: Copy }) {
return (
<>
- {before}
- {link && (
+ {copy.before}
+ {copy.link && (
<Link href="/" className="text-brand underline-offset-2 hover:underline">
- {link}
+ {copy.link}
</Link>
)}
- {after}
+ {copy.after}
</>
);
}
diff --git a/export/app/ask/hubScopeCopy.test.ts b/export/app/ask/hubScopeCopy.test.ts
@@ -0,0 +1,61 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ ASK_SCOPE_LINE,
+ LIVE_CHAT_MISSING,
+ NO_ARCHIVES_IN_SCOPE,
+ NO_ARCHIVES_ON_HUB,
+ copy,
+ link,
+} from "./hubScopeCopy";
+
+// The hub's copy lines carry their link in the line itself — copy`…${link("…")}…`
+// — so nothing is parsed out of the words (it used to be a [bracketed] phrase
+// found by a regex; the operator ruled that out, 2026-09-28).
+
+test("a line with a link: its text, and the words either side of the link", () => {
+ assert.deepEqual(copy`Choose some on the ${link("hub's front page")} to ask.`, {
+ before: "Choose some on the ",
+ link: "hub's front page",
+ after: " to ask.",
+ text: "Choose some on the hub's front page to ask.",
+ });
+});
+
+test("a line with no link is all text; plain interpolations are text, brackets too", () => {
+ const n = 3;
+ assert.deepEqual(copy`Searching ${n} of ${"4"} [archives].`, {
+ before: "Searching 3 of 4 [archives].",
+ after: "",
+ text: "Searching 3 of 4 [archives].",
+ });
+ // A link may sit anywhere, and text after it may interpolate too.
+ assert.deepEqual(copy`${link("Here")} — ${2} left`, {
+ before: "",
+ link: "Here",
+ after: " — 2 left",
+ text: "Here — 2 left",
+ });
+});
+
+test("two links in one line throw (a programmer error, caught when the line is built)", () => {
+ assert.throws(
+ () => copy`${link("one")} and ${link("two")}`,
+ /links one phrase at most: "one" and "two"/,
+ );
+});
+
+test("the four shipped lines read as the operator ruled them (2026-09-28)", () => {
+ assert.equal(NO_ARCHIVES_IN_SCOPE.text, "No archives selected. Choose some on the hub's front page to ask.");
+ assert.equal(NO_ARCHIVES_IN_SCOPE.link, "hub's front page");
+ assert.equal(NO_ARCHIVES_ON_HUB.text, "This hub has no archives yet. Add one on the hub's front page to ask.");
+ assert.equal(NO_ARCHIVES_ON_HUB.link, "hub's front page");
+ assert.equal(LIVE_CHAT_MISSING, "live chat didn't load");
+ assert.deepEqual(ASK_SCOPE_LINE(1, 2), {
+ before: "Searching 1 of 2 archives — ",
+ link: "change on the front page",
+ after: ".",
+ text: "Searching 1 of 2 archives — change on the front page.",
+ });
+ assert.equal(ASK_SCOPE_LINE(1, 1)?.text, "Searching 1 of 1 archive — change on the front page.");
+});
diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts
@@ -1,50 +1,87 @@
-// The hub's /ask line for a browser that has switched EVERY archive off with
-// the front page's scope chips: the chat has nothing to search, so its composer
-// is disabled and this one line says why. The operator's copy — change it here
-// and nowhere else (AskChat renders it, the hub e2e asserts it). The words in
-// [brackets] become a link to the hub's front page, where the chips are; drop
-// the brackets for no link.
-export const NO_ARCHIVES_IN_SCOPE =
- "No archives selected. Choose some on the [hub's front page] to ask.";
-
-// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
-// The hub's /ask line for a hub with NO archives at all: its /hub-sites.json
-// lists none (a fresh self-hosted hub) or could not be read, and this browser
-// has added none. The composer is disabled and this line says why, instead of
-// "Loading transcripts…" forever. The [bracketed] words link to the hub's front
-// page, where the form to add an archive is; drop the brackets for no link.
-export const NO_ARCHIVES_ON_HUB =
- "This hub has no archives yet. Add one on the [hub's front page] to ask.";
-
-// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
+// The hub's copy lines for /ask and the scope chips — the operator's words,
+// ruled 2026-09-28. Change them here and nowhere else: the components render
+// these values and the hub e2e asserts them.
+//
+// To edit a line:
+// - change the words inside the backticks;
+// - the phrase wrapped in ${link("…")} is a link to the hub's front page —
+// move the wrapper to link other words, or drop it (keep the words) for no
+// link. One link per line at most: a second one throws when this module
+// loads;
+// - ASK_SCOPE_LINE may return null to show no line at all (e.g.
+// `inScope === total ? null : copy`…`` to show it only when some archives
+// are switched off).
+// A line with no link can be a plain string (LIVE_CHAT_MISSING).
+
+// A copy line: its words, with at most one phrase linked to the hub's front
+// page. `text` is the line as a reader reads it; `before` + `link` + `after`
+// is the same line split around the link (`link` absent, and `after` empty,
+// when there is none).
+export type Copy = { before: string; link?: string; after: string; text: string };
+
+export class CopyLink {
+ constructor(readonly words: string) {}
+}
+
+// The linked phrase of a copy line (see `copy`).
+export function link(words: string): CopyLink {
+ return new CopyLink(words);
+}
+
+// The tag for a copy line. Plain interpolations (numbers, strings) are text;
+// only a link(…) value becomes the link. Nothing is parsed out of the words.
+export function copy(strings: TemplateStringsArray, ...values: unknown[]): Copy {
+ let before = "";
+ let after = "";
+ let linked: string | undefined;
+ const add = (s: string) => {
+ if (linked === undefined) before += s;
+ else after += s;
+ };
+ strings.forEach((s, i) => {
+ add(s);
+ if (i >= values.length) return;
+ const v = values[i];
+ if (v instanceof CopyLink) {
+ if (linked !== undefined) {
+ throw new Error(
+ `A copy line links one phrase at most: "${linked}" and "${v.words}" (hubScopeCopy.ts)`,
+ );
+ }
+ linked = v.words;
+ } else {
+ add(String(v));
+ }
+ });
+ return linked === undefined
+ ? { before, after: "", text: before }
+ : { before, link: linked, after, text: before + linked + after };
+}
+
+// /ask, for a browser that has switched EVERY archive off with the front
+// page's scope chips: the chat has nothing to search, so its composer is
+// disabled and this one line says why. The link goes to the chips.
+export const NO_ARCHIVES_IN_SCOPE = copy`No archives selected. Choose some on the ${link("hub's front page")} to ask.`;
+
+// /ask, on a hub with NO archives at all: its /hub-sites.json lists none (a
+// fresh self-hosted hub) or could not be read, and this browser has added
+// none. The composer is disabled and this line says why, instead of "Loading
+// transcripts…" forever. The link goes to the form that adds an archive.
+export const NO_ARCHIVES_ON_HUB = copy`This hub has no archives yet. Add one on the ${link("hub's front page")} to ask.`;
+
// The note on a hub scope chip (the front page) when that archive's live chat
// could not be read: its videos are searched, its live chat is not. A Retry
// for the live chat alone sits beside it. Chip notes are lower case, like the
// chip's own "loading…" and "failed".
export const LIVE_CHAT_MISSING = "live chat didn't load";
-// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling
// The one line at the top of the hub's /ask that says what the chat searches:
// `inScope` of the hub's `total` archives, the rest switched off with the
-// front page's scope chips. The [bracketed] words link to the front page;
-// drop the brackets for no link. Return null to show no line — e.g.
-// `inScope === total ? null : …` to show it only when some archives are off.
-// Not shown when none are in scope (NO_ARCHIVES_IN_SCOPE says so instead).
-export const ASK_SCOPE_LINE = (inScope: number, total: number): string | null =>
- `Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — [change on the front page].`;
-
-// A copy line split around its one [bracketed] link.
-export function copyWithLink(copy: string): {
- before: string;
- link?: string;
- after: string;
-} {
- const m = /^([^[]*)\[([^\]]+)\]([\s\S]*)$/.exec(copy);
- return m ? { before: m[1], link: m[2], after: m[3] } : { before: copy, after: "" };
-}
+// front page's scope chips. Not shown when none are in scope
+// (NO_ARCHIVES_IN_SCOPE says so instead).
+export const ASK_SCOPE_LINE = (inScope: number, total: number): Copy | null =>
+ copy`Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — ${link("change on the front page")}.`;
-// The line as a reader sees it (the brackets gone).
-export function copyText(copy: string): string {
- const { before, link = "", after } = copyWithLink(copy);
- return before + link + after;
-}
+// Evaluated once here, so a second link in it throws when this module loads
+// rather than when /ask renders.
+ASK_SCOPE_LINE(1, 2);
diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts
@@ -1,9 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
-import {
- NO_ARCHIVES_ON_HUB,
- copyText,
- copyWithLink,
-} from "../app/ask/hubScopeCopy";
+import { NO_ARCHIVES_ON_HUB } from "../app/ask/hubScopeCopy";
// The hub's /ask route.
//
@@ -78,11 +74,11 @@ test.describe("hub /ask", () => {
.locator("form")
.filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
const blocked = composer.getByTestId("ask-blocked");
- await expect(blocked).toHaveText(copyText(NO_ARCHIVES_ON_HUB));
+ await expect(blocked).toHaveText(NO_ARCHIVES_ON_HUB.text);
await expect(blocked).toHaveAttribute("role", "status");
- // Its [bracketed] words (when the copy has any) link to the front page,
+ // Its ${link()} phrase (when the copy has one) links to the front page,
// where the form to add an archive is.
- const { link } = copyWithLink(NO_ARCHIVES_ON_HUB);
+ const { link } = NO_ARCHIVES_ON_HUB;
if (link) {
await expect(
blocked.getByRole("link", { name: link, exact: true }),
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -3,8 +3,6 @@ import {
ASK_SCOPE_LINE,
LIVE_CHAT_MISSING,
NO_ARCHIVES_IN_SCOPE,
- copyText,
- copyWithLink,
} from "../app/ask/hubScopeCopy";
// The hub's federated search, per archive: two official members (hub-sites.json)
@@ -420,14 +418,14 @@ test.describe("hub federated search — scope, per-archive state, attribution",
expect(mocks.a.requests).toContain("/summaries/page-0000.json");
expect(mocks.b.requests).toEqual([]);
- // And it says so: one line, its [bracketed] words a link to the chips.
+ // And it says so: one line, its ${link()} phrase a link to the chips.
const scope = page.getByTestId("ask-scope");
const oneOfTwo = ASK_SCOPE_LINE(1, 2);
if (oneOfTwo === null) {
await expect(scope).toHaveCount(0);
} else {
- await expect(scope).toHaveText(copyText(oneOfTwo));
- const { link } = copyWithLink(oneOfTwo);
+ await expect(scope).toHaveText(oneOfTwo.text);
+ const { link } = oneOfTwo;
if (link) {
await expect(
scope.getByRole("link", { name: link, exact: true }),
@@ -448,7 +446,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
expect(mocks.b.requests).toContain("/summaries/page-0000.json");
const twoOfTwo = ASK_SCOPE_LINE(2, 2);
if (twoOfTwo === null) await expect(scope).toHaveCount(0);
- else await expect(scope).toHaveText(copyText(twoOfTwo));
+ else await expect(scope).toHaveText(twoOfTwo.text);
});
test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({
@@ -513,7 +511,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
).toBeVisible();
const both = ASK_SCOPE_LINE(2, 2);
if (both !== null) {
- await expect(page.getByTestId("ask-scope")).toHaveText(copyText(both));
+ await expect(page.getByTestId("ask-scope")).toHaveText(both.text);
}
});
@@ -659,11 +657,11 @@ test.describe("hub federated search — scope, per-archive state, attribution",
.locator("form")
.filter({ has: page.getByRole("button", { name: "Ask", exact: true }) });
const blocked = composer.getByTestId("ask-blocked");
- await expect(blocked).toHaveText(copyText(NO_ARCHIVES_IN_SCOPE));
+ await expect(blocked).toHaveText(NO_ARCHIVES_IN_SCOPE.text);
await expect(page.getByTestId("ask-blocked")).toHaveCount(1);
- // Its [bracketed] words (when the copy has any) link to the front page,
+ // Its ${link()} phrase (when the copy has one) links to the front page,
// where the chips are.
- const { link } = copyWithLink(NO_ARCHIVES_IN_SCOPE);
+ const { link } = NO_ARCHIVES_IN_SCOPE;
if (link) {
await expect(
blocked.getByRole("link", { name: link, exact: true }),
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -6753,6 +6753,9 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
`data-testid="ask-scope"` once `listed` with at least one archive in scope. `null` hides it.
- `NO_ARCHIVES_ON_HUB`, `LIVE_CHAT_MISSING` and `ASK_SCOPE_LINE` (`hubScopeCopy.ts:16/24/33`) are
DRAFTs awaiting the operator's ruling. `LinkedCopy` renders a line's one [bracketed] link to `/`.
+ - **RULED 2026-09-28 (O1c): the four lines ship as written, and the [bracket] link is gone.** Each
+ line is `` copy`…${link("…")}…` `` (a `Copy` `{before, link?, after, text}`). `copyWithLink`,
+ `copyText` and the regex are deleted. See "The hub (O1, O2b)" below.
- **The official instances' order is the homepage's key and tiebreak.** The homepage sorts its
public sites by `transcribed.total` descending, then `siteTitle.localeCompare`
(`common/lib/homepageSummary.ts:437-441`); `hub-summary.json` keeps that order, and
@@ -7025,6 +7028,19 @@ out. O3's facts are the section just above ("O3 — runner lows"). Anchors are a
- `builtinsLoaded` / `listed` / the three DRAFT lines: see "L1 — hub lows" above, AMENDED 2026-09-28.
The DRAFT constants are `export/app/ask/hubScopeCopy.ts` `NO_ARCHIVES_IN_SCOPE` `:7` (release 10's,
still unruled), `NO_ARCHIVES_ON_HUB` `:16`, `LIVE_CHAT_MISSING` `:24`, `ASK_SCOPE_LINE` `:33`.
+ **Ruled 2026-09-28 (O1c; `release-11.md`, "Operator rulings and follow-up O1c"): the four ship as
+ written, with no DRAFT markers.** The copy convention is now a tagged template in
+ `export/app/ask/hubScopeCopy.ts`:
+ - `` copy`words ${link("linked phrase")} words` `` returns a `Copy` `{before, link?, after, text}`.
+ - Plain interpolations are text; only a `link()` value (a `CopyLink`) is the link to `/`.
+ - Two links in one line throw where it is built: at module load for the constants, and
+ `ASK_SCOPE_LINE` (`Copy | null`) is evaluated once at load.
+ - `LinkedCopy` renders `before` + one `<Link href="/">` + `after`, and the hub specs read
+ `.text` / `.link`.
+ - A line with no link may be a plain string (`LIVE_CHAT_MISSING`).
+ - Nothing parses brackets any more.
+ - `export/app/ask/*.test.ts` (this one, `citations`, `askChatStorage`) are in no suite script; run
+ them with `export/node_modules/.bin/tsx --test`.
- **`normalizeSocialSvg` themes a SINGLE-colour icon only** (`common/lib/settingsSchema.ts:1373`).
The solid colours of the root and every child outside a `<mask>` or `<clipPath>` are counted
together (spellings folded by `paintKey` `:1304`: `#FFF`, `#ffffff`, `white`, `rgb(255,255,255)`
@@ -7056,8 +7072,9 @@ out. O3's facts are the section just above ("O3 — runner lows"). Anchors are a
= 5` (`siteColor.ts:48-54`), `REQUIRED_TOKENS` is 50 (`common/components/themeConfig.ts:104`). Past
six, `seriesColor`'s golden angle still gives hue 105 (7th) and 243 (8th, near chart-1's blue).
**`ChartView.tsx` (export and editor charts) still cycles `(i % 5) + 1`** and never wears
- `--chart-6`. The rust sits near `--state-gone` (normal ΔE 7.2–13.1) — an open operator question;
- gone is only ever labelled text on the homepage.
+ `--chart-6`. The rust sits near `--state-gone` (normal ΔE 7.2–13.1); gone is only ever labelled
+ text on the homepage. **Ruled 2026-09-28: the rust is accepted near `--state-gone`, and Vermilion
+ keeps slot 6** (`ACCENT_CHART_SLOT.vermilion = 5`).
- **The mark's forced-colours edge** is two classes on `BRAND_MARK_RING_CLASS`
(`common/components/BrandMark.tsx:36-38`): `forced-colors:outline
forced-colors:outline-[color:CanvasText]`, a 1px outline at offset 0 on every base under
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -26,7 +26,7 @@ changed at integration. Nothing was deployed, cut, pushed or restarted; :3001 st
- **O1 — hub lows** (`6cd3a9fe`): `/ask` waits for the hub's list, says when there are no
archives, shows a scope line; a member's missing live chat on its chip with a live-chat Retry;
`normalizeSocialSvg` themes a single-colour icon (the x.com fix); `mcp/README.md`'s `fetch_clip`
- env lines. Three DRAFT copy lines.
+ env lines. Three DRAFT copy lines (ruled 2026-09-28: shipped as written, O1c).
- **O2 — homepage and brand lows** (`139a648e`): a custom hex fitted per base (`perBaseColor`),
`--chart-6` (a rust, Vermilion's family, validated), the mark's forced-colours outline, the
homepage e2e on a synthetic summary.
@@ -48,14 +48,18 @@ changed at integration. Nothing was deployed, cut, pushed or restarted; :3001 st
checkpoint.
- **What the operator owes, in order** (`release-11.md`, "Rollout"; the parent's runbook
`~/reports/overnight-2026-09-28/MORNING.html` has the scripts):
- 1. **The copy rulings** — four lines in `export/app/ask/hubScopeCopy.ts`: `NO_ARCHIVES_IN_SCOPE`
- (`:7`, release 10's, still unruled) and the three DRAFTs `NO_ARCHIVES_ON_HUB` (`:16`),
- `LIVE_CHAT_MISSING` (`:24`) and `ASK_SCOPE_LINE` (`:33`). They ship as drafted unless changed;
- a change is one small commit before the cut.
- 2. **The operator questions the records raise:** should Build all honour `buildPipeline.mode`
- (today a label; O6)? Is `--chart-6`'s rust near `--state-gone` acceptable (O2)? Settle the five
- 410'd Rumble videos with a "Full-check unexpected" on `rekietalaw-rumble` and
- `the-quartering-rumble` (O3)?
+ 1. ~~**The copy rulings**~~ **Ruled 2026-09-28 (the operator: "ship the drafts as written but I
+ don't like this regex bracket link thing").** The four lines in
+ `export/app/ask/hubScopeCopy.ts` ship as written. Follow-up O1c (`a4ffa959`, `r11/hub-lows`)
+ removed the DRAFT markers and replaced the [bracket] + regex link with `` copy`…${link("…")}…` ``.
+ No reader-visible change, so no bullet. Record: `release-11.md`, "Operator rulings and
+ follow-up O1c".
+ 2. **The operator questions the records raise:**
+ - Should Build all honour `buildPipeline.mode` (today a label; O6)? **Open.**
+ - ~~Is `--chart-6`'s rust near `--state-gone` acceptable (O2)?~~ **Ruled 2026-09-28: accepted;
+ Vermilion keeps slot 6.**
+ - Settle the five 410'd Rumble videos with a "Full-check unexpected" on `rekietalaw-rumble`
+ and `the-quartering-rumble` (O3)? **Open.**
3. **A release cut** (`archilyzer release cut all next --commit`; editor 8 and export 9 pending
bullets — export's is public on every `/changelog`).
4. **The :3001 restart** on the new `main` (it brings O4's Homepage section and routes, O3's
diff --git a/plans/release-11.md b/plans/release-11.md
@@ -816,7 +816,9 @@ implementer. Scratch files `o1-*` in the job's `tmp/overnight`.
hub's list, its scope line, the chip's missing live chat, the social icon). `editor/CHANGELOG.md`
and `homepage/CHANGELOG.md` are unchanged, because neither app changed.
-**DRAFT copy for the morning ruling** (`export/app/ask/hubScopeCopy.ts`):
+**DRAFT copy for the morning ruling** (`export/app/ask/hubScopeCopy.ts`). *(Ruled 2026-09-28: all four
+ship as written; the [bracket] link became `${link("…")}` in a `copy` template. See "Operator rulings
+and follow-up O1c" below.)*
- `:16` `NO_ARCHIVES_ON_HUB` = "This hub has no archives yet. Add one on the [hub's front page] to ask."
- `:24` `LIVE_CHAT_MISSING` = "live chat didn't load"
- `:33` `ASK_SCOPE_LINE(n, m)` = "Searching {n} of {m} archive(s) — [change on the front page]." (shown
@@ -964,7 +966,8 @@ implementer. Scratch files `o2-*` in the job's `tmp/overnight`.
- `seriesColor(5)` is `var(--chart-6)` (`CHART_SLOTS = 6`), no longer the golden angle's
`hsl(328 64% 55%)`.
- `ACCENT_CHART_SLOT.vermilion = 5`. The rust is Vermilion's hue family: 8–10° of OKLCH hue from
- its swatches, and 27–33° from the nearest other slot (amber).
+ its swatches, and 27–33° from the nearest other slot (amber). *(Ruled 2026-09-28: Vermilion keeps
+ slot 6.)*
- So Jasolyzer, once published, wears the rust at any rank, and any six sites wear the six
validated slots. Signal, a custom hex and no accent still have no family; they take the lowest
free slot, and the sixth when it is free.
@@ -1022,8 +1025,8 @@ surface | page ground> --pairs all|adjacent`. Log: `o2-validate.log`.
- teal fails CVD against blue and green (4.0–6.4), and would sit on Signal;
- olive fails the normal floor on light and sepia (11–14.9);
- an electric blue or violet fails on dark, or reads as a second chart-1 or chart-3.
-- **A question for the operator: the rust sits near `--state-gone`.** The validator FAILs that pair,
- so it is information, not a gate:
+- **A question for the operator: the rust sits near `--state-gone`.** *(Ruled 2026-09-28: accepted.)*
+ The validator FAILs that pair, so it is information, not a gate:
- normal ΔE 8.4 / 7.2 / 13.1, CVD 5.4 / 3.6 / 11.7;
- release 10 (S2's review) accepted amber and magenta at 11.8–14.5, because gone is only ever
labelled text, never a chart mark; the rust is closer;
@@ -1095,7 +1098,7 @@ with the new tests kept:
`site.accent || FALLBACK_ACCENT`, and the chips and stripes that read the registry's raw `accent`.
It is `export/app/components/hub/**`, O1's, so it was not touched here. The fix is one call where
it parses: `perBaseColor(resolveAccent(hex))`. For O1 or the parent.
-- **The rust near `--state-gone`:** the operator question above.
+- **The rust near `--state-gone`:** the operator question above (ruled 2026-09-28: accepted).
- **Two things do not reach the sixth slot:**
- `ChartView.tsx` (export and editor charts) cycles `var(--chart-${(i % 5) + 1})`, so it never
wears `--chart-6`; its sixth series repeats chart-1, as before. Not a homepage surface.
@@ -1671,6 +1674,72 @@ logs `int-g-*.log` / `int-e2e-*.log`).
| `3643c974` | `plans:` `brand-and-themes.md` — O5 closed S4's Fira and bold-mono notes |
| _this_ | `plans:` this record, STATE ("Now (2026-09-28, morning)"), the Rollout steps |
+### Operator rulings and follow-up O1c (2026-09-28 morning)
+
+**The operator's words:** "Accept the rust, Vermilion keeps slot 6, ship the drafts as written but I
+don't like this regex bracket link thing, just put it in the string or use a template literal or
+something."
+
+**The rulings:**
+- **The four copy lines ship as written.** The three DRAFT markers are removed, and the words are
+ unchanged:
+ - `NO_ARCHIVES_IN_SCOPE` (release 10's): "No archives selected. Choose some on the hub's front
+ page to ask.";
+ - `NO_ARCHIVES_ON_HUB`: "This hub has no archives yet. Add one on the hub's front page to ask.";
+ - `LIVE_CHAT_MISSING`: "live chat didn't load";
+ - `ASK_SCOPE_LINE`: "Searching N of M archive(s) — change on the front page." It is shown whenever
+ at least one archive is in scope.
+- **`--chart-6`'s rust near `--state-gone` is accepted** (O2's question).
+- **Vermilion keeps slot 6** (`ACCENT_CHART_SLOT.vermilion = 5`, the runbook's question).
+- **Still open:** O6's Build all vs `buildPipeline.mode`, and O3's full check of the five 410'd
+ Rumble videos.
+
+**O1c: the link is written in the line** (`a4ffa959`). Branch `r11/hub-lows`, fast-forwarded to
+`main` `e1cedf3d` first.
+- **What replaced the regex.** Each line is a tagged template:
+ `` copy`No archives selected. Choose some on the ${link("hub's front page")} to ask.` ``.
+ - `copy` returns a `Copy`: `{before, link?, after, text}`.
+ - Plain interpolations (numbers, strings, brackets) are text. Only a `link(…)` value (a
+ `CopyLink`) becomes the link to `/`, so nothing is parsed out of the words.
+ - Two links in one line throw where the line is built: at module load for the constants.
+ `ASK_SCOPE_LINE` is evaluated once at load for the same reason.
+- **What was deleted:** `copyWithLink`, `copyText` and the regex.
+- **Where a `Copy` goes:**
+ - `LinkedCopy` takes a `Copy`. `AskChat` and `AskScope` pass the values through unchanged, and
+ `ASK_SCOPE_LINE` returns `Copy | null`.
+ - The hub specs (`ask.spec.ts`, `federated-search.spec.ts`) read `.text` / `.link`.
+- **How to edit a line** is in `hubScopeCopy.ts`'s header: change the words; wrap the linked phrase
+ in `${link("…")}`, or drop the wrapper for no link; `ASK_SCOPE_LINE` may return `null` to hide the
+ line.
+- **Rendered output is unchanged, byte for byte.** `LinkedCopy` still renders `before`, one
+ `<Link href="/">` with the same classes, then `after`. `o1c-same.log` compares the old regex
+ split and the old `copyText` with the new values for both constants and `ASK_SCOPE_LINE` at
+ (0,1), (1,1), (1,2), (2,5) and (5,5): all identical, and `LIVE_CHAT_MISSING` is the same string.
+- **Why:** the operator's ruling. A reader-facing line should not depend on a bracket convention and
+ a regex, and a stray `[` in future copy would have been read as a link.
+- No changelog bullet: nothing a reader sees changed.
+
+| sha | what |
+|---|---|
+| `a4ffa959` | `hub:` `copy` / `link()` / `Copy` replace the [bracket] + regex; the four lines as ruled, DRAFT markers gone; `LinkedCopy` takes a `Copy`; the specs read `.text` / `.link`; +4 unit tests |
+| _this_ | `plans:` the rulings here, in O2's and O1's records, FACTS and STATE; Rollout step 1 |
+
+**Gates** (logs `o1c-*.log`; the e2e logs are `o1-e2e-{hub,export}-3.log`):
+- **tsc:** clean (`o1c-tsc-1.log`, 82 s).
+- **Unit and script tests** (`o1c-units.log`): common **2,112/2,112**, editor unit **85**, `test:scripts`
+ **185 + 1 skipped**, mcp **269**.
+- **The new unit test:** `export/app/ask/hubScopeCopy.test.ts` **4/4** (run with export's tsx; no
+ suite script runs `export/app/**/*.test.ts`).
+- **e2e** (`o1c-run.log`):
+ - `e2e:hub` in full: **33 passed**, 1.8 min;
+ - export `ask-chat` + `ask-workspace`: **29 passed**, 1.4 min.
+ - The primary's `export/public` was unchanged: 380 files, size + mtime + md5.
+
+**It bites** (`o1c-bite.log`):
+- **Against the old `hubScopeCopy.ts`:** **4 of 4 fail**. There is no `copy`/`link`, and the
+ shipped lines are strings with no `.text`.
+- **A mutation, a second link not throwing:** exactly the two-links test fails.
+
## Rollout
Nothing is rolled out: no cut, no deploy and no restart happened overnight, by rule. `main` is the
@@ -1678,24 +1747,14 @@ integration tip (above); the live :3001 editor still runs `BUILD_ID` `S07zTu3MKT
(2026-09-26 21:46), and the live sites are 0.9.4. The operator owes, in order (the parent's runbook,
`~/reports/overnight-2026-09-28/MORNING.html`, has the scripts):
-1. **Rule on the copy lines** in `export/app/ask/hubScopeCopy.ts`: the three marked `// DRAFT
- (release 11, 2026-09-28)`, and release 10's still-unruled one.
- - `:7` `NO_ARCHIVES_IN_SCOPE` (release 10's, unmarked): "No archives selected. Choose some on the
- [hub's front page] to ask."
- - `:16` `NO_ARCHIVES_ON_HUB`: "This hub has no archives yet. Add one on the [hub's front page] to
- ask."
- - `:24` `LIVE_CHAT_MISSING`: "live chat didn't load"
- - `:33` `ASK_SCOPE_LINE(n, m)`: "Searching {n} of {m} archive(s) — [change on the front page]."
- (shown whenever at least one archive is in scope; return `null` for all-in-scope to show it only
- when some are off)
-
- They ship as drafted unless changed. A change is one small commit on `main` before the cut;
- `ask.spec` and `federated-search.spec` import these constants, so they follow.
+1. ~~**Rule on the copy lines**~~ **Ruled 2026-09-28: the four lines ship as written** ("Operator
+ rulings and follow-up O1c", above). The drafts' markers are gone and each link is written in its
+ line (`` copy`…${link("…")}…` ``); no commit is owed for them before the cut.
2. **Answer the records' questions** (none blocks the rollout):
- O6: should Build all / Build & deploy all honour `buildPipeline.mode`? Today the mode is a label
and containers are used whenever `docker version` answers.
- - O2: is `--chart-6`'s rust acceptable beside `--state-gone`? The validator fails that pair; gone
- is only labelled text on the homepage.
+ - ~~O2: is `--chart-6`'s rust acceptable beside `--state-gone`?~~ **Ruled 2026-09-28: accepted;
+ Vermilion keeps slot 6.**
- O3: settle the five Rumble videos that got HTTP 410 on 2026-09-25 with a "Full-check unexpected"
on `rekietalaw-rumble` and `the-quartering-rumble`. They read "Missing?" after the next build;
the full check would make them Deleted.