@analytix/dashboard 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { DEFAULT_DASHBOARD_WIDGETS, percentChange } from "@analytix/core";
5
5
  import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts";
6
- import { Download, Eye, Users, Clock, MousePointerClick, Repeat, FileText, UserPlus, UserCheck, Moon, Sun, SlidersHorizontal, } from "lucide-react";
6
+ import { Download, Eye, Users, Clock, MousePointerClick, Repeat, FileText, UserPlus, UserCheck, Sun, Moon, Monitor, SlidersHorizontal, } from "lucide-react";
7
7
  import { AnalyticsDashboardSkeleton } from "./AnalyticsDashboardSkeleton";
8
8
  import { useDashboardWidgets } from "./useDashboardWidgets";
9
+ import { DASHBOARD_THEME_LABELS, useDashboardTheme } from "./useDashboardTheme";
9
10
  import { WidgetCustomizePanel } from "./WidgetCustomizePanel";
10
11
  function formatBucketLabel(value, granularity) {
11
12
  const date = new Date(value);
@@ -32,21 +33,6 @@ function Delta({ current, previous }) {
32
33
  function BreakdownPanel({ title, emptyMessage, rows, }) {
33
34
  return (_jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "panelTitle", children: title }), rows.length === 0 ? (_jsx("p", { className: "emptyState", children: emptyMessage })) : (_jsx("ul", { className: "list", children: rows.map((row) => (_jsxs("li", { children: [_jsx("span", { className: "listLabel", children: row.label }), _jsx("span", { className: "listValue", children: row.value })] }, row.key))) }))] }));
34
35
  }
