@ultimat3/ui 24.0.0 → 25.1.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
@@ -5,6 +5,9 @@
5
5
  // The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a
6
6
  // table and a card list cannot disagree about what "loading with stale rows" looks like. Only the
7
7
  // PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
8
+ //
9
+ // A narrow table is the SAME table restyled as labelled cards (`DataTable.module.scss`), never a
10
+ // second copy: a duplicate is read twice or, hidden, is links the reader is told are not there.
8
11
 
9
12
  import { type AsyncState, finiteCount } from '@ultimat3/core';
10
13
  import type { JSX } from 'solid-js';
@@ -14,6 +17,14 @@ import { UI_KEYS } from '../i18n-keys';
14
17
  import { useUi } from '../theme/context';
15
18
  import { type AsyncBranch, asyncBranch, isBusyBranch } from './async-branch';
16
19
  import styles from './DataTable.module.scss';
20
+ import {
21
+ type ColumnPriority,
22
+ columnPriority,
23
+ type MoreColumn,
24
+ moreColumn,
25
+ priorityAttr,
26
+ TABLE_ROLES,
27
+ } from './data-table-view';
17
28
  import { EmptyState } from './EmptyState';
18
29
  import { ErrorState } from './ErrorState';
19
30
  import { Pagination } from './Pagination';
@@ -31,8 +42,18 @@ export interface Column<Row> {
31
42
  /** Right-aligns in LTR and left-aligns in RTL via `text-align: end`. */
32
43
  numeric?: boolean | undefined;
33
44
  width?: string | undefined;
45
+ /**
46
+ * 1 (default) is a cell at every width; 2 hides the cell while the TABLE is narrower than `md`,
47
+ * 3 while it is narrower than `lg`. A hidden value moves into the row's "more" disclosure, so it
48
+ * is never lost — which renders `cell` a second time: give a priority to columns that SHOW a
49
+ * value, never to one holding a form control or a fixed `id`.
50
+ */
51
+ priority?: ColumnPriority | undefined;
34
52
  }
35
53
 
