commit cf6353130de911f1302cb64ddee06ef499d63d9c
parent 8799ae0a09f20bfcaefae6bf9a01ce5cc6eedf04
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 21:09:31 -0400
homepage, common: chart bands are parted by a 2 px gap in the surface's colour; the foreground separator lines are withdrawn
The operator's ruling: the chart follows standard practice for light and dark
grounds. The growth chart's gap runs along each band's upper edge in the page
ground's colour (the chart sits on it), 2 px, non-scaling, round joins, Canvas
in forced colours. It is worked out for each of the plot's three heights (one
<g> each, shown by its class): a gap is drawn only where another band sits on
the edge and both bands are at least 3 px tall at that height, and a run under
three months is dropped, so a thin band touches its neighbour rather than
vanishing. The shared charts' stacked bars (2 px) and stacked areas (4 px, 2
showing under the band above) take the same gap in --chart-gap: the chart
surface, and Canvas in forced colours. Single-series charts, lines and
side-by-side bars are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
11 files changed, 201 insertions(+), 60 deletions(-)
diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx
@@ -26,6 +26,7 @@ import {
import { useMediaQuery } from "../../lib/useMediaQuery";
import type { ChartData } from "../../lib/chartAggregate";
import { xAxisLabel, yAxisLabel, type ChartConfig } from "../../lib/chartConfig";
+import { AREA_GAP, BAR_GAP } from "./surfaceGap";
const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
@@ -170,7 +171,7 @@ export function ChartView({
dataKey={m.id}
name={m.label}
type="monotone"
- stroke={`var(--color-${m.id})`}
+ {...(meta.length > 1 ? AREA_GAP : { stroke: `var(--color-${m.id})` })}
fill={`var(--color-${m.id})`}
fillOpacity={0.2}
stackId={meta.length > 1 ? "a" : undefined}
@@ -187,6 +188,7 @@ export function ChartView({
name={m.label}
fill={`var(--color-${m.id})`}
radius={2}
+ {...(config.type === "stackedBar" && meta.length > 1 ? BAR_GAP : {})}
stackId={config.type === "stackedBar" ? "a" : undefined}
/>
))}
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -22,6 +22,7 @@ import { ChartMessage } from "./ChartMessage";
import type { HomepageSummary } from "../../lib/homepageSummary";
import { type ChartState } from "../../lib/homepageChart";
import { buildTimeSeries } from "../../lib/homepageChartData";
+import { AREA_GAP, BAR_GAP } from "./surfaceGap";
// The stacked renderer: area (cumulative/Growth) or bars, optionally 100%-share.
// Stacking shows per-series composition *and* the combined total at once — the
@@ -67,6 +68,9 @@ export function CrossSiteChart({
}
const isArea = state.chartType === "area";
+ // The marks spec's surface gap between touching marks (charts/surfaceGap.ts):
+ // only when there is more than one series to part.
+ const stacked = series.length > 1;
const isShare = state.valueMode === "share";
const stackOffset = isShare ? "expand" : undefined;
const showLegend = state.breakdown === "channel" && series.length > 1;
@@ -115,10 +119,9 @@ export function CrossSiteChart({
dataKey={s.id}
name={s.label}
type="monotone"
- stroke={s.color}
+ {...(stacked ? AREA_GAP : { stroke: s.color, strokeWidth: 1.5 })}
fill={s.color}
fillOpacity={0.25}
- strokeWidth={1.5}
stackId="a"
isAnimationActive={false}
/>
@@ -133,6 +136,7 @@ export function CrossSiteChart({
dataKey={s.id}
name={s.label}
fill={s.color}
+ {...(stacked ? BAR_GAP : {})}
stackId="a"
isAnimationActive={false}
/>
diff --git a/common/components/charts/surfaceGap.ts b/common/components/charts/surfaceGap.ts
@@ -0,0 +1,15 @@
+// THE SURFACE GAP (the marks spec): touching marks — the bands of a stacked
+// area, the segments of a stacked bar — are parted by a 2 px gap in the colour
+// behind the plot, one width across the stack, never by a line drawn around
+// them. `--chart-gap` is that colour (tokens.css: the chart surface, and the
+// reader's Canvas in forced colours). Recharts draws in CSS pixels, so a width
+// here is a width on screen.
+//
+// A BAR segment's stroke is centred on its edge: 1 px inside each of two
+// touching segments makes the 2 px gap (the outer edges meet the surface, so
+// nothing shows there). An AREA's stroke runs along its upper edge only, and
+// the band above is drawn after it and covers the upper half; the stroke is
+// 4 px so the 2 px left inside the lower band are the gap. The bands' fills
+// are translucent, so the covered half reads as the band above.
+export const BAR_GAP = { stroke: "var(--chart-gap)", strokeWidth: 2 } as const;
+export const AREA_GAP = { stroke: "var(--chart-gap)", strokeWidth: 4 } as const;
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -470,3 +470,18 @@ html[data-accent="custom"] {
--brand: var(--swatch-custom);
--brand-mark: var(--accent-custom-dark, #5fa8a0);
}
+
+/* ---------------------------------------------------------------------------
+ THE CHART GAP — the colour that parts touching chart marks, the marks
+ spec's surface gap (common/components/charts/surfaceGap.ts): the chart
+ surface on every base (a chart card is `--card`, the same value), and the
+ reader's Canvas in forced colours.
+ --------------------------------------------------------------------------- */
+:root {
+ --chart-gap: var(--chart-surface);
+}
+@media (forced-colors: active) {
+ :root {
+ --chart-gap: Canvas;
+ }
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
- **The charts count every transcript, once the site is rebuilt.** A transcript that arrived after its video was first indexed was missing from the charts' transcript and cue counts and from "Transcribed over time", and a video with YouTube captions alone had no transcription date. Both are counted now, and a captioned video is dated by when its captions arrived.
- **A social icon that fails the check is shown as its label, and every icon paints inside its box.** The footer inlines a social link's SVG only if it passes the same check a save runs (what an icon may contain is in `SITE.md`); otherwise the link shows its label as text, at most 10rem with an ellipsis. Each icon is clipped to its own box. Needs a rebuild and deploy of each site.
+- **A chart's stacked bars and stacked areas are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; a stacked area's bands each had a line in their own colour along the top. Both now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site.
## [0.10.0] - 2026-09-28
- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site.
diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts
@@ -210,4 +210,41 @@ test.describe("charts (search view mode)", () => {
page.getByTestId("chart-options").locator("select").first(),
).toHaveValue("bar");
});
+ // THE SURFACE GAP (common/components/charts/surfaceGap.ts): touching marks
+ // are parted by the colour behind the plot — the chart card — never by a
+ // line of their own. A stacked bar's segments carry a 2 px stroke in it; a
+ // stacked area's bands a 4 px one along their upper edge, half of it under
+ // the band above, so 2 px show.
+ test("stacked marks are parted by a gap in the card's colour", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("results-summary")).toContainText("All videos");
+ await openChart(page);
+ const opts = page.getByTestId("chart-options");
+ await opts.locator('label:has-text("Chart type") select').selectOption("stackedBar");
+ await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType");
+ const bars = page.locator(".recharts-bar-rectangle path");
+ await expect(bars.first()).toBeVisible({ timeout: 20_000 });
+ const surface = await page.locator(".recharts-surface").evaluate((el) => {
+ for (let n: Element | null = el; n; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg;
+ }
+ return "";
+ });
+ const text = await page.evaluate(() => getComputedStyle(document.body).color);
+ expect(surface).not.toBe(text);
+ const strokes = (sel: string) =>
+ page.locator(sel).evaluateAll((els) =>
+ els.map((e) => [getComputedStyle(e).stroke, getComputedStyle(e).strokeWidth]),
+ );
+ const barStrokes = await strokes(".recharts-bar-rectangle path");
+ expect(barStrokes.length).toBeGreaterThan(0);
+ for (const s of barStrokes) expect(s).toEqual([surface, "2px"]);
+
+ await opts.locator('label:has-text("Chart type") select').selectOption("area");
+ await expect(page.locator(".recharts-area-curve").first()).toBeAttached({ timeout: 20_000 });
+ const curves = await strokes(".recharts-area-curve");
+ expect(curves.length).toBeGreaterThan(1);
+ for (const s of curves) expect(s).toEqual([surface, "4px"]);
+ });
});
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -6,7 +6,7 @@
- **One theme toggle in place of the two theme buttons.** The header's theme menu (Base and Accent) and its base toggle are one toggle, the last of the header's icons, that cycles the ground (System, Light, Sepia, Dark) and is named for the next one. There is no accent to pick: the homepage's is its own, Signal, and an accent stored by an earlier build is ignored here, with no flash.
- **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).
-- **The growth chart's strata are parted by lines in the text colour.** The line along each stratum's upper edge was drawn in the page's background colour, so it was light on Light and dark on Dark; it is now a 1 px line in the ground's foreground (dark on Light and Sepia, light on Dark, the system's text colour in high-contrast mode). The gridlines are unchanged.
+- **The growth chart's bands are separated by a 2 px gap in the ground's colour.** The gap runs along each band's upper edge where another band sits on it, the same 2 px at every width (it was a 1.25 px line in the page colour); where a band is too thin to give its share and keep a pixel of its own colour at the size the chart is drawn, the two bands touch instead, so no band disappears. In high-contrast mode the gap is the system's background colour. The gridlines are unchanged. The `/stats` charts' stacked areas and stacked bars are separated by the same 2 px gap in the chart panel's colour.
- **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, and every icon paints inside its own box. A stored icon that fails the check a save runs is shown as its label (at most 10rem, with an ellipsis) instead.
- **The e2e no longer reads the checkout's `settings.json` or `homepage.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient with an outline, 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. `e2e/social.spec.ts` covers the header and footer rows (at every width, with 1, 3 and 4 links, both pointers; the scroll fallback; hostile stored icons that must neither run nor fetch), `e2e/toggle.spec.ts` the theme toggle and the pinned accent, `e2e/svg-vectors.spec.ts` that every accepted icon stays inside its `<svg>` in a real parse, `e2e/growth-chart.spec.ts` the chart's lines, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the ground through one helper, `chooseTheme` (`e2e/helpers.ts`).
- **A site's card counts every transcript, and never shows 0 channels while it serves recordings.** A transcript that arrived after its video was first indexed, or a video with YouTube captions alone, could be left out of the family's numbers: one site served 1,889 recordings and its card said 0 transcripts, 0 channels and 0 hours. Such transcripts are counted now — in the card, the family totals and the archive-growth chart — and one with no transcription date is left off only what is placed by that date: the charts by transcription date, "this month" and the recent list. The official-instance figures on the hub move with them.
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -56,6 +56,18 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
const W = 1000;
const H = 300;
+// The surface gap between bands (the marks spec: 2 px, one width across the
+// stack), and the plot's rendered heights with the classes that show the gaps
+// worked out for each (the plot box is `h-[200px] sm:h-[260px] lg:h-[300px]`).
+const GAP_PX = 2;
+const PLOT_HEIGHTS = [
+ { px: 200, className: "sm:hidden" },
+ { px: 260, className: "hidden sm:inline lg:hidden" },
+ { px: 300, className: "hidden lg:inline" },
+] as const;
+// A run of gap shorter than this many months is dropped: a speck of gap reads
+// as noise, not as a parting.
+const MIN_GAP_RUN = 3;
const STACK_ORDER = [0, 1, 2, 3, 4];
function stackOrder(n: number): number[] {
@@ -104,19 +116,55 @@ export function ArchiveGrowthChart({
// Layers bottom-up, each with its lower and upper edge per month.
const order = stackOrder(sites.length);
const base = new Array<number>(n).fill(0);
- const layers = order.map((si) => {
- const site = sites[si];
+ const stacked = order.map((si) => {
const lo = base.slice();
- const hi = months.map((m, i) => (base[i] += m.bySite[site.siteId] ?? 0));
+ const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0));
+ return { si, lo, hi };
+ });
+ const height = (k: number, i: number) => stacked[k].hi[i] - stacked[k].lo[i];
+ // The band a gap along band k's upper edge would share with: the next band
+ // up with a height this month, or none (the stack's top meets the surface).
+ const nextUp = (k: number, i: number) => {
+ for (let m = k + 1; m < stacked.length; m++) if (height(m, i) > 0) return height(m, i);
+ return 0;
+ };
+ const layers = stacked.map(({ si, lo, hi }) => {
const top = hi.map((v, i) => `${r(x(i))},${r(y(v))}`);
const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse();
return {
- site,
+ site: sites[si],
color: colors[si],
+ top,
area: `M${top.join("L")}L${bottom.join("L")}Z`,
- edge: `M${top.join("L")}`,
};
});
+ // The gaps, per plot height. A band gives half a gap on each edge it
+ // shares, so up to GAP_PX in all: it gives any only when it is at least
+ // GAP_PX + 1 px tall at that height, keeping a pixel of its own colour. So a
+ // gap runs along a band's upper edge where another band sits on it (the
+ // stack's top meets the surface itself) and both are that tall; thinner
+ // bands touch rather than vanish.
+ const gapSets = PLOT_HEIGHTS.map(({ px, className }) => {
+ const minBand = ((GAP_PX + 1) * yMax) / px;
+ const paths = layers.map((l, k) => {
+ const parted = (i: number) => height(k, i) >= minBand && nextUp(k, i) >= minBand;
+ const runs: string[] = [];
+ let run: string[] = [];
+ const close = () => {
+ if (run.length > MIN_GAP_RUN) runs.push(`M${run.join("L")}`);
+ run = [];
+ };
+ for (let i = 0; i < n - 1; i++) {
+ if (parted(i) && parted(i + 1)) {
+ if (run.length === 0) run.push(l.top[i]);
+ run.push(l.top[i + 1]);
+ } else close();
+ }
+ close();
+ return { key: l.site.siteId, d: runs.join("") };
+ });
+ return { px, className, paths: paths.filter((p) => p.d) };
+ });
// Year ticks at each January. Every second year from sm up, every fourth on a
// phone; the ends are skipped so a label never hangs off the plot.
@@ -180,26 +228,31 @@ export function ArchiveGrowthChart({
{layers.map((l) => (
<path key={l.site.siteId} d={l.area} fill={l.color} />
))}
- {/* The separators: each layer's upper edge is a 1 px hairline in
- the ground's FOREGROUND (the page text's colour) at full
- strength — dark on Light and Sepia, light on Dark. Colour and
- width are `.growth-sep` in globals.css (CanvasText in forced
- colours), so they follow the theme with no script. Full
- strength because only it reaches 3:1 against any band (60 %
- and 35 % reach it against none); 1 px, not the 1.25 px the
- old page-coloured gap was, so a 2–3 px stratum keeps its
- colour between two lines. Against some bands even the
- foreground stays under 3:1 (Light: violet, rust; Sepia:
- green, violet, magenta, rust; Dark: green, violet, amber). */}
- {layers.map((l) => (
- <path
- key={`${l.site.siteId}-edge`}
- d={l.edge}
- className="growth-sep"
- fill="none"
- strokeLinejoin="round"
- vectorEffect="non-scaling-stroke"
- />
+ {/* THE SURFACE GAP (the marks spec): touching bands are parted by
+ a 2 px gap in the colour behind the plot — the page ground, as
+ the chart sits on it — never by a line of their own. It runs
+ along each band's upper edge, centred, so each neighbour gives
+ 1 px. Where nothing sits on a band (the stack's top meets the
+ surface itself), or where either band is too thin to give its
+ pixel and keep one of its own colour at the height the plot
+ is drawn (one set of gaps per height, shown by its class),
+ there is no gap: thin bands touch rather than vanish. Colour
+ and width are
+ `.growth-gap` in globals.css (Canvas in forced colours), so
+ they follow the theme with no script. */}
+ {gapSets.map((set) => (
+ <g key={set.px} className={set.className}>
+ {set.paths.map((p) => (
+ <path
+ key={p.key}
+ d={p.d}
+ className="growth-gap"
+ fill="none"
+ strokeLinejoin="round"
+ vectorEffect="non-scaling-stroke"
+ />
+ ))}
+ </g>
))}
{months.map((m, i) => {
const w = W / n;
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -106,17 +106,16 @@ body {
fill: var(--chart-grid);
}
-/* The growth chart's separators: each stratum's upper edge, a 1 px line in
- the ground's foreground at full strength — dark on Light and Sepia, light on
- Dark (ArchiveGrowthChart.tsx says why this strength). */
-.growth-sep {
- stroke: var(--foreground);
- stroke-opacity: 1;
- stroke-width: 1px;
+/* The growth chart's surface gap (ArchiveGrowthChart.tsx): 2 px along a
+ band's upper edge, in the colour behind the plot — the page ground — so
+ touching bands read apart by a gap, never by a line drawn around them.
+ Forced colours: the reader's Canvas. */
+.growth-gap {
+ stroke: var(--background);
+ stroke-width: 2px;
}
@media (forced-colors: active) {
- .growth-sep {
- stroke: CanvasText;
- stroke-opacity: 1;
+ .growth-gap {
+ stroke: Canvas;
}
}
diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts
@@ -1,10 +1,11 @@
import { test, expect, type Page } from "@playwright/test";
-// The growth chart's separators — each stratum's upper edge — are the ground's
-// FOREGROUND at full strength, 1 px: dark on Light and Sepia, light on Dark
-// (ArchiveGrowthChart.tsx, globals.css `.growth-sep`), and CanvasText in forced
-// colours. The fixture summary (fixture-summary.ts) has six sites with monthly
-// data, so the chart and its six separators render.
+// The growth chart's bands are parted by the marks spec's SURFACE GAP: 2 px
+// along each band's upper edge, in the colour behind the plot — the page
+// ground, which the chart sits on — never a line in the text colour
+// (ArchiveGrowthChart.tsx, globals.css `.growth-gap`), and the reader's Canvas
+// in forced colours. The fixture summary (fixture-summary.ts) has six sites
+// with monthly data, so the chart and its gaps render.
const BASE_KEY = "ytdlp-tb:base";
@@ -18,15 +19,15 @@ const resolveColor = (page: Page, css: string) =>
return out;
}, css);
-const separators = (page: Page) =>
- page.locator("figure svg path.growth-sep").evaluateAll((els) =>
+const gaps = (page: Page) =>
+ page.locator("figure svg path.growth-gap").evaluateAll((els) =>
els.map((el) => {
const s = getComputedStyle(el);
- return { stroke: s.stroke, opacity: s.strokeOpacity, width: s.strokeWidth };
+ return { stroke: s.stroke, width: s.strokeWidth, scaling: s.vectorEffect };
}),
);
-test("the separators are the ground's foreground at full strength, never the page background", async ({
+test("the bands are parted by a 2 px gap in the ground's colour, never the text colour", async ({
page,
}) => {
await page.goto("/");
@@ -34,22 +35,31 @@ test("the separators are the ground's foreground at full strength, never the pag
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-base", base);
- const fg = await resolveColor(page, "var(--foreground)");
- const bg = await resolveColor(page, "var(--background)");
- expect(fg).not.toBe(bg);
- const seps = await separators(page);
- expect(seps.length, base).toBeGreaterThan(0);
- for (const s of seps) {
- expect(s, base).toEqual({ stroke: fg, opacity: "1", width: "1px" });
+ // The chart sits on the page ground: nothing between them paints.
+ const holder = await page.locator("figure").evaluate((el) => {
+ for (let n: Element | null = el; n && n !== document.body; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg;
+ }
+ return getComputedStyle(document.body).backgroundColor;
+ });
+ const ground = await resolveColor(page, "var(--background)");
+ const text = await resolveColor(page, "var(--foreground)");
+ expect(holder, `${base}: what the chart sits on`).toBe(ground);
+ expect(ground).not.toBe(text);
+ const seen = await gaps(page);
+ expect(seen.length, base).toBeGreaterThan(0);
+ for (const g of seen) {
+ expect(g, base).toEqual({ stroke: ground, width: "2px", scaling: "non-scaling-stroke" });
}
}
});
-test("in forced colours the separators are CanvasText", async ({ page }) => {
+test("in forced colours the gap is the reader's Canvas", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/");
- const canvasText = await resolveColor(page, "CanvasText");
- const seps = await separators(page);
- expect(seps.length).toBeGreaterThan(0);
- for (const s of seps) expect(s.stroke).toBe(canvasText);
+ const canvas = await resolveColor(page, "Canvas");
+ const seen = await gaps(page);
+ expect(seen.length).toBeGreaterThan(0);
+ for (const g of seen) expect(g.stroke).toBe(canvas);
});
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -48,6 +48,8 @@ branch:
8. The options menu is dropped for now in favour of a three-way toggle.
9. (The re-review, ruled by the parent.) A title or desc can no longer reach a page; an icon loads
nothing from elsewhere; a stored icon the checker refuses does not block an unrelated save.
+10. (After the merge, 2026-09-28.) The chart follows standard practice for light and dark grounds;
+ the foreground-coloured separator lines are withdrawn.
**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,
@@ -250,6 +252,9 @@ colours. The gridlines are unchanged.
(`common/components/charts/`, the homepage's `/stats`, the export sites and the hub) draw each
series' edge in its own colour and have no page-coloured separator. Whether they take a
foreground separator is left for the operator to rule on.
+- **Withdrawn (ruling 10).** The separators were first drawn in the foreground colour, then
+ withdrawn for a 2 px surface gap on the operator's ruling; that change is on
+ `r14/two-grounds-headers` (its record, below).
**Beyond the prompt.**
- **Icon ids are scoped per copy (`scopeSvgIds`).** An id resolves to the first element carrying