@ultimat3/ui 23.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 (100) hide show
  1. package/CATALOG.md +119 -15
  2. package/CLAUDE.md +17 -8
  3. package/README.md +119 -33
  4. package/package.json +6 -6
  5. package/src/a11y.ts +27 -13
  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/CommandPalette.module.scss +8 -5
  16. package/src/components/Container.tsx +46 -9
  17. package/src/components/DataTable.module.scss +190 -0
  18. package/src/components/DataTable.tsx +161 -51
  19. package/src/components/Divider.tsx +0 -2
  20. package/src/components/DonutChart.module.scss +73 -0
  21. package/src/components/DonutChart.tsx +154 -0
  22. package/src/components/Dropzone.module.scss +3 -1
  23. package/src/components/Dropzone.tsx +17 -8
  24. package/src/components/ErrorState.tsx +39 -12
  25. package/src/components/FileInput.module.scss +3 -1
  26. package/src/components/FileInput.tsx +6 -2
  27. package/src/components/Form.tsx +17 -4
  28. package/src/components/Gauge.module.scss +48 -0
  29. package/src/components/Gauge.tsx +67 -0
  30. package/src/components/Grid.tsx +34 -13
  31. package/src/components/Image.tsx +2 -2
  32. package/src/components/InlineBar.module.scss +23 -0
  33. package/src/components/InlineBar.tsx +31 -0
  34. package/src/components/LineChart.module.scss +147 -0
  35. package/src/components/LineChart.tsx +169 -0
  36. package/src/components/Menu.tsx +9 -5
  37. package/src/components/Meter.tsx +3 -3
  38. package/src/components/PageHeader.tsx +5 -2
  39. package/src/components/Popover.module.scss +17 -20
  40. package/src/components/Popover.tsx +14 -7
  41. package/src/components/Section.tsx +45 -26
  42. package/src/components/Stack.tsx +59 -18
  43. package/src/components/Table.module.scss +30 -0
  44. package/src/components/Table.tsx +14 -3
  45. package/src/components/Tabs.module.scss +6 -0
  46. package/src/components/Tabs.tsx +2 -2
  47. package/src/components/Text.tsx +41 -15
  48. package/src/components/Textarea.tsx +7 -4
  49. package/src/components/Toaster.tsx +1 -1
  50. package/src/components/Toolbar.tsx +7 -6
  51. package/src/components/Tooltip.module.scss +8 -3
  52. package/src/components/app-shell-view.ts +6 -4
  53. package/src/components/bar-chart-view.ts +11 -6
  54. package/src/components/chart-frame-view.ts +219 -0
  55. package/src/components/chart-ticks-view.ts +173 -0
  56. package/src/components/data-table-view.ts +68 -0
  57. package/src/components/donut-chart-view.ts +120 -0
  58. package/src/components/file-input-view.ts +32 -13
  59. package/src/components/gauge-view.ts +43 -0
  60. package/src/components/heading-node.tsx +62 -0
  61. package/src/components/image-source.ts +3 -3
  62. package/src/components/line-chart-view.ts +153 -0
  63. package/src/components/link-target.ts +21 -1
  64. package/src/components/meter-view.ts +13 -0
  65. package/src/components/money-view.ts +2 -7
  66. package/src/components/qr-matrix.ts +16 -23
  67. package/src/components/relative-time-view.ts +11 -4
  68. package/src/form/form-binding.ts +32 -14
  69. package/src/form/use-form.ts +3 -3
  70. package/src/global.scss +9 -0
  71. package/src/icons/build-icons.ts +109 -24
  72. package/src/index.ts +47 -15
  73. package/src/jsx-probe.ts +5 -4
  74. package/src/theme/brand-declarations.ts +113 -0
  75. package/src/theme/brand.ts +104 -96
  76. package/src/theme/context.ts +1 -1
  77. package/src/theme/preset-scifi.ts +99 -0
  78. package/src/theme/preset-shape.ts +18 -0
  79. package/src/theme/presets.ts +15 -0
  80. package/src/theme/shadow-value.ts +39 -0
  81. package/src/theme/theme.ts +36 -11
  82. package/src/toast/toast-state.ts +0 -0
  83. package/src/toast/toast-store.ts +1 -1
  84. package/src/toast/use-toasts.ts +1 -1
  85. package/src/tokens/_colors.scss +20 -0
  86. package/src/tokens/_effects.scss +59 -0
  87. package/src/tokens/_index.scss +2 -0
  88. package/src/tokens/_mixins.scss +6 -4
  89. package/src/tokens/_shadow.scss +16 -0
  90. package/src/tokens/_touch.scss +39 -0
  91. package/src/tokens/_typography.scss +14 -0
  92. package/src/tokens/color-tokens.ts +133 -0
  93. package/src/tokens/colour-vision.ts +106 -0
  94. package/src/tokens/contrast-pairs.ts +6 -1
  95. package/src/tokens/contrast.ts +1 -1
  96. package/src/tokens/theme.scss +2 -0
  97. package/src/tokens/tokens.ts +41 -87
  98. package/src/fake-dom.ts +0 -206
  99. package/src/sass-probe.ts +0 -55
  100. package/src/theme/inline-script.ts +0 -49
