@variantlab/core 0.1.4 → 0.1.6

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.
@@ -0,0 +1,372 @@
1
+ # Deep link + QR code state sharing
2
+
3
+ Share exact variant state between devices via deep links or QR codes. This is the killer collaboration feature for QA and design.
4
+
5
+ ## Table of contents
6
+
7
+ - [The use case](#the-use-case)
8
+ - [The payload format](#the-payload-format)
9
+ - [Generating a share](#generating-a-share)
10
+ - [Consuming a share](#consuming-a-share)
11
+ - [QR codes](#qr-codes)
12
+ - [Security](#security)
13
+ - [Override rules](#override-rules)
14
+ - [Framework integration](#framework-integration)
15
+
16
+ ---
17
+
18
+ ## The use case
19
+
20
+ A designer sitting next to a developer says:
21
+
22
+ > "Try the 2-column layout with the small hero image on the profile page."
23
+
24
+ With traditional A/B tools, the developer opens a dashboard, finds the experiment, changes the variant, and reloads. 30 seconds of friction times 50 tests a day.
25
+
26
+ With variantlab:
27
+
28
+ > "Scan this QR code."
29
+
30
+ The designer holds up their phone with the desired state. The developer scans. The app reloads with the exact variant state in 2 seconds.
31
+
32
+ ---
33
+
34
+ ## The payload format
35
+
36
+ A share payload is a URL-safe base64-encoded JSON object:
37
+
38
+ ```json
39
+ {
40
+ "v": 1,
41
+ "overrides": {
42
+ "news-card-layout": "pip-thumbnail",
43
+ "cta-copy": "try-free",
44
+ "theme": "dark"
45
+ },
46
+ "context": {
47
+ "userId": "qa-tester",
48
+ "attributes": { "betaOptIn": true }
49
+ },
50
+ "expires": 1741040000000
51
+ }
52
+ ```
53
+
54
+ Fields:
55
+
56
+ - `v` — payload version (currently `1`)
57
+ - `overrides` — map of experiment ID → variant ID
58
+ - `context` — (optional) context overrides
59
+ - `expires` — (optional) Unix ms timestamp after which the payload is rejected
60
+
61
+ ### Encoding
62
+
63
+ The payload is:
64
+
65
+ 1. Serialized to compact JSON
66
+ 2. Gzip-compressed (via `CompressionStream` on web / polyfill on RN)
67
+ 3. Base64url-encoded (no `=` padding, `+` → `-`, `/` → `_`)
68
+
69
+ Typical payloads compress to 40-200 bytes. Max supported: 1 KB (rejected otherwise).
70
+
71
+ ---
72
+
73
+ ## Generating a share
74
+
75
+ ### From the debug overlay
76
+
77
+ Tap "Share state" in the overlay. A modal appears with:
78
+
79
+ - A QR code for the current state
80
+ - A copyable deep link
81
+ - An expiration picker (5 min, 1 hour, 1 day, never)
82
+ - A lock toggle (prevents further changes after import)
83
+
84
+ ### Programmatically
85
+
86
+ ```ts
87
+ import { createShareLink } from "@variantlab/react";
88
+
89
+ const link = createShareLink({
90
+ overrides: {
91
+ "news-card-layout": "pip-thumbnail",
92
+ "cta-copy": "try-free",
93
+ },
94
+ expires: Date.now() + 60 * 60 * 1000, // 1 hour
95
+ scheme: "drishtikon", // app URL scheme
96
+ });
97
+
98
+ // link = "drishtikon://variantlab?p=eJyrVipILUssys..."
99
+ ```
100
+
101
+ ### Partial shares
102
+
103
+ You don't have to include every experiment. Only the ones in `overrides` are affected; others remain on their current assignment.
104
+
105
+ ```ts
106
+ createShareLink({
107
+ overrides: { "card-layout": "pip-thumbnail" },
108
+ });
109
+ // Only changes one experiment
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Consuming a share
115
+
116
+ ### Deep links
117
+
118
+ The app registers a URL scheme handler. When a link like `drishtikon://variantlab?p=...` is opened:
119
+
120
+ 1. The adapter intercepts it via `Linking.addEventListener("url")` (RN) or middleware (Next.js)
121
+ 2. Parses the `p` query parameter
122
+ 3. Decodes, validates, and applies the payload
123
+ 4. Shows a confirmation toast
124
+
125
+ ```tsx
126
+ import { registerDeepLinkHandler } from "@variantlab/react-native";
127
+
128
+ useEffect(() => {
129
+ const unregister = registerDeepLinkHandler({
130
+ scheme: "drishtikon",
131
+ onApply: (payload) => {
132
+ console.log("Applied variant overrides:", payload.overrides);
133
+ },
134
+ });
135
+ return unregister;
136
+ }, []);
137
+ ```
138
+
139
+ ### Manual apply
140
+
141
+ ```ts
142
+ const engine = useVariantLabEngine();
143
+ const payload = decodeShareLink(link);
144
+ engine.applyOverrides(payload.overrides);
145
+ ```
146
+
147
+ ### Confirmation before apply
148
+
149
+ For safety, especially in production, show a confirmation dialog:
150
+
151
+ ```tsx
152
+ registerDeepLinkHandler({
153
+ scheme: "drishtikon",
154
+ confirmBeforeApply: true,
155
+ });
156
+ ```
157
+
158
+ The adapter shows a modal with the experiments being changed. The user taps "Apply" or "Cancel".
159
+
160
+ ---
161
+
162
+ ## QR codes
163
+
164
+ ### Why QR
165
+
166
+ Phone-to-phone sharing is often harder than phone-to-phone deep linking:
167
+
168
+ - Airdrop is iOS-only
169
+ - Messaging apps strip URLs
170
+ - Pasting into the other device takes time
171
+
172
+ QR codes are instant, universal, and work across platforms.
173
+
174
+ ### Generating QR codes
175
+
176
+ variantlab does **not** bundle a QR code library. The overlay uses a minimal hand-rolled QR encoder (~2 KB) that produces a data URL:
177
+
178
+ ```ts
179
+ import { createQRCode } from "@variantlab/react-native/qr";
180
+
181
+ const qr = createQRCode(shareLink, { size: 256, errorCorrection: "M" });
182
+ // qr = "data:image/svg+xml;base64,..."
183
+ ```
184
+
185
+ Errors correction levels: L (low), M (medium), Q (quartile), H (high). Default: M.
186
+
187
+ ### Reading QR codes
188
+
189
+ Each adapter provides a helper to read QRs using the native camera:
190
+
191
+ ```tsx
192
+ import { VariantQRScanner } from "@variantlab/react-native";
193
+
194
+ <VariantQRScanner
195
+ onScan={(payload) => {
196
+ engine.applyOverrides(payload.overrides);
197
+ }}
198
+ onError={(error) => console.warn(error)}
199
+ />
200
+ ```
201
+
202
+ The scanner uses `expo-barcode-scanner` on Expo and `react-native-vision-camera` elsewhere. These are **peer dependencies** — variantlab does not bundle them.
203
+
204
+ ---
205
+
206
+ ## Security
207
+
208
+ Share payloads are user-generated content. We assume they may be malicious.
209
+
210
+ ### Validation
211
+
212
+ Every payload is validated before applying:
213
+
214
+ - Must have `v: 1`
215
+ - `overrides` must be an object of string keys to string values
216
+ - `overrides` max 100 entries
217
+ - Each key must be a valid experiment ID (regex match)
218
+ - Each value must be a valid variant ID (regex match)
219
+ - `context` fields must pass the context schema
220
+ - Total payload size < 1 KB decoded
221
+ - No prototype pollution keys (`__proto__`, `constructor`, etc.)
222
+
223
+ ### Optional HMAC signing
224
+
225
+ Shares can be signed with HMAC-SHA256 using a pre-shared key:
226
+
227
+ ```ts
228
+ const link = createShareLink({
229
+ overrides: { ... },
230
+ signWith: process.env.SHARE_KEY,
231
+ });
232
+ ```
233
+
234
+ When the adapter imports a signed payload, it verifies the signature before applying:
235
+
236
+ ```ts
237
+ registerDeepLinkHandler({
238
+ scheme: "drishtikon",
239
+ requireSignature: true,
240
+ hmacKey: process.env.SHARE_KEY,
241
+ });
242
+ ```
243
+
244
+ Unsigned payloads are rejected in strict mode.
245
+
246
+ ### Overridable flag
247
+
248
+ Experiments with `overridable: false` cannot be overridden via shares. This is the default for all experiments — you must explicitly opt-in:
249
+
250
+ ```json
251
+ {
252
+ "id": "ai-assistant-beta",
253
+ "overridable": true,
254
+ "variants": [...]
255
+ }
256
+ ```
257
+
258
+ Non-overridable experiments are silently ignored in the share payload. This lets you mark experiments as "safe for QA" without worrying about other experiments being abused.
259
+
260
+ ### Expiration
261
+
262
+ Payloads with an `expires` timestamp in the past are rejected. The overlay defaults to 1-hour expiration.
263
+
264
+ ### Rate limiting
265
+
266
+ The adapter debounces deep link imports to at most 1 per second to prevent override flooding from malicious deep links.
267
+
268
+ ### Production safety
269
+
270
+ In production:
271
+
272
+ - Deep link handler is off by default — must be explicitly enabled
273
+ - Requires `requireSignature: true`
274
+ - Shows a confirmation dialog before applying
275
+ - Logs every applied override (without PII)
276
+
277
+ ---
278
+
279
+ ## Override rules
280
+
281
+ Once a share is applied, overrides take precedence over:
282
+
283
+ - Targeting (even if targeting fails)
284
+ - Assignment (even if weighted)
285
+ - Default (even on new users)
286
+
287
+ But **not** over:
288
+
289
+ - Kill switch (`enabled: false` at the config level)
290
+ - Archived experiments (`status: "archived"`)
291
+ - `overridable: false`
292
+
293
+ Overrides persist in Storage so they survive app restart. The user can clear them:
294
+
295
+ - Via the debug overlay → "Reset all"
296
+ - Programmatically: `engine.resetAll()`
297
+ - By scanning a new share with empty overrides
298
+
299
+ ---
300
+
301
+ ## Framework integration
302
+
303
+ ### React Native (Expo)
304
+
305
+ ```tsx
306
+ import { Linking } from "react-native";
307
+ import { registerDeepLinkHandler } from "@variantlab/react-native";
308
+
309
+ // Register once at app startup
310
+ registerDeepLinkHandler({ scheme: "drishtikon" });
311
+
312
+ // Expo requires the scheme in app.json:
313
+ // "expo": { "scheme": "drishtikon" }
314
+ ```
315
+
316
+ ### Next.js
317
+
318
+ ```tsx
319
+ // app/variantlab/apply/page.tsx
320
+ import { applyShareLinkFromURL } from "@variantlab/next";
321
+
322
+ export default function Apply({ searchParams }) {
323
+ applyShareLinkFromURL(searchParams.p);
324
+ return <Redirect to="/" />;
325
+ }
326
+ ```
327
+
328
+ Next.js doesn't have a native deep link concept, so we use a dedicated route that applies the override and redirects.
329
+
330
+ ### Vue / Nuxt
331
+
332
+ ```ts
333
+ // app.vue
334
+ import { registerDeepLinkHandler } from "@variantlab/vue";
335
+
336
+ onMounted(() => {
337
+ registerDeepLinkHandler({ param: "variantlab" });
338
+ });
339
+ ```
340
+
341
+ ### Svelte / SvelteKit
342
+
343
+ ```svelte
344
+ <!-- +layout.svelte -->
345
+ <script>
346
+ import { registerDeepLinkHandler } from "@variantlab/svelte";
347
+ registerDeepLinkHandler({ param: "variantlab" });
348
+ </script>
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Advanced: collaborative sessions
354
+
355
+ A future feature: live-sync sessions where a designer's overlay state mirrors on a developer's phone in real time.
356
+
357
+ Implementation sketch (not in v0.1):
358
+
359
+ - QR encodes a session ID instead of a payload
360
+ - App connects to a user-provided WebSocket server (no hosted version)
361
+ - Overlay broadcasts state changes to the session
362
+ - Other clients in the session apply the changes live
363
+
364
+ This is intentionally deferred to post-1.0 — it adds complexity and requires either a hosted service (against principle 7) or user-hosted infrastructure.
365
+
366
+ ---
367
+
368
+ ## See also
369
+
370
+ - [`debug-overlay.md`](./debug-overlay.md) — where the share UI lives
371
+ - [`hmac-signing.md`](./hmac-signing.md) — signing payloads
372
+ - [`API.md`](../../API.md) — `createShareLink`, `registerDeepLinkHandler`