35
- function useResolvedTheme(mode) {
36
- const [resolved, setResolved] = useState("light");
37
- useEffect(() => {
38
- if (mode !== "system") {
39
- setResolved(mode);
40
- return;
41
- }
42
- const media = window.matchMedia("(prefers-color-scheme: dark)");
43
- const apply = () => setResolved(media.matches ? "dark" : "light");
44
- apply();
45
- media.addEventListener("change", apply);
46
- return () => media.removeEventListener("change", apply);
47
- }, [mode]);
48
- return resolved;
49
- }
50
36
  export function AnalyticsDashboard({ siteId, summaryEndpoint, exportEndpoint, loadingFallback, defaultTheme = "system", defaultWidgets = DEFAULT_DASHBOARD_WIDGETS, settingsEndpoint, onWidgetsSaved, onWidgetsSaveError, }) {
51
37
  const summaryUrl = summaryEndpoint ?? `/api/v1/sites/${siteId}/summary`;
52
38
  const exportUrl = exportEndpoint ?? `/api/v1/sites/${siteId}/export`;
@@ -63,8 +49,7 @@ export function AnalyticsDashboard({ siteId, summaryEndpoint, exportEndpoint, lo
63
49
  const [initialLoading, setInitialLoading] = useState(true);
64
50
  const [refreshing, setRefreshing] = useState(false);
65
51
  const [error, setError] = useState("");
66
- const [themeMode, setThemeMode] = useState(defaultTheme);
67
- const resolvedTheme = useResolvedTheme(themeMode);
52
+ const { themeMode, cycleTheme, resolvedTheme } = useDashboardTheme(siteId, defaultTheme);
68
53
  const hasLoadedRef = useRef(false);
69
54
  const [showWidgetCustomize, setShowWidgetCustomize] = useState(false);
70
55
  const [savingDefaultWidgets, setSavingDefaultWidgets] = useState(false);
@@ -196,9 +181,7 @@ export function AnalyticsDashboard({ siteId, summaryEndpoint, exportEndpoint, lo
196
181
  setSavingDefaultWidgets(false);
197
182
  }
198
183
  }
199
- return (_jsxs("div", { className: `analytix-dash ${themeClass}${refreshing ? " analytix-refreshing" : ""}`, children: [_jsxs("div", { className: "toolbar", children: [_jsxs("span", { className: "realtime", children: [summary.realtime_visitors, " visitors in the last 15 minutes"] }), _jsxs("div", { className: "toolbarActions", children: [_jsx("button", { type: "button", className: "btn btnIcon", onClick: () => setShowWidgetCustomize((open) => !open), "aria-label": "Customize widgets", children: _jsx(SlidersHorizontal, { size: 16 }) }), _jsx("button", { type: "button", className: "btn btnIcon", onClick: () => setThemeMode((current) => current === "dark" || (current === "system" && resolvedTheme === "dark")
200
- ? "light"
201
- : "dark"), "aria-label": "Toggle theme", children: resolvedTheme === "dark" ? _jsx(Sun, { size: 16 }) : _jsx(Moon, { size: 16 }) }), _jsxs("a", { className: "btn", href: `${exportUrl}?${filterParams.toString()}`, children: [_jsx(Download, { size: 14 }), "Export CSV"] })] })] }), showWidgetCustomize ? (_jsx(WidgetCustomizePanel, { widgets: widgets, onToggle: toggleWidget, onReset: resetToDefault, onSaveDefault: settingsEndpoint ? saveDefaultWidgets : undefined, savingDefault: savingDefaultWidgets })) : null, _jsxs("div", { className: "filters", children: [_jsxs("div", { className: "filterGroup filterGroupWide", children: [_jsx("span", { className: "filterLabel", children: "Range" }), _jsxs("div", { className: "chips", children: [["24h", "7d", "30d", "90d"].map((key) => (_jsx("button", { type: "button", className: !useCustomRange && range === key ? "chipActive" : "chip", onClick: () => {
184
+ return (_jsxs("div", { className: `analytix-dash ${themeClass}${refreshing ? " analytix-refreshing" : ""}`, children: [_jsxs("div", { className: "toolbar", children: [_jsxs("span", { className: "realtime", children: [summary.realtime_visitors, " visitors in the last 15 minutes"] }), _jsxs("div", { className: "toolbarActions", children: [_jsx("button", { type: "button", className: "btn btnIcon", onClick: () => setShowWidgetCustomize((open) => !open), "aria-label": "Customize widgets", children: _jsx(SlidersHorizontal, { size: 16 }) }), _jsx("button", { type: "button", className: "btn btnIcon", onClick: cycleTheme, "aria-label": `Theme: ${DASHBOARD_THEME_LABELS[themeMode]}. Click to change.`, title: `Theme: ${DASHBOARD_THEME_LABELS[themeMode]}`, children: themeMode === "system" ? (_jsx(Monitor, { size: 16 })) : resolvedTheme === "dark" ? (_jsx(Sun, { size: 16 })) : (_jsx(Moon, { size: 16 })) }), _jsxs("a", { className: "btn", href: `${exportUrl}?${filterParams.toString()}`, children: [_jsx(Download, { size: 14 }), "Export CSV"] })] })] }), showWidgetCustomize ? (_jsx(WidgetCustomizePanel, { widgets: widgets, onToggle: toggleWidget, onReset: resetToDefault, onSaveDefault: settingsEndpoint ? saveDefaultWidgets : undefined, savingDefault: savingDefaultWidgets })) : null, _jsxs("div", { className: "filters", children: [_jsxs("div", { className: "filterGroup filterGroupWide", children: [_jsx("span", { className: "filterLabel", children: "Range" }), _jsxs("div", { className: "chips", children: [["24h", "7d", "30d", "90d"].map((key) => (_jsx("button", { type: "button", className: !useCustomRange && range === key ? "chipActive" : "chip", onClick: () => {
202
185
  setUseCustomRange(false);
203
186
  setRange(key);
204
187
  }, children: key }, key))), _jsx("button", { type: "button", className: useCustomRange ? "chipActive" : "chip", onClick: () => setUseCustomRange(true), children: "Custom" })] }), useCustomRange && (_jsxs("div", { className: "dateRangeRow", children: [_jsxs("label", { children: [_jsx("span", { className: "filterLabel", children: "From" }), _jsx("input", { type: "date", value: customFrom, max: customTo, onChange: (e) => setCustomFrom(e.target.value) })] }), _jsxs("label", { children: [_jsx("span", { className: "filterLabel", children: "To" }), _jsx("input", { type: "date", value: customTo, min: customFrom, max: toDateInputValue(new Date()), onChange: (e) => setCustomTo(e.target.value) })] })] }))] }), _jsxs("div", { className: "filterGroup", children: [_jsx("span", { className: "filterLabel", children: "Granularity" }), _jsx("div", { className: "chips", children: ["hour", "day"].map((key) => (_jsx("button", { type: "button", className: granularity === key ? "chipActive" : "chip", onClick: () => setGranularity(key), children: key }, key))) })] }), _jsxs("div", { className: "filterGroup", children: [_jsx("span", { className: "filterLabel", children: "Scope" }), _jsx("div", { className: "chips", children: ["all", "page", "blog"].map((key) => (_jsx("button", { type: "button", className: trafficScope === key ? "chipActive" : "chip", onClick: () => setTrafficScope(key), children: key }, key))) })] }), trafficScope === "page" && (_jsxs("div", { className: "filterGroup filterGroupWide textField", children: [_jsx("span", { className: "filterLabel", children: "Path" }), _jsx("input", { value: path, onChange: (e) => setPath(e.target.value), placeholder: "/pricing" }), path.trim() === "/blog" && (_jsxs("label", { className: "checkboxRow", children: [_jsx("input", { type: "checkbox", checked: includeBlogArticles, onChange: (e) => setIncludeBlogArticles(e.target.checked) }), "Include blog articles"] }))] })), trafficScope === "blog" && (_jsxs("div", { className: "filterGroup filterGroupWide textField", children: [_jsx("span", { className: "filterLabel", children: "Content ID (optional)" }), _jsx("input", { value: contentId, onChange: (e) => setContentId(e.target.value), placeholder: "Filter to a single article" })] }))] }), isVisible("metrics") ? (_jsxs("div", { className: "metrics", children: [_jsxs("div", { className: "metricCard", children: [_jsx(Eye, { size: 18 }), _jsx("span", { className: "metricValue", children: formatNumber(summary.total_page_views) }), _jsx("span", { className: "metricLabel", children: "Page views" }), _jsx(Delta, { current: summary.total_page_views, previous: prev?.total_page_views })] }), _jsxs("div", { className: "metricCard", children: [_jsx(Users, { size: 18 }), _jsx("span", { className: "metricValue", children: formatNumber(summary.unique_visitors) }), _jsx("span", { className: "metricLabel", children: "Unique visitors" }), _jsx(Delta, { current: summary.unique_visitors, previous: prev?.unique_visitors })] }), _jsxs("div", { className: "metricCard", children: [_jsx(Repeat, { size: 18 }), _jsx("span", { className: "metricValue", children: formatNumber(summary.total_sessions) }), _jsx("span", { className: "metricLabel", children: "Sessions" }), _jsx(Delta, { current: summary.total_sessions, previous: prev?.total_sessions })] }), _jsxs("div", { className: "metricCard", children: [_jsx(FileText, { size: 18 }), _jsx("span", { className: "metricValue", children: summary.pages_per_session.toFixed(2) }), _jsx("span", { className: "metricLabel", children: "Pages / session" })] }), _jsxs("div", { className: "metricCard", children: [_jsx(UserPlus, { size: 18 }), _jsx("span", { className: "metricValue", children: formatNumber(summary.new_visitors) }), _jsx("span", { className: "metricLabel", children: "New visitors" })] }), _jsxs("div", { className: "metricCard", children: [_jsx(UserCheck, { size: 18 }), _jsx("span", { className: "metricValue", children: formatNumber(summary.returning_visitors) }), _jsx("span", { className: "metricLabel", children: "Returning visitors" })] }), _jsxs("div", { className: "metricCard", children: [_jsx(MousePointerClick, { size: 18 }), _jsxs("span", { className: "metricValue", children: [summary.bounce_rate, "%"] }), _jsx("span", { className: "metricLabel", children: "Bounce rate" }), _jsx(Delta, { current: summary.bounce_rate, previous: prev?.bounce_rate })] }), _jsxs("div", { className: "metricCard", children: [_jsx(Clock, { size: 18 }), _jsxs("span", { className: "metricValue", children: [summary.avg_engagement_seconds, "s"] }), _jsx("span", { className: "metricLabel", children: "Avg engagement" }), _jsx(Delta, { current: summary.avg_engagement_seconds, previous: prev?.avg_engagement_seconds })] })] })) : null, isVisible("chart") ? (_jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "panelTitle", children: "Traffic over time" }), _jsx("div", { className: "chartContainer", children: chartData.length === 0 ? (_jsx("p", { className: "emptyState", children: "No traffic recorded for this period." })) : (_jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(ComposedChart, { data: chartData, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: gridStroke }), _jsx(XAxis, { dataKey: "label", tick: { fontSize: 12, fill: "var(--ax-muted)" } }), _jsx(YAxis, { tick: { fontSize: 12, fill: "var(--ax-muted)" } }), _jsx(Tooltip, { contentStyle: {
@@ -1,4 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  export function AnalyticsDashboardSkeleton() {
3
- return (_jsxs("div", { className: "analytix-dash analytix-skeleton", "aria-busy": "true", "aria-label": "Loading analytics", children: [_jsxs("div", { className: "toolbar", children: [_jsx("div", { className: "sk skRealtime" }), _jsx("div", { className: "sk skBtn" })] }), _jsx("div", { className: "filters", children: Array.from({ length: 4 }).map((_, i) => (_jsxs("div", { className: "filterGroup", children: [_jsx("div", { className: "sk skLabel" }), _jsxs("div", { className: "skRow", children: [_jsx("div", { className: "sk skChip" }), _jsx("div", { className: "sk skChip" }), _jsx("div", { className: "sk skChip" })] })] }, i))) }), _jsx("div", { className: "metrics", children: Array.from({ length: 7 }).map((_, i) => (_jsxs("div", { className: "metricCard", children: [_jsx("div", { className: "sk skIcon" }), _jsx("div", { className: "sk skMetricValue" }), _jsx("div", { className: "sk skMetricLabel" }), _jsx("div", { className: "sk skDelta" })] }, i))) }), _jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "sk skPanelTitle" }), _jsx("div", { className: "sk skChart" })] }), _jsx("div", { className: "splitPanels", children: Array.from({ length: 2 }).map((_, i) => (_jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "sk skPanelTitle" }), _jsx("div", { className: "skList", children: Array.from({ length: 5 }).map((__, j) => (_jsx("div", { className: "sk skListRow" }, j))) })] }, i))) })] }));
3
+ return (_jsxs("div", { className: "analytix-dash analytix-theme-light analytix-skeleton", "aria-busy": "true", "aria-label": "Loading analytics", children: [_jsxs("div", { className: "toolbar", children: [_jsx("div", { className: "sk skRealtime" }), _jsx("div", { className: "sk skBtn" })] }), _jsx("div", { className: "filters", children: Array.from({ length: 4 }).map((_, i) => (_jsxs("div", { className: "filterGroup", children: [_jsx("div", { className: "sk skLabel" }), _jsxs("div", { className: "skRow", children: [_jsx("div", { className: "sk skChip" }), _jsx("div", { className: "sk skChip" }), _jsx("div", { className: "sk skChip" })] })] }, i))) }), _jsx("div", { className: "metrics", children: Array.from({ length: 7 }).map((_, i) => (_jsxs("div", { className: "metricCard", children: [_jsx("div", { className: "sk skIcon" }), _jsx("div", { className: "sk skMetricValue" }), _jsx("div", { className: "sk skMetricLabel" }), _jsx("div", { className: "sk skDelta" })] }, i))) }), _jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "sk skPanelTitle" }), _jsx("div", { className: "sk skChart" })] }), _jsx("div", { className: "splitPanels", children: Array.from({ length: 2 }).map((_, i) => (_jsxs("div", { className: "chartPanel", children: [_jsx("div", { className: "sk skPanelTitle" }), _jsx("div", { className: "skList", children: Array.from({ length: 5 }).map((__, j) => (_jsx("div", { className: "sk skListRow" }, j))) })] }, i))) })] }));
4
4
  }
@@ -1,42 +1,55 @@
1
+ /**
2
+ * Embeddable dashboard styles.
3
+ * Host apps may override tokens via --analytix-dash-* on a parent element.
4
+ */
1
5
  .analytix-dash {
2
- --ax-primary: #0f172a;
3
- --ax-muted: #64748b;
4
- --ax-accent: #0052ff;
5
- --ax-surface: rgba(255, 255, 255, 0.72);
6
- --ax-surface-solid: #ffffff;
7
- --ax-border: rgba(15, 23, 42, 0.08);
8
- --ax-success: #047857;
9
- --ax-success-bg: rgba(5, 150, 105, 0.08);
10
- --ax-danger: #dc2626;
11
- --ax-up: #059669;
12
- --ax-down: #dc2626;
13
- --ax-chart-grid: rgba(15, 23, 42, 0.08);
6
+ --ax-primary: var(--analytix-dash-ink, #111111);
7
+ --ax-muted: var(--analytix-dash-muted, #787774);
8
+ --ax-accent: var(--analytix-dash-accent, #111111);
9
+ --ax-surface: var(--analytix-dash-surface, rgba(255, 255, 255, 0.82));
10
+ --ax-surface-solid: var(--analytix-dash-surface-solid, #ffffff);
11
+ --ax-border: var(--analytix-dash-border, rgba(17, 17, 17, 0.08));
12
+ --ax-success: var(--analytix-dash-success, #346538);
13
+ --ax-success-bg: var(--analytix-dash-success-bg, #edf3ec);
14
+ --ax-danger: var(--analytix-dash-danger, #9f2f2d);
15
+ --ax-up: #346538;
16
+ --ax-down: #9f2f2d;
17
+ --ax-chart-grid: rgba(17, 17, 17, 0.08);
14
18
  display: flex;
15
19
  flex-direction: column;
16
20
  gap: 24px;
17
21
  width: 100%;
22
+ min-width: 0;
18
23
  color: var(--ax-primary);
19
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ font-family: var(
25
+ --analytix-dash-font,
26
+ system-ui,
27
+ -apple-system,
28
+ BlinkMacSystemFont,
29
+ "Segoe UI",
30
+ sans-serif
31
+ );
20
32
  }
21
33
 
22
34
  .analytix-dash.analytix-theme-dark {
23
- --ax-primary: #e2e8f0;
24
- --ax-muted: #94a3b8;
25
- --ax-accent: #60a5fa;
26
- --ax-surface: rgba(15, 23, 42, 0.72);
27
- --ax-surface-solid: #0f172a;
28
- --ax-border: rgba(148, 163, 184, 0.18);
29
- --ax-success: #34d399;
30
- --ax-success-bg: rgba(52, 211, 153, 0.12);
31
- --ax-up: #34d399;
32
- --ax-down: #f87171;
33
- --ax-chart-grid: rgba(148, 163, 184, 0.15);
35
+ --ax-primary: var(--analytix-dash-ink, #f7f6f3);
36
+ --ax-muted: var(--analytix-dash-muted, #9b9a97);
37
+ --ax-accent: var(--analytix-dash-accent, #f7f6f3);
38
+ --ax-surface: var(--analytix-dash-surface, rgba(25, 25, 24, 0.88));
39
+ --ax-surface-solid: var(--analytix-dash-surface-solid, #191918);
40
+ --ax-border: var(--analytix-dash-border, rgba(255, 255, 255, 0.1));
41
+ --ax-success: #6ee7b7;
42
+ --ax-success-bg: rgba(52, 101, 56, 0.22);
43
+ --ax-danger: #fca5a5;
44
+ --ax-up: #6ee7b7;
45
+ --ax-down: #fca5a5;
46
+ --ax-chart-grid: rgba(255, 255, 255, 0.08);
34
47
  }
35
48
 
36
49
  .analytix-dash.analytix-refreshing {
37
50
  opacity: 0.72;
38
51
  pointer-events: none;
39
- transition: opacity 0.15s ease;
52
+ transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
40
53
  }
41
54
 
42
55
  .analytix-dash .filters {
@@ -45,7 +58,7 @@
45
58
  gap: 14px;
46
59
  padding: 18px;
47
60
  border: 1px solid var(--ax-border);
48
- border-radius: 14px;
61
+ border-radius: 12px;
49
62
  background: var(--ax-surface);
50
63
  }
51
64
 
@@ -83,17 +96,33 @@
83
96
  font-weight: 600;
84
97
  cursor: pointer;
85
98
  color: var(--ax-muted);
99
+ transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
100
+ background 160ms cubic-bezier(0.23, 1, 0.32, 1),
101
+ border-color 160ms cubic-bezier(0.23, 1, 0.32, 1),
102
+ color 160ms cubic-bezier(0.23, 1, 0.32, 1);
103
+ }
104
+
105
+ @media (hover: hover) and (pointer: fine) {
106
+ .analytix-dash .chip:hover {
107
+ color: var(--ax-primary);
108
+ border-color: var(--ax-accent);
109
+ }
110
+ }
111
+
112
+ .analytix-dash .chip:active,
113
+ .analytix-dash .chipActive:active {
114
+ transform: scale(0.97);
86
115
  }
87
116
 
88
117
  .analytix-dash .chipActive {
89
- background: rgba(0, 82, 255, 0.08);
90
- border-color: rgba(0, 82, 255, 0.18);
118
+ background: rgba(17, 17, 17, 0.06);
119
+ border-color: rgba(17, 17, 17, 0.14);
91
120
  color: var(--ax-accent);
92
121
  }
93
122
 
94
123
  .analytix-dash.analytix-theme-dark .chipActive {
95
- background: rgba(96, 165, 250, 0.14);
96
- border-color: rgba(96, 165, 250, 0.28);
124
+ background: rgba(255, 255, 255, 0.08);
125
+ border-color: rgba(255, 255, 255, 0.16);
97
126
  }
98
127
 
99
128
  .analytix-dash .dateRangeRow {
@@ -115,9 +144,17 @@
115
144
  font: inherit;
116
145
  padding: 10px 12px;
117
146
  border: 1px solid var(--ax-border);
118
- border-radius: 10px;
147
+ border-radius: 8px;
119
148
  background: var(--ax-surface-solid);
120
149
  color: var(--ax-primary);
150
+ transition: border-color 160ms cubic-bezier(0.23, 1, 0.32, 1);
151
+ }
152
+
153
+ .analytix-dash .textField input:focus,
154
+ .analytix-dash .dateRangeRow input:focus,
155
+ .analytix-dash select:focus {
156
+ outline: none;
157
+ border-color: var(--ax-accent);
121
158
  }
122
159
 
123
160
  .analytix-dash .checkboxRow {
@@ -139,7 +176,7 @@
139
176
  flex-direction: column;
140
177
  gap: 8px;
141
178
  padding: 18px;
142
- border-radius: 14px;
179
+ border-radius: 12px;
143
180
  border: 1px solid var(--ax-border);
144
181
  background: var(--ax-surface);
145
182
  color: var(--ax-accent);
@@ -151,6 +188,7 @@
151
188
  font-weight: 600;
152
189
  color: var(--ax-primary);
153
190
  line-height: 1.2;
191
+ font-variant-numeric: tabular-nums;
154
192
  }
155
193
 
156
194
  .analytix-dash .metricLabel {
@@ -173,7 +211,7 @@
173
211
 
174
212
  .analytix-dash .chartPanel {
175
213
  padding: 18px;
176
- border-radius: 14px;
214
+ border-radius: 12px;
177
215
  border: 1px solid var(--ax-border);
178
216
  background: var(--ax-surface);
179
217
  min-width: 0;
@@ -183,11 +221,15 @@
183
221
  width: 100%;
184
222
  height: 280px;
185
223
  min-height: 280px;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
186
227
  }
187
228
 
188
229
  .analytix-dash .panelTitle {
189
- font-size: 0.875rem;
230
+ font-size: 0.75rem;
190
231
  font-weight: 700;
232
+ letter-spacing: 0.06em;
191
233
  text-transform: uppercase;
192
234
  color: var(--ax-muted);
193
235
  margin-bottom: 14px;
@@ -250,6 +292,7 @@
250
292
  display: flex;
251
293
  gap: 8px;
252
294
  align-items: center;
295
+ flex-wrap: wrap;
253
296
  }
254
297
 
255
298
  .analytix-dash .btn {
@@ -265,6 +308,18 @@
265
308
  font-weight: 600;
266
309
  color: var(--ax-primary);
267
310
  text-decoration: none;
311
+ transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
312
+ background 160ms cubic-bezier(0.23, 1, 0.32, 1);
313
+ }
314
+
315
+ @media (hover: hover) and (pointer: fine) {
316
+ .analytix-dash .btn:hover {
317
+ background: var(--ax-surface);
318
+ }
319
+ }
320
+
321
+ .analytix-dash .btn:active {
322
+ transform: scale(0.97);
268
323
  }
269
324
 
270
325
  .analytix-dash .btnIcon {
@@ -272,27 +327,49 @@
272
327
  }
273
328
 
274
329
  .analytix-dash .realtime {
330
+ display: inline-flex;
331
+ align-items: center;
332
+ gap: 8px;
275
333
  padding: 10px 14px;
276
- border-radius: 10px;
334
+ border-radius: 9999px;
277
335
  background: var(--ax-success-bg);
278
336
  color: var(--ax-success);
279
337
  font-size: 0.875rem;
280
338
  font-weight: 600;
281
339
  }
282
340
 
341
+ .analytix-dash .realtime::before {
342
+ content: "";
343
+ width: 7px;
344
+ height: 7px;
345
+ border-radius: 50%;
346
+ background: currentColor;
347
+ opacity: 0.85;
348
+ }
349
+
283
350
  /* Skeleton */
284
351
  .analytix-dash.analytix-skeleton .sk {
285
352
  border-radius: 8px;
286
353
  background: linear-gradient(
287
354
  90deg,
288
- rgba(148, 163, 184, 0.12) 0%,
289
- rgba(148, 163, 184, 0.22) 50%,
290
- rgba(148, 163, 184, 0.12) 100%
355
+ rgba(120, 119, 116, 0.1) 0%,
356
+ rgba(120, 119, 116, 0.18) 50%,
357
+ rgba(120, 119, 116, 0.1) 100%
291
358
  );
292
359
  background-size: 200% 100%;
293
360
  animation: analytix-shimmer 1.4s ease-in-out infinite;
294
361
  }
295
362
 
363
+ .analytix-dash.analytix-theme-dark.analytix-skeleton .sk {
364
+ background: linear-gradient(
365
+ 90deg,
366
+ rgba(255, 255, 255, 0.06) 0%,
367
+ rgba(255, 255, 255, 0.12) 50%,
368
+ rgba(255, 255, 255, 0.06) 100%
369
+ );
370
+ background-size: 200% 100%;
371
+ }
372
+
296
373
  .analytix-dash.analytix-skeleton .skRow {
297
374
  display: flex;
298
375
  gap: 6px;
@@ -302,6 +379,7 @@
302
379
  .analytix-dash.analytix-skeleton .skRealtime {
303
380
  width: 260px;
304
381
  height: 40px;
382
+ border-radius: 9999px;
305
383
  }
306
384
 
307
385
  .analytix-dash.analytix-skeleton .skBtn {
@@ -372,6 +450,17 @@
372
450
  }
373
451
  }
374
452
 
453
+ @media (prefers-reduced-motion: reduce) {
454
+ .analytix-dash.analytix-skeleton .sk {
455
+ animation: none;
456
+ background: rgba(120, 119, 116, 0.12);
457
+ }
458
+
459
+ .analytix-dash.analytix-theme-dark.analytix-skeleton .sk {
460
+ background: rgba(255, 255, 255, 0.08);
461
+ }
462
+ }
463
+
375
464
  @media (max-width: 900px) {
376
465
  .analytix-dash .filters,
377
466
  .analytix-dash .splitPanels {
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export { AnalyticsDashboard, type AnalyticsDashboardProps } from "./AnalyticsDas
2
2
  export { AnalyticsDashboardSkeleton } from "./AnalyticsDashboardSkeleton";
3
3
  export { WidgetCustomizePanel } from "./WidgetCustomizePanel";
4
4
  export { useDashboardWidgets } from "./useDashboardWidgets";
5
+ export { useDashboardTheme, DASHBOARD_THEME_LABELS, type DashboardThemeMode } from "./useDashboardTheme";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { AnalyticsDashboard } from "./AnalyticsDashboard";
2
2
  export { AnalyticsDashboardSkeleton } from "./AnalyticsDashboardSkeleton";
3
3
  export { WidgetCustomizePanel } from "./WidgetCustomizePanel";
4
4
  export { useDashboardWidgets } from "./useDashboardWidgets";
5
+ export { useDashboardTheme, DASHBOARD_THEME_LABELS } from "./useDashboardTheme";
@@ -0,0 +1,8 @@
1
+ export type DashboardThemeMode = "light" | "dark" | "system";
2
+ export declare function useDashboardTheme(siteId: string, defaultTheme?: DashboardThemeMode): {
3
+ themeMode: DashboardThemeMode;
4
+ setThemeMode: (next: DashboardThemeMode) => void;
5
+ cycleTheme: () => void;
6
+ resolvedTheme: "light" | "dark";
7
+ };
8
+ export declare const DASHBOARD_THEME_LABELS: Record<DashboardThemeMode, string>;
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ const STORAGE_PREFIX = "analytix_dashboard_theme";
4
+ function storageKey(siteId) {
5
+ return `${STORAGE_PREFIX}_${siteId}`;
6
+ }
7
+ function readStoredTheme(siteId, fallback) {
8
+ if (typeof window === "undefined")
9
+ return fallback;
10
+ const stored = localStorage.getItem(storageKey(siteId));
11
+ if (stored === "light" || stored === "dark" || stored === "system")
12
+ return stored;
13
+ return fallback;
14
+ }
15
+ export function useDashboardTheme(siteId, defaultTheme = "system") {
16
+ const [themeMode, setThemeModeState] = useState(defaultTheme);
17
+ useEffect(() => {
18
+ setThemeModeState(readStoredTheme(siteId, defaultTheme));
19
+ }, [siteId, defaultTheme]);
20
+ useEffect(() => {
21
+ localStorage.setItem(storageKey(siteId), themeMode);
22
+ }, [siteId, themeMode]);
23
+ function setThemeMode(next) {
24
+ setThemeModeState(next);
25
+ }
26
+ function cycleTheme() {
27
+ setThemeModeState((current) => current === "light" ? "dark" : current === "dark" ? "system" : "light");
28
+ }
29
+ const resolvedTheme = useResolvedTheme(themeMode);
30
+ return { themeMode, setThemeMode, cycleTheme, resolvedTheme };
31
+ }
32
+ function useResolvedTheme(mode) {
33
+ const [resolved, setResolved] = useState("light");
34
+ useEffect(() => {
35
+ if (mode !== "system") {
36
+ setResolved(mode);
37
+ return;
38
+ }
39
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
40
+ const apply = () => setResolved(media.matches ? "dark" : "light");
41
+ apply();
42
+ media.addEventListener("change", apply);
43
+ return () => media.removeEventListener("change", apply);
44
+ }, [mode]);
45
+ return resolved;
46
+ }
47
+ export const DASHBOARD_THEME_LABELS = {
48
+ light: "Light",
49
+ dark: "Dark",
50
+ system: "System",
51
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analytix/dashboard",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Analytix embeddable analytics dashboard UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -36,7 +36,7 @@
36
36
  "lucide-react": ">=0.400"
37
37
  },
38
38
  "dependencies": {
39
- "@analytix/core": "^0.3.0"
39
+ "@analytix/core": "^0.3.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/react": "^19",
package/src/dashboard.css CHANGED
@@ -1,42 +1,55 @@
1
+ /**
2
+ * Embeddable dashboard styles.
3
+ * Host apps may override tokens via --analytix-dash-* on a parent element.
4
+ */
1
5
  .analytix-dash {
2
- --ax-primary: #0f172a;
3
- --ax-muted: #64748b;
4
- --ax-accent: #0052ff;
5
- --ax-surface: rgba(255, 255, 255, 0.72);
6
- --ax-surface-solid: #ffffff;
7
- --ax-border: rgba(15, 23, 42, 0.08);
8
- --ax-success: #047857;
9
- --ax-success-bg: rgba(5, 150, 105, 0.08);
10
- --ax-danger: #dc2626;
11
- --ax-up: #059669;
12
- --ax-down: #dc2626;
13
- --ax-chart-grid: rgba(15, 23, 42, 0.08);
6
+ --ax-primary: var(--analytix-dash-ink, #111111);
7
+ --ax-muted: var(--analytix-dash-muted, #787774);
8
+ --ax-accent: var(--analytix-dash-accent, #111111);
9
+ --ax-surface: var(--analytix-dash-surface, rgba(255, 255, 255, 0.82));
10
+ --ax-surface-solid: var(--analytix-dash-surface-solid, #ffffff);
11
+ --ax-border: var(--analytix-dash-border, rgba(17, 17, 17, 0.08));
12
+ --ax-success: var(--analytix-dash-success, #346538);
13
+ --ax-success-bg: var(--analytix-dash-success-bg, #edf3ec);
14
+ --ax-danger: var(--analytix-dash-danger, #9f2f2d);
15
+ --ax-up: #346538;
16
+ --ax-down: #9f2f2d;
17
+ --ax-chart-grid: rgba(17, 17, 17, 0.08);
14
18
  display: flex;
15
19
  flex-direction: column;
16
20
  gap: 24px;
17
21
  width: 100%;
22
+ min-width: 0;
18
23
  color: var(--ax-primary);
19
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ font-family: var(
25
+ --analytix-dash-font,
26
+ system-ui,
27
+ -apple-system,
28
+ BlinkMacSystemFont,
29
+ "Segoe UI",
30
+ sans-serif
31
+ );
20
32
  }
21
33
 
22
34
  .analytix-dash.analytix-theme-dark {
23
- --ax-primary: #e2e8f0;
24
- --ax-muted: #94a3b8;
25
- --ax-accent: #60a5fa;
26
- --ax-surface: rgba(15, 23, 42, 0.72);
27
- --ax-surface-solid: #0f172a;
28
- --ax-border: rgba(148, 163, 184, 0.18);
29
- --ax-success: #34d399;
30
- --ax-success-bg: rgba(52, 211, 153, 0.12);
31
- --ax-up: #34d399;
32
- --ax-down: #f87171;
33
- --ax-chart-grid: rgba(148, 163, 184, 0.15);
35
+ --ax-primary: var(--analytix-dash-ink, #f7f6f3);
36
+ --ax-muted: var(--analytix-dash-muted, #9b9a97);
37
+ --ax-accent: var(--analytix-dash-accent, #f7f6f3);
38
+ --ax-surface: var(--analytix-dash-surface, rgba(25, 25, 24, 0.88));
39
+ --ax-surface-solid: var(--analytix-dash-surface-solid, #191918);
40
+ --ax-border: var(--analytix-dash-border, rgba(255, 255, 255, 0.1));
41
+ --ax-success: #6ee7b7;
42
+ --ax-success-bg: rgba(52, 101, 56, 0.22);
43
+ --ax-danger: #fca5a5;
44
+ --ax-up: #6ee7b7;
45
+ --ax-down: #fca5a5;
46
+ --ax-chart-grid: rgba(255, 255, 255, 0.08);
34
47
  }
35
48
 
36
49
  .analytix-dash.analytix-refreshing {
37
50
  opacity: 0.72;
38
51
  pointer-events: none;
39
- transition: opacity 0.15s ease;
52
+ transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
40
53
  }
41
54
 
42
55
  .analytix-dash .filters {
@@ -45,7 +58,7 @@
45
58
  gap: 14px;
46
59
  padding: 18px;
47
60
  border: 1px solid var(--ax-border);
48
- border-radius: 14px;
61
+ border-radius: 12px;
49
62
  background: var(--ax-surface);
50
63
  }
51
64
 
@@ -83,17 +96,33 @@
83
96
  font-weight: 600;
84
97
  cursor: pointer;
85
98
  color: var(--ax-muted);
99
+ transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
100
+ background 160ms cubic-bezier(0.23, 1, 0.32, 1),
101
+ border-color 160ms cubic-bezier(0.23, 1, 0.32, 1),
102
+ color 160ms cubic-bezier(0.23, 1, 0.32, 1);
103
+ }
104
+
105
+ @media (hover: hover) and (pointer: fine) {
106
+ .analytix-dash .chip:hover {
107
+ color: var(--ax-primary);
108
+ border-color: var(--ax-accent);
109
+ }
110
+ }
111
+
112
+ .analytix-dash .chip:active,
113
+ .analytix-dash .chipActive:active {
114
+ transform: scale(0.97);
86
115
  }
87
116
 
88
117
  .analytix-dash .chipActive {
89
- background: rgba(0, 82, 255, 0.08);
90
- border-color: rgba(0, 82, 255, 0.18);
118
+ background: rgba(17, 17, 17, 0.06);
119
+ border-color: rgba(17, 17, 17, 0.14);
91
120
  color: var(--ax-accent);
92
121
  }
93
122
 
94
123
  .analytix-dash.analytix-theme-dark .chipActive {
95
- background: rgba(96, 165, 250, 0.14);
96
- border-color: rgba(96, 165, 250, 0.28);
124
+ background: rgba(255, 255, 255, 0.08);
125
+ border-color: rgba(255, 255, 255, 0.16);
97
126
  }
98
127
 
99
128
  .analytix-dash .dateRangeRow {
@@ -115,9 +144,17 @@
115
144
  font: inherit;
116
145
  padding: 10px 12px;
117
146
  border: 1px solid var(--ax-border);
118
- border-radius: 10px;
147
+ border-radius: 8px;
119
148
  background: var(--ax-surface-solid);
120
149
  color: var(--ax-primary);
150
+ transition: border-color 160ms cubic-bezier(0.23, 1, 0.32, 1);
151
+ }
152
+
153
+ .analytix-dash .textField input:focus,
154
+ .analytix-dash .dateRangeRow input:focus,
155
+ .analytix-dash select:focus {
156
+ outline: none;
157
+ border-color: var(--ax-accent);
121
158
  }
122
159
 
123
160
  .analytix-dash .checkboxRow {
@@ -139,7 +176,7 @@
139
176
  flex-direction: column;
140
177
  gap: 8px;
141
178
  padding: 18px;
142
- border-radius: 14px;
179
+ border-radius: 12px;
143
180
  border: 1px solid var(--ax-border);
144
181
  background: var(--ax-surface);
145
182
  color: var(--ax-accent);
@@ -151,6 +188,7 @@
151
188
  font-weight: 600;
152
189
  color: var(--ax-primary);
153
190
  line-height: 1.2;
191
+ font-variant-numeric: tabular-nums;
154
192
  }
155
193
 
156
194
  .analytix-dash .metricLabel {
@@ -173,7 +211,7 @@
173
211
 
174
212
  .analytix-dash .chartPanel {
175
213
  padding: 18px;
176
- border-radius: 14px;
214
+ border-radius: 12px;
177
215
  border: 1px solid var(--ax-border);
178
216
  background: var(--ax-surface);
179
217
  min-width: 0;
@@ -183,11 +221,15 @@
183
221
  width: 100%;
184
222
  height: 280px;
185
223
  min-height: 280px;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
186
227
  }
187
228
 
188
229
  .analytix-dash .panelTitle {
189
- font-size: 0.875rem;
230
+ font-size: 0.75rem;
190
231
  font-weight: 700;
232
+ letter-spacing: 0.06em;
191
233
  text-transform: uppercase;
192
234
  color: var(--ax-muted);
193
235
  margin-bottom: 14px;
@@ -250,6 +292,7 @@
250
292
  display: flex;
251
293
  gap: 8px;
252
294
  align-items: center;
295
+ flex-wrap: wrap;
253
296
  }
254
297
 
255
298
  .analytix-dash .btn {
@@ -265,6 +308,18 @@
265
308
  font-weight: 600;
266
309
  color: var(--ax-primary);
267
310
  text-decoration: none;
311
+ transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
312
+ background 160ms cubic-bezier(0.23, 1, 0.32, 1);
313
+ }
314
+
315
+ @media (hover: hover) and (pointer: fine) {
316
+ .analytix-dash .btn:hover {
317
+ background: var(--ax-surface);
318
+ }
319
+ }
320
+
321
+ .analytix-dash .btn:active {
322
+ transform: scale(0.97);
268
323
  }
269
324
 
270
325
  .analytix-dash .btnIcon {
@@ -272,27 +327,49 @@
272
327
  }
273
328
 
274
329
  .analytix-dash .realtime {
330
+ display: inline-flex;
331
+ align-items: center;
332
+ gap: 8px;
275
333
  padding: 10px 14px;
276
- border-radius: 10px;
334
+ border-radius: 9999px;
277
335
  background: var(--ax-success-bg);
278
336
  color: var(--ax-success);
279
337
  font-size: 0.875rem;
280
338
  font-weight: 600;
281
339
  }
282
340
 
341
+ .analytix-dash .realtime::before {
342
+ content: "";
343
+ width: 7px;
344
+ height: 7px;
345
+ border-radius: 50%;
346
+ background: currentColor;
347
+ opacity: 0.85;
348
+ }
349
+
283
350
  /* Skeleton */
284
351
  .analytix-dash.analytix-skeleton .sk {
285
352
  border-radius: 8px;
286
353
  background: linear-gradient(
287
354
  90deg,
288
- rgba(148, 163, 184, 0.12) 0%,
289
- rgba(148, 163, 184, 0.22) 50%,
290
- rgba(148, 163, 184, 0.12) 100%
355
+ rgba(120, 119, 116, 0.1) 0%,
356
+ rgba(120, 119, 116, 0.18) 50%,
357
+ rgba(120, 119, 116, 0.1) 100%
291
358
  );
292
359
  background-size: 200% 100%;
293
360
  animation: analytix-shimmer 1.4s ease-in-out infinite;
294
361
  }
295
362
 
363
+ .analytix-dash.analytix-theme-dark.analytix-skeleton .sk {
364
+ background: linear-gradient(
365
+ 90deg,
366
+ rgba(255, 255, 255, 0.06) 0%,
367
+ rgba(255, 255, 255, 0.12) 50%,
368
+ rgba(255, 255, 255, 0.06) 100%
369
+ );
370
+ background-size: 200% 100%;
371
+ }
372
+
296
373
  .analytix-dash.analytix-skeleton .skRow {
297
374
  display: flex;
298
375
  gap: 6px;
@@ -302,6 +379,7 @@
302
379
  .analytix-dash.analytix-skeleton .skRealtime {
303
380
  width: 260px;
304
381
  height: 40px;
382
+ border-radius: 9999px;
305
383
  }
306
384
 
307
385
  .analytix-dash.analytix-skeleton .skBtn {
@@ -372,6 +450,17 @@
372
450
  }
373
451
  }
374
452
 
453
+ @media (prefers-reduced-motion: reduce) {
454
+ .analytix-dash.analytix-skeleton .sk {
455
+ animation: none;
456
+ background: rgba(120, 119, 116, 0.12);
457
+ }
458
+
459
+ .analytix-dash.analytix-theme-dark.analytix-skeleton .sk {
460
+ background: rgba(255, 255, 255, 0.08);
461
+ }
462
+ }
463
+
375
464
  @media (max-width: 900px) {
376
465
  .analytix-dash .filters,
377
466
  .analytix-dash .splitPanels {