@ultimat3/ui 20.1.6 → 20.2.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.
@@ -0,0 +1,187 @@
1
+ // The data half of a pure-TypeScript QR encoder — byte mode, error-correction level M, no
2
+ // dependency: the framework's own docs use a sparkline pulling in a charting library as the
3
+ // cautionary example (see `BarChart.tsx`), and a QR library would be the same mistake for a
4
+ // component that draws one short URL. `qr-matrix.ts` is the placement half.
5
+ //
6
+ // SUPPORTED VERSION RANGE: 1 through 3 ONLY (21x21 through 29x29 modules), byte mode, EC level M.
7
+ // That is not a shortcut that happens to work for `HELLO WORLD` and nothing else: version 3's
8
+ // byte-mode capacity at level M is 42 bytes, and a short URL (`origin + '/' + 6-char slug`) runs
9
+ // roughly 25-30 bytes — comfortably inside version 3, and past version 1's 14-byte and version 2's
10
+ // 26-byte ceilings for anything but the shortest local origin. Encoding a longer payload (a longer
11
+ // domain, a full page URL) throws `X_UI_QR_CAPACITY` naming the byte count and the version-3
12
+ // ceiling, rather than silently truncating a link nobody could then scan.
13
+ //
14
+ // Versions 1-3 are the ones with AT MOST ONE alignment pattern (version 1 has none) and no
15
+ // version-information block (that only starts at version 7) — the two things that make the
16
+ // placement algorithm tractable without the full 1-40 table set a general-purpose encoder needs.
17
+ // Versions 1-3 at level M also always split into exactly ONE Reed-Solomon block, so no
18
+ // interleaving is needed either.
19
+
20
+ import { qrCapacityError } from '../errors';
21
+
22
+ /** The one error correction level this module implements — the format-info field for level M. */
23
+ export const EC_LEVEL_M_INDICATOR = 0b00;
24
+
25
+ /** The largest version this encoder draws. */
26
+ export const MAX_VERSION = 3;
27
+
28
+ /** Per-version constants, indexed by `version - 1`. Hardcoded because only versions 1-3 exist
29
+ * here — a general encoder would read these from the ISO 18004 tables for all 40 versions. */
30
+ const TOTAL_CODEWORDS: readonly number[] = [26, 44, 70];
31
+ const ECC_CODEWORDS_PER_BLOCK: readonly number[] = [10, 16, 26];
32
+
33
+ /** Alignment pattern center coordinates along one axis; the full set of centers is every pair
34
+ * from this list, MINUS the corner that overlaps the top-left finder pattern. Version 1 has no
35
+ * alignment pattern at all. */
36
+ export const ALIGNMENT_POSITIONS: readonly (readonly number[])[] = [[], [6, 18], [6, 22]];
37
+
38
+ const MODE_BYTE = 0b0100;
39
+ /** Versions 1-9 use an 8-bit character-count field for byte mode. */
40
+ const CHAR_COUNT_BITS = 8;
41
+
42
+ /** Data codewords at `version`: the total minus the error-correction block. */
43
+ export function dataCapacityBytes(version: number): number {
44
+ return (TOTAL_CODEWORDS[version - 1] ?? 0) - (ECC_CODEWORDS_PER_BLOCK[version - 1] ?? 0);
45
+ }
46
+
47
+ /** Mode indicator (4 bits) + character count (8 bits) precede the payload in the data stream. */
48
+ const HEADER_BITS = 4 + CHAR_COUNT_BITS;
49
+
50
+ /** The most payload bytes `version` holds: the data codewords minus the header's 12 bits, which
51
+ * cost two whole bytes once the stream is byte-aligned — 14, 26 and 42 for versions 1-3. NOT
52
+ * `dataCapacityBytes`: that is 44 at version 3, and an error naming 44 as the ceiling would
53
+ * tell the reader a 43-byte value fits when it does not. */
54
+ export function payloadCapacityBytes(version: number): number {
55
+ return Math.floor((dataCapacityBytes(version) * 8 - HEADER_BITS) / 8);
56
+ }
57
+
58
+ /** The smallest of versions 1-3 whose data capacity holds `byteLength` bytes of byte-mode
59
+ * payload, accounting for the header's own 12 bits (1.5 bytes) and a 4-bit terminator that can
60
+ * share the last byte. Throws past version 3's ceiling. */
61
+ export function pickVersion(byteLength: number): number {
62
+ for (let version = 1; version <= MAX_VERSION; version++) {
63
+ if (byteLength <= payloadCapacityBytes(version)) return version;
64
+ }
65
+ throw qrCapacityError(byteLength, payloadCapacityBytes(MAX_VERSION));
66
+ }
67
+
68
+ /** Modules per side: 21, 25, 29 for versions 1, 2, 3. */
69
+ export function sizeForVersion(version: number): number {
70
+ return 4 * version + 17;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------------------------
74
+ // GF(256) arithmetic and Reed-Solomon error correction (ISO 18004 Annex A).
75
+ // ---------------------------------------------------------------------------------------------
76
+
77
+ const GF_EXP = new Uint8Array(512);
78
+ const GF_LOG = new Uint8Array(256);
79
+
80
+ (function initGaloisField(): void {
81
+ let x = 1;
82
+ for (let i = 0; i < 255; i++) {
83
+ GF_EXP[i] = x;
84
+ GF_LOG[x] = i;
85
+ x <<= 1;
86
+ if ((x & 0x100) !== 0) x ^= 0x11d; // primitive polynomial x^8 + x^4 + x^3 + x^2 + 1
87
+ }
88
+ for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255] ?? 0;
89
+ })();
90
+
91
+ export function gfMul(a: number, b: number): number {
92
+ if (a === 0 || b === 0) return 0;
93
+ return GF_EXP[(GF_LOG[a] ?? 0) + (GF_LOG[b] ?? 0)] ?? 0;
94
+ }
95
+
96
+ /** The generator polynomial for a Reed-Solomon code with `degree` correction codewords, as the
97
+ * coefficients of (x - 2^0)(x - 2^1)...(x - 2^(degree-1)), highest degree first, monic term
98
+ * implicit. */
99
+ export function rsGeneratorPolynomial(degree: number): Uint8Array {
100
+ const coefs = new Uint8Array(degree);
101
+ coefs[degree - 1] = 1;
102
+ let root = 1;
103
+ for (let i = 0; i < degree; i++) {
104
+ for (let j = 0; j < degree; j++) {
105
+ coefs[j] = gfMul(coefs[j] ?? 0, root);
106
+ if (j + 1 < degree) coefs[j] = (coefs[j] ?? 0) ^ (coefs[j + 1] ?? 0);
107
+ }
108
+ root = gfMul(root, 2);
109
+ }
110
+ return coefs;
111
+ }
112
+
113
+ /** The Reed-Solomon remainder (the error-correction codewords) of `data` divided by the
114
+ * generator polynomial `divisor` — polynomial long division carried out in GF(256). */
115
+ export function rsRemainder(data: readonly number[], divisor: Uint8Array): Uint8Array {
116
+ const result = new Uint8Array(divisor.length);
117
+ for (const b of data) {
118
+ const factor = b ^ (result[0] ?? 0);
119
+ result.copyWithin(0, 1);
120
+ result[result.length - 1] = 0;
121
+ for (let i = 0; i < result.length; i++) {
122
+ result[i] = (result[i] ?? 0) ^ gfMul(divisor[i] ?? 0, factor);
123
+ }
124
+ }
125
+ return result;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------------------------
129
+ // Data encoding: byte mode, terminator, padding, error correction.
130
+ // ---------------------------------------------------------------------------------------------
131
+
132
+ /** A growable bit buffer, most-significant-bit first — the order every field in a QR symbol's
133
+ * data stream is written in. */
134
+ export class BitBuffer {
135
+ private bits: number[] = [];
136
+
137
+ appendBits(value: number, length: number): void {
138
+ for (let i = length - 1; i >= 0; i--) this.bits.push((value >>> i) & 1);
139
+ }
140
+
141
+ get length(): number {
142
+ return this.bits.length;
143
+ }
144
+
145
+ /** Pads to a whole byte with zero bits, then returns one byte per 8 bits. */
146
+ toBytes(): number[] {
147
+ while (this.bits.length % 8 !== 0) this.bits.push(0);
148
+ const bytes: number[] = [];
149
+ for (let i = 0; i < this.bits.length; i += 8) {
150
+ let byte = 0;
151
+ for (let j = 0; j < 8; j++) byte = (byte << 1) | (this.bits[i + j] ?? 0);
152
+ bytes.push(byte);
153
+ }
154
+ return bytes;
155
+ }
156
+ }
157
+
158
+ /** The pad codewords the standard alternates until the block is full. */
159
+ const PAD = [0xec, 0x11] as const;
160
+
161
+ /** The full codeword sequence for one QR symbol at `version`: data codewords (byte-mode payload,
162
+ * terminated and padded to capacity) followed by the Reed-Solomon error-correction codewords.
163
+ * Versions 1-3 at level M are always exactly one block, so no interleaving is needed. */
164
+ export function buildCodewords(payload: Uint8Array, version: number): number[] {
165
+ const capacity = dataCapacityBytes(version);
166
+ const buffer = new BitBuffer();
167
+ buffer.appendBits(MODE_BYTE, 4);
168
+ buffer.appendBits(payload.length, CHAR_COUNT_BITS);
169
+ for (const byte of payload) buffer.appendBits(byte, 8);
170
+
171
+ // Terminator: up to 4 zero bits, but never past the data capacity — a payload that exactly
172
+ // fills the capacity gets no terminator at all, per the standard.
173
+ const terminatorBits = Math.min(4, capacity * 8 - buffer.length);
174
+ if (terminatorBits > 0) buffer.appendBits(0, terminatorBits);
175
+
176
+ const dataBytes = buffer.toBytes();
177
+ // Pad codewords 0xEC, 0x11 alternating until the block is exactly `capacity` bytes.
178
+ let padIndex = 0;
179
+ while (dataBytes.length < capacity) {
180
+ dataBytes.push(PAD[padIndex % 2] ?? 0);
181
+ padIndex++;
182
+ }
183
+
184
+ const eccLength = ECC_CODEWORDS_PER_BLOCK[version - 1] ?? 0;
185
+ const ecc = rsRemainder(dataBytes, rsGeneratorPolynomial(eccLength));
186
+ return [...dataBytes, ...Array.from(ecc)];
187
+ }
@@ -0,0 +1,329 @@
1
+ // The placement half of the pure-TypeScript QR encoder: function patterns, data placement,
2
+ // masking and format information over the codewords `qr-encode.ts` builds. Versions 1-3 only
3
+ // (see that file's header for why): at most one alignment pattern, no version-information
4
+ // block, one Reed-Solomon block — the three simplifications that keep this file a page long.
5
+
6
+ import {
7
+ ALIGNMENT_POSITIONS,
8
+ buildCodewords,
9
+ EC_LEVEL_M_INDICATOR,
10
+ pickVersion,
11
+ sizeForVersion,
12
+ } from './qr-encode';
13
+
14
+ type Module = boolean | undefined;
15
+
16
+ /** `true`/`false` is a set module (dark/light); `undefined` is not yet claimed by any pattern —
17
+ * the placement pass below only ever writes into `undefined` cells, so a function pattern drawn
18
+ * first can never be overwritten by data. */
19
+ class Matrix {
20
+ readonly size: number;
21
+ private cells: Module[];
22
+
23
+ constructor(size: number) {
24
+ this.size = size;
25
+ this.cells = new Array<Module>(size * size).fill(undefined);
26
+ }
27
+
28
+ get(row: number, col: number): Module {
29
+ if (row < 0 || col < 0 || row >= this.size || col >= this.size) return undefined;
30
+ return this.cells[row * this.size + col];
31
+ }
32
+
33
+ set(row: number, col: number, dark: boolean): void {
34
+ this.cells[row * this.size + col] = dark;
35
+ }
36
+
37
+ isSet(row: number, col: number): boolean {
38
+ return this.get(row, col) !== undefined;
39
+ }
40
+
41
+ toBooleans(): boolean[][] {
42
+ const out: boolean[][] = [];
43
+ for (let r = 0; r < this.size; r++) {
44
+ const row: boolean[] = [];
45
+ for (let c = 0; c < this.size; c++) row.push(this.cells[r * this.size + c] === true);
46
+ out.push(row);
47
+ }
48
+ return out;
49
+ }
50
+ }
51
+
52
+ function drawFinderPattern(matrix: Matrix, centerRow: number, centerCol: number): void {
53
+ for (let dr = -4; dr <= 4; dr++) {
54
+ for (let dc = -4; dc <= 4; dc++) {
55
+ const r = centerRow + dr;
56
+ const c = centerCol + dc;
57
+ if (r < 0 || c < 0 || r >= matrix.size || c >= matrix.size) continue;
58
+ const dist = Math.max(Math.abs(dr), Math.abs(dc));
59
+ // Concentric squares: dark core (0-1), light ring (2), dark ring (3), light border (4).
60
+ const dark = dist <= 1 || dist === 3;
61
+ matrix.set(r, c, dark);
62
+ }
63
+ }
64
+ }
65
+
66
+ function drawAlignmentPattern(matrix: Matrix, centerRow: number, centerCol: number): void {
67
+ for (let dr = -2; dr <= 2; dr++) {
68
+ for (let dc = -2; dc <= 2; dc++) {
69
+ const dist = Math.max(Math.abs(dr), Math.abs(dc));
70
+ matrix.set(centerRow + dr, centerCol + dc, dist !== 1);
71
+ }
72
+ }
73
+ }
74
+
75
+ function drawFunctionPatterns(matrix: Matrix, version: number): void {
76
+ const size = matrix.size;
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.
80
+ const corners: readonly (readonly [number, number])[] = [
81
+ [3, 3],
82
+ [3, size - 4],
83
+ [size - 4, 3],
84
+ ];
85
+ 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
+
101
+ // Timing patterns: row 6 and column 6, alternating dark/light, skipping cells the finder
102
+ // patterns already claimed.
103
+ for (let i = 0; i < size; i++) {
104
+ const dark = i % 2 === 0;
105
+ if (!matrix.isSet(6, i)) matrix.set(6, i, dark);
106
+ if (!matrix.isSet(i, 6)) matrix.set(i, 6, dark);
107
+ }
108
+
109
+ // Alignment pattern(s): every (row, col) pair from this version's coordinate list, except the
110
+ // one that overlaps the top-left finder pattern.
111
+ const positions = ALIGNMENT_POSITIONS[version - 1] ?? [];
112
+ for (const row of positions) {
113
+ for (const col of positions) {
114
+ if (row === 6 && col === 6) continue;
115
+ drawAlignmentPattern(matrix, row, col);
116
+ }
117
+ }
118
+
119
+ // The single dark module every version carries at a fixed offset from the bottom-left finder
120
+ // pattern — part of the format-information area, not itself format data.
121
+ matrix.set(4 * version + 9, 8, true);
122
+
123
+ // Reserve (as light, for now) the format-information cells around the top-left finder pattern
124
+ // and the two strips mirroring it — `drawFormatInfo` overwrites the ones that carry a 1 bit.
125
+ for (let i = 0; i < 8; i++) {
126
+ if (!matrix.isSet(8, i)) matrix.set(8, i, false);
127
+ if (!matrix.isSet(i, 8)) matrix.set(i, 8, false);
128
+ if (!matrix.isSet(8, size - 1 - i)) matrix.set(8, size - 1 - i, false);
129
+ if (!matrix.isSet(size - 1 - i, 8)) matrix.set(size - 1 - i, 8, false);
130
+ }
131
+ if (!matrix.isSet(8, 8)) matrix.set(8, 8, false);
132
+ }
133
+
134
+ /** BCH(15,5) error-correction over the 5-bit format-info value (2-bit EC level + 3-bit mask
135
+ * pattern), XORed with the fixed mask 0b101010000010010 — the exact construction ISO 18004
136
+ * §8.9 specifies for the 15-bit format string every QR symbol below version 7 carries twice. */
137
+ export function formatInfoBits(maskPattern: number): number {
138
+ const GENERATOR = 0b10100110111;
139
+ const data = (EC_LEVEL_M_INDICATOR << 3) | maskPattern;
140
+ let value = data << 10;
141
+ for (let shift = 4; shift >= 0; shift--) {
142
+ if ((value & (1 << (shift + 10))) !== 0) value ^= GENERATOR << shift;
143
+ }
144
+ return ((data << 10) | value) ^ 0b101010000010010;
145
+ }
146
+
147
+ function drawFormatInfo(matrix: Matrix, maskPattern: number): void {
148
+ const size = matrix.size;
149
+ const bits = formatInfoBits(maskPattern);
150
+ const bit = (i: number): boolean => ((bits >>> i) & 1) === 1;
151
+
152
+ // The top-left strip: bits 0-5 down column 8 (skipping the timing row), bits 6-7 continue
153
+ // down the same column past row 7, bits 8-14 continue along row 8 to the right of column 8
154
+ // (skipping the timing column) — the standard's own zig from the corner.
155
+ for (let i = 0; i <= 5; i++) matrix.set(i, 8, bit(i));
156
+ matrix.set(7, 8, bit(6));
157
+ matrix.set(8, 8, bit(7));
158
+ matrix.set(8, 7, bit(8));
159
+ for (let i = 9; i <= 14; i++) matrix.set(8, 14 - i, bit(i));
160
+
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));
165
+ }
166
+
167
+ /** Whether mask `pattern` (0-7, ISO 18004 §8.8.1) flips the module at `(row, col)`. */
168
+ export function applyMask(row: number, col: number, pattern: number): boolean {
169
+ switch (pattern) {
170
+ case 0:
171
+ return (row + col) % 2 === 0;
172
+ case 1:
173
+ return row % 2 === 0;
174
+ case 2:
175
+ return col % 3 === 0;
176
+ case 3:
177
+ return (row + col) % 3 === 0;
178
+ case 4:
179
+ return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0;
180
+ case 5:
181
+ return ((row * col) % 2) + ((row * col) % 3) === 0;
182
+ case 6:
183
+ return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0;
184
+ default:
185
+ return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0;
186
+ }
187
+ }
188
+
189
+ /** Writes `codewords` into every module the function patterns left `undefined`, in the standard
190
+ * boustrophedon (up-down, right-to-left in 2-column strides, skipping the timing column) order,
191
+ * masking each written bit with `pattern` as it goes — masking happens INLINE here rather than
192
+ * as a second pass, because only the data modules (never a function pattern) are ever masked. */
193
+ function drawData(matrix: Matrix, codewords: readonly number[], pattern: number): void {
194
+ const size = matrix.size;
195
+ const bits: boolean[] = [];
196
+ for (const byte of codewords) for (let i = 7; i >= 0; i--) bits.push(((byte >>> i) & 1) === 1);
197
+
198
+ let bitIndex = 0;
199
+ let upward = true;
200
+ for (let colPair = size - 1; colPair > 0; colPair -= 2) {
201
+ for (let count = 0; count < size; count++) {
202
+ const row = upward ? size - 1 - count : count;
203
+ for (let colOffset = 0; colOffset < 2; colOffset++) {
204
+ const col = colPair - colOffset;
205
+ if (col === 6) continue; // the vertical timing column carries no data
206
+ if (matrix.isSet(row, col)) continue;
207
+ const bit = bits[bitIndex] ?? false;
208
+ bitIndex++;
209
+ const masked = applyMask(row, col, pattern) ? !bit : bit;
210
+ matrix.set(row, col, masked);
211
+ }
212
+ }
213
+ upward = !upward;
214
+ }
215
+ }
216
+
217
+ /** N1: five or more same-colour modules in a row/column. */
218
+ function runPenalty(line: readonly boolean[]): number {
219
+ let total = 0;
220
+ let runLength = 1;
221
+ for (let i = 1; i <= line.length; i++) {
222
+ if (i < line.length && line[i] === line[i - 1]) {
223
+ runLength++;
224
+ continue;
225
+ }
226
+ if (runLength >= 5) total += 3 + (runLength - 5);
227
+ runLength = 1;
228
+ }
229
+ return total;
230
+ }
231
+
232
+ /** N3: the finder-like dark-light-dark-dark-dark-light-dark run, with 4 light either side. */
233
+ const FINDER_LIKE = [true, false, true, true, true, false, true, false, false, false, false];
234
+ const FINDER_LIKE_REVERSED = [...FINDER_LIKE].reverse();
235
+
236
+ function finderLikePenalty(line: readonly boolean[]): number {
237
+ let total = 0;
238
+ const matches = (start: number, target: readonly boolean[]): boolean =>
239
+ target.every((v, i) => line[start + i] === v);
240
+ for (let i = 0; i + FINDER_LIKE.length <= line.length; i++) {
241
+ if (matches(i, FINDER_LIKE) || matches(i, FINDER_LIKE_REVERSED)) total += 40;
242
+ }
243
+ return total;
244
+ }
245
+
246
+ /** The standard penalty score (ISO 18004 §8.8.2, rules N1-N4) — lower is a better mask, chosen
247
+ * by trying all 8 candidates rather than fixing one, so this encoder's output is a symbol a
248
+ * real scanner reads reliably rather than merely a structurally valid one. */
249
+ export function maskPenalty(bools: readonly (readonly boolean[])[]): number {
250
+ const size = bools.length;
251
+ const column = (c: number): boolean[] => bools.map((row) => row[c] === true);
252
+ let penalty = 0;
253
+
254
+ for (const row of bools) penalty += runPenalty(row) + finderLikePenalty(row);
255
+ for (let c = 0; c < size; c++) {
256
+ const col = column(c);
257
+ penalty += runPenalty(col) + finderLikePenalty(col);
258
+ }
259
+
260
+ // N2: 2x2 blocks of one colour.
261
+ for (let r = 0; r < size - 1; r++) {
262
+ for (let c = 0; c < size - 1; c++) {
263
+ const v = bools[r]?.[c] ?? false;
264
+ if (bools[r]?.[c + 1] === v && bools[r + 1]?.[c] === v && bools[r + 1]?.[c + 1] === v) {
265
+ penalty += 3;
266
+ }
267
+ }
268
+ }
269
+
270
+ // N4: overall dark-module proportion, penalised the further it strays from 50%.
271
+ const dark = bools.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
272
+ const percentDark = (dark * 100) / (size * size);
273
+ penalty += Math.floor(Math.abs(percentDark - 50) / 5) * 10;
274
+
275
+ return penalty;
276
+ }
277
+
278
+ /** Light modules on every side of the symbol. Four is the standard's minimum; fewer and a
279
+ * scanner cannot separate the finder patterns from whatever the page drew next to them. */
280
+ export const DEFAULT_QUIET_ZONE = 4;
281
+
282
+ /** The quiet zone a caller asked for, screened: a non-integer, a negative or `NaN` would move
283
+ * every module by a nonsense offset, so anything but a whole count of modules is the default. */
284
+ export function quietZoneOf(requested: number | undefined): number {
285
+ if (typeof requested !== 'number' || !Number.isSafeInteger(requested) || requested < 0) {
286
+ return DEFAULT_QUIET_ZONE;
287
+ }
288
+ return requested;
289
+ }
290
+
291
+ export interface QrMatrix {
292
+ /** Modules per side — 21, 25 or 29 for versions 1, 2 and 3. */
293
+ readonly size: number;
294
+ /** `modules[row][col]` — `true` is a dark (foreground) module. */
295
+ readonly modules: readonly (readonly boolean[])[];
296
+ readonly version: number;
297
+ }
298
+
299
+ interface MaskCandidate {
300
+ readonly pattern: number;
301
+ readonly bools: boolean[][];
302
+ readonly penalty: number;
303
+ }
304
+
305
+ /**
306
+ * Encodes `text` as a QR symbol, byte mode, error-correction level M, choosing the smallest of
307
+ * versions 1-3 that fits. Throws `X_UI_QR_CAPACITY` past version 3's 42-byte ceiling. Pure: the
308
+ * same text always yields the same modules, so a server render and a hydrated one agree.
309
+ */
310
+ export function encodeQr(text: string): QrMatrix {
311
+ const payload = new TextEncoder().encode(text);
312
+ const version = pickVersion(payload.length);
313
+ const codewords = buildCodewords(payload, version);
314
+ const size = sizeForVersion(version);
315
+
316
+ let best: MaskCandidate | undefined;
317
+ for (let pattern = 0; pattern < 8; pattern++) {
318
+ const matrix = new Matrix(size);
319
+ drawFunctionPatterns(matrix, version);
320
+ drawData(matrix, codewords, pattern);
321
+ drawFormatInfo(matrix, pattern);
322
+ const bools = matrix.toBooleans();
323
+ const penalty = maskPenalty(bools);
324
+ if (best === undefined || penalty < best.penalty) best = { pattern, bools, penalty };
325
+ }
326
+ // `pickVersion` already threw if nothing fits, so by construction all eight masks ran; the
327
+ // fallback is unreachable and exists so the return type needs no assertion.
328
+ return { size, modules: best?.bools ?? [], version };
329
+ }
@@ -0,0 +1,36 @@
1
+ // The path of a Sparkline, apart from its markup. A sparkline has no axes and no scale to read —
2
+ // the shape of the trend is the entire point — so this is one `M`/`L` path through every point,
3
+ // scaled into the box. Pure, so two renders draw one line.
4
+
5
+ import type { ChartPoint } from './bar-chart-view';
6
+ import { maxOf } from './bar-chart-view';
7
+
8
+ /** 600 × 72: stretched to a content column, 240 × 48 came out ~220px tall — a chart, not a sparkline. */
9
+ export const SPARKLINE = { width: 600, height: 72, pad: 4 } as const;
10
+
11
+ export interface SparkPoint {
12
+ readonly x: number;
13
+ readonly y: number;
14
+ }
15
+
16
+ /** Every point's position in the box, oldest first; a single point sits at the start. */
17
+ export function sparkPoints(points: readonly ChartPoint[]): readonly SparkPoint[] {
18
+ const max = maxOf(points);
19
+ const innerWidth = SPARKLINE.width - SPARKLINE.pad * 2;
20
+ const innerHeight = SPARKLINE.height - SPARKLINE.pad * 2;
21
+ const step = points.length > 1 ? innerWidth / (points.length - 1) : 0;
22
+ return points.map((point, index) => {
23
+ const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
24
+ return {
25
+ x: SPARKLINE.pad + step * index,
26
+ y: SPARKLINE.pad + innerHeight - (value / max) * innerHeight,
27
+ };
28
+ });
29
+ }
30
+
31
+ /** The `d` attribute, one decimal per coordinate — stable across renders. Empty for no points. */
32
+ export function sparklinePath(points: readonly ChartPoint[]): string {
33
+ return sparkPoints(points)
34
+ .map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
35
+ .join(' ');
36
+ }
@@ -0,0 +1,30 @@
1
+ // The trend chip on a StatTile, as a pure rule: a percentage against a baseline, or nothing at
2
+ // all when the baseline is zero — a percentage of nothing is a number that means nothing.
3
+
4
+ export type StatTrend = 'up' | 'down' | 'flat';
5
+
6
+ export interface StatDelta {
7
+ /** Already formatted: "+12%", "−4%", "0%". */
8
+ readonly text: string;
9
+ readonly trend: StatTrend;
10
+ }
11
+
12
+ /**
13
+ * U+2212, the real minus: it is the width of `+`, so the chip does not jump between states.
14
+ * Rounded to a whole percent — a tile is a glance, not a ledger.
15
+ */
16
+ export function deltaOf(current: number, baseline: number): StatDelta | undefined {
17
+ if (!Number.isFinite(current) || !Number.isFinite(baseline) || baseline <= 0) return undefined;
18
+ const pct = Math.round(((current - baseline) / baseline) * 100);
19
+ if (pct === 0) return { text: '0%', trend: 'flat' };
20
+ return pct > 0
21
+ ? { text: `+${pct}%`, trend: 'up' }
22
+ : { text: `−${Math.abs(pct)}%`, trend: 'down' };
23
+ }
24
+
25
+ /** Drawn, not typed: an arrow character inherits the font's metrics and sits off-centre in a pill. */
26
+ export const DELTA_ARROW_PATH: Readonly<Record<StatTrend, string>> = {
27
+ up: 'M2 7 L5 3 L8 7',
28
+ down: 'M2 3 L5 7 L8 3',
29
+ flat: 'M2 5 L8 5',
30
+ };
package/src/errors.ts CHANGED
@@ -10,6 +10,7 @@ export const UI_ERROR_CODES = {
10
10
  invalidValue: 'X_UI_INVALID_VALUE',
11
11
  formPathInvalid: 'X_UI_FORM_PATH_INVALID',
12
12
  contrastInsufficient: 'X_UI_CONTRAST_INSUFFICIENT',
13
+ qrCapacity: 'X_UI_QR_CAPACITY',
13
14
  } as const;
