diff --git a/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx b/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx index 10bb64069..6d64f8b77 100644 --- a/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx +++ b/apps/dev-playground/client/src/components/lakebase/ActivityLogsPanel.tsx @@ -202,7 +202,7 @@ export function ActivityLogsPanel() { {logsLoading && (
-
+
Loading activity logs...
)} diff --git a/apps/dev-playground/client/src/components/lakebase/OboProductsPanel.tsx b/apps/dev-playground/client/src/components/lakebase/OboProductsPanel.tsx index 673570a3a..e56dacb40 100644 --- a/apps/dev-playground/client/src/components/lakebase/OboProductsPanel.tsx +++ b/apps/dev-playground/client/src/components/lakebase/OboProductsPanel.tsx @@ -100,11 +100,11 @@ export function OboProductsPanel() { return (
{/* Header */} - +
-
- +
+
Raw Driver — On-Behalf-Of (OBO) @@ -222,7 +222,7 @@ export function OboProductsPanel() { {/* Side-by-side comparison */}
{/* My products (OBO, RLS filtered) */} - +
@@ -242,7 +242,7 @@ export function OboProductsPanel() { {myLoading && (
-
+
Loading...
)} @@ -283,7 +283,7 @@ export function OboProductsPanel() { {allLoading && (
-
+
Loading...
)} diff --git a/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx b/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx index 8730bb04e..8d82e4958 100644 --- a/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx +++ b/apps/dev-playground/client/src/components/lakebase/OrdersPanel.tsx @@ -338,7 +338,7 @@ export function OrdersPanel() { {ordersLoading && (
-
+
Loading orders...
)} diff --git a/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx b/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx index fdc5c19a7..9eb8c6572 100644 --- a/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx +++ b/apps/dev-playground/client/src/components/lakebase/TasksPanel.tsx @@ -273,7 +273,7 @@ export function TasksPanel() { {tasksLoading && (
-
+
Loading tasks...
)} diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 5dbf7869a..a8554f553 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -594,6 +594,9 @@ .h-3 { height: calc(var(--spacing) * 3); } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } .h-4 { height: calc(var(--spacing) * 4); } @@ -726,6 +729,9 @@ .w-3 { width: calc(var(--spacing) * 3); } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } .w-3\/4 { width: calc(3/4 * 100%); } @@ -1267,6 +1273,12 @@ border-color: color-mix(in oklab, var(--border) 50%, transparent); } } + .border-border\/60 { + border-color: var(--border); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--border) 60%, transparent); + } + } .border-input { border-color: var(--input); } @@ -1728,6 +1740,9 @@ .opacity-50 { opacity: 50%; } + .opacity-60 { + opacity: 60%; + } .opacity-70 { opacity: 70%; } @@ -5378,6 +5393,7 @@ } :root { --radius: 0.625rem; + color-scheme: light; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); @@ -5425,6 +5441,10 @@ --chart-div-6: hsla(10, 60%, 65%, 1); --chart-div-7: hsla(10, 72%, 50%, 1); --chart-div-8: hsla(10, 80%, 40%, 1); + --chart-axis-label: hsla(240, 4%, 46%, 1); + --chart-axis-title: hsla(240, 6%, 10%, 1); + --chart-grid: hsla(240, 5%, 90%, 1); + --chart-tooltip-bg: hsla(0, 0%, 100%, 1); --chart-1: var(--chart-cat-1); --chart-2: var(--chart-cat-2); --chart-3: var(--chart-cat-3); @@ -5443,6 +5463,7 @@ --sidebar-ring: oklch(0.705 0.015 286.067); } .dark { + color-scheme: dark; --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); @@ -5466,11 +5487,42 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); + --chart-cat-1: hsla(217, 91%, 65%, 1); + --chart-cat-2: hsla(160, 65%, 55%, 1); + --chart-cat-3: hsla(291, 60%, 65%, 1); + --chart-cat-4: hsla(38, 95%, 60%, 1); + --chart-cat-5: hsla(349, 80%, 62%, 1); + --chart-cat-6: hsla(189, 85%, 52%, 1); + --chart-cat-7: hsla(271, 65%, 70%, 1); + --chart-cat-8: hsla(142, 60%, 55%, 1); + --chart-seq-1: hsla(217, 50%, 25%, 1); + --chart-seq-2: hsla(217, 55%, 35%, 1); + --chart-seq-3: hsla(217, 60%, 45%, 1); + --chart-seq-4: hsla(217, 65%, 55%, 1); + --chart-seq-5: hsla(217, 70%, 62%, 1); + --chart-seq-6: hsla(217, 75%, 70%, 1); + --chart-seq-7: hsla(217, 80%, 78%, 1); + --chart-seq-8: hsla(217, 85%, 88%, 1); + --chart-div-1: hsla(217, 85%, 70%, 1); + --chart-div-2: hsla(217, 70%, 60%, 1); + --chart-div-3: hsla(217, 50%, 50%, 1); + --chart-div-4: hsla(217, 25%, 40%, 1); + --chart-div-5: hsla(10, 25%, 40%, 1); + --chart-div-6: hsla(10, 55%, 50%, 1); + --chart-div-7: hsla(10, 70%, 58%, 1); + --chart-div-8: hsla(10, 80%, 65%, 1); + --chart-1: var(--chart-cat-1); + --chart-2: var(--chart-cat-2); + --chart-3: var(--chart-cat-3); + --chart-4: var(--chart-cat-4); + --chart-5: var(--chart-cat-5); + --chart-6: var(--chart-cat-6); + --chart-7: var(--chart-cat-7); + --chart-8: var(--chart-cat-8); + --chart-axis-label: hsla(240, 5%, 65%, 1); + --chart-axis-title: hsla(0, 0%, 98%, 1); + --chart-grid: hsla(0, 0%, 100%, 0.1); + --chart-tooltip-bg: hsla(240, 6%, 13%, 1); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); @@ -5482,6 +5534,7 @@ } @media (prefers-color-scheme: dark) { :root:not(.light) { + color-scheme: dark; --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); @@ -5534,6 +5587,10 @@ --chart-div-6: hsla(10, 55%, 50%, 1); --chart-div-7: hsla(10, 70%, 58%, 1); --chart-div-8: hsla(10, 80%, 65%, 1); + --chart-axis-label: hsla(240, 5%, 65%, 1); + --chart-axis-title: hsla(0, 0%, 98%, 1); + --chart-grid: hsla(0, 0%, 100%, 0.1); + --chart-tooltip-bg: hsla(240, 6%, 13%, 1); --chart-1: var(--chart-cat-1); --chart-2: var(--chart-cat-2); --chart-3: var(--chart-cat-3); diff --git a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts index 44a7aff21..5a777fafd 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/options.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/options.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { FALLBACK_UI_TOKENS } from "../constants"; import { buildCartesianOption, buildHeatmapOption, @@ -14,6 +15,9 @@ interface EChartsOption { legend?: unknown; tooltip?: { formatter?: (params: { data: [number, number, number] }) => string; + backgroundColor?: string; + borderColor?: string; + textStyle?: { color: string }; }; xAxis: { type: string; data?: unknown[] }; yAxis: { type: string; data?: unknown[] }; @@ -58,6 +62,14 @@ function asRadarOption(result: Record): RadarOption { return result as unknown as RadarOption; } +// Distinct UI tokens so theming assertions are unambiguous +const TEST_UI = { + axisLabel: "#a11111", + axisTitle: "#b22222", + grid: "#c33333", + tooltipBg: "#d44444", +}; + // Base context used across tests const createBaseContext = ( overrides: Partial = {}, @@ -68,6 +80,7 @@ const createBaseContext = ( colors: ["#ff0000", "#00ff00", "#0000ff"], title: "Test Chart", showLegend: true, + ui: TEST_UI, ...overrides, }); @@ -588,6 +601,7 @@ describe("buildHeatmapOption", () => { colors: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"], title: "Activity Heatmap", showLegend: false, + ui: TEST_UI, // Heatmap-specific yAxisData: ["Mon", "Tue", "Wed"], heatmapData: [ @@ -674,3 +688,243 @@ describe("buildHeatmapOption", () => { ); }); }); + +describe("axis & UI theming", () => { + type AxisShape = { + axisLabel: { color?: string; rotate?: number; width?: number }; + axisLine: { lineStyle: { color: string } }; + axisTick: { lineStyle: { color: string } }; + splitLine: { lineStyle: { color: string } }; + nameTextStyle?: { color: string }; + }; + type TextStyled = { textStyle: { color: string } }; + + test("applies ui tokens to cartesian axes, title, and legend", () => { + const ctx = createBaseContext({ + yFields: ["a", "b"], + yDataMap: { a: [1, 2], b: [3, 4] }, + }); + const opt = buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + + const xAxis = opt.xAxis as AxisShape; + const yAxis = opt.yAxis as AxisShape; + expect(xAxis.axisLabel.color).toBe(TEST_UI.axisLabel); + expect(xAxis.axisLine.lineStyle.color).toBe(TEST_UI.grid); + expect(yAxis.axisLabel.color).toBe(TEST_UI.axisLabel); + expect(yAxis.splitLine.lineStyle.color).toBe(TEST_UI.grid); + expect((opt.title as TextStyled).textStyle.color).toBe(TEST_UI.axisTitle); + expect((opt.legend as TextStyled).textStyle.color).toBe(TEST_UI.axisTitle); + }); + + test("preserves x-axis formatter/rotate while setting label color", () => { + const ctx = createBaseContext({ + xData: Array.from({ length: 15 }, (_, i) => `Item${i}`), + yDataMap: { value: Array(15).fill(10) }, + }); + const opt = buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + + const xAxis = opt.xAxis as AxisShape; + expect(xAxis.axisLabel.color).toBe(TEST_UI.axisLabel); + expect(xAxis.axisLabel.rotate).toBe(45); + }); + + test("applies ui tokens to horizontal bar axes (preserving label width)", () => { + const ctx = createBaseContext(); + const opt = buildHorizontalBarOption(ctx, false); + + const xAxis = opt.xAxis as AxisShape; + const yAxis = opt.yAxis as AxisShape; + expect(yAxis.axisLabel.color).toBe(TEST_UI.axisLabel); + expect(yAxis.axisLabel.width).toBe(100); + expect(xAxis.axisLine.lineStyle.color).toBe(TEST_UI.grid); + }); + + test("applies ui tokens to heatmap axes and visualMap", () => { + const ctx: HeatmapContext = { + ...createBaseContext({ showLegend: false }), + yAxisData: ["Mon", "Tue"], + heatmapData: [ + [0, 0, 10], + [1, 0, 20], + ], + min: 10, + max: 20, + showLabels: false, + }; + const opt = buildHeatmapOption(ctx); + + expect((opt.xAxis as AxisShape).axisLabel.color).toBe(TEST_UI.axisLabel); + expect((opt.yAxis as AxisShape).axisLabel.color).toBe(TEST_UI.axisLabel); + expect((opt.visualMap as TextStyled).textStyle.color).toBe( + TEST_UI.axisTitle, + ); + }); + + test("applies ui tokens to pie legend", () => { + const ctx = createBaseContext({ showLegend: true }); + const opt = buildPieOption(ctx, "pie", 0, true, "outside"); + expect((opt.legend as TextStyled).textStyle.color).toBe(TEST_UI.axisTitle); + }); + + test("applies ui tokens to radar", () => { + const ctx = createBaseContext(); + const opt = buildRadarOption(ctx, true); + const radar = opt.radar as { + axisName: { color: string }; + axisLine: { lineStyle: { color: string } }; + splitLine: { lineStyle: { color: string } }; + }; + expect(radar.axisName.color).toBe(TEST_UI.axisTitle); + expect(radar.splitLine.lineStyle.color).toBe(TEST_UI.grid); + }); + + test("applies ui tokens to radar legend (multi-series)", () => { + const ctx = createBaseContext({ + yFields: ["a", "b"], + yDataMap: { a: [1, 2, 3], b: [4, 5, 6] }, + showLegend: true, + }); + const opt = buildRadarOption(ctx, true); + expect((opt.legend as TextStyled).textStyle.color).toBe(TEST_UI.axisTitle); + }); + + test("applies axis-label color on scatter x-axis", () => { + const ctx = createBaseContext(); + const opt = buildCartesianOption({ + ...ctx, + chartType: "scatter", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + expect((opt.xAxis as AxisShape).axisLabel.color).toBe(TEST_UI.axisLabel); + }); + + test("applies axis-label color on time-series x-axis", () => { + const ctx = createBaseContext(); + const opt = buildCartesianOption({ + ...ctx, + chartType: "line", + isTimeSeries: true, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + expect((opt.xAxis as AxisShape).axisLabel.color).toBe(TEST_UI.axisLabel); + }); + + test("falls back to default UI tokens when ui is omitted", () => { + const ctx = createBaseContext({ ui: undefined }); + const opt = buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }); + + expect((opt.xAxis as AxisShape).axisLabel.color).toBe( + FALLBACK_UI_TOKENS.axisLabel, + ); + expect((opt.yAxis as AxisShape).splitLine.lineStyle.color).toBe( + FALLBACK_UI_TOKENS.grid, + ); + expect((opt.title as TextStyled).textStyle.color).toBe( + FALLBACK_UI_TOKENS.axisTitle, + ); + expect((opt.tooltip as { backgroundColor: string }).backgroundColor).toBe( + FALLBACK_UI_TOKENS.tooltipBg, + ); + }); +}); + +describe("tooltip theming", () => { + test("themes cartesian tooltip (background, border, text)", () => { + const ctx = createBaseContext(); + const opt = asOption( + buildCartesianOption({ + ...ctx, + chartType: "bar", + isTimeSeries: false, + stacked: false, + smooth: false, + showSymbol: false, + symbolSize: 8, + }), + ); + + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect(opt.tooltip?.borderColor).toBe(TEST_UI.grid); + expect(opt.tooltip?.textStyle?.color).toBe(TEST_UI.axisTitle); + }); + + test("themes horizontal bar tooltip while keeping axisPointer", () => { + const ctx = createBaseContext(); + const opt = asOption(buildHorizontalBarOption(ctx, false)); + + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect( + (opt.tooltip as { axisPointer?: { type: string } }).axisPointer?.type, + ).toBe("shadow"); + }); + + test("themes pie tooltip while keeping the formatter", () => { + const ctx = createBaseContext(); + const opt = asOption(buildPieOption(ctx, "pie", 0, true, "outside")); + + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect(opt.tooltip?.borderColor).toBe(TEST_UI.grid); + expect((opt.tooltip as { formatter?: string }).formatter).toBe( + "{b}: {c} ({d}%)", + ); + }); + + test("themes radar tooltip", () => { + const ctx = createBaseContext(); + const opt = asOption(buildRadarOption(ctx, true)); + + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect(opt.tooltip?.textStyle?.color).toBe(TEST_UI.axisTitle); + }); + + test("themes heatmap tooltip while keeping the escaping formatter", () => { + const ctx: HeatmapContext = { + ...createBaseContext({ showLegend: false }), + yAxisData: ["Mon", "Tue"], + heatmapData: [ + [0, 0, 10], + [1, 0, 20], + ], + min: 10, + max: 20, + showLabels: false, + }; + const opt = asOption(buildHeatmapOption(ctx)); + + expect(opt.tooltip?.backgroundColor).toBe(TEST_UI.tooltipBg); + expect(opt.tooltip?.borderColor).toBe(TEST_UI.grid); + // Formatter still runs (and still escapes) on top of the themed tooltip. + expect(opt.tooltip?.formatter?.({ data: [0, 0, 10] })).toBe("A, Mon: 10"); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/theme.test.ts b/packages/appkit-ui/src/react/charts/__tests__/theme.test.ts index 4bcadcc78..e1ae91ffb 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/theme.test.ts +++ b/packages/appkit-ui/src/react/charts/__tests__/theme.test.ts @@ -4,13 +4,16 @@ import { CHART_COLOR_VARS_CATEGORICAL, CHART_COLOR_VARS_DIVERGING, CHART_COLOR_VARS_SEQUENTIAL, + CHART_UI_VARS, FALLBACK_COLORS_CATEGORICAL, FALLBACK_COLORS_DIVERGING, FALLBACK_COLORS_SEQUENTIAL, + FALLBACK_UI_TOKENS, } from "../constants"; import { - resetThemeColorCache, + resetThemeCache, useAllThemeColors, + useChartUITokens, useThemeColors, } from "../theme"; import type { ChartColorPalette } from "../types"; @@ -36,7 +39,7 @@ describe("useThemeColors", () => { beforeEach(() => { // Reset the module-level cache before each test - resetThemeColorCache(); + resetThemeCache(); // Mock matchMedia window.matchMedia = createMockMatchMedia() as typeof window.matchMedia; @@ -448,7 +451,7 @@ describe("useThemeColors", () => { expect(disconnectSpy).toHaveBeenCalled(); }); - test("cleans up old listeners when palette changes", () => { + test("does not churn listeners when palette changes", () => { const removeEventListenerSpy = vi.fn(); const disconnectSpy = vi.fn(); const addEventListenerSpy = vi.fn(); @@ -470,24 +473,31 @@ describe("useThemeColors", () => { disconnect: disconnectSpy, })); - const { rerender } = renderHook( + const { rerender, unmount } = renderHook( ({ palette }: { palette: ChartColorPalette }) => useThemeColors(palette), { initialProps: { palette: "categorical" as ChartColorPalette } }, ); - // Initial setup + // Initial setup: one shared listener + observer. expect(addEventListenerSpy).toHaveBeenCalledTimes(1); expect(observeSpy).toHaveBeenCalledTimes(1); - // Change palette + // Changing the palette swaps the hook's callback but must NOT re-install + // the shared listeners — this is the per-render subscription churn the + // shared subscriber removes. rerender({ palette: "sequential" as ChartColorPalette }); - // Old listeners should be cleaned up, new ones set up + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + expect(disconnectSpy).not.toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(observeSpy).toHaveBeenCalledTimes(1); + + // Listeners are torn down only when the hook unmounts. + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); expect(disconnectSpy).toHaveBeenCalledTimes(1); - expect(addEventListenerSpy).toHaveBeenCalledTimes(2); - expect(observeSpy).toHaveBeenCalledTimes(2); }); }); }); @@ -499,7 +509,7 @@ describe("useAllThemeColors", () => { beforeEach(() => { // Reset the module-level cache before each test - resetThemeColorCache(); + resetThemeCache(); // Mock matchMedia window.matchMedia = createMockMatchMedia() as typeof window.matchMedia; @@ -569,3 +579,250 @@ describe("useAllThemeColors", () => { } }); }); + +describe("useChartUITokens", () => { + const originalGetComputedStyle = window.getComputedStyle; + const originalMatchMedia = window.matchMedia; + const originalMutationObserver = window.MutationObserver; + + beforeEach(() => { + resetThemeCache(); + window.matchMedia = createMockMatchMedia() as typeof window.matchMedia; + window.MutationObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })); + // Default: empty CSS variables → fallbacks + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + getPropertyValue: () => "", + }) as unknown as CSSStyleDeclaration, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.getComputedStyle = originalGetComputedStyle; + window.matchMedia = originalMatchMedia; + window.MutationObserver = originalMutationObserver; + }); + + test("returns fallback UI tokens when CSS vars unavailable", () => { + const { result } = renderHook(() => useChartUITokens()); + + expect(result.current).toEqual(FALLBACK_UI_TOKENS); + }); + + test("reads UI tokens from CSS variables by name", () => { + const values: Record = { + [CHART_UI_VARS.axisLabel]: "rgb(10, 10, 10)", + [CHART_UI_VARS.axisTitle]: "rgb(20, 20, 20)", + [CHART_UI_VARS.grid]: "rgb(30, 30, 30)", + [CHART_UI_VARS.tooltipBg]: "rgb(40, 40, 40)", + }; + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + getPropertyValue: (prop: string) => values[prop] || "", + }) as unknown as CSSStyleDeclaration, + ); + + const { result } = renderHook(() => useChartUITokens()); + + expect(result.current).toEqual({ + axisLabel: "rgb(10, 10, 10)", + axisTitle: "rgb(20, 20, 20)", + grid: "rgb(30, 30, 30)", + tooltipBg: "rgb(40, 40, 40)", + }); + }); + + test("falls back per field when only some UI vars resolve", () => { + // axisTitle intentionally missing — must fall back without shifting the + // others (the failure mode of the old positional-array resolution). + const values: Record = { + [CHART_UI_VARS.axisLabel]: "rgb(10, 10, 10)", + [CHART_UI_VARS.grid]: "rgb(30, 30, 30)", + }; + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + getPropertyValue: (prop: string) => values[prop] || "", + }) as unknown as CSSStyleDeclaration, + ); + + const { result } = renderHook(() => useChartUITokens()); + + expect(result.current).toEqual({ + axisLabel: "rgb(10, 10, 10)", + axisTitle: FALLBACK_UI_TOKENS.axisTitle, + grid: "rgb(30, 30, 30)", + tooltipBg: FALLBACK_UI_TOKENS.tooltipBg, + }); + }); + + test("reads each UI CSS variable by name", () => { + const getPropertyValueSpy = vi.fn().mockReturnValue(""); + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + getPropertyValue: getPropertyValueSpy, + }) as unknown as CSSStyleDeclaration, + ); + + renderHook(() => useChartUITokens()); + + for (const varName of Object.values(CHART_UI_VARS)) { + expect(getPropertyValueSpy).toHaveBeenCalledWith(varName); + } + }); + + // Listener wiring (subscribe/cleanup/ref-counting) is shared via + // useThemeChangeEffect and covered by useThemeColors + the "shared + // theme-change subscription" block; here we only assert token values re-resolve. + describe("theme change reactivity", () => { + test("updates tokens when system color scheme changes", () => { + let matchMediaCallback: () => void = () => {}; + + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: (_event: string, callback: () => void) => { + matchMediaCallback = callback; + }, + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.MutationObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })); + + let callCount = 0; + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { + getPropertyValue: (prop: string) => { + if (prop === CHART_UI_VARS.axisLabel) { + return callCount++ === 0 ? "rgb(1, 1, 1)" : "rgb(2, 2, 2)"; + } + return ""; + }, + } as unknown as CSSStyleDeclaration; + }); + + const { result } = renderHook(() => useChartUITokens()); + + expect(result.current.axisLabel).toBe("rgb(1, 1, 1)"); + + act(() => { + matchMediaCallback(); + }); + + expect(result.current.axisLabel).toBe("rgb(2, 2, 2)"); + }); + + test("updates tokens when theme attributes change via MutationObserver", () => { + let mutationCallback: () => void = () => {}; + + window.MutationObserver = vi.fn().mockImplementation((callback) => { + mutationCallback = callback; + return { + observe: vi.fn(), + disconnect: vi.fn(), + }; + }); + + let callCount = 0; + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { + getPropertyValue: (prop: string) => { + if (prop === CHART_UI_VARS.axisLabel) { + return callCount++ === 0 ? "rgb(10, 10, 10)" : "rgb(20, 20, 20)"; + } + return ""; + }, + } as unknown as CSSStyleDeclaration; + }); + + const { result } = renderHook(() => useChartUITokens()); + + expect(result.current.axisLabel).toBe("rgb(10, 10, 10)"); + + act(() => { + mutationCallback(); + }); + + expect(result.current.axisLabel).toBe("rgb(20, 20, 20)"); + }); + }); +}); + +describe("shared theme-change subscription", () => { + const originalGetComputedStyle = window.getComputedStyle; + const originalMatchMedia = window.matchMedia; + const originalMutationObserver = window.MutationObserver; + + beforeEach(() => { + resetThemeCache(); + vi.spyOn(window, "getComputedStyle").mockImplementation( + () => + ({ + getPropertyValue: () => "", + }) as unknown as CSSStyleDeclaration, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.getComputedStyle = originalGetComputedStyle; + window.matchMedia = originalMatchMedia; + window.MutationObserver = originalMutationObserver; + }); + + test("installs one shared listener for many hooks and releases it only after the last unmounts", () => { + const addEventListenerSpy = vi.fn(); + const removeEventListenerSpy = vi.fn(); + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + dispatchEvent: vi.fn(), + })); + + const observeSpy = vi.fn(); + const disconnectSpy = vi.fn(); + window.MutationObserver = vi.fn().mockImplementation(() => ({ + observe: observeSpy, + disconnect: disconnectSpy, + })); + + // Three independent hook instances — what a dashboard with several charts + // looks like. The old per-hook design installed three listeners + observers. + const a = renderHook(() => useThemeColors("categorical")); + const b = renderHook(() => useChartUITokens()); + const c = renderHook(() => useThemeColors("sequential")); + + expect(window.matchMedia).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(observeSpy).toHaveBeenCalledTimes(1); + + // Tearing down all but the last keeps the shared listener alive. + a.unmount(); + b.unmount(); + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + expect(disconnectSpy).not.toHaveBeenCalled(); + + // The final unmount releases the shared listener exactly once. + c.unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 6a623eb4e..3e7192448 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -10,7 +10,7 @@ import { buildRadarOption, type OptionBuilderContext, } from "./options"; -import { useThemeColors } from "./theme"; +import { useChartUITokens, useThemeColors } from "./theme"; import type { ChartColorPalette, ChartData, @@ -132,6 +132,8 @@ export function BaseChart({ const themeColors = useThemeColors(resolvedPalette); const colors = customColors ?? themeColors; + const ui = useChartUITokens(); + // Store ECharts instance directly to avoid stale ref issues on unmount const echartsInstanceRef = useRef(null); @@ -191,6 +193,7 @@ export function BaseChart({ title, showLegend, xField, + ui, }; const isPie = chartType === "pie" || chartType === "donut"; const isRadar = chartType === "radar"; @@ -255,6 +258,7 @@ export function BaseChart({ }, [ normalized, colors, + ui, title, showLegend, chartType, diff --git a/packages/appkit-ui/src/react/charts/constants.ts b/packages/appkit-ui/src/react/charts/constants.ts index 65d4ac0f8..83674251a 100644 --- a/packages/appkit-ui/src/react/charts/constants.ts +++ b/packages/appkit-ui/src/react/charts/constants.ts @@ -2,6 +2,8 @@ // Shared Constants for Chart Components // ============================================================================ +import type { ChartUITokens } from "./types"; + // Re-export field patterns from shared constants export { DATE_FIELD_PATTERNS, @@ -100,3 +102,23 @@ export const FALLBACK_COLORS_DIVERGING = [ "hsla(10, 72%, 50%, 1)", "hsla(10, 80%, 40%, 1)", // Strong positive ]; + +// ============================================================================ +// Chart UI tokens (axis text, titles, grid lines) +// ============================================================================ + +/** CSS variable names for the chart UI tokens (read at runtime like the palettes) */ +export const CHART_UI_VARS: Record = { + axisLabel: "--chart-axis-label", + axisTitle: "--chart-axis-title", + grid: "--chart-grid", + tooltipBg: "--chart-tooltip-bg", +}; + +/** Fallback chart UI tokens (light values). */ +export const FALLBACK_UI_TOKENS: ChartUITokens = { + axisLabel: "hsla(240, 4%, 46%, 1)", // ≈ --muted-foreground + axisTitle: "hsla(240, 6%, 10%, 1)", // ≈ --foreground + grid: "hsla(240, 5%, 90%, 1)", // ≈ --border + tooltipBg: "hsla(0, 0%, 100%, 1)", // ≈ --popover +}; diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index 0f976f953..f5e374e8d 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -63,6 +63,7 @@ export { export { useAllThemeColors, + useChartUITokens, useThemeColors, } from "./theme"; @@ -110,6 +111,7 @@ export type { ChartColorPalette, ChartData, ChartType, + ChartUITokens, // Data formats DataFormat, DataProps, diff --git a/packages/appkit-ui/src/react/charts/options.ts b/packages/appkit-ui/src/react/charts/options.ts index 73919ea77..e50711c83 100644 --- a/packages/appkit-ui/src/react/charts/options.ts +++ b/packages/appkit-ui/src/react/charts/options.ts @@ -1,4 +1,5 @@ -import type { ChartType } from "./types"; +import { FALLBACK_UI_TOKENS } from "./constants"; +import type { ChartType, ChartUITokens } from "./types"; import { createTimeSeriesData, escapeHtml, @@ -18,6 +19,7 @@ export interface OptionBuilderContext { title?: string; showLegend: boolean; xField?: string; + ui?: ChartUITokens; } export interface CartesianContext extends OptionBuilderContext { @@ -34,12 +36,51 @@ export interface CartesianContext extends OptionBuilderContext { // ============================================================================ function buildBaseOption(ctx: OptionBuilderContext): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; return { - title: ctx.title ? { text: ctx.title, left: "center" } : undefined, + title: ctx.title + ? { + text: ctx.title, + left: "center", + textStyle: { color: ui.axisTitle }, + } + : undefined, color: ctx.colors, }; } +function axisCommon(ui: ChartUITokens) { + return { + axisLabel: { color: ui.axisLabel }, + axisLine: { lineStyle: { color: ui.grid } }, + axisTick: { lineStyle: { color: ui.grid } }, + splitLine: { lineStyle: { color: ui.grid } }, + nameTextStyle: { color: ui.axisTitle }, + }; +} + +function mergeAxisLabel( + ui: ChartUITokens, + axisLabel: Record, +): Record { + return { + ...axisCommon(ui), + axisLabel: { color: ui.axisLabel, ...axisLabel }, + }; +} + +function legendTextStyle(ui: ChartUITokens) { + return { textStyle: { color: ui.axisTitle } }; +} + +function tooltipTokens(ui: ChartUITokens) { + return { + backgroundColor: ui.tooltipBg, + borderColor: ui.grid, + textStyle: { color: ui.axisTitle }, + }; +} + // ============================================================================ // Radar Chart Option // ============================================================================ @@ -48,21 +89,27 @@ export function buildRadarOption( ctx: OptionBuilderContext, showArea = true, ): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; const maxValue = Math.max( ...ctx.yFields.flatMap((f) => ctx.yDataMap[f].map((v) => Number(v) || 0)), ); return { ...buildBaseOption(ctx), - tooltip: { trigger: "item" }, + tooltip: { ...tooltipTokens(ui), trigger: "item" }, legend: - ctx.showLegend && ctx.yFields.length > 1 ? { top: "bottom" } : undefined, + ctx.showLegend && ctx.yFields.length > 1 + ? { top: "bottom", ...legendTextStyle(ui) } + : undefined, radar: { indicator: ctx.xData.map((name) => ({ name: String(name), max: maxValue * 1.2, })), shape: "polygon", + axisName: { color: ui.axisTitle }, + axisLine: { lineStyle: { color: ui.grid } }, + splitLine: { lineStyle: { color: ui.grid } }, }, series: [ { @@ -89,6 +136,7 @@ export function buildPieOption( showLabels: boolean, labelPosition: string, ): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; const pieData = ctx.xData.map((name, i) => ({ name: String(name), value: ctx.yDataMap[ctx.yFields[0]]?.[i] ?? 0, @@ -98,9 +146,18 @@ export function buildPieOption( return { ...buildBaseOption(ctx), - tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" }, + tooltip: { + ...tooltipTokens(ui), + trigger: "item", + formatter: "{b}: {c} ({d}%)", + }, legend: ctx.showLegend - ? { orient: "vertical", left: "left", top: "middle" } + ? { + orient: "vertical", + left: "left", + top: "middle", + ...legendTextStyle(ui), + } : undefined, series: [ { @@ -135,27 +192,35 @@ export function buildHorizontalBarOption( ctx: OptionBuilderContext, stacked: boolean, ): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; const hasMultipleSeries = ctx.yFields.length > 1; return { ...buildBaseOption(ctx), - tooltip: { trigger: "axis", axisPointer: { type: "shadow" } }, - legend: ctx.showLegend && hasMultipleSeries ? { top: "bottom" } : undefined, + tooltip: { + ...tooltipTokens(ui), + trigger: "axis", + axisPointer: { type: "shadow" }, + }, + legend: + ctx.showLegend && hasMultipleSeries + ? { top: "bottom", ...legendTextStyle(ui) } + : undefined, grid: { left: "20%", right: "10%", top: ctx.title ? "15%" : "5%", bottom: ctx.showLegend && hasMultipleSeries ? "15%" : "5%", }, - xAxis: { type: "value" }, + xAxis: { type: "value", ...axisCommon(ui) }, yAxis: { type: "category", data: ctx.xData, - axisLabel: { + ...mergeAxisLabel(ui, { width: 100, overflow: "truncate", formatter: (value: string) => truncateLabel(String(value)), - }, + }), }, series: ctx.yFields.map((key, idx) => ({ name: formatLabel(key), @@ -188,9 +253,11 @@ export interface HeatmapContext extends OptionBuilderContext { export function buildHeatmapOption( ctx: HeatmapContext, ): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; return { ...buildBaseOption(ctx), tooltip: { + ...tooltipTokens(ui), trigger: "item", formatter: (params: { data: [number, number, number] }) => { const [xIdx, yIdx, value] = params.data; @@ -211,18 +278,18 @@ export function buildHeatmapOption( type: "category", data: ctx.xData, splitArea: { show: true }, - axisLabel: { + ...mergeAxisLabel(ui, { rotate: ctx.xData.length > 10 ? 45 : 0, formatter: (v: string) => truncateLabel(String(v), 10), - }, + }), }, yAxis: { type: "category", data: ctx.yAxisData, splitArea: { show: true }, - axisLabel: { + ...mergeAxisLabel(ui, { formatter: (v: string) => truncateLabel(String(v), 12), - }, + }), }, visualMap: { min: ctx.min, @@ -231,6 +298,7 @@ export function buildHeatmapOption( orient: "vertical", right: "2%", top: "center", + textStyle: { color: ui.axisTitle }, inRange: { color: ctx.colors.length >= 2 ? ctx.colors : ["#f0f0f0", ctx.colors[0]], }, @@ -262,6 +330,7 @@ export function buildHeatmapOption( export function buildCartesianOption( ctx: CartesianContext, ): Record { + const ui = ctx.ui ?? FALLBACK_UI_TOKENS; const { chartType, isTimeSeries, stacked, smooth, showSymbol, symbolSize } = ctx; const hasMultipleSeries = ctx.yFields.length > 1; @@ -270,8 +339,11 @@ export function buildCartesianOption( return { ...buildBaseOption(ctx), - tooltip: { trigger: isScatter ? "item" : "axis" }, - legend: ctx.showLegend && hasMultipleSeries ? { top: "bottom" } : undefined, + tooltip: { ...tooltipTokens(ui), trigger: isScatter ? "item" : "axis" }, + legend: + ctx.showLegend && hasMultipleSeries + ? { top: "bottom", ...legendTextStyle(ui) } + : undefined, grid: { left: "10%", right: "10%", @@ -283,17 +355,20 @@ export function buildCartesianOption( type: isScatter ? "value" : isTimeSeries ? "time" : "category", data: isScatter || isTimeSeries ? undefined : ctx.xData, name: ctx.xField ? formatLabel(ctx.xField) : undefined, - axisLabel: + ...mergeAxisLabel( + ui, isScatter || isTimeSeries ? { show: true } : { rotate: ctx.xData.length > 10 ? 45 : 0, formatter: (v: string) => truncateLabel(String(v), 10), }, + ), }, yAxis: { type: "value", name: ctx.yFields.length === 1 ? formatLabel(ctx.yFields[0]) : undefined, + ...axisCommon(ui), }, series: ctx.yFields.map((key, idx) => ({ name: formatLabel(key), diff --git a/packages/appkit-ui/src/react/charts/theme.ts b/packages/appkit-ui/src/react/charts/theme.ts index e7438e1ff..dfc32154f 100644 --- a/packages/appkit-ui/src/react/charts/theme.ts +++ b/packages/appkit-ui/src/react/charts/theme.ts @@ -1,13 +1,15 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { CHART_COLOR_VARS_CATEGORICAL, CHART_COLOR_VARS_DIVERGING, CHART_COLOR_VARS_SEQUENTIAL, + CHART_UI_VARS, FALLBACK_COLORS_CATEGORICAL, FALLBACK_COLORS_DIVERGING, FALLBACK_COLORS_SEQUENTIAL, + FALLBACK_UI_TOKENS, } from "./constants"; -import type { ChartColorPalette } from "./types"; +import type { ChartColorPalette, ChartUITokens } from "./types"; // ============================================================================ // Theme Colors (resolved from CSS variables) @@ -41,13 +43,13 @@ const PALETTE_CONFIG: Record< */ const colorCache = new Map(); -/** - * Clears the theme color cache. - * Called when theme change events fire, or for testing when mocks change. - * @internal - */ -export function resetThemeColorCache(): void { +/** Cache for the computed chart UI tokens (axis text, grid lines). */ +let uiTokenCache: ChartUITokens | null = null; + +/** Clears both theme caches (palette colors + UI tokens). */ +function clearThemeCaches(): void { colorCache.clear(); + uiTokenCache = null; } /** @@ -82,6 +84,107 @@ function getThemeColors(palette: ChartColorPalette = "categorical"): string[] { return result; } +/** + * Gets the chart UI tokens (axis text, titles, grid lines) with caching. + * Authored in `hsla` because ECharts/zrender cannot parse the `oklch` semantic + * tokens. + */ +function getThemeUITokens(): ChartUITokens { + if (typeof window === "undefined") return FALLBACK_UI_TOKENS; + + if (uiTokenCache) return uiTokenCache; + + const styles = getComputedStyle(document.documentElement); + const read = (varName: string, fallback: string): string => { + const value = styles.getPropertyValue(varName).trim(); + return value || fallback; + }; + + uiTokenCache = { + axisLabel: read(CHART_UI_VARS.axisLabel, FALLBACK_UI_TOKENS.axisLabel), + axisTitle: read(CHART_UI_VARS.axisTitle, FALLBACK_UI_TOKENS.axisTitle), + grid: read(CHART_UI_VARS.grid, FALLBACK_UI_TOKENS.grid), + tooltipBg: read(CHART_UI_VARS.tooltipBg, FALLBACK_UI_TOKENS.tooltipBg), + }; + + return uiTokenCache; +} + +// ============================================================================ +// Theme Change Subscription (shared) +// ============================================================================ + +// One shared, ref-counted matchMedia + MutationObserver for the whole module: a +// theme change clears the caches once, then notifies every subscribed hook. +const subscribers = new Set<() => void>(); +let teardownListeners: (() => void) | null = null; + +function handleThemeChange(): void { + clearThemeCaches(); + // Snapshot: a subscriber may add/remove itself during iteration. + for (const notify of [...subscribers]) notify(); +} + +function ensureListening(): void { + if (teardownListeners) return; + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.addEventListener("change", handleThemeChange); + + const observer = new MutationObserver(handleThemeChange); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "data-mode"], + }); + + teardownListeners = () => { + mediaQuery.removeEventListener("change", handleThemeChange); + observer.disconnect(); + }; +} + +/** + * Resets all module-level theme state: clears both caches and drops the shared + * subscription (removing the matchMedia/MutationObserver listeners). Used by + * tests to isolate runs; the runtime only ever clears caches. + * @internal + */ +export function resetThemeCache(): void { + clearThemeCaches(); + subscribers.clear(); + teardownListeners?.(); + teardownListeners = null; +} + +/** + * Subscribes `onChange` to theme changes (system color scheme via matchMedia, or + * a theme attribute on the root element). Listeners are shared and ref-counted, + * so each hook subscribes once per mount regardless of how `onChange`'s identity + * changes between renders. + */ +function useThemeChangeEffect(onChange: () => void): void { + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + const subscriber = () => onChangeRef.current(); + subscribers.add(subscriber); + ensureListening(); + + return () => { + subscribers.delete(subscriber); + if (subscribers.size === 0 && teardownListeners) { + teardownListeners(); + teardownListeners = null; + } + }; + }, []); +} + +// ============================================================================ +// Hooks +// ============================================================================ + /** * Hook to get theme colors with automatic updates on theme change. * Re-resolves CSS variables when color scheme or theme attributes change. @@ -97,31 +200,34 @@ export function useThemeColors( : getThemeColors(palette), ); - useEffect(() => { - // Clear cache and re-fetch colors when theme changes - const updateColors = () => { - resetThemeColorCache(); - setColors(getThemeColors(palette)); - }; + // Re-resolve colors when the theme changes. + const updateColors = useCallback(() => { + setColors(getThemeColors(palette)); + }, [palette]); - // Listen for system color scheme changes - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - mediaQuery.addEventListener("change", updateColors); + useThemeChangeEffect(updateColors); - // Listen for theme attribute changes (e.g., class="dark", data-theme="dark") - const observer = new MutationObserver(updateColors); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "data-theme", "data-mode"], - }); + return colors; +} - return () => { - mediaQuery.removeEventListener("change", updateColors); - observer.disconnect(); - }; - }, [palette]); +/** + * Hook to get the chart UI tokens (axis text, titles, grid lines) with automatic + * updates on theme change. Pass the result into the ECharts option builders so + * axis labels, lines, legends, and titles follow the active theme. + */ +export function useChartUITokens(): ChartUITokens { + const [tokens, setTokens] = useState(() => + typeof window === "undefined" ? FALLBACK_UI_TOKENS : getThemeUITokens(), + ); - return colors; + // Re-resolve tokens when the theme changes. + const updateTokens = useCallback(() => { + setTokens(getThemeUITokens()); + }, []); + + useThemeChangeEffect(updateTokens); + + return tokens; } /** diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 65804a741..60efb1e16 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -31,6 +31,18 @@ export type ChartData = Table | Record[]; /** Color palette types for different visualization needs */ export type ChartColorPalette = "categorical" | "sequential" | "diverging"; +/** Resolved colors for the chart UI — axis text, titles, grid lines, and tooltip. */ +export interface ChartUITokens { + /** Axis tick labels (≈ `--muted-foreground`) */ + axisLabel: string; + /** Axis names, legend, title, and visualMap text (≈ `--foreground`) */ + axisTitle: string; + /** Axis, tick, and split (grid) lines (≈ `--border`) */ + grid: string; + /** Tooltip popover background (≈ `--popover`) */ + tooltipBg: string; +} + /** Common visual and behavior props for all charts */ export interface ChartBaseProps { /** Chart title */ diff --git a/packages/appkit-ui/src/react/styles/globals.css b/packages/appkit-ui/src/react/styles/globals.css index ab69fe96f..70d15cf46 100644 --- a/packages/appkit-ui/src/react/styles/globals.css +++ b/packages/appkit-ui/src/react/styles/globals.css @@ -4,6 +4,7 @@ :root { --radius: 0.625rem; + color-scheme: light; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); @@ -67,6 +68,15 @@ --chart-div-7: hsla(10, 72%, 50%, 1); --chart-div-8: hsla(10, 80%, 40%, 1); /* Strong positive */ + /* ======================================== + CHART UI TOKENS - axis labels, titles, grid lines, tooltip + ≈ muted-foreground / foreground / border / popover (hsla so ECharts can parse) + ======================================== */ + --chart-axis-label: hsla(240, 4%, 46%, 1); + --chart-axis-title: hsla(240, 6%, 10%, 1); + --chart-grid: hsla(240, 5%, 90%, 1); + --chart-tooltip-bg: hsla(0, 0%, 100%, 1); + /* Legacy aliases (for backwards compatibility) */ --chart-1: var(--chart-cat-1); --chart-2: var(--chart-cat-2); @@ -88,6 +98,7 @@ /* Dark theme via class (takes precedence over media query) */ .dark { + color-scheme: dark; --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); @@ -111,11 +122,47 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); + /* CATEGORICAL */ + --chart-cat-1: hsla(217, 91%, 65%, 1); /* Blue */ + --chart-cat-2: hsla(160, 65%, 55%, 1); /* Teal */ + --chart-cat-3: hsla(291, 60%, 65%, 1); /* Purple */ + --chart-cat-4: hsla(38, 95%, 60%, 1); /* Amber */ + --chart-cat-5: hsla(349, 80%, 62%, 1); /* Rose */ + --chart-cat-6: hsla(189, 85%, 52%, 1); /* Cyan */ + --chart-cat-7: hsla(271, 65%, 70%, 1); /* Lavender */ + --chart-cat-8: hsla(142, 60%, 55%, 1); /* Emerald */ + /* SEQUENTIAL (inverted for dark mode) */ + --chart-seq-1: hsla(217, 50%, 25%, 1); + --chart-seq-2: hsla(217, 55%, 35%, 1); + --chart-seq-3: hsla(217, 60%, 45%, 1); + --chart-seq-4: hsla(217, 65%, 55%, 1); + --chart-seq-5: hsla(217, 70%, 62%, 1); + --chart-seq-6: hsla(217, 75%, 70%, 1); + --chart-seq-7: hsla(217, 80%, 78%, 1); + --chart-seq-8: hsla(217, 85%, 88%, 1); + /* DIVERGING */ + --chart-div-1: hsla(217, 85%, 70%, 1); + --chart-div-2: hsla(217, 70%, 60%, 1); + --chart-div-3: hsla(217, 50%, 50%, 1); + --chart-div-4: hsla(217, 25%, 40%, 1); + --chart-div-5: hsla(10, 25%, 40%, 1); + --chart-div-6: hsla(10, 55%, 50%, 1); + --chart-div-7: hsla(10, 70%, 58%, 1); + --chart-div-8: hsla(10, 80%, 65%, 1); + /* Legacy aliases */ + --chart-1: var(--chart-cat-1); + --chart-2: var(--chart-cat-2); + --chart-3: var(--chart-cat-3); + --chart-4: var(--chart-cat-4); + --chart-5: var(--chart-cat-5); + --chart-6: var(--chart-cat-6); + --chart-7: var(--chart-cat-7); + --chart-8: var(--chart-cat-8); + /* Chart UI tokens (≈ muted-foreground / foreground / border / popover, hsla for ECharts) */ + --chart-axis-label: hsla(240, 5%, 65%, 1); + --chart-axis-title: hsla(0, 0%, 98%, 1); + --chart-grid: hsla(0, 0%, 100%, 0.1); + --chart-tooltip-bg: hsla(240, 6%, 13%, 1); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); @@ -129,6 +176,7 @@ /* Dark theme via media query (fallback when no class is set) */ @media (prefers-color-scheme: dark) { :root:not(.light) { + color-scheme: dark; --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); @@ -193,6 +241,12 @@ --chart-div-7: hsla(10, 70%, 58%, 1); --chart-div-8: hsla(10, 80%, 65%, 1); /* Strong positive (red) */ + /* Chart UI tokens (≈ muted-foreground / foreground / border / popover, hsla for ECharts) */ + --chart-axis-label: hsla(240, 5%, 65%, 1); + --chart-axis-title: hsla(0, 0%, 98%, 1); + --chart-grid: hsla(0, 0%, 100%, 0.1); + --chart-tooltip-bg: hsla(240, 6%, 13%, 1); + /* Legacy aliases */ --chart-1: var(--chart-cat-1); --chart-2: var(--chart-cat-2); @@ -276,6 +330,12 @@ --color-chart-div-6: var(--chart-div-6); --color-chart-div-7: var(--chart-div-7); --color-chart-div-8: var(--chart-div-8); + + /* Chart UI tokens */ + --color-chart-axis-label: var(--chart-axis-label); + --color-chart-axis-title: var(--chart-axis-title); + --color-chart-grid: var(--chart-grid); + --color-chart-tooltip-bg: var(--chart-tooltip-bg); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary);