@enbox/browser 0.3.57 → 0.3.59

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 (40) hide show
  1. package/dist/browser.mjs +14 -14
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/dweb-connect-client.js +14 -15
  4. package/dist/esm/dweb-connect-client.js.map +1 -1
  5. package/dist/esm/dweb-connect-wallet.js +11 -13
  6. package/dist/esm/dweb-connect-wallet.js.map +1 -1
  7. package/dist/esm/handled-deferred.js +24 -0
  8. package/dist/esm/handled-deferred.js.map +1 -0
  9. package/dist/esm/index.js +7 -2
  10. package/dist/esm/index.js.map +1 -1
  11. package/dist/esm/relay-connect-runner.js +55 -26
  12. package/dist/esm/relay-connect-runner.js.map +1 -1
  13. package/dist/esm/ui/connect-modal.js +111 -77
  14. package/dist/esm/ui/connect-modal.js.map +1 -1
  15. package/dist/esm/ui/qr.js +121 -64
  16. package/dist/esm/ui/qr.js.map +1 -1
  17. package/dist/esm/web-features.js +54 -41
  18. package/dist/esm/web-features.js.map +1 -1
  19. package/dist/types/dweb-connect-client.d.ts +4 -4
  20. package/dist/types/dweb-connect-client.d.ts.map +1 -1
  21. package/dist/types/dweb-connect-wallet.d.ts +3 -3
  22. package/dist/types/dweb-connect-wallet.d.ts.map +1 -1
  23. package/dist/types/handled-deferred.d.ts +22 -0
  24. package/dist/types/handled-deferred.d.ts.map +1 -0
  25. package/dist/types/index.d.ts +6 -3
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/relay-connect-runner.d.ts +1 -1
  28. package/dist/types/relay-connect-runner.d.ts.map +1 -1
  29. package/dist/types/ui/connect-modal.d.ts.map +1 -1
  30. package/dist/types/ui/qr.d.ts.map +1 -1
  31. package/dist/types/web-features.d.ts.map +1 -1
  32. package/package.json +11 -10
  33. package/src/dweb-connect-client.ts +15 -15
  34. package/src/dweb-connect-wallet.ts +14 -14
  35. package/src/handled-deferred.ts +38 -0
  36. package/src/index.ts +50 -0
  37. package/src/relay-connect-runner.ts +71 -30
  38. package/src/ui/connect-modal.ts +113 -72
  39. package/src/ui/qr.ts +145 -69
  40. package/src/web-features.ts +55 -43
@@ -346,6 +346,19 @@ function createRelaySession(): RelaySession {
346
346
  return session as RelaySession;
347
347
  }
348
348
 
