sid-ui 0.0.0-stage → 0.1.1

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.
Files changed (122) hide show
  1. package/README.md +54 -2
  2. package/package.json +51 -4
  3. package/src/brand/BrandFooter.tsx +45 -0
  4. package/src/brand/BrandHeading.tsx +24 -0
  5. package/src/brand/BrandLockup.tsx +47 -0
  6. package/src/brand/JoinedLogo.tsx +67 -0
  7. package/src/brand/JoinedMark.tsx +76 -0
  8. package/src/brand/geometry.ts +26 -0
  9. package/src/brand/index.ts +11 -0
  10. package/src/brand/name.ts +5 -0
  11. package/src/brand/paint.tsx +56 -0
  12. package/src/components/Action.tsx +44 -0
  13. package/src/components/AddressSelector.tsx +99 -0
  14. package/src/components/BarChart.tsx +132 -0
  15. package/src/components/Calendar.tsx +284 -0
  16. package/src/components/CalendarAgenda.tsx +87 -0
  17. package/src/components/CalendarMonth.tsx +223 -0
  18. package/src/components/CalendarSchedule.tsx +210 -0
  19. package/src/components/Card.tsx +80 -0
  20. package/src/components/Charts.tsx +64 -0
  21. package/src/components/Chat.tsx +35 -0
  22. package/src/components/CitySelector.tsx +81 -0
  23. package/src/components/Clock.tsx +196 -0
  24. package/src/components/CodeInput.tsx +183 -0
  25. package/src/components/Container.tsx +20 -0
  26. package/src/components/Content.tsx +50 -0
  27. package/src/components/Data.tsx +43 -0
  28. package/src/components/DataInput.tsx +107 -0
  29. package/src/components/DateField.tsx +162 -0
  30. package/src/components/DonutChart.tsx +123 -0
  31. package/src/components/Drawer.tsx +110 -0
  32. package/src/components/Feedback.tsx +37 -0
  33. package/src/components/FileUploader.tsx +377 -0
  34. package/src/components/FunnelChart.tsx +83 -0
  35. package/src/components/Glyph.tsx +330 -0
  36. package/src/components/GoogleSignIn.tsx +67 -0
  37. package/src/components/GridSystem.tsx +131 -0
  38. package/src/components/HeatmapCalendar.tsx +167 -0
  39. package/src/components/KanbanBoard.tsx +421 -0
  40. package/src/components/Kpi.tsx +61 -0
  41. package/src/components/LayoutPrimitives.tsx +86 -0
  42. package/src/components/Lists.tsx +19 -0
  43. package/src/components/LocationSelector.tsx +49 -0
  44. package/src/components/MessageList.tsx +80 -0
  45. package/src/components/Nav.tsx +69 -0
  46. package/src/components/Navigation.tsx +52 -0
  47. package/src/components/Notification.tsx +115 -0
  48. package/src/components/NotificationTrigger.tsx +248 -0
  49. package/src/components/Overlay.tsx +50 -0
  50. package/src/components/Page.tsx +143 -0
  51. package/src/components/PageTabs.tsx +28 -0
  52. package/src/components/PickerShell.tsx +138 -0
  53. package/src/components/PillNav.tsx +194 -0
  54. package/src/components/Primitives.tsx +30 -0
  55. package/src/components/Rating.tsx +78 -0
  56. package/src/components/Responsive.tsx +128 -0
  57. package/src/components/ResponsiveFrame.tsx +122 -0
  58. package/src/components/Segment.tsx +190 -0
  59. package/src/components/Sparkline.tsx +52 -0
  60. package/src/components/StateSelector.tsx +39 -0
  61. package/src/components/Sticky.tsx +95 -0
  62. package/src/components/Table.tsx +344 -0
  63. package/src/components/ThemeToggle.tsx +48 -0
  64. package/src/components/Tile.tsx +23 -0
  65. package/src/components/TimeColumns.tsx +155 -0
  66. package/src/components/TimeDial.tsx +260 -0
  67. package/src/components/TimeField.tsx +196 -0
  68. package/src/components/TimeInput.tsx +213 -0
  69. package/src/components/TimeSlots.tsx +83 -0
  70. package/src/components/Timeline.tsx +111 -0
  71. package/src/components/TokenDemo.tsx +378 -0
  72. package/src/components/Tree.tsx +283 -0
  73. package/src/components/TrendChart.tsx +211 -0
  74. package/src/components/breakpoints.ts +42 -0
  75. package/src/components/calendarTypes.ts +26 -0
  76. package/src/components/chartMath.ts +125 -0
  77. package/src/components/chartParts.tsx +116 -0
  78. package/src/components/codeInputRules.ts +58 -0
  79. package/src/components/date.ts +87 -0
  80. package/src/components/hooks.ts +53 -0
  81. package/src/components/index.ts +195 -0
  82. package/src/components/kanban.ts +78 -0
  83. package/src/components/motion.ts +78 -0
  84. package/src/components/places-search.tsx +73 -0
  85. package/src/components/places.ts +209 -0
  86. package/src/components/size.ts +3 -0
  87. package/src/components/time.ts +83 -0
  88. package/src/components/useFileDrop.ts +104 -0
  89. package/src/places/geoapify.ts +129 -0
  90. package/src/styles/astryx.css +5 -0
  91. package/src/styles/components/brand.css +98 -0
  92. package/src/styles/components/calendar.css +680 -0
  93. package/src/styles/components/charts.css +659 -0
  94. package/src/styles/components/code-input.css +233 -0
  95. package/src/styles/components/google.css +11 -0
  96. package/src/styles/components/message-list.css +153 -0
  97. package/src/styles/components/notification.css +350 -0
  98. package/src/styles/components/picker.css +114 -0
  99. package/src/styles/components/pill-nav.css +179 -0
  100. package/src/styles/components/responsive.css +419 -0
  101. package/src/styles/components/sticky.css +23 -0
  102. package/src/styles/components/table.css +267 -0
  103. package/src/styles/components/time.css +526 -0
  104. package/src/styles/components/timeline.css +426 -0
  105. package/src/styles/components/tree.css +269 -0
  106. package/src/styles/components.css +423 -0
  107. package/src/styles/joined.css +4 -0
  108. package/src/styles/theme.css +79 -0
  109. package/src/styles/tokens.css +338 -0
  110. package/src/theme/JoinedProvider.tsx +50 -0
  111. package/src/theme/index.ts +3 -0
  112. package/src/theme/joined.d.ts +11 -0
  113. package/src/theme/joined.js +569 -0
  114. package/src/theme/joined.theme.ts +74 -0
  115. package/src/theme/joined.variants.d.ts +16 -0
  116. package/src/theme/theme.css +786 -0
  117. package/styles/astryx.css +1 -0
  118. package/styles/components.css +1 -0
  119. package/styles/joined.css +1 -0
  120. package/styles/theme.css +1 -0
  121. package/styles/tokens.css +1 -0
  122. package/styles.css +1 -0