@@ -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;
@@ -106,22 +106,41 @@ export interface FileTarget {
106
106
  files: FileList | null;
107
107
  }
108
108
 
109
+ /** Builds the `FileList` an input's `files` accepts — the one constructor the DOM offers for it. */
110
+ export type FileListOf<TFile extends FileCandidate> = (files: readonly TFile[]) => FileList;
111
+
112
+ function dataTransferList<TFile extends FileCandidate>(files: readonly TFile[]): FileList {
113
+ const transfer = new DataTransfer();
114
+ for (const one of files) transfer.items.add(one as unknown as File);
115
+ return transfer.files;
116
+ }
117
+
109
118
  /**
110
- * Hand a drop's files to the real `<input>`, so a dropped file behaves exactly like a picked one.
119
+ * Make the real `<input>` hold exactly the files the control ACCEPTED, so the form posts what the
120
+ * control showed and nothing it refused.
111
121
  *
112
- * Without this a `<Dropzone name="avatar" required>` inside a `<form>` shows the accepted file and
113
- * then refuses to submit: `onSelect` fired, but `input.files` is still empty, so `required` blocks
114
- * on "Please select a file" and a submit that got past it would post no file at all. Native form
115
- * participation is what the `name`/`required` props promise. `files` is assignable from a
116
- * `DataTransfer`'s own `FileList` in every current engine — that is the supported way to do this.
122
+ * A drop never reaches the input on its own: without this a `<Dropzone name="avatar" required>`
123
+ * shows the accepted file and then refuses to submit. And handing it the whole drop posted the
124
+ * files `accept` / `maxBytes` / `maxFiles` turned away — `accept` is a hint to the picker, never a
125
+ * filter on `files`. So a `drop` with something accepted is adopted as the accepted list, and a
126
+ * drop with nothing accepted leaves an earlier pick alone, as a refused drop should. A `pick`
127
+ * already set `files` itself; it is narrowed only when it refused something — to an empty list if
128
+ * it refused everything, because the refused file is the one the form would otherwise post. A list
129
+ * is BUILT (`listOf`, a `DataTransfer` in a browser) only when something was refused.
117
130
  */
