@ultimat3/ui 20.1.6 → 20.2.1
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 +100 -1
- package/CLAUDE.md +7 -3
- package/README.md +1 -0
- package/package.json +10 -6
- package/src/components/BarChart.module.scss +45 -0
- package/src/components/BarChart.tsx +78 -0
- package/src/components/CommandPalette.module.scss +110 -0
- package/src/components/CommandPalette.tsx +127 -0
- package/src/components/CopyButton.module.scss +40 -0
- package/src/components/CopyButton.tsx +62 -0
- package/src/components/Kbd.module.scss +15 -0
- package/src/components/Kbd.tsx +17 -0
- package/src/components/Meter.module.scss +18 -0
- package/src/components/Meter.tsx +58 -0
- package/src/components/QrCode.module.scss +41 -0
- package/src/components/QrCode.tsx +49 -0
- package/src/components/Sparkline.module.scss +20 -0
- package/src/components/Sparkline.tsx +40 -0
- package/src/components/StatTile.module.scss +86 -0
- package/src/components/StatTile.tsx +49 -0
- package/src/components/bar-chart-view.ts +55 -0
- package/src/components/command-palette-view.ts +61 -0
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-encode.ts +187 -0
- package/src/components/qr-matrix.ts +329 -0
- package/src/components/sparkline-view.ts +36 -0
- package/src/components/stat-delta.ts +30 -0
- package/src/error-registry.ts +23 -0
- package/src/errors.ts +21 -13
- package/src/i18n-keys.ts +4 -0
- package/src/index.ts +39 -1
- package/src/theme/ambient-slot.ts +20 -0
- package/src/theme/ambient.browser.ts +11 -0
- package/src/theme/ambient.ts +37 -0
- package/src/theme/context.ts +33 -38
- package/src/theme/inline-script.ts +5 -0
- package/src/theme/provider.tsx +5 -2
- package/src/tokens/_mixins.scss +30 -0
- package/src/tokens/reset.scss +7 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @ultimat3/ui's error TITLES, registered at import. Its own module, and the one `sideEffects`
|
|
2
|
+
// names, so `errors.ts` — the constructors — can be declared pure: a barrel re-export of a pure
|
|
3
|
+
// module costs a chunk nothing, while a module declared side-effectful is kept by every bundler
|
|
4
|
+
// that honours the field the moment the barrel is imported, which under Bun 1.4.2 put core's
|
|
5
|
+
// whole error registry (~10 kB) into an island that did nothing but register a runtime.
|
|
6
|
+
//
|
|
7
|
+
// `errors.ts` imports this file bare, so a chunk that constructs a `UiError` still registers its
|
|
8
|
+
// titles first — anchored by use, exactly as `scripts/lib/side-effects-scan.ts` describes.
|
|
9
|
+
|
|
10
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
// Unconditional like every other package: every code here is ui's own, and a second package
|
|
13
|
+
// claiming one has to throw X_ERROR_CODE_DUPLICATE at import. Taking the process down there is the
|
|
14
|
+
// point — the alternative is two packages shipping two meanings for one code, decided by load order.
|
|
15
|
+
registerErrorCodes({
|
|
16
|
+
X_TOKEN_UNKNOWN: { title: 'design token role does not exist' },
|
|
17
|
+
X_THEME_INVALID: { title: 'theme is not "light" or "dark"' },
|
|
18
|
+
X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
|
|
19
|
+
X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
|
|
20
|
+
X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
|
|
21
|
+
X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
|
|
22
|
+
X_UI_QR_CAPACITY: { title: 'text is too long for a QR code this component can draw' },
|
|
23
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @ultimat3/ui error codes. Every throw carries a stable code, the cause, and
|
|
2
2
|
// the exact fix — identical in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
5
|
+
// The registration is `error-registry.ts`, imported bare: this module is then side-effect-free
|
|
6
|
+
// as `package.json` declares it, so a bundler that honours `sideEffects` (Bun 1.4.2 does; 1.4.0
|
|
7
|
+
// ignored the array, oven-sh/bun#40650) drops it from a chunk that reaches it only through the
|
|
8
|
+
// barrel — and keeps it, registration included, wherever a constructor below is actually called.
|
|
9
|
+
import './error-registry';
|
|
5
10
|
|
|
6
11
|
export const UI_ERROR_CODES = {
|
|
7
12
|
tokenUnknown: 'X_TOKEN_UNKNOWN',
|
|
@@ -10,22 +15,11 @@ export const UI_ERROR_CODES = {
|
|
|
10
15
|
invalidValue: 'X_UI_INVALID_VALUE',
|
|
11
16
|
formPathInvalid: 'X_UI_FORM_PATH_INVALID',
|
|
12
17
|
contrastInsufficient: 'X_UI_CONTRAST_INSUFFICIENT',
|
|
18
|
+
qrCapacity: 'X_UI_QR_CAPACITY',
|
|
13
19
|
} as const;
|
|
14
20
|
|
|
15
21
|
export type UiErrorCode = (typeof UI_ERROR_CODES)[keyof typeof UI_ERROR_CODES];
|
|
16
22
|
|
|
17
|
-
// Unconditional like every other package: every code here is ui's own, and a second package
|
|
18
|
-
// claiming one has to throw X_ERROR_CODE_DUPLICATE at import. Taking the process down there is the
|
|
19
|
-
// point — the alternative is two packages shipping two meanings for one code, decided by load order.
|
|
20
|
-
registerErrorCodes({
|
|
21
|
-
X_TOKEN_UNKNOWN: { title: 'design token role does not exist' },
|
|
22
|
-
X_THEME_INVALID: { title: 'theme is not "light" or "dark"' },
|
|
23
|
-
X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
|
|
24
|
-
X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
|
|
25
|
-
X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
|
|
26
|
-
X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
|
|
27
|
-
});
|
|
28
|
-
|
|
29
23
|
export class UiError extends UltimateError {
|
|
30
24
|
override readonly name: string = 'UiError';
|
|
31
25
|
|
|
@@ -205,3 +199,17 @@ export function conflictingFieldNameError(name: string, at: string): UiError {
|
|
|
205
199
|
fix: `rename one of the two controls — a path segment holds a value or a container, never both`,
|
|
206
200
|
});
|
|
207
201
|
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `<QrCode>` was handed more bytes than version 3 holds. Its own code rather than
|
|
205
|
+
* X_UI_INVALID_VALUE: the value is not malformed, it is too long for THIS encoder, and the fix is
|
|
206
|
+
* a shorter value or a full-range library — neither of which "parse it in the loader" describes.
|
|
207
|
+
* Refused rather than truncated, because a truncated link scans and then leads nowhere.
|
|
208
|
+
*/
|
|
209
|
+
export function qrCapacityError(byteLength: number, ceiling: number): UiError {
|
|
210
|
+
return new UiError({
|
|
211
|
+
code: UI_ERROR_CODES.qrCapacity,
|
|
212
|
+
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`,
|
|
213
|
+
fix: 'shorten the value (a short URL, not a full page URL), or draw it with a full-range QR library outside @ultimat3/ui',
|
|
214
|
+
});
|
|
215
|
+
}
|
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',
|