@proveanything/smartlinks 1.11.0 → 1.11.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.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.0 | Generated: 2026-04-30T11:35:44.821Z
3
+ Version: 1.11.1 | Generated: 2026-04-30T12:17:30.051Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -103,8 +103,8 @@ The manifest is loaded automatically by the platform for every collection page.
103
103
  {
104
104
  "name": "WarehousePickContainer",
105
105
  "description": "In-field operator admin surface.",
106
- "audience": "admin-mobile",
107
- "capabilities": ["nfc", "qr", "offline-queue"]
106
+ "capabilities": ["nfc", "qr"],
107
+ "offline": true
108
108
  }
109
109
  ]
110
110
  },
@@ -236,8 +236,8 @@ See [mobile-admin-container.md](mobile-admin-container.md) for the `AdminMobileH
236
236
  {
237
237
  "name": "WarehousePickContainer",
238
238
  "description": "Pick orders by scanning NFC tags",
239
- "audience": "admin-mobile",
240
- "capabilities": ["nfc", "qr", "offline-queue"]
239
+ "capabilities": ["nfc", "qr"],
240
+ "offline": true
241
241
  }
242
242
  ]
243
243
  }
@@ -250,8 +250,8 @@ See [mobile-admin-container.md](mobile-admin-container.md) for the `AdminMobileH
250
250
  | `files.css` | CSS bundle path — set to `null` if no styles |
251
251
  | `components[].name` | Exported component name (must match the UMD bundle export) |
252
252
  | `components[].description` | Shown in the mobile launcher's app picker |
