@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.
- package/README.md +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
|
@@ -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`
|