@ultimat3/ui 24.0.0 → 25.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 (86) hide show
  1. package/CATALOG.md +117 -13
  2. package/CLAUDE.md +14 -5
  3. package/README.md +109 -24
  4. package/package.json +6 -6
  5. package/src/a11y.ts +3 -3
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/Container.tsx +46 -9
  16. package/src/components/DataTable.module.scss +190 -0
  17. package/src/components/DataTable.tsx +124 -17
  18. package/src/components/Divider.tsx +0 -2
  19. package/src/components/DonutChart.module.scss +73 -0
  20. package/src/components/DonutChart.tsx +154 -0
  21. package/src/components/Dropzone.tsx +9 -1
  22. package/src/components/ErrorState.tsx +39 -12
  23. package/src/components/Form.tsx +17 -4
  24. package/src/components/Gauge.module.scss +48 -0
  25. package/src/components/Gauge.tsx +67 -0
  26. package/src/components/Grid.tsx +34 -13
  27. package/src/components/Image.tsx +2 -2
  28. package/src/components/InlineBar.module.scss +23 -0
  29. package/src/components/InlineBar.tsx +31 -0
  30. package/src/components/LineChart.module.scss +147 -0
  31. package/src/components/LineChart.tsx +169 -0
  32. package/src/components/Menu.tsx +9 -5
  33. package/src/components/PageHeader.tsx +5 -2
  34. package/src/components/Popover.tsx +2 -2
  35. package/src/components/Section.tsx +45 -26
  36. package/src/components/Stack.tsx +59 -18
  37. package/src/components/Table.module.scss +30 -0
  38. package/src/components/Table.tsx +14 -3
  39. package/src/components/Tabs.tsx +2 -2
  40. package/src/components/Text.tsx +41 -15
  41. package/src/components/Toaster.tsx +1 -1
  42. package/src/components/Toolbar.tsx +7 -6
  43. package/src/components/app-shell-view.ts +6 -4
  44. package/src/components/bar-chart-view.ts +5 -4
  45. package/src/components/chart-frame-view.ts +219 -0
  46. package/src/components/chart-ticks-view.ts +173 -0
  47. package/src/components/data-table-view.ts +68 -0
  48. package/src/components/donut-chart-view.ts +120 -0
  49. package/src/components/file-input-view.ts +1 -1
  50. package/src/components/gauge-view.ts +43 -0
  51. package/src/components/heading-node.tsx +62 -0
  52. package/src/components/image-source.ts +3 -3
  53. package/src/components/line-chart-view.ts +153 -0
  54. package/src/components/money-view.ts +2 -7
  55. package/src/form/form-binding.ts +23 -6
  56. package/src/form/use-form.ts +3 -3
  57. package/src/global.scss +9 -0
  58. package/src/icons/build-icons.ts +15 -6
  59. package/src/index.ts +45 -9
  60. package/src/jsx-probe.ts +5 -4
  61. package/src/theme/brand-declarations.ts +113 -0
  62. package/src/theme/brand.ts +98 -109
  63. package/src/theme/context.ts +1 -1
  64. package/src/theme/preset-scifi.ts +99 -0
  65. package/src/theme/preset-shape.ts +18 -0
  66. package/src/theme/presets.ts +15 -0
  67. package/src/theme/shadow-value.ts +39 -0
  68. package/src/theme/theme.ts +1 -1
  69. package/src/toast/toast-store.ts +1 -1
  70. package/src/toast/use-toasts.ts +1 -1
  71. package/src/tokens/_colors.scss +20 -0
  72. package/src/tokens/_effects.scss +59 -0
  73. package/src/tokens/_index.scss +2 -0
  74. package/src/tokens/_mixins.scss +6 -4
  75. package/src/tokens/_shadow.scss +16 -0
  76. package/src/tokens/_touch.scss +39 -0
  77. package/src/tokens/_typography.scss +14 -0
  78. package/src/tokens/color-tokens.ts +133 -0
  79. package/src/tokens/colour-vision.ts +106 -0
  80. package/src/tokens/contrast-pairs.ts +6 -1
  81. package/src/tokens/contrast.ts +1 -1
  82. package/src/tokens/theme.scss +2 -0
  83. package/src/tokens/tokens.ts +41 -87
  84. package/src/components/qr-reader.ts +0 -187
  85. package/src/fake-dom.ts +0 -206
  86. package/src/sass-probe.ts +0 -55