349
+ /**
350
+ * Wraps `settler` so the returned thunk runs it through `settle()`. Defined
351
+ * at module scope — not as an inline closure inside each render function —
352
+ * so the returned closure is lexically nested one level (inside this
353
+ * function), not several levels inside `runConnectModal()`'s own render
354
+ * closures (Sonar S2004). `settle` and `settler` are threaded through
355
+ * unchanged, so behavior is identical to writing `() => settle(settler)`
356
+ * inline.
357
+ */
358
+ function settleWith(settle: (settler: () => void) => void, settler: () => void): () => void {
359
+ return () => settle(settler);
360
+ }
361
+
349
362
  /**
350
363
  * Opens the connect modal and resolves when the session ends.
351
364
  *
@@ -410,7 +423,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
410
423
  const theme = options.theme ?? {};
411
424
  const appearance = theme.appearance ?? 'auto';
412
425
  if (appearance !== 'auto') {
413
- modal.setAttribute('data-appearance', appearance);
426
+ modal.dataset.appearance = appearance;
414
427
  }
415
428
 
416
429
  // Dapp palette overrides land as inline custom properties: setProperty
@@ -420,9 +433,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
420
433
  const hasPaletteOverrides = theme.accent !== undefined || theme.accentContrast !== undefined
421
434
  || theme.light !== undefined || theme.dark !== undefined;
422
435
  const applyPaletteTokens = (): void => {
423
- const scheme = appearance === 'auto'
424
- ? (systemDark?.matches === true ? 'dark' : 'light')
425
- : appearance;
436
+ const systemScheme = systemDark?.matches === true ? 'dark' : 'light';
437
+ const scheme = appearance === 'auto' ? systemScheme : appearance;
426
438
  const palette: ConnectModalPalette = {
427
439
  accent : theme.accent,
428
440
  accentContrast : theme.accentContrast,
@@ -452,6 +464,11 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
452
464
  let remintAt: number | undefined;
453
465
  let popupBusy = false;
454
466
  let pinResolve: ((pin: string) => void) | undefined;
467
+ // Proxy pinResolve reads/clears for buildPinInputs (module scope), whose
468
+ // per-input closures would otherwise be nested inside renderPin()'s own
469
+ // executor chain too deeply (Sonar S2004).
470
+ const getPinResolve = (): ((pin: string) => void) | undefined => pinResolve;
471
+ const clearPinResolve = (): void => { pinResolve = undefined; };
455
472
  const discoveryCache = new Map<string, Promise<string | undefined>>();
456
473
 
457
474
  // Collapsed identity row: the selected wallet plus the next catalog
@@ -489,7 +506,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
489
506
  document.removeEventListener('keydown', onKeydown);
490
507
  document.removeEventListener('visibilitychange', onVisibilityReturn);
491
508
  systemDark?.removeEventListener?.('change', onSchemeChange);
492
- try { document.body.removeChild(host); } catch { /* best effort */ }
509
+ try { host.remove(); } catch { /* best effort */ }
493
510
  };
494
511
 
495
512
  const settle = (settler: () => void): void => {
@@ -504,8 +521,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
504
521
  writeLastChoice(deps.storage, { method, walletUrl: walletUrl ?? '' });
505
522
  }
506
523
  renderConnected();
507
- const finish = (): void => settle(() => resolve(result));
508
- setTimeout(finish, 1_200);
524
+ setTimeout(settleWith(settle, () => resolve(result)), 1_200);
509
525
  };
510
526
 
511
527
  const cancelModal = (): void => settle(() => reject(new Error('[@enbox/browser] Connect cancelled.')));
@@ -659,54 +675,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
659
675
  boxes.classList.add('shake');
660
676
  }
661
677
 
662
- const inputs: HTMLInputElement[] = [];
663
- for (let i = 0; i < PIN_LENGTH; i++) {
664
- const input = document.createElement('input');
665
- input.className = 'pin-input';
666
- input.type = 'text';
667
- input.inputMode = 'numeric';
668
- input.maxLength = 1;
669
- input.setAttribute('aria-label', `Code digit ${i + 1}`);
670
- inputs.push(input);
671
- boxes.appendChild(input);
672
- }
673
-
674
- const submitIfComplete = (): void => {
675
- const code = inputs.map((i) => i.value).join('');
676
- if (code.length === PIN_LENGTH && pinResolve !== undefined) {
677
- const resolveOnce = pinResolve;
678
- pinResolve = undefined;
679
- renderBusy('Checking the code…');
680
- resolveOnce(code);
681
- }
682
- };
683
-
684
- inputs.forEach((input, index) => {
685
- input.addEventListener('input', () => {
686
- input.value = input.value.replace(/\D/g, '').slice(0, 1);
687
- if (input.value !== '' && index + 1 < inputs.length) {
688
- inputs[index + 1].focus();
689
- }
690
- submitIfComplete();
691
- });
692
- input.addEventListener('keydown', (event) => {
693
- if (event.key === 'Backspace' && input.value === '' && index > 0) {
694
- inputs[index - 1].focus();
695
- }
696
- });
697
- input.addEventListener('paste', (event) => {
698
- const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, PIN_LENGTH);
699
- if (digits.length === 0) { return; }
700
- event.preventDefault();
701
- digits.split('').forEach((digit, digitIndex) => {
702
- if (inputs[digitIndex] !== undefined) {
703
- inputs[digitIndex].value = digit;
704
- }
705
- });
706
- inputs[Math.min(digits.length, PIN_LENGTH) - 1]?.focus();
707
- submitIfComplete();
708
- });
709
- });
678
+ const inputs = buildPinInputs(boxes, getPinResolve, clearPinResolve, renderBusy);
710
679
 
