commit 88bcd4ec6196b14dd65da847449e62a596f78ac4
parent 8322925460d57f913c3e2f673ba78ba77db4ce6c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 22:58:35 -0400
common, export: stacked areas keep their series-coloured edge; the surface gap stays on stacked bars only (review H1)
The 4 px surface stroke along each band's top also ran along the stack's upper
edge and over any band under ~2 px: on translucent fills it erased small values
and cut peaks (/stats at 390 px read a 19.8K peak as about 17.6K). Ruled:
ChartView's and CrossSiteChart's stacked areas are main's again (the series
colour, 1 px and 1.5 px); BAR_GAP stays.
charts.spec reads the chart back from a screenshot (common/testing/
chartPixels.ts: the browser decodes the PNG into a canvas): at 390 and 1280 px
the stack's topmost painted row at each month is within 1 px of the value
scale's y for the true total (gridline y), and each series with data shows
pixels of its own composited fill. With the old gap the tops land 2–4 px low
and the test fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
6 files changed, 225 insertions(+), 36 deletions(-)
diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx
@@ -26,7 +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";
+import { BAR_GAP } from "./surfaceGap";
const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
@@ -171,7 +171,7 @@ export function ChartView({
dataKey={m.id}
name={m.label}
type="monotone"
- {...(meta.length > 1 ? AREA_GAP : { stroke: `var(--color-${m.id})` })}
+ stroke={`var(--color-${m.id})`}
fill={`var(--color-${m.id})`}
fillOpacity={0.2}
stackId={meta.length > 1 ? "a" : undefined}
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -22,7 +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";
+import { 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
@@ -68,8 +68,8 @@ 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.
+ // The marks spec's surface gap between touching bar segments
+ // (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;
@@ -119,9 +119,10 @@ export function CrossSiteChart({
dataKey={s.id}
name={s.label}
type="monotone"
- {...(stacked ? AREA_GAP : { stroke: s.color, strokeWidth: 1.5 })}
+ stroke={s.color}
fill={s.color}
fillOpacity={0.25}
+ strokeWidth={1.5}
stackId="a"
isAnimationActive={false}
/>
diff --git a/common/components/charts/surfaceGap.ts b/common/components/charts/surfaceGap.ts
@@ -1,15 +1,14 @@
-// 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.
+// THE SURFACE GAP (the marks spec) between touching BARS: the segments of a
+// stacked bar are parted by a 2 px gap in the colour behind the plot, 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 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).
//
-// 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.
+// Stacked AREAS keep their series-coloured top edge (ruled after review,
+// 2026-09-28): a surface stroke along a band's top also runs along the stack's
+// upper edge and over any band under ~2 px, and on translucent fills it erased
+// small values and cut peaks. A gap there waits on opaque fills (a ruling), a
+// gap on inner boundaries only, and the growth chart's thin-band rule.
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/testing/chartPixels.ts b/common/testing/chartPixels.ts
@@ -0,0 +1,110 @@
+// WHAT A CHART PAINTED, for the e2e specs: a screenshot of the chart read back
+// as pixels in the page (the browser's own PNG decoder, an <img> drawn to a
+// canvas), so a test checks the rendered geometry — where the stack's top
+// is, which colours show — rather than what a style says a stroke is.
+//
+// No Playwright import (common/ does not depend on it): the page and the
+// locator are typed by the two methods this uses.
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Shooter = { screenshot(opts?: any): Promise<Buffer> };
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Evaluator = { evaluate(fn: any, arg: any): Promise<any> };
+
+export type Rgb = [number, number, number];
+
+// "rgb(1, 2, 3)" / "rgba(1, 2, 3, 0.5)" → [1, 2, 3].
+export function rgbOf(css: string): Rgb {
+ const m = css.match(/[\d.]+/g);
+ if (!m || m.length < 3) throw new Error(`not an rgb() colour: ${css}`);
+ return [Number(m[0]), Number(m[1]), Number(m[2])];
+}
+
+// `alpha` of `fg` over `bg`, as the browser composites a translucent fill.
+export function over(fg: Rgb, alpha: number, bg: Rgb): Rgb {
+ return [0, 1, 2].map((c) => Math.round(fg[c] * alpha + bg[c] * (1 - alpha))) as Rgb;
+}
+
+export type Painted = {
+ // Per requested column: the first row, from the top, of a run of `run` rows
+ // that each differ from `ground` by more than `tol` in some channel; null
+ // when the column is all ground.
+ tops: (number | null)[];
+ // Per requested colour: how many pixels are within `tol` of it, and in how
+ // many distinct columns.
+ counts: { pixels: number; columns: number }[];
+ width: number;
+ height: number;
+};
+
+export async function painted(
+ page: Evaluator,
+ target: Shooter,
+ opts: { columns: number[]; ground: Rgb; colours: Rgb[]; tol?: number; run?: number },
+): Promise<Painted> {
+ const png = await target.screenshot({ scale: "css", animations: "disabled" });
+ return page.evaluate(
+ async ({
+ b64,
+ columns,
+ ground,
+ colours,
+ tol,
+ run,
+ }: {
+ b64: string;
+ columns: number[];
+ ground: Rgb;
+ colours: Rgb[];
+ tol: number;
+ run: number;
+ }) => {
+ const img = new Image();
+ img.src = `data:image/png;base64,${b64}`;
+ await img.decode();
+ const c = document.createElement("canvas");
+ c.width = img.naturalWidth;
+ c.height = img.naturalHeight;
+ const ctx = c.getContext("2d")!;
+ ctx.drawImage(img, 0, 0);
+ const { data, width, height } = ctx.getImageData(0, 0, c.width, c.height);
+ const at = (x: number, y: number) => (y * width + x) * 4;
+ const differs = (x: number, y: number) => {
+ const p = at(x, y);
+ return [0, 1, 2].some((k) => Math.abs(data[p + k] - ground[k]) > tol);
+ };
+ const tops = columns.map((cx) => {
+ const x = Math.min(width - 1, Math.max(0, Math.round(cx)));
+ for (let y = 0; y + run <= height; y++) {
+ let solid = true;
+ for (let d = 0; d < run && solid; d++) solid = differs(x, y + d);
+ if (solid) return y;
+ }
+ return null;
+ });
+ const counts = colours.map((col) => {
+ let pixels = 0;
+ const cols = new Set<number>();
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const p = at(x, y);
+ if ([0, 1, 2].every((k) => Math.abs(data[p + k] - col[k]) <= tol)) {
+ pixels++;
+ cols.add(x);
+ }
+ }
+ }
+ return { pixels, columns: cols.size };
+ });
+ return { tops, counts, width, height };
+ },
+ {
+ b64: png.toString("base64"),
+ columns: opts.columns,
+ ground: opts.ground,
+ colours: opts.colours,
+ tol: opts.tol ?? 12,
+ run: opts.run ?? 3,
+ },
+ );
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,7 +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.
+- **A chart's stacked bars are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; they now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Stacked areas keep their line in each series' colour along the top, charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site.
- **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark, and the slide-out menu's Base list offers those three. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site.
- **The header carries the operator's social links and one theme toggle, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's now end with the social icons (the site's `socialLinks`, else `settings.json`'s; at most four: the ones marked **Show in header** when any is, else the last four) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring; the footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour). The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. On a narrow screen no icon is hidden: the site's name drops first and its mark stays, whenever the name does not fit beside the icons (whatever its length), and only then do the icons scroll sideways in their own box, the last one in view first. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site.
diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { installChartRoutes, urlParams } from "./helpers";
+import { over, painted, rgbOf } from "../../common/testing/chartPixels";
// Charts are now a VIEW MODE of the search page: a "Results | Chart" toggle
// plots the current search/filters as a single chart. Stats, summaries and
@@ -16,6 +17,17 @@ async function runSearch(page: Page, term: string) {
await expect(page).toHaveURL(/[?&]qt=/);
}
+// The chart card's colour: what is behind the plot.
+function cardColour(page: Page) {
+ return page.locator(".recharts-surface").first().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 "";
+ });
+}
+
function chartTab(page: Page) {
return page.getByTestId("view-toggle").getByRole("button", { name: "Chart" });
}
@@ -210,12 +222,14 @@ 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 }) => {
+ // THE SURFACE GAP (common/components/charts/surfaceGap.ts): the segments of
+ // a stacked bar are parted by a 2 px stroke in the colour behind the plot —
+ // the chart card — never by a line of their own. Stacked AREAS keep their
+ // series-coloured top edge (a surface stroke there erased small values and
+ // cut peaks).
+ test("stacked bars are parted by a gap in the card's colour; stacked areas keep their own edge", async ({
+ page,
+ }) => {
await page.goto("/");
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
@@ -224,13 +238,7 @@ test.describe("charts (search view mode)", () => {
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 surface = await cardColour(page);
const text = await page.evaluate(() => getComputedStyle(document.body).color);
expect(surface).not.toBe(text);
const strokes = (sel: string) =>
@@ -243,8 +251,79 @@ test.describe("charts (search view mode)", () => {
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"]);
+ const edges = await page.locator(".recharts-area").evaluateAll((els) =>
+ els.map((g) => [
+ getComputedStyle(g.querySelector(".recharts-area-curve")!).stroke,
+ getComputedStyle(g.querySelector(".recharts-area-area")!).fill,
+ ]),
+ );
+ expect(edges.length).toBeGreaterThan(1);
+ for (const [stroke, fill] of edges) {
+ expect(stroke).toBe(fill);
+ expect(stroke).not.toBe(surface);
+ }
});
+
+ // THE DATA IS WHAT IS PAINTED. Read back from a screenshot: at each month
+ // the stack's topmost painted row is within 1 px of the value scale's y for
+ // the month's true total (three fixture videos, one per month, so a total of
+ // 1 each), and every series with data shows pixels of its own fill.
+ for (const width of [390, 1280]) {
+ test(`${width} px: a stacked area paints its true total and every band`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 1000 });
+ await page.goto("/");
+ await expect(page.getByTestId("results-summary")).toContainText("All videos");
+ await openChart(page);
+ const opts = page.getByTestId("chart-options");
+ // Collapsed on a phone.
+ if (!(await opts.evaluate((el) => (el as HTMLDetailsElement).open))) {
+ await opts.locator("summary").click();
+ }
+ await opts.locator('label:has-text("Chart type") select').selectOption("area");
+ await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType");
+ const svg = page.locator(".recharts-surface").first();
+ await expect(page.locator(".recharts-area")).toHaveCount(3, { timeout: 20_000 });
+ // No tooltip or active dot over the plot, and recharts' entry animation done.
+ await page.mouse.move(0, 0);
+ await expect(page.locator(".recharts-tooltip-wrapper")).toBeHidden();
+ await page.waitForTimeout(1_600);
+ const geo = await svg.evaluate((el) => {
+ const num = (t: Element) => Number((t.textContent ?? "").replace(/[^\d.-]/g, ""));
+ // The value scale: each tick's value, at its gridline's y (a tick
+ // label sits a pixel off its line).
+ const values = [...el.querySelectorAll(".recharts-yAxis .recharts-cartesian-axis-tick-value")]
+ .map(num)
+ .sort((p, q) => p - q);
+ const lines = [...el.querySelectorAll(".recharts-cartesian-grid-horizontal line")]
+ .map((l) => Number(l.getAttribute("y1")))
+ .sort((p, q) => q - p);
+ const yTicks = values.map((v, i) => ({ v, y: lines[i] }));
+ const xTicks = [...el.querySelectorAll(".recharts-xAxis .recharts-cartesian-axis-tick-value")].map((t) =>
+ Number(t.getAttribute("x")),
+ );
+ const fills = [...el.querySelectorAll(".recharts-area-area")].map((p) => {
+ const cs = getComputedStyle(p);
+ return { fill: cs.fill, opacity: Number(cs.fillOpacity) };
+ });
+ return { yTicks, xTicks, fills };
+ });
+ const [a, b] = [geo.yTicks[0], geo.yTicks[geo.yTicks.length - 1]];
+ const yOf = (v: number) => a.y + ((v - a.v) * (b.y - a.y)) / (b.v - a.v);
+ const ground = rgbOf(await cardColour(page));
+ const shot = await painted(page, svg, {
+ columns: geo.xTicks,
+ ground,
+ colours: geo.fills.map((f) => over(rgbOf(f.fill), f.opacity, ground)),
+ tol: 10,
+ });
+ expect(geo.xTicks.length).toBe(3);
+ for (const [i, top] of shot.tops.entries()) {
+ expect(top, `month ${i}: nothing painted`).not.toBeNull();
+ expect(Math.abs(top! - yOf(1)), `month ${i}: top ${top} vs ${yOf(1).toFixed(1)}`).toBeLessThanOrEqual(1);
+ }
+ for (const [i, c] of shot.counts.entries()) {
+ expect(c.columns, `series ${i}'s own colour`).toBeGreaterThan(0);
+ }
+ });
+ }
});