@ultimat3/ui 29.3.2 → 30.0.0

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 (168) hide show
  1. package/CATALOG.md +491 -24
  2. package/CLAUDE.md +23 -16
  3. package/README.md +338 -33
  4. package/package.json +16 -5
  5. package/src/components/AppShell.module.scss +23 -7
  6. package/src/components/AppShell.tsx +9 -0
  7. package/src/components/BarChart.module.scss +26 -0
  8. package/src/components/BarChart.tsx +242 -93
  9. package/src/components/Board.module.scss +30 -0
  10. package/src/components/Board.tsx +47 -0
  11. package/src/components/BottomNav.module.scss +49 -0
  12. package/src/components/BottomNav.tsx +57 -0
  13. package/src/components/Calendar.module.scss +118 -0
  14. package/src/components/Calendar.tsx +246 -0
  15. package/src/components/ChartFrame.module.scss +40 -3
  16. package/src/components/ChartFrame.tsx +84 -28
  17. package/src/components/CodeBlock.module.scss +101 -0
  18. package/src/components/CodeBlock.tsx +76 -0
  19. package/src/components/CommandPalette.module.scss +25 -0
  20. package/src/components/CommandPalette.tsx +50 -22
  21. package/src/components/ConfirmDialog.module.scss +36 -0
  22. package/src/components/ConfirmDialog.tsx +188 -0
  23. package/src/components/DataTable.tsx +92 -33
  24. package/src/components/DatePicker.module.scss +37 -0
  25. package/src/components/DatePicker.tsx +134 -0
  26. package/src/components/DateRangePicker.module.scss +54 -0
  27. package/src/components/DateRangePicker.tsx +156 -0
  28. package/src/components/DescriptionList.module.scss +46 -0
  29. package/src/components/DescriptionList.tsx +38 -0
  30. package/src/components/DiffView.module.scss +124 -0
  31. package/src/components/DiffView.tsx +169 -0
  32. package/src/components/DonutChart.tsx +13 -4
  33. package/src/components/Drawer.module.scss +40 -0
  34. package/src/components/Drawer.tsx +56 -0
  35. package/src/components/Gauge.module.scss +8 -0
  36. package/src/components/Gauge.tsx +40 -13
  37. package/src/components/HeatStrip.module.scss +133 -0
  38. package/src/components/HeatStrip.tsx +64 -0
  39. package/src/components/JsonView.module.scss +64 -0
  40. package/src/components/JsonView.tsx +68 -0
  41. package/src/components/LineChart.tsx +45 -18
  42. package/src/components/LiveIndicator.module.scss +44 -0
  43. package/src/components/LiveIndicator.tsx +45 -0
  44. package/src/components/LogViewer.module.scss +102 -0
  45. package/src/components/LogViewer.tsx +123 -0
  46. package/src/components/Markdown.module.scss +87 -0
  47. package/src/components/Markdown.tsx +84 -0
  48. package/src/components/Meter.module.scss +7 -0
  49. package/src/components/Meter.tsx +16 -0
  50. package/src/components/NavList.module.scss +66 -0
  51. package/src/components/NavList.tsx +95 -0
  52. package/src/components/Pagination.module.scss +6 -2
  53. package/src/components/Pagination.tsx +57 -13
  54. package/src/components/Popover.tsx +12 -2
  55. package/src/components/PullToRefresh.module.scss +41 -0
  56. package/src/components/PullToRefresh.tsx +108 -0
  57. package/src/components/Radio.module.scss +51 -0
  58. package/src/components/Radio.tsx +12 -1
  59. package/src/components/SearchableSelect.module.scss +216 -0
  60. package/src/components/SearchableSelect.tsx +331 -0
  61. package/src/components/SearchableSelectChips.tsx +41 -0
  62. package/src/components/SearchableSelectNative.tsx +89 -0
  63. package/src/components/ShortcutSheet.module.scss +33 -0
  64. package/src/components/ShortcutSheet.tsx +67 -0
  65. package/src/components/Slider.module.scss +50 -0
  66. package/src/components/Slider.tsx +68 -0
  67. package/src/components/SortableList.module.scss +72 -0
  68. package/src/components/SortableList.tsx +42 -0
  69. package/src/components/Sparkline.module.scss +10 -2
  70. package/src/components/Sparkline.tsx +27 -10
  71. package/src/components/SplitPane.module.scss +39 -0
  72. package/src/components/SplitPane.tsx +96 -0
  73. package/src/components/StatTile.module.scss +29 -3
  74. package/src/components/StatTile.tsx +75 -16
  75. package/src/components/Stepper.module.scss +78 -0
  76. package/src/components/Stepper.tsx +73 -0
  77. package/src/components/Tabs.module.scss +13 -1
  78. package/src/components/Tabs.tsx +77 -1
  79. package/src/components/TerminalView.module.scss +68 -0
  80. package/src/components/TerminalView.tsx +97 -0
  81. package/src/components/TimeRangePicker.module.scss +85 -0
  82. package/src/components/TimeRangePicker.tsx +91 -0
  83. package/src/components/Timeline.module.scss +74 -0
  84. package/src/components/Timeline.tsx +60 -0
  85. package/src/components/Tree.module.scss +98 -0
  86. package/src/components/Tree.tsx +185 -0
  87. package/src/components/VirtualList.module.scss +60 -0
  88. package/src/components/VirtualList.tsx +251 -0
  89. package/src/components/app-shell-view.ts +4 -1
  90. package/src/components/bar-chart-view.ts +121 -39
  91. package/src/components/board-view.ts +25 -0
  92. package/src/components/bottom-nav-view.ts +18 -0
  93. package/src/components/calendar-view.ts +226 -0
  94. package/src/components/chart-checks.ts +59 -0
  95. package/src/components/chart-frame-view.ts +33 -2
  96. package/src/components/chart-reference-view.ts +41 -0
  97. package/src/components/command-palette-view.ts +47 -6
  98. package/src/components/confirm-dialog-view.ts +47 -0
  99. package/src/components/data-table-rows.ts +93 -0
  100. package/src/components/date-picker-view.ts +38 -0
  101. package/src/components/date-range-view.ts +46 -0
  102. package/src/components/diff-view-view.ts +85 -0
  103. package/src/components/drawer-gesture-view.ts +33 -0
  104. package/src/components/gauge-view.ts +29 -0
  105. package/src/components/heat-strip-view.ts +68 -0
  106. package/src/components/json-view-view.ts +54 -0
  107. package/src/components/line-chart-view.ts +87 -25
  108. package/src/components/log-viewer-view.ts +51 -0
  109. package/src/components/markdown-nodes.tsx +150 -0
  110. package/src/components/nav-list-view.ts +37 -0
  111. package/src/components/pull-refresh-view.ts +93 -0
  112. package/src/components/rank-options.ts +61 -0
  113. package/src/components/searchable-select-keys.ts +118 -0
  114. package/src/components/searchable-select-view.ts +187 -0
  115. package/src/components/slider-view.ts +47 -0
  116. package/src/components/sort-pointer.ts +141 -0
  117. package/src/components/sort-surface.tsx +231 -0
  118. package/src/components/sparkline-view.ts +74 -14
  119. package/src/components/split-pane-view.ts +69 -0
  120. package/src/components/stat-delta.ts +54 -11
  121. package/src/components/stepper-view.ts +38 -0
  122. package/src/components/thunk.ts +9 -0
  123. package/src/components/timeline-view.ts +29 -0
  124. package/src/components/tree-node.tsx +133 -0
  125. package/src/components/tree-view.ts +185 -0
  126. package/src/components/use-command-palette.ts +153 -0
  127. package/src/components/virtual-list-anchor.ts +35 -0
  128. package/src/components/virtual-list-observer.ts +58 -0
  129. package/src/components/virtual-list-view.ts +145 -0
  130. package/src/content/ansi.ts +144 -0
  131. package/src/content/heading-slug.ts +32 -0
  132. package/src/content/highlight.ts +129 -0
  133. package/src/content/languages/all.ts +21 -0
  134. package/src/content/languages/diff.ts +14 -0
  135. package/src/content/languages/html.ts +16 -0
  136. package/src/content/languages/json.ts +15 -0
  137. package/src/content/languages/shell.ts +20 -0
  138. package/src/content/languages/sql.ts +27 -0
  139. package/src/content/languages/ts.ts +26 -0
  140. package/src/content/languages/yaml.ts +18 -0
  141. package/src/content/line-diff.ts +191 -0
  142. package/src/content/markdown-inline.ts +368 -0
  143. package/src/content/markdown-parse.ts +267 -0
  144. package/src/content/markdown-tree.ts +58 -0
  145. package/src/content/unified-patch.ts +53 -0
  146. package/src/drag/pointer-drag.ts +185 -0
  147. package/src/drag/sort-controller.ts +196 -0
  148. package/src/drag/sort-view.ts +201 -0
  149. package/src/errors.ts +1 -1
  150. package/src/form/field-path.ts +7 -33
  151. package/src/form/form-binding.ts +5 -8
  152. package/src/form/form-issue.ts +30 -36
  153. package/src/form/form-values.ts +3 -2
  154. package/src/hotkeys-view.ts +150 -0
  155. package/src/i18n-keys.ts +7 -50
  156. package/src/index.ts +214 -15
  157. package/src/list-url.ts +123 -0
  158. package/src/live-window.ts +23 -0
  159. package/src/roving.ts +2 -0
  160. package/src/theme/inert-runtime.ts +4 -0
  161. package/src/theme/runtime-slot.ts +2 -2
  162. package/src/theme/solid-adapter.ts +11 -5
  163. package/src/time-range-url.ts +200 -0
  164. package/src/tokens/_mixins.scss +1 -1
  165. package/src/tokens/_touch.scss +30 -1
  166. package/src/tokens/contrast-pairs.ts +12 -0
  167. package/src/ui-keys.ts +126 -0
  168. package/src/use-hotkeys.ts +145 -0