@@ -0,0 +1,283 @@
1
+ "use client";
2
+
3
+ import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
4
+
5
+ import { Glyph } from "./Glyph";
6
+ import { useControllable } from "./hooks";
7
+
8
+ export interface TreeNode {
9
+ id: string;
10
+ label: string;
11
+ description?: string;
12
+ /** Trailing content — a count, a badge, a status. */
13
+ meta?: ReactNode;
14
+ /** Icon or avatar before the label. Explorer shows folder and file icons when this is empty. */
15
+ leading?: ReactNode;
16
+ disabled?: boolean;
17
+ children?: TreeNode[];
18
+ }
19
+
20
+ /**
21
+ * guides — indent lines connect every level.
22
+ * explorer — folders and files; guides appear on hover.
23
+ * cards — each top-level branch on its own surface.
24
+ * selection — one chosen row with an accent bar.
25
+ * checkbox — tri-state checks that roll up to parents.
26
+ */
27
+ export type TreeVariant = "guides" | "explorer" | "cards" | "selection" | "checkbox";
28
+ export type TreeCheckState = "checked" | "unchecked" | "mixed";
29
+
30
+ export interface TreeProps {
31
+ nodes: TreeNode[];
32
+ variant?: TreeVariant;
33
+ density?: "compact" | "regular" | "spacious";
34
+ /** Open branch ids. Pass `expanded` to control it, or `defaultExpanded` to seed it. */
35
+ expanded?: string[];
36
+ defaultExpanded?: string[];
37
+ onExpandedChange?: (ids: string[]) => void;
38
+ selectedId?: string;
39
+ onSelect?: (id: string) => void;
40
+ /** Checked leaf ids for `variant="checkbox"`. Parents derive their state. */
41
+ checked?: string[];
42
+ defaultChecked?: string[];
43
+ onCheckedChange?: (ids: string[]) => void;
44
+ /** Marks this text inside labels — pair it with a filter. */
45
+ highlight?: string;
46
+ label?: string;
47
+ }
48
+
49
+ interface Flat {
50
+ node: TreeNode;
51
+ depth: number;
52
+ parent: string | null;
53
+ }
54
+
55
+ function leavesOf(node: TreeNode): string[] {
56
+ if (!node.children?.length) return node.disabled ? [] : [node.id];
57
+ return node.children.flatMap(leavesOf);
58
+ }
59
+
60
+ function Highlight({ text, query }: { text: string; query?: string }) {
61
+ const q = query?.trim();
62
+ const at = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
63
+ if (!q || at < 0) return <>{text}</>;
64
+ return (
65
+ <>
66
+ {text.slice(0, at)}
67
+ <mark className="os-tree-mark">{text.slice(at, at + q.length)}</mark>
68
+ {text.slice(at + q.length)}
69
+ </>
70
+ );
71
+ }
72
+
73
+ /** A nested hierarchy. Arrow keys move, Right/Left open and close, Space checks or selects. */
74
+ export function Tree({
75
+ nodes,
76
+ variant = "guides",
77
+ density = "regular",
78
+ expanded: expandedProp,
79
+ defaultExpanded = [],
80
+ onExpandedChange,
81
+ selectedId,
82
+ onSelect,
83
+ checked: checkedProp,
84
+ defaultChecked = [],
85
+ onCheckedChange,
86
+ highlight,
87
+ label = "Tree",
88
+ }: TreeProps) {
89
+ const [expandedList, setExpandedList] = useControllable(
90
+ expandedProp,
91
+ defaultExpanded,
92
+ onExpandedChange,
93
+ );
94
+ const [checkedList, setCheckedList] = useControllable(
95
+ checkedProp,
96
+ defaultChecked,
97
+ onCheckedChange,
98
+ );
99
+ const expanded = useMemo(() => new Set(expandedList), [expandedList]);
100
+ const checked = useMemo(() => new Set(checkedList), [checkedList]);
101
+ const [focusedId, setFocusedId] = useState<string | null>(null);
102
+ const rows = useRef(new Map<string, HTMLLIElement>());
103
+
104
+ const visible = useMemo(() => {
105
+ const out: Flat[] = [];
106
+ const walk = (list: TreeNode[], depth: number, parent: string | null) => {
107
+ for (const node of list) {
108
+ out.push({ node, depth, parent });
109
+ if (node.children?.length && expanded.has(node.id)) walk(node.children, depth + 1, node.id);
110
+ }
111
+ };
112
+ walk(nodes, 0, null);
113
+ return out;
114
+ }, [nodes, expanded]);
115
+
116
+ const tabStop = visible.some((row) => row.node.id === focusedId)
117
+ ? focusedId
118
+ : visible[0]?.node.id;
119
+
120
+ function focus(id: string | undefined) {
121
+ if (!id) return;
122
+ setFocusedId(id);
123
+ rows.current.get(id)?.focus();
124
+ }
125
+
126
+ function toggleOpen(id: string, open?: boolean) {
127
+ const willOpen = open ?? !expanded.has(id);
128
+ setExpandedList(willOpen ? [...expandedList, id] : expandedList.filter((x) => x !== id));
129
+ }
130
+
131
+ function checkState(node: TreeNode): TreeCheckState {
132
+ const leaves = leavesOf(node);
133
+ const on = leaves.filter((id) => checked.has(id)).length;
134
+ return on === 0 ? "unchecked" : on === leaves.length ? "checked" : "mixed";
135
+ }
136
+
137
+ function toggleCheck(node: TreeNode) {
138
+ const leaves = leavesOf(node);
139
+ const next = new Set(checked);
140
+ if (checkState(node) === "checked") leaves.forEach((id) => next.delete(id));
141
+ else leaves.forEach((id) => next.add(id));
142
+ setCheckedList([...next]);
143
+ }
144
+
145
+ function activate(node: TreeNode) {
146
+ if (node.disabled) return;
147
+ setFocusedId(node.id);
148
+ if (variant === "checkbox") toggleCheck(node);
149
+ else {
150
+ onSelect?.(node.id);
151
+ if (node.children?.length && variant !== "selection") toggleOpen(node.id);
152
+ }
153
+ }
154
+
155
+ function onKeyDown(event: KeyboardEvent<HTMLLIElement>, row: Flat, index: number) {
156
+ if (event.target !== event.currentTarget) return;
157
+ const { node } = row;
158
+ const hasChildren = Boolean(node.children?.length);
159
+ const open = expanded.has(node.id);
160
+ const moves: Record<string, () => void> = {
161
+ ArrowDown: () => focus(visible[index + 1]?.node.id),
162
+ ArrowUp: () => focus(visible[index - 1]?.node.id),
163
+ Home: () => focus(visible[0]?.node.id),
164
+ End: () => focus(visible[visible.length - 1]?.node.id),
165
+ ArrowRight: () =>
166
+ hasChildren && !open
167
+ ? toggleOpen(node.id, true)
168
+ : hasChildren && focus(node.children![0].id),
169
+ ArrowLeft: () =>
170
+ hasChildren && open ? toggleOpen(node.id, false) : focus(row.parent ?? undefined),
171
+ Enter: () => activate(node),
172
+ " ": () => (variant === "checkbox" ? !node.disabled && toggleCheck(node) : activate(node)),
173
+ };
174
+ const move = moves[event.key];
175
+ if (!move) return;
176
+ event.preventDefault();
177
+ move();
178
+ }
179
+
180
+ function renderBranch(list: TreeNode[], depth: number): ReactNode {
181
+ return list.map((node) => {
182
+ const index = visible.findIndex((row) => row.node.id === node.id);
183
+ const row = visible[index];
184
+ const hasChildren = Boolean(node.children?.length);
185
+ const open = hasChildren && expanded.has(node.id);
186
+ const selected = selectedId === node.id;
187
+ const state = variant === "checkbox" ? checkState(node) : null;
188
+ const icon =
189
+ node.leading ??
190
+ (variant === "explorer" ? (
191
+ <Glyph name={hasChildren ? (open ? "folderOpen" : "folder") : "file"} />
192
+ ) : null);
193
+
194
+ return (
195
+ <li
196
+ key={node.id}
197
+ ref={(el) => {
198
+ if (el) rows.current.set(node.id, el);
199
+ else rows.current.delete(node.id);
200
+ }}
201
+ role="treeitem"
202
+ className={["os-tree-item", depth === 0 && "os-tree-root"].filter(Boolean).join(" ")}
203
+ aria-level={depth + 1}
204
+ aria-expanded={hasChildren ? open : undefined}
205
+ aria-selected={variant === "selection" ? selected : undefined}
206
+ aria-checked={state ? (state === "mixed" ? "mixed" : state === "checked") : undefined}
207
+ aria-disabled={node.disabled || undefined}
208
+ tabIndex={tabStop === node.id ? 0 : -1}
209
+ onFocus={(event) => event.target === event.currentTarget && setFocusedId(node.id)}
210
+ onKeyDown={(event) => onKeyDown(event, row, index)}
211
+ >
212
+ <div
213
+ className={[
214
+ "os-tree-row",
215
+ selected && "os-tree-row-selected",
216
+ node.disabled && "os-tree-row-disabled",
217
+ ]
218
+ .filter(Boolean)
219
+ .join(" ")}
220
+ style={{ ["--os-tree-depth" as string]: depth }}
221
+ onClick={() => activate(node)}
222
+ >
223
+ {Array.from({ length: depth }, (_, i) => (
224
+ <span key={i} className="os-tree-indent" aria-hidden />
225
+ ))}
226
+ <span
227
+ className={hasChildren ? "os-tree-chevron" : "os-tree-chevron os-tree-chevron-empty"}
228
+ aria-hidden
229
+ onClick={(event) => {
230
+ if (!hasChildren || node.disabled) return;
231
+ event.stopPropagation();
232
+ setFocusedId(node.id);
233
+ toggleOpen(node.id);
234
+ }}
235
+ >
236
+ {hasChildren && <Glyph name="chevronRight" />}
237
+ </span>
238
+ {state && (
239
+ <span className="os-tree-check" data-state={state} aria-hidden>
240
+ {state === "checked" && <Glyph name="check" />}
241
+ {state === "mixed" && <Glyph name="minus" />}
242
+ </span>
243
+ )}
244
+ {icon && (
245
+ <span
246
+ className={
247
+ variant === "explorer" && !node.leading
248
+ ? "os-tree-lead os-tree-icon"
249
+ : "os-tree-lead"
250
+ }
251
+ >
252
+ {icon}
253
+ </span>
254
+ )}
255
+ <span className="os-tree-text">
256
+ <span className="os-tree-label">
257
+ <Highlight text={node.label} query={highlight} />
258
+ </span>
259
+ {node.description && <span className="os-tree-desc">{node.description}</span>}
260
+ </span>
261
+ {node.meta != null && <span className="os-tree-meta">{node.meta}</span>}
262
+ </div>
263
+ {open && (
264
+ <ul role="group" className="os-tree-group">
265
+ {renderBranch(node.children!, depth + 1)}
266
+ </ul>
267
+ )}
268
+ </li>
269
+ );
270
+ });
271
+ }
272
+
273
+ return (
274
+ <ul
275
+ role="tree"
276
+ aria-label={label}
277
+ aria-multiselectable={variant === "checkbox" || undefined}
278
+ className={`os-tree os-tree-${variant} os-tree-${density}`}
279
+ >
280
+ {renderBranch(nodes, 0)}
281
+ </ul>
282
+ );
283
+ }
@@ -0,0 +1,211 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
4
+
5
+ import { areaPath, linePath, linearScale, nearestIndex, niceTicks } from "./chartMath";
6
+ import {
7
+ ChartLegend,
8
+ ChartTable,
9
+ ChartTip,
10
+ defaultFormat,
11
+ toneAt,
12
+ type ChartTone,
13
+ } from "./chartParts";
14
+ import { useElementWidth } from "./Responsive";
15
+
16
+ export type TrendSeries = {
17
+ label: string;
18
+ values: number[];
19
+ tone?: ChartTone;
20
+ };
21
+
22
+ const MARGIN = { top: 12, right: 12, bottom: 28, left: 36 };
23
+ const DEFAULT_HEIGHT = 240;
24
+ const Y_TICKS = 4;
25
+ /** Room each x label needs before labels start skipping. */
26
+ const X_LABEL_ROOM = 64;
27
+ const TIP_GAP = 12;
28
+
29
+ /**
30
+ * Change over time for up to four series on one axis. Lines are 2px with a 10% wash in
31
+ * `area`; a crosshair snaps to the nearest point and reads out every series at once.
32
+ * Arrow keys move the readout for keyboard users; a hidden table carries every value.
33
+ */
34
+ export function TrendChart({
35
+ labels,
36
+ series,
37
+ label,
38
+ variant = "area",
39
+ height = DEFAULT_HEIGHT,
40
+ formatValue = defaultFormat,
41
+ hasLegend,
42
+ }: {
43
+ /** One per point, e.g. week starts. */
44
+ labels: string[];
45
+ series: TrendSeries[];
46
+ /** Accessible name for the chart. */
47
+ label: string;
48
+ variant?: "area" | "line";
49
+ height?: number;
50
+ formatValue?: (value: number) => string;
51
+ /** Defaults to on for two or more series; one series is named by its card title. */
52
+ hasLegend?: boolean;
53
+ }) {
54
+ const frame = useRef<HTMLDivElement>(null);
55
+ const width = useElementWidth(frame);
56
+ const [active, setActive] = useState<number | null>(null);
57
+
58
+ const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);
59
+ const plotH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
60
+ const max = Math.max(0, ...series.flatMap((item) => item.values));
61
+ const ticks = niceTicks(max, Y_TICKS);
62
+ const top = ticks[ticks.length - 1];
63
+ const count = labels.length;
64
+ const x = linearScale([0, Math.max(1, count - 1)], [MARGIN.left, MARGIN.left + plotW]);
65
+ const y = linearScale([0, top], [MARGIN.top + plotH, MARGIN.top]);
66
+ const xs = labels.map((_, index) => x(index));
67
+ const every = Math.max(1, Math.ceil(count / Math.max(1, Math.floor(plotW / X_LABEL_ROOM))));
68
+ const toned = series.map((item, index) => ({ ...item, tone: toneAt(index, item.tone) }));
69
+ const showLegend = hasLegend ?? series.length > 1;
70
+
71
+ const onMove = (event: PointerEvent<SVGRectElement>) => {
72
+ const box = event.currentTarget.ownerSVGElement?.getBoundingClientRect();
73
+ if (!box || count === 0) return;
74
+ setActive(nearestIndex(xs, event.clientX - box.left));
75
+ };
76
+
77
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
78
+ if (count === 0) return;
79
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
80
+ event.preventDefault();
81
+ const step = event.key === "ArrowRight" ? 1 : -1;
82
+ setActive((current) => Math.min(count - 1, Math.max(0, (current ?? count - 1) + step)));
83
+ } else if (event.key === "Escape") {
84
+ setActive(null);
85
+ }
86
+ };
87
+
88
+ const activeX = active === null ? 0 : xs[active];
89
+ const tipAlign = activeX < width / 3 ? "start" : activeX > (width * 2) / 3 ? "end" : "center";
90
+
91
+ return (
92
+ <div className="os-chart">
93
+ {showLegend ? (
94
+ <ChartLegend
95
+ mark="line"
96
+ items={toned.map((item) => ({ label: item.label, tone: item.tone }))}
97
+ />
98
+ ) : null}
99
+ <div
100
+ ref={frame}
101
+ className="os-chart-frame"
102
+ style={{ height }}
103
+ tabIndex={0}
104
+ role="group"
105
+ aria-label={`${label}. Use the arrow keys to read each point.`}
106
+ onKeyDown={onKey}
107
+ onBlur={() => setActive(null)}
108
+ >
109
+ {width > 0 ? (
110
+ <svg width={width} height={height} className="os-chart-svg" aria-hidden>
111
+ {ticks.map((tick) => (
112
+ <g key={tick}>
113
+ <line
114
+ className="os-chart-grid"
115
+ x1={MARGIN.left}
116
+ x2={MARGIN.left + plotW}
117
+ y1={y(tick)}
118
+ y2={y(tick)}
119
+ />
120
+ <text
121
+ className="os-chart-axis"
122
+ x={MARGIN.left - 8}
123
+ y={y(tick)}
124
+ dy="0.32em"
125
+ textAnchor="end"
126
+ >
127
+ {formatValue(tick)}
128
+ </text>
129
+ </g>
130
+ ))}
131
+ {labels.map((text, index) =>
132
+ index % every === 0 || index === count - 1 ? (
133
+ <text
134
+ key={`${text}-${index}`}
135
+ className="os-chart-axis"
136
+ x={xs[index]}
137
+ y={height - 8}
138
+ textAnchor={index === 0 ? "start" : index === count - 1 ? "end" : "middle"}
139
+ >
140
+ {index === count - 1 || count - 1 - index >= every ? text : ""}
141
+ </text>
142
+ ) : null,
143
+ )}
144
+ {toned.map((item) => {
145
+ const points = item.values.map((value, index) => ({ x: xs[index], y: y(value) }));
146
+ return (
147
+ <g key={item.label} data-tone={item.tone}>
148
+ {variant === "area" ? (
149
+ <path className="os-chart-area" d={areaPath(points, y(0))} />
150
+ ) : null}
151
+ <path className="os-chart-line" d={linePath(points)} />
152
+ </g>
153
+ );
154
+ })}
155
+ {active !== null ? (
156
+ <g>
157
+ <line
158
+ className="os-chart-crosshair"
159
+ x1={activeX}
160
+ x2={activeX}
161
+ y1={MARGIN.top}
162
+ y2={MARGIN.top + plotH}
163
+ />
164
+ {toned.map((item) => (
165
+ <circle
166
+ key={item.label}
167
+ className="os-chart-dot"
168
+ data-tone={item.tone}
169
+ cx={activeX}
170
+ cy={y(item.values[active] ?? 0)}
171
+ r={4}
172
+ />
173
+ ))}
174
+ </g>
175
+ ) : null}
176
+ <rect
177
+ className="os-chart-hit"
178
+ x={MARGIN.left}
179
+ y={MARGIN.top}
180
+ width={plotW}
181
+ height={plotH}
182
+ onPointerMove={onMove}
183
+ onPointerLeave={() => setActive(null)}
184
+ />
185
+ </svg>
186
+ ) : null}
187
+ {active !== null ? (
188
+ <ChartTip
189
+ title={labels[active]}
190
+ left={activeX + (tipAlign === "start" ? TIP_GAP : tipAlign === "end" ? -TIP_GAP : 0)}
191
+ top={MARGIN.top}
192
+ align={tipAlign}
193
+ rows={toned.map((item) => ({
194
+ label: item.label,
195
+ value: formatValue(item.values[active] ?? 0),
196
+ tone: item.tone,
197
+ }))}
198
+ />
199
+ ) : null}
200
+ </div>
201
+ <ChartTable
202
+ caption={label}
203
+ headers={["", ...series.map((item) => item.label)]}
204
+ rows={labels.map((text, index) => [
205
+ text,
206
+ ...series.map((item) => formatValue(item.values[index] ?? 0)),
207
+ ])}
208
+ />
209
+ </div>
210
+ );
211
+ }
@@ -0,0 +1,42 @@
1
+ import { DEFAULT_WIDTH_BREAKPOINTS } from "@astryxdesign/core/theme";
2
+
3
+ /** Responsive tiers, smallest first. `base` is everything below `sm`. */
4
+ export const TIERS = ["sm", "md", "lg", "xl"] as const;
5
+ export type Tier = (typeof TIERS)[number];
6
+ export type TierOrBase = "base" | Tier;
7
+
8
+ /** How a responsive component measures width. */
9
+ export type ResponsiveTo = "container" | "viewport";
10
+
11
+ /** Viewport tiers are Astryx's own width breakpoints. */
12
+ export const VIEWPORT_TIERS: Record<Tier, number> = {
13
+ sm: DEFAULT_WIDTH_BREAKPOINTS.sm,
14
+ md: DEFAULT_WIDTH_BREAKPOINTS.md,
15
+ lg: DEFAULT_WIDTH_BREAKPOINTS.lg,
16
+ xl: DEFAULT_WIDTH_BREAKPOINTS.xl,
17
+ };
18
+
19
+ /**
20
+ * Container tiers are smaller: a card or a sidebar never gets as wide as a
21
+ * screen. Keep in sync with the @container rules in styles/components/responsive.css.
22
+ */
23
+ export const CONTAINER_TIERS: Record<Tier, number> = {
24
+ sm: 480,
25
+ md: 640,
26
+ lg: 800,
27
+ xl: 1024,
28
+ };
29
+
30
+ export function tierFor(width: number, to: ResponsiveTo = "container"): TierOrBase {
31
+ const table = to === "container" ? CONTAINER_TIERS : VIEWPORT_TIERS;
32
+ let current: TierOrBase = "base";
33
+ for (const tier of TIERS) if (width >= table[tier]) current = tier;
34
+ return current;
35
+ }
36
+
37
+ /** Astryx spacing steps map to `--spacing-*` tokens; 1.5 becomes `--spacing-1-5`. */
38
+ export type SpacingStep = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12;
39
+
40
+ export function spacing(step: SpacingStep) {
41
+ return `var(--spacing-${String(step).replace(".", "-")})`;
42
+ }
@@ -0,0 +1,26 @@
1
+ export type CalendarView = "month" | "week" | "day" | "agenda";
2
+ export type CalendarTone = "accent" | "success" | "warning" | "danger" | "neutral";
3
+
4
+ export interface CalendarEvent {
5
+ id: string;
6
+ date: Date;
7
+ title: string;
8
+ /** "HH:mm". Events without a start are all-day. */
9
+ start?: string;
10
+ end?: string;
11
+ tone?: CalendarTone;
12
+ location?: string;
13
+ }
14
+
15
+ export interface DateRange {
16
+ start: Date;
17
+ /** null while the second date is still being picked. */
18
+ end: Date | null;
19
+ }
20
+
21
+ export const VIEW_LABELS: Record<CalendarView, string> = {
22
+ month: "Month",
23
+ week: "Week",
24
+ day: "Day",
25
+ agenda: "Agenda",
26
+ };
@@ -0,0 +1,125 @@
1
+ /** Pure maths for the charts, kept apart from React so it is unit-testable. */
2
+
3
+ /** Each value's share of the total, in percent. All-zero input yields all zeros, never NaN. */
4
+ export function segmentShares(values: number[]): number[] {
5
+ const total = values.reduce((sum, value) => sum + Math.max(0, value), 0);
6
+ if (total === 0) return values.map(() => 0);
7
+ return values.map((value) => (Math.max(0, value) / total) * 100);
8
+ }
9
+
10
+ const NICE_STEPS = [1, 2, 2.5, 5, 10];
11
+
12
+ /**
13
+ * Clean axis ticks from zero up to at least `max`: 0, 5, 10, 15 — never 0, 3.7, 7.4.
14
+ * Returns `[0, 1]` when there is nothing to plot so an empty chart still has a frame.
15
+ */
16
+ export function niceTicks(max: number, count = 4): number[] {
17
+ if (!(max > 0) || count < 1) return [0, 1];
18
+ const raw = max / count;
19
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
20
+ const step = (NICE_STEPS.find((nice) => nice * magnitude >= raw) ?? 10) * magnitude;
21
+ const top = Math.ceil(max / step) * step;
22
+ const ticks: number[] = [];
23
+ for (let tick = 0; tick <= top + step / 2; tick += step) ticks.push(round(tick));
24
+ return ticks;
25
+ }
26
+
27
+ function round(value: number) {
28
+ return Math.round(value * 1e6) / 1e6;
29
+ }
30
+
31
+ /** Maps a domain onto a pixel range. A flat domain maps everything to the range start. */
32
+ export function linearScale(domain: [number, number], range: [number, number]) {
33
+ const [d0, d1] = domain;
34
+ const [r0, r1] = range;
35
+ const span = d1 - d0;
36
+ return (value: number) => (span === 0 ? r0 : r0 + ((value - d0) / span) * (r1 - r0));
37
+ }
38
+
39
+ export type Point = { x: number; y: number };
40
+
41
+ /** An SVG path through the points with straight segments. */
42
+ export function linePath(points: Point[]): string {
43
+ return points
44
+ .map((point, index) => `${index === 0 ? "M" : "L"}${fmt(point.x)},${fmt(point.y)}`)
45
+ .join("");
46
+ }
47
+
48
+ /** The line closed down to a baseline, for the area wash under it. */
49
+ export function areaPath(points: Point[], baseline: number): string {
50
+ if (points.length === 0) return "";
51
+ const first = points[0];
52
+ const last = points[points.length - 1];
53
+ return `${linePath(points)}L${fmt(last.x)},${fmt(baseline)}L${fmt(first.x)},${fmt(baseline)}Z`;
54
+ }
55
+
56
+ /**
57
+ * One ring segment from `start` to `end` (fractions of a turn, 0 at twelve o'clock),
58
+ * between `inner` and `outer` radii around (cx, cy).
59
+ */
60
+ export function arcPath(
61
+ cx: number,
62
+ cy: number,
63
+ inner: number,
64
+ outer: number,
65
+ start: number,
66
+ end: number,
67
+ ): string {
68
+ const sweep = Math.min(end - start, 0.9999);
69
+ if (sweep <= 0) return "";
70
+ const a0 = start * 2 * Math.PI - Math.PI / 2;
71
+ const a1 = (start + sweep) * 2 * Math.PI - Math.PI / 2;
72
+ const large = sweep > 0.5 ? 1 : 0;
73
+ const p = (radius: number, angle: number) =>
74
+ `${fmt(cx + radius * Math.cos(angle))},${fmt(cy + radius * Math.sin(angle))}`;
75
+ return [
76
+ `M${p(outer, a0)}`,
77
+ `A${fmt(outer)},${fmt(outer)} 0 ${large} 1 ${p(outer, a1)}`,
78
+ `L${p(inner, a1)}`,
79
+ `A${fmt(inner)},${fmt(inner)} 0 ${large} 0 ${p(inner, a0)}`,
80
+ "Z",
81
+ ].join("");
82
+ }
83
+
84
+ function fmt(value: number) {
85
+ return String(Math.round(value * 100) / 100);
86
+ }
87
+
88
+ /**
89
+ * Buckets a value into one of `steps` intensity levels for a heatmap: 0 means none,
90
+ * 1…steps scale with the value against `max`. Any positive value is at least level 1.
91
+ */
92
+ export function heatLevel(value: number, max: number, steps = 4): number {
93
+ if (!(value > 0) || !(max > 0)) return 0;
94
+ return Math.min(steps, Math.max(1, Math.ceil((value / max) * steps)));
95
+ }
96
+
97
+ /** Index of the item whose x is nearest to `x` — the crosshair's snap. */
98
+ export function nearestIndex(xs: number[], x: number): number {
99
+ let best = 0;
100
+ let distance = Infinity;
101
+ xs.forEach((value, index) => {
102
+ const next = Math.abs(value - x);
103
+ if (next < distance) {
104
+ distance = next;
105
+ best = index;
106
+ }
107
+ });
108
+ return best;
109
+ }
110
+
111
+ /** Step-to-step conversion in percent; the first step is always 100. */
112
+ export function conversionRates(values: number[]): number[] {
113
+ return values.map((value, index) => {
114
+ if (index === 0) return 100;
115
+ const previous = values[index - 1];
116
+ return previous > 0 ? Math.round((value / previous) * 100) : 0;
117
+ });
118
+ }
119
+
120
+ /** Compact figures for axes and tiles: 950, 1.2K, 12K, 3.4M. */
121
+ export function compactNumber(value: number): string {
122
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(
123
+ value,
124
+ );
125
+ }