@ultimat3/ui 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +45 -14
- package/CLAUDE.md +16 -52
- package/README.md +69 -13
- package/package.json +6 -5
- package/src/a11y.ts +24 -10
- package/src/catalog/parse-component.ts +85 -12
- package/src/catalog/render-catalog.ts +21 -1
- package/src/components/Avatar.module.scss +1 -1
- package/src/components/BarChart.module.scss +12 -1
- package/src/components/BarChart.tsx +41 -15
- package/src/components/Button.tsx +3 -8
- package/src/components/Card.module.scss +1 -1
- package/src/components/Checkbox.module.scss +4 -4
- package/src/components/Checkbox.tsx +6 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +135 -71
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +6 -4
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +4 -2
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.module.scss +2 -2
- package/src/components/Input.module.scss +1 -1
- package/src/components/Link.tsx +64 -12
- package/src/components/Meter.tsx +3 -3
- package/src/components/Pagination.tsx +89 -37
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Radio.module.scss +2 -2
- package/src/components/Select.module.scss +3 -3
- package/src/components/Skeleton.module.scss +1 -1
- package/src/components/Spinner.module.scss +3 -3
- package/src/components/Switch.module.scss +2 -2
- package/src/components/Table.module.scss +2 -2
- package/src/components/Tabs.module.scss +11 -5
- package/src/components/Textarea.tsx +7 -4
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +35 -5
- package/src/components/button-classes.ts +25 -0
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +34 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +15 -9
- package/src/sass-probe.ts +55 -0
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/tokens/_index.scss +3 -1
- package/src/tokens/_mixins.scss +56 -8
- package/src/tokens/_motion.scss +4 -0
- package/src/tokens/_stroke.scss +20 -0
- package/src/tokens/_units.scss +47 -0
- package/src/tokens/reset.scss +4 -4
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +6 -0
- package/src/theme/inline-script.ts +0 -49
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
border-inline-end: 1px solid t.role('line');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// On the list's inline-END edge, beside its border. A shadow's x offset is physical, so RTL —
|
|
22
|
+
// where that edge is the left one — gets the mirrored sign, as `Switch.module.scss` does.
|
|
21
23
|
.tab[aria-selected='true'] {
|
|
22
|
-
box-shadow: inset -
|
|
24
|
+
box-shadow: inset calc(-1 * #{t.stroke(thick)}) 0 0 0 t.role('accent');
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
[dir='rtl'] .orientation-vertical .tab[aria-selected='true'] {
|
|
29
|
+
box-shadow: inset t.stroke(thick) 0 0 0 t.role('accent');
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
.list {
|
|
27
33
|
@include t.custom-scrollbar;
|
|
28
34
|
display: flex;
|
|
@@ -32,7 +38,7 @@
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
.tab {
|
|
35
|
-
@include t.focus-ring(
|
|
41
|
+
@include t.focus-ring('accent', t.stroke(thick), calc(-1 * #{t.stroke(thick)}));
|
|
36
42
|
@include t.transition(color, box-shadow, background);
|
|
37
43
|
padding-block: t.space(2);
|
|
38
44
|
padding-inline: t.space(3);
|
|
@@ -54,7 +60,7 @@
|
|
|
54
60
|
&[aria-selected='true'] {
|
|
55
61
|
color: t.role('accent');
|
|
56
62
|
// Inset shadow instead of a border, so the tab does not shift on select.
|
|
57
|
-
box-shadow: inset 0 -
|
|
63
|
+
box-shadow: inset 0 calc(-1 * #{t.stroke(thick)}) 0 0 t.role('accent');
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -62,7 +68,7 @@
|
|
|
62
68
|
min-inline-size: 0;
|
|
63
69
|
|
|
64
70
|
&:focus-visible {
|
|
65
|
-
outline:
|
|
66
|
-
outline-offset:
|
|
71
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
72
|
+
outline-offset: t.stroke(thick);
|
|
67
73
|
}
|
|
68
74
|
}
|
|
@@ -28,11 +28,13 @@ export interface TextareaProps {
|
|
|
28
28
|
/**
|
|
29
29
|
* `<textarea>` has NO `value` attribute — its value is its text content, and the parser drops the
|
|
30
30
|
* attribute silently, so `value={…}` rendered every admin edit box empty whatever the row held.
|
|
31
|
-
*
|
|
32
|
-
* newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
-
* starts with a newline survive the round trip.
|
|
31
|
+
* On the SERVER the leading newline is the serializer's, not the value's: the HTML parser strips
|
|
32
|
+
* exactly one newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
+
* itself starts with a newline survive the round trip. In a BROWSER no parser runs — an island
|
|
34
|
+
* builds the element — so the value is the `.value` property, and the newline would be data.
|
|
34
35
|
*/
|
|
35
36
|
export function Textarea(props: TextareaProps): JSX.Element {
|
|
37
|
+
const onServer = typeof document === 'undefined';
|
|
36
38
|
return (
|
|
37
39
|
<textarea
|
|
38
40
|
class={cx(styles['textarea'], props.autoGrow === true && styles['autoGrow'], props.class)}
|
|
@@ -49,8 +51,9 @@ export function Textarea(props: TextareaProps): JSX.Element {
|
|
|
49
51
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
50
52
|
onInput={props.onInput}
|
|
51
53
|
onBlur={props.onBlur}
|
|
54
|
+
value={onServer ? undefined : (props.value ?? '')}
|
|
52
55
|
>
|
|
53
|
-
{`\n${props.value ?? ''}`}
|
|
56
|
+
{onServer ? `\n${props.value ?? ''}` : undefined}
|
|
54
57
|
</textarea>
|
|
55
58
|
);
|
|
56
59
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
align-items: flex-start;
|
|
39
39
|
gap: t.space(3);
|
|
40
40
|
padding: t.space(4);
|
|
41
|
-
border-inline-start:
|
|
41
|
+
border-inline-start: t.stroke(heavy) solid var(--toast-accent);
|
|
42
42
|
color: t.role('fg');
|
|
43
43
|
pointer-events: auto;
|
|
44
44
|
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
|
|
57
57
|
@starting-style {
|
|
58
58
|
opacity: 0;
|
|
59
|
-
translate: 0
|
|
59
|
+
translate: 0 t.space(3);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
@include t.transition(opacity, translate);
|
|
10
10
|
position: absolute;
|
|
11
11
|
z-index: t.z(tooltip);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Centred over the trigger in either direction: auto margins inside an inset band far wider than
|
|
13
|
+
// any bubble. A 50% logical inset with a -50% physical translate lands a whole width off-centre
|
|
14
|
+
// under `dir="rtl"` (`rtl-sheets.test.ts`).
|
|
15
|
+
inset-inline: -100vw;
|
|
16
|
+
margin-inline: auto;
|
|
17
|
+
inline-size: max-content;
|
|
18
|
+
translate: 0 t.space(1);
|
|
14
19
|
max-inline-size: 18rem;
|
|
15
20
|
padding-block: t.space(1);
|
|
16
21
|
padding-inline: t.space(2);
|
|
@@ -36,6 +41,6 @@
|
|
|
36
41
|
// Hover and keyboard focus both reveal it — a hover-only tooltip is inaccessible.
|
|
37
42
|
.wrap:hover .bubble,
|
|
38
43
|
.wrap:focus-within .bubble {
|
|
39
|
-
translate:
|
|
44
|
+
translate: 0 0;
|
|
40
45
|
opacity: 1;
|
|
41
46
|
}
|
|
@@ -5,6 +5,11 @@ export interface ChartPoint {
|
|
|
5
5
|
/** The bar's name — a day, a bucket, a label. Read by `<title>` and the axis. */
|
|
6
6
|
readonly key: string;
|
|
7
7
|
readonly value: number;
|
|
8
|
+
/**
|
|
9
|
+
* A second series, stacked on `value` — failed on top of done. Absent or 0 draws nothing, so a
|
|
10
|
+
* one-series chart is exactly the chart it was.
|
|
11
|
+
*/
|
|
12
|
+
readonly secondary?: number;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
export interface BarRect {
|
|
@@ -24,11 +29,16 @@ export const BAR_CHART = { width: 600, top: 12, plot: 104, height: 128, gap: 3 }
|
|
|
24
29
|
/** Quarter lines. Four is what a reader can use to judge a bar's height without a y-axis. */
|
|
25
30
|
export const GRID_STEPS = [0.25, 0.5, 0.75, 1] as const;
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
const finite = (value: number | undefined): number =>
|
|
33
|
+
value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
34
|
+
|
|
35
|
+
/** The busiest point — its whole stack — and never below 1, so an all-zero series still divides. */
|
|
28
36
|
export function maxOf(points: readonly ChartPoint[]): number {
|
|
29
37
|
let max = 1;
|
|
30
|
-
for (const point of points)
|
|
31
|
-
|
|
38
|
+
for (const point of points) {
|
|
39
|
+
const total = finite(point.value) + finite(point.secondary);
|
|
40
|
+
if (total > max) max = total;
|
|
41
|
+
}
|
|
32
42
|
return max;
|
|
33
43
|
}
|
|
34
44
|
|
|
@@ -40,16 +50,36 @@ export function gridY(step: number): number {
|
|
|
40
50
|
/** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
|
|
41
51
|
export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
|
|
42
52
|
const count = Math.max(points.length, 1);
|
|
43
|
-
|
|
53
|
+
// The gap gives way before the bars do: at most half of each bar's slot, so past the 201 points
|
|
54
|
+
// where 3px gaps alone fill the width, every bar is still a positive rect and the row still ends
|
|
55
|
+
// at the right edge.
|
|
56
|
+
const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / count / 2);
|
|
57
|
+
const width = (BAR_CHART.width - gap * (count - 1)) / count;
|
|
44
58
|
const max = maxOf(points);
|
|
45
59
|
return points.map((point, index) => {
|
|
46
60
|
const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
|
|
47
61
|
const height = Math.max(2, (value / max) * BAR_CHART.plot);
|
|
48
62
|
return {
|
|
49
|
-
x: index * (width +
|
|
63
|
+
x: index * (width + gap),
|
|
50
64
|
y: BAR_CHART.top + BAR_CHART.plot - height,
|
|
51
65
|
width,
|
|
52
66
|
height,
|
|
53
67
|
};
|
|
54
68
|
});
|
|
55
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The second series' rect per point, stacked on that point's primary bar; `null` where it is 0.
|
|
73
|
+
* Same scale as `barRects`, so a stack is read against the same grid lines.
|
|
74
|
+
*/
|
|
75
|
+
export function secondaryRects(points: readonly ChartPoint[]): readonly (BarRect | null)[] {
|
|
76
|
+
const primary = barRects(points);
|
|
77
|
+
const max = maxOf(points);
|
|
78
|
+
return points.map((point, index) => {
|
|
79
|
+
const value = finite(point.secondary);
|
|
80
|
+
const below = primary[index];
|
|
81
|
+
if (value === 0 || below === undefined) return null;
|
|
82
|
+
const height = (value / max) * BAR_CHART.plot;
|
|
83
|
+
return { x: below.x, y: below.y - height, width: below.width, height };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The classes a button's look is made of — asked by `<Button>` and by `<Link appearance="button">`,
|
|
2
|
+
// so a control that submits and a control that navigates cannot drift into two looks. Keys into
|
|
3
|
+
// `Button.module.scss`, never class names: the scoped name is the stylesheet's to give.
|
|
4
|
+
|
|
5
|
+
import type { ButtonVariant, Size, Tone } from './variants';
|
|
6
|
+
|
|
7
|
+
export interface ButtonLook {
|
|
8
|
+
variant?: ButtonVariant | undefined;
|
|
9
|
+
tone?: Tone | undefined;
|
|
10
|
+
size?: Size | undefined;
|
|
11
|
+
fullWidth?: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The classes of the element's CONTENT, which both elements wrap their label in. */
|
|
15
|
+
export const BUTTON_CONTENT_KEYS = ['label', 'spinner'] as const;
|
|
16
|
+
|
|
17
|
+
export function buttonClassKeys(look: ButtonLook): readonly string[] {
|
|
18
|
+
return [
|
|
19
|
+
'button',
|
|
20
|
+
`variant-${look.variant ?? 'primary'}`,
|
|
21
|
+
`tone-${look.tone ?? 'accent'}`,
|
|
22
|
+
`size-${look.size ?? 'md'}`,
|
|
23
|
+
...(look.fullWidth === true ? ['full'] : []),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
@@ -106,22 +106,41 @@ export interface FileTarget {
|
|
|
106
106
|
files: FileList | null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Builds the `FileList` an input's `files` accepts — the one constructor the DOM offers for it. */
|
|
110
|
+
export type FileListOf<TFile extends FileCandidate> = (files: readonly TFile[]) => FileList;
|
|
111
|
+
|
|
112
|
+
function dataTransferList<TFile extends FileCandidate>(files: readonly TFile[]): FileList {
|
|
113
|
+
const transfer = new DataTransfer();
|
|
114
|
+
for (const one of files) transfer.items.add(one as unknown as File);
|
|
115
|
+
return transfer.files;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
|
-
*
|
|
119
|
+
* Make the real `<input>` hold exactly the files the control ACCEPTED, so the form posts what the
|
|
120
|
+
* control showed and nothing it refused.
|
|
111
121
|
*
|
|
112
|
-
*
|
|
113
|
-
* then refuses to submit
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
122
|
+
* A drop never reaches the input on its own: without this a `<Dropzone name="avatar" required>`
|
|
123
|
+
* shows the accepted file and then refuses to submit. And handing it the whole drop posted the
|
|
124
|
+
* files `accept` / `maxBytes` / `maxFiles` turned away — `accept` is a hint to the picker, never a
|
|
125
|
+
* filter on `files`. So a `drop` with something accepted is adopted as the accepted list, and a
|
|
126
|
+
* drop with nothing accepted leaves an earlier pick alone, as a refused drop should. A `pick`
|
|
127
|
+
* already set `files` itself; it is narrowed only when it refused something — to an empty list if
|
|
128
|
+
* it refused everything, because the refused file is the one the form would otherwise post. A list
|
|
129
|
+
* is BUILT (`listOf`, a `DataTransfer` in a browser) only when something was refused.
|
|
117
130
|
*/
|
|
118
|
-
export function
|
|
131
|
+
export function adoptAcceptedFiles<TFile extends FileCandidate>(
|
|
119
132
|
input: FileTarget | undefined,
|
|
120
|
-
|
|
133
|
+
offered: FileList | null | undefined,
|
|
134
|
+
selection: FileSelection<TFile>,
|
|
135
|
+
origin: 'drop' | 'pick',
|
|
136
|
+
listOf: FileListOf<TFile> = dataTransferList,
|
|
121
137
|
): void {
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
138
|
+
if (input === undefined) return;
|
|
139
|
+
if (origin === 'drop' && selection.accepted.length === 0) return;
|
|
140
|
+
if (selection.rejected.length > 0) {
|
|
141
|
+
input.files = listOf(selection.accepted);
|
|
142
|
+
} else if (origin === 'drop' && offered !== null && offered !== undefined) {
|
|
143
|
+
// Nothing refused: the drop's own list, which needs no constructor at all.
|
|
144
|
+
input.files = offered;
|
|
125
145
|
}
|
|
126
|
-
input.files = dropped;
|
|
127
146
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Which stylesheet a `<Link>` reads and which of its classes — the one decision `appearance`
|
|
2
|
+
// makes. Pure, so it is testable with no renderer: the `.tsx` only maps the answer onto a sheet.
|
|
3
|
+
|
|
4
|
+
import { type ButtonLook, buttonClassKeys } from './button-classes';
|
|
5
|
+
import type { Tone } from './variants';
|
|
6
|
+
|
|
7
|
+
export interface LinkLook extends Omit<ButtonLook, 'tone'> {
|
|
8
|
+
appearance?: 'link' | 'button' | undefined;
|
|
9
|
+
underline?: 'always' | 'hover' | 'none' | undefined;
|
|
10
|
+
/** A text link's own two tones, or — as a button — any `Tone`. */
|
|
11
|
+
tone?: Tone | 'inherit' | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LinkClasses {
|
|
15
|
+
/** `button` is `Button.module.scss`: one set of button styles, whichever element wears them. */
|
|
16
|
+
readonly sheet: 'link' | 'button';
|
|
17
|
+
readonly keys: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function linkClasses(look: LinkLook): LinkClasses {
|
|
21
|
+
if (look.appearance === 'button') {
|
|
22
|
+
return {
|
|
23
|
+
sheet: 'button',
|
|
24
|
+
// `inherit` is a text link's tone and no button has it; the type refuses the pairing, and a
|
|
25
|
+
// caller that got past the type gets the default tone rather than a class nothing declares.
|
|
26
|
+
keys: buttonClassKeys({
|
|
27
|
+
variant: look.variant,
|
|
28
|
+
tone: look.tone === 'inherit' ? undefined : look.tone,
|
|
29
|
+
size: look.size,
|
|
30
|
+
fullWidth: look.fullWidth,
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
sheet: 'link',
|
|
36
|
+
keys: ['link', `underline-${look.underline ?? 'hover'}`, `tone-${look.tone ?? 'accent'}`],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -19,5 +19,38 @@ export interface LinkTarget {
|
|
|
19
19
|
export function linkTarget(href: string, declaredExternal?: boolean | undefined): LinkTarget {
|
|
20
20
|
const safe = safeUrl(href, 'href');
|
|
21
21
|
if (safe === null) return { href: undefined, external: false };
|
|
22
|
-
return { href: safe, external: declaredExternal === true ||
|
|
22
|
+
return { href: safe, external: declaredExternal === true || namesAnotherOrigin(safe) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An origin no real link can name: whatever resolves AWAY from it carried its own authority. */
|
|
26
|
+
const SENTINEL_ORIGIN = 'http://link-target.invalid';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decided the way a browser resolves the href, not by its spelling: `//cdn.test`, `HTTPS://…` and
|
|
30
|
+
* a leading space all leave the page's origin, and a regex over the raw string read each of them
|
|
31
|
+
* as internal — no `noopener`, no external hint. A value the parser refuses is treated as external,
|
|
32
|
+
* because the hardening is the safe side of a wrong guess.
|
|
33
|
+
*/
|
|
34
|
+
function namesAnotherOrigin(href: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(href, `${SENTINEL_ORIGIN}/`);
|
|
37
|
+
return (
|
|
38
|
+
(url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== SENTINEL_ORIGIN
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `rel` an anchor carries. A relation is ADDED to the external hardening, never traded for
|
|
47
|
+
* it — which is why `<Link>` takes the relation as a closed pair and not a free `rel` string a
|
|
48
|
+
* caller could use to write the hardening away.
|
|
49
|
+
*/
|
|
50
|
+
export function linkRel(
|
|
51
|
+
external: boolean,
|
|
52
|
+
relation?: 'next' | 'prev' | undefined,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
if (!external) return relation;
|
|
55
|
+
return relation === undefined ? 'noopener noreferrer' : `${relation} noopener noreferrer`;
|
|
23
56
|
}
|
|
@@ -11,3 +11,16 @@ export function meterShare(value: number, max: number): number {
|
|
|
11
11
|
export function meterWidth(share: number): string {
|
|
12
12
|
return (share * 100).toFixed(1);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `aria-valuenow` / `aria-valuemax` pair, from the SAME rule as the fill: a meter that draws
|
|
17
|
+
* full must not speak "7 of 4". With no usable maximum the bar is empty, and so is the range it
|
|
18
|
+
* speaks — 0 of 1, because `aria-valuemax` must stay above `aria-valuemin`.
|
|
19
|
+
*/
|
|
20
|
+
export function meterAria(
|
|
21
|
+
value: number,
|
|
22
|
+
max: number,
|
|
23
|
+
): { readonly now: number; readonly max: number } {
|
|
24
|
+
if (!Number.isFinite(max) || max <= 0) return { now: 0, max: 1 };
|
|
25
|
+
return { now: Number.isFinite(value) ? Math.min(max, Math.max(0, value)) : 0, max };
|
|
26
|
+
}
|
|
@@ -75,28 +75,15 @@ function drawAlignmentPattern(matrix: Matrix, centerRow: number, centerCol: numb
|
|
|
75
75
|
function drawFunctionPatterns(matrix: Matrix, version: number): void {
|
|
76
76
|
const size = matrix.size;
|
|
77
77
|
|
|
78
|
-
// Three finder patterns
|
|
79
|
-
//
|
|
78
|
+
// Three finder patterns. `drawFinderPattern`'s outermost ring (`dist === 4`) IS the light
|
|
79
|
+
// separator, clipped to the matrix — drawing a second ring one further out would claim row and
|
|
80
|
+
// column 8, the format strip and two timing modules.
|
|
80
81
|
const corners: readonly (readonly [number, number])[] = [
|
|
81
82
|
[3, 3],
|
|
82
83
|
[3, size - 4],
|
|
83
84
|
[size - 4, 3],
|
|
84
85
|
];
|
|
85
86
|
for (const [row, col] of corners) drawFinderPattern(matrix, row, col);
|
|
86
|
-
// Separators: the light ring one module beyond each finder pattern, clipped to the matrix.
|
|
87
|
-
for (const [row, col] of corners) {
|
|
88
|
-
for (let d = -4; d <= 4; d++) {
|
|
89
|
-
for (const [r, c] of [
|
|
90
|
-
[row + d, col - 5],
|
|
91
|
-
[row + d, col + 5],
|
|
92
|
-
[row - 5, col + d],
|
|
93
|
-
[row + 5, col + d],
|
|
94
|
-
] as const) {
|
|
95
|
-
if (r >= 0 && c >= 0 && r < size && c < size && !matrix.isSet(r, c))
|
|
96
|
-
matrix.set(r, c, false);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
87
|
|
|
101
88
|
// Timing patterns: row 6 and column 6, alternating dark/light, skipping cells the finder
|
|
102
89
|
// patterns already claimed.
|
|
@@ -107,11 +94,14 @@ function drawFunctionPatterns(matrix: Matrix, version: number): void {
|
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
// Alignment pattern(s): every (row, col) pair from this version's coordinate list, except the
|
|
110
|
-
//
|
|
97
|
+
// three that would land on a finder pattern — (first, first), (first, last) and (last, first).
|
|
111
98
|
const positions = ALIGNMENT_POSITIONS[version - 1] ?? [];
|
|
99
|
+
const first = positions[0];
|
|
100
|
+
const last = positions[positions.length - 1];
|
|
112
101
|
for (const row of positions) {
|
|
113
102
|
for (const col of positions) {
|
|
114
|
-
if (row ===
|
|
103
|
+
if (row === first && (col === first || col === last)) continue;
|
|
104
|
+
if (row === last && col === first) continue;
|
|
115
105
|
drawAlignmentPattern(matrix, row, col);
|
|
116
106
|
}
|
|
117
107
|
}
|
|
@@ -158,10 +148,11 @@ function drawFormatInfo(matrix: Matrix, maskPattern: number): void {
|
|
|
158
148
|
matrix.set(8, 7, bit(8));
|
|
159
149
|
for (let i = 9; i <= 14; i++) matrix.set(8, 14 - i, bit(i));
|
|
160
150
|
|
|
161
|
-
// The
|
|
162
|
-
// column `size-
|
|
163
|
-
|
|
164
|
-
for (let i =
|
|
151
|
+
// The second copy: bits 0-7 along row 8 from column `size-1` leftwards to `size-8`, bits 8-14
|
|
152
|
+
// down column 8 from row `size-7` to `size-1` — one short of the fixed dark module at
|
|
153
|
+
// (`size-8`, 8), which belongs to no format copy.
|
|
154
|
+
for (let i = 0; i <= 7; i++) matrix.set(8, size - 1 - i, bit(i));
|
|
155
|
+
for (let i = 8; i <= 14; i++) matrix.set(size - 15 + i, 8, bit(i));
|
|
165
156
|
}
|
|
166
157
|
|
|
167
158
|
/** Whether mask `pattern` (0-7, ISO 18004 §8.8.1) flips the module at `(row, col)`. */
|
|
@@ -198,11 +189,13 @@ function drawData(matrix: Matrix, codewords: readonly number[], pattern: number)
|
|
|
198
189
|
let bitIndex = 0;
|
|
199
190
|
let upward = true;
|
|
200
191
|
for (let colPair = size - 1; colPair > 0; colPair -= 2) {
|
|
192
|
+
// The vertical timing column carries no data, so the strip that would straddle it moves one
|
|
193
|
+
// module left — which is also what makes column 0 the last strip's left half.
|
|
194
|
+
if (colPair === 6) colPair = 5;
|
|
201
195
|
for (let count = 0; count < size; count++) {
|
|
202
196
|
const row = upward ? size - 1 - count : count;
|
|
203
197
|
for (let colOffset = 0; colOffset < 2; colOffset++) {
|
|
204
198
|
const col = colPair - colOffset;
|
|
205
|
-
if (col === 6) continue; // the vertical timing column carries no data
|
|
206
199
|
if (matrix.isSet(row, col)) continue;
|
|
207
200
|
const bit = bits[bitIndex] ?? false;
|
|
208
201
|
bitIndex++;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// TEST-ONLY: an independent QR reader for versions 1-3, byte mode, level M — written from ISO
|
|
2
|
+
// 18004's layout, sharing no code with `qr-encode.ts`/`qr-matrix.ts`, so a placement defect in
|
|
3
|
+
// the encoder cannot be mirrored here. Never exported from `index.ts`.
|
|
4
|
+
|
|
5
|
+
/** The eight published level-M format strings (ISO 18004 Table C.1), indexed by mask pattern —
|
|
6
|
+
* a table, not a BCH computation, so the reader does not share the encoder's arithmetic. */
|
|
7
|
+
const LEVEL_M_FORMAT: readonly number[] = [
|
|
8
|
+
0b101010000010010, 0b101000100100101, 0b101111001111100, 0b101101101001011, 0b100010111111001,
|
|
9
|
+
0b100000011001110, 0b100111110010111, 0b100101010100000,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/** Total and data codewords per version, level M (ISO 18004 Table 9). */
|
|
13
|
+
const CODEWORDS: ReadonlyMap<number, { readonly total: number; readonly data: number }> = new Map([
|
|
14
|
+
[1, { total: 26, data: 16 }],
|
|
15
|
+
[2, { total: 44, data: 28 }],
|
|
16
|
+
[3, { total: 70, data: 44 }],
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export interface QrReading {
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly mask: number;
|
|
22
|
+
readonly codewords: readonly number[];
|
|
23
|
+
readonly text: string;
|
|
24
|
+
/** Every place the symbol departs from the standard's fixed layout, as `what@row,col`. */
|
|
25
|
+
readonly layoutFaults: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Grid = readonly (readonly boolean[])[];
|
|
29
|
+
|
|
30
|
+
const at = (grid: Grid, row: number, col: number): boolean => grid[row]?.[col] === true;
|
|
31
|
+
|
|
32
|
+
export function isFunctionModule(size: number, row: number, col: number): boolean {
|
|
33
|
+
const inCorner = (r0: number, c0: number): boolean =>
|
|
34
|
+
row >= r0 && row < r0 + 8 && col >= c0 && col < c0 + 8;
|
|
35
|
+
if (inCorner(0, 0) || inCorner(0, size - 8) || inCorner(size - 8, 0)) return true;
|
|
36
|
+
if (row === 6 || col === 6) return true;
|
|
37
|
+
if (row === 8 && (col <= 8 || col >= size - 8)) return true;
|
|
38
|
+
if (col === 8 && (row <= 8 || row >= size - 8)) return true;
|
|
39
|
+
// Versions 2-3 have exactly one alignment pattern, centred 7 modules in from the far corner.
|
|
40
|
+
if (size > 21 && Math.abs(row - (size - 7)) <= 2 && Math.abs(col - (size - 7)) <= 2) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function finderFaults(grid: Grid, size: number): string[] {
|
|
45
|
+
const faults: string[] = [];
|
|
46
|
+
for (const [r0, c0] of [
|
|
47
|
+
[0, 0],
|
|
48
|
+
[0, size - 7],
|
|
49
|
+
[size - 7, 0],
|
|
50
|
+
] as const) {
|
|
51
|
+
for (let dr = -1; dr <= 7; dr++) {
|
|
52
|
+
for (let dc = -1; dc <= 7; dc++) {
|
|
53
|
+
const row = r0 + dr;
|
|
54
|
+
const col = c0 + dc;
|
|
55
|
+
if (row < 0 || col < 0 || row >= size || col >= size) continue;
|
|
56
|
+
const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
|
|
57
|
+
const dark = ring <= 1 || ring === 3;
|
|
58
|
+
if (at(grid, row, col) !== dark) faults.push(`finder@${row},${col}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return faults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fixedFaults(grid: Grid, size: number): string[] {
|
|
66
|
+
const faults = finderFaults(grid, size);
|
|
67
|
+
for (let i = 8; i < size - 8; i++) {
|
|
68
|
+
if (at(grid, 6, i) !== (i % 2 === 0)) faults.push(`timing@6,${i}`);
|
|
69
|
+
if (at(grid, i, 6) !== (i % 2 === 0)) faults.push(`timing@${i},6`);
|
|
70
|
+
}
|
|
71
|
+
if (size > 21) {
|
|
72
|
+
const centre = size - 7;
|
|
73
|
+
for (let dr = -2; dr <= 2; dr++) {
|
|
74
|
+
for (let dc = -2; dc <= 2; dc++) {
|
|
75
|
+
const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1;
|
|
76
|
+
if (at(grid, centre + dr, centre + dc) !== dark) {
|
|
77
|
+
faults.push(`alignment@${centre + dr},${centre + dc}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!at(grid, size - 8, 8)) faults.push(`dark-module@${size - 8},8`);
|
|
83
|
+
return faults;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Cell = readonly [number, number];
|
|
87
|
+
|
|
88
|
+
/** The cells of both format copies, bit 14 first; positions straight from ISO 18004 Figure 25. */
|
|
89
|
+
export function formatCells(size: number): readonly [readonly Cell[], readonly Cell[]] {
|
|
90
|
+
const first: Cell[] = [];
|
|
91
|
+
for (let col = 0; col <= 8; col++) if (col !== 6) first.push([8, col]);
|
|
92
|
+
for (let row = 7; row >= 0; row--) if (row !== 6) first.push([row, 8]);
|
|
93
|
+
const second: Cell[] = [];
|
|
94
|
+
for (let row = size - 1; row >= size - 7; row--) second.push([row, 8]);
|
|
95
|
+
for (let col = size - 8; col < size; col++) second.push([8, col]);
|
|
96
|
+
return [first, second];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatCopies(grid: Grid, size: number): readonly [number, number] {
|
|
100
|
+
const read = (cells: readonly Cell[]): number =>
|
|
101
|
+
cells.reduce((acc, [row, col]) => (acc << 1) | (at(grid, row, col) ? 1 : 0), 0);
|
|
102
|
+
const [first, second] = formatCells(size);
|
|
103
|
+
return [read(first), read(second)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function maskFlips(mask: number, row: number, col: number): boolean {
|
|
107
|
+
const formulas: readonly ((i: number, j: number) => boolean)[] = [
|
|
108
|
+
(i, j) => (i + j) % 2 === 0,
|
|
109
|
+
(i) => i % 2 === 0,
|
|
110
|
+
(_i, j) => j % 3 === 0,
|
|
111
|
+
(i, j) => (i + j) % 3 === 0,
|
|
112
|
+
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0,
|
|
113
|
+
(i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
|
114
|
+
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0,
|
|
115
|
+
(i, j) => (((i * j) % 3) + ((i + j) % 2)) % 2 === 0,
|
|
116
|
+
];
|
|
117
|
+
return formulas[mask]?.(row, col) === true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readCodewords(grid: Grid, size: number, mask: number, count: number): number[] {
|
|
121
|
+
const bits: number[] = [];
|
|
122
|
+
// Two-column strips from the right edge; the strip that would straddle the vertical timing
|
|
123
|
+
// column moves one left, so column 6 is never read and column 0 always is.
|
|
124
|
+
const strips: number[] = [];
|
|
125
|
+
for (let right = size - 1; right >= 1; right -= 2) strips.push(right > 6 ? right : right - 1);
|
|
126
|
+
strips.forEach((right, index) => {
|
|
127
|
+
const upward = index % 2 === 0;
|
|
128
|
+
for (let step = 0; step < size; step++) {
|
|
129
|
+
const row = upward ? size - 1 - step : step;
|
|
130
|
+
for (const col of [right, right - 1]) {
|
|
131
|
+
if (isFunctionModule(size, row, col)) continue;
|
|
132
|
+
bits.push(at(grid, row, col) !== maskFlips(mask, row, col) ? 1 : 0);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
const words: number[] = [];
|
|
137
|
+
for (let i = 0; i < count; i++) {
|
|
138
|
+
words.push(bits.slice(i * 8, i * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0));
|
|
139
|
+
}
|
|
140
|
+
return words;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reed-Solomon syndromes over GF(256)/0x11d: all zero exactly when no codeword is corrupt. */
|
|
144
|
+
export function syndromesZero(codewords: readonly number[], eccCount: number): boolean {
|
|
145
|
+
const exp: number[] = [];
|
|
146
|
+
let x = 1;
|
|
147
|
+
for (let i = 0; i < 255; i++) {
|
|
148
|
+
exp.push(x);
|
|
149
|
+
x = x & 0x80 ? ((x << 1) ^ 0x11d) & 0xff : x << 1;
|
|
150
|
+
}
|
|
151
|
+
const log = new Map<number, number>(exp.map((v, i) => [v, i]));
|
|
152
|
+
const mul = (a: number, b: number): number =>
|
|
153
|
+
a === 0 || b === 0 ? 0 : (exp[((log.get(a) ?? 0) + (log.get(b) ?? 0)) % 255] ?? 0);
|
|
154
|
+
for (let k = 0; k < eccCount; k++) {
|
|
155
|
+
let s = 0;
|
|
156
|
+
for (const word of codewords) s = mul(s, exp[k] ?? 1) ^ word;
|
|
157
|
+
if (s !== 0) return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reads `grid` as a QR symbol. Never throws: a symbol that does not read is `text: ''` plus
|
|
163
|
+
* whatever faults explain why. */
|
|
164
|
+
export function readQr(grid: Grid): QrReading {
|
|
165
|
+
const size = grid.length;
|
|
166
|
+
const version = (size - 17) / 4;
|
|
167
|
+
const faults = fixedFaults(grid, size);
|
|
168
|
+
const [first, second] = formatCopies(grid, size);
|
|
169
|
+
if (first !== second) faults.push('format-copies-differ');
|
|
170
|
+
const mask = LEVEL_M_FORMAT.indexOf(first);
|
|
171
|
+
if (mask < 0) faults.push('format-not-level-m');
|
|
172
|
+
const total = CODEWORDS.get(version)?.total ?? 0;
|
|
173
|
+
const codewords = mask < 0 ? [] : readCodewords(grid, size, mask, total);
|
|
174
|
+
const dataCount = CODEWORDS.get(version)?.data ?? 0;
|
|
175
|
+
if (codewords.length === 0 || !syndromesZero(codewords, total - dataCount)) {
|
|
176
|
+
return { version, mask, codewords, text: '', layoutFaults: [...faults, 'reed-solomon'] };
|
|
177
|
+
}
|
|
178
|
+
const bits = codewords
|
|
179
|
+
.slice(0, dataCount)
|
|
180
|
+
.flatMap((word) => Array.from({ length: 8 }, (_, i) => (word >>> (7 - i)) & 1));
|
|
181
|
+
const field = (start: number, length: number): number =>
|
|
182
|
+
bits.slice(start, start + length).reduce((acc, bit) => (acc << 1) | bit, 0);
|
|
183
|
+
if (field(0, 4) !== 0b0100) return { version, mask, codewords, text: '', layoutFaults: faults };
|
|
184
|
+
const length = field(4, 8);
|
|
185
|
+
const bytes = Uint8Array.from({ length }, (_, i) => field(12 + i * 8, 8));
|
|
186
|
+
return { version, mask, codewords, text: new TextDecoder().decode(bytes), layoutFaults: faults };
|
|
187
|
+
}
|