@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.
Files changed (65) hide show
  1. package/CATALOG.md +45 -14
  2. package/CLAUDE.md +16 -52
  3. package/README.md +69 -13
  4. package/package.json +6 -5
  5. package/src/a11y.ts +24 -10
  6. package/src/catalog/parse-component.ts +85 -12
  7. package/src/catalog/render-catalog.ts +21 -1
  8. package/src/components/Avatar.module.scss +1 -1
  9. package/src/components/BarChart.module.scss +12 -1
  10. package/src/components/BarChart.tsx +41 -15
  11. package/src/components/Button.tsx +3 -8
  12. package/src/components/Card.module.scss +1 -1
  13. package/src/components/Checkbox.module.scss +4 -4
  14. package/src/components/Checkbox.tsx +6 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/DataTable.module.scss +2 -0
  17. package/src/components/DataTable.tsx +135 -71
  18. package/src/components/Dialog.module.scss +2 -2
  19. package/src/components/Dropzone.module.scss +6 -4
  20. package/src/components/Dropzone.tsx +9 -8
  21. package/src/components/FileInput.module.scss +4 -2
  22. package/src/components/FileInput.tsx +6 -2
  23. package/src/components/Form.module.scss +2 -2
  24. package/src/components/Input.module.scss +1 -1
  25. package/src/components/Link.tsx +64 -12
  26. package/src/components/Meter.tsx +3 -3
  27. package/src/components/Pagination.tsx +89 -37
  28. package/src/components/Popover.module.scss +17 -20
  29. package/src/components/Popover.tsx +12 -5
  30. package/src/components/Radio.module.scss +2 -2
  31. package/src/components/Select.module.scss +3 -3
  32. package/src/components/Skeleton.module.scss +1 -1
  33. package/src/components/Spinner.module.scss +3 -3
  34. package/src/components/Switch.module.scss +2 -2
  35. package/src/components/Table.module.scss +2 -2
  36. package/src/components/Tabs.module.scss +11 -5
  37. package/src/components/Textarea.tsx +7 -4
  38. package/src/components/ThemeToggle.module.scss +1 -1
  39. package/src/components/Toast.module.scss +2 -2
  40. package/src/components/Tooltip.module.scss +8 -3
  41. package/src/components/bar-chart-view.ts +35 -5
  42. package/src/components/button-classes.ts +25 -0
  43. package/src/components/file-input-view.ts +31 -12
  44. package/src/components/link-classes.ts +38 -0
  45. package/src/components/link-target.ts +34 -1
  46. package/src/components/meter-view.ts +13 -0
  47. package/src/components/qr-matrix.ts +16 -23
  48. package/src/components/qr-reader.ts +187 -0
  49. package/src/components/relative-time-view.ts +11 -4
  50. package/src/form/form-binding.ts +9 -8
  51. package/src/icons/build-icons.ts +96 -20
  52. package/src/index.ts +15 -9
  53. package/src/sass-probe.ts +55 -0
  54. package/src/theme/brand.ts +22 -3
  55. package/src/theme/theme.ts +35 -10
  56. package/src/toast/toast-state.ts +0 -0
  57. package/src/tokens/_index.scss +3 -1
  58. package/src/tokens/_mixins.scss +56 -8
  59. package/src/tokens/_motion.scss +4 -0
  60. package/src/tokens/_stroke.scss +20 -0
  61. package/src/tokens/_units.scss +47 -0
  62. package/src/tokens/reset.scss +4 -4
  63. package/src/tokens/theme.scss +2 -0
  64. package/src/tokens/tokens.ts +6 -0
  65. 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 -2px 0 0 0 t.role('accent');
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(('accent'), 2px, -2px);
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 -2px 0 0 t.role('accent');
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: 2px solid t.role('accent');
66
- outline-offset: 2px;
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
- * The leading newline is the serializer's, not the value's: the HTML parser strips exactly one
32
- * newline after `<textarea>`, so emitting it unconditionally is what makes a value that itself
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
  }
@@ -18,6 +18,6 @@
18
18
 
19
19
  &[data-theme-glyph='dark'] {
20
20
  background: transparent;
21
- box-shadow: inset -0.28rem -0.18rem 0 0 currentcolor;
21
+ box-shadow: inset t.rem(-4.5px) t.rem(-3px) 0 0 currentcolor;
22
22
  }
23
23
  }
