From 289a9c8b946eb05f95a1cbcdf797071c7d6dd152 Mon Sep 17 00:00:00 2001 From: ecency Date: Fri, 3 Jul 2026 11:24:57 +0000 Subject: [PATCH 1/4] feat(creator-economy): hover values on trend lines (pure CSS) Hovering a line chart now reveals the exact values for the quarter under the cursor: a per-point hit band shows a hairline, ringed dots on each series and a fixed readout (quarter plus formatted values) at the chart's top left. Implementation is CSS-only visibility on server-rendered SVG - no client JS, and no svg title tooltips (the document title streams late on this page, so a first-title parser would read a tooltip as the page title; same reasoning as the aria-hidden marks). Touch/keyboard users keep the full-precision tables, which remain the accessible view of every chart. Adds a LineChart spec (hover band per point, readout contents, multi-series naming, no-title invariant). --- .../creator-economy/_components/charts.tsx | 47 +++++++++++++++++++ .../(staticPages)/creator-economy/page.tsx | 2 + .../creator-economy/line-chart.spec.tsx | 45 ++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 apps/web/src/specs/features/creator-economy/line-chart.spec.tsx diff --git a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx index f3277b7f89..667056acce 100644 --- a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx +++ b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx @@ -129,6 +129,11 @@ export function ColumnChart({ * Mark specs: 2px round-join line, >=8px end dot with a 2px surface ring, * ~10% area wash for a single series. Labels are selective (first / peak / * last values; year ticks on the x-axis) - the table views carry every value. + * + * Hover: pure-CSS per-point bands (see .ce-hband/.ce-hlbl in the page's style + * block) reveal a hairline, point dots and a fixed readout with the exact + * values. No client JS, and no svg tooltips - a streamed-late document + * <title> means naive parsers would read a tooltip as the page title. */ export function LineChart({ labels, @@ -252,6 +257,48 @@ export function LineChart({ </g> ); })} + {/* hover layer: one hit band per point; CSS reveals the readout */} + <g aria-hidden="true"> + {labels.map((label, i) => { + const left = i === 0 ? 0 : (px(i - 1) + px(i)) / 2; + const right = i === n - 1 ? W : (px(i) + px(i + 1)) / 2; + return ( + <g key={`h-${i}`} className="ce-hband"> + <rect x={left} y={0} width={right - left} height={H} fill="transparent" /> + <g className="ce-hlbl"> + <line + x1={px(i)} + y1={padTop} + x2={px(i)} + y2={H - padBottom} + stroke="var(--ce-text2)" + strokeWidth={1} + opacity={0.4} + /> + {series.map((s, si) => ( + <circle + key={s.name} + cx={px(i)} + cy={py(s.values[i] ?? 0)} + r={3.5} + fill={seriesColor(si)} + stroke="var(--ce-surface)" + strokeWidth={2} + /> + ))} + <text x={padX} y={14} textAnchor="start" fontSize={11} fill="var(--ce-text2)"> + {label} + {series.map((s) => + series.length > 1 + ? ` · ${s.name} ${valueFormatter(s.values[i] ?? 0)}` + : ` · ${valueFormatter(s.values[i] ?? 0)}` + )} + </text> + </g> + </g> + ); + })} + </g> </svg> </div> ); diff --git a/apps/web/src/app/(staticPages)/creator-economy/page.tsx b/apps/web/src/app/(staticPages)/creator-economy/page.tsx index 87f36db491..c9f7bab62d 100644 --- a/apps/web/src/app/(staticPages)/creator-economy/page.tsx +++ b/apps/web/src/app/(staticPages)/creator-economy/page.tsx @@ -31,6 +31,8 @@ export async function generateMetadata( const VIZ_VARS = ` .ce-viz{--ce-s1:#357ce6;--ce-s2:#1baf7a;--ce-grid:#e5e7eb;--ce-text2:#6b7280;--ce-surface:#ffffff} .dark .ce-viz{--ce-s1:#3987e5;--ce-s2:#199e70;--ce-grid:#2f2f2f;--ce-text2:#9ca3af;--ce-surface:#131111} +.ce-viz .ce-hlbl{opacity:0;pointer-events:none;transition:opacity .1s} +.ce-viz .ce-hband:hover .ce-hlbl{opacity:1} `; export default async function CreatorEconomyPage() { diff --git a/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx b/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx new file mode 100644 index 0000000000..079d7ce93f --- /dev/null +++ b/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx @@ -0,0 +1,45 @@ +import { render } from "@testing-library/react"; +import { LineChart } from "@/app/(staticPages)/creator-economy/_components/charts"; + +const labels = ["Q1 2023", "Q2 2023", "Q3 2023", "Q4 2023"]; + +describe("LineChart", () => { + it("renders one hover band per point with an exact-value readout", () => { + const { container } = render( + <LineChart + labels={labels} + series={[{ name: "USD", values: [100, 250, 200, 150] }]} + ariaLabel="usd" + /> + ); + const bands = container.querySelectorAll(".ce-hband"); + expect(bands).toHaveLength(labels.length); + // readout carries the quarter and the formatted value + expect(bands[1].textContent).toContain("Q2 2023"); + expect(bands[1].textContent).toContain("250"); + }); + + it("names each series in multi-series readouts", () => { + const { container } = render( + <LineChart + labels={labels} + series={[ + { name: "Posts", values: [10, 20, 30, 40] }, + { name: "Comments", values: [1000, 2000, 3000, 4000] } + ]} + ariaLabel="content" + /> + ); + const band = container.querySelectorAll(".ce-hband")[2]; + expect(band.textContent).toContain("Posts"); + expect(band.textContent).toContain("Comments"); + expect(band.textContent).toContain("3K"); + }); + + it("never emits svg title elements (document-title streaming hazard)", () => { + const { container } = render( + <LineChart labels={labels} series={[{ name: "USD", values: [1, 2, 3, 4] }]} ariaLabel="x" /> + ); + expect(container.querySelectorAll("svg title")).toHaveLength(0); + }); +}); From 4ad040a7fc7aa89ea685ae1ffeb63163e6280e67 Mon Sep 17 00:00:00 2001 From: ecency <hello@ecency.com> Date: Fri, 3 Jul 2026 11:34:04 +0000 Subject: [PATCH 2/4] review: exact values in hover readouts + dot/readout polish - Readouts now show full-precision values (readoutFormatter, default formatFull; USD keeps its dollar prefix) - the compact formatter defeated the point of the feature (Codex). - Hover dot r=4 matches the permanent end dot, removing the layered-radius mismatch on the last point (Greptile). - Readout drops to y=20 and wears a surface-color halo (paint-order stroke) so it stays legible even when a long readout crosses the peak label (Greptile). - ColumnChart accepts-and-ignores readoutFormatter for Trend prop parity. --- .../creator-economy/_components/charts.tsx | 29 ++++++++++++++----- .../(staticPages)/creator-economy/page.tsx | 1 + 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx index 667056acce..af87e6435a 100644 --- a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx +++ b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { columnPath, formatCompact, hbarPath, scaleMax } from "./chart-utils"; +import { columnPath, formatCompact, formatFull, hbarPath, scaleMax } from "./chart-utils"; /** * Server-rendered SVG charts (zero client JS). Colors come from CSS custom @@ -23,12 +23,15 @@ export function ColumnChart({ labels, series, ariaLabel, - valueFormatter = formatCompact + valueFormatter = formatCompact, + readoutFormatter }: { labels: string[]; series: ColumnSeries[]; // 1-2 series (grouped) ariaLabel: string; valueFormatter?: (n: number) => string; + /** unused here: columns carry direct cap labels; kept for Trend prop parity */ + readoutFormatter?: (n: number) => string; }) { const W = 560; const H = 230; @@ -139,12 +142,15 @@ export function LineChart({ labels, series, ariaLabel, - valueFormatter = formatCompact + valueFormatter = formatCompact, + readoutFormatter = formatFull }: { labels: string[]; // one per point, e.g. "Q2 2020" series: ColumnSeries[]; // 1-2 series ariaLabel: string; valueFormatter?: (n: number) => string; + /** hover readout shows EXACT values; sparse on-chart labels stay compact */ + readoutFormatter?: (n: number) => string; }) { const W = 560; const H = 230; @@ -280,18 +286,27 @@ export function LineChart({ key={s.name} cx={px(i)} cy={py(s.values[i] ?? 0)} - r={3.5} + r={4} fill={seriesColor(si)} stroke="var(--ce-surface)" strokeWidth={2} /> ))} - <text x={padX} y={14} textAnchor="start" fontSize={11} fill="var(--ce-text2)"> + <text + x={padX} + y={20} + textAnchor="start" + fontSize={11} + fill="var(--ce-text2)" + stroke="var(--ce-surface)" + strokeWidth={3} + paintOrder="stroke" + > {label} {series.map((s) => series.length > 1 - ? ` · ${s.name} ${valueFormatter(s.values[i] ?? 0)}` - : ` · ${valueFormatter(s.values[i] ?? 0)}` + ? ` · ${s.name} ${readoutFormatter(s.values[i] ?? 0)}` + : ` · ${readoutFormatter(s.values[i] ?? 0)}` )} </text> </g> diff --git a/apps/web/src/app/(staticPages)/creator-economy/page.tsx b/apps/web/src/app/(staticPages)/creator-economy/page.tsx index c9f7bab62d..ae447287ff 100644 --- a/apps/web/src/app/(staticPages)/creator-economy/page.tsx +++ b/apps/web/src/app/(staticPages)/creator-economy/page.tsx @@ -116,6 +116,7 @@ export default async function CreatorEconomyPage() { series={[{ name: "USD", values: quarters.map((q) => q.rewards.usd ?? 0) }]} ariaLabel={t("chart-usd")} valueFormatter={(n) => `$${formatCompact(n)}`} + readoutFormatter={(n) => `$${formatFull(n)}`} /> </section> From 1d069edbfd04844363c9b878df1899a7e892f130 Mon Sep 17 00:00:00 2001 From: ecency <hello@ecency.com> Date: Fri, 3 Jul 2026 11:34:39 +0000 Subject: [PATCH 3/4] test: readout expectation follows full-precision change --- .../web/src/specs/features/creator-economy/line-chart.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx b/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx index 079d7ce93f..fdb148b74d 100644 --- a/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx +++ b/apps/web/src/specs/features/creator-economy/line-chart.spec.tsx @@ -33,7 +33,8 @@ describe("LineChart", () => { const band = container.querySelectorAll(".ce-hband")[2]; expect(band.textContent).toContain("Posts"); expect(band.textContent).toContain("Comments"); - expect(band.textContent).toContain("3K"); + // readouts are full-precision by design (review: compact defeated the purpose) + expect(band.textContent).toContain("3,000"); }); it("never emits svg title elements (document-title streaming hazard)", () => { From 55f2b7024b96a9ce897f057053e7d0f67f6046b4 Mon Sep 17 00:00:00 2001 From: ecency <hello@ecency.com> Date: Fri, 3 Jul 2026 11:35:04 +0000 Subject: [PATCH 4/4] docs: chart header no longer claims title tooltips (removed two PRs ago) --- .../(staticPages)/creator-economy/_components/charts.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx index af87e6435a..55b478c635 100644 --- a/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx +++ b/apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx @@ -5,8 +5,10 @@ import { columnPath, formatCompact, formatFull, hbarPath, scaleMax } from "./cha * Server-rendered SVG charts (zero client JS). Colors come from CSS custom * properties defined on .ce-viz (light + .dark overrides in the page), so the * marks respond to the site theme without hydration. Text wears text tokens, - * never the series color; every mark carries a native <title> tooltip and the - * page ships full tables as the accessible view. + * never the series color. No svg <title> tooltips anywhere (the document + * title streams late, so first-title parsers would read a tooltip as the page + * title); values live in direct labels, CSS hover readouts and the page's + * full tables, which are the accessible view. */ // Two validated series slots; charts on this page use at most two series.