253
- | `components[].audience` | Must be `"admin-mobile"` |
254
- | `components[].capabilities` | Hardware/feature capabilities this component needs or can use. See [capability list](mobile-admin-container.md#hardware-capabilities--the-capability-matrix). |
253
+ | `components[].capabilities` | Hardware capabilities this component needs or can use. See [capability list](mobile-admin-container.md#hardware-capabilities--the-capability-matrix). |
254
+ | `components[].offline` | Set to `true` if this component queues writes locally and needs offline sync support. |
255
255
  #### `linkable`
256
256
 
257
257
  Static deep-linkable states built into the app — fixed routes that exist regardless of per-collection content. Declared once at build time.
@@ -16,12 +16,13 @@ This document describes how to build a **Mobile Admin Container** — a SmartLin
16
16
  4. [Hardware Capabilities & the Capability Matrix](#hardware-capabilities--the-capability-matrix)
17
17
  5. [Capacitor Plugin Baseline](#capacitor-plugin-baseline)
18
18
  6. [Manifest Declaration](#manifest-declaration)
19
- 7. [Event Stream](#event-stream)
20
- 8. [Error Handling](#error-handling)
21
- 9. [Lifecycle](#lifecycle)
22
- 10. [Build & Bundle Requirements](#build--bundle-requirements)
23
- 11. [Example: Minimal Mobile Admin Container](#example-minimal-mobile-admin-container)
24
- 12. [Best Practices](#best-practices)
19
+ 7. [Launcher Discovery](#launcher-discovery)
20
+ 8. [Event Stream](#event-stream)
21
+ 9. [Error Handling](#error-handling)
22
+ 10. [Lifecycle](#lifecycle)
23
+ 11. [Build & Bundle Requirements](#build--bundle-requirements)
24
+ 12. [Example: Minimal Mobile Admin Container](#example-minimal-mobile-admin-container)
25
+ 13. [Best Practices](#best-practices)
25
26
 
26
27
  ---
27
28
 
@@ -106,7 +107,8 @@ interface AdminMobileHostContext {
106
107
  network: { isOnline: () => boolean }
107
108
  device: { info: () => Promise<{ model: string; platform: string }> }
108
109
 
109
- // Host context version — for future feature-detection
110
+ // Informational host version — use for logging/diagnostics, not feature detection.
111
+ // For feature detection prefer existence checks: 'requestNfcTap' in host.actions
110
112
  _version: number
111
113
  }
112
114
  ```
@@ -114,7 +116,15 @@ interface AdminMobileHostContext {
114
116
  ### Contract rules
115
117
 
116
118
  1. **Check `host.hardware.X` before calling `host.actions.X`.** Calls to unavailable capabilities reject with `HostCapabilityUnavailableError`.
117
- 2. **Do not call `initializeApi`.** `host.SL` is already configured with the right `baseURL`, auth, and logger.
119
+ 2. **Do not call `initializeApi`, and do not use top-level SDK imports for API calls.** `host.SL` is already configured with the right `baseURL`, auth, and logger. Code that does `import * as SL from "@proveanything/smartlinks"` and calls `SL.attestation.create(...)` will silently hit the wrong base URL — the top-level import is uninitialised in the launcher's runtime and produces no loud error. Always go through `host.SL`:
120
+ ```typescript
121
+ // ❌ Silent footgun — uninitialised SDK, wrong baseURL
122
+ import * as SL from '@proveanything/smartlinks'
123
+ await SL.attestation.create(...)
124
+
125
+ // ✅ Correct
126
+ await host.SL.attestation.create(...)
127
+ ```
118
128
  3. **Do not access `window.SmartlinksScanner` or `window.Capacitor` directly.** The host wraps both; direct access produces containers that only work on one shell.
119
129
  4. **Externalise all shared deps** (React, ReactDOM, SL, Radix, etc.) — the launcher provides them as globals. See [Build & Bundle Requirements](#build--bundle-requirements).
120
130
 
@@ -129,6 +139,36 @@ if (host.hardware.nfc) {
129
139
  }
130
140
  ```
131
141
 
142
+ ### `host.ui` — native helpers vs. your own components
143
+
144
+ `host.ui.setHeaderTitle()` and `host.ui.navigateBack()` are **host-only** — there is no in-container equivalent. Call them via `host.ui` or omit them.
145
+
146
+ `host.ui.toast()` and `host.ui.haptic()` are **optional conveniences**. Use them when you want native system feedback. When rendering in Storybook, unit tests, or a plain browser tab, your own `<Toaster />` is a perfectly valid substitute — you do not need to stub the entire `host.ui` surface just to get toast notifications.
147
+
148
+ **Stub pattern for testing and Storybook:**
149
+
150
+ ```typescript
151
+ const stubHost: Partial<AdminMobileHostContext> = {
152
+ hardware: { nfc: false, rfid: false, qr: true, camera: true, keyboard: false },
153
+ actions: {
154
+ requestQrScan: async () => 'STUB_QR_CODE',
155
+ requestNfcTap: async () => ({ uid: 'STUB_UID' }),
156
+ requestCameraPhoto: async () => new Blob(),
157
+ share: async () => {},
158
+ clipboard: { read: async () => '', write: async () => {} },
159
+ },
160
+ ui: {
161
+ toast: (opts) => console.log('[stub toast]', opts.title),
162
+ haptic: () => {},
163
+ setHeaderTitle: (t) => { if (t) document.title = t },
164
+ navigateBack: () => history.back(),
165
+ },
166
+ network: { isOnline: () => true },
167
+ user: { isAdmin: true, displayName: 'Test User', email: 'test@example.com' },
168
+ SL: undefined as any, // replace with your test SL instance
169
+ }
170
+ ```
171
+
132
172
  ---
133
173
 
134
174
  ## Hardware Capabilities & the Capability Matrix
@@ -148,7 +188,6 @@ Containers declare which capabilities they need (or can use) in the manifest `ca
148
188
  | `"camera"` | Photo capture, gallery picker |
149
189
  | `"keyboard"` | Physical hardware trigger/action buttons |
150
190
  | `"geolocation"` | GPS coordinates |
151
- | `"offline-queue"` | Container needs local write queuing + sync |
152
191
  | `"push"` | Remote push notifications (FCM/APNs) |
153
192
 
154
193
  The full hardware capability matrix by host:
@@ -196,7 +235,7 @@ The custom Kotlin shell and both Capacitor shells ship the same baseline plugin
196
235
  | `@capgo/capacitor-nfc` | `"nfc"` |
197
236
  | `@capacitor/geolocation` | `"geolocation"` |
198
237
  | `@capacitor/push-notifications` | `"push"` |
199
- | `@capacitor/filesystem` | *(declare `"offline-queue"` if needed)* |
238
+ | `@capacitor/filesystem` | *(used when `offline: true`; bundle it in)* |
200
239
 
201
240
  ### Tier 3 — custom-android only (not Capacitor)
202
241
 
@@ -231,8 +270,8 @@ Declare the bundle under the top-level `mobileAdmin` key in `app.manifest.json`.
231
270
  {
232
271
  "name": "WarehousePickContainer",
233
272
  "description": "Pick orders by scanning NFC tags",
234
- "audience": "admin-mobile",
235
- "capabilities": ["nfc", "qr", "offline-queue"]
273
+ "capabilities": ["nfc", "qr"],
274
+ "offline": true
236
275
  }
237
276
  ]
238
277
  }
@@ -247,8 +286,29 @@ A `mobileAdmin` bundle can export multiple components targeting different operat
247
286
  |-------|------|-------------|
248
287
  | `name` | string | Exported component name (must match the UMD bundle export) |
249
288
  | `description` | string | Shown in the mobile launcher's app picker |
250
- | `audience` | `"admin-mobile"` | Required tells the launcher this is an operator surface |
251
- | `capabilities` | string[] | Capabilities this component needs or can use. See table above. |
289
+ | `capabilities` | string[] | Hardware capabilities this component needs or can use. See table above. |
290
+ | `offline` | boolean | Set to `true` if this component queues writes locally and needs offline sync support. |
291
+
292
+ ---
293
+
294
+ ## Launcher Discovery
295
+
296
+ The SmartLinks Mobile launcher loads your `mobileAdmin` bundle through the platform's app registry — you do not reference the manifest URL directly.
297
+
298
+ **How it works:**
299
+
300
+ 1. A collection admin enables the app for a collection in the SmartLinks admin console.
301
+ 2. On startup (and periodically), the launcher fetches `app.manifest.json` for every enabled app in that collection via the SmartLinks platform API. These requests are authenticated with the launcher's operator session.
302
+ 3. The launcher reads `mobileAdmin.components[]`, evaluates each component's `capabilities` against the current device's hardware flags, and shows matching components in the app picker.
303
+ 4. When the operator opens a component, the launcher resolves `mobileAdmin.files.js.umd` against the app's CDN base, fetches the bundle, and mounts the component with its `host` prop.
304
+
305
+ **File URL resolution:** `files.js.umd` / `files.js.esm` are paths relative to your app's CDN root (set when the app version is published). Provide relative paths — the launcher resolves them; do not hardcode absolute URLs.
306
+
307
+ **Auth:** Manifest and bundle fetches use the launcher's session token. Your container is already authenticated via `host.user` and `host.SL` — do not add additional auth headers.
308
+
309
+ **On load failure:** If the bundle returns a non-2xx response, the launcher shows a user-visible error and logs it — it does not crash the full launcher. If `app.manifest.json` itself cannot be fetched, the app is silently excluded from the picker for that session.
310
+
311
+ > To debug a missing or stale manifest, confirm in the SmartLinks admin console that the app version is published and that the `mobileAdmin` key is present in the uploaded manifest.
252
312
 
253
313
  ---
254
314
 
@@ -318,7 +378,7 @@ try {
318
378
  |-------|------|------------|
319
379
  | mount | User opens your container | Subscribe to events, start readers |
320
380
  | unmount | User backs out / app backgrounded > 30s | Unsubscribe; persist in-flight state |
321
- | `lifecycle: 'offline'` | Network lost | Switch to offline-queue mode |
381
+ | `lifecycle: 'offline'` | Network lost | Switch to offline mode; queue writes locally |
322
382
  | `lifecycle: 'online'` | Network restored | Flush queued writes |
323
383
  | `lifecycle: 'pause'` | App backgrounded | Pause readers to save battery |
324
384
  | `lifecycle: 'resume'` | App foregrounded | Resubscribe; refresh stale data |
@@ -459,9 +519,10 @@ export const MOBILE_ADMIN_MANIFEST = {
459
519
 
460
520
  - **Always check `host.hardware.X` before calling `host.actions.X`** — never assume a capability is available.
461
521
  - **Always wrap action calls in try/catch** — handle `HostPermissionDeniedError`, `HostTimeoutError`, and `HostCapabilityUnavailableError`.
462
- - **Use `host.ui.toast`, `host.ui.haptic`, `host.ui.setHeaderTitle`** instead of mounting your own UI chrome — these integrate with the launcher's native UI.
522
+ - **Use `host.ui.setHeaderTitle` and `host.ui.navigateBack`** for header integration — these are host-only and have no in-container equivalent.
523
+ - **`host.ui.toast` and `host.ui.haptic` are optional** — use them for native feedback, or fall back to your own `<Toaster />` when testing in isolation.
463
524
  - **Use `host.events.subscribe` for lifecycle events** — `'offline'`/`'online'`/`'pause'`/`'resume'` fire consistently on every host.
464
- - **Never call `initializeApi`** — `host.SL` is already configured.
525
+ - **Never call `initializeApi`, never use top-level SDK imports for API calls** — `host.SL` is already configured. `SL.method()` instead of `host.SL.method()` silently uses the wrong baseURL.
465
526
  - **Bundle Capacitor plugins in** (do not externalise) — so the component degrades gracefully on PWA/browser without crashing.
466
- - **Declare `offline-queue` in capabilities** if your container queues writes — this signals the launcher to provision local storage support.
467
- - **Use `host._version`** to feature-detect newer host context fields breaking changes bump the major version and the launcher gates incompatible combinations.
527
+ - **Declare `offline: true`** on a component if it queues writes locally — this signals the launcher to provision offline sync support.
528
+ - **Use `'methodName' in host.actions`** to feature-detect new host capabilities rather than comparing `host._version`. The version is informational only.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.0 | Generated: 2026-04-30T11:35:44.821Z
3
+ Version: 1.11.1 | Generated: 2026-04-30T12:17:30.051Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -103,8 +103,8 @@ The manifest is loaded automatically by the platform for every collection page.
103
103
  {
104
104
  "name": "WarehousePickContainer",
105
105
  "description": "In-field operator admin surface.",
106
- "audience": "admin-mobile",
107
- "capabilities": ["nfc", "qr", "offline-queue"]
106
+ "capabilities": ["nfc", "qr"],
107
+ "offline": true
108
108
  }
109
109
  ]
110
110
  },
@@ -236,8 +236,8 @@ See [mobile-admin-container.md](mobile-admin-container.md) for the `AdminMobileH
236
236
  {
237
237
  "name": "WarehousePickContainer",
238
238
  "description": "Pick orders by scanning NFC tags",
239
- "audience": "admin-mobile",
240
- "capabilities": ["nfc", "qr", "offline-queue"]
239
+ "capabilities": ["nfc", "qr"],
240
+ "offline": true
241
241
  }
242
242
  ]
243
243
  }
@@ -250,8 +250,8 @@ See [mobile-admin-container.md](mobile-admin-container.md) for the `AdminMobileH
250
250
  | `files.css` | CSS bundle path — set to `null` if no styles |
251
251
  | `components[].name` | Exported component name (must match the UMD bundle export) |
252
252
  | `components[].description` | Shown in the mobile launcher's app picker |
253
- | `components[].audience` | Must be `"admin-mobile"` |
254
- | `components[].capabilities` | Hardware/feature capabilities this component needs or can use. See [capability list](mobile-admin-container.md#hardware-capabilities--the-capability-matrix). |
253
+ | `components[].capabilities` | Hardware capabilities this component needs or can use. See [capability list](mobile-admin-container.md#hardware-capabilities--the-capability-matrix). |
254
+ | `components[].offline` | Set to `true` if this component queues writes locally and needs offline sync support. |
255
255
  #### `linkable`
256
256
 
257
257
  Static deep-linkable states built into the app — fixed routes that exist regardless of per-collection content. Declared once at build time.
@@ -16,12 +16,13 @@ This document describes how to build a **Mobile Admin Container** — a SmartLin
16
16
  4. [Hardware Capabilities & the Capability Matrix](#hardware-capabilities--the-capability-matrix)
17
17
  5. [Capacitor Plugin Baseline](#capacitor-plugin-baseline)
18
18
  6. [Manifest Declaration](#manifest-declaration)
19
- 7. [Event Stream](#event-stream)
20
- 8. [Error Handling](#error-handling)
21
- 9. [Lifecycle](#lifecycle)
22
- 10. [Build & Bundle Requirements](#build--bundle-requirements)
23
- 11. [Example: Minimal Mobile Admin Container](#example-minimal-mobile-admin-container)
24
- 12. [Best Practices](#best-practices)
19
+ 7. [Launcher Discovery](#launcher-discovery)
20
+ 8. [Event Stream](#event-stream)
21
+ 9. [Error Handling](#error-handling)
22
+ 10. [Lifecycle](#lifecycle)
23
+ 11. [Build & Bundle Requirements](#build--bundle-requirements)
24
+ 12. [Example: Minimal Mobile Admin Container](#example-minimal-mobile-admin-container)
25
+ 13. [Best Practices](#best-practices)
25
26
 
26
27
  ---
27
28
 
@@ -106,7 +107,8 @@ interface AdminMobileHostContext {
106
107
  network: { isOnline: () => boolean }
107
108
  device: { info: () => Promise<{ model: string; platform: string }> }
108
109
 
109
- // Host context version — for future feature-detection
110
+ // Informational host version — use for logging/diagnostics, not feature detection.
111
+ // For feature detection prefer existence checks: 'requestNfcTap' in host.actions
110
112
  _version: number
111
113
  }
112
114
  ```
@@ -114,7 +116,15 @@ interface AdminMobileHostContext {
114
116
  ### Contract rules
115
117
 
116
118
  1. **Check `host.hardware.X` before calling `host.actions.X`.** Calls to unavailable capabilities reject with `HostCapabilityUnavailableError`.
117
- 2. **Do not call `initializeApi`.** `host.SL` is already configured with the right `baseURL`, auth, and logger.
119
+ 2. **Do not call `initializeApi`, and do not use top-level SDK imports for API calls.** `host.SL` is already configured with the right `baseURL`, auth, and logger. Code that does `import * as SL from "@proveanything/smartlinks"` and calls `SL.attestation.create(...)` will silently hit the wrong base URL — the top-level import is uninitialised in the launcher's runtime and produces no loud error. Always go through `host.SL`:
120
+ ```typescript
121
+ // ❌ Silent footgun — uninitialised SDK, wrong baseURL
122
+ import * as SL from '@proveanything/smartlinks'
123
+ await SL.attestation.create(...)
124
+
125
+ // ✅ Correct
126
+ await host.SL.attestation.create(...)
127
+ ```
118
128
  3. **Do not access `window.SmartlinksScanner` or `window.Capacitor` directly.** The host wraps both; direct access produces containers that only work on one shell.
119
129
  4. **Externalise all shared deps** (React, ReactDOM, SL, Radix, etc.) — the launcher provides them as globals. See [Build & Bundle Requirements](#build--bundle-requirements).
120
130
 
@@ -129,6 +139,36 @@ if (host.hardware.nfc) {
129
139
  }
130
140
  ```
131
141
 
142
+ ### `host.ui` — native helpers vs. your own components
143
+
144
+ `host.ui.setHeaderTitle()` and `host.ui.navigateBack()` are **host-only** — there is no in-container equivalent. Call them via `host.ui` or omit them.
145
+
146
+ `host.ui.toast()` and `host.ui.haptic()` are **optional conveniences**. Use them when you want native system feedback. When rendering in Storybook, unit tests, or a plain browser tab, your own `<Toaster />` is a perfectly valid substitute — you do not need to stub the entire `host.ui` surface just to get toast notifications.
147
+
148
+ **Stub pattern for testing and Storybook:**
149
+
150
+ ```typescript
151
+ const stubHost: Partial<AdminMobileHostContext> = {
152
+ hardware: { nfc: false, rfid: false, qr: true, camera: true, keyboard: false },
153
+ actions: {
154
+ requestQrScan: async () => 'STUB_QR_CODE',
155
+ requestNfcTap: async () => ({ uid: 'STUB_UID' }),
156
+ requestCameraPhoto: async () => new Blob(),
157
+ share: async () => {},
158
+ clipboard: { read: async () => '', write: async () => {} },
159
+ },
160
+ ui: {
161
+ toast: (opts) => console.log('[stub toast]', opts.title),
162
+ haptic: () => {},
163
+ setHeaderTitle: (t) => { if (t) document.title = t },
164
+ navigateBack: () => history.back(),
165
+ },
166
+ network: { isOnline: () => true },
167
+ user: { isAdmin: true, displayName: 'Test User', email: 'test@example.com' },
168
+ SL: undefined as any, // replace with your test SL instance
169
+ }
170
+ ```
171
+
132
172
  ---
133
173
 
134
174
  ## Hardware Capabilities & the Capability Matrix
@@ -148,7 +188,6 @@ Containers declare which capabilities they need (or can use) in the manifest `ca
148
188
  | `"camera"` | Photo capture, gallery picker |
149
189
  | `"keyboard"` | Physical hardware trigger/action buttons |
150
190
  | `"geolocation"` | GPS coordinates |
151
- | `"offline-queue"` | Container needs local write queuing + sync |
152
191
  | `"push"` | Remote push notifications (FCM/APNs) |
153
192
 
154
193
  The full hardware capability matrix by host:
@@ -196,7 +235,7 @@ The custom Kotlin shell and both Capacitor shells ship the same baseline plugin
196
235
  | `@capgo/capacitor-nfc` | `"nfc"` |
197
236
  | `@capacitor/geolocation` | `"geolocation"` |
198
237
  | `@capacitor/push-notifications` | `"push"` |
199
- | `@capacitor/filesystem` | *(declare `"offline-queue"` if needed)* |
238
+ | `@capacitor/filesystem` | *(used when `offline: true`; bundle it in)* |
200
239
 
201
240
  ### Tier 3 — custom-android only (not Capacitor)
202
241
 
@@ -231,8 +270,8 @@ Declare the bundle under the top-level `mobileAdmin` key in `app.manifest.json`.
231
270
  {
232
271
  "name": "WarehousePickContainer",
233
272
  "description": "Pick orders by scanning NFC tags",
234
- "audience": "admin-mobile",
235
- "capabilities": ["nfc", "qr", "offline-queue"]
273
+ "capabilities": ["nfc", "qr"],
274
+ "offline": true
236
275
  }
237
276
  ]
238
277
  }
@@ -247,8 +286,29 @@ A `mobileAdmin` bundle can export multiple components targeting different operat
247
286
  |-------|------|-------------|
248
287
  | `name` | string | Exported component name (must match the UMD bundle export) |
249
288
  | `description` | string | Shown in the mobile launcher's app picker |
250
- | `audience` | `"admin-mobile"` | Required tells the launcher this is an operator surface |
251
- | `capabilities` | string[] | Capabilities this component needs or can use. See table above. |
289
+ | `capabilities` | string[] | Hardware capabilities this component needs or can use. See table above. |
290
+ | `offline` | boolean | Set to `true` if this component queues writes locally and needs offline sync support. |
291
+
292
+ ---
293
+
294
+ ## Launcher Discovery
295
+
296
+ The SmartLinks Mobile launcher loads your `mobileAdmin` bundle through the platform's app registry — you do not reference the manifest URL directly.
297
+
298
+ **How it works:**
299
+
300
+ 1. A collection admin enables the app for a collection in the SmartLinks admin console.
301
+ 2. On startup (and periodically), the launcher fetches `app.manifest.json` for every enabled app in that collection via the SmartLinks platform API. These requests are authenticated with the launcher's operator session.
302
+ 3. The launcher reads `mobileAdmin.components[]`, evaluates each component's `capabilities` against the current device's hardware flags, and shows matching components in the app picker.
303
+ 4. When the operator opens a component, the launcher resolves `mobileAdmin.files.js.umd` against the app's CDN base, fetches the bundle, and mounts the component with its `host` prop.
304
+
305
+ **File URL resolution:** `files.js.umd` / `files.js.esm` are paths relative to your app's CDN root (set when the app version is published). Provide relative paths — the launcher resolves them; do not hardcode absolute URLs.
306
+
307
+ **Auth:** Manifest and bundle fetches use the launcher's session token. Your container is already authenticated via `host.user` and `host.SL` — do not add additional auth headers.
308
+
309
+ **On load failure:** If the bundle returns a non-2xx response, the launcher shows a user-visible error and logs it — it does not crash the full launcher. If `app.manifest.json` itself cannot be fetched, the app is silently excluded from the picker for that session.
310
+
311
+ > To debug a missing or stale manifest, confirm in the SmartLinks admin console that the app version is published and that the `mobileAdmin` key is present in the uploaded manifest.
252
312
 
253
313
  ---
254
314
 
@@ -318,7 +378,7 @@ try {
318
378
  |-------|------|------------|
319
379
  | mount | User opens your container | Subscribe to events, start readers |
320
380
  | unmount | User backs out / app backgrounded > 30s | Unsubscribe; persist in-flight state |
321
- | `lifecycle: 'offline'` | Network lost | Switch to offline-queue mode |
381
+ | `lifecycle: 'offline'` | Network lost | Switch to offline mode; queue writes locally |
322
382
  | `lifecycle: 'online'` | Network restored | Flush queued writes |
323
383
  | `lifecycle: 'pause'` | App backgrounded | Pause readers to save battery |
324
384
  | `lifecycle: 'resume'` | App foregrounded | Resubscribe; refresh stale data |
@@ -459,9 +519,10 @@ export const MOBILE_ADMIN_MANIFEST = {
459
519
 
460
520
  - **Always check `host.hardware.X` before calling `host.actions.X`** — never assume a capability is available.
461
521
  - **Always wrap action calls in try/catch** — handle `HostPermissionDeniedError`, `HostTimeoutError`, and `HostCapabilityUnavailableError`.
462
- - **Use `host.ui.toast`, `host.ui.haptic`, `host.ui.setHeaderTitle`** instead of mounting your own UI chrome — these integrate with the launcher's native UI.
522
+ - **Use `host.ui.setHeaderTitle` and `host.ui.navigateBack`** for header integration — these are host-only and have no in-container equivalent.
523
+ - **`host.ui.toast` and `host.ui.haptic` are optional** — use them for native feedback, or fall back to your own `<Toaster />` when testing in isolation.
463
524
  - **Use `host.events.subscribe` for lifecycle events** — `'offline'`/`'online'`/`'pause'`/`'resume'` fire consistently on every host.
464
- - **Never call `initializeApi`** — `host.SL` is already configured.
525
+ - **Never call `initializeApi`, never use top-level SDK imports for API calls** — `host.SL` is already configured. `SL.method()` instead of `host.SL.method()` silently uses the wrong baseURL.
465
526
  - **Bundle Capacitor plugins in** (do not externalise) — so the component degrades gracefully on PWA/browser without crashing.
466
- - **Declare `offline-queue` in capabilities** if your container queues writes — this signals the launcher to provision local storage support.
467
- - **Use `host._version`** to feature-detect newer host context fields breaking changes bump the major version and the launcher gates incompatible combinations.
527
+ - **Declare `offline: true`** on a component if it queues writes locally — this signals the launcher to provision offline sync support.
528
+ - **Use `'methodName' in host.actions`** to feature-detect new host capabilities rather than comparing `host._version`. The version is informational only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,556 +0,0 @@
1
- # Scanner Container SDK
2
-
3
- > **Version:** 1.0 · **Platform:** SmartLinks R4 · **Last updated:** 2026-03-04
4
-
5
- This document describes how to build a **Scanner Container** — a SmartLinks microapp that replaces the default scanner UI inside the SmartLinks Scanner host application. Scanner containers receive a unified stream of hardware events (RFID, NFC, QR, key presses) and can implement any domain-specific logic on top of raw scan data.
6
-
7
- > **See also:** [containers.md](containers.md) covers the other container type — portal-embedded full-app containers that run inside web-based SmartLinks portals. Scanner containers are a distinct interface designed specifically for the Android scanner host; they share the UMD bundle format and the `containers` manifest section, but differ in props, build requirements, and runtime context.
8
-
9
- ---
10
-
11
- ## Table of Contents
12
-
13
- 1. [Overview](#overview)
14
- 2. [Architecture](#architecture)
15
- 3. [Manifest Declaration](#manifest-declaration)
16
- 4. [Container Props](#container-props)
17
- 5. [Hardware Event Stream](#hardware-event-stream)
18
- 6. [Event Types Reference](#event-types-reference)
19
- 7. [Subscribing to Events](#subscribing-to-events)
20
- 8. [Build & Bundle Requirements](#build--bundle-requirements)
21
- 9. [Shared Dependencies](#shared-dependencies)
22
- 10. [Lifecycle](#lifecycle)
23
- 11. [Example: Minimal Scanner Container](#example-minimal-scanner-container)
24
- 12. [Best Practices](#best-practices)
25
-
26
- ---
27
-
28
- ## Overview
29
-
30
- The SmartLinks Scanner is a host application that manages hardware readers (RFID, NFC, USB, QR camera) on Android devices. By default it displays scanned tags in a built-in list view with automatic SmartLinks tag resolution.
31
-
32
- A **Scanner Container** is a microapp that *replaces* this default UI entirely. When a user selects your scanner app, the host:
33
-
34
- 1. Hides its own tag list and lookup logic
35
- 2. Loads your UMD container bundle via `<script>` injection
36
- 3. Renders your exported React component
37
- 4. Forwards **all** hardware events to your component via a pub/sub subscription
38
-
39
- Your container has full control over how scans are displayed, resolved, and acted upon. The host continues to manage the hardware readers themselves (start/stop RFID, NFC session handling, etc.).
40
-
41
- ```
42
- ┌─────────────────────────────────────────────┐
43
- │ Scanner Host App │
44
- │ │
45
- │ ┌──────────┐ ┌────────────────────────┐ │
46
- │ │ Hardware │──▶│ Event Dispatcher │ │
47
- │ │ Bridge │ │ (android-bridge.ts) │ │
48
- │ └──────────┘ └───────────┬────────────┘ │
49
- │ │ │
50
- │ pub/sub │ │
51
- │ ▼ │
52
- │ ┌─────────────────────────┐ │
53
- │ │ Your Scanner │ │
54
- │ │ Container Component │ │
55
- │ │ (UMD bundle) │ │
56
- │ └─────────────────────────┘ │
57
- └─────────────────────────────────────────────┘
58
- ```
59
-
60
- ---
61
-
62
- ## Architecture
63
-
64
- ### Host Responsibilities
65
-
66
- The host application handles:
67
-
68
- - **Hardware management**: Starting/stopping RFID readers, NFC sessions, QR camera
69
- - **Key mapping**: Physical trigger buttons (key 293 = scan trigger, key 139 = clear)
70
- - **Collection context**: User selects a collection before scanning
71
- - **App discovery**: Fetching widget/container manifests and presenting app selection UI
72
- - **Bundle loading**: Injecting UMD scripts, resolving exports, managing CSS cleanup
73
- - **Event forwarding**: Converting raw bridge messages into typed `ScannerEvent` objects
74
-
75
- ### Container Responsibilities
76
-
77
- Your container handles:
78
-
79
- - **Scan processing**: Deciding what to do with each event (resolve tags, build UI, etc.)
80
- - **Data resolution**: Calling SmartLinks APIs to look up tag/product/proof data
81
- - **Business logic**: Domain-specific workflows (e.g., cask tracking, inventory, quality control)
82
- - **UI rendering**: Complete control over the scan interface
83
-
84
- ### What the Host Does NOT Do When Your App Is Active
85
-
86
- When a scanner container is selected, the host **bypasses all local processing**:
87
-
88
- - No local tag list tracking
89
- - No automatic SmartLinks tag resolution
90
- - No deduplication or lookup debouncing
91
- - Raw events are forwarded directly to your subscriber
92
-
93
- This ensures zero redundant work and gives your container full ownership of the data flow.
94
-
95
- ---
96
-
97
- ## Manifest Declaration
98
-
99
- To be discovered as a scanner container, your app's `app.manifest.json` must declare a container component with `"uiRole": "scanner"`:
100
-
101
- ```json
102
- {
103
- "meta": {
104
- "name": "My Scanner App",
105
- "appId": "my-scanner-app",
106
- "version": "1.0.0"
107
- },
108
- "containers": {
109
- "files": {
110
- "js": { "umd": "containers.umd.js", "esm": "containers.es.js" },
111
- "css": null
112
- },
113
- "components": [
114
- {
115
- "name": "ScannerContainer",
116
- "description": "Custom scanner interface for cask tracking",
117
- "uiRole": "scanner",
118
- "scope": "collection",
119
- "audience": "admin",
120
- "settings": {
121
- "type": "object",
122
- "properties": {
123
- "autoResolve": {
124
- "type": "boolean",
125
- "title": "Auto-resolve tags",
126
- "description": "Automatically look up tag metadata on scan",
127
- "default": true
128
- }
129
- }
130
- }
131
- }
132
- ]
133
- }
134
- }
135
- ```
136
-
137
- ### Key Fields
138
-
139
- | Field | Required | Description |
140
- | ------------- | -------- | ----------------------------------------------------------------------- |
141
- | `name` | ✅ | Export name in the UMD bundle (`window.SmartLinksContainers[name]`) |
142
- | `uiRole` | ✅ | Must be `"scanner"` for the host to recognize it |
143
- | `description` | ✅ | Shown in the scanner app picker UI |
144
- | `scope` | Optional | `"collection"` or `"product"` — the data scope |
145
- | `audience` | Optional | `"admin"`, `"public"`, or `"both"` |
146
- | `settings` | Optional | JSON Schema describing configurable props (see Widget Settings Schema) |
147
-
148
- ---
149
-
150
- ## Container Props
151
-
152
- The host renders your container with these props:
153
-
154
- ```typescript
155
- interface ScannerContainerProps {
156
- /** The active collection ID */
157
- collectionId: string;
158
-
159
- /** Your app's unique identifier */
160
- appId: string;
161
-
162
- /** Whether the current user is an admin */
163
- isAdmin: boolean;
164
-
165
- /**
166
- * Subscribe to all hardware events.
167
- * Call once in useEffect; returns an unsubscribe function.
168
- */
169
- onSubscribeScannerEvents: (
170
- callback: (event: ScannerEvent) => void
171
- ) => (() => void);
172
-
173
- /**
174
- * @deprecated Use onSubscribeScannerEvents instead.
175
- * Provided for backward compatibility — identical behavior.
176
- */
177
- onSubscribeScanEvents?: (
178
- callback: (event: ScannerEvent) => void
179
- ) => (() => void);
180
- }
181
- ```
182
-
183
- > **Note — no `SL` prop:** Unlike portal containers (see [containers.md](containers.md)), scanner containers do not receive an `SL` prop from the host. Instead, the SmartLinks SDK is externalized to `window.SL` and imported directly in your bundle (`import * as SL from '@proveanything/smartlinks'`). See [Shared Dependencies](#shared-dependencies) for the full externals table.
184
-
185
- ---
186
-
187
- ## Hardware Event Stream
188
-
189
- All hardware inputs are normalized into a single **discriminated union** type called `ScannerEvent`. The `type` field tells you the source:
190
-
191
- ```typescript
192
- type ScannerEvent =
193
- | { type: 'rfid'; uid: string; timestamp: number; rssi?: number }
194
- | { type: 'nfc'; uid: string; timestamp: number; ndef?: string }
195
- | { type: 'qr'; data: string; timestamp: number }
196
- | { type: 'key'; keyCode: number; action: 'down' | 'up'; timestamp: number };
197
- ```
198
-
199
- ### Why a Unified Stream?
200
-
201
- Rather than providing four separate subscription channels, a single stream:
202
-
203
- - Simplifies the container API surface (one `useEffect`, one cleanup)
204
- - Allows containers to correlate cross-device events (e.g., "trigger key held while RFID scans arrive")
205
- - Makes it trivial to log or replay all hardware activity
206
- - Avoids race conditions from multiple independent subscriptions
207
-
208
- ---
209
-
210
- ## Event Types Reference
211
-
212
- ### `rfid` — RFID Tag Scan
213
-
214
- Emitted when the Chainway UHF RFID reader detects a tag.
215
-
216
- | Field | Type | Description |
217
- | ----------- | -------- | ----------------------------------------------- |
218
- | `type` | `'rfid'` | Discriminant |
219
- | `uid` | `string` | The EPC (Electronic Product Code) hex string |
220
- | `timestamp` | `number` | `Date.now()` when the event was created |
221
- | `rssi` | `number` | Signal strength (optional, reader-dependent) |
222
-
223
- **Notes:**
224
- - RFID readers emit continuously while active — expect many events per second
225
- - The same EPC will appear repeatedly; your container should handle deduplication
226
- - The host starts/stops the RFID reader via hardware key 293 (trigger button)
227
-
228
- ### `nfc` — NFC / USB Tag Scan
229
-
230
- Emitted when the device's built-in NFC reader or an external USB NFC reader scans a tag.
231
-
232
- | Field | Type | Description |
233
- | ----------- | -------- | --------------------------------------------------- |
234
- | `type` | `'nfc'` | Discriminant |
235
- | `uid` | `string` | The tag's unique ID (hex string, e.g., `04A3B2...`) |
236
- | `timestamp` | `number` | `Date.now()` when the event was created |
237
- | `ndef` | `string` | NDEF record content (URL or text), empty if none |
238
-
239
- **Notes:**
240
- - NFC scans are one-shot (tap to scan)
241
- - Both native NFC and USB reader scans are normalized to this type
242
- - The `ndef` field often contains a SmartLinks URL that can be parsed for context
243
-
244
- ### `qr` — QR Code Scan
245
-
246
- Emitted when the native QR code scanner successfully reads a code.
247
-
248
- | Field | Type | Description |
249
- | ----------- | ------- | ------------------------------------------ |
250
- | `type` | `'qr'` | Discriminant |
251
- | `data` | `string`| The decoded QR code content (URL or text) |
252
- | `timestamp` | `number`| `Date.now()` when the event was created |
253
-
254
- **Notes:**
255
- - Only successful scans are forwarded (cancelled/error scans are filtered)
256
- - QR scans typically contain SmartLinks URLs or serial numbers
257
-
258
- ### `key` — Hardware Key Press
259
-
260
- Emitted when a physical button is pressed or released on the device.
261
-
262
- | Field | Type | Description |
263
- | ----------- | ----------------- | ------------------------------------------- |
264
- | `type` | `'key'` | Discriminant |
265
- | `keyCode` | `number` | Android key code constant |
266
- | `action` | `'down' \| 'up'` | Press or release |
267
- | `timestamp` | `number` | `Date.now()` when the event was created |
268
-
269
- **Common Key Codes:**
270
-
271
- | Code | Button | Default Host Behavior |
272
- | ----- | --------------- | -------------------------------------------- |
273
- | `293` | Scan trigger | Start RFID on DOWN, stop on UP |
274
- | `139` | Function/Clear | Clear tag list on DOWN |
275
-
276
- **Notes:**
277
- - Key events are forwarded to your container **and** processed by the host simultaneously
278
- - The host will still start/stop RFID reading on key 293 — your container receives the resulting RFID events
279
- - You can use key events for custom actions (e.g., confirm selection, switch modes)
280
-
281
- ---
282
-
283
- ## Subscribing to Events
284
-
285
- Use the `onSubscribeScannerEvents` prop in a `useEffect`:
286
-
287
- ```tsx
288
- import { useEffect } from 'react';
289
-
290
- export function ScannerContainer({ onSubscribeScannerEvents, collectionId, appId }) {
291
- const [scans, setScans] = useState([]);
292
-
293
- useEffect(() => {
294
- const unsubscribe = onSubscribeScannerEvents((event) => {
295
- switch (event.type) {
296
- case 'rfid':
297
- console.log('RFID:', event.uid, 'RSSI:', event.rssi);
298
- // Deduplicate + resolve against SmartLinks
299
- break;
300
- case 'nfc':
301
- console.log('NFC:', event.uid, 'NDEF:', event.ndef);
302
- break;
303
- case 'qr':
304
- console.log('QR:', event.data);
305
- break;
306
- case 'key':
307
- console.log('Key:', event.keyCode, event.action);
308
- break;
309
- }
310
- });
311
-
312
- return unsubscribe; // Clean up on unmount
313
- }, [onSubscribeScannerEvents]);
314
-
315
- return <div>...</div>;
316
- }
317
- ```
318
-
319
- ### Important Patterns
320
-
321
- 1. **Subscribe once** — The subscriber function is stable (wrapped in `useCallback` by the host). Subscribe in a `useEffect` with `[onSubscribeScannerEvents]` as the dependency.
322
-
323
- 2. **Use refs for mutable state** — If your event handler needs access to current state, use refs to avoid stale closures:
324
-
325
- ```tsx
326
- const scansRef = useRef(new Map());
327
-
328
- useEffect(() => {
329
- const unsubscribe = onSubscribeScannerEvents((event) => {
330
- if (event.type === 'rfid') {
331
- scansRef.current.set(event.uid, event);
332
- // Trigger re-render via setState
333
- }
334
- });
335
- return unsubscribe;
336
- }, [onSubscribeScannerEvents]);
337
- ```
338
-
339
- 3. **Batch state updates** — RFID events can arrive at high frequency. Consider debouncing UI updates while accumulating events.
340
-
341
- ---
342
-
343
- ## Build & Bundle Requirements
344
-
345
- Scanner containers must be built as **UMD bundles** that register exports on `window.SmartLinksContainers`:
346
-
347
- ```typescript
348
- // vite.config.container.ts
349
- export default defineConfig({
350
- build: {
351
- lib: {
352
- entry: 'src/containers/index.ts',
353
- name: 'SmartLinksContainers', // ← window global name
354
- formats: ['umd'],
355
- fileName: 'containers',
356
- },
357
- rollupOptions: {
358
- external: [/* shared dependencies — see below */],
359
- output: {
360
- globals: {/* dependency → window global mapping */},
361
- },
362
- },
363
- },
364
- });
365
- ```
366
-
367
- ### Export Structure
368
-
369
- ```typescript
370
- // src/containers/index.ts
371
- export { ScannerContainer } from './ScannerContainer';
372
- ```
373
-
374
- The host resolves your component by the `name` field in the manifest:
375
-
376
- ```javascript
377
- const Component = window.SmartLinksContainers['ScannerContainer'];
378
- ```
379
-
380
- ---
381
-
382
- ## Shared Dependencies
383
-
384
- The host exposes these libraries on `window` — your container build **must externalize them** (do not bundle):
385
-
386
- | Import | Window Global |
387
- | ----------------------------- | ----------------------- |
388
- | `react` | `window.React` |
389
- | `react-dom` | `window.ReactDOM` |
390
- | `react/jsx-runtime` | `window.jsxRuntime` |
391
- | `@proveanything/smartlinks` | `window.SL` |
392
- | `class-variance-authority` | `window.CVA` |
393
- | `react-router-dom` | `window.ReactRouterDOM` |
394
- | `@tanstack/react-query` | `window.ReactQuery` |
395
- | `lucide-react` | `window.LucideReact` |
396
- | `date-fns` | `window.dateFns` |
397
- | `@radix-ui/react-*` | `window.Radix*` |
398
-
399
- See the [SmartLinks Microapp Development Guide](../README.md) for the full shared dependencies table with exact global names.
400
-
401
- Any dependency **not** in this list must be bundled into your container.
402
-
403
- ---
404
-
405
- ## Lifecycle
406
-
407
- ```
408
- 1. User selects collection
409
- 2. Host fetches widget/container manifests via SmartLinks API
410
- 3. Host identifies containers with uiRole === 'scanner'
411
- 4. User picks your scanner app from the list
412
- → Selection is persisted in localStorage per collection
413
- 5. Host loads your UMD bundle via <script> tag
414
- 6. Host resolves your component from window.SmartLinksContainers
415
- 7. Host renders <YourComponent ...props />
416
- 8. Your component subscribes to onSubscribeScannerEvents
417
- 9. User presses hardware trigger → RFID/NFC/QR events flow to your callback
418
- 10. User switches away or deselects → component unmounts, script/CSS removed
419
- ```
420
-
421
- ### Cleanup
422
-
423
- The host handles all cleanup when your container is deselected or the collection changes:
424
-
425
- - Script tag removal
426
- - Injected CSS/style removal
427
- - React component unmounting
428
- - Event listener cleanup (via your returned unsubscribe function)
429
-
430
- ---
431
-
432
- ## Example: Minimal Scanner Container
433
-
434
- ```tsx
435
- import React, { useState, useEffect, useRef, useCallback } from 'react';
436
- import * as SL from '@proveanything/smartlinks';
437
-
438
- // Types — import from your own types file or declare inline
439
- type ScannerEvent =
440
- | { type: 'rfid'; uid: string; timestamp: number; rssi?: number }
441
- | { type: 'nfc'; uid: string; timestamp: number; ndef?: string }
442
- | { type: 'qr'; data: string; timestamp: number }
443
- | { type: 'key'; keyCode: number; action: 'down' | 'up'; timestamp: number };
444
-
445
- interface Props {
446
- collectionId: string;
447
- appId: string;
448
- isAdmin: boolean;
449
- onSubscribeScannerEvents: (cb: (event: ScannerEvent) => void) => () => void;
450
- }
451
-
452
- interface ScannedTag {
453
- uid: string;
454
- source: 'rfid' | 'nfc' | 'qr';
455
- firstSeen: number;
456
- count: number;
457
- productName?: string;
458
- }
459
-
460
- export function ScannerContainer({ collectionId, appId, isAdmin, onSubscribeScannerEvents }: Props) {
461
- const [tags, setTags] = useState<Map<string, ScannedTag>>(new Map());
462
- const tagsRef = useRef(tags);
463
- tagsRef.current = tags;
464
-
465
- const resolveTag = useCallback(async (cId: string, uid: string) => {
466
- try {
467
- const result = await SL.tags.publicGetByCollection(cId, uid, 'product');
468
- const product = result.embedded?.products?.[result.tag?.productId!];
469
- setTags(prev => {
470
- const next = new Map(prev);
471
- const tag = next.get(uid);
472
- if (tag) {
473
- next.set(uid, { ...tag, productName: product?.name ?? 'Unknown' });
474
- }
475
- return next;
476
- });
477
- } catch (err) {
478
- console.error('Tag resolution failed:', uid, err);
479
- }
480
- }, []);
481
-
482
- useEffect(() => {
483
- const unsubscribe = onSubscribeScannerEvents((event) => {
484
- if (event.type === 'rfid' || event.type === 'nfc') {
485
- const uid = event.uid;
486
- setTags(prev => {
487
- const next = new Map(prev);
488
- const existing = next.get(uid);
489
- if (existing) {
490
- next.set(uid, { ...existing, count: existing.count + 1 });
491
- } else {
492
- next.set(uid, {
493
- uid,
494
- source: event.type,
495
- firstSeen: event.timestamp,
496
- count: 1,
497
- });
498
- // Resolve tag in background
499
- resolveTag(collectionId, uid);
500
- }
501
- return next;
502
- });
503
- } else if (event.type === 'qr') {
504
- console.log('QR scanned:', event.data);
505
- }
506
- });
507
-
508
- return unsubscribe;
509
- }, [onSubscribeScannerEvents, collectionId, resolveTag]);
510
-
511
- return (
512
- <div style={{ padding: 16 }}>
513
- <h2>Scanned Tags ({tags.size})</h2>
514
- {Array.from(tags.values()).map(tag => (
515
- <div key={tag.uid} style={{ padding: 8, borderBottom: '1px solid #eee' }}>
516
- <strong>{tag.uid}</strong> × {tag.count}
517
- {tag.productName && <span> — {tag.productName}</span>}
518
- <span style={{ opacity: 0.5, marginLeft: 8 }}>{tag.source.toUpperCase()}</span>
519
- </div>
520
- ))}
521
- {tags.size === 0 && <p style={{ opacity: 0.5 }}>Waiting for scans...</p>}
522
- </div>
523
- );
524
- }
525
- ```
526
-
527
- ---
528
-
529
- ## Best Practices
530
-
531
- ### Performance
532
-
533
- - **Debounce UI updates** for RFID — you may receive 50+ events/second during active scanning
534
- - **Use refs** for state accessed inside the event callback to avoid stale closures
535
- - **Batch API calls** — use `SL.tags.lookupTags()` for batch resolution rather than one call per tag
536
-
537
- ### Data Resolution
538
-
539
- - **Prefer collection-scoped lookups** — since you receive `collectionId` as a prop, always use `SL.tags.publicGetByCollection(collectionId, tagId)` for fast, direct resolution
540
- - **Use `SL.tags.resolveTag` only as fallback** — for the rare case where a tag might belong to a different collection
541
-
542
- ### UX
543
-
544
- - **Show scan feedback immediately** — display the raw UID before resolution completes
545
- - **Handle key events thoughtfully** — the host already manages RFID start/stop via key 293; use key events for your own UI actions (confirm, navigate, toggle modes)
546
- - **Support high-density scanning** — RFID use cases often involve scanning 50–100+ tags in a session
547
-
548
- ### Error Handling
549
-
550
- - **Gracefully handle missing tags** — not every scanned EPC/UID will resolve to a SmartLinks tag
551
- - **Network resilience** — tag resolution may fail; show cached/partial data and retry
552
-
553
- ### Bundle Size
554
-
555
- - **Externalize shared dependencies** — never bundle React, SmartLinks SDK, or other shared libs
556
- - **Keep containers focused** — a scanner container should do one thing well