diff --git a/apps/start/src/components/charts/area-gradient-defs.tsx b/apps/start/src/components/charts/area-gradient-defs.tsx index 8615ff3f3..958181cd4 100644 --- a/apps/start/src/components/charts/area-gradient-defs.tsx +++ b/apps/start/src/components/charts/area-gradient-defs.tsx @@ -47,11 +47,13 @@ export function AreaGradientDefs({ )} @@ -62,7 +64,8 @@ export function AreaGradientDefs({ ))} @@ -75,7 +78,8 @@ export function AreaGradientDefs({ ))} diff --git a/apps/start/src/components/charts/area.tsx b/apps/start/src/components/charts/area.tsx index f1f9e77d2..314aa7057 100644 --- a/apps/start/src/components/charts/area.tsx +++ b/apps/start/src/components/charts/area.tsx @@ -7,7 +7,9 @@ import { AreaClosed, LinePath } from "@visx/shape"; // biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type type CurveFactory = any; -import { useCallback, useId, useRef } from "react"; +import { useCallback, useRef } from "react"; +import { useSvgId } from "./use-svg-id"; + import { AreaGradientDefs } from "./area-gradient-defs"; import { chartCssVars, useChartStable } from "./chart-context"; import { type FadeEdges, resolveFadeSides } from "./fade-edges"; @@ -100,8 +102,8 @@ export function Area({ const pathMetricsKey = `${renderData.length}:${innerWidth}:${dashFromIndex}:${showLine}`; const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey); - // Unique IDs for this area - const uniqueId = useId(); + // Unique IDs for this area (Safari-safe — raw useId breaks url(#…) paints) + const uniqueId = useSvgId("area"); const gradientId = `area-gradient-${dataKey}-${uniqueId}`; const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`; const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`; diff --git a/apps/start/src/components/charts/bar.tsx b/apps/start/src/components/charts/bar.tsx index 784f2416f..ca5c62366 100644 --- a/apps/start/src/components/charts/bar.tsx +++ b/apps/start/src/components/charts/bar.tsx @@ -3,7 +3,8 @@ import type { scaleBand } from "@visx/scale"; import type { Transition } from "motion/react"; import { motion } from "motion/react"; -import { memo, useId, useMemo } from "react"; +import { memo, useMemo } from "react"; +import { useSvgId } from "./use-svg-id"; import { chartCssVars, useChart, useChartStable } from "./chart-context"; import { transitionWithDelay } from "./motion-utils"; @@ -163,7 +164,7 @@ const BarInner = memo(function BarInner({ const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread const calculatedStaggerDelay = staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0); - const uniqueId = useId(); + const uniqueId = useSvgId("bar"); const isHorizontal = orientation === "horizontal"; diff --git a/apps/start/src/components/charts/chart-reveal-clip.tsx b/apps/start/src/components/charts/chart-reveal-clip.tsx index 3a8591a0c..2b807155d 100644 --- a/apps/start/src/components/charts/chart-reveal-clip.tsx +++ b/apps/start/src/components/charts/chart-reveal-clip.tsx @@ -2,6 +2,7 @@ import type { Transition } from "motion/react"; import { motion } from "motion/react"; +import { useEffect, useState } from "react"; import { clipRevealTransition } from "./animation"; export interface ChartRevealClipProps { @@ -15,6 +16,15 @@ export interface ChartRevealClipProps { padding?: number; } +function isSafariSvgClipBug(): boolean { + if (typeof navigator === "undefined") { + return false; + } + // Motion animates SVG `width` via CSS on Safari, which clipPath ignores — + // charts stay clipped to 0. Skip the motion path there. + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); +} + /** * Left-to-right clip reveal for cartesian series. * Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center). @@ -30,6 +40,24 @@ export function ChartRevealClip({ const transition = clipRevealTransition(enterTransition); const paddedWidth = Math.max(0, targetWidth + padding * 2); const paddedHeight = height + padding * 2; + const [safariSafe, setSafariSafe] = useState(false); + + useEffect(() => { + setSafariSafe(isSafariSvgClipBug()); + }, []); + + if (safariSafe) { + return ( + + + + ); + } return ( diff --git a/apps/start/src/components/charts/dash-tail-stroke.tsx b/apps/start/src/components/charts/dash-tail-stroke.tsx index becbca5fa..1c643205a 100644 --- a/apps/start/src/components/charts/dash-tail-stroke.tsx +++ b/apps/start/src/components/charts/dash-tail-stroke.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId } from "react"; +import { useSvgId } from "./use-svg-id"; export interface DashTailStrokeProps { /** SVG path `d` for the full series (single curved path). */ @@ -30,7 +30,7 @@ export function DashTailStroke({ strokeWidth, dashArray, }: DashTailStrokeProps) { - const clipPathId = useId().replace(/:/g, ""); + const clipPathId = useSvgId("dash-tail"); if (!pathD || pathLength <= 0 || dashStartLength >= pathLength) { return null; diff --git a/apps/start/src/components/charts/grid.tsx b/apps/start/src/components/charts/grid.tsx index 08bf38377..e75d7b0cd 100644 --- a/apps/start/src/components/charts/grid.tsx +++ b/apps/start/src/components/charts/grid.tsx @@ -1,7 +1,8 @@ "use client"; import { GridColumns, GridRows } from "@visx/grid"; -import { useId } from "react"; +import { useSvgId } from "./use-svg-id"; + import { chartCssVars, useChartStable } from "./chart-context"; export interface GridProps { @@ -68,7 +69,7 @@ export function Grid({ // For vertical grid lines in horizontal bar charts, use yScale (the value scale) // For time-based charts, use xScale const columnScale = isHorizontalBarChart ? yScale : xScale; - const uniqueId = useId(); + const uniqueId = useSvgId("grid"); // Horizontal fade mask (for grid rows - fades left/right) const hMaskId = `grid-rows-fade-${uniqueId}`; @@ -84,13 +85,10 @@ export function Grid({ {horizontal && fadeHorizontal && ( - - - - + + + + - - - - + + + + ) => { @@ -115,7 +116,8 @@ export function Line({ ))} diff --git a/apps/start/src/components/charts/sanitize-svg-id.test.ts b/apps/start/src/components/charts/sanitize-svg-id.test.ts new file mode 100644 index 000000000..d5ad6124b --- /dev/null +++ b/apps/start/src/components/charts/sanitize-svg-id.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeSvgId } from "./sanitize-svg-id"; + +describe("sanitizeSvgId", () => { + it("strips React useId colons so Safari can resolve url(#id)", () => { + expect(sanitizeSvgId(":r1:")).toBe("r1"); + }); + + it("strips guillemet wrappers from older React 19 ids", () => { + expect(sanitizeSvgId("«r0»")).toBe("r0"); + }); + + it("keeps underscore-style React 19.2 ids", () => { + expect(sanitizeSvgId("_r_0_")).toBe("_r_0_"); + }); + + it("prefixes when the cleaned id would start with a digit", () => { + expect(sanitizeSvgId("123")).toBe("svg-123"); + }); +}); diff --git a/apps/start/src/components/charts/sanitize-svg-id.ts b/apps/start/src/components/charts/sanitize-svg-id.ts new file mode 100644 index 000000000..abb44f990 --- /dev/null +++ b/apps/start/src/components/charts/sanitize-svg-id.ts @@ -0,0 +1,12 @@ +/** + * React `useId()` historically produced ids like `:r1:` / `«r0»` that are + * invalid inside SVG `url(#…)` references on Safari. Strip anything that + * is not a safe SVG/XML Name character so fills, masks, and clipPaths resolve. + */ +export function sanitizeSvgId(id: string): string { + const cleaned = id.replace(/[^A-Za-z0-9_-]/g, ""); + // SVG ids must not start with a digit. + return cleaned.length === 0 || /^[0-9]/.test(cleaned) + ? `svg-${cleaned || "id"}` + : cleaned; +} diff --git a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx index 446c815ab..776fabba6 100644 --- a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx +++ b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx @@ -107,14 +107,16 @@ function TooltipIndicatorInner({ - - - + + + diff --git a/apps/start/src/components/charts/use-svg-id.ts b/apps/start/src/components/charts/use-svg-id.ts new file mode 100644 index 000000000..41e35a4f4 --- /dev/null +++ b/apps/start/src/components/charts/use-svg-id.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useId } from "react"; +import { sanitizeSvgId } from "./sanitize-svg-id"; + +/** + * Stable, Safari-safe id for SVG paint servers (`url(#id)`, mask, clipPath). + */ +export function useSvgId(prefix?: string): string { + const raw = sanitizeSvgId(useId()); + return prefix ? `${sanitizeSvgId(prefix)}-${raw}` : raw; +} diff --git a/apps/start/src/components/overview/overview-metrics.tsx b/apps/start/src/components/overview/overview-metrics.tsx index 8449d9837..be6bf272c 100644 --- a/apps/start/src/components/overview/overview-metrics.tsx +++ b/apps/start/src/components/overview/overview-metrics.tsx @@ -34,7 +34,7 @@ interface OverviewMetricsProps { const REVENUE_COLOR = 'oklch(0.68 0.11 158)'; // Bump alpha (0.2 → 0.4) so the bklit hover highlight — which inherits the // line's stroke color — has enough body to be perceptible on hover. -const PREV_LINE_COLOR = 'oklch(from var(--foreground) l c h / 0.4)'; +const PREV_LINE_COLOR = 'var(--chart-crosshair)'; const TITLES = [ { diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx index 70adba4be..5919f18d8 100644 --- a/apps/start/src/components/report-chart/area/chart.tsx +++ b/apps/start/src/components/report-chart/area/chart.tsx @@ -226,7 +226,7 @@ export function Chart({ data }: Props) {