@@ -0,0 +1,173 @@
1
+ // Axis arithmetic shared by every chart with a value axis: a "nice" scale (steps of 1, 2 or 5 ×
2
+ // 10ⁿ, the only steps a reader can count in their head) and which category keys get a label.
3
+ // Pure, so the server render and an island draw the same axis, and testable with no renderer.
4
+
5
+ export interface NiceScale {
6
+ /** The axis' lowest tick — at or below the data's minimum. */
7
+ readonly min: number;
8
+ /** The axis' highest tick — at or above the data's maximum. */
9
+ readonly max: number;
10
+ readonly step: number;
11
+ /** `min`, `min + step`, … `max`, as exact decimals. */
12
+ readonly ticks: readonly number[];
13
+ }
14
+
15
+ const MANTISSAS = [1, 2, 5] as const;
16
+
17
+ /** Rounds away binary noise: `0.1 + 0.2` is drawn as `0.3`, never `0.30000000000000004`. */
18
+ const exact = (value: number): number => Number(value.toPrecision(12));
19
+
20
+ /** The largest step of the 1-2-5 ladder at or below `raw`; 1 for a raw step that is not one. */
21
+ export function niceStep(raw: number): number {
22
+ if (!Number.isFinite(raw) || raw <= 0) return 1;
23
+ const exp = Math.floor(Math.log10(raw));
24
+ const power = 10 ** exp;
25
+ const fraction = raw / power;
26
+ let mantissa = 1;
27
+ for (const candidate of MANTISSAS) if (candidate <= fraction * (1 + 1e-9)) mantissa = candidate;
28
+ return exact(mantissa * power);
29
+ }
30
+
31
+ /** The next rung of the ladder above `step`: 1 → 2 → 5 → 10 → 20. */
32
+ function nextStep(step: number): number {
33
+ const exp = Math.floor(Math.log10(step));
34
+ const mantissa = Math.round(step / 10 ** exp);
35
+ if (mantissa === 1) return exact(2 * 10 ** exp);
36
+ if (mantissa === 2) return exact(5 * 10 ** exp);
37
+ return exact(10 ** (exp + 1));
38
+ }
39
+
40
+ /** Fraction digits a tick at `step` needs: 0 for 5 or 500, 1 for 0.5, 2 for 0.02. */
41
+ export function tickDecimals(step: number): number {
42
+ if (!Number.isFinite(step) || step <= 0) return 0;
43
+ return Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
44
+ }
45
+
46
+ /** A flat range has no height to divide: open it by a tenth of its size (or by 1 at zero). */
47
+ function bounds(lo: number, hi: number): readonly [number, number] {
48
+ const a = Number.isFinite(lo) ? lo : 0;
49
+ const b = Number.isFinite(hi) ? hi : a;
50
+ const [low, high] = a <= b ? [a, b] : [b, a];
51
+ if (high > low) return [low, high];
52
+ if (low === 0) return [0, 1];
53
+ const open = Math.abs(low) * 0.1;
54
+ // Clamped, because a flat range at ±MAX_VALUE opens past float64 into ±Infinity.
55
+ return [Math.max(-Number.MAX_VALUE, low - open), Math.min(Number.MAX_VALUE, high + open)];
56
+ }
57
+
58
+ export interface NiceTicksOptions {
59
+ /**
60
+ * The data is whole numbers — a count. Steps are then at least 1, so no tick falls between two
61
+ * values the data can take: "0.4 signups" labels nothing, and it pushed the max off the labels.
62
+ */
63
+ readonly integer?: boolean | undefined;
64
+ }
65
+
66
+ /**
67
+ * The densest 1-2-5 labelling of `lo..hi` with at most `maxTicks` ticks (read as at least 2):
68
+ * start at the step that would fit exactly and climb the ladder until the count fits.
69
+ */
70
+ export function niceTicks(
71
+ lo: number,
72
+ hi: number,
73
+ maxTicks = 6,
74
+ options: NiceTicksOptions = {},
75
+ ): NiceScale {
76
+ const cap = Math.max(2, Math.floor(maxTicks));
77
+ const [low, high] = bounds(lo, hi);
78
+ const fitted = niceStep((high - low) / (cap - 1));
79
+ // Every rung of the ladder from 1 up is a whole number, so flooring the START at 1 is enough.
80
+ let step = options.integer === true ? Math.max(1, fitted) : fitted;
81
+ for (let rung = 0; rung < MAX_RUNGS; rung += 1) {
82
+ const min = exact(Math.floor(low / step + 1e-9) * step);
83
+ const max = exact(Math.ceil(high / step - 1e-9) * step);
84
+ const count = Math.round((max - min) / step) + 1;
85
+ // Past float64's edge the ladder stops being one: `ceil(hi / step) * step` overflows, the
86
+ // next rung IS `Infinity`, and `NaN <= cap` is false forever — a LineChart SSR that never
87
+ // returned. An underflowing step collapses the range to one tick instead. Either way the
88
+ // data's own two ends are the only honest axis left.
89
+ if (!(step > 0 && Number.isFinite(step) && Number.isFinite(min) && Number.isFinite(max))) break;
90
+ if (count < 2) break;
91
+ if (count <= cap) {
92
+ const ticks = Array.from({ length: count }, (_, i) => exact(min + i * step));
93
+ return { min, max, step, ticks };
94
+ }
95
+ step = nextStep(step);
96
+ }
97
+ return edgeScale(low, high);
98
+ }
99
+
100
+ /**
101
+ * Rungs from the smallest subnormal step to the largest finite one: ~632 decades × 3. The loop
102
+ * already exits at the first non-finite rung; the bound makes termination a fact, not an argument.
103
+ */
104
+ const MAX_RUNGS = 2_048;
105
+
106
+ /** The fallback axis: just the data's two ends, which `bounds` has already made finite. */
107
+ function edgeScale(low: number, high: number): NiceScale {
108
+ const span = high - low;
109
+ return {
110
+ min: low,
111
+ max: high,
112
+ step: Number.isFinite(span) && span > 0 ? span : Number.MAX_VALUE,
113
+ ticks: [low, high],
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Which of `count` category keys carry a label, at most `max` of them: an even stride counted
119
+ * back from the NEWEST key, which is always labelled — on a time axis it is the one people read.
120
+ * The stride is at least `(count - 1) / (max - 1)` keys, which is what lets a label be capped at
121
+ * a fixed share of the axis and never touch its neighbour.
122
+ */
123
+ export function axisLabelIndices(count: number, max: number): readonly number[] {
124
+ if (count <= 0) return [];
125
+ const cap = Math.max(2, Math.floor(max));
126
+ if (count <= cap) return Array.from({ length: count }, (_, i) => i);
127
+ const stride = Math.ceil((count - 1) / (cap - 1));
128
+ const out: number[] = [];
129
+ for (let index = count - 1; index >= 0; index -= stride) out.unshift(index);
130
+ return out;
131
+ }
132
+
133
+ /** Category labels a chart under its container breakpoint shows — a 390px phone. */
134
+ export const NARROW_LABELS = 3;
135
+ /** And at or above it. Each label is capped at ⅔ of the narrowest stride, so neither overlaps. */
136
+ export const WIDE_LABELS = 6;
137
+
138
+ export type LabelEdge = 'start' | 'middle' | 'end';
139
+
140
+ export interface CategoryLabel {
141
+ readonly index: number;
142
+ /** Shown below the container breakpoint (`data-narrow`). */
143
+ readonly narrow: boolean;
144
+ /** Shown at or above it (`data-wide`). */
145
+ readonly wide: boolean;
146
+ /** The oldest key aligns to the start edge, the newest to the end, so neither leaves the box. */
147
+ readonly edge: LabelEdge;
148
+ }
149
+
150
+ /**
151
+ * Every key that either width labels, oldest first, each flagged with the width that shows it.
152
+ * The sheet hides by flag, so one server render serves the phone and the desktop with no JS.
153
+ */
154
+ export function categoryLabels(count: number): readonly CategoryLabel[] {
155
+ const narrow = new Set(axisLabelIndices(count, NARROW_LABELS));
156
+ const wide = new Set(axisLabelIndices(count, WIDE_LABELS));
157
+ const indices = [...new Set([...narrow, ...wide])].sort((a, b) => a - b);
158
+ return indices.map((index) => ({
159
+ index,
160
+ narrow: narrow.has(index),
161
+ wide: wide.has(index),
162
+ edge: count === 1 ? 'middle' : index === 0 ? 'start' : index === count - 1 ? 'end' : 'middle',
163
+ }));
164
+ }
165
+
166
+ /**
167
+ * Which of `count` value-axis labels a narrow chart hides: every other one counted down from the
168
+ * TOP, once there are more than three — the highest tick is the one that bounds the data, so it is
169
+ * never the one dropped. Hidden labels keep their grid line; only the text goes.
170
+ */
171
+ export function minorTicks(count: number): readonly boolean[] {
172
+ return Array.from({ length: count }, (_, i) => count > 3 && (count - 1 - i) % 2 === 1);
173
+ }
@@ -0,0 +1,68 @@
1
+ // Where each DataTable value is visible, by the table's own width. A column's `priority` hides its
2
+ // cell in a narrow table; the row's "more" disclosure shows the same value for exactly the widths
3
+ // the cell is gone, so no value is ever on screen twice and none is ever nowhere.
4
+
5
+ import { invalidValueError } from '../errors';
6
+
7
+ /**
8
+ * The roles every DataTable element re-declares. Redundant on a table laid out as a table, and
9
+ * deliberately so: below `sm` the card view changes `display` on the table, its row groups, rows
10
+ * and cells, and WebKit drops the implicit table semantics of an element whose `display` changed —
11
+ * a screen reader then meets loose text with no row, no column and no header. The explicit role is
12
+ * the documented remedy, and ONE constant is the one place the redundancy is argued, rather than a
13
+ * lint suppression on every element.
14
+ */
15
+ export const TABLE_ROLES = {
16
+ rowgroup: 'rowgroup',
17
+ row: 'row',
18
+ columnheader: 'columnheader',
19
+ cell: 'cell',
20
+ } as const;
21
+
22
+ /** 1 is always a cell; 2 is a cell from `md` up; 3 from `lg` up. Widths are the TABLE's. */
23
+ export const COLUMN_PRIORITIES = [1, 2, 3] as const;
24
+ export type ColumnPriority = (typeof COLUMN_PRIORITIES)[number];
25
+
26
+ /**
27
+ * The `data-priority` / `data-more` values the stylesheet's width rules select on. Attributes, not
28
+ * classes: they are state the markup carries, and a test can read them where a CSS-module class
29
+ * resolves to nothing under `bun test`.
30
+ */
31
+ export type PriorityAttr = '2' | '3';
32
+ /** The rung the disclosure shows below — the widest range any of its columns is hidden over. */
33
+ export type MoreUntil = 'md' | 'lg';
34
+
35
+ /**
36
+ * The priority a column declared, refused when it is not one: the type stops a TypeScript caller,
37
+ * and nothing stops a column list parsed from config — a `priority: 4` used to be a column shown
38
+ * at every width with no word that it was not what was asked for.
39
+ */
40
+ export function columnPriority(priority: unknown): ColumnPriority {
41
+ if (priority === undefined) return 1;
42
+ if (priority === 1 || priority === 2 || priority === 3) return priority;
43
+ throw invalidValueError('DataTable', priority, 'a column priority of 1, 2 or 3');
44
+ }
45
+
46
+ /** What a cell (and its header) carries; priority 1 carries nothing and is never hidden. */
47
+ export function priorityAttr(priority: ColumnPriority): PriorityAttr | undefined {
48
+ return priority === 1 ? undefined : `${priority}`;
49
+ }
50
+
51
+ export interface MoreColumn<C> {
52
+ /** The columns whose values the disclosure may hold, in declaration order. */
53
+ readonly columns: readonly C[];
54
+ readonly until: MoreUntil;
55
+ }
56
+
57
+ /**
58
+ * The trailing "more" column, or `undefined` when every column is priority 1 — a table that
59
+ * hides nothing gets no extra column, no extra header and no extra bytes.
60
+ */
61
+ export function moreColumn<C extends { readonly priority?: unknown }>(
62
+ columns: readonly C[],
63
+ ): MoreColumn<C> | undefined {
64
+ const hidden = columns.filter((column) => columnPriority(column.priority) > 1);
65
+ if (hidden.length === 0) return undefined;
66
+ const widest = Math.max(...hidden.map((column) => columnPriority(column.priority)));
67
+ return { columns: hidden, until: widest === 3 ? 'lg' : 'md' };
68
+ }
@@ -0,0 +1,120 @@
1
+ // The arc maths of a DonutChart (and a Gauge's track): where each segment starts and ends, the
2
+ // annular-sector path that draws it, and where its marker sits. Angles are radians from twelve
3
+ // o'clock, clockwise — the way a reader goes round a clock face. Pure, so two renders agree.
4
+
5
+ import { svgNumber } from './chart-frame-view';
6
+
7
+ /** A 200 × 200 box; the ring is 32 units wide, which leaves room for a centre label inside. */
8
+ export const DONUT = { size: 200, centre: 100, outer: 96, inner: 64, marker: 6 } as const;
9
+
10
+ const TURN = Math.PI * 2;
11
+ /** The gap between two neighbours, so a segment boundary reads without relying on colour. */
12
+ const GAP = 0.03;
13
+ /** The least sweep a non-zero segment is drawn with: a 0.1% share is still a visible sliver. */
14
+ const MIN_SWEEP = 0.012;
15
+
16
+ export interface CirclePoint {
17
+ readonly x: number;
18
+ readonly y: number;
19
+ }
20
+
21
+ export function pointOnCircle(cx: number, cy: number, r: number, angle: number): CirclePoint {
22
+ return { x: cx + r * Math.sin(angle), y: cy - r * Math.cos(angle) };
23
+ }
24
+
25
+ const at = (p: CirclePoint): string => `${svgNumber(p.x)},${svgNumber(p.y)}`;
26
+
27
+ function sector(cx: number, cy: number, outer: number, inner: number, a0: number, a1: number) {
28
+ const large = a1 - a0 > Math.PI ? 1 : 0;
29
+ const o0 = pointOnCircle(cx, cy, outer, a0);
30
+ const o1 = pointOnCircle(cx, cy, outer, a1);
31
+ const i1 = pointOnCircle(cx, cy, inner, a1);
32
+ const i0 = pointOnCircle(cx, cy, inner, a0);
33
+ return (
34
+ `M${at(o0)} A${outer},${outer} 0 ${large} 1 ${at(o1)} ` +
35
+ `L${at(i1)} A${inner},${inner} 0 ${large} 0 ${at(i0)} Z`
36
+ );
37
+ }
38
+
39
+ /**
40
+ * The ring between radii `inner` and `outer` from angle `a0` to `a1`. A sweep of a whole turn is
41
+ * drawn as two halves: an SVG arc from a point back to itself has no direction and draws nothing.
42
+ */
43
+ export function annularSector(
44
+ cx: number,
45
+ cy: number,
46
+ outer: number,
47
+ inner: number,
48
+ a0: number,
49
+ a1: number,
50
+ ): string {
51
+ if (!(a1 > a0)) return '';
52
+ if (a1 - a0 >= TURN - 1e-9) {
53
+ const half = a0 + Math.PI;
54
+ return `${sector(cx, cy, outer, inner, a0, half)} ${sector(cx, cy, outer, inner, half, a0 + TURN)}`;
55
+ }
56
+ return sector(cx, cy, outer, inner, a0, a1);
57
+ }
58
+
59
+ export interface DonutArc {
60
+ /** This value's part of the positive total; 0 for a value that is not drawn. */
61
+ readonly share: number;
62
+ /** The segment's slot on the ring, before the gap is taken off. */
63
+ readonly start: number;
64
+ readonly end: number;
65
+ /** What is actually drawn: the slot less half a gap at each end. */
66
+ readonly drawnStart: number;
67
+ readonly drawnEnd: number;
68
+ readonly path: string;
69
+ /** Mid-ring, mid-segment: where a focusable segment's hit sits. `null` for a share of 0. */
70
+ readonly anchor: CirclePoint | null;
71
+ /** The anchor again, or `null` where the segment is too narrow to hold a marker. */
72
+ readonly marker: CirclePoint | null;
73
+ }
74
+
75
+ const positive = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);
76
+
77
+ /** One arc per value, in order from twelve o'clock; a non-positive value is listed, not drawn. */
78
+ export function donutArcs(values: readonly number[]): readonly DonutArc[] {
79
+ const { centre, outer, inner, marker } = DONUT;
80
+ const total = values.reduce((sum, value) => sum + positive(value), 0);
81
+ const drawn = values.filter((value) => positive(value) > 0).length;
82
+ const gap = drawn > 1 ? GAP : 0;
83
+ const mid = (outer + inner) / 2;
84
+ let cursor = 0;
85
+ return values.map((value): DonutArc => {
86
+ const share = total > 0 ? positive(value) / total : 0;
87
+ const start = cursor;
88
+ const end = cursor + share * TURN;
89
+ cursor = end;
90
+ if (share === 0) {
91
+ return {
92
+ share,
93
+ start,
94
+ end,
95
+ drawnStart: start,
96
+ drawnEnd: start,
97
+ path: '',
98
+ anchor: null,
99
+ marker: null,
100
+ };
101
+ }
102
+ const centreAngle = (start + end) / 2;
103
+ const sweep = Math.max(MIN_SWEEP, end - start - gap);
104
+ const drawnStart = gap === 0 ? start : centreAngle - sweep / 2;
105
+ const drawnEnd = gap === 0 ? end : centreAngle + sweep / 2;
106
+ // A marker needs about three of its own widths of arc, or it spills onto the neighbours.
107
+ const fits = sweep * mid >= marker * 3;
108
+ const anchor = pointOnCircle(centre, centre, mid, centreAngle);
109
+ return {
110
+ share,
111
+ start,
112
+ end,
113
+ drawnStart,
114
+ drawnEnd,
115
+ path: annularSector(centre, centre, outer, inner, drawnStart, drawnEnd),
116
+ anchor,
117
+ marker: fits ? anchor : null,
118
+ };
119
+ });
120
+ }
@@ -85,7 +85,7 @@ const BYTE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as c
85
85
  const BYTE_STEP = 1000;
86
86
 
87
87
  /** A size a human reads, in the viewer's locale — never a hand-built "1.2 MB" string. */
88
- export function formatBytes(bytes: number, locale: string): string {
88
+ export function formatFileSize(bytes: number, locale: string): string {
89
89
  const safe = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
90
90
  let value = safe;
91
91
  let index = 0;
@@ -0,0 +1,43 @@
1
+ // The dial of a Gauge, apart from its markup: a 270° ring open at the bottom, and the share of it
2
+ // a value fills. `Gauge` takes that share from `meterShare` — one rule for "value of max", so a
3
+ // Gauge and a Meter beside it can never disagree about what 7 of 4 draws.
4
+
5
+ import { annularSector } from './donut-chart-view';
6
+
7
+ /**
8
+ * 200 wide, cropped to 172 tall: the dial's open quarter is at the bottom, and the box ends just
9
+ * under the ring's lowest point (`gauge-view.test.ts` holds that) instead of spending the strip.
10
+ */
11
+ export const GAUGE = {
12
+ width: 200,
13
+ height: 172,
14
+ centre: 100,
15
+ outer: 92,
16
+ inner: 74,
17
+ /** Seven-thirty: -135° from twelve o'clock. */
18
+ from: -Math.PI * 0.75,
19
+ sweep: Math.PI * 1.5,
20
+ } as const;
21
+
22
+ const clampShare = (share: number): number =>
23
+ Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0;
24
+
25
+ /** The angle where a fill of `share` stops. */
26
+ export function gaugeFillEnd(share: number): number {
27
+ return GAUGE.from + clampShare(share) * GAUGE.sweep;
28
+ }
29
+
30
+ export interface GaugeArcs {
31
+ readonly track: string;
32
+ /** `''` at a share of 0 — an empty dial is the track alone, not a zero-width sliver. */
33
+ readonly fill: string;
34
+ }
35
+
36
+ export function gaugeArcs(share: number): GaugeArcs {
37
+ const { centre, outer, inner, from } = GAUGE;
38
+ const end = gaugeFillEnd(share);
39
+ return {
40
+ track: annularSector(centre, centre, outer, inner, from, gaugeFillEnd(1)),
41
+ fill: annularSector(centre, centre, outer, inner, from, end),
42
+ };
43
+ }
@@ -0,0 +1,62 @@
1
+ // The heading element a composite renders, written out one intrinsic per level. `<Heading>` over
2
+ // `headingTag(level)` is a string to the server's JSX factory and a COMPONENT to the island
3
+ // build's Solid transform, which calls it and throws (#488, `intrinsic-root.test.ts`). PageHeader,
4
+ // Section and Accordion all render through this, so the switch exists once.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import type { HeadingTag } from './heading-level';
8
+
9
+ export interface HeadingNodeAttrs {
10
+ readonly id?: string | undefined;
11
+ readonly class?: string | undefined;
12
+ }
13
+
14
+ /**
15
+ * `tag` comes from `headingTag(level)`, resolved once by the caller so an off-scale level still
16
+ * throws `X_UI_INVALID_VALUE` there. `content` is an accessor: read inside the element, it stays
17
+ * reactive in an island.
18
+ */
19
+ export function headingNode(
20
+ tag: HeadingTag,
21
+ attrs: HeadingNodeAttrs,
22
+ content: () => JSX.Element,
23
+ ): JSX.Element {
24
+ switch (tag) {
25
+ case 'h1':
26
+ return (
27
+ <h1 id={attrs.id} class={attrs.class}>
28
+ {content()}
29
+ </h1>
30
+ );
31
+ case 'h2':
32
+ return (
33
+ <h2 id={attrs.id} class={attrs.class}>
34
+ {content()}
35
+ </h2>
36
+ );
37
+ case 'h3':
38
+ return (
39
+ <h3 id={attrs.id} class={attrs.class}>
40
+ {content()}
41
+ </h3>
42
+ );
43
+ case 'h4':
44
+ return (
45
+ <h4 id={attrs.id} class={attrs.class}>
46
+ {content()}
47
+ </h4>
48
+ );
49
+ case 'h5':
50
+ return (
51
+ <h5 id={attrs.id} class={attrs.class}>
52
+ {content()}
53
+ </h5>
54
+ );
55
+ case 'h6':
56
+ return (
57
+ <h6 id={attrs.id} class={attrs.class}>
58
+ {content()}
59
+ </h6>
60
+ );
61
+ }
62
+ }
@@ -41,7 +41,7 @@ interface Candidate {
41
41
  * byte-stable whatever order the caller built the list in. `undefined` for an
42
42
  * empty list — the component omits the attribute rather than emitting an empty one.
43
43
  */
44
- export function srcsetFor(variants: readonly ImageVariant[] | undefined): string | undefined {
44
+ export function variantSrcset(variants: readonly ImageVariant[] | undefined): string | undefined {
45
45
  if (variants === undefined || variants.length === 0) return undefined;
46
46
 
47
47
  const candidates = variants.map(toCandidate);
@@ -239,9 +239,9 @@ export interface ImageSourceSet {
239
239
  export function sourceSetsFor(sources: ImageSources | undefined): readonly ImageSourceSet[] {
240
240
  if (sources === undefined) return [];
241
241
  const sets: ImageSourceSet[] = [];
242
- const avif = srcsetFor(sources.avif);
242
+ const avif = variantSrcset(sources.avif);
243
243
  if (avif !== undefined) sets.push({ type: 'image/avif', srcset: avif });
244
- const webp = srcsetFor(sources.webp);
244
+ const webp = variantSrcset(sources.webp);
245
245
  if (webp !== undefined) sets.push({ type: 'image/webp', srcset: webp });
246
246
  return sets;
247
247
  }
@@ -0,0 +1,153 @@
1
+ // The geometry of a LineChart / AreaChart, apart from its markup: the nice value scale over every
2
+ // series, each point's position, the line through each unbroken run and the area under it. Pure,
3
+ // so a server render and an island draw one chart; `null` is a gap, never a dive to zero.
4
+
5
+ import type { ChartSeries } from './chart-frame-view';
6
+ import { finiteOrNull } from './chart-frame-view';
7
+ import type { NiceScale } from './chart-ticks-view';
8
+ import { niceTicks } from './chart-ticks-view';
9
+
10
+ /**
11
+ * 600 × 200, 3:1 — a dashboard row, not a hero. `pad` keeps a marker on the outermost point (and
12
+ * a stroke on the top grid line) inside the box. Labels are HTML outside the svg, like BarChart's
13
+ * (issue 494): inside a viewBox they would scale with it.
14
+ */
15
+ export const LINE_CHART = { width: 600, height: 200, pad: 8 } as const;
16
+
17
+ /** Past this many keys a marker per point is a second, lumpier line; only the newest is marked. */
18
+ export const MARKERS_ON_EVERY_POINT_UP_TO = 24;
19
+
20
+ export interface LinePoint {
21
+ /** The key's index — a gap skips an index, so this is not the point's position in the array. */
22
+ readonly index: number;
23
+ readonly x: number;
24
+ readonly y: number;
25
+ readonly value: number;
26
+ }
27
+
28
+ export interface LineSeriesGeometry {
29
+ readonly points: readonly LinePoint[];
30
+ /** `M … L …`, one `M` per unbroken run; `''` for a series with no value. */
31
+ readonly line: string;
32
+ /** Each run of two or more points closed down to the baseline; `''` when there is none. */
33
+ readonly area: string;
34
+ }
35
+
36
+ export interface LineTick {
37
+ readonly value: number;
38
+ readonly y: number;
39
+ }
40
+
41
+ export interface LineLayout {
42
+ readonly scale: NiceScale;
43
+ readonly ticks: readonly LineTick[];
44
+ /** The y of zero — or of the axis edge nearest it, for a range that does not cross zero. */
45
+ readonly baselineY: number;
46
+ /** The x of every key, gap or not — where its axis label sits. */
47
+ readonly keyX: readonly number[];
48
+ readonly series: readonly LineSeriesGeometry[];
49
+ }
50
+
51
+ export interface LineLayoutInput {
52
+ readonly keys: readonly string[];
53
+ readonly series: readonly ChartSeries[];
54
+ /** Start the axis at zero (the honest default for counts) or fit it to the data. */
55
+ readonly zero: boolean;
56
+ readonly maxTicks: number;
57
+ }
58
+
59
+ const round = (value: number): number => Math.round(value * 10) / 10;
60
+
61
+ /**
62
+ * Every drawn value is a whole number — a count. Detected rather than asked for: a prop is one
63
+ * more thing a caller forgets, and the data already says it. One fraction anywhere turns it off.
64
+ */
65
+ function allIntegers(input: LineLayoutInput): boolean {
66
+ return input.series.every((series) =>
67
+ input.keys.every((_, i) => {
68
+ const value = finiteOrNull(series.values[i]);
69
+ return value === null || Number.isInteger(value);
70
+ }),
71
+ );
72
+ }
73
+
74
+ function extent(input: LineLayoutInput): readonly [number, number] {
75
+ let lo = Number.POSITIVE_INFINITY;
76
+ let hi = Number.NEGATIVE_INFINITY;
77
+ for (const series of input.series) {
78
+ for (let i = 0; i < input.keys.length; i += 1) {
79
+ const value = finiteOrNull(series.values[i]);
80
+ if (value === null) continue;
81
+ lo = Math.min(lo, value);
82
+ hi = Math.max(hi, value);
83
+ }
84
+ }
85
+ if (lo > hi) return [0, 1];
86
+ return input.zero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
87
+ }
88
+
89
+ function runsOf(points: readonly LinePoint[]): LinePoint[][] {
90
+ const runs: LinePoint[][] = [];
91
+ let previous = -2;
92
+ for (const point of points) {
93
+ if (point.index !== previous + 1 || runs.length === 0) runs.push([]);
94
+ runs.at(-1)?.push(point);
95
+ previous = point.index;
96
+ }
97
+ return runs;
98
+ }
99
+
100
+ const at = (point: LinePoint): string => `${point.x},${point.y}`;
101
+
102
+ export function lineLayout(input: LineLayoutInput): LineLayout {
103
+ const [lo, hi] = extent(input);
104
+ const scale = niceTicks(lo, hi, input.maxTicks, { integer: allIntegers(input) });
105
+ const { width, height, pad } = LINE_CHART;
106
+ // Halved on both sides, which leaves the ratio exact (a power-of-two scale commutes with
107
+ // rounding): `max - min` over the full float64 range overflows to Infinity, and the bottom
108
+ // tick's `Infinity / Infinity` drew `y="NaN"`.
109
+ const half = scale.max / 2 - scale.min / 2;
110
+ const yOf = (value: number): number =>
111
+ round(pad + ((scale.max / 2 - value / 2) / half) * (height - pad * 2));
112
+ const count = input.keys.length;
113
+ const xOf = (index: number): number =>
114
+ count <= 1 ? width / 2 : round(pad + (index * (width - pad * 2)) / (count - 1));
115
+ const baselineY = yOf(Math.min(scale.max, Math.max(scale.min, 0)));
116
+
117
+ const series = input.series.map((one): LineSeriesGeometry => {
118
+ const points: LinePoint[] = [];
119
+ for (let index = 0; index < count; index += 1) {
120
+ const value = finiteOrNull(one.values[index]);
121
+ if (value !== null) points.push({ index, x: xOf(index), y: yOf(value), value });
122
+ }
123
+ const runs = runsOf(points);
124
+ const line = runs
125
+ .map((run) => run.map((point, i) => `${i === 0 ? 'M' : 'L'}${at(point)}`).join(' '))
126
+ .join(' ');
127
+ const area = runs
128
+ .filter((run) => run.length > 1)
129
+ .map((run) => {
130
+ const first = run[0] as LinePoint;
131
+ const last = run.at(-1) as LinePoint;
132
+ const top = run.map((point) => `L${at(point)}`).join(' ');
133
+ return `M${first.x},${baselineY} ${top} L${last.x},${baselineY} Z`;
134
+ })
135
+ .join(' ');
136
+ return { points, line, area };
137
+ });
138
+
139
+ return {
140
+ scale,
141
+ ticks: scale.ticks.map((value) => ({ value, y: yOf(value) })),
142
+ baselineY,
143
+ keyX: input.keys.map((_, index) => xOf(index)),
144
+ series,
145
+ };
146
+ }
147
+
148
+ /** The points that carry a marker: all of them on a short series, only the newest on a dense one. */
149
+ export function markedPoints(points: readonly LinePoint[], keyCount: number): readonly LinePoint[] {
150
+ if (keyCount <= MARKERS_ON_EVERY_POINT_UP_TO) return points;
151
+ const last = points.at(-1);
152
+ return last === undefined ? [] : [last];
153
+ }
@@ -1,12 +1,7 @@
1
1
  // Pure formatting core behind <Money>. Split out so the rule "format through the
2
2
  // injected locale, never an ambient default" is testable without a renderer.
3
3
 
4
- import {
5
- type FormatMoneyOptions,
6
- formatMoney,
7
- type Money,
8
- money as makeMoney,
9
- } from '@ultimat3/money';
4
+ import { type FormatMoneyOptions, formatMoney, fromMinor, type Money } from '@ultimat3/money';
10
5
  import { invalidValueError } from '../errors';
11
6
 
12
7
  /** A bare number is minor units in the context currency. */
@@ -36,7 +31,7 @@ export function toMoney(value: MoneyInput, currency: string): Money {
36
31
  throw invalidValueError('Money', value, 'an integer number of minor units');
37
32
  }
38
33
  // Delegated so the currency code is validated by the one authority on it.
39
- return makeMoney(value, currency);
34
+ return fromMinor(value, currency);
40
35
  }
41
36
  if (!Number.isInteger(value.minor)) {
42
37
  throw invalidValueError('Money', value, 'an integer number of minor units');