14
15
 
15
16
  export type UiErrorCode = (typeof UI_ERROR_CODES)[keyof typeof UI_ERROR_CODES];
@@ -24,6 +25,7 @@ registerErrorCodes({
24
25
  X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
25
26
  X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
26
27
  X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
28
+ X_UI_QR_CAPACITY: { title: 'text is too long for a QR code this component can draw' },
27
29
  });
28
30
 
29
31
  export class UiError extends UltimateError {
@@ -205,3 +207,17 @@ export function conflictingFieldNameError(name: string, at: string): UiError {
205
207
  fix: `rename one of the two controls — a path segment holds a value or a container, never both`,
206
208
  });
207
209
  }
210
+
211
+ /**
212
+ * `<QrCode>` was handed more bytes than version 3 holds. Its own code rather than
213
+ * X_UI_INVALID_VALUE: the value is not malformed, it is too long for THIS encoder, and the fix is
214
+ * a shorter value or a full-range library — neither of which "parse it in the loader" describes.
215
+ * Refused rather than truncated, because a truncated link scans and then leads nowhere.
216
+ */
217
+ export function qrCapacityError(byteLength: number, ceiling: number): UiError {
218
+ return new UiError({
219
+ code: UI_ERROR_CODES.qrCapacity,
220
+ cause: `<QrCode> value is ${byteLength} bytes in UTF-8; @ultimat3/ui encodes byte mode up to version 3 at error-correction level M, a ${ceiling}-byte ceiling`,
221
+ fix: 'shorten the value (a short URL, not a full page URL), or draw it with a full-range QR library outside @ultimat3/ui',
222
+ });
223
+ }
package/src/i18n-keys.ts CHANGED
@@ -41,6 +41,10 @@ export const UI_KEYS = {
41
41
  loadMore: 'ui.load.more',
42
42
  /** InfiniteScroll: announced when the last page has arrived. */
43
43
  endOfList: 'ui.load.end',
44
+ /** CopyButton: the control's name before a copy. */
45
+ copy: 'ui.copy',
46
+ /** CopyButton: announced, and the control's name, once the write succeeded. */
47
+ copied: 'ui.copied',
44
48
  errorCode: 'ui.error.code',
45
49
  errorCause: 'ui.error.cause',
46
50
  errorFix: 'ui.error.fix',