@@ -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: 3px solid var(--toast-accent);
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 12px;
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
- inset-inline-start: 50%;
13
- translate: -50% 4px;
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: -50% 0;
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
- /** The busiest point, and never below 1, so an all-zero series still divides. */
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
- if (Number.isFinite(point.value) && point.value > max) max = point.value;
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
- const width = (BAR_CHART.width - BAR_CHART.gap * (count - 1)) / count;
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 + BAR_CHART.gap),
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
- * Hand a drop's files to the real `<input>`, so a dropped file behaves exactly like a picked one.
119
+ * Make the real `<input>` hold exactly the files the control ACCEPTED, so the form posts what the
120
+ * control showed and nothing it refused.
111
121
  *
112
- * Without this a `<Dropzone name="avatar" required>` inside a `<form>` shows the accepted file and
113
- * then refuses to submit: `onSelect` fired, but `input.files` is still empty, so `required` blocks
114
- * on "Please select a file" and a submit that got past it would post no file at all. Native form
115
- * participation is what the `name`/`required` props promise. `files` is assignable from a
116
- * `DataTransfer`'s own `FileList` in every current engine — that is the supported way to do this.
122
+ * A drop never reaches the input on its own: without this a `<Dropzone name="avatar" required>`
123
+ * shows the accepted file and then refuses to submit. And handing it the whole drop posted the
124
+ * files `accept` / `maxBytes` / `maxFiles` turned away — `accept` is a hint to the picker, never a
125
+ * filter on `files`. So a `drop` with something accepted is adopted as the accepted list, and a
126
+ * drop with nothing accepted leaves an earlier pick alone, as a refused drop should. A `pick`
127
+ * already set `files` itself; it is narrowed only when it refused something — to an empty list if
128
+ * it refused everything, because the refused file is the one the form would otherwise post. A list
129
+ * is BUILT (`listOf`, a `DataTransfer` in a browser) only when something was refused.
117
130
  */