@@ -1,15 +1,20 @@
1
1
  // The geometry of a BarChart, apart from its markup: where each bar sits, how tall it is, where
2
- // the grid lines fall. Pure, so the server render and a hydrated one cannot draw two charts.
2
+ // zero and the grid lines fall. Signed: a negative bar hangs below a real zero line, `null` is no
3
+ // bar at all, and zero is a 2px stub on the line — three different marks for three facts.
4
+
5
+ import type { ChartSeries } from './chart-frame-view';
6
+ import { finiteOrNull } from './chart-frame-view';
3
7
 
4
8
  export interface ChartPoint {
5
9
  /** The bar's name — a day, a bucket, a label. Read by `<title>` and the axis. */
6
10
  readonly key: string;
7
- readonly value: number;
11
+ /** `null` is unknown: no bar, and the table says so. Never zero-fill a gap. */
12
+ readonly value: number | null;
8
13
  /**
9
14
  * A second series, stacked on `value` — failed on top of done. Absent or 0 draws nothing, so a
10
15
  * one-series chart is exactly the chart it was.
11
16
  */
12
- readonly secondary?: number;
17
+ readonly secondary?: number | undefined;
13
18
  }
14
19
 
15
20
  export interface BarRect {
@@ -30,57 +35,134 @@ export const BAR_CHART = { width: 600, top: 2, plot: 104, height: 108, gap: 3 }
30
35
  /** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
31
36
  export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
32
37
 
33
- const finite = (value: number | undefined): number =>
34
- value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0;
38
+ /** The shortest mark a known value draws: a zero is still visibly there, unlike a gap. */
39
+ const STUB = 2;
40
+
41
+ /** The value range the plot spans: always through zero, never empty, so it always divides. */
42
+ export interface BarExtent {
43
+ readonly min: number;
44
+ readonly max: number;
45
+ }
46
+
47
+ const positive = (value: number | null | undefined): number =>
48
+ Math.max(0, finiteOrNull(value) ?? 0);
49
+
50
+ /** Folds `also` (reference values) into a range, keeping it non-empty. */
51
+ function widened(min: number, max: number, also: readonly number[]): BarExtent {
52
+ const lo = Math.min(min, ...also);
53
+ const hi = Math.max(max, ...also);
54
+ return hi === lo ? { min: lo, max: lo + 1 } : { min: lo, max: hi };
55
+ }
35
56
 
36
- /** The busiest point — its whole stack — and never below 1, so an all-zero series still divides. */
37
- export function maxOf(points: readonly ChartPoint[]): number {
38
- let max = 1;
57
+ /**
58
+ * The lowest and highest point — a stack's whole height — always including zero, and any
59
+ * reference value in `also`, so a target above every bar is still on the plot.
60
+ */
61
+ export function barExtent(points: readonly ChartPoint[], also: readonly number[] = []): BarExtent {
62
+ let min = 0;
63
+ let max = 0;
39
64
  for (const point of points) {
40
- const total = finite(point.value) + finite(point.secondary);
41
- if (total > max) max = total;
65
+ const value = finiteOrNull(point.value);
66
+ if (value !== null && value < 0) min = Math.min(min, value);
67
+ max = Math.max(max, positive(value) + positive(point.secondary));
68
+ }
69
+ return widened(min, max, also);
70
+ }
71
+
72
+ /** The same over an n-series chart's every value. */
73
+ export function seriesExtent(
74
+ series: readonly ChartSeries[],
75
+ also: readonly number[] = [],
76
+ ): BarExtent {
77
+ let min = 0;
78
+ let max = 0;
79
+ for (const one of series) {
80
+ for (const raw of one.values) {
81
+ const value = finiteOrNull(raw);
82
+ if (value === null) continue;
83
+ min = Math.min(min, value);
84
+ max = Math.max(max, value);
85
+ }
42
86
  }
43
- return max;
87
+ return widened(min, max, also);
44
88
  }
45
89
 
46
- /** The y of a grid line at `step` of the plot height (1 = the top line = the busiest point). */
90
+ /** The y of `value` in the plot. */
91
+ export function valueY(value: number, extent: BarExtent): number {
92
+ return BAR_CHART.top + ((extent.max - value) / (extent.max - extent.min)) * BAR_CHART.plot;
93
+ }
94
+
95
+ /** The y of the zero line — the plot's floor for an all-positive chart. */
96
+ export function zeroY(extent: BarExtent): number {
97
+ return valueY(0, extent);
98
+ }
99
+
100
+ /** The y of a grid line at `step` of the plot height (1 = the top line). */
47
101
  export function gridY(step: number): number {
48
102
  return BAR_CHART.top + BAR_CHART.plot - step * BAR_CHART.plot;
49
103
  }
50
104
 
51
- /** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
52
- export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
53
- const count = Math.max(points.length, 1);
54
- // The gap gives way before the bars do: at most half of each bar's slot, so past the 201 points
55
- // where 3px gaps alone fill the width, every bar is still a positive rect and the row still ends
56
- // at the right edge.
57
- const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / count / 2);
58
- const width = (BAR_CHART.width - gap * (count - 1)) / count;
59
- const max = maxOf(points);
105
+ /** `count` slots across the width: the gap gives way before the bars do, at most half a slot. */
106
+ function slots(count: number): { readonly width: number; readonly gap: number } {
107
+ const n = Math.max(count, 1);
108
+ const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / n / 2);
109
+ return { width: (BAR_CHART.width - gap * (n - 1)) / n, gap };
110
+ }
111
+
112
+ /** A bar from zero to `value` at `x`: up for a positive, down for a negative, a stub for zero. */
113
+ function barAt(x: number, width: number, value: number, extent: BarExtent): BarRect {
114
+ const zero = zeroY(extent);
115
+ const height = Math.max(STUB, Math.abs(valueY(value, extent) - zero));
116
+ return { x, y: value < 0 ? zero : zero - height, width, height };
117
+ }
118
+
119
+ /** One rect per point, oldest first; `null` where the value is unknown. */
120
+ export function barRects(
121
+ points: readonly ChartPoint[],
122
+ extent: BarExtent = barExtent(points),
123
+ ): readonly (BarRect | null)[] {
124
+ const { width, gap } = slots(points.length);
60
125
  return points.map((point, index) => {
61
- const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
62
- const height = Math.max(2, (value / max) * BAR_CHART.plot);
63
- return {
64
- x: index * (width + gap),
65
- y: BAR_CHART.top + BAR_CHART.plot - height,
66
- width,
67
- height,
68
- };
126
+ const value = finiteOrNull(point.value);
127
+ return value === null ? null : barAt(index * (width + gap), width, value, extent);
69
128
  });
70
129
  }
71
130
 
72
131
  /**
73
- * The second series' rect per point, stacked on that point's primary bar; `null` where it is 0.
74
- * Same scale as `barRects`, so a stack is read against the same grid lines.
132
+ * The second series' rect per point, stacked on the positive part of that point's bar (or on the
133
+ * zero line); `null` where it is 0. Same scale as `barRects`, read against the same grid.
75
134
  */
76
- export function secondaryRects(points: readonly ChartPoint[]): readonly (BarRect | null)[] {
77
- const primary = barRects(points);
78
- const max = maxOf(points);
135
+ export function secondaryRects(
136
+ points: readonly ChartPoint[],
137
+ extent: BarExtent = barExtent(points),
138
+ ): readonly (BarRect | null)[] {
139
+ const { width, gap } = slots(points.length);
79
140
  return points.map((point, index) => {
80
- const value = finite(point.secondary);
81
- const below = primary[index];
82
- if (value === 0 || below === undefined) return null;
83
- const height = (value / max) * BAR_CHART.plot;
84
- return { x: below.x, y: below.y - height, width: below.width, height };
141
+ const value = positive(point.secondary);
142
+ if (value === 0) return null;
143
+ const base = valueY(positive(point.value), extent);
144
+ const top = valueY(positive(point.value) + value, extent);
145
+ return { x: index * (width + gap), y: top, width, height: base - top };
85
146
  });
86
147
  }
148
+
149
+ /**
150
+ * n series over shared keys, side by side within each key's slot — every bar signed on its own,
151
+ * so a negative in one series never hides another. `[series][key]`, `null` for a gap.
152
+ */
153
+ export function groupedBarRects(
154
+ keyCount: number,
155
+ series: readonly ChartSeries[],
156
+ extent: BarExtent = seriesExtent(series),
157
+ ): readonly (readonly (BarRect | null)[])[] {
158
+ const slot = slots(keyCount);
159
+ const inner = Math.min(1, slot.width / Math.max(series.length, 1) / 4);
160
+ const width = (slot.width - inner * (series.length - 1)) / Math.max(series.length, 1);
161
+ return series.map((one, s) =>
162
+ Array.from({ length: keyCount }, (_, k) => {
163
+ const value = finiteOrNull(one.values[k]);
164
+ const x = k * (slot.width + slot.gap) + s * (width + inner);
165
+ return value === null ? null : barAt(x, width, value, extent);
166
+ }),
167
+ );
168
+ }
@@ -0,0 +1,25 @@
1
+ // A board's columns, checked: column ids unique (a move names one) and card keys unique across
2
+ // every column (a card is one node). Refused as X_UI_INVALID_VALUE naming the id.
3
+
4
+ import { type SortGroup, sortPositions } from '../drag/sort-view';
5
+ import { invalidValueError } from '../errors';
6
+
7
+ export interface BoardGroupInput<T> {
8
+ readonly id: string;
9
+ readonly label: string;
10
+ readonly items: readonly T[];
11
+ }
12
+
13
+ export function boardGroups<T, G extends BoardGroupInput<T>>(
14
+ groups: readonly G[],
15
+ itemKey: (item: T) => string,
16
+ ): readonly G[] {
17
+ const seen = new Set<string>();
18
+ for (const group of groups) {
19
+ if (seen.has(group.id))
20
+ throw invalidValueError('Board', group.id, 'a column id unique on the board');
21
+ seen.add(group.id);
22
+ }
23
+ sortPositions(groups as readonly SortGroup<T>[], itemKey);
24
+ return groups;
25
+ }
@@ -0,0 +1,18 @@
1
+ // The bottom bar's one rule beyond `currentOf`: five destinations at most. A sixth no longer fits
2
+ // a 320px phone at a touch target each, and the thumb cannot reach a scroll it cannot see.
3
+
4
+ import { invalidValueError } from '../errors';
5
+
6
+ export const BOTTOM_NAV_MAX = 5;
7
+
8
+ /** `items` unchanged, or `X_UI_INVALID_VALUE` naming the count when there are more than five. */
9
+ export function bottomNavItems<T>(items: readonly T[]): readonly T[] {
10
+ if (items.length > BOTTOM_NAV_MAX) {
11
+ throw invalidValueError(
12
+ 'BottomNav',
13
+ `${items.length} items`,
14
+ `at most ${BOTTOM_NAV_MAX} destinations — move the rest into AppShell's sidebar (NavList)`,
15
+ );
16
+ }
17
+ return items;
18
+ }
@@ -0,0 +1,226 @@
1
+ // A month as a grid of `PlainDate`s, and every rule a calendar keys on: week start from the
2
+ // locale, 6 rows always so the box never jumps, the arrow step (flipped by `dir`, skipping what is
3
+ // disabled), and the one day holding the tab stop. Pure, over `@ultimat3/time`'s plain dates.
4
+
5
+ import { cachedFormatter } from '@ultimat3/core';
6
+ import {
7
+ addPlainDays,
8
+ addPlainMonths,
9
+ comparePlainDates,
10
+ isPlainDate,
11
+ type PlainDate,
12
+ plainDateOf,
13
+ plainDateParts,
14
+ plainDateToUtcInstant,
15
+ } from '@ultimat3/time';
16
+ import { invalidValueError } from '../errors';
17
+
18
+ /** ISO weekday: 1 is Monday, 7 is Sunday — `Intl.Locale#getWeekInfo().firstDay`'s numbering. */
19
+ export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
20
+
21
+ export interface CalendarBounds {
22
+ readonly min?: PlainDate | undefined;
23
+ readonly max?: PlainDate | undefined;
24
+ readonly isDisabled?: ((date: PlainDate) => boolean) | undefined;
25
+ }
26
+
27
+ export interface CalendarDay {
28
+ readonly date: PlainDate;
29
+ readonly day: number;
30
+ /** In the month shown; the others pad the grid to whole weeks. */
31
+ readonly inMonth: boolean;
32
+ readonly disabled: boolean;
33
+ }
34
+
35
+ const WEEKS = 6;
36
+ /** How far an arrow keeps stepping over disabled days before it gives up and stays put. */
37
+ const MAX_SKIP = 366;
38
+
39
+ /** `value` as a `PlainDate`, or `X_UI_INVALID_VALUE` naming the prop. */
40
+ export function plainDateProp(prop: string, value: unknown): PlainDate {
41
+ if (!isPlainDate(value)) throw invalidValueError(prop, value, 'a YYYY-MM-DD calendar date');
42
+ return value;
43
+ }
44
+
45
+ /** An optional date prop: absent or empty is no date, anything else must be a `PlainDate`. */
46
+ export const optionalDateProp = (prop: string, value: string | undefined): PlainDate | undefined =>
47
+ value === undefined || value === '' ? undefined : plainDateProp(prop, value);
48
+
49
+ /** `min` after `max` leaves no day to pick: refused, naming both. */
50
+ export function assertBounds(component: string, bounds: CalendarBounds): void {
51
+ const { min, max } = bounds;
52
+ if (min !== undefined) plainDateProp(`${component} min`, min);
53
+ if (max !== undefined) plainDateProp(`${component} max`, max);
54
+ if (min !== undefined && max !== undefined && comparePlainDates(min, max) > 0) {
55
+ throw invalidValueError(
56
+ `${component} min`,
57
+ `${min} after max ${max}`,
58
+ 'a min on or before max',
59
+ );
60
+ }
61
+ }
62
+
63
+ interface WeekInfoLocale {
64
+ getWeekInfo?: () => { readonly firstDay?: number };
65
+ readonly weekInfo?: { readonly firstDay?: number };
66
+ }
67
+
68
+ const isWeekday = (value: unknown): value is Weekday =>
69
+ typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 7;
70
+
71
+ /**
72
+ * The locale's first day of the week, from the platform (`en-US` Sunday, `de` Monday), so no table
73
+ * ships. `override` wins; an engine without `getWeekInfo` falls back to ISO Monday.
74
+ */
75
+ export function weekStartOf(locale: string, override?: Weekday | undefined): Weekday {
76
+ if (override !== undefined) {
77
+ if (!isWeekday(override))
78
+ throw invalidValueError('Calendar weekStart', override, 'a weekday 1–7');
79
+ return override;
80
+ }
81
+ const info = new Intl.Locale(locale) as unknown as WeekInfoLocale;
82
+ const firstDay = (info.getWeekInfo?.() ?? info.weekInfo)?.firstDay;
83
+ return isWeekday(firstDay) ? firstDay : 1;
84
+ }
85
+
86
+ /** The ISO weekday of a plain date. */
87
+ export function weekdayOf(date: PlainDate): Weekday {
88
+ const sunday0 = plainDateToUtcInstant(date).getUTCDay();
89
+ return (sunday0 === 0 ? 7 : sunday0) as Weekday;
90
+ }
91
+
92
+ export const firstOfMonth = (date: PlainDate): PlainDate => {
93
+ const { year, month } = plainDateParts(date);
94
+ return plainDateOf({ year, month, day: 1 });
95
+ };
96
+
97
+ export function isDayDisabled(date: PlainDate, bounds: CalendarBounds): boolean {
98
+ if (bounds.min !== undefined && comparePlainDates(date, bounds.min) < 0) return true;
99
+ if (bounds.max !== undefined && comparePlainDates(date, bounds.max) > 0) return true;
100
+ return bounds.isDisabled?.(date) === true;
101
+ }
102
+
103
+ /** Six weeks starting on `weekStart`, around `month`'s first day. Always 42 days. */
104
+ export function monthGrid(
105
+ month: PlainDate,
106
+ weekStart: Weekday,
107
+ bounds: CalendarBounds = {},
108
+ ): readonly (readonly CalendarDay[])[] {
109
+ const first = firstOfMonth(month);
110
+ const lead = (weekdayOf(first) - weekStart + 7) % 7;
111
+ const start = addPlainDays(first, -lead);
112
+ const shown = plainDateParts(first).month;
113
+ return Array.from({ length: WEEKS }, (_, week) =>
114
+ Array.from({ length: 7 }, (_, weekday) => {
115
+ const date = addPlainDays(start, week * 7 + weekday);
116
+ const parts = plainDateParts(date);
117
+ return {
118
+ date,
119
+ day: parts.day,
120
+ inMonth: parts.month === shown,
121
+ disabled: isDayDisabled(date, bounds),
122
+ };
123
+ }),
124
+ );
125
+ }
126
+
127
+ export interface StepOptions extends CalendarBounds {
128
+ readonly dir: 'ltr' | 'rtl';
129
+ readonly weekStart: Weekday;
130
+ readonly shiftKey?: boolean | undefined;
131
+ }
132
+
133
+ /** One key's move, ignoring what is disabled. `undefined` is a key the grid does not take. */
134
+ function rawStep(from: PlainDate, key: string, options: StepOptions): PlainDate | undefined {
135
+ const forward = options.dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
136
+ const back = options.dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
137
+ const offset = (weekdayOf(from) - options.weekStart + 7) % 7;
138
+ switch (key) {
139
+ case forward:
140
+ return addPlainDays(from, 1);
141
+ case back:
142
+ return addPlainDays(from, -1);
143
+ case 'ArrowDown':
144
+ return addPlainDays(from, 7);
145
+ case 'ArrowUp':
146
+ return addPlainDays(from, -7);
147
+ case 'Home':
148
+ return addPlainDays(from, -offset);
149
+ case 'End':
150
+ return addPlainDays(from, 6 - offset);
151
+ case 'PageDown':
152
+ return addPlainMonths(from, options.shiftKey === true ? 12 : 1);
153
+ case 'PageUp':
154
+ return addPlainMonths(from, options.shiftKey === true ? -12 : -1);
155
+ default:
156
+ return undefined;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The day a key lands on. A disabled day is stepped over one day at a time in the direction the
162
+ * key moved, so the roving stop never lands on a day that refuses focus. Stays put (`undefined`)
163
+ * when nothing reachable is left that way.
164
+ */
165
+ export function stepDay(from: PlainDate, key: string, options: StepOptions): PlainDate | undefined {
166
+ let target = rawStep(from, key, options);
167
+ if (target === undefined) return undefined;
168
+ const ahead = comparePlainDates(target, from) > 0;
169
+ for (let skipped = 0; skipped < MAX_SKIP; skipped += 1) {
170
+ if (target === from) return undefined;
171
+ if (!isDayDisabled(target, options)) return target;
172
+ const outOfRange =
173
+ (ahead && options.max !== undefined && comparePlainDates(target, options.max) > 0) ||
174
+ (!ahead && options.min !== undefined && comparePlainDates(target, options.min) < 0);
175
+ if (outOfRange) return undefined;
176
+ target = addPlainDays(target, ahead ? 1 : -1);
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ /**
182
+ * The one day carrying `tabindex="0"`: the focused day, else the selected one, else the first
183
+ * enabled day of the month — each only when it is in the month shown and enabled. `undefined` when
184
+ * the whole month is disabled, and then nothing is tabbable, which is the correct answer.
185
+ */
186
+ export function rovingDay(
187
+ grid: readonly (readonly CalendarDay[])[],
188
+ candidates: readonly (PlainDate | undefined)[],
189
+ ): PlainDate | undefined {
190
+ const days = grid.flat().filter((day) => day.inMonth && !day.disabled);
191
+ for (const candidate of candidates) {
192
+ if (candidate !== undefined && days.some((day) => day.date === candidate)) return candidate;
193
+ }
194
+ return days[0]?.date;
195
+ }
196
+
197
+ const formatters = new Map<string, Intl.DateTimeFormat>();
198
+ /** `timeZone: 'UTC'` on a plain date's UTC midnight: the date named, never shifted by a zone. */
199
+ const formatter = (locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat =>
200
+ cachedFormatter(
201
+ formatters,
202
+ `${locale}|${JSON.stringify(options)}`,
203
+ () => new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }),
204
+ );
205
+
206
+ export const monthLabel = (month: PlainDate, locale: string): string =>
207
+ formatter(locale, { month: 'long', year: 'numeric' }).format(plainDateToUtcInstant(month));
208
+
209
+ export const dayLabel = (date: PlainDate, locale: string): string =>
210
+ formatter(locale, { dateStyle: 'full' }).format(plainDateToUtcInstant(date));
211
+
212
+ /** Seven column headers from `weekStart`: the short name shown, the long one as `abbr`. */
213
+ export function weekdayLabels(
214
+ locale: string,
215
+ weekStart: Weekday,
216
+ ): readonly { readonly short: string; readonly long: string }[] {
217
+ // 2024-01-01 is a Monday: ISO weekday `n` is that date plus `n - 1` days.
218
+ const monday = plainDateOf({ year: 2024, month: 1, day: 1 });
219
+ return Array.from({ length: 7 }, (_, index) => {
220
+ const date = plainDateToUtcInstant(addPlainDays(monday, (weekStart - 1 + index) % 7));
221
+ return {
222
+ short: formatter(locale, { weekday: 'narrow' }).format(date),
223
+ long: formatter(locale, { weekday: 'long' }).format(date),
224
+ };
225
+ });
226
+ }
@@ -0,0 +1,59 @@
1
+ // The shapes a chart refuses rather than draws wrong: a series whose values do not line up with
2
+ // the keys, x positions that are not ascending, gauge thresholds out of order. A misaligned row
3
+ // drawn anyway puts a value under the wrong key — a chart that lies quietly — so each is coded.
4
+
5
+ import { renderCauseValue } from '@ultimat3/core';
6
+ import { UI_ERROR_CODES, UiError } from '../errors';
7
+ import type { ChartSeries } from './chart-frame-view';
8
+
9
+ function refused(cause: string, fix: string): UiError {
10
+ return new UiError({ code: UI_ERROR_CODES.invalidValue, cause, fix });
11
+ }
12
+
13
+ /** Every series carries exactly one value per key; `null` is how a gap is said. */
14
+ export function checkSeries(
15
+ chart: string,
16
+ keys: readonly string[],
17
+ series: readonly ChartSeries[],
18
+ ): void {
19
+ for (const one of series) {
20
+ if (one.values.length === keys.length) continue;
21
+ throw refused(
22
+ `<${chart}> series ${renderCauseValue(one.label)} has ${one.values.length} values for ${keys.length} keys`,
23
+ 'values: keys.map((key) => byKey.get(key) ?? null) # one value per key, null for a gap — never a shorter row',
24
+ );
25
+ }
26
+ }
27
+
28
+ /** True x positions: one per key, finite, strictly ascending. */
29
+ export function checkAt(chart: string, keys: readonly string[], at: readonly number[]): void {
30
+ const fix =
31
+ 'at: rows.map((row) => row.at.getTime()) # one finite number per key, oldest first, no repeats';
32
+ if (at.length !== keys.length) {
33
+ throw refused(`<${chart}> at has ${at.length} positions for ${keys.length} keys`, fix);
34
+ }
35
+ for (let i = 0; i < at.length; i += 1) {
36
+ const value = at[i] as number;
37
+ if (!Number.isFinite(value) || (i > 0 && value <= (at[i - 1] as number))) {
38
+ throw refused(
39
+ `<${chart}> at[${i}] is ${renderCauseValue(value)}, which is not finite and above at[${i - 1}]`,
40
+ fix,
41
+ );
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Thresholds ascend by `at`, so "the last one passed" is one answer. */
47
+ export function checkThresholds(
48
+ chart: string,
49
+ thresholds: readonly { readonly at: number }[],
50
+ ): void {
51
+ for (let i = 0; i < thresholds.length; i += 1) {
52
+ const at = thresholds[i]?.at as number;
53
+ if (Number.isFinite(at) && (i === 0 || at > (thresholds[i - 1]?.at as number))) continue;
54
+ throw refused(
55
+ `<${chart}> thresholds[${i}].at is ${renderCauseValue(at)}, which is not finite and above the one before`,
56
+ "thresholds: [{ at: 70, tone: 'warning' }, { at: 90, tone: 'danger' }] # ascending by at, in the value's unit",
57
+ );
58
+ }
59
+ }
@@ -3,6 +3,8 @@
3
3
  // no-colour reader get instead of the picture, and how a number becomes text — always in a locale
4
4
  // the caller passed, never the runtime's default.
5
5
 
6
+ import type { Tone } from './variants';
7
+
6
8
  /** The chart colour roles, `chart-1` … `chart-8` (`--color-chart-N`). */
7
9
  export const CHART_SERIES_SLOTS = 8;
8
10
 
@@ -144,8 +146,13 @@ export function finiteOrNull(value: number | null | undefined): number | null {
144
146
  export interface ChartSeries {
145
147
  /** The series' name, already translated: the legend entry and the table's column head. */
146
148
  readonly label: string;
147
- /** One value per key, in key order; `null` is a gap in the line and an empty table cell. */
149
+ /** One value per key, in key order; `null` is a gap in the line and `ui.chart.none` in the table. */
148
150
  readonly values: readonly (number | null)[];
151
+ /**
152
+ * A status colour in place of the slot's — an error rate in `danger`. The dash and marker stay,
153
+ * so the series is never told apart by colour alone.
154
+ */
155
+ readonly tone?: Tone | undefined;
149
156
  }
150
157
 
151
158
  export interface ChartTableRow {
@@ -166,6 +173,8 @@ export interface ChartTableInput {
166
173
  readonly keys: readonly string[];
167
174
  readonly series: readonly ChartSeries[];
168
175
  readonly format: (value: number) => string;
176
+ /** What a gap's cell says, already translated (`ui.chart.none`) — an empty cell reads as nothing. */
177
+ readonly none: string;
169
178
  }
170
179
 
171
180
  /** The same numbers the picture draws, as rows a screen reader walks cell by cell. */
@@ -177,7 +186,7 @@ export function chartTable(input: ChartTableInput): ChartTable {
177
186
  key,
178
187
  cells: input.series.map((series) => {
179
188
  const value = finiteOrNull(series.values[index]);
180
- return value === null ? '' : input.format(value);
189
+ return value === null ? input.none : input.format(value);
181
190
  }),
182
191
  })),
183
192
  };
@@ -217,3 +226,25 @@ export function hitEdge(x: number, width: number): 'start' | 'middle' | 'end' {
217
226
  if (share > 0.8) return 'end';
218
227
  return 'middle';
219
228
  }
229
+
230
+ /**
231
+ * A point's stable identity across ticks: its series and its key, so a live window that drops the
232
+ * oldest key keeps every other point's node — and the focus on it. A repeated key gets `#n`, the
233
+ * first `n` no earlier id holds: a key may itself read `a#1`, and a duplicate id throws in
234
+ * `keyedRows`, taking the whole chart with it.
235
+ */
236
+ export function hitIds(series: number, keys: readonly string[]): readonly string[] {
237
+ const seen = new Map<string, number>();
238
+ const used = new Set<string>();
239
+ return keys.map((key) => {
240
+ let n = seen.get(key) ?? 0;
241
+ let id = n === 0 ? `${series}:${key}` : `${series}:${key}#${n}`;
242
+ while (used.has(id)) {
243
+ n += 1;
244
+ id = `${series}:${key}#${n}`;
245
+ }
246
+ seen.set(key, n + 1);
247
+ used.add(id);
248
+ return id;
249
+ });
250
+ }
@@ -0,0 +1,41 @@
1
+ // Reference lines — a target, a limit, last week's mean — as geometry every xy chart shares: a
2
+ // horizontal rule at a value, its label, and its tone. The value widens the chart's range, so a
3
+ // target above every bar is still on the plot rather than silently off it.
4
+
5
+ import { finiteOrNull } from './chart-frame-view';
6
+ import type { Tone } from './variants';
7
+
8
+ export interface ChartReference {
9
+ readonly value: number;
10
+ /** Already translated: "Target", "SLO 99.9%". Drawn beside the rule with its value. */
11
+ readonly label: string;
12
+ /** Default `neutral`. The rule is dashed in every tone, so it never reads as a series. */
13
+ readonly tone?: Tone | undefined;
14
+ }
15
+
16
+ export interface ReferenceLine {
17
+ readonly value: number;
18
+ readonly y: number;
19
+ readonly label: string;
20
+ readonly tone: Tone;
21
+ }
22
+
23
+ /** The finite values among `references` — what an extent folds in. */
24
+ export function referenceValues(references: readonly ChartReference[] | undefined): number[] {
25
+ return (references ?? []).flatMap((ref) => {
26
+ const value = finiteOrNull(ref.value);
27
+ return value === null ? [] : [value];
28
+ });
29
+ }
30
+
31
+ /** Each drawable reference at its y, by the chart's own scale; a non-finite value is dropped. */
32
+ export function referenceLines(
33
+ references: readonly ChartReference[] | undefined,
34
+ yOf: (value: number) => number,
35
+ ): readonly ReferenceLine[] {
36
+ return (references ?? []).flatMap((ref) => {
37
+ const value = finiteOrNull(ref.value);
38
+ if (value === null) return [];
39
+ return [{ value, y: yOf(value), label: ref.label, tone: ref.tone ?? 'neutral' }];
40
+ });
41
+ }