118
- export function adoptDroppedFiles(
131
+ export function adoptAcceptedFiles<TFile extends FileCandidate>(
119
132
  input: FileTarget | undefined,
120
- dropped: FileList | null | undefined,
133
+ offered: FileList | null | undefined,
134
+ selection: FileSelection<TFile>,
135
+ origin: 'drop' | 'pick',
136
+ listOf: FileListOf<TFile> = dataTransferList,
121
137
  ): void {
122
- // An empty drop must not CLEAR a previous pick: the browser does not, and neither does this.
123
- if (input === undefined || dropped === null || dropped === undefined || dropped.length === 0) {
124
- return;
138
+ if (input === undefined) return;
139
+ if (origin === 'drop' && selection.accepted.length === 0) return;
140
+ if (selection.rejected.length > 0) {
141
+ input.files = listOf(selection.accepted);
142
+ } else if (origin === 'drop' && offered !== null && offered !== undefined) {
143
+ // Nothing refused: the drop's own list, which needs no constructor at all.
144
+ input.files = offered;
125
145
  }
126
- input.files = dropped;
127
146
  }
@@ -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
+ }
@@ -19,7 +19,27 @@ export interface LinkTarget {
19
19
  export function linkTarget(href: string, declaredExternal?: boolean | undefined): LinkTarget {
20
20
  const safe = safeUrl(href, 'href');
21
21
  if (safe === null) return { href: undefined, external: false };
22
- return { href: safe, external: declaredExternal === true || /^https?:\/\//.test(safe) };
22
+ return { href: safe, external: declaredExternal === true || namesAnotherOrigin(safe) };
23
+ }
24
+
25
+ /** An origin no real link can name: whatever resolves AWAY from it carried its own authority. */
26
+ const SENTINEL_ORIGIN = 'http://link-target.invalid';
27
+
28
+ /**
29
+ * Decided the way a browser resolves the href, not by its spelling: `//cdn.test`, `HTTPS://…` and
30
+ * a leading space all leave the page's origin, and a regex over the raw string read each of them
31
+ * as internal — no `noopener`, no external hint. A value the parser refuses is treated as external,
32
+ * because the hardening is the safe side of a wrong guess.
33
+ */
34
+ function namesAnotherOrigin(href: string): boolean {
35
+ try {
36
+ const url = new URL(href, `${SENTINEL_ORIGIN}/`);
37
+ return (
38
+ (url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== SENTINEL_ORIGIN
39
+ );
40
+ } catch {
41
+ return true;
42
+ }
23
43
  }
24
44
 
25
45
  /**
@@ -11,3 +11,16 @@ export function meterShare(value: number, max: number): number {
11
11
  export function meterWidth(share: number): string {
12
12
  return (share * 100).toFixed(1);
13
13
  }
14
+
15
+ /**
16
+ * The `aria-valuenow` / `aria-valuemax` pair, from the SAME rule as the fill: a meter that draws
17
+ * full must not speak "7 of 4". With no usable maximum the bar is empty, and so is the range it
18
+ * speaks — 0 of 1, because `aria-valuemax` must stay above `aria-valuemin`.
19
+ */
20
+ export function meterAria(
21
+ value: number,
22
+ max: number,
23
+ ): { readonly now: number; readonly max: number } {
24
+ if (!Number.isFinite(max) || max <= 0) return { now: 0, max: 1 };
25
+ return { now: Number.isFinite(value) ? Math.min(max, Math.max(0, value)) : 0, max };
26
+ }
@@ -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');
@@ -75,28 +75,15 @@ function drawAlignmentPattern(matrix: Matrix, centerRow: number, centerCol: numb
75
75
  function drawFunctionPatterns(matrix: Matrix, version: number): void {
76
76
  const size = matrix.size;
77
77
 
78
- // Three finder patterns, each an 8x8 area (7x7 pattern plus a light separator ring); only the
79
- // 7x7 pattern is drawn as dark/light above, so the surrounding separator is drawn explicitly.
78
+ // Three finder patterns. `drawFinderPattern`'s outermost ring (`dist === 4`) IS the light
79
+ // separator, clipped to the matrix — drawing a second ring one further out would claim row and
80
+ // column 8, the format strip and two timing modules.
80
81
  const corners: readonly (readonly [number, number])[] = [
81
82
  [3, 3],
82
83
  [3, size - 4],
83
84
  [size - 4, 3],
84
85
  ];
85
86
  for (const [row, col] of corners) drawFinderPattern(matrix, row, col);
86
- // Separators: the light ring one module beyond each finder pattern, clipped to the matrix.
87
- for (const [row, col] of corners) {
88
- for (let d = -4; d <= 4; d++) {
89
- for (const [r, c] of [
90
- [row + d, col - 5],
91
- [row + d, col + 5],
92
- [row - 5, col + d],
93
- [row + 5, col + d],
94
- ] as const) {
95
- if (r >= 0 && c >= 0 && r < size && c < size && !matrix.isSet(r, c))
96
- matrix.set(r, c, false);
97
- }
98
- }
99
- }
100
87
 
101
88
  // Timing patterns: row 6 and column 6, alternating dark/light, skipping cells the finder
102
89
  // patterns already claimed.
@@ -107,11 +94,14 @@ function drawFunctionPatterns(matrix: Matrix, version: number): void {
107
94
  }
108
95
 
109
96
  // Alignment pattern(s): every (row, col) pair from this version's coordinate list, except the
110
- // one that overlaps the top-left finder pattern.
97
+ // three that would land on a finder pattern — (first, first), (first, last) and (last, first).
111
98
  const positions = ALIGNMENT_POSITIONS[version - 1] ?? [];
99
+ const first = positions[0];
100
+ const last = positions[positions.length - 1];
112
101
  for (const row of positions) {
113
102
  for (const col of positions) {
114
- if (row === 6 && col === 6) continue;
103
+ if (row === first && (col === first || col === last)) continue;
104
+ if (row === last && col === first) continue;
115
105
  drawAlignmentPattern(matrix, row, col);
116
106
  }
117
107
  }
@@ -158,10 +148,11 @@ function drawFormatInfo(matrix: Matrix, maskPattern: number): void {
158
148
  matrix.set(8, 7, bit(8));
159
149
  for (let i = 9; i <= 14; i++) matrix.set(8, 14 - i, bit(i));
160
150
 
161
- // The mirrored copy: bits 0-7 along row `size-1` down to `size-8` in column 8, bits 8-14 up
162
- // column `size-1` down to `size-15` in row 8.
163
- for (let i = 0; i <= 7; i++) matrix.set(size - 1 - i, 8, bit(i));
164
- for (let i = 8; i <= 14; i++) matrix.set(8, size - 15 + i, bit(i));
151
+ // The second copy: bits 0-7 along row 8 from column `size-1` leftwards to `size-8`, bits 8-14
152
+ // down column 8 from row `size-7` to `size-1` — one short of the fixed dark module at
153
+ // (`size-8`, 8), which belongs to no format copy.
154
+ for (let i = 0; i <= 7; i++) matrix.set(8, size - 1 - i, bit(i));
155
+ for (let i = 8; i <= 14; i++) matrix.set(size - 15 + i, 8, bit(i));
165
156
  }
166
157
 
167
158
  /** Whether mask `pattern` (0-7, ISO 18004 §8.8.1) flips the module at `(row, col)`. */
@@ -198,11 +189,13 @@ function drawData(matrix: Matrix, codewords: readonly number[], pattern: number)
198
189
  let bitIndex = 0;
199
190
  let upward = true;
200
191
  for (let colPair = size - 1; colPair > 0; colPair -= 2) {
192
+ // The vertical timing column carries no data, so the strip that would straddle it moves one
193
+ // module left — which is also what makes column 0 the last strip's left half.
194
+ if (colPair === 6) colPair = 5;
201
195
  for (let count = 0; count < size; count++) {
202
196
  const row = upward ? size - 1 - count : count;
203
197
  for (let colOffset = 0; colOffset < 2; colOffset++) {
204
198
  const col = colPair - colOffset;
205
- if (col === 6) continue; // the vertical timing column carries no data
206
199
  if (matrix.isSet(row, col)) continue;
207
200
  const bit = bits[bitIndex] ?? false;
208
201
  bitIndex++;
@@ -27,16 +27,23 @@ const THRESHOLDS: readonly Threshold[] = [
27
27
  { unit: 'second', ms: 1000 },
28
28
  ];
29
29
 
30
+ const unitMs = (index: number): number => (THRESHOLDS[index] as Threshold).ms;
31
+ const roundIn = (delta: number, index: number): number => Math.round(delta / unitMs(index));
32
+
30
33
  export function relativeTimeText(options: RelativeTimeOptions): string {
31
34
  const target = toDate(options.value).getTime();
32
35
  const base = options.now === undefined ? Date.now() : toDate(options.now).getTime();
33
36
  const delta = target - base;
34
37
  const magnitude = Math.abs(delta);
35
38
 
36
- const threshold =
37
- THRESHOLDS.find((candidate) => magnitude >= candidate.ms) ??
38
- (THRESHOLDS[THRESHOLDS.length - 1] as Threshold);
39
- const value = Math.round(delta / threshold.ms);
39
+ let index = THRESHOLDS.findIndex((candidate) => magnitude >= candidate.ms);
40
+ if (index < 0) index = THRESHOLDS.length - 1;
41
+ // Promote AFTER rounding: 59.6 minutes rounds to 60, and "60 minutes ago" is "1 hour ago".
42
+ while (index > 0 && Math.abs(roundIn(delta, index)) * unitMs(index) >= unitMs(index - 1)) {
43
+ index -= 1;
44
+ }
45
+ const threshold = THRESHOLDS[index] as Threshold;
46
+ const value = roundIn(delta, index);
40
47
 
41
48
  const formatter = new Intl.RelativeTimeFormat(options.locale, {
42
49
  numeric: options.numeric ?? 'auto',
@@ -99,13 +99,19 @@ function declaredFields(fields: readonly string[]): ReadonlySet<string> {
99
99
  return declared;
100
100
  }
101
101
 
102
- export function createFormBinding<TValues, TResult>(
102
+ export function formBinding<TValues, TResult>(
103
103
  options: FormBindingOptions<TValues, TResult>,
104
104
  ): FormBinding<TValues, TResult> {
105
105
  const fields = declaredFields(options.fields);
106
106
  let state: FormState<TResult> = IDLE_FORM_STATE;
107
107
  let touch: FormTouch = NO_FORM_TOUCH;
108
108
  let inFlight: Promise<FormState<TResult>> | null = null;
109
+ /**
110
+ * The paths changed since the in-flight submit read the form, or `null` with none in flight.
111
+ * Every edit counts, a revert to `initial` included: the server now holds what was submitted,
112
+ * not the opening value, so the baseline cannot say whether an edit made after the read is lost.
113
+ */
114
+ let editedInFlight: Set<string> | null = null;
109
115
 
110
116
  /**
111
117
  * A transition names the submit's own members; `touched`/`dirty` are added here, from the one
@@ -146,24 +152,32 @@ export function createFormBinding<TValues, TResult>(
146
152
  const run = async (values: TValues): Promise<FormState<TResult>> => {
147
153
  // Cleared, never carried: a stale message would mark a control invalid for a value the user
148
154
  // has already changed, on the one screen where the user is watching for exactly that.
155
+ // Opened BEFORE the publish: `onState` is synchronous, so a subscriber can edit in reaction
156
+ // to `submitting` itself, and that edit already postdates what this submit read.
157
+ editedInFlight = new Set();
149
158
  publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
150
159
 
151
- const schema = options.schema;
152
- if (schema !== undefined) {
153
- // The result's `value` is read by nothing. Deliberately: the parse below is the browser's
154
- // opinion, and the only thing this file wants from it is which paths to complain about.
155
- const local = issuesFromValidation(await schema['~standard'].validate(values));
156
- if (local.length > 0) return failed(local);
157
- }
158
-
159
160
  try {
161
+ // Inside the `try`: a validate that throws or rejects is a failed submit, never a `submit()`
162
+ // that rejects with the state left at `submitting` and the control refusing every retry.
163
+ const schema = options.schema;
164
+ if (schema !== undefined) {
165
+ // The result's `value` is read by nothing. Deliberately: the parse below is the browser's
166
+ // opinion, and the only thing this file wants from it is which paths to complain about.
167
+ const local = issuesFromValidation(await schema['~standard'].validate(values));
168
+ if (local.length > 0) return failed(local);
169
+ }
160
170
  const result = await options.submit(values);
161
- // The server accepted what the form held, so there is nothing left to lose. `touched` stays:
162
- // the user has still visited those fields, and a hint that vanishes on save is a flicker.
163
- touch = { touched: touch.touched, dirty: NO_FORM_TOUCH.dirty };
171
+ // The server accepted what the form held WHEN IT SUBMITTED, so only what changed since is
172
+ // left to lose — clearing that too let a navigation guard drop an edit typed during the save.
173
+ // `touched` stays: the user has still visited those fields, and a hint that vanishes on save
174
+ // is a flicker.
175
+ touch = { touched: touch.touched, dirty: editedInFlight ?? NO_FORM_TOUCH.dirty };
164
176
  return publish({ status: 'succeeded', ...NO_FORM_ERRORS, result, issues: [] });
165
177
  } catch (rejection) {
166
178
  return failed(issuesFromRejection(rejection));
179
+ } finally {
180
+ editedInFlight = null;
167
181
  }
168
182
  };
169
183
 
@@ -183,10 +197,14 @@ export function createFormBinding<TValues, TResult>(
183
197
  pending: () => state.status === 'submitting',
184
198
  firstInvalidField: () => firstInvalidField(state, fields),
185
199
  touch: (path) => publishTouch(markTouched(touch, path)),
186
- edit: (path, value) =>
187
- publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path)))),
200
+ edit: (path, value) => {
201
+ editedInFlight?.add(path);
202
+ publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path))));
203
+ },
188
204
  reset: () => {
189
205
  touch = NO_FORM_TOUCH;
206
+ // A reset mid-flight discards what was typed before it, the in-flight window's share too.
207
+ if (editedInFlight !== null) editedInFlight = new Set();
190
208
  publish(IDLE_FORM_STATE);
191
209
  },
192
210
  };
@@ -1,11 +1,11 @@
1
- // The reactive shell over `createFormBinding`, and the whole of what it adds: the state lands in a
1
+ // The reactive shell over `formBinding`, and the whole of what it adds: the state lands in a
2
2
  // signal, so a `<Field error={form.errorFor('title')}>` re-renders when a submit answers.
3
3
  //
4
4
  // Reactivity goes through `solid()` like every other read in this package — a component that
5
5
  // imported solid-js directly would make @ultimat3/ui depend on a runtime it deliberately does not.
6
6
 
7
7
  import { solid } from '../theme/solid-adapter';
8
- import { createFormBinding, type FormBinding, type FormBindingOptions } from './form-binding';
8
+ import { type FormBinding, type FormBindingOptions, formBinding } from './form-binding';
9
9
  import {
10
10
  errorOf,
11
11
  type FormState,
@@ -24,7 +24,7 @@ export function useForm<TValues, TResult>(
24
24
  ): FormBinding<TValues, TResult> {
25
25
  const runtime = solid();
26
26
  const [state, setState] = runtime.createSignal<FormState<TResult>>(IDLE_FORM_STATE);
27
- const binding = createFormBinding<TValues, TResult>({
27
+ const binding = formBinding<TValues, TResult>({
28
28
  ...options,
29
29
  onState: (next) => {
30
30
  options.onState?.(next);
package/src/global.scss CHANGED
@@ -5,3 +5,12 @@
5
5
 
6
6
  @use 'tokens/theme';
7
7
  @use 'tokens/reset';
8
+
9
+ // A held island's reveal that needs no script (`@ultimat3/render`'s `ISLAND_HOLD_REVEAL`, issue 506):
10
+ // its wrapper animates to this after the hold's cap, so JS off or a blocked runtime still shows
11
+ // the server's markup. The name is held equal to render's by `island-hold-replay.test.ts`.
12
+ @keyframes ultimate-hold-reveal {
13
+ to {
14
+ visibility: visible;
15
+ }
16
+ }