@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.
@@ -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
  }
@@ -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
+ }
@@ -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++;
@@ -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
- 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',
@@ -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.
@@ -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
- export const LUCIDE_ICON_NODES_URL = `https://cdn.jsdelivr.net/npm/lucide-static@${LUCIDE_VERSION}/icon-nodes.json`;
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: unknown = JSON.parse(text);
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. lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
46
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
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
- for (const node of Array.isArray(value) ? (value as unknown[]) : []) {
72
- if (!Array.isArray(node) || typeof node[0] !== 'string') continue;
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((node[1] ?? {}) as Record<string, unknown>)) {
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; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
86
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
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; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
99
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
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, then re-run this generator');
175
+ throw runtimeMissingError('the biome binary', 'bun install');
140
176
  }
141
- const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR]);
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
- `run: ${biome} format --write ${GLYPHS_DIR}`,
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(LUCIDE_ICON_NODES_URL);
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@${LUCIDE_VERSION} icon data (HTTP ${response.status})`,
155
- `check network access to ${LUCIDE_ICON_NODES_URL}, then re-run: bun run --filter @ultimat3/ui icons`,
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 count = await buildIcons();
170
- const json = { ok: true, icons: count, lucide: LUCIDE_VERSION, dir: GLYPHS_DIR };
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,