@marianmeres/scanner 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md ADDED
@@ -0,0 +1,696 @@
1
+ # API
2
+
3
+ Package exports:
4
+
5
+ - `@marianmeres/scanner` — headless core (no DOM construction; DOM types only)
6
+ - `@marianmeres/scanner/stage` — framework-agnostic DOM stage UI
7
+
8
+ ## Functions
9
+
10
+ ### `createScanner(config?)`
11
+
12
+ Create a headless (no UI) camera barcode scanner.
13
+
14
+ Reactive state is exposed via the svelte-store-compatible `subscribe`/`get`. Methods
15
+ never throw — errors land in `state.error` (see
16
+ [`ScannerErrorCode`](#scannererrorcode)).
17
+
18
+ **Parameters:**
19
+
20
+ - `config` (`ScannerConfig`, optional) — Configuration options. See
21
+ [`ScannerConfig`](#scannerconfig).
22
+
23
+ **Returns:** `Scanner` — Scanner instance with reactive state
24
+
25
+ **Example:**
26
+
27
+ ```typescript
28
+ const scanner = createScanner({ onScan: (r) => console.log(r.value) });
29
+ const result = await scanner.start(); // single-shot: auto-stops on first hit
30
+ scanner.destroy();
31
+ ```
32
+
33
+ ---
34
+
35
+ ### `scanImage(source, options?)`
36
+
37
+ Decode barcodes from a still image — no camera involved. Useful for file uploads,
38
+ drag & drop, or as a fallback where `getUserMedia` is unavailable.
39
+
40
+ Unlike the scanner instance methods (which never throw), this standalone utility
41
+ **THROWS** on fetch/decode failure. An image without any recognizable code resolves
42
+ with `[]` (not an error).
43
+
44
+ **Parameters:**
45
+
46
+ - `source` (`DetectorSource | string`) — The image to decode: `Blob` / `File`,
47
+ `ImageData`, `ImageBitmap`, `HTMLImageElement`, `HTMLCanvasElement`,
48
+ `HTMLVideoElement`, or a URL string (fetched, then decoded as a `Blob`).
49
+ - `options` (`ScanImageOptions`, optional)
50
+ - `options.formats` (`BarcodeFormat[]`) — Formats to detect. Default `["qr_code"]`.
51
+ - `options.detector` (`Detector`) — Decoding engine seam. Default wraps the
52
+ `barcode-detector` ponyfill.
53
+ - `options.wasmOverrides` (`Record<string, unknown>`) — See
54
+ [`ScannerConfig.wasmOverrides`](#scannerconfig).
55
+
56
+ **Returns:** `Promise<ScanResult[]>` — All detections found in the image (all share
57
+ one timestamp).
58
+
59
+ **Example:**
60
+
61
+ ```typescript
62
+ const results = await scanImage(file); // File | Blob | ImageData | <img> | url
63
+ if (results.length) console.log(results[0].value);
64
+ ```
65
+
66
+ ---
67
+
68
+ ### `createDefaultCameraAdapter(options?)`
69
+
70
+ Create the default [`CameraAdapter`](#cameraadapter). Stream acquisition and
71
+ device enumeration wrap `navigator.mediaDevices` directly (the scanner must
72
+ RETAIN the stream — deliberately out of mediaperms' scope); the permission
73
+ lifecycle (query + change tracking, Android-WebView sticky-denial coercion,
74
+ bfcache/app-resume rechecks) is delegated to `@marianmeres/mediaperms`. Used
75
+ internally when no `config.adapter` is given; exported for consumers who want
76
+ to share a mediaperms instance or extend the default behavior.
77
+
78
+ **Parameters:**
79
+
80
+ - `options` (`CreateDefaultCameraAdapterOptions`, optional):
81
+ - `perms` (`MediaPerms`, optional) — share an existing app-owned
82
+ `@marianmeres/mediaperms` instance (camera kind). The adapter will NOT
83
+ destroy a shared instance. When omitted, the adapter lazily creates its
84
+ own via `createCamPerms()` and destroys it in `destroy()`.
85
+ - `permsConfig` (`MediaPermsConfig`, optional) — forwarded to
86
+ `createCamPerms()` when the adapter creates its own instance (platform
87
+ hints, webview bridges, logger…). Ignored when `perms` is provided.
88
+
89
+ **Returns:** `CameraAdapter`
90
+
91
+ **Example:**
92
+
93
+ ```typescript
94
+ import { createCamPerms } from "@marianmeres/mediaperms";
95
+
96
+ // self-contained (internal mediaperms instance):
97
+ const scanner = createScanner({ adapter: createDefaultCameraAdapter() });
98
+
99
+ // or share the app's instance:
100
+ const perms = createCamPerms();
101
+ const adapter = createDefaultCameraAdapter({ perms });
102
+ const scanner2 = createScanner({ adapter });
103
+ ```
104
+
105
+ ---
106
+
107
+ ### `createDefaultDetector(options?)`
108
+
109
+ Create the default [`Detector`](#detector) wrapping the `barcode-detector` ponyfill
110
+ (zxing-cpp wasm engine, all major 1D/2D formats).
111
+
112
+ NOTE: the ~1 MB decoder `.wasm` is fetched lazily (on first `detect()`) from the
113
+ jsDelivr CDN by default — pass `wasmOverrides.locateFile` to self-host it.
114
+
115
+ **Parameters:**
116
+
117
+ - `options` (`CreateDefaultDetectorOptions`, optional)
118
+ - `options.formats` (`BarcodeFormat[]`) — Formats to detect. Default `["qr_code"]`.
119
+ - `options.wasmOverrides` (`Record<string, unknown>`) — Overrides passed to
120
+ zxing-wasm's `prepareZXingModule` — most notably `locateFile` for self-hosting
121
+ the `.wasm` binary.
122
+
123
+ **Returns:** `Detector`
124
+
125
+ **Example:**
126
+
127
+ ```typescript
128
+ const detector = createDefaultDetector({
129
+ formats: ["qr_code", "ean_13"],
130
+ wasmOverrides: { locateFile: (path: string) => `/assets/${path}` },
131
+ });
132
+ const scanner = createScanner({ detector });
133
+ ```
134
+
135
+ ---
136
+
137
+ ### `classifyAcquireError(e)`
138
+
139
+ Classify a `getUserMedia` (or adapter) rejection into a [`ScannerError`](#scannererror).
140
+ Maps `NotFoundError`/`DevicesNotFoundError` → `NO_DEVICE`, `SecurityError` →
141
+ `INSECURE_CONTEXT`, `NotReadableError`/`TrackStartError` → `DEVICE_BUSY`,
142
+ `NotSupportedError` → `NOT_SUPPORTED`, anything else → `REQUEST_FAILED`. Note:
143
+ permission denial (`NotAllowedError`/`PermissionDeniedError`) is handled
144
+ separately by the scanner (→ `PERMISSION_DENIED` + `state.permission =
145
+ "denied"`) before this classifier applies. Used internally; exported for
146
+ custom adapters that want identical taxonomy.
147
+
148
+ **Parameters:**
149
+
150
+ - `e` (`unknown`) — The thrown value (typically a `DOMException`).
151
+
152
+ **Returns:** `ScannerError` — `{ code, message }`
153
+
154
+ ---
155
+
156
+ ### `detectFacing(label)`
157
+
158
+ Best-effort camera facing detection from a device label (`/back|rear|environment/i`
159
+ → `"environment"`, `/front|user|facetime|selfie/i` → `"user"`).
160
+
161
+ **Parameters:**
162
+
163
+ - `label` (`string`) — Device label as reported by `enumerateDevices`.
164
+
165
+ **Returns:** `CameraFacing | null` — `null` when the label matches neither pattern.
166
+
167
+ ---
168
+
169
+ ### `toScanResult(d, timestamp?)`
170
+
171
+ Map a raw detection ([`DetectedBarcodeLike`](#detectedbarcodelike)) to the public
172
+ [`ScanResult`](#scanresult) shape. Used internally; useful when implementing a custom
173
+ `Detector`.
174
+
175
+ **Parameters:**
176
+
177
+ - `d` (`DetectedBarcodeLike`) — The raw detection.
178
+ - `timestamp` (`number`, optional) — Detection timestamp. Default `Date.now()`.
179
+
180
+ **Returns:** `ScanResult`
181
+
182
+ ---
183
+
184
+ ## Scanner Instance
185
+
186
+ Returned by `createScanner()`. After `destroy()`, the lifecycle methods become
187
+ no-ops (`start()` logs a warning and resolves `null`, `setTorch()` resolves `false`);
188
+ `subscribe()`/`get()` keep working on the final state.
189
+
190
+ ### `subscribe(cb)`
191
+
192
+ Subscribe to reactive state changes. Callback fires immediately with the current
193
+ state, then on every change. Compatible with Svelte's `$store` contract.
194
+
195
+ **Parameters:**
196
+
197
+ - `cb` (`(state: ScannerState) => void`) — State callback
198
+
199
+ **Returns:** `Unsubscribe` (from `@marianmeres/store`) — idempotent unsubscribe
200
+ function that also implements `Symbol.dispose` (usable with `using`)
201
+
202
+ ---
203
+
204
+ ### `get()`
205
+
206
+ Get the current state snapshot.
207
+
208
+ **Returns:** `ScannerState`
209
+
210
+ ---
211
+
212
+ ### `start()`
213
+
214
+ Acquire the camera and start scanning. Never rejects.
215
+
216
+ - `"single"` mode: resolves with the first [`ScanResult`](#scanresult), or `null` when
217
+ cancelled via `stop()` (or on failure — check `state.error`).
218
+ - `"continuous"` mode: resolves `null` when stopped.
219
+
220
+ Concurrent calls while active return the same in-flight promise. On acquisition
221
+ failure the status returns to `"idle"`; after a successful scan/cancel it is
222
+ `"stopped"`. A permission denial additionally sets `state.permission = "denied"`.
223
+
224
+ **Returns:** `Promise<ScanResult | null>`
225
+
226
+ ---
227
+
228
+ ### `stop()`
229
+
230
+ Cancel: stop the detection loop, release the camera (all tracks stopped, video
231
+ `srcObject` detached), resolve a pending `start()` with `null`, set status
232
+ `"stopped"`. No-op when nothing is running.
233
+
234
+ **Returns:** `void`
235
+
236
+ ---
237
+
238
+ ### `listCameras()`
239
+
240
+ List available video input devices (also updates `state.cameras`). NOTE: browsers
241
+ return empty labels until camera permission has been granted at least once — the
242
+ scanner re-enumerates automatically after a successful `start()`. Resolves `[]` on
243
+ enumeration failure (never rejects).
244
+
245
+ **Returns:** `Promise<CameraInfo[]>`
246
+
247
+ ---
248
+
249
+ ### `setCamera(deviceId)`
250
+
251
+ Switch to another camera. While scanning, performs a live switch (the current stream
252
+ is released first — most mobile browsers cannot open two cameras at once); otherwise
253
+ the device is remembered and applied on the next `start()`. Concurrent calls are
254
+ serialized (the second switch runs after the first completes); switching to the
255
+ already-active camera is a no-op. On a failed live switch the error is classified
256
+ into `state.error` and the scanner stops.
257
+
258
+ **Parameters:**
259
+
260
+ - `deviceId` (`string`) — Device id from [`CameraInfo`](#camerainfo).
261
+
262
+ **Returns:** `Promise<void>`
263
+
264
+ ---
265
+
266
+ ### `setTorch(on)`
267
+
268
+ Turn the torch (flashlight) on/off via `MediaStreamTrack.applyConstraints`. Resolves
269
+ `true` on success, `false` when unsupported or failed (never rejects). Support is
270
+ reflected in `state.torch.supported` — probed at stream acquisition and re-probed
271
+ ~500 ms later (Android Chrome reports track capabilities only once the camera is
272
+ actually streaming); the torch resets to off on stream release/switch.
273
+
274
+ **Parameters:**
275
+
276
+ - `on` (`boolean`) — Desired torch state.
277
+
278
+ **Returns:** `Promise<boolean>`
279
+
280
+ ---
281
+
282
+ ### `getVideo()`
283
+
284
+ The video element used for the live preview. Lazily created when none was configured
285
+ (with `playsinline`, `muted`, `autoplay` handled); returns `null` in non-DOM
286
+ environments. The shipped stage UI renders this element.
287
+
288
+ **Returns:** `HTMLVideoElement | null`
289
+
290
+ ---
291
+
292
+ ### `destroy()`
293
+
294
+ Stop everything and release all resources/listeners (including the permission-change
295
+ subscription). Idempotent.
296
+
297
+ **Returns:** `void`
298
+
299
+ ---
300
+
301
+ ## Types
302
+
303
+ ### `ScannerConfig`
304
+
305
+ ```typescript
306
+ interface ScannerConfig {
307
+ video?: HTMLVideoElement;
308
+ formats?: BarcodeFormat[]; // default ["qr_code"]
309
+ mode?: ScanMode; // default "single"
310
+ onScan?: (result: ScanResult) => void;
311
+ onError?: (error: ScannerError) => void;
312
+ preferredCamera?: CameraFacing | (string & Record<never, never>); // default "environment"
313
+ scanIntervalMs?: number; // default 100 (~10 fps)
314
+ dedupeMs?: number; // default 1500
315
+ adapter?: CameraAdapter;
316
+ detector?: Detector;
317
+ wasmOverrides?: Record<string, unknown>;
318
+ logger?: LoggerLike;
319
+ }
320
+ ```
321
+
322
+ | Field | Description |
323
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
324
+ | `video` | Video element to play the camera stream in. Created lazily when omitted (requires DOM). |
325
+ | `formats` | Formats to detect. Fewer formats = faster decode, fewer false positives. |
326
+ | `mode` | `"single"` — auto-stop after first detection; `"continuous"` — keep scanning, dedupe within `dedupeMs`. |
327
+ | `onScan` | Called for every successful detection. |
328
+ | `onError` | Called whenever an error is set on the state. |
329
+ | `preferredCamera` | `"environment"` / `"user"` facing hint (via `facingMode: { ideal }`), or a concrete `deviceId` (via `deviceId: { exact }`). |
330
+ | `scanIntervalMs` | Minimal interval between decode attempts in ms (clamped to `>= 0`). |
331
+ | `dedupeMs` | Continuous mode: suppress repeated detections of the identical value within this window (clamped to `>= 0`). |
332
+ | `adapter` | Camera acquisition seam. Default wraps `navigator.mediaDevices`. |
333
+ | `detector` | Decoding engine seam. Default wraps the `barcode-detector` ponyfill (created lazily on first `start()`; when set, `formats`/`wasmOverrides` are ignored). |
334
+ | `wasmOverrides` | Passed to zxing-wasm's `prepareZXingModule` — most notably `locateFile` for self-hosting the `.wasm` (CSP-strict or offline apps). |
335
+ | `logger` | Custom logger (`@marianmeres/clog` compatible). Default `createClog("scanner")`. |
336
+
337
+ ### `Scanner`
338
+
339
+ ```typescript
340
+ interface Scanner {
341
+ subscribe(cb: (state: ScannerState) => void): () => void;
342
+ get(): ScannerState;
343
+ start(): Promise<ScanResult | null>;
344
+ stop(): void;
345
+ listCameras(): Promise<CameraInfo[]>;
346
+ setCamera(deviceId: string): Promise<void>;
347
+ setTorch(on: boolean): Promise<boolean>;
348
+ getVideo(): HTMLVideoElement | null;
349
+ destroy(): void;
350
+ }
351
+ ```
352
+
353
+ ### `ScannerState`
354
+
355
+ ```typescript
356
+ interface ScannerState {
357
+ status: ScannerStatus;
358
+ error: ScannerError | null;
359
+ permission: CameraPermissionStatus;
360
+ torch: { supported: boolean; on: boolean };
361
+ cameras: CameraInfo[];
362
+ activeCameraId: string | null;
363
+ lastResult: ScanResult | null;
364
+ }
365
+ ```
366
+
367
+ | Field | Description |
368
+ | ---------------- | -------------------------------------------------------------------------------------------------------- |
369
+ | `status` | `"idle"` → `"initializing"` → `"scanning"` → `"stopped"` (back to `"idle"` on failed start) |
370
+ | `error` | Last error, or `null`. Methods never throw — errors land here. Cleared on each `start()`. |
371
+ | `permission` | Camera permission, best effort (Permissions API where available, otherwise from `getUserMedia` outcomes) |
372
+ | `torch` | Torch capability + current state of the active track |
373
+ | `cameras` | Populated on `listCameras()` and after successful `start()` (labels require permission) |
374
+ | `activeCameraId` | Device id of the currently active camera, or `null` |
375
+ | `lastResult` | Most recent successful detection, or `null`. Cleared on each `start()`. |
376
+
377
+ ### `ScanResult`
378
+
379
+ ```typescript
380
+ interface ScanResult {
381
+ value: string; // decoded payload
382
+ format: BarcodeFormat; // e.g. "qr_code"
383
+ cornerPoints: Point2D[]; // corners in source coordinates
384
+ boundingBox: DOMRectReadOnly; // bounding box in source coordinates
385
+ timestamp: number; // Date.now()
386
+ }
387
+ ```
388
+
389
+ ### `ScannerError`
390
+
391
+ ```typescript
392
+ interface ScannerError {
393
+ code: ScannerErrorCode;
394
+ message: string;
395
+ }
396
+ ```
397
+
398
+ ### `ScannerStatus`
399
+
400
+ ```typescript
401
+ type ScannerStatus = "idle" | "initializing" | "scanning" | "stopped";
402
+ ```
403
+
404
+ ### `ScanMode`
405
+
406
+ ```typescript
407
+ type ScanMode = "single" | "continuous";
408
+ ```
409
+
410
+ ### `CameraPermissionStatus`
411
+
412
+ ```typescript
413
+ type CameraPermissionStatus = "unknown" | "prompt" | "granted" | "denied";
414
+ ```
415
+
416
+ ### `CameraFacing`
417
+
418
+ ```typescript
419
+ type CameraFacing = "user" | "environment";
420
+ ```
421
+
422
+ ### `CameraInfo`
423
+
424
+ ```typescript
425
+ interface CameraInfo {
426
+ deviceId: string; // usable with scanner.setCamera()
427
+ label: string; // empty until permission has been granted at least once
428
+ facing: CameraFacing | null; // best-effort, derived from the label
429
+ }
430
+ ```
431
+
432
+ ### `BarcodeFormat`
433
+
434
+ Formats supported by the underlying zxing-cpp engine. The special values
435
+ `"linear_codes"`, `"matrix_codes"` and `"any"` are engine-level shortcuts expanding to
436
+ the respective format groups. The type is open (`string`-compatible) for
437
+ forward-compat while keeping autocomplete on the known values.
438
+
439
+ ```typescript
440
+ type BarcodeFormat =
441
+ | "aztec"
442
+ | "codabar"
443
+ | "code_39"
444
+ | "code_93"
445
+ | "code_128"
446
+ | "data_matrix"
447
+ | "databar"
448
+ | "databar_expanded"
449
+ | "databar_limited"
450
+ | "dx_film_edge"
451
+ | "ean_8"
452
+ | "ean_13"
453
+ | "itf"
454
+ | "maxi_code"
455
+ | "micro_qr_code"
456
+ | "pdf417"
457
+ | "qr_code"
458
+ | "rm_qr_code"
459
+ | "upc_a"
460
+ | "upc_e"
461
+ | "linear_codes"
462
+ | "matrix_codes"
463
+ | "any"
464
+ | (string & Record<never, never>);
465
+ ```
466
+
467
+ ### `Point2D`
468
+
469
+ ```typescript
470
+ interface Point2D {
471
+ x: number;
472
+ y: number;
473
+ }
474
+ ```
475
+
476
+ ### `Detector`
477
+
478
+ Abstraction over the barcode decoding engine — shaped like the W3C `BarcodeDetector`.
479
+ Injectable via `ScannerConfig.detector` for testing or engine swaps.
480
+
481
+ ```typescript
482
+ interface Detector {
483
+ detect(source: DetectorSource): Promise<DetectedBarcodeLike[]>;
484
+ }
485
+ ```
486
+
487
+ ### `DetectorSource`
488
+
489
+ ```typescript
490
+ type DetectorSource =
491
+ | HTMLVideoElement
492
+ | HTMLImageElement
493
+ | HTMLCanvasElement
494
+ | ImageBitmap
495
+ | ImageData
496
+ | Blob;
497
+ ```
498
+
499
+ ### `DetectedBarcodeLike`
500
+
501
+ The raw detection shape produced by a `Detector` — structurally compatible with the
502
+ W3C `DetectedBarcode`.
503
+
504
+ ```typescript
505
+ interface DetectedBarcodeLike {
506
+ rawValue: string;
507
+ format: string;
508
+ cornerPoints: Point2D[];
509
+ boundingBox: DOMRectReadOnly;
510
+ }
511
+ ```
512
+
513
+ ### `CameraAdapter`
514
+
515
+ Abstraction over camera acquisition. Injectable via `ScannerConfig.adapter` for
516
+ testing or replacement. The default implementation
517
+ ([`createDefaultCameraAdapter`](#createdefaultcameraadapteroptions)) wraps
518
+ `navigator.mediaDevices` for streams/enumeration and delegates the permission
519
+ lifecycle to `@marianmeres/mediaperms`.
520
+
521
+ NOTE (difference vs mediaperms): the scanner RETAINS the acquired stream for the live
522
+ preview — the adapter must return a live `MediaStream`, the scanner is responsible for
523
+ stopping its tracks.
524
+
525
+ ```typescript
526
+ interface CameraAdapter {
527
+ getStream(constraints: MediaStreamConstraints): Promise<MediaStream>;
528
+ enumerateVideoDevices(): Promise<CameraInfo[]>;
529
+ queryPermission(): Promise<CameraPermissionStatus | null>;
530
+ onPermissionChange(
531
+ cb: (status: CameraPermissionStatus) => void,
532
+ ): (() => void) | null;
533
+ destroy?(): void;
534
+ }
535
+ ```
536
+
537
+ | Method | Description |
538
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------- |
539
+ | `getStream(constraints)` | Acquire a camera stream. May prompt. Rejections are classified by the scanner. |
540
+ | `enumerateVideoDevices()` | List available video input devices. |
541
+ | `queryPermission()` | Query camera permission without prompting. Return `null` when undeterminable. |
542
+ | `onPermissionChange(cb)` | Subscribe to permission changes. Return unsubscribe fn, or `null` when not supported. |
543
+ | `destroy()` (optional) | Release adapter-owned resources. Called by `Scanner.destroy()` ONLY for the adapter the scanner created itself. |
544
+
545
+ ### `LoggerLike`
546
+
547
+ Logger shape (`@marianmeres/clog` compatible; `console` also satisfies it).
548
+
549
+ ```typescript
550
+ interface LoggerLike {
551
+ (...args: any[]): any;
552
+ debug: (...args: any[]) => any;
553
+ warn: (...args: any[]) => any;
554
+ error: (...args: any[]) => any;
555
+ }
556
+ ```
557
+
558
+ ### `CreateDefaultDetectorOptions`
559
+
560
+ ```typescript
561
+ interface CreateDefaultDetectorOptions {
562
+ formats?: BarcodeFormat[]; // default ["qr_code"]
563
+ wasmOverrides?: Record<string, unknown>;
564
+ }
565
+ ```
566
+
567
+ ### `ScanImageOptions`
568
+
569
+ ```typescript
570
+ interface ScanImageOptions {
571
+ formats?: BarcodeFormat[]; // default ["qr_code"]
572
+ detector?: Detector;
573
+ wasmOverrides?: Record<string, unknown>;
574
+ }
575
+ ```
576
+
577
+ ---
578
+
579
+ ## Constants
580
+
581
+ ### `ScannerErrorCode`
582
+
583
+ Machine-readable error codes attached to `ScannerState.error.code`.
584
+
585
+ ```typescript
586
+ const ScannerErrorCode = {
587
+ NoDevice: "NO_DEVICE",
588
+ InsecureContext: "INSECURE_CONTEXT",
589
+ DeviceBusy: "DEVICE_BUSY",
590
+ PermissionDenied: "PERMISSION_DENIED",
591
+ RequestFailed: "REQUEST_FAILED",
592
+ NotSupported: "NOT_SUPPORTED",
593
+ DetectorFailed: "DETECTOR_FAILED",
594
+ } as const;
595
+
596
+ type ScannerErrorCode = typeof ScannerErrorCode[keyof typeof ScannerErrorCode];
597
+ ```
598
+
599
+ | Code | Cause |
600
+ | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
601
+ | `NO_DEVICE` | `getUserMedia` threw `NotFoundError` / `DevicesNotFoundError` — no camera available |
602
+ | `INSECURE_CONTEXT` | `getUserMedia` threw `SecurityError` — origin not secure (camera requires HTTPS) or Permissions-Policy blocks it |
603
+ | `DEVICE_BUSY` | `getUserMedia` threw `NotReadableError` / `TrackStartError` — hardware held by another consumer |
604
+ | `PERMISSION_DENIED` | Camera access denied by the user or platform policy (`NotAllowedError`) — `state.permission` is set to `"denied"` too |
605
+ | `REQUEST_FAILED` | Camera acquisition failed with a non-classified error, or the live camera track ended unexpectedly |
606
+ | `NOT_SUPPORTED` | Required platform API is missing (no `mediaDevices`, no DOM for the video element, …) |
607
+ | `DETECTOR_FAILED` | The barcode detector threw repeatedly (5 consecutive failures stop the scan) or failed to construct |
608
+
609
+ ### `DEFAULT_FORMATS`
610
+
611
+ `["qr_code"]` — Default formats (QR only — the fastest and the primary use case).
612
+
613
+ ---
614
+
615
+ ## Stage (`@marianmeres/scanner/stage`)
616
+
617
+ ### `createScannerStage(scanner, options)`
618
+
619
+ Mount the scanning stage UI into `options.container`: the scanner's cover-fit
620
+ `<video>` preview, a dimmed overlay with a rounded viewfinder cutout + corner guides,
621
+ an optional animated scan line (`prefers-reduced-motion` honored), a brief success
622
+ flash on detection, and opt-in control buttons. One scoped `<style>` element is
623
+ injected into `document.head` (once, id `mms-styles`).
624
+
625
+ The stage is a pure consumer of the headless `Scanner` — destroying the stage does
626
+ NOT stop or destroy the scanner (and vice versa).
627
+
628
+ Unlike the scanner methods, this factory **throws**: when called without a DOM
629
+ (`document` undefined), without `options.container`, or when the scanner cannot
630
+ provide a video element.
631
+
632
+ **Parameters:**
633
+
634
+ - `scanner` (`Scanner`) — The headless scanner instance (its `getVideo()` element is
635
+ rendered).
636
+ - `options` (`ScannerStageOptions`)
637
+
638
+ | Field | Type | Description |
639
+ | ----------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
640
+ | `container` | `HTMLElement` — **required** | Element to mount the stage into (its `position` is set to `relative` when unset). |
641
+ | `controls` | `{ cancel?: boolean; torch?: boolean; cameraSwitch?: boolean }` | Which built-in buttons to render. Default `{ cancel: true }`. Buttons for unsupported features hide automatically (torch without capability, camera switch with a single camera). |
642
+ | `scanLine` | `boolean` — default `true` | Render the animated scan line inside the viewfinder. |
643
+ | `theme` | `"auto" \| "light" \| "dark"` — default `"auto"` | `"auto"` follows `prefers-color-scheme` (live). |
644
+ | `accent` | `string` — optional | Accent color (any CSS color value) — sets `--mms-accent`. |
645
+ | `onCancel` | `() => void` — optional | Called after the built-in cancel button stopped the scanner. |
646
+
647
+ **Returns:** `ScannerStage`
648
+
649
+ ```typescript
650
+ interface ScannerStage {
651
+ el: HTMLElement; // the stage root (already mounted into the container)
652
+ destroy(): void; // unmount + release stage resources; does NOT touch the scanner
653
+ }
654
+ ```
655
+
656
+ **Example:**
657
+
658
+ ```typescript
659
+ import { createScanner } from "@marianmeres/scanner";
660
+ import { createScannerStage } from "@marianmeres/scanner/stage";
661
+
662
+ const scanner = createScanner();
663
+ const stage = createScannerStage(scanner, {
664
+ container: document.getElementById("scan")!,
665
+ controls: { cancel: true, torch: true, cameraSwitch: true },
666
+ accent: "#e11d48",
667
+ onCancel: () => console.log("cancelled"),
668
+ });
669
+
670
+ const result = await scanner.start();
671
+ stage.destroy();
672
+ scanner.destroy();
673
+ ```
674
+
675
+ ### `ScannerStageTheme`
676
+
677
+ ```typescript
678
+ type ScannerStageTheme = "auto" | "light" | "dark";
679
+ ```
680
+
681
+ ### Stage theming (CSS custom properties)
682
+
683
+ Scoped to the `.mms-stage` root — override on the container or via a stylesheet:
684
+
685
+ | Variable | Default | Purpose |
686
+ | -------------------- | -------------------------- | --------------------------------- |
687
+ | `--mms-accent` | `#3b82f6` | Scan line + active torch button |
688
+ | `--mms-success` | `#22c55e` | Detection success flash |
689
+ | `--mms-dim` | `rgba(0, 0, 0, 0.55)` | Overlay dim around the viewfinder |
690
+ | `--mms-corner-color` | `rgba(255, 255, 255, 0.9)` | Corner guide color |
691
+ | `--mms-corner-size` | `26px` | Corner guide length |
692
+ | `--mms-corner-width` | `4px` | Corner guide stroke width |
693
+ | `--mms-frame-size` | `min(65%, 65vmin)` | Viewfinder size |
694
+ | `--mms-radius` | `0` | Stage border radius |
695
+ | `--mms-btn-bg` | theme-dependent | Control button background |
696
+ | `--mms-btn-fg` | theme-dependent | Control button foreground |
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ # Project Instructions
2
+
3
+ See [AGENTS.md](./AGENTS.md) for complete project documentation and AI agent instructions.