Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions apps/start/src/components/charts/area-gradient-defs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@ export function AreaGradientDefs({
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: fill, stopOpacity: fillOpacity }}
stopColor={fill}
stopOpacity={fillOpacity}
/>
<stop
offset="100%"
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
stopColor={fill}
stopOpacity={gradientToOpacity}
/>
</linearGradient>
)}
Expand All @@ -62,7 +64,8 @@ export function AreaGradientDefs({
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: resolvedStroke, stopOpacity: stop.opacity }}
stopColor={resolvedStroke}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
Expand All @@ -75,7 +78,8 @@ export function AreaGradientDefs({
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
stopColor="white"
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
Expand Down
8 changes: 5 additions & 3 deletions apps/start/src/components/charts/area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`;
Expand Down
5 changes: 3 additions & 2 deletions apps/start/src/components/charts/bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";

Expand Down
28 changes: 28 additions & 0 deletions apps/start/src/components/charts/chart-reveal-clip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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).
Expand All @@ -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 (
<clipPath id={clipPathId}>
<rect
height={paddedHeight}
width={paddedWidth}
x={-padding}
y={-padding}
/>
</clipPath>
);
}

return (
<clipPath id={clipPathId}>
Expand Down
4 changes: 2 additions & 2 deletions apps/start/src/components/charts/dash-tail-stroke.tsx
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 11 additions & 16 deletions apps/start/src/components/charts/grid.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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}`;
Expand All @@ -84,13 +85,10 @@ export function Grid({
{horizontal && fadeHorizontal && (
<defs>
<linearGradient id={hGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
<stop offset="0%" stopColor="white" stopOpacity={0} />
<stop offset="10%" stopColor="white" stopOpacity={1} />
<stop offset="90%" stopColor="white" stopOpacity={1} />
<stop offset="100%" stopColor="white" stopOpacity={0} />
</linearGradient>
<mask id={hMaskId}>
<rect
Expand All @@ -108,13 +106,10 @@ export function Grid({
{vertical && fadeVertical && (
<defs>
<linearGradient id={vGradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
<stop offset="0%" stopColor="white" stopOpacity={0} />
<stop offset="10%" stopColor="white" stopOpacity={1} />
<stop offset="90%" stopColor="white" stopOpacity={1} />
<stop offset="100%" stopColor="white" stopOpacity={0} />
</linearGradient>
<mask id={vMaskId}>
<rect
Expand Down
5 changes: 3 additions & 2 deletions apps/start/src/components/charts/highlight-segment.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { type MotionValue, motion } from "motion/react";
import { type RefObject, useId } from "react";
import type { RefObject } from "react";
import { useSvgId } from "./use-svg-id";

// Hover-highlight overlay: re-strokes the base path `d`, clipped to a vertical
// band whose x/width spring to track the hovered point, so only the segment
Expand Down Expand Up @@ -33,7 +34,7 @@ export function HighlightSegment({
x,
width,
}: HighlightSegmentProps) {
const clipId = useId();
const clipId = useSvgId("highlight");
if (!(visible && pathRef.current)) {
return null;
}
Expand Down
10 changes: 6 additions & 4 deletions apps/start/src/components/charts/line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { 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 { chartCssVars, useChartStable } from "./chart-context";
import {
type FadeEdges,
Expand Down Expand Up @@ -90,8 +92,7 @@ export function Line({
const pathMetricsKey = `${renderData.length}:${innerWidth}:${dashFromIndex}:${animate}`;
const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey);

const reactId = useId();
const gradientId = `line-gradient-${dataKey}-${reactId}`;
const gradientId = useSvgId(`line-gradient-${dataKey}`);

const getY = useCallback(
(d: Record<string, unknown>) => {
Expand All @@ -115,7 +116,8 @@ export function Line({
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: stroke, stopOpacity: stop.opacity }}
stopColor={stroke}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
Expand Down
20 changes: 20 additions & 0 deletions apps/start/src/components/charts/sanitize-svg-id.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
12 changes: 12 additions & 0 deletions apps/start/src/components/charts/sanitize-svg-id.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 7 additions & 5 deletions apps/start/src/components/charts/tooltip/tooltip-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,16 @@ function TooltipIndicatorInner({
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }}
stopColor={colorEdge}
stopOpacity={edgeOpacity}
/>
<stop offset="10%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: colorMid, stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: colorEdge, stopOpacity: 1 }} />
<stop offset="10%" stopColor={colorEdge} stopOpacity={1} />
<stop offset="50%" stopColor={colorMid} stopOpacity={1} />
<stop offset="90%" stopColor={colorEdge} stopOpacity={1} />
<stop
offset="100%"
style={{ stopColor: colorEdge, stopOpacity: edgeOpacity }}
stopColor={colorEdge}
stopOpacity={edgeOpacity}
/>
</linearGradient>
</defs>
Expand Down
12 changes: 12 additions & 0 deletions apps/start/src/components/charts/use-svg-id.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion apps/start/src/components/overview/overview-metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/report-chart/area/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function Chart({ data }: Props) {
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
stroke="var(--border)"
strokeDasharray={'3 3'}
label={{
value: ref.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function Chart({ data }: Props) {
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
stroke="var(--border)"
strokeDasharray={'3 3'}
label={{
value: ref.title,
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/report-chart/histogram/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export function Chart({ data }: Props) {
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
stroke="var(--border)"
strokeDasharray={'3 3'}
label={{
value: ref.title,
Expand Down
2 changes: 1 addition & 1 deletion apps/start/src/components/report-chart/line/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function Chart({ data }: Props) {
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
stroke="var(--border)"
strokeDasharray={'3 3'}
label={{
value: ref.title,
Expand Down
8 changes: 4 additions & 4 deletions apps/start/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@ code {
--chart-foreground-muted: var(--muted-foreground);
--chart-line-primary: var(--chart-0);
--chart-line-secondary: var(--chart-1);
--chart-crosshair: oklch(from var(--foreground) l c h / 0.4);
--chart-crosshair: color-mix(in oklch, var(--foreground) 40%, transparent);
--chart-grid: var(--border);
/* Outer tooltip box is transparent — OPTooltipCard owns the visible card. */
--chart-tooltip-background: transparent;
--chart-tooltip-foreground: var(--background);
--chart-tooltip-muted: oklch(from var(--background) l c h / 0.6);
--chart-tooltip-muted: color-mix(in oklch, var(--background) 60%, transparent);
--chart-marker-background: var(--def-100);
--chart-marker-border: var(--border);
--chart-marker-foreground: var(--foreground);
Expand All @@ -92,7 +92,7 @@ code {
black and the count badge renders as a black dot. */
--chart-marker-badge-background: var(--foreground);
--chart-marker-badge-foreground: var(--background);
--chart-ring-background: oklch(from var(--foreground) l c h / 0.12);
--chart-ring-background: color-mix(in oklch, var(--foreground) 12%, transparent);
--chart-label: var(--muted-foreground);
}

Expand Down Expand Up @@ -132,7 +132,7 @@ code {
theme-agnostic and is defined in :root only, so we do NOT redefine it here. */
--chart-tooltip-background: transparent;
--chart-tooltip-foreground: var(--background);
--chart-tooltip-muted: oklch(from var(--background) l c h / 0.6);
--chart-tooltip-muted: color-mix(in oklch, var(--background) 60%, transparent);
--chart-1: oklch(1 0 none);
--chart-2: oklch(0.73 0 none);
--chart-3: oklch(0.51 0 none);
Expand Down