@ultimat3/ui 23.0.0 → 24.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.
- package/CATALOG.md +2 -2
- package/CLAUDE.md +3 -3
- package/README.md +10 -9
- package/package.json +5 -5
- package/src/a11y.ts +24 -10
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.tsx +50 -47
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Meter.tsx +3 -3
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +6 -2
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +2 -6
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/theme/inline-script.ts +0 -49
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
113
|
-
* then refuses to submit
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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
|
|
131
|
+
export function adoptAcceptedFiles<TFile extends FileCandidate>(
|
|
119
132
|
input: FileTarget | undefined,
|
|
120
|
-
|
|
133
|
+
offered: FileList | null | undefined,
|
|
134
|
+
selection: FileSelection<TFile>,
|
|
135
|
+
origin: 'drop' | 'pick',
|
|
136
|
+
listOf: FileListOf<TFile> = dataTransferList,
|
|
121
137
|
): void {
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
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
|
}
|
|
@@ -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 ||
|
|
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
|
+
}
|
|
@@ -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
|
|
79
|
-
//
|
|
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
|
-
//
|
|
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 ===
|
|
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
|
|
162
|
-
// column `size-
|
|
163
|
-
|
|
164
|
-
for (let 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++;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// TEST-ONLY: an independent QR reader for versions 1-3, byte mode, level M — written from ISO
|
|
2
|
+
// 18004's layout, sharing no code with `qr-encode.ts`/`qr-matrix.ts`, so a placement defect in
|
|
3
|
+
// the encoder cannot be mirrored here. Never exported from `index.ts`.
|
|
4
|
+
|
|
5
|
+
/** The eight published level-M format strings (ISO 18004 Table C.1), indexed by mask pattern —
|
|
6
|
+
* a table, not a BCH computation, so the reader does not share the encoder's arithmetic. */
|
|
7
|
+
const LEVEL_M_FORMAT: readonly number[] = [
|
|
8
|
+
0b101010000010010, 0b101000100100101, 0b101111001111100, 0b101101101001011, 0b100010111111001,
|
|
9
|
+
0b100000011001110, 0b100111110010111, 0b100101010100000,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** Total and data codewords per version, level M (ISO 18004 Table 9). */
|
|
13
|
+
const CODEWORDS: ReadonlyMap<number, { readonly total: number; readonly data: number }> = new Map([
|
|
14
|
+
[1, { total: 26, data: 16 }],
|
|
15
|
+
[2, { total: 44, data: 28 }],
|
|
16
|
+
[3, { total: 70, data: 44 }],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export interface QrReading {
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly mask: number;
|
|
22
|
+
readonly codewords: readonly number[];
|
|
23
|
+
readonly text: string;
|
|
24
|
+
/** Every place the symbol departs from the standard's fixed layout, as `what@row,col`. */
|
|
25
|
+
readonly layoutFaults: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Grid = readonly (readonly boolean[])[];
|
|
29
|
+
|
|
30
|
+
const at = (grid: Grid, row: number, col: number): boolean => grid[row]?.[col] === true;
|
|
31
|
+
|
|
32
|
+
export function isFunctionModule(size: number, row: number, col: number): boolean {
|
|
33
|
+
const inCorner = (r0: number, c0: number): boolean =>
|
|
34
|
+
row >= r0 && row < r0 + 8 && col >= c0 && col < c0 + 8;
|
|
35
|
+
if (inCorner(0, 0) || inCorner(0, size - 8) || inCorner(size - 8, 0)) return true;
|
|
36
|
+
if (row === 6 || col === 6) return true;
|
|
37
|
+
if (row === 8 && (col <= 8 || col >= size - 8)) return true;
|
|
38
|
+
if (col === 8 && (row <= 8 || row >= size - 8)) return true;
|
|
39
|
+
// Versions 2-3 have exactly one alignment pattern, centred 7 modules in from the far corner.
|
|
40
|
+
if (size > 21 && Math.abs(row - (size - 7)) <= 2 && Math.abs(col - (size - 7)) <= 2) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function finderFaults(grid: Grid, size: number): string[] {
|
|
45
|
+
const faults: string[] = [];
|
|
46
|
+
for (const [r0, c0] of [
|
|
47
|
+
[0, 0],
|
|
48
|
+
[0, size - 7],
|
|
49
|
+
[size - 7, 0],
|
|
50
|
+
] as const) {
|
|
51
|
+
for (let dr = -1; dr <= 7; dr++) {
|
|
52
|
+
for (let dc = -1; dc <= 7; dc++) {
|
|
53
|
+
const row = r0 + dr;
|
|
54
|
+
const col = c0 + dc;
|
|
55
|
+
if (row < 0 || col < 0 || row >= size || col >= size) continue;
|
|
56
|
+
const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
|
|
57
|
+
const dark = ring <= 1 || ring === 3;
|
|
58
|
+
if (at(grid, row, col) !== dark) faults.push(`finder@${row},${col}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return faults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fixedFaults(grid: Grid, size: number): string[] {
|
|
66
|
+
const faults = finderFaults(grid, size);
|
|
67
|
+
for (let i = 8; i < size - 8; i++) {
|
|
68
|
+
if (at(grid, 6, i) !== (i % 2 === 0)) faults.push(`timing@6,${i}`);
|
|
69
|
+
if (at(grid, i, 6) !== (i % 2 === 0)) faults.push(`timing@${i},6`);
|
|
70
|
+
}
|
|
71
|
+
if (size > 21) {
|
|
72
|
+
const centre = size - 7;
|
|
73
|
+
for (let dr = -2; dr <= 2; dr++) {
|
|
74
|
+
for (let dc = -2; dc <= 2; dc++) {
|
|
75
|
+
const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1;
|
|
76
|
+
if (at(grid, centre + dr, centre + dc) !== dark) {
|
|
77
|
+
faults.push(`alignment@${centre + dr},${centre + dc}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!at(grid, size - 8, 8)) faults.push(`dark-module@${size - 8},8`);
|
|
83
|
+
return faults;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Cell = readonly [number, number];
|
|
87
|
+
|
|
88
|
+
/** The cells of both format copies, bit 14 first; positions straight from ISO 18004 Figure 25. */
|
|
89
|
+
export function formatCells(size: number): readonly [readonly Cell[], readonly Cell[]] {
|
|
90
|
+
const first: Cell[] = [];
|
|
91
|
+
for (let col = 0; col <= 8; col++) if (col !== 6) first.push([8, col]);
|
|
92
|
+
for (let row = 7; row >= 0; row--) if (row !== 6) first.push([row, 8]);
|
|
93
|
+
const second: Cell[] = [];
|
|
94
|
+
for (let row = size - 1; row >= size - 7; row--) second.push([row, 8]);
|
|
95
|
+
for (let col = size - 8; col < size; col++) second.push([8, col]);
|
|
96
|
+
return [first, second];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatCopies(grid: Grid, size: number): readonly [number, number] {
|
|
100
|
+
const read = (cells: readonly Cell[]): number =>
|
|
101
|
+
cells.reduce((acc, [row, col]) => (acc << 1) | (at(grid, row, col) ? 1 : 0), 0);
|
|
102
|
+
const [first, second] = formatCells(size);
|
|
103
|
+
return [read(first), read(second)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function maskFlips(mask: number, row: number, col: number): boolean {
|
|
107
|
+
const formulas: readonly ((i: number, j: number) => boolean)[] = [
|
|
108
|
+
(i, j) => (i + j) % 2 === 0,
|
|
109
|
+
(i) => i % 2 === 0,
|
|
110
|
+
(_i, j) => j % 3 === 0,
|
|
111
|
+
(i, j) => (i + j) % 3 === 0,
|
|
112
|
+
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0,
|
|
113
|
+
(i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
|
114
|
+
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0,
|
|
115
|
+
(i, j) => (((i * j) % 3) + ((i + j) % 2)) % 2 === 0,
|
|
116
|
+
];
|
|
117
|
+
return formulas[mask]?.(row, col) === true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readCodewords(grid: Grid, size: number, mask: number, count: number): number[] {
|
|
121
|
+
const bits: number[] = [];
|
|
122
|
+
// Two-column strips from the right edge; the strip that would straddle the vertical timing
|
|
123
|
+
// column moves one left, so column 6 is never read and column 0 always is.
|
|
124
|
+
const strips: number[] = [];
|
|
125
|
+
for (let right = size - 1; right >= 1; right -= 2) strips.push(right > 6 ? right : right - 1);
|
|
126
|
+
strips.forEach((right, index) => {
|
|
127
|
+
const upward = index % 2 === 0;
|
|
128
|
+
for (let step = 0; step < size; step++) {
|
|
129
|
+
const row = upward ? size - 1 - step : step;
|
|
130
|
+
for (const col of [right, right - 1]) {
|
|
131
|
+
if (isFunctionModule(size, row, col)) continue;
|
|
132
|
+
bits.push(at(grid, row, col) !== maskFlips(mask, row, col) ? 1 : 0);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const words: number[] = [];
|
|
137
|
+
for (let i = 0; i < count; i++) {
|
|
138
|
+
words.push(bits.slice(i * 8, i * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0));
|
|
139
|
+
}
|
|
140
|
+
return words;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reed-Solomon syndromes over GF(256)/0x11d: all zero exactly when no codeword is corrupt. */
|
|
144
|
+
export function syndromesZero(codewords: readonly number[], eccCount: number): boolean {
|
|
145
|
+
const exp: number[] = [];
|
|
146
|
+
let x = 1;
|
|
147
|
+
for (let i = 0; i < 255; i++) {
|
|
148
|
+
exp.push(x);
|
|
149
|
+
x = x & 0x80 ? ((x << 1) ^ 0x11d) & 0xff : x << 1;
|
|
150
|
+
}
|
|
151
|
+
const log = new Map<number, number>(exp.map((v, i) => [v, i]));
|
|
152
|
+
const mul = (a: number, b: number): number =>
|
|
153
|
+
a === 0 || b === 0 ? 0 : (exp[((log.get(a) ?? 0) + (log.get(b) ?? 0)) % 255] ?? 0);
|
|
154
|
+
for (let k = 0; k < eccCount; k++) {
|
|
155
|
+
let s = 0;
|
|
156
|
+
for (const word of codewords) s = mul(s, exp[k] ?? 1) ^ word;
|
|
157
|
+
if (s !== 0) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reads `grid` as a QR symbol. Never throws: a symbol that does not read is `text: ''` plus
|
|
163
|
+
* whatever faults explain why. */
|
|
164
|
+
export function readQr(grid: Grid): QrReading {
|
|
165
|
+
const size = grid.length;
|
|
166
|
+
const version = (size - 17) / 4;
|
|
167
|
+
const faults = fixedFaults(grid, size);
|
|
168
|
+
const [first, second] = formatCopies(grid, size);
|
|
169
|
+
if (first !== second) faults.push('format-copies-differ');
|
|
170
|
+
const mask = LEVEL_M_FORMAT.indexOf(first);
|
|
171
|
+
if (mask < 0) faults.push('format-not-level-m');
|
|
172
|
+
const total = CODEWORDS.get(version)?.total ?? 0;
|
|
173
|
+
const codewords = mask < 0 ? [] : readCodewords(grid, size, mask, total);
|
|
174
|
+
const dataCount = CODEWORDS.get(version)?.data ?? 0;
|
|
175
|
+
if (codewords.length === 0 || !syndromesZero(codewords, total - dataCount)) {
|
|
176
|
+
return { version, mask, codewords, text: '', layoutFaults: [...faults, 'reed-solomon'] };
|
|
177
|
+
}
|
|
178
|
+
const bits = codewords
|
|
179
|
+
.slice(0, dataCount)
|
|
180
|
+
.flatMap((word) => Array.from({ length: 8 }, (_, i) => (word >>> (7 - i)) & 1));
|
|
181
|
+
const field = (start: number, length: number): number =>
|
|
182
|
+
bits.slice(start, start + length).reduce((acc, bit) => (acc << 1) | bit, 0);
|
|
183
|
+
if (field(0, 4) !== 0b0100) return { version, mask, codewords, text: '', layoutFaults: faults };
|
|
184
|
+
const length = field(4, 8);
|
|
185
|
+
const bytes = Uint8Array.from({ length }, (_, i) => field(12 + i * 8, 8));
|
|
186
|
+
return { version, mask, codewords, text: new TextDecoder().decode(bytes), layoutFaults: faults };
|
|
187
|
+
}
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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',
|
package/src/form/form-binding.ts
CHANGED
|
@@ -148,15 +148,16 @@ export function createFormBinding<TValues, TResult>(
|
|
|
148
148
|
// has already changed, on the one screen where the user is watching for exactly that.
|
|
149
149
|
publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
|
|
150
150
|
|
|
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
151
|
try {
|
|
152
|
+
// Inside the `try`: a validate that throws or rejects is a failed submit, never a `submit()`
|
|
153
|
+
// that rejects with the state left at `submitting` and the control refusing every retry.
|
|
154
|
+
const schema = options.schema;
|
|
155
|
+
if (schema !== undefined) {
|
|
156
|
+
// The result's `value` is read by nothing. Deliberately: the parse below is the browser's
|
|
157
|
+
// opinion, and the only thing this file wants from it is which paths to complain about.
|
|
158
|
+
const local = issuesFromValidation(await schema['~standard'].validate(values));
|
|
159
|
+
if (local.length > 0) return failed(local);
|
|
160
|
+
}
|
|
160
161
|
const result = await options.submit(values);
|
|
161
162
|
// The server accepted what the form held, so there is nothing left to lose. `touched` stays:
|
|
162
163
|
// the user has still visited those fields, and a hint that vanishes on save is a flicker.
|
package/src/icons/build-icons.ts
CHANGED
|
@@ -11,7 +11,17 @@ import { invalidIconDataError, runtimeMissingError } from '../errors';
|
|
|
11
11
|
/** Pinned: a floating version would silently redraw icons under an app that never asked. */
|
|
12
12
|
export const LUCIDE_VERSION = '1.31.0';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** Where one pinned version's node table lives. */
|
|
15
|
+
export const iconNodesUrl = (version: string): string =>
|
|
16
|
+
`https://cdn.jsdelivr.net/npm/lucide-static@${version}/icon-nodes.json`;
|
|
17
|
+
|
|
18
|
+
export const LUCIDE_ICON_NODES_URL = iconNodesUrl(LUCIDE_VERSION);
|
|
19
|
+
|
|
20
|
+
/** The registry's answer for the newest published `lucide-static` — what `--bump` pins to. */
|
|
21
|
+
export const LUCIDE_LATEST_URL = 'https://registry.npmjs.org/lucide-static/latest';
|
|
22
|
+
|
|
23
|
+
/** The tail of every cause about data the PIN published: why re-running alone cannot help. */
|
|
24
|
+
const PIN_REPEATS = `lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this; \`--bump\` raises LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts to the latest release and regenerates`;
|
|
15
25
|
|
|
16
26
|
export const GLYPHS_DIR = new URL('./glyphs/', import.meta.url).pathname;
|
|
17
27
|
|
|
@@ -27,7 +37,7 @@ export function identifierFor(name: string): string {
|
|
|
27
37
|
* the network, so its shape is an assumption until it is checked.
|
|
28
38
|
*/
|
|
29
39
|
export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
30
|
-
const parsed
|
|
40
|
+
const parsed = parseJson(text);
|
|
31
41
|
// `typeof [] === 'object'`, and an array of icons is a different upstream format, not this one.
|
|
32
42
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
33
43
|
throw invalidIconDataError(
|
|
@@ -42,8 +52,8 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
42
52
|
// writes, so `../../index` is a delete plus an overwrite of a hand-written module.
|
|
43
53
|
if (!SAFE_ICON_NAME.test(name)) {
|
|
44
54
|
throw invalidIconDataError(
|
|
45
|
-
`carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped.
|
|
46
|
-
'bun run --filter @ultimat3/ui icons
|
|
55
|
+
`carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped. ${PIN_REPEATS}`,
|
|
56
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
47
57
|
);
|
|
48
58
|
}
|
|
49
59
|
out.set(name, toGlyph(name, value));
|
|
@@ -51,6 +61,29 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
51
61
|
return out;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** The body as JSON, or the same coded refusal a wrong shape gets — never a bare `SyntaxError`. */
|
|
65
|
+
function parseJson(text: string): unknown {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
throw invalidIconDataError(
|
|
70
|
+
`is not JSON (${renderCauseValue(text.slice(0, 80))}…); ${LUCIDE_ICON_NODES_URL} served an error page or a truncated body`,
|
|
71
|
+
'bun run --filter @ultimat3/ui icons',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A node that is not `[tag, attrs?]` with an object for attrs — refused, never skipped. */
|
|
77
|
+
function malformedNode(name: string, node: unknown): ReturnType<typeof invalidIconDataError> {
|
|
78
|
+
return invalidIconDataError(
|
|
79
|
+
`for the icon "${name}" carries ${renderCauseValue(node)} where a [tag, attributes] node belongs; skipping it would write a partial glyph. ${PIN_REPEATS}`,
|
|
80
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isAttrs = (value: unknown): value is Record<string, unknown> =>
|
|
85
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
86
|
+
|
|
54
87
|
/**
|
|
55
88
|
* An icon name, as characters: lowercase kebab-case, no leading, trailing or doubled hyphen. The
|
|
56
89
|
* same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE`, one layer up — the name is interpolated
|
|
@@ -68,10 +101,13 @@ export const SAFE_ATTR_VALUE = /^[\w\s.,+-]*$/;
|
|
|
68
101
|
|
|
69
102
|
function toGlyph(name: string, value: unknown): IconGlyph {
|
|
70
103
|
const glyph: (readonly [string, Readonly<Record<string, string>>])[] = [];
|
|
71
|
-
|
|
72
|
-
|
|
104
|
+
if (!Array.isArray(value)) throw malformedNode(name, value);
|
|
105
|
+
for (const node of value as unknown[]) {
|
|
106
|
+
if (!Array.isArray(node) || typeof node[0] !== 'string') throw malformedNode(name, node);
|
|
107
|
+
const rawAttrs: unknown = node[1] ?? {};
|
|
108
|
+
if (!isAttrs(rawAttrs)) throw malformedNode(name, node);
|
|
73
109
|
const attrs: Record<string, string> = {};
|
|
74
|
-
for (const [key, item] of Object.entries(
|
|
110
|
+
for (const [key, item] of Object.entries(rawAttrs)) {
|
|
75
111
|
// Upstream keys an element for its diff tooling; it is not an SVG attribute.
|
|
76
112
|
if (key === 'key') continue;
|
|
77
113
|
const text = String(item);
|
|
@@ -82,8 +118,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
82
118
|
// no legitimate Lucide artwork.
|
|
83
119
|
if (!SAFE_ATTR_VALUE.test(text)) {
|
|
84
120
|
throw invalidIconDataError(
|
|
85
|
-
`for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry;
|
|
86
|
-
'bun run --filter @ultimat3/ui icons
|
|
121
|
+
`for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry; ${PIN_REPEATS}`,
|
|
122
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
87
123
|
);
|
|
88
124
|
}
|
|
89
125
|
attrs[key] = text;
|
|
@@ -95,8 +131,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
95
131
|
iconElements(glyph);
|
|
96
132
|
if (glyph.length === 0) {
|
|
97
133
|
throw invalidIconDataError(
|
|
98
|
-
`for the icon "${name}" carries no renderable node data;
|
|
99
|
-
'bun run --filter @ultimat3/ui icons
|
|
134
|
+
`for the icon "${name}" carries no renderable node data; ${PIN_REPEATS}`,
|
|
135
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
100
136
|
);
|
|
101
137
|
}
|
|
102
138
|
return glyph;
|
|
@@ -136,23 +172,24 @@ export function moduleSource(name: string, glyph: IconGlyph): string {
|
|
|
136
172
|
async function format(): Promise<void> {
|
|
137
173
|
const biome = new URL('../../../../node_modules/.bin/biome', import.meta.url).pathname;
|
|
138
174
|
if (!(await Bun.file(biome).exists())) {
|
|
139
|
-
throw runtimeMissingError('the biome binary', 'bun install
|
|
175
|
+
throw runtimeMissingError('the biome binary', 'bun install');
|
|
140
176
|
}
|
|
141
|
-
|
|
177
|
+
// A formatter over one directory of generated files; a wedged one is the refusal below.
|
|
178
|
+
const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR], { timeout: 60_000 });
|
|
142
179
|
if (result.exitCode !== 0) {
|
|
143
180
|
throw runtimeMissingError(
|
|
144
181
|
'a successful `biome format` over the generated glyphs',
|
|
145
|
-
|
|
182
|
+
'bunx biome format --write packages/ui/src/icons/glyphs',
|
|
146
183
|
);
|
|
147
184
|
}
|
|
148
185
|
}
|
|
149
186
|
|
|
150
|
-
export async function buildIcons(): Promise<number> {
|
|
151
|
-
const response = await fetch(
|
|
187
|
+
export async function buildIcons(version: string = LUCIDE_VERSION): Promise<number> {
|
|
188
|
+
const response = await fetch(iconNodesUrl(version));
|
|
152
189
|
if (!response.ok) {
|
|
153
190
|
throw runtimeMissingError(
|
|
154
|
-
`lucide-static@${
|
|
155
|
-
|
|
191
|
+
`lucide-static@${version} icon data (HTTP ${response.status} from ${iconNodesUrl(version)})`,
|
|
192
|
+
'bun run --filter @ultimat3/ui icons',
|
|
156
193
|
);
|
|
157
194
|
}
|
|
158
195
|
const glyphs = parseIconNodes(await response.text());
|
|
@@ -165,8 +202,47 @@ export async function buildIcons(): Promise<number> {
|
|
|
165
202
|
return glyphs.size;
|
|
166
203
|
}
|
|
167
204
|
|
|
205
|
+
/** A plain `major.minor.patch` — the only shape allowed into a TypeScript string literal here. */
|
|
206
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
207
|
+
|
|
208
|
+
/** `source` with its `LUCIDE_VERSION` line moved to `version`. Pure; refuses rather than guesses. */
|
|
209
|
+
export function withPin(source: string, version: string): string {
|
|
210
|
+
if (!SEMVER.test(version)) {
|
|
211
|
+
throw invalidIconDataError(
|
|
212
|
+
`answered ${renderCauseValue(version)} as the latest lucide-static version, which is not plain semver; ${LUCIDE_LATEST_URL} served something else`,
|
|
213
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const pin = /^export const LUCIDE_VERSION = '[^']*';$/m;
|
|
217
|
+
if (!pin.test(source)) {
|
|
218
|
+
throw invalidIconDataError(
|
|
219
|
+
'has no `export const LUCIDE_VERSION = …` line in packages/ui/src/icons/build-icons.ts to move',
|
|
220
|
+
'git checkout -- packages/ui/src/icons/build-icons.ts',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return source.replace(pin, `export const LUCIDE_VERSION = '${version}';`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Moves the pin to the registry's latest release, in this very file, and answers the version. */
|
|
227
|
+
async function bumpPin(): Promise<string> {
|
|
228
|
+
const response = await fetch(LUCIDE_LATEST_URL);
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
throw runtimeMissingError(
|
|
231
|
+
`the latest lucide-static version (HTTP ${response.status} from ${LUCIDE_LATEST_URL})`,
|
|
232
|
+
'bun run --filter @ultimat3/ui icons --bump',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const body: unknown = await response.json();
|
|
236
|
+
const version =
|
|
237
|
+
typeof body === 'object' && body !== null && 'version' in body ? String(body.version) : '';
|
|
238
|
+
const file = new URL(import.meta.url).pathname;
|
|
239
|
+
await Bun.write(file, withPin(await Bun.file(file).text(), version));
|
|
240
|
+
return version;
|
|
241
|
+
}
|
|
242
|
+
|
|
168
243
|
if (import.meta.main) {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
244
|
+
const version = Bun.argv.includes('--bump') ? await bumpPin() : LUCIDE_VERSION;
|
|
245
|
+
const count = await buildIcons(version);
|
|
246
|
+
const json = { ok: true, icons: count, lucide: version, dir: GLYPHS_DIR };
|
|
171
247
|
console.log(Bun.argv.includes('--json') ? JSON.stringify(json) : `${count} icon modules written`);
|
|
172
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -312,12 +312,6 @@ export {
|
|
|
312
312
|
useUi,
|
|
313
313
|
} from './theme/context';
|
|
314
314
|
export { INERT_SOLID_RUNTIME } from './theme/inert-runtime';
|
|
315
|
-
export {
|
|
316
|
-
THEME_INLINE_SCRIPT,
|
|
317
|
-
themeInlineScriptCspSource,
|
|
318
|
-
themeInlineScriptHash,
|
|
319
|
-
themeInlineScriptTag,
|
|
320
|
-
} from './theme/inline-script';
|
|
321
315
|
export type { UiProviderProps } from './theme/provider';
|
|
322
316
|
export { UiProvider } from './theme/provider';
|
|
323
317
|
// The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
|
|
@@ -330,6 +324,7 @@ export type { ThemeEnv } from './theme/theme';
|
|
|
330
324
|
export {
|
|
331
325
|
browserThemeEnv,
|
|
332
326
|
clearTheme,
|
|
327
|
+
defaultTheme,
|
|
333
328
|
initTheme,
|
|
334
329
|
isTheme,
|
|
335
330
|
osTheme,
|
|
@@ -337,6 +332,7 @@ export {
|
|
|
337
332
|
setTheme,
|
|
338
333
|
storedTheme,
|
|
339
334
|
THEME_ATTRIBUTE,
|
|
335
|
+
THEME_DEFAULT_ATTRIBUTE,
|
|
340
336
|
THEME_MEDIA_QUERY,
|
|
341
337
|
THEME_STORAGE_KEY,
|
|
342
338
|
toggleTheme,
|