711
680
  setStage(
712
681
  el('p', 'stage-caption', `Enter the code shown in ${walletName}`),
@@ -730,7 +699,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
730
699
  setStage(
731
700
  el('p', 'stage-caption', 'No problem — nothing was shared.'),
732
701
  stageButton('Start over', () => { void startMethod(); }),
733
- stageLinkButton('Close', () => settle(() => resolve(undefined))),
702
+ stageLinkButton('Close', settleWith(settle, () => resolve(undefined))),
734
703
  );
735
704
  };
736
705
 
@@ -738,7 +707,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
738
707
  setStage(
739
708
  el('p', 'stage-caption', message),
740
709
  stageButton('Start over', () => { void startMethod(); }),
741
- stageLinkButton('Close', () => settle(() => reject(error ?? new Error(`[@enbox/browser] ${message}`)))),
710
+ stageLinkButton('Close', settleWith(settle, () => reject(error ?? new Error(`[@enbox/browser] ${message}`)))),
742
711
  );
743
712
  };
744
713
 
@@ -911,7 +880,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
911
880
  }
912
881
  };
913
882
 
914
- const startPopup = (): void => {
883
+ const startPopup = async (): Promise<void> => {
915
884
  // Synchronous popup open inside the click's call stack.
916
885
  relaySession?.cancel();
917
886
  clearRemint();
@@ -928,8 +897,12 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
928
897
  popupBusy = true;
929
898
  renderPopupWaiting();
930
899
 
900
+ // `runPopup(...)` is invoked synchronously (its `window.open` runs before the
901
+ // first `await`), preserving the user-gesture popup open. Awaiting it inside the
902
+ // try means both a synchronous throw (a custom transport) and an async rejection
903
+ // are handled here — no unhandled promise sits inside the try (Sonar S4822).
931
904
  try {
932
- deps.runPopup({
905
+ const result = await deps.runPopup({
933
906
  walletUrl,
934
907
  permissionRequests : options.permissionRequests,
935
908
  appName,
@@ -937,20 +910,17 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
937
910
  timeoutMs : options.timeout,
938
911
  delegatePortableDid : options.delegatePortableDid,
939
912
  requestType : options.mode,
940
- })
941
- .then((result) => {
942
- if (settled) { return; }
943
- if (result === undefined) {
944
- renderDenied();
945
- } else {
946
- succeed(result);
947
- }
948
- })
949
- .catch(renderPopupFailure)
950
- .finally(() => { popupBusy = false; });
913
+ });
914
+ if (settled) { return; }
915
+ if (result === undefined) {
916
+ renderDenied();
917
+ } else {
918
+ succeed(result);
919
+ }
951
920
  } catch (error) {
952
- popupBusy = false;
953
921
  renderPopupFailure(error);
922
+ } finally {
923
+ popupBusy = false;
954
924
  }
955
925
  };
956
926
 
@@ -1044,7 +1014,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
1044
1014
 
1045
1015
  // The panel grid holds only the wallets NOT already visible in the
1046
1016
  // row, so expanding never shows the same wallet twice.
1047
- const gridWallets = wallets.filter((wallet) => !rowWallets.some((shown) => shown.url === wallet.url));
1017
+ const gridWallets = wallets.filter((wallet) => !walletInCatalog(rowWallets, wallet.url));
1048
1018
 
1049
1019
  const more = document.createElement('button');
1050
1020
  more.className = 'row-tile more-tile';
@@ -1340,6 +1310,77 @@ function buildHeader(titleText: string, appIcon: string | undefined, onCancel: (
1340
1310
  return header;
1341
1311
  }
1342
1312
 
1313
+ /**
1314
+ * Builds the PIN-entry boxes, wires their input/keydown/paste behavior, and
1315
+ * returns the input elements in order. Module-level — `renderPin()`'s own
1316
+ * `new Promise` executor already nests two levels deep inside
1317
+ * `runConnectModal()`, so a per-input closure defined there would sit too
1318
+ * deep for Sonar's S2004 nesting-depth check.
1319
+ *
1320
+ * `getPinResolve`/`clearPinResolve` proxy the `pinResolve` state kept in
1321
+ * `runConnectModal()`'s closure: a completed code needs it read once, then
1322
+ * cleared — in that order, before the busy state renders and the pending
1323
+ * promise resolves — exactly as it was inline.
1324
+ */
1325
+ function buildPinInputs(
1326
+ boxes: HTMLDivElement,
1327
+ getPinResolve: () => ((pin: string) => void) | undefined,
1328
+ clearPinResolve: () => void,
1329
+ renderBusy: (message: string) => void,
1330
+ ): HTMLInputElement[] {
1331
+ const inputs: HTMLInputElement[] = [];
1332
+ for (let i = 0; i < PIN_LENGTH; i++) {
1333
+ const input = document.createElement('input');
1334
+ input.className = 'pin-input';
1335
+ input.type = 'text';
1336
+ input.inputMode = 'numeric';
1337
+ input.maxLength = 1;
1338
+ input.setAttribute('aria-label', `Code digit ${i + 1}`);
1339
+ inputs.push(input);
1340
+ boxes.appendChild(input);
1341
+ }
1342
+
1343
+ const submitIfComplete = (): void => {
1344
+ const code = inputs.map((i) => i.value).join('');
1345
+ const pinResolve = getPinResolve();
1346
+ if (code.length === PIN_LENGTH && pinResolve !== undefined) {
1347
+ const resolveOnce = pinResolve;
1348
+ clearPinResolve();
1349
+ renderBusy('Checking the code…');
1350
+ resolveOnce(code);
1351
+ }
1352
+ };
1353
+
1354
+ inputs.forEach((input, index) => {
1355
+ input.addEventListener('input', () => {
1356
+ input.value = input.value.replace(/\D/g, '').slice(0, 1);
1357
+ if (input.value !== '' && index + 1 < inputs.length) {
1358
+ inputs[index + 1].focus();
1359
+ }
1360
+ submitIfComplete();
1361
+ });
1362
+ input.addEventListener('keydown', (event) => {
1363
+ if (event.key === 'Backspace' && input.value === '' && index > 0) {
1364
+ inputs[index - 1].focus();
1365
+ }
1366
+ });
1367
+ input.addEventListener('paste', (event) => {
1368
+ const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, PIN_LENGTH);
1369
+ if (digits.length === 0) { return; }
1370
+ event.preventDefault();
1371
+ digits.split('').forEach((digit, digitIndex) => {
1372
+ if (inputs[digitIndex] !== undefined) {
1373
+ inputs[digitIndex].value = digit;
1374
+ }
1375
+ });
1376
+ inputs[Math.min(digits.length, PIN_LENGTH) - 1]?.focus();
1377
+ submitIfComplete();
1378
+ });
1379
+ });
1380
+
1381
+ return inputs;
1382
+ }
1383
+
1343
1384
  function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
1344
1385
  const focusables = Array.from(
1345
1386
  shadow.querySelectorAll<HTMLElement>('button, input, a[href], [tabindex]:not([tabindex="-1"])'),
package/src/ui/qr.ts CHANGED
@@ -59,21 +59,58 @@ function dataCodewords(version: number): number {
59
59
  export function encodeQr(text: string, forcedMask?: number): QrCode {
60
60
  const data = new TextEncoder().encode(text);
61
61
 
62
- // ── Version selection: smallest version that fits the segment. ──
63
- let version = 0;
62
+ const version = selectVersion(data.length);
63
+ if (version === 0) {
64
+ throw new Error(`[@enbox/browser] QR payload too long (${data.length} bytes exceeds version ${MAX_VERSION}-M capacity).`);
65
+ }
66
+
67
+ const codewords = buildDataCodewords(data, version);
68
+
69
+ // ── Reed-Solomon blocks + interleave. ──
70
+ const allCodewords = buildInterleavedCodewords(codewords, version);
71
+
72
+ // ── Module placement. ──
73
+ const size = 17 + 4 * version;
74
+ const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
75
+ const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
76
+
77
+ drawFunctionPatterns(modules, isFunction, version);
78
+ drawCodewords(modules, isFunction, allCodewords);
79
+
80
+ // ── Mask selection: apply each mask, score, keep the best. ──
81
+ let mask = forcedMask ?? -1;
82
+ if (mask === -1) {
83
+ mask = selectBestMask(modules, isFunction);
84
+ }
85
+
86
+ applyMask(modules, isFunction, mask);
87
+ drawFormatBits(modules, isFunction, mask);
88
+
89
+ return { size, modules, version, mask };
90
+ }
91
+
92
+ /**
93
+ * Smallest version (1–{@link MAX_VERSION}) whose level-M byte-mode capacity
94
+ * fits a `dataLength`-byte payload plus its mode/char-count header; `0` if
95
+ * none fits.
96
+ */
97
+ function selectVersion(dataLength: number): number {
64
98
  for (let candidate = 1; candidate <= MAX_VERSION; candidate++) {
65
99
  const capacityBits = dataCodewords(candidate) * 8;
66
100
  const headerBits = 4 + (candidate <= 9 ? 8 : 16);
67
- if (headerBits + data.length * 8 <= capacityBits) {
68
- version = candidate;
69
- break;
101
+ if (headerBits + dataLength * 8 <= capacityBits) {
102
+ return candidate;
70
103
  }
71
104
  }
72
- if (version === 0) {
73
- throw new Error(`[@enbox/browser] QR payload too long (${data.length} bytes exceeds version ${MAX_VERSION}-M capacity).`);
74
- }
105
+ return 0;
106
+ }
75
107
 
76
- // ── Segment bits: mode 0100, char count, data, terminator, padding. ──
108
+ /**
109
+ * Builds the packed data codewords for `data` at `version`: mode indicator,
110
+ * char count, byte data, terminator, byte-alignment padding, and the
111
+ * alternating pad-byte fill up to the version's level-M data capacity.
112
+ */
113
+ function buildDataCodewords(data: Uint8Array, version: number): Uint8Array {
77
114
  const bits: number[] = [];
78
115
  const pushBits = (value: number, length: number): void => {
79
116
  for (let i = length - 1; i >= 0; i--) {
@@ -92,46 +129,34 @@ export function encodeQr(text: string, forcedMask?: number): QrCode {
92
129
  pushBits(0, (8 - (bits.length % 8)) % 8); // byte align
93
130
 
94
131
  const padBytes = [0xEC, 0x11];
95
- for (let i = 0; bits.length < capacityBits; i++) {
96
- pushBits(padBytes[i % 2], 8);
132
+ let padIndex = 0;
133
+ while (bits.length < capacityBits) {
134
+ pushBits(padBytes[padIndex], 8);
135
+ padIndex ^= 1; // alternate the two pad bytes
97
136
  }
98
137
 
99
138
  const codewords = new Uint8Array(capacityBits / 8);
100
139
  for (let i = 0; i < bits.length; i++) {
101
140
  codewords[i >> 3] |= bits[i] << (7 - (i & 7));
102
141
  }
142
+ return codewords;
143
+ }
103
144
 
104
- // ── Reed-Solomon blocks + interleave. ──
105
- const allCodewords = buildInterleavedCodewords(codewords, version);
106
-
107
- // ── Module placement. ──
108
- const size = 17 + 4 * version;
109
- const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
110
- const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
111
-
112
- drawFunctionPatterns(modules, isFunction, version);
113
- drawCodewords(modules, isFunction, allCodewords);
114
-
115
- // ── Mask selection: apply each mask, score, keep the best. ──
116
- let mask = forcedMask ?? -1;
117
- if (mask === -1) {
118
- let bestPenalty = Infinity;
119
- for (let candidate = 0; candidate < 8; candidate++) {
120
- applyMask(modules, isFunction, candidate);
121
- drawFormatBits(modules, isFunction, candidate);
122
- const penalty = computePenalty(modules);
123
- if (penalty < bestPenalty) {
124
- bestPenalty = penalty;
125
- mask = candidate;
126
- }
127
- applyMask(modules, isFunction, candidate); // undo (XOR is involutive)
145
+ /** Applies each of the 8 mask patterns, scores it, and returns the lowest-penalty mask index. */
146
+ function selectBestMask(modules: boolean[][], isFunction: boolean[][]): number {
147
+ let bestMask = 0;
148
+ let bestPenalty = Infinity;
149
+ for (let candidate = 0; candidate < 8; candidate++) {
150
+ applyMask(modules, isFunction, candidate);
151
+ drawFormatBits(modules, isFunction, candidate);
152
+ const penalty = computePenalty(modules);
153
+ if (penalty < bestPenalty) {
154
+ bestPenalty = penalty;
155
+ bestMask = candidate;
128
156
  }
157
+ applyMask(modules, isFunction, candidate); // undo (XOR is involutive)
129
158
  }
130
-
131
- applyMask(modules, isFunction, mask);
132
- drawFormatBits(modules, isFunction, mask);
133
-
134
- return { size, modules, version, mask };
159
+ return bestMask;
135
160
  }
136
161
 
137
162
  /** Splits data codewords into level-M blocks, computes ECC, interleaves. */
@@ -357,21 +382,46 @@ function drawCodewords(modules: boolean[][], isFunction: boolean[][], codewords:
357
382
  let bitIndex = 0;
358
383
 
359
384
  for (let right = size - 1; right >= 1; right -= 2) {
360
- if (right === 6) {
361
- right = 5;
362
- }
363
- for (let vertical = 0; vertical < size; vertical++) {
364
- for (let j = 0; j < 2; j++) {
365
- const x = right - j;
366
- const upward = ((right + 1) & 2) === 0;
367
- const y = upward ? size - 1 - vertical : vertical;
368
- if (!isFunction[y][x] && bitIndex < codewords.length * 8) {
369
- modules[y][x] = ((codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1) !== 0;
370
- bitIndex++;
371
- }
385
+ // Visit columns in right-to-left pairs, shifting one column left of the
386
+ // vertical timing column (index 6) so it is skipped — without mutating the
387
+ // loop counter (Sonar S2310). `size` is always odd, so `right` is always
388
+ // even and `right - 1` maps 6→5, 4→3, 2→1, matching the original traversal.
389
+ const rightCol = right <= 6 ? right - 1 : right;
390
+ bitIndex = placeColumnPairBits(modules, isFunction, codewords, rightCol, size, bitIndex);
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Places codeword bits into the zig-zag column pair (`rightCol`,
396
+ * `rightCol - 1`) top-to-bottom or bottom-to-top per {@link zigZagCoordinate},
397
+ * starting at `bitIndex`; returns the updated bit index.
398
+ */
399
+ function placeColumnPairBits(
400
+ modules: boolean[][],
401
+ isFunction: boolean[][],
402
+ codewords: Uint8Array,
403
+ rightCol: number,
404
+ size: number,
405
+ bitIndex: number,
406
+ ): number {
407
+ for (let vertical = 0; vertical < size; vertical++) {
408
+ for (let j = 0; j < 2; j++) {
409
+ const { x, y } = zigZagCoordinate(rightCol, vertical, j, size);
410
+ if (!isFunction[y][x] && bitIndex < codewords.length * 8) {
411
+ modules[y][x] = ((codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1) !== 0;
412
+ bitIndex++;
372
413
  }
373
414
  }
374
415
  }
416
+ return bitIndex;
417
+ }
418
+
419
+ /** Module coordinate for column-pair `rightCol`, row `vertical`, sub-column `j` (0 = right, 1 = left). */
420
+ function zigZagCoordinate(rightCol: number, vertical: number, j: number, size: number): { x: number; y: number } {
421
+ const x = rightCol - j;
422
+ const upward = ((rightCol + 1) & 2) === 0;
423
+ const y = upward ? size - 1 - vertical : vertical;
424
+ return { x, y };
375
425
  }
376
426
 
377
427
  /** XORs the mask pattern over non-function modules (involutive). */
@@ -402,11 +452,10 @@ function applyMask(modules: boolean[][], isFunction: boolean[][], mask: number):
402
452
 
403
453
  // ── Mask penalty scoring (rules N1–N4) ──────────────────────────
404
454
 
405
- function computePenalty(modules: boolean[][]): number {
406
- const size = modules.length;
455
+ /** N1: runs of ≥5 same-colour modules in a row/column. */
456
+ function penaltyAdjacentRuns(modules: boolean[][], size: number): number {
407
457
  let penalty = 0;
408
458
 
409
- // N1: runs of ≥5 same-colour modules in a row/column.
410
459
  for (let y = 0; y < size; y++) {
411
460
  let runColor = false;
412
461
  let runLength = 0;
@@ -442,7 +491,13 @@ function computePenalty(modules: boolean[][]): number {
442
491
  }
443
492
  }
444
493
 
445
- // N2: 2×2 blocks of the same colour.
494
+ return penalty;
495
+ }
496
+
497
+ /** N2: 2×2 blocks of the same colour. */
498
+ function penaltyBlocks(modules: boolean[][], size: number): number {
499
+ let penalty = 0;
500
+
446
501
  for (let y = 0; y < size - 1; y++) {
447
502
  for (let x = 0; x < size - 1; x++) {
448
503
  const c = modules[y][x];
@@ -452,17 +507,24 @@ function computePenalty(modules: boolean[][]): number {
452
507
  }
453
508
  }
454
509
 
455
- // N3: finder-like 1:1:3:1:1 patterns with 4-module light flank.
456
- const hasFinderPattern = (line: boolean[], start: number): number => {
457
- const core = [true, false, true, true, true, false, true];
458
- const matchesCore = core.every((value, index) => line[start + index] === value);
459
- if (!matchesCore) {
460
- return 0;
461
- }
462
- const lightBefore = start >= 4 && line.slice(start - 4, start).every((v) => !v);
463
- const lightAfter = start + 11 <= line.length && line.slice(start + 7, start + 11).every((v) => !v);
464
- return (lightBefore ? 40 : 0) + (lightAfter ? 40 : 0);
465
- };
510
+ return penalty;
511
+ }
512
+
513
+ /** Checks `line` for a 1:1:3:1:1 finder-like core starting at `start`, scoring a 4-module light flank on either side. */
514
+ function hasFinderPattern(line: boolean[], start: number): number {
515
+ const core = [true, false, true, true, true, false, true];
516
+ const matchesCore = core.every((value, index) => line[start + index] === value);
517
+ if (!matchesCore) {
518
+ return 0;
519
+ }
520
+ const lightBefore = start >= 4 && line.slice(start - 4, start).every((v) => !v);
521
+ const lightAfter = start + 11 <= line.length && line.slice(start + 7, start + 11).every((v) => !v);
522
+ return (lightBefore ? 40 : 0) + (lightAfter ? 40 : 0);
523
+ }
524
+
525
+ /** N3: finder-like 1:1:3:1:1 patterns with 4-module light flank. */
526
+ function penaltyFinderPatterns(modules: boolean[][], size: number): number {
527
+ let penalty = 0;
466
528
 
467
529
  for (let y = 0; y < size; y++) {
468
530
  const row = modules[y];
@@ -473,7 +535,11 @@ function computePenalty(modules: boolean[][]): number {
473
535
  }
474
536
  }
475
537
 
476
- // N4: dark-module proportion deviation from 50%, in 5% steps.
538
+ return penalty;
539
+ }
540
+
541
+ /** N4: dark-module proportion deviation from 50%, in 5% steps. */
542
+ function penaltyDarkRatio(modules: boolean[][], size: number): number {
477
543
  let dark = 0;
478
544
  for (const row of modules) {
479
545
  for (const module of row) {
@@ -484,7 +550,17 @@ function computePenalty(modules: boolean[][]): number {
484
550
  }
485
551
  const total = size * size;
486
552
  const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
487
- penalty += k * 10;
553
+ return k * 10;
554
+ }
555
+
556
+ function computePenalty(modules: boolean[][]): number {
557
+ const size = modules.length;
558
+ let penalty = 0;
559
+
560
+ penalty += penaltyAdjacentRuns(modules, size);
561
+ penalty += penaltyBlocks(modules, size);
562
+ penalty += penaltyFinderPatterns(modules, size);
563
+ penalty += penaltyDarkRatio(modules, size);
488
564
 
489
565
  return penalty;
490
566
  }