@picked/svelte 0.0.1-dev.1793758300441 → 0.0.1-dev.1793769511104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/plugins/devtools/DevToolsExperience.svelte +142 -30
  3. package/dist/plugins/devtools/customTools.d.ts +24 -5
  4. package/dist/plugins/devtools/customTools.js +1 -1
  5. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
  6. package/dist/plugins/devtools/derive/index.d.ts +4 -0
  7. package/dist/plugins/devtools/derive/index.js +1 -1
  8. package/dist/plugins/devtools/derive/persona-builder/index.d.ts +3 -0
  9. package/dist/plugins/devtools/derive/persona-builder/index.js +1 -0
  10. package/dist/plugins/devtools/derive/persona-builder/persona-builder.d.ts +177 -0
  11. package/dist/plugins/devtools/derive/persona-builder/persona-builder.js +1 -0
  12. package/dist/plugins/devtools/derive/personas/index.d.ts +3 -0
  13. package/dist/plugins/devtools/derive/personas/index.js +1 -0
  14. package/dist/plugins/devtools/derive/personas/personas.d.ts +197 -0
  15. package/dist/plugins/devtools/derive/personas/personas.js +1 -0
  16. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +2 -0
  17. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
  18. package/dist/plugins/devtools/index.d.ts +2 -1
  19. package/dist/plugins/devtools/options.d.ts +33 -1
  20. package/dist/plugins/devtools/options.js +1 -1
  21. package/dist/plugins/devtools/plugin.d.ts +2 -1
  22. package/dist/plugins/devtools/review/index.d.ts +5 -1
  23. package/dist/plugins/devtools/review/index.js +1 -1
  24. package/dist/plugins/devtools/review/personas/ChoiceButton.svelte +50 -0
  25. package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +450 -0
  26. package/dist/plugins/devtools/review/personas/PersonaCard.svelte +86 -0
  27. package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +546 -0
  28. package/dist/plugins/devtools/review/personas/load-persona.d.ts +226 -0
  29. package/dist/plugins/devtools/review/personas/load-persona.js +1 -0
  30. package/dist/plugins/devtools/session/session.js +1 -1
  31. package/dist/plugins/devtools/sidebar/SidebarFooter.svelte +114 -0
  32. package/dist/plugins/devtools/sidebar/ToolIcon.svelte +32 -0
  33. package/dist/plugins/devtools/sidebar/nav.d.ts +19 -9
  34. package/dist/plugins/devtools/sidebar/nav.js +1 -1
  35. package/dist/plugins/devtools/ui/forms/TextField.svelte +33 -6
  36. package/package.json +2 -2