118
- export function adoptDroppedFiles(
131
+ export function adoptAcceptedFiles<TFile extends FileCandidate>(
119
132
  input: FileTarget | undefined,
120
- dropped: FileList | null | undefined,
133
+ offered: FileList | null | undefined,
134
+ selection: FileSelection<TFile>,
135
+ origin: 'drop' | 'pick',
136
+ listOf: FileListOf<TFile> = dataTransferList,
121
137
  ): void {
122
- // An empty drop must not CLEAR a previous pick: the browser does not, and neither does this.
123
- if (input === undefined || dropped === null || dropped === undefined || dropped.length === 0) {
124
- return;
138
+ if (input === undefined) return;
139
+ if (origin === 'drop' && selection.accepted.length === 0) return;
140
+ if (selection.rejected.length > 0) {
141
+ input.files = listOf(selection.accepted);
142
+ } else if (origin === 'drop' && offered !== null && offered !== undefined) {
143
+ // Nothing refused: the drop's own list, which needs no constructor at all.
144
+ input.files = offered;
125
145
  }
126
- input.files = dropped;
127
146
  }
@@ -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 || /^https?:\/\//.test(safe) };
22
+ return { href: safe, external: declaredExternal === true || namesAnotherOrigin(safe) };
23
+ }
24
+
25
+ /** An origin no real link can name: whatever resolves AWAY from it carried its own authority. */
26
+ const SENTINEL_ORIGIN = 'http://link-target.invalid';
27
+
28
+ /**
29
+ * Decided the way a browser resolves the href, not by its spelling: `//cdn.test`, `HTTPS://…` and
30
+ * a leading space all leave the page's origin, and a regex over the raw string read each of them
31
+ * as internal — no `noopener`, no external hint. A value the parser refuses is treated as external,
32
+ * because the hardening is the safe side of a wrong guess.
33
+ */
34
+ function namesAnotherOrigin(href: string): boolean {
35
+ try {
36
+ const url = new URL(href, `${SENTINEL_ORIGIN}/`);
37
+ return (
38
+ (url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== SENTINEL_ORIGIN
39
+ );
40
+ } catch {
41
+ return true;
42
+ }
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, each an 8x8 area (7x7 pattern plus a light separator ring); only the
79
- // 7x7 pattern is drawn as dark/light above, so the surrounding separator is drawn explicitly.
78
+ // Three finder patterns. `drawFinderPattern`'s outermost ring (`dist === 4`) IS the light
79
+ // separator, clipped to the matrix — drawing a second ring one further out would claim row and
80
+ // column 8, the format strip and two timing modules.
80
81
  const corners: readonly (readonly [number, number])[] = [
81
82
  [3, 3],
82
83
  [3, size - 4],
83
84
  [size - 4, 3],
84
85
  ];
85
86
  for (const [row, col] of corners) drawFinderPattern(matrix, row, col);
86
- // Separators: the light ring one module beyond each finder pattern, clipped to the matrix.
87
- for (const [row, col] of corners) {
88
- for (let d = -4; d <= 4; d++) {
89
- for (const [r, c] of [
90
- [row + d, col - 5],
91
- [row + d, col + 5],
92
- [row - 5, col + d],
93
- [row + 5, col + d],
94
- ] as const) {
95
- if (r >= 0 && c >= 0 && r < size && c < size && !matrix.isSet(r, c))
96
- matrix.set(r, c, false);
97
- }
98
- }
99
- }
100
87
 
101
88
  // Timing patterns: row 6 and column 6, alternating dark/light, skipping cells the finder
102
89
  // patterns already claimed.
@@ -107,11 +94,14 @@ function drawFunctionPatterns(matrix: Matrix, version: number): void {
107
94
  }
108
95
 
109
96
  // Alignment pattern(s): every (row, col) pair from this version's coordinate list, except the
110
- // one that overlaps the top-left finder pattern.
97
+ // three that would land on a finder pattern — (first, first), (first, last) and (last, first).
111
98
  const positions = ALIGNMENT_POSITIONS[version - 1] ?? [];
99
+ const first = positions[0];
100
+ const last = positions[positions.length - 1];
112
101
  for (const row of positions) {
113
102
  for (const col of positions) {
114
- if (row === 6 && col === 6) continue;
103
+ if (row === first && (col === first || col === last)) continue;
104
+ if (row === last && col === first) continue;
115
105
  drawAlignmentPattern(matrix, row, col);
116
106
  }
117
107
  }
@@ -158,10 +148,11 @@ function drawFormatInfo(matrix: Matrix, maskPattern: number): void {
158
148
  matrix.set(8, 7, bit(8));
159
149
  for (let i = 9; i <= 14; i++) matrix.set(8, 14 - i, bit(i));
160
150
 
161
- // The mirrored copy: bits 0-7 along row `size-1` down to `size-8` in column 8, bits 8-14 up
162
- // column `size-1` down to `size-15` in row 8.
163
- for (let i = 0; i <= 7; i++) matrix.set(size - 1 - i, 8, bit(i));
164
- for (let i = 8; i <= 14; i++) matrix.set(8, size - 15 + i, bit(i));
151
+ // The second copy: bits 0-7 along row 8 from column `size-1` leftwards to `size-8`, bits 8-14
152
+ // down column 8 from row `size-7` to `size-1` — one short of the fixed dark module at
153
+ // (`size-8`, 8), which belongs to no format copy.
154
+ for (let i = 0; i <= 7; i++) matrix.set(8, size - 1 - i, bit(i));
155
+ for (let i = 8; i <= 14; i++) matrix.set(size - 15 + i, 8, bit(i));
165
156
  }
166
157
 
167
158
  /** Whether mask `pattern` (0-7, ISO 18004 §8.8.1) flips the module at `(row, col)`. */
@@ -198,11 +189,13 @@ function drawData(matrix: Matrix, codewords: readonly number[], pattern: number)
198
189
  let bitIndex = 0;
199
190
  let upward = true;
200
191
  for (let colPair = size - 1; colPair > 0; colPair -= 2) {
192
+ // The vertical timing column carries no data, so the strip that would straddle it moves one
193
+ // module left — which is also what makes column 0 the last strip's left half.
194
+ if (colPair === 6) colPair = 5;
201
195
  for (let count = 0; count < size; count++) {
202
196
  const row = upward ? size - 1 - count : count;
203
197
  for (let colOffset = 0; colOffset < 2; colOffset++) {
204
198
  const col = colPair - colOffset;
205
- if (col === 6) continue; // the vertical timing column carries no data
206
199
  if (matrix.isSet(row, col)) continue;
207
200
  const bit = bits[bitIndex] ?? false;
208
201
  bitIndex++;
@@ -0,0 +1,187 @@
1
+ // TEST-ONLY: an independent QR reader for versions 1-3, byte mode, level M — written from ISO
2
+ // 18004's layout, sharing no code with `qr-encode.ts`/`qr-matrix.ts`, so a placement defect in
3
+ // the encoder cannot be mirrored here. Never exported from `index.ts`.
4
+
5
+ /** The eight published level-M format strings (ISO 18004 Table C.1), indexed by mask pattern —
6
+ * a table, not a BCH computation, so the reader does not share the encoder's arithmetic. */
7
+ const LEVEL_M_FORMAT: readonly number[] = [
8
+ 0b101010000010010, 0b101000100100101, 0b101111001111100, 0b101101101001011, 0b100010111111001,
9
+ 0b100000011001110, 0b100111110010111, 0b100101010100000,
10
+ ];
11
+
12
+ /** Total and data codewords per version, level M (ISO 18004 Table 9). */
13
+ const CODEWORDS: ReadonlyMap<number, { readonly total: number; readonly data: number }> = new Map([
14
+ [1, { total: 26, data: 16 }],
15
+ [2, { total: 44, data: 28 }],
16
+ [3, { total: 70, data: 44 }],
17
+ ]);
18
+
19
+ export interface QrReading {
20
+ readonly version: number;
21
+ readonly mask: number;
22
+ readonly codewords: readonly number[];
23
+ readonly text: string;
24
+ /** Every place the symbol departs from the standard's fixed layout, as `what@row,col`. */
25
+ readonly layoutFaults: readonly string[];
26
+ }
27
+
28
+ type Grid = readonly (readonly boolean[])[];
29
+
30
+ const at = (grid: Grid, row: number, col: number): boolean => grid[row]?.[col] === true;
31
+
32
+ export function isFunctionModule(size: number, row: number, col: number): boolean {
33
+ const inCorner = (r0: number, c0: number): boolean =>
34
+ row >= r0 && row < r0 + 8 && col >= c0 && col < c0 + 8;
35
+ if (inCorner(0, 0) || inCorner(0, size - 8) || inCorner(size - 8, 0)) return true;
36
+ if (row === 6 || col === 6) return true;
37
+ if (row === 8 && (col <= 8 || col >= size - 8)) return true;
38
+ if (col === 8 && (row <= 8 || row >= size - 8)) return true;
39
+ // Versions 2-3 have exactly one alignment pattern, centred 7 modules in from the far corner.
40
+ if (size > 21 && Math.abs(row - (size - 7)) <= 2 && Math.abs(col - (size - 7)) <= 2) return true;
41
+ return false;
42
+ }
43
+
44
+ function finderFaults(grid: Grid, size: number): string[] {
45
+ const faults: string[] = [];
46
+ for (const [r0, c0] of [
47
+ [0, 0],
48
+ [0, size - 7],
49
+ [size - 7, 0],
50
+ ] as const) {
51
+ for (let dr = -1; dr <= 7; dr++) {
52
+ for (let dc = -1; dc <= 7; dc++) {
53
+ const row = r0 + dr;
54
+ const col = c0 + dc;
55
+ if (row < 0 || col < 0 || row >= size || col >= size) continue;
56
+ const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
57
+ const dark = ring <= 1 || ring === 3;
58
+ if (at(grid, row, col) !== dark) faults.push(`finder@${row},${col}`);
59
+ }
60
+ }
61
+ }
62
+ return faults;
63
+ }
64
+
65
+ function fixedFaults(grid: Grid, size: number): string[] {
66
+ const faults = finderFaults(grid, size);
67
+ for (let i = 8; i < size - 8; i++) {
68
+ if (at(grid, 6, i) !== (i % 2 === 0)) faults.push(`timing@6,${i}`);
69
+ if (at(grid, i, 6) !== (i % 2 === 0)) faults.push(`timing@${i},6`);
70
+ }
71
+ if (size > 21) {
72
+ const centre = size - 7;
73
+ for (let dr = -2; dr <= 2; dr++) {
74
+ for (let dc = -2; dc <= 2; dc++) {
75
+ const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1;
76
+ if (at(grid, centre + dr, centre + dc) !== dark) {
77
+ faults.push(`alignment@${centre + dr},${centre + dc}`);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ if (!at(grid, size - 8, 8)) faults.push(`dark-module@${size - 8},8`);
83
+ return faults;
84
+ }
85
+
86
+ type Cell = readonly [number, number];
87
+
88
+ /** The cells of both format copies, bit 14 first; positions straight from ISO 18004 Figure 25. */
89
+ export function formatCells(size: number): readonly [readonly Cell[], readonly Cell[]] {
90
+ const first: Cell[] = [];
91
+ for (let col = 0; col <= 8; col++) if (col !== 6) first.push([8, col]);
92
+ for (let row = 7; row >= 0; row--) if (row !== 6) first.push([row, 8]);
93
+ const second: Cell[] = [];
94
+ for (let row = size - 1; row >= size - 7; row--) second.push([row, 8]);
95
+ for (let col = size - 8; col < size; col++) second.push([8, col]);
96
+ return [first, second];
97
+ }
98
+
99
+ function formatCopies(grid: Grid, size: number): readonly [number, number] {
100
+ const read = (cells: readonly Cell[]): number =>
101
+ cells.reduce((acc, [row, col]) => (acc << 1) | (at(grid, row, col) ? 1 : 0), 0);
102
+ const [first, second] = formatCells(size);
103
+ return [read(first), read(second)];
104
+ }
105
+
106
+ function maskFlips(mask: number, row: number, col: number): boolean {
107
+ const formulas: readonly ((i: number, j: number) => boolean)[] = [
108
+ (i, j) => (i + j) % 2 === 0,
109
+ (i) => i % 2 === 0,
110
+ (_i, j) => j % 3 === 0,
111
+ (i, j) => (i + j) % 3 === 0,
112
+ (i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0,
113
+ (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
114
+ (i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0,
115
+ (i, j) => (((i * j) % 3) + ((i + j) % 2)) % 2 === 0,
116
+ ];
117
+ return formulas[mask]?.(row, col) === true;
118
+ }
119
+
120
+ function readCodewords(grid: Grid, size: number, mask: number, count: number): number[] {
121
+ const bits: number[] = [];
122
+ // Two-column strips from the right edge; the strip that would straddle the vertical timing
123
+ // column moves one left, so column 6 is never read and column 0 always is.
124
+ const strips: number[] = [];
125
+ for (let right = size - 1; right >= 1; right -= 2) strips.push(right > 6 ? right : right - 1);
126
+ strips.forEach((right, index) => {
127
+ const upward = index % 2 === 0;
128
+ for (let step = 0; step < size; step++) {
129
+ const row = upward ? size - 1 - step : step;
130
+ for (const col of [right, right - 1]) {
131
+ if (isFunctionModule(size, row, col)) continue;
132
+ bits.push(at(grid, row, col) !== maskFlips(mask, row, col) ? 1 : 0);
133
+ }
134
+ }
135
+ });
136
+ const words: number[] = [];
137
+ for (let i = 0; i < count; i++) {
138
+ words.push(bits.slice(i * 8, i * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0));
139
+ }
140
+ return words;
141
+ }
142
+
143
+ /** Reed-Solomon syndromes over GF(256)/0x11d: all zero exactly when no codeword is corrupt. */
144
+ export function syndromesZero(codewords: readonly number[], eccCount: number): boolean {
145
+ const exp: number[] = [];
146
+ let x = 1;
147
+ for (let i = 0; i < 255; i++) {
148
+ exp.push(x);
149
+ x = x & 0x80 ? ((x << 1) ^ 0x11d) & 0xff : x << 1;
150
+ }
151
+ const log = new Map<number, number>(exp.map((v, i) => [v, i]));
152
+ const mul = (a: number, b: number): number =>
153
+ a === 0 || b === 0 ? 0 : (exp[((log.get(a) ?? 0) + (log.get(b) ?? 0)) % 255] ?? 0);
154
+ for (let k = 0; k < eccCount; k++) {
155
+ let s = 0;
156
+ for (const word of codewords) s = mul(s, exp[k] ?? 1) ^ word;
157
+ if (s !== 0) return false;
158
+ }
159
+ return true;
160
+ }
161
+
162
+ /** Reads `grid` as a QR symbol. Never throws: a symbol that does not read is `text: ''` plus
163
+ * whatever faults explain why. */
164
+ export function readQr(grid: Grid): QrReading {
165
+ const size = grid.length;
166
+ const version = (size - 17) / 4;
167
+ const faults = fixedFaults(grid, size);
168
+ const [first, second] = formatCopies(grid, size);
169
+ if (first !== second) faults.push('format-copies-differ');
170
+ const mask = LEVEL_M_FORMAT.indexOf(first);
171
+ if (mask < 0) faults.push('format-not-level-m');
172
+ const total = CODEWORDS.get(version)?.total ?? 0;
173
+ const codewords = mask < 0 ? [] : readCodewords(grid, size, mask, total);
174
+ const dataCount = CODEWORDS.get(version)?.data ?? 0;
175
+ if (codewords.length === 0 || !syndromesZero(codewords, total - dataCount)) {
176
+ return { version, mask, codewords, text: '', layoutFaults: [...faults, 'reed-solomon'] };
177
+ }
178
+ const bits = codewords
179
+ .slice(0, dataCount)
180
+ .flatMap((word) => Array.from({ length: 8 }, (_, i) => (word >>> (7 - i)) & 1));
181
+ const field = (start: number, length: number): number =>
182
+ bits.slice(start, start + length).reduce((acc, bit) => (acc << 1) | bit, 0);
183
+ if (field(0, 4) !== 0b0100) return { version, mask, codewords, text: '', layoutFaults: faults };
184
+ const length = field(4, 8);
185
+ const bytes = Uint8Array.from({ length }, (_, i) => field(12 + i * 8, 8));
186
+ return { version, mask, codewords, text: new TextDecoder().decode(bytes), layoutFaults: faults };
187
+ }