54
+ /** Below `sm`, `cards` restacks each row as a labelled card; `scroll` keeps the scrolling table. */
55
+ export type DataTableNarrow = 'cards' | 'scroll';
56
+
36
57
  interface DataTableBaseProps<Row> {
37
58
  caption: string;
38
59
  columns: readonly Column<Row>[];
@@ -49,6 +70,10 @@ interface DataTableBaseProps<Row> {
49
70
  nextCursor?: string | undefined;
50
71
  prevCursor?: string | undefined;
51
72
  stickyHeader?: boolean | undefined;
73
+ /** Pins the first column while the table scrolls sideways. */
74
+ stickyFirstColumn?: boolean | undefined;
75
+ /** What a table narrower than `sm` becomes. Default `cards`. */
76
+ narrow?: DataTableNarrow | undefined;
52
77
  density?: 'comfortable' | 'compact' | undefined;
53
78
  /** Placeholder row count while loading. Match the usual page size. */
54
79
  skeletonRows?: number | undefined;
@@ -108,6 +133,43 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
108
133
 
109
134
  const branch = (): AsyncBranch<readonly Row[]> => asyncBranch(state());
110
135
 
136
+ const cards = (): boolean => (props.narrow ?? 'cards') === 'cards';
137
+ /** Read through the validator on every render, so a bad priority is refused, never shown. */
138
+ const priorityOf = (column: Column<Row>): string | undefined =>
139
+ priorityAttr(columnPriority(column.priority));
140
+
141
+ /** The card label rides inside the cell and is `aria-hidden`: the header already names it. */
142
+ const cellContent = (column: Column<Row>, row: Row): JSX.Element =>
143
+ cards()
144
+ ? [
145
+ // No class: the attribute is the stylesheet's hook, and this span is on every cell.
146
+ <span data-label="" aria-hidden="true">
147
+ {column.header}
148
+ </span>,
149
+ column.cell(row),
150
+ ]
151
+ : column.cell(row);
152
+
153
+ /**
154
+ * The hidden columns' values, each entry shown only over the widths its own cell is hidden —
155
+ * a `<details>`, so it opens with scripting off.
156
+ */
157
+ const disclosure = (more: MoreColumn<Column<Row>>, row: Row): JSX.Element => (
158
+ <td role={TABLE_ROLES.cell} class={styles['more']} data-more={more.until}>
159
+ <details class={styles['disclosure']}>
160
+ <summary class={styles['summary']}>{ui.t(UI_KEYS.more)}</summary>
161
+ <dl class={styles['entries']}>
162
+ {more.columns.map((column) => (
163
+ <div class={styles['entry']} data-priority={priorityOf(column)}>
164
+ <dt>{column.header}</dt>
165
+ <dd>{column.cell(row)}</dd>
166
+ </div>
167
+ ))}
168
+ </dl>
169
+ </details>
170
+ </td>
171
+ );
172
+
111
173
  const body = (): JSX.Element => {
112
174
  if (branch().kind === 'pending') {
113
175
  // `Array.from({ length: NaN })` is `[]`, so a busy tbody with no placeholders in it is the
@@ -116,23 +178,33 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
116
178
  // neither, because `NaN` is not nullish. 0 stays legal: a loading table with no placeholders
117
179
  // is a choice, and it is a visible one.
118
180
  const placeholders = finiteCount('DataTable', 'skeletonRows', props.skeletonRows ?? 5, 0);
181
+ const more = moreColumn(props.columns);
119
182
  return Array.from({ length: placeholders }, () => (
120
- <tr>
121
- {props.columns.map(() => (
122
- <td>
183
+ <tr role={TABLE_ROLES.row} class={styles['row']}>
184
+ {props.columns.map((column) => (
185
+ <td role={TABLE_ROLES.cell} class={styles['cell']} data-priority={priorityOf(column)}>
123
186
  <Skeleton height="1.1em" />
124
187
  </td>
125
188
  ))}
189
+ {more === undefined ? null : (
190
+ <td role={TABLE_ROLES.cell} class={styles['more']} data-more={more.until} />
191
+ )}
126
192
  </tr>
127
193
  ));
128
194
  }
195
+ const more = moreColumn(props.columns);
129
196
  return props.rows.map((row) => (
130
- <tr data-row={props.rowKey(row)}>
197
+ <tr role={TABLE_ROLES.row} class={styles['row']} data-row={props.rowKey(row)}>
131
198
  {props.columns.map((column) => (
132
- <td class={column.numeric === true ? styles['numeric'] : undefined}>
133
- {column.cell(row)}
199
+ <td
200
+ role={TABLE_ROLES.cell}
201
+ class={cx(styles['cell'], column.numeric === true && styles['numeric'])}
202
+ data-priority={priorityOf(column)}
203
+ >
204
+ {cellContent(column, row)}
134
205
  </td>
135
206
  ))}
207
+ {more === undefined ? null : disclosure(more, row)}
136
208
  </tr>
137
209
  ));
138
210
  };
@@ -168,6 +240,10 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
168
240
  * linked, a button that calls back when it is not. A linked table with no `sortHrefFor` gets the
169
241
  * header text alone — a `<button>` on a page that never hydrates is a control that does nothing.
170
242
  */
243
+ /** Whether a header renders a control at all — the card view shows only those headers. */
244
+ const hasSortControl = (column: Column<Row>): boolean =>
245
+ column.sortable === true && (props.hrefFor === undefined || props.sortHrefFor !== undefined);
246
+
171
247
  const sortControl = (column: Column<Row>): JSX.Element => {
172
248
  if (column.sortable !== true) return column.header;
173
249
  const label = `${column.header}: ${sortLabel(column.key)}`;
@@ -201,28 +277,55 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
201
277
 
202
278
  // An array, not a fragment: this package's `.tsx` compiles to two factories (Solid's and render's
203
279
  // `h`), and only an array means the same sibling list to both.
204
- const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
205
- <Table
206
- caption={props.caption}
207
- stickyHeader={props.stickyHeader !== false}
208
- density={props.density ?? 'comfortable'}
209
- >
210
- <thead>
211
- <tr>
280
+ const header = (): JSX.Element => {
281
+ const more = moreColumn(props.columns);
282
+ return (
283
+ <thead
284
+ role={TABLE_ROLES.rowgroup}
285
+ class={styles['head']}
286
+ data-sortable={props.columns.some(hasSortControl) ? '' : undefined}
287
+ >
288
+ <tr role={TABLE_ROLES.row}>
212
289
  {props.columns.map((column) => (
213
290
  <th
214
291
  scope="col"
292
+ role={TABLE_ROLES.columnheader}
215
293
  style={column.width === undefined ? undefined : { 'inline-size': column.width }}
216
294
  aria-sort={ariaSortFor(props.sort, column.key)}
217
- class={column.numeric === true ? styles['numeric'] : undefined}
295
+ class={cx(styles['heading'], column.numeric === true && styles['numeric'])}
296
+ data-priority={priorityOf(column)}
297
+ data-sort-control={hasSortControl(column) ? '' : undefined}
218
298
  >
219
299
  {sortControl(column)}
220
300
  </th>
221
301
  ))}
302
+ {more === undefined ? null : (
303
+ <th
304
+ scope="col"
305
+ role={TABLE_ROLES.columnheader}
306
+ class={styles['more']}
307
+ data-more={more.until}
308
+ >
309
+ {ui.t(UI_KEYS.more)}
310
+ </th>
311
+ )}
222
312
  </tr>
223
313
  </thead>
314
+ );
315
+ };
316
+
317
+ const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
318
+ <Table
319
+ caption={props.caption}
320
+ stickyHeader={props.stickyHeader !== false}
321
+ stickyFirstColumn={props.stickyFirstColumn === true}
322
+ explicitRoles
323
+ density={props.density ?? 'comfortable'}
324
+ >
325
+ {header()}
224
326
  <tbody
225
- class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
327
+ role={TABLE_ROLES.rowgroup}
328
+ class={cx(styles['body'], decided.kind === 'ready' && decided.busy && styles['stale'])}
226
329
  aria-busy={ariaBool(isBusyBranch(decided))}
227
330
  >
228
331
  {body()}
@@ -249,5 +352,9 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
249
352
  return table(decided);
250
353
  };
251
354
 
252
- return <div class={cx(styles['wrap'], props.class)}>{content()}</div>;
355
+ return (
356
+ <div class={cx(styles['wrap'], props.class)} data-narrow={props.narrow ?? 'cards'}>
357
+ {content()}
358
+ </div>
359
+ );
253
360
  }
@@ -25,9 +25,7 @@ export function Divider(props: DividerProps): JSX.Element {
25
25
  // ARIA does not give a value and does not put in the tab order. `<hr>` is void, so the
26
26
  // caption has to live in a container that carries the role itself.
27
27
  // biome-ignore lint/a11y/useSemanticElements: <hr> cannot hold the caption
28
- // biome-ignore lint/a11y/useFocusableInteractive: a captioned rule is not a splitter
29
28
  <div
30
- // biome-ignore lint/a11y/useAriaPropsForRole: aria-valuenow is the splitter flavour's
31
29
  role="separator"
32
30
  aria-orientation={orientation()}
33
31
  class={cx(styles['labelled'], props.class)}
@@ -0,0 +1,73 @@
1
+ @use '../tokens' as t;
2
+
3
+ // A square box, centred, never wider than a phone column needs: the centre label is HTML laid
4
+ // over the ring, so it keeps its type step at every size (issue 494's rule).
5
+ .donut {
6
+ position: relative;
7
+ display: grid;
8
+ place-items: center;
9
+ inline-size: 100%;
10
+ max-inline-size: t.rem(224px);
11
+ margin-inline: auto;
12
+ aspect-ratio: 1;
13
+ // The ring is drawn clockwise from twelve in either direction; its overlay must agree.
14
+ direction: ltr;
15
+ }
16
+
17
+ .ring {
18
+ grid-area: 1 / 1;
19
+ display: block;
20
+ inline-size: 100%;
21
+ block-size: 100%;
22
+ }
23
+
24
+ .track {
25
+ fill: t.role('line', 0.35);
26
+ }
27
+
28
+ .segment {
29
+ fill: var(--series);
30
+ }
31
+
32
+ // The marker is cut out of the segment in the surface colour, so its SHAPE names the series.
33
+ .marker {
34
+ fill: t.role('surface');
35
+ }
36
+
37
+ .centre {
38
+ grid-area: 1 / 1;
39
+ display: grid;
40
+ justify-items: center;
41
+ gap: t.space(1);
42
+ max-inline-size: 55%;
43
+ margin: 0;
44
+ text-align: center;
45
+ pointer-events: none;
46
+ }
47
+
48
+ .total {
49
+ @include t.data-text;
50
+ font-size: t.text(xl);
51
+ font-weight: t.weight(semibold);
52
+ line-height: t.leading(tight);
53
+ color: t.role('fg-strong');
54
+ }
55
+
56
+ .caption {
57
+ @include t.label-caps;
58
+ color: t.role('fg-muted');
59
+ }
60
+
61
+ .key {
62
+ display: block;
63
+ inline-size: t.space(3);
64
+ block-size: t.space(3);
65
+ }
66
+
67
+ .keyBlock {
68
+ fill: var(--series);
69
+ }
70
+
71
+ .keyMarker {
72
+ fill: t.role('surface');
73
+ }
@@ -0,0 +1,154 @@
1
+ // Parts of one whole — traffic by device, runs by outcome — as a ring of segments with the total
2
+ // (or the caller's figure) in its centre, a legend giving each part's value and share, and a hidden
3
+ // data table. Each segment carries its series' marker shape and a gap from its neighbours, so
4
+ // the ring reads in greyscale. SVG geometry from `donut-chart-view.ts`; text is HTML.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import { useUi } from '../theme/context';
8
+ import type { ChartHit } from './ChartFrame';
9
+ import { ChartFrame, chartHits } from './ChartFrame';
10
+ import type { MarkerShape } from './chart-frame-view';
11
+ import {
12
+ chartNumberFormat,
13
+ chartPercentFormat,
14
+ chartTable,
15
+ cssPercent,
16
+ hitEdge,
17
+ markerPath,
18
+ pointLabel,
19
+ seriesStyle,
20
+ } from './chart-frame-view';
21
+ import styles from './DonutChart.module.scss';
22
+ import type { DonutArc } from './donut-chart-view';
23
+ import { annularSector, DONUT, donutArcs } from './donut-chart-view';
24
+
25
+ export interface DonutSegment {
26
+ /** The part's name, already translated. */
27
+ readonly label: string;
28
+ /** Its size; zero, negative and non-finite values are listed but not drawn. */
29
+ readonly value: number;
30
+ }
31
+
32
+ export interface DonutChartProps {
33
+ /** The chart's name, already translated: the figure's caption and the svg's accessible name. */
34
+ label: string;
35
+ /** Clockwise from twelve o'clock, in the order the legend lists them. */
36
+ segments: readonly DonutSegment[];
37
+ /** The figure in the ring's centre, already formatted. Default: the formatted total. */
38
+ centreValue?: string | undefined;
39
+ /** A short line under the centre figure, already translated — "Total", "Sessions". */
40
+ centreLabel?: string | undefined;
41
+ /** The segment column's head in the data table, already translated — "Device". */
42
+ keyLabel?: string | undefined;
43
+ /** The value column's head in the data table, already translated. Default: `label`. */
44
+ valueLabel?: string | undefined;
45
+ /** Formats every value. Default: `Intl.NumberFormat` in the page's locale. */
46
+ format?: ((value: number) => string) | undefined;
47
+ /** Give every drawn segment a tab stop and a hover/focus readout. Default off. */
48
+ focusable?: boolean | undefined;
49
+ /** Show the caption above the chart. Default off — it names the figure either way. */
50
+ showCaption?: boolean | undefined;
51
+ /** Lands on the root `<figure>`. */
52
+ class?: string | undefined;
53
+ }
54
+
55
+ const finitePositive = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);
56
+
57
+ export function DonutChart(props: DonutChartProps): JSX.Element {
58
+ const ui = useUi();
59
+ const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
60
+ const percent = (share: number): string => chartPercentFormat(ui.locale)(share);
61
+ const arcs = (): readonly DonutArc[] => donutArcs(props.segments.map((s) => s.value));
62
+ const total = (): number => props.segments.reduce((sum, s) => sum + finitePositive(s.value), 0);
63
+ const { size, centre, outer, inner, marker } = DONUT;
64
+
65
+ const hits = (): readonly ChartHit[] =>
66
+ props.segments.flatMap((segment, i) => {
67
+ const anchor = arcs()[i]?.anchor;
68
+ if (anchor === null || anchor === undefined) return [];
69
+ const share = arcs()[i]?.share ?? 0;
70
+ return [
71
+ {
72
+ x: cssPercent(anchor.x, size),
73
+ y: cssPercent(anchor.y, size),
74
+ label: pointLabel(
75
+ segment.label,
76
+ undefined,
77
+ `${format(segment.value)} (${percent(share)})`,
78
+ ),
79
+ edge: hitEdge(anchor.x, size),
80
+ },
81
+ ];
82
+ });
83
+
84
+ return (
85
+ <ChartFrame
86
+ label={props.label}
87
+ showCaption={props.showCaption}
88
+ class={props.class}
89
+ legend={props.segments.map((segment, i) => ({
90
+ label: segment.label,
91
+ value: format(segment.value),
92
+ detail: percent(arcs()[i]?.share ?? 0),
93
+ swatch: segmentSwatch(seriesStyle(i).slot, seriesStyle(i).marker),
94
+ }))}
95
+ table={chartTable({
96
+ caption: props.label,
97
+ keyLabel: props.keyLabel,
98
+ keys: props.segments.map((segment) => segment.label),
99
+ series: [
100
+ { label: props.valueLabel ?? props.label, values: props.segments.map((s) => s.value) },
101
+ ],
102
+ format,
103
+ })}
104
+ >
105
+ <div class={styles['donut']}>
106
+ <svg
107
+ role="img"
108
+ aria-label={props.label}
109
+ viewBox={`0 0 ${size} ${size}`}
110
+ class={styles['ring']}
111
+ >
112
+ {/* The track: the empty ring a chart with nothing to total still draws. */}
113
+ <path
114
+ class={styles['track']}
115
+ d={annularSector(centre, centre, outer, inner, 0, Math.PI * 2)}
116
+ />
117
+ {arcs().map((arc, i) => {
118
+ if (arc.path === '') return null;
119
+ const style = seriesStyle(i);
120
+ return (
121
+ <g data-series={style.slot} data-segment={String(i)}>
122
+ <path class={styles['segment']} d={arc.path} />
123
+ {arc.marker === null ? null : (
124
+ <path
125
+ class={styles['marker']}
126
+ d={markerPath(style.marker, arc.marker.x, arc.marker.y, marker)}
127
+ data-marker={style.marker}
128
+ />
129
+ )}
130
+ </g>
131
+ );
132
+ })}
133
+ </svg>
134
+ <p class={styles['centre']}>
135
+ <span class={styles['total']}>{props.centreValue ?? format(total())}</span>
136
+ {props.centreLabel === undefined ? null : (
137
+ <span class={styles['caption']}>{props.centreLabel}</span>
138
+ )}
139
+ </p>
140
+ {props.focusable === true ? chartHits(hits()) : null}
141
+ </div>
142
+ </ChartFrame>
143
+ );
144
+ }
145
+
146
+ /** A legend key for a segment: its colour slot with its marker shape cut out of it. */
147
+ function segmentSwatch(slot: number, shape: MarkerShape): JSX.Element {
148
+ return (
149
+ <svg class={styles['key']} viewBox="0 0 12 12" data-series={slot} aria-hidden="true">
150
+ <rect class={styles['keyBlock']} width={12} height={12} rx={2} />
151
+ <path class={styles['keyMarker']} d={markerPath(shape, 6, 6, 3.5)} />
152
+ </svg>
153
+ );
154
+ }
@@ -23,11 +23,13 @@ export interface DropzoneProps {
23
23
  name?: string | undefined;
24
24
  /** The `accept` attribute, and the same list a dropped file is partitioned against. */
25
25
  accept?: string | undefined;
26
+ /** Without it the zone takes ONE file, dropped or picked — the rest are refused by `count`. */
26
27
  multiple?: boolean | undefined;
27
28
  required?: boolean | undefined;
28
29
  disabled?: boolean | undefined;
29
30
  /** Client-side ceiling. The server enforces its own; this only spares a doomed transfer. */
30
31
  maxBytes?: number | undefined;
32
+ /** Read only with `multiple`: a single-file zone's ceiling is one. */
31
33
  maxFiles?: number | undefined;
32
34
  /** 0..1 while an upload runs. Absent renders no bar at all — never one stuck at zero. */
33
35
  progress?: number | undefined;
@@ -52,7 +54,13 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
52
54
  // The input first, then the callback: a drop that only reaches `onSelect` leaves the real
53
55
  // control empty, and a refused file left in it is posted by the form anyway.
54
56
  const offer = (files: FileList | null | undefined, origin: 'drop' | 'pick'): void => {
55
- const selection = selectFiles([...(files ?? [])], props);
57
+ // `multiple` limits a drop too: the picker enforces the attribute itself, but a drop never
58
+ // goes through the picker, so a single-file zone would otherwise take every file dropped on it.
59
+ const selection = selectFiles([...(files ?? [])], {
60
+ accept: props.accept,
61
+ maxBytes: props.maxBytes,
62
+ maxFiles: props.multiple === true ? props.maxFiles : 1,
63
+ });
56
64
  adoptAcceptedFiles(input, files, selection, origin);
57
65
  props.onSelect(selection);
58
66
  };
@@ -2,7 +2,13 @@
2
2
  // code, cause, fix. Identical text in the CLI, the overlay, and `--json` is the
3
3
  // whole point of the error contract — this component must not paraphrase.
4
4
 
5
- import { describeErrorCode, isUltimateError, renderCauseValue } from '@ultimat3/core';
5
+ import {
6
+ describeErrorCode,
7
+ isUltimateError,
8
+ renderCauseValue,
9
+ renderFixShellArg,
10
+ stringField,
11
+ } from '@ultimat3/core';
6
12
  import type { JSX } from 'solid-js';
7
13
  import { cx } from '../cx';
8
14
  import { UI_KEYS } from '../i18n-keys';
@@ -30,20 +36,31 @@ interface ErrorParts {
30
36
  }
31
37
 
32
38
  export function errorParts(error: unknown): ErrorParts {
33
- if (isUltimateError(error)) {
39
+ // Every field through `stringField`, never `error.code`: a getter or a `Proxy` trap on the value
40
+ // throws during the READ, and this is the component that renders an error — its throw replaced
41
+ // the screen reporting one with a blank tree. A field that will not read falls back to the
42
+ // registry's answer for the code, so the code itself survives whatever else is unreadable.
43
+ const code = isUltimateError(error) ? stringField(error, 'code') : undefined;
44
+ if (code !== undefined) {
45
+ const known = describeErrorCode(code);
34
46
  return {
35
- code: error.code,
36
- title: error.title,
37
- cause: error.cause,
38
- fix: error.fix,
39
- docs: error.docs,
47
+ code,
48
+ // The error's own `title` first: a remote code this realm never registered carries the
49
+ // server's title there (`remoteTitle`), and the registry would only humanise the code.
50
+ title: stringField(error, 'title') ?? known.title,
51
+ cause: stringField(error, 'cause') ?? renderCauseValue(error),
52
+ // Screened: the code came off a value the app (or a wire) built, and a fix is pasted into a
53
+ // shell. The placeholder is single-quoted so it is inert there, never a `<` redirection.
54
+ fix:
55
+ stringField(error, 'fix') ??
56
+ `x errors explain ${renderFixShellArg(code, "'<the code above>'")}`,
57
+ docs: stringField(error, 'docs') ?? known.docs,
40
58
  };
41
59
  }
42
- // `props.error` is any thrown value, so `String()` ran the app's own `toString` — and this is the
43
- // component that RENDERS an error, so its throw replaced the screen that was reporting one with a
44
- // blank tree. Laundering it through a local `message` is exactly what `scripts/error-render.ts`
45
- // says it cannot see, which is why this one shipped.
46
- const message = error instanceof Error ? error.message : renderCauseValue(error);
60
+ // `props.error` is any thrown value, so `String()` ran the app's own `toString`. Laundering it
61
+ // through a local `message` is exactly what `scripts/error-render.ts` says it cannot see, which
62
+ // is why this one shipped.
63
+ const message = thrownMessage(error) ?? renderCauseValue(error);
47
64
  // Title and docs come from core's registry, never a hand-copy: this screen must read exactly
48
65
  // as `x errors explain X_INTERNAL` does, and there is one docs URL for every code.
49
66
  const described = describeErrorCode('X_INTERNAL');
@@ -59,6 +76,16 @@ export function errorParts(error: unknown): ErrorParts {
59
76
  };
60
77
  }
61
78
 
79
+ /** An `Error`'s own message, or `undefined` when it is not one or will not be read. */
80
+ function thrownMessage(error: unknown): string | undefined {
81
+ try {
82
+ // `instanceof` runs a `Proxy`'s `getPrototypeOf` trap, so the test is inside the guard too.
83
+ return error instanceof Error ? stringField(error, 'message') : undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
62
89
  export function ErrorState(props: ErrorStateProps): JSX.Element {
63
90
  const ui = useUi();
64
91
  const parts = (): ErrorParts => errorParts(props.error);
@@ -58,8 +58,7 @@ export function Form(props: FormProps): JSX.Element {
58
58
  * the path grammar does not accept, which is the allowlist that keeps a caller's string out of a
59
59
  * selector — no accepted name can carry a quote or close the attribute.
60
60
  */
61
- const invalidControl = (): HTMLElement | null => {
62
- const name = props.invalidField;
61
+ const invalidControl = (name: string | undefined): HTMLElement | null => {
63
62
  if (name === undefined || element === undefined) return null;
64
63
  const selector = fieldSelector(name);
65
64
  return selector === null ? null : element.querySelector<HTMLElement>(selector);
@@ -68,13 +67,27 @@ export function Form(props: FormProps): JSX.Element {
68
67
  // `tabindex="-1"` alone was a focus target nothing ever aimed at: `summaryId` is internal, so no
69
68
  // caller could move focus here, and the component never did either. A failed submit that leaves
70
69
  // focus on the button leaves a keyboard user to hunt for what went wrong.
70
+ //
71
+ // Focus moves when the FAILURE changes, never merely when the effect re-runs. Bound to
72
+ // `useForm`, both props read the form's state signal, which `touch()`/`edit()` republish on every
73
+ // keystroke — so an effect that focused on each run dragged the user back to the first invalid
74
+ // field while they were fixing the second. A new submit clears both props while it is in flight,
75
+ // so a second failure naming the same field is still a change, and still moves focus.
76
+ let aimed: { readonly field: string | undefined; readonly error: string | undefined } = {
77
+ field: undefined,
78
+ error: undefined,
79
+ };
71
80
  rt.createEffect(() => {
72
- const control = invalidControl();
81
+ const field = props.invalidField;
82
+ const error = props.error;
83
+ if (field === aimed.field && error === aimed.error) return;
84
+ aimed = { field, error };
85
+ const control = invalidControl(field);
73
86
  if (control !== null) {
74
87
  control.focus();
75
88
  return;
76
89
  }
77
- if (props.error !== undefined) summary?.focus();
90
+ if (error !== undefined) summary?.focus();
78
91
  });
79
92
 
80
93
  /**
@@ -0,0 +1,48 @@
1
+ @use '../tokens' as t;
2
+
3
+ @include t.tone-classes;
4
+
5
+ // A gauge is a tile, not a row: its caption sits centred under the dial.
6
+ .gauge {
7
+ text-align: center;
8
+
9
+ // ChartFrame writes the caption first, for the reading order; a gauge SHOWS it under the dial.
10
+ > figcaption {
11
+ order: 1;
12
+ }
13
+ }
14
+
15
+ .dial {
16
+ position: relative;
17
+ inline-size: 100%;
18
+ max-inline-size: t.rem(192px);
19
+ margin-inline: auto;
20
+ }
21
+
22
+ .plot {
23
+ display: block;
24
+ inline-size: 100%;
25
+ block-size: auto;
26
+ }
27
+
28
+ .track {
29
+ fill: t.role('line', 0.4);
30
+ }
31
+
32
+ .fill {
33
+ fill: var(--tone);
34
+ }
35
+
36
+ // HTML over the dial's centre, so the figure keeps its type step at every gauge size. The ring's
37
+ // centre is not the box's — the open quarter is cropped — so `--centre` carries where it is.
38
+ .readout {
39
+ @include t.data-text;
40
+ position: absolute;
41
+ inset-block-start: var(--centre);
42
+ inset-inline: 0;
43
+ translate: 0 -50%;
44
+ font-size: t.text(xl);
45
+ font-weight: t.weight(semibold);
46
+ line-height: t.leading(tight);
47
+ color: t.role('fg-strong');
48
+ }