@@ -0,0 +1,546 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file The Personas & Scenarios tab: customers and situations to try the journey as. The site's
4
+ * own come from the plugin's `personas` option. A reviewer makes theirs in this browser, either by
5
+ * answering the journey's questions here, screen by screen, or from where they are on the page.
6
+ * A persona keeps the answers a customer gives on fields they can see, the screens moved on from,
7
+ * the screen it stops on and the page it starts on. Loading one reloads that page clean and walks
8
+ * the journey with its answers, screen by screen, up to that screen.
9
+ */
10
+ import type { FieldValue, Picked } from '@picked/client'
11
+ import { getStepFields } from '@picked/client/form'
12
+ import { Banner, Button, Card, EmptyState, RadioGroup, Stack, Text, TextField } from '../../ui'
13
+ import {
14
+ buildPersona,
15
+ customerAnswers,
16
+ deslug,
17
+ entryUrlOf,
18
+ isBlank,
19
+ passedScreens,
20
+ readSavedPersonas,
21
+ writeSavedPersonas,
22
+ type Persona,
23
+ type BuilderWalk,
24
+ type PersonaCapture,
25
+ type PersonaLanding,
26
+ type PersonaStorageGetter,
27
+ type RouteEntry,
28
+ type SafetyPosture,
29
+ } from '../../derive'
30
+ import type { JourneyView } from '../../session'
31
+ import { loadUrl, startPersonaLoad } from './load-persona'
32
+ import PersonaBuilder from './PersonaBuilder.svelte'
33
+ import PersonaCard from './PersonaCard.svelte'
34
+
35
+ interface PersonasPanelProps {
36
+ client: Picked
37
+ posture: SafetyPosture
38
+ /** The journey in front, which a new persona lands on. */
39
+ journey: JourneyView | null
40
+ /** The site's own personas. */
41
+ personas: readonly Persona[]
42
+ /** Every field a journey's screens ask, minus the code-owned ones. */
43
+ asked: ReadonlySet<string>
44
+ /** Every screen moved on from this session, in order. */
45
+ route: readonly RouteEntry[]
46
+ /** Experience keys the devtools never follow. */
47
+ hiddenKeys: ReadonlySet<string>
48
+ /** Keys a screen carries only for its code. */
49
+ codeOwned: ReadonlySet<string>
50
+ /** Screens that move on by themselves, and actions that open another journey. */
51
+ personaWalk: {
52
+ selfAdvancingSteps: ReadonlySet<string>
53
+ handoffs: Readonly<Record<string, string>>
54
+ }
55
+ /** How the last load's walk ended; `null` when no load ran on this page. */
56
+ walkNotice: Notice | null
57
+ onWalkNoticeDismiss: () => void
58
+ /** The storage saved personas live in. */
59
+ getStorage?: PersonaStorageGetter
60
+ /** The tab's storage, which carries a load across its reload. */
61
+ getSessionStorage?: () => Pick<Storage, 'setItem'>
62
+ /**
63
+ * Goes to a URL; a load reloads the page through it.
64
+ *
65
+ * @param url - The URL.
66
+ */
67
+ navigate?: (url: string) => void
68
+ }
69
+
70
+ /** A banner's tone and words. */
71
+ interface Notice {
72
+ status: 'info' | 'success' | 'warning' | 'error'
73
+ text: string
74
+ }
75
+
76
+ const {
77
+ client,
78
+ posture,
79
+ journey,
80
+ personas,
81
+ asked,
82
+ route,
83
+ hiddenKeys,
84
+ codeOwned,
85
+ personaWalk,
86
+ walkNotice,
87
+ onWalkNoticeDismiss,
88
+ getStorage = () => window.localStorage,
89
+ getSessionStorage = () => window.sessionStorage,
90
+ navigate = (url: string) => {
91
+ window.location.assign(url)
92
+ },
93
+ }: PersonasPanelProps = $props()
94
+
95
+ // SDK stores read straight off the prop: effector stores meet Svelte's store contract, so `$`
96
+ // subscribes, and `$derived` keeps it on whichever client the panel is given.
97
+ const responsesStore = $derived(client.responses.getAllStore)
98
+
99
+ /**
100
+ * This browser's saved personas for the client's project.
101
+ *
102
+ * @returns {Persona[]} The saved personas, oldest first.
103
+ * @example readSaved()
104
+ */
105
+ function readSaved(): Persona[] {
106
+ return readSavedPersonas(getStorage, client.projectId)
107
+ }
108
+
109
+ let saved = $state<Persona[]>(readSaved())
110
+ let creating = $state(false)
111
+ /** How a new persona is made: answered here, or taken from where the reviewer is. */
112
+ let mode = $state<'build' | 'capture'>('build')
113
+ let name = $state('')
114
+ let description = $state('')
115
+ let entryText = $state('')
116
+ let nameError = $state<string | null>(null)
117
+ let entryError = $state<string | null>(null)
118
+ /** The journey a build starts in, its draft answers, and its picks on the site's outcomes. */
119
+ let buildStart = $state('')
120
+ let buildAnswers = $state.raw<Record<string, FieldValue>>({})
121
+ let buildChoices = $state.raw<Record<string, boolean>>({})
122
+ let buildWalk = $state.raw<BuilderWalk | null>(null)
123
+ let notice = $state<Notice | null>(null)
124
+ let loadingId = $state<string | null>(null)
125
+ /** The answers Save would keep; `null` while they are read. */
126
+ let captured = $state<Record<string, FieldValue> | null>(null)
127
+
128
+ const isEmpty = $derived(personas.length === 0 && saved.length === 0)
129
+ const postureReason = $derived(posture.canEditAnswers ? undefined : (posture.reason ?? undefined))
130
+
131
+ /**
132
+ * Why a persona's Load is withheld: the session's posture, or another persona loading.
133
+ *
134
+ * @param {Persona} persona - The persona on the card.
135
+ * @returns {string | undefined} The reason, or `undefined` while Load is allowed.
136
+ * @example loadReason(persona)
137
+ */
138
+ function loadReason(persona: Persona): string | undefined {
139
+ if (postureReason !== undefined) return postureReason
140
+ return loadingId !== null && loadingId !== persona.id
141
+ ? 'Another persona is loading.'
142
+ : undefined
143
+ }
144
+ const landing = $derived<PersonaLanding | null>(
145
+ journey === null
146
+ ? null
147
+ : {
148
+ experienceKey: journey.experienceKey,
149
+ experienceName: journey.experienceName,
150
+ stepKey: journey.stepKey,
151
+ stepName: journey.stepName,
152
+ },
153
+ )
154
+ const buildSummary = $derived.by(() => {
155
+ if (buildWalk === null) return 'Working out the route…'
156
+ const count = Object.keys(buildWalk.answers).length
157
+ const answers = count === 1 ? '1 answer' : `${count} answers`
158
+ const stop = buildWalk.landing
159
+ if (stop === null) return `Saves ${answers}.`
160
+ const screen = stop.stepName ?? deslug(stop.stepKey ?? '')
161
+ return `Saves ${answers}. Loading it walks the journey to ${screen}.`
162
+ })
163
+
164
+ const captureSummary = $derived.by(() => {
165
+ if (captured === null) return 'Reading your answers…'
166
+ const count = Object.keys(captured).length
167
+ const answers = count === 1 ? '1 answer' : `${count} answers`
168
+ if (landing === null)
169
+ return `Saves ${answers}. No journey is open, so it loads the answers only.`
170
+ const journeyName = landing.experienceName ?? deslug(landing.experienceKey)
171
+ if (landing.stepKey === null) return `Saves ${answers}. Loading it walks ${journeyName}.`
172
+ const screen = landing.stepName ?? deslug(landing.stepKey)
173
+ return `Saves ${answers}. Loading it walks ${journeyName} to ${screen}.`
174
+ })
175
+
176
+ /**
177
+ * The answers a customer gave: held values of the fields a journey's screens ask, minus any whose
178
+ * own condition hides it now.
179
+ *
180
+ * @param {Readonly<Record<string, FieldValue>>} responses - Every held response.
181
+ * @returns {Promise<Record<string, FieldValue>>} The answers a persona keeps.
182
+ * @example await readCustomerAnswers(client.responses.getAllStore.getState())
183
+ */
184
+ async function readCustomerAnswers(
185
+ responses: Readonly<Record<string, FieldValue>>,
186
+ ): Promise<Record<string, FieldValue>> {
187
+ const answered = client.fields.listStore
188
+ .getState()
189
+ .filter((field) => asked.has(field.key) && !isBlank(responses[field.key]))
190
+ const visible = await getStepFields(client, answered)
191
+ return customerAnswers(responses, new Set(visible.map((field) => field.key)))
192
+ }
193
+
194
+ // While the form is open, keep what Save would keep in step with the answers.
195
+ $effect(() => {
196
+ if (!creating || mode !== 'capture') return
197
+ let current = true
198
+ void readCustomerAnswers($responsesStore).then((answers) => {
199
+ if (current) captured = answers
200
+ })
201
+ return () => {
202
+ current = false
203
+ }
204
+ })
205
+
206
+ /**
207
+ * A new persona's id, unique in this browser.
208
+ *
209
+ * @returns {string} The id.
210
+ * @example newId() // 'persona-lx2k9-4f1a2b3c'
211
+ */
212
+ function newId(): string {
213
+ const [entropy = 0] = crypto.getRandomValues(new Uint32Array(1))
214
+ return `persona-${Date.now().toString(36)}-${entropy.toString(16)}`
215
+ }
216
+
217
+ /**
218
+ * The journey a build starts in: the one in front, else the first one open.
219
+ *
220
+ * @returns {string} Its experience key, or nothing when no journey is open.
221
+ * @example defaultStart() // 'needs-analysis'
222
+ */
223
+ function defaultStart(): string {
224
+ if (journey !== null) return journey.experienceKey
225
+ const open = client.experienceInstances.listStore
226
+ .getState()
227
+ .find(
228
+ (instance) => instance.isOpenStore.getState() && !hiddenKeys.has(instance.experienceKey),
229
+ )
230
+ return open?.experienceKey ?? ''
231
+ }
232
+
233
+ /**
234
+ * Opens the form that makes a persona: answered here on a fresh page, or from where the reviewer
235
+ * is once they have answered something on it.
236
+ *
237
+ * @example startCreate()
238
+ */
239
+ function startCreate(): void {
240
+ const responses = client.responses.getAllStore.getState()
241
+ const answeredHere = [...asked].some((key) => !isBlank(responses[key]))
242
+ creating = true
243
+ mode = answeredHere ? 'capture' : 'build'
244
+ name = ''
245
+ description = ''
246
+ entryText = `${window.location.pathname}${window.location.search}`
247
+ nameError = null
248
+ entryError = null
249
+ notice = null
250
+ captured = null
251
+ buildStart = defaultStart()
252
+ buildAnswers = {}
253
+ buildChoices = {}
254
+ buildWalk = null
255
+ }
256
+
257
+ /**
258
+ * What Save keeps, from the build or from where the reviewer is.
259
+ *
260
+ * @param {string | null} entryUrl - The page a load starts on.
261
+ * @returns {Promise<PersonaCapture | null>} The capture, or `null` while the build's route is worked out.
262
+ * @example await capture('/compare')
263
+ */
264
+ async function capture(entryUrl: string | null): Promise<PersonaCapture | null> {
265
+ const id = newId()
266
+ const savedAt = new Date().toISOString()
267
+ if (mode === 'build') {
268
+ if (buildWalk === null) return null
269
+ return {
270
+ responses: buildWalk.answers,
271
+ journey: buildWalk.landing,
272
+ passed: buildWalk.passed,
273
+ entryUrl: entryUrl ?? '',
274
+ id,
275
+ savedAt,
276
+ }
277
+ }
278
+
279
+ return {
280
+ responses: await readCustomerAnswers(client.responses.getAllStore.getState()),
281
+ journey: landing,
282
+ passed: passedScreens(route, hiddenKeys),
283
+ entryUrl: entryUrl ?? `${window.location.pathname}${window.location.search}`,
284
+ id,
285
+ savedAt,
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Saves where the reviewer is as a new persona, in this browser.
291
+ *
292
+ * @returns {Promise<void>} Settles once it is saved or refused.
293
+ * @example await save()
294
+ */
295
+ async function save(): Promise<void> {
296
+ const entryUrl = entryUrlOf(entryText, window.location.origin)
297
+ if (entryText.trim().length > 0 && entryUrl === null) {
298
+ entryError = 'Use a page on this site, such as /compare.'
299
+ return
300
+ }
301
+
302
+ const kept = await capture(entryUrl)
303
+ if (kept === null) return
304
+ const result = buildPersona({ name, description }, kept)
305
+ if (!result.ok) {
306
+ nameError = result.error
307
+ return
308
+ }
309
+
310
+ saved = [...saved, result.persona]
311
+ creating = false
312
+ const stored = writeSavedPersonas(getStorage, client.projectId, saved)
313
+ notice = stored
314
+ ? { status: 'success', text: `Saved ${result.persona.name}.` }
315
+ : {
316
+ status: 'warning',
317
+ text: `${result.persona.name} is here until you reload. This browser would not let it be saved.`,
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Deletes a saved persona.
323
+ *
324
+ * @param {Persona} persona - The persona to delete.
325
+ * @example remove(persona)
326
+ */
327
+ function remove(persona: Persona): void {
328
+ saved = saved.filter((entry) => entry.id !== persona.id)
329
+ writeSavedPersonas(getStorage, client.projectId, saved)
330
+ notice = { status: 'success', text: `Deleted ${persona.name}.` }
331
+ }
332
+
333
+ /**
334
+ * Loads a persona: the site's own loader when it has one, else a clean reload that walks its
335
+ * journey on the persona's page, keeping this page's query.
336
+ *
337
+ * @param {Persona} persona - The persona to load.
338
+ * @returns {Promise<void>} Settles once it has loaded, failed, or the reload is under way.
339
+ * @example await load(persona)
340
+ */
341
+ async function load(persona: Persona): Promise<void> {
342
+ loadingId = persona.id
343
+ notice = null
344
+ onWalkNoticeDismiss()
345
+ try {
346
+ if (persona.load === null) {
347
+ const started = startPersonaLoad(client, persona, {
348
+ storage: getSessionStorage(),
349
+ url: loadUrl(persona.entryUrl, window.location),
350
+ navigate,
351
+ })
352
+ if (!started) {
353
+ notice = { status: 'error', text: 'This browser would not let the load start.' }
354
+ loadingId = null
355
+ }
356
+
357
+ return
358
+ }
359
+
360
+ await persona.load(client)
361
+ notice = { status: 'success', text: `Loaded ${persona.name}.` }
362
+ loadingId = null
363
+ } catch (error) {
364
+ const detail = error instanceof Error ? ` ${error.message}` : ''
365
+ notice = { status: 'error', text: `${persona.name} did not load.${detail}` }
366
+ loadingId = null
367
+ }
368
+ }
369
+ </script>
370
+
371
+ <div class="personas" data-testid="devtools-personas">
372
+ {#if walkNotice}
373
+ <Banner
374
+ status={walkNotice.status}
375
+ testId="devtools-persona-walk"
376
+ onDismiss={onWalkNoticeDismiss}
377
+ >
378
+ {walkNotice.text}
379
+ </Banner>
380
+ {/if}
381
+
382
+ {#if notice}
383
+ <Banner status={notice.status} onDismiss={() => (notice = null)}>{notice.text}</Banner>
384
+ {/if}
385
+
386
+ {#if creating}
387
+ <Card>
388
+ <div class="form">
389
+ <Stack direction="vertical" gap="medium">
390
+ <Text weight="semibold">New persona</Text>
391
+ <RadioGroup
392
+ label="Make it by"
393
+ name="persona-mode"
394
+ orientation="horizontal"
395
+ value={mode}
396
+ options={[
397
+ { value: 'build', label: 'Answering the questions here' },
398
+ { value: 'capture', label: 'Using where I am now' },
399
+ ]}
400
+ onchange={(value) => (mode = value === 'capture' ? 'capture' : 'build')}
401
+ />
402
+ <TextField
403
+ label="Name"
404
+ name="persona-name"
405
+ placeholder="Young family"
406
+ required
407
+ value={name}
408
+ error={nameError ?? undefined}
409
+ oninput={(value) => {
410
+ name = value
411
+ nameError = null
412
+ }}
413
+ />
414
+ <TextField
415
+ label="Description"
416
+ name="persona-description"
417
+ helpText="Who they are, or what they test, in a sentence."
418
+ value={description}
419
+ oninput={(value) => (description = value)}
420
+ />
421
+ <TextField
422
+ label="Starts on"
423
+ name="persona-entry-url"
424
+ helpText="The page a load opens. Keep a campaign page's address to load it as that campaign."
425
+ value={entryText}
426
+ error={entryError ?? undefined}
427
+ oninput={(value) => {
428
+ entryText = value
429
+ entryError = null
430
+ }}
431
+ />
432
+ {#if mode === 'build'}
433
+ <PersonaBuilder
434
+ {client}
435
+ start={buildStart}
436
+ answers={buildAnswers}
437
+ choices={buildChoices}
438
+ entryUrl={entryUrlOf(entryText, window.location.origin) ?? window.location.pathname}
439
+ {asked}
440
+ {codeOwned}
441
+ selfAdvancing={personaWalk.selfAdvancingSteps}
442
+ handoffs={personaWalk.handoffs}
443
+ {hiddenKeys}
444
+ onStartChange={(key) => (buildStart = key)}
445
+ onAnswersChange={(next) => (buildAnswers = next)}
446
+ onChoicesChange={(next) => (buildChoices = next)}
447
+ onWalk={(next) => (buildWalk = next)}
448
+ />
449
+ {/if}
450
+ <Text size="small" color="subdued">
451
+ {mode === 'build' ? buildSummary : captureSummary}
452
+ </Text>
453
+ <div class="form-actions">
454
+ <Button variant="primary" size="small" onclick={() => void save()}>Save</Button>
455
+ <Button variant="ghost" size="small" onclick={() => (creating = false)}>Cancel</Button>
456
+ </div>
457
+ </Stack>
458
+ </div>
459
+ </Card>
460
+ {:else if isEmpty}
461
+ <div class="empty">
462
+ <EmptyState
463
+ message="No personas or scenarios yet"
464
+ hint="Walk the journey the way a customer would, then save it here to load it again in one click."
465
+ />
466
+ <Button variant="primary" onclick={startCreate}>Create</Button>
467
+ </div>
468
+ {:else}
469
+ <div class="toolbar">
470
+ <Button variant="secondary" size="small" onclick={startCreate}>Create</Button>
471
+ </div>
472
+ {/if}
473
+
474
+ {#if personas.length > 0}
475
+ <section class="group" aria-label="For this site">
476
+ <h3 class="group-title">For this site</h3>
477
+ {#each personas as persona (persona.id)}
478
+ <PersonaCard
479
+ {persona}
480
+ loadReason={loadReason(persona)}
481
+ loading={loadingId === persona.id}
482
+ onLoad={() => void load(persona)}
483
+ />
484
+ {/each}
485
+ </section>
486
+ {/if}
487
+
488
+ {#if saved.length > 0}
489
+ <section class="group" aria-label="Saved in this browser">
490
+ <h3 class="group-title">Saved in this browser</h3>
491
+ {#each saved as persona (persona.id)}
492
+ <PersonaCard
493
+ {persona}
494
+ loadReason={loadReason(persona)}
495
+ loading={loadingId === persona.id}
496
+ onLoad={() => void load(persona)}
497
+ onDelete={() => remove(persona)}
498
+ />
499
+ {/each}
500
+ </section>
501
+ {/if}
502
+ </div>
503
+
504
+ <style>
505
+ .personas {
506
+ display: flex;
507
+ flex-direction: column;
508
+ gap: var(--space-4);
509
+ }
510
+
511
+ .form {
512
+ max-width: 36rem;
513
+ }
514
+
515
+ .empty {
516
+ display: flex;
517
+ flex-direction: column;
518
+ align-items: center;
519
+ gap: var(--space-3);
520
+ }
521
+
522
+ .toolbar,
523
+ .form-actions {
524
+ display: flex;
525
+ gap: var(--space-2);
526
+ }
527
+
528
+ .toolbar {
529
+ justify-content: flex-end;
530
+ }
531
+
532
+ .group {
533
+ display: flex;
534
+ flex-direction: column;
535
+ gap: var(--space-2);
536
+ }
537
+
538
+ .group-title {
539
+ margin: 0;
540
+ font-size: var(--text-xs);
541
+ font-weight: var(--font-semibold);
542
+ letter-spacing: 0.04em;
543
+ text-transform: uppercase;
544
+ color: var(--color-gray-500);
545
+ }
546
+ </style>