@picked/svelte 0.0.1-dev.1793776638554 → 0.0.1-dev.1793923852884

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 (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/experience/set-picked-context.js +1 -1
  3. package/dist/plugins/devtools/DevToolsExperience.svelte +155 -80
  4. package/dist/plugins/devtools/customTools.d.ts +2 -2
  5. package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +14 -0
  6. package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -1
  7. package/dist/plugins/devtools/derive/answer-display/index.d.ts +2 -2
  8. package/dist/plugins/devtools/derive/answer-display/index.js +1 -1
  9. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
  10. package/dist/plugins/devtools/derive/index.d.ts +5 -5
  11. package/dist/plugins/devtools/derive/index.js +1 -1
  12. package/dist/plugins/devtools/derive/personas/index.d.ts +2 -2
  13. package/dist/plugins/devtools/derive/personas/index.js +1 -1
  14. package/dist/plugins/devtools/derive/personas/personas.d.ts +88 -7
  15. package/dist/plugins/devtools/derive/personas/personas.js +1 -1
  16. package/dist/plugins/devtools/derive/plain-language/index.d.ts +1 -1
  17. package/dist/plugins/devtools/derive/plain-language/index.js +1 -1
  18. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +5 -5
  19. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
  20. package/dist/plugins/devtools/index.d.ts +1 -1
  21. package/dist/plugins/devtools/launcher/Launcher.svelte +13 -4
  22. package/dist/plugins/devtools/options.d.ts +6 -4
  23. package/dist/plugins/devtools/options.js +1 -1
  24. package/dist/plugins/devtools/review/journey/ExperienceDetails.svelte +129 -0
  25. package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +54 -8
  26. package/dist/plugins/devtools/review/personas/ChoiceButton.svelte +40 -6
  27. package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +69 -45
  28. package/dist/plugins/devtools/review/personas/PersonaCard.svelte +226 -43
  29. package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +171 -74
  30. package/dist/plugins/devtools/review/personas/load-persona.d.ts +41 -20
  31. package/dist/plugins/devtools/review/personas/load-persona.js +1 -1
  32. package/dist/plugins/devtools/review/personas/persona-route.d.ts +87 -0
  33. package/dist/plugins/devtools/review/personas/persona-route.js +1 -0
  34. package/dist/plugins/devtools/review/personas/persona-session.d.ts +94 -0
  35. package/dist/plugins/devtools/review/personas/persona-session.js +1 -0
  36. package/dist/plugins/devtools/sidebar/Sidebar.svelte +14 -48
  37. package/dist/plugins/devtools/sidebar/SidebarRow.svelte +2 -2
  38. package/dist/plugins/devtools/sidebar/nav.d.ts +26 -39
  39. package/dist/plugins/devtools/sidebar/nav.js +1 -1
  40. package/package.json +2 -2
  41. package/dist/plugins/devtools/screens/experience-instances/ExperienceInstancesListScreen.svelte +0 -10
  42. package/dist/plugins/devtools/screens/experiences/ExperienceDetailsScreen.svelte +0 -44
  43. package/dist/plugins/devtools/screens/experiences/ExperienceExperienceInstancesListScreen.svelte +0 -13
  44. package/dist/plugins/devtools/screens/experiences/ExperienceScreen.svelte +0 -32
  45. package/dist/plugins/devtools/screens/experiences/ExperienceTreeScreen.svelte +0 -500
  46. package/dist/plugins/devtools/screens/experiences/ExperiencesListScreen.svelte +0 -22
  47. package/dist/plugins/devtools/screens/features/FeaturesListScreen.svelte +0 -239
  48. package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +0 -286
  49. package/dist/plugins/devtools/screens/responses/ResponsesListScreen.svelte +0 -12
  50. package/dist/plugins/devtools/screens/responses/ResponsesTable.svelte +0 -88
@@ -1,22 +1,29 @@
1
1
  <script lang="ts">
2
2
  /**
3
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. Site tools placed here
9
- * follow, each a section under its label.
4
+ * own come from the plugin's `personas` option, under their headings. A reviewer makes theirs in
5
+ * this browser, either by answering the journey's questions here, screen by screen, or from where
6
+ * they are on the page, and can edit any persona; an edit of the site's own stays in this
7
+ * browser. A persona keeps the answers a customer gives on fields they can see, the screens moved
8
+ * on from, the screen it stops on and the page it starts on.
9
+ *
10
+ * Walk through reloads that page clean and pre-fills every step with the persona's responses, so
11
+ * the reviewer moves through it themselves. Jump to step walks the persona to a step on its
12
+ * route first. Site tools placed here follow, each a section under its label.
10
13
  */
11
14
  import type { FieldValue, Picked } from '@picked/client'
12
15
  import { getStepFields } from '@picked/client/form'
16
+ import { SvelteMap } from 'svelte/reactivity'
13
17
  import { Banner, Button, Card, EmptyState, RadioGroup, Stack, Text, TextField } from '../../ui'
14
18
  import {
19
+ applyEdits,
15
20
  buildPersona,
16
21
  customerAnswers,
17
22
  deslug,
18
23
  entryUrlOf,
24
+ groupPersonas,
19
25
  isBlank,
26
+ matchesSearch,
20
27
  passedScreens,
21
28
  readSavedPersonas,
22
29
  writeSavedPersonas,
@@ -32,6 +39,8 @@
32
39
  import type { CustomTool } from '../../customTools'
33
40
  import type { JourneyView } from '../../session'
34
41
  import { loadUrl, startPersonaLoad } from './load-persona'
42
+ import { personaLink } from './persona-session'
43
+ import { personaRoute, routeScreens, type RouteScreen } from './persona-route'
35
44
  import PersonaBuilder from './PersonaBuilder.svelte'
36
45
  import PersonaCard from './PersonaCard.svelte'
37
46
 
@@ -50,14 +59,22 @@
50
59
  hiddenKeys: ReadonlySet<string>
51
60
  /** Keys a screen carries only for its code. */
52
61
  codeOwned: ReadonlySet<string>
53
- /** Screens that move on by themselves, and actions that open another journey. */
62
+ /**
63
+ * Screens that move on by themselves, responses every walk gives, and actions that open
64
+ * another journey.
65
+ */
54
66
  personaWalk: {
55
67
  selfAdvancingSteps: ReadonlySet<string>
68
+ responses: Readonly<Record<string, FieldValue>>
56
69
  handoffs: Readonly<Record<string, string>>
57
70
  }
58
71
  /** The site's tools placed in this tab. */
59
72
  tools?: readonly CustomTool[]
60
- /** How the last load's walk ended; `null` when no load ran on this page. */
73
+ /** The persona this tab walks through as; `null` outside a persona session. */
74
+ activePersona: Persona | null
75
+ /** Ends the persona session. */
76
+ onStop: () => void
77
+ /** How the last jump's walk ended; `null` when no jump ran on this page. */
61
78
  walkNotice: Notice | null
62
79
  onWalkNoticeDismiss: () => void
63
80
  /** The storage saved personas live in. */
@@ -89,6 +106,8 @@
89
106
  codeOwned,
90
107
  personaWalk,
91
108
  tools = [],
109
+ activePersona,
110
+ onStop,
92
111
  walkNotice,
93
112
  onWalkNoticeDismiss,
94
113
  getStorage = () => window.localStorage,
@@ -129,26 +148,65 @@
129
148
  let buildChoices = $state.raw<Record<string, boolean>>({})
130
149
  let buildWalk = $state.raw<BuilderWalk | null>(null)
131
150
  let notice = $state<Notice | null>(null)
132
- let loadingId = $state<string | null>(null)
151
+ let startingId = $state<string | null>(null)
152
+ let search = $state('')
133
153
  /** The answers Save would keep; `null` while they are read. */
134
154
  let captured = $state<Record<string, FieldValue> | null>(null)
135
155
 
156
+ const listed = $derived(applyEdits(personas, saved))
157
+ const siteGroups = $derived(
158
+ groupPersonas(
159
+ listed.site.filter((persona) => matchesSearch(persona, search)),
160
+ 'For this site',
161
+ ),
162
+ )
163
+ const own = $derived(listed.own.filter((persona) => matchesSearch(persona, search)))
164
+ /** Every heading in the list: the site's groups, then this browser's own. */
165
+ const sections = $derived(
166
+ own.length > 0 ? [...siteGroups, { title: 'Saved in this browser', personas: own }] : siteGroups,
167
+ )
136
168
  const isEmpty = $derived(personas.length === 0 && saved.length === 0 && tools.length === 0)
169
+ /** Enough personas that a reviewer needs to search them. */
170
+ const searchable = $derived(personas.length + listed.own.length > 6)
137
171
  const postureReason = $derived(posture.canEditAnswers ? undefined : (posture.reason ?? undefined))
172
+ const routeSettings = $derived({
173
+ asked,
174
+ codeOwned,
175
+ selfAdvancing: personaWalk.selfAdvancingSteps,
176
+ handoffs: personaWalk.handoffs,
177
+ hiddenKeys,
178
+ responses: personaWalk.responses,
179
+ })
180
+ /** Each persona's route as last worked out, so a jump moves on from its screens. */
181
+ const routes = new SvelteMap<string, BuilderWalk>()
138
182
 
139
183
  /**
140
- * Why a persona's Load is withheld: the session's posture, or another persona loading.
184
+ * Why starting a persona is withheld: the session's posture, or another persona starting.
141
185
  *
142
186
  * @param {Persona} persona - The persona on the card.
143
- * @returns {string | undefined} The reason, or `undefined` while Load is allowed.
144
- * @example loadReason(persona)
187
+ * @returns {string | undefined} The reason, or `undefined` while it can start.
188
+ * @example startReason(persona)
145
189
  */
146
- function loadReason(persona: Persona): string | undefined {
190
+ function startReason(persona: Persona): string | undefined {
147
191
  if (postureReason !== undefined) return postureReason
148
- return loadingId !== null && loadingId !== persona.id
149
- ? 'Another persona is loading.'
192
+ return startingId !== null && startingId !== persona.id
193
+ ? 'Another persona is starting.'
150
194
  : undefined
151
195
  }
196
+
197
+ /**
198
+ * The screens a persona's responses take it through.
199
+ *
200
+ * @param {Persona} persona - The persona.
201
+ * @returns {Promise<RouteScreen[] | null>} The screens, or `null` when it has no journey.
202
+ * @example await routeOf(persona)
203
+ */
204
+ async function routeOf(persona: Persona): Promise<RouteScreen[] | null> {
205
+ const walk = await personaRoute(client, persona, routeSettings, window.location)
206
+ if (walk === null) return null
207
+ routes.set(persona.id, walk)
208
+ return routeScreens(walk, persona.landing)
209
+ }
152
210
  const landing = $derived<PersonaLanding | null>(
153
211
  journey === null
154
212
  ? null
@@ -166,7 +224,7 @@
166
224
  const stop = buildWalk.landing
167
225
  if (stop === null) return `Saves ${answers}.`
168
226
  const screen = stop.stepName ?? deslug(stop.stepKey ?? '')
169
- return `Saves ${answers}. Loading it walks the experience to ${screen}.`
227
+ return `Saves ${answers}. It stops at ${screen}.`
170
228
  })
171
229
 
172
230
  const captureSummary = $derived.by(() => {
@@ -174,11 +232,11 @@
174
232
  const count = Object.keys(captured).length
175
233
  const answers = count === 1 ? '1 response' : `${count} responses`
176
234
  if (landing === null)
177
- return `Saves ${answers}. No experience is open, so it loads the responses only.`
235
+ return `Saves ${answers}. No experience is open, so it keeps the responses only.`
178
236
  const journeyName = landing.experienceName ?? deslug(landing.experienceKey)
179
- if (landing.stepKey === null) return `Saves ${answers}. Loading it walks ${journeyName}.`
237
+ if (landing.stepKey === null) return `Saves ${answers}. It starts in ${journeyName}.`
180
238
  const screen = landing.stepName ?? deslug(landing.stepKey)
181
- return `Saves ${answers}. Loading it walks ${journeyName} to ${screen}.`
239
+ return `Saves ${answers}. It stops at ${screen} in ${journeyName}.`
182
240
  })
183
241
 
184
242
  /**
@@ -355,7 +413,7 @@
355
413
  }
356
414
 
357
415
  const edited = editingId !== null
358
- saved = edited
416
+ saved = saved.some((entry) => entry.id === result.persona.id)
359
417
  ? saved.map((entry) => (entry.id === result.persona.id ? result.persona : entry))
360
418
  : [...saved, result.persona]
361
419
  creating = false
@@ -370,56 +428,69 @@
370
428
  }
371
429
 
372
430
  /**
373
- * Deletes a saved persona.
431
+ * Deletes a saved persona, or drops this browser's edit of a site persona.
374
432
  *
375
- * @param {Persona} persona - The persona to delete.
433
+ * @param {Persona} persona - The persona.
376
434
  * @example remove(persona)
377
435
  */
378
436
  function remove(persona: Persona): void {
379
437
  saved = saved.filter((entry) => entry.id !== persona.id)
380
438
  writeSavedPersonas(getStorage, client.projectId, saved)
381
- notice = { status: 'success', text: `Deleted ${persona.name}.` }
439
+ notice = {
440
+ status: 'success',
441
+ text: persona.edited ? `Put ${persona.name} back as the site has it.` : `Deleted ${persona.name}.`,
442
+ }
382
443
  }
383
444
 
384
445
  /**
385
- * Loads a persona: the site's own loader when it has one, else a clean reload that walks its
386
- * journey on the persona's page, keeping this page's query.
446
+ * Starts a persona session: a clean reload of the persona's page that pre-fills every step, after
447
+ * walking to `stop` when one is picked.
387
448
  *
388
- * @param {Persona} persona - The persona to load.
389
- * @returns {Promise<void>} Settles once it has loaded, failed, or the reload is under way.
390
- * @example await load(persona)
449
+ * @param {Persona} persona - The persona.
450
+ * @param {RouteScreen | null} stop - The step to walk to; `null` for a walk-through from the start.
451
+ * @example start(persona, null)
391
452
  */
392
- async function load(persona: Persona): Promise<void> {
393
- loadingId = persona.id
453
+ function start(persona: Persona, stop: RouteScreen | null): void {
454
+ startingId = persona.id
394
455
  notice = null
395
456
  onWalkNoticeDismiss()
396
- try {
397
- if (persona.load === null) {
398
- const started = startPersonaLoad(client, persona, {
399
- storage: getSessionStorage(),
400
- url: loadUrl(persona.entryUrl, window.location),
401
- navigate,
402
- })
403
- if (!started) {
404
- notice = { status: 'error', text: 'This browser would not let the load start.' }
405
- loadingId = null
406
- }
407
-
408
- return
409
- }
410
-
411
- await persona.load(client)
412
- notice = { status: 'success', text: `Loaded ${persona.name}.` }
413
- loadingId = null
414
- } catch (error) {
415
- const detail = error instanceof Error ? ` ${error.message}` : ''
416
- notice = { status: 'error', text: `${persona.name} did not load.${detail}` }
417
- loadingId = null
457
+ const started = startPersonaLoad(
458
+ client,
459
+ {
460
+ id: persona.id,
461
+ stop:
462
+ stop === null
463
+ ? null
464
+ : {
465
+ experienceKey: stop.experienceKey,
466
+ experienceName: stop.experienceName,
467
+ stepKey: stop.stepKey,
468
+ stepName: stop.stepName,
469
+ },
470
+ passed: stop === null ? [] : (routes.get(persona.id)?.passed ?? []),
471
+ },
472
+ { storage: getSessionStorage(), url: loadUrl(persona.entryUrl, window.location), navigate },
473
+ )
474
+ if (!started) {
475
+ notice = { status: 'error', text: 'This browser would not let the persona start.' }
476
+ startingId = null
418
477
  }
419
478
  }
420
479
  </script>
421
480
 
422
481
  <div class="personas" data-testid="devtools-personas">
482
+ {#if activePersona}
483
+ <Banner status="info" testId="devtools-persona-session">
484
+ <div class="session">
485
+ <span>
486
+ Walking through as <strong>{activePersona.name}</strong>. Each step shows their
487
+ responses. Click Continue, or change a response first.
488
+ </span>
489
+ <Button variant="secondary" size="small" onclick={onStop}>Stop</Button>
490
+ </div>
491
+ </Banner>
492
+ {/if}
493
+
423
494
  {#if walkNotice}
424
495
  <Banner
425
496
  status={walkNotice.status}
@@ -439,6 +510,11 @@
439
510
  <div class="form">
440
511
  <Stack direction="vertical" gap="medium">
441
512
  <Text weight="semibold">{editingId === null ? 'New persona' : 'Edit persona'}</Text>
513
+ {#if personas.some((persona) => persona.id === editingId)}
514
+ <Text size="small" color="subdued">
515
+ Your edits stay in this browser. Reset puts it back as the site has it.
516
+ </Text>
517
+ {/if}
442
518
  <RadioGroup
443
519
  label="Make it by"
444
520
  name="persona-mode"
@@ -518,38 +594,46 @@
518
594
  </div>
519
595
  {:else}
520
596
  <div class="toolbar">
597
+ {#if searchable}
598
+ <div class="search">
599
+ <TextField
600
+ label="Search"
601
+ name="persona-search"
602
+ placeholder="Search, such as moving or gas"
603
+ value={search}
604
+ oninput={(value) => (search = value)}
605
+ />
606
+ </div>
607
+ {/if}
521
608
  <Button variant="secondary" size="small" onclick={startCreate}>Create</Button>
522
609
  </div>
523
610
  {/if}
524
611
 
525
- {#if personas.length > 0}
526
- <section class="group" aria-label="For this site">
527
- <h3 class="group-title">For this site</h3>
528
- {#each personas as persona (persona.id)}
612
+ {#each sections as section (section.title)}
613
+ <section class="group" aria-label={section.title}>
614
+ <h3 class="group-title">{section.title}</h3>
615
+ {#each section.personas as persona (persona.id)}
529
616
  <PersonaCard
530
617
  {persona}
531
- loadReason={loadReason(persona)}
532
- loading={loadingId === persona.id}
533
- onLoad={() => void load(persona)}
534
- />
535
- {/each}
536
- </section>
537
- {/if}
538
-
539
- {#if saved.length > 0}
540
- <section class="group" aria-label="Saved in this browser">
541
- <h3 class="group-title">Saved in this browser</h3>
542
- {#each saved as persona (persona.id)}
543
- <PersonaCard
544
- {persona}
545
- loadReason={loadReason(persona)}
546
- loading={loadingId === persona.id}
547
- onLoad={() => void load(persona)}
618
+ startReason={startReason(persona)}
619
+ starting={startingId === persona.id}
620
+ active={activePersona?.id === persona.id}
621
+ onWalkThrough={() => start(persona, null)}
622
+ route={() => routeOf(persona)}
623
+ onJump={(screen) => start(persona, screen)}
548
624
  onEdit={() => startEdit(persona)}
549
- onDelete={() => remove(persona)}
625
+ onDelete={persona.source === 'saved' ? () => remove(persona) : undefined}
626
+ onReset={persona.edited ? () => remove(persona) : undefined}
627
+ link={persona.source === 'site'
628
+ ? personaLink(window.location, persona.entryUrl, persona.id)
629
+ : undefined}
550
630
  />
551
631
  {/each}
552
632
  </section>
633
+ {/each}
634
+
635
+ {#if search.length > 0 && sections.length === 0}
636
+ <Text size="small" color="subdued">No persona or scenario matches “{search}”.</Text>
553
637
  {/if}
554
638
 
555
639
  {#each tools as tool (tool.id)}
@@ -588,9 +672,22 @@
588
672
  }
589
673
 
590
674
  .toolbar {
675
+ align-items: flex-end;
591
676
  justify-content: flex-end;
592
677
  }
593
678
 
679
+ .search {
680
+ flex: 1 1 auto;
681
+ }
682
+
683
+ .session {
684
+ display: flex;
685
+ flex-wrap: wrap;
686
+ align-items: center;
687
+ justify-content: space-between;
688
+ gap: var(--space-2);
689
+ }
690
+
594
691
  .group {
595
692
  display: flex;
596
693
  flex-direction: column;
@@ -1,20 +1,20 @@
1
1
  /**
2
- * @file Loads a persona the way a customer arrives: the page reloads clean, then the persona's
3
- * journey is walked from its first screen. Each screen gets its own answers as the walk reaches it,
4
- * then Continue, so every condition and action between the screens runs for real. A host persona
5
- * with its own `load` skips this.
2
+ * @file Starts a persona the way a customer arrives: the page reloads clean on the persona's entry
3
+ * page. A jump then walks the persona's journey from its first screen to the screen picked. Each
4
+ * screen gets its own answers as the walk reaches it, then Continue, so every condition and action
5
+ * between the screens runs for real.
6
6
  *
7
- * Load has two halves on either side of the reload. The first, {@link startPersonaLoad}, clears the
8
- * answers and the journey history the SDK keeps, notes the persona in `sessionStorage` and
9
- * reloads. The second, {@link walkPersona}, runs on the next page, once the devtools mount there
10
- * and pick up the note.
7
+ * A start has two halves on either side of the reload. The first, {@link startPersonaLoad}, clears
8
+ * the answers and the journey history the SDK keeps, notes the persona in `sessionStorage` and
9
+ * reloads. The second runs on the next page, once the devtools mount there and pick up the note:
10
+ * {@link walkPersona} for a jump, {@link openPersonaJourney} for a walk-through.
11
11
  *
12
- * The walk stops at the persona's screen, or at the first screen it cannot finish: a question with
13
- * no answer, an answer that fails its check, a screen that asks nothing and was not moved on from
14
- * when the persona was saved, or a Continue that lands nowhere.
12
+ * The walk stops at the screen picked, or at the first screen it cannot finish: a question with no
13
+ * answer, an answer that fails its check, a screen that asks nothing and is not on the persona's
14
+ * route, or a Continue that lands nowhere.
15
15
  */
16
16
  import type { FieldValue, Picked } from '@picked/client';
17
- import { type Persona } from '../../derive';
17
+ import { type Persona, type PersonaLanding, type PersonaScreen } from '../../derive';
18
18
  /** The part of an experience instance a walk reads and moves. */
19
19
  export interface WalkInstance {
20
20
  id: string;
@@ -142,6 +142,15 @@ export interface WalkOutcome {
142
142
  }
143
143
  /** The `sessionStorage` key that carries a load across the reload. */
144
144
  export declare const PENDING_LOAD_KEY = "picked-devtools-persona-load";
145
+ /** A persona start, carried across its reload. */
146
+ export interface PendingLoad {
147
+ /** The persona's id. */
148
+ id: string;
149
+ /** The screen a jump walks to; `null` for a walk-through, which walks nowhere. */
150
+ stop: PersonaLanding | null;
151
+ /** The screens the walk moves on from on its way to `stop`, in order. */
152
+ passed: PersonaScreen[];
153
+ }
145
154
  /** Where a load reloads, and how. */
146
155
  export interface PersonaReload {
147
156
  /** The tab's `sessionStorage`, which carries the load across the reload. */
@@ -166,16 +175,16 @@ export interface PersonaReload {
166
175
  */
167
176
  export declare function screenQuestions(client: Picked, fieldKeys: readonly string[], codeOwned: ReadonlySet<string>): Promise<string[]>;
168
177
  /**
169
- * Clears what the session holds and reloads, so the next page can walk the persona from the start.
170
- * The answers go, and so does the journey history, which a host may keep across reloads.
178
+ * Clears what the session holds and reloads, so the next page can start the persona clean. The
179
+ * answers go, and so does the journey history, which a host may keep across reloads.
171
180
  *
172
181
  * @param {ResetClient} client - The client.
173
- * @param {Persona} persona - The persona to load.
182
+ * @param {PendingLoad} load - The persona, and the screen a jump walks to.
174
183
  * @param {PersonaReload} reload - Where the load reloads, and how.
175
184
  * @returns {boolean} `false` when the load could not be noted, so nothing was cleared.
176
- * @example startPersonaLoad(client, persona, { storage: sessionStorage, url: '/', navigate })
185
+ * @example startPersonaLoad(client, { id: persona.id, stop: null, passed: [] }, reload)
177
186
  */
178
- export declare function startPersonaLoad(client: ResetClient, persona: Persona, reload: PersonaReload): boolean;
187
+ export declare function startPersonaLoad(client: ResetClient, load: PendingLoad, reload: PersonaReload): boolean;
179
188
  /**
180
189
  * The URL a load goes to: the persona's entry page, or this page when it has none. This page's
181
190
  * `picked-` parameters (a preview token, a devtools switch) ride along where the entry URL does not
@@ -193,13 +202,13 @@ export declare function loadUrl(entryUrl: string | null, here: {
193
202
  search: string;
194
203
  }): string;
195
204
  /**
196
- * Takes the persona a reload was for off `sessionStorage`, so it loads once.
205
+ * Takes the start a reload was for off `sessionStorage`, so it runs once.
197
206
  *
198
207
  * @param {Pick<Storage, 'getItem' | 'removeItem'>} storage - The tab's `sessionStorage`.
199
- * @returns {string | null} The persona's id, or `null` when no load is waiting.
208
+ * @returns {PendingLoad | null} The start, or `null` when none is waiting.
200
209
  * @example takePendingLoad(sessionStorage)
201
210
  */
202
- export declare function takePendingLoad(storage: Pick<Storage, 'getItem' | 'removeItem'>): string | null;
211
+ export declare function takePendingLoad(storage: Pick<Storage, 'getItem' | 'removeItem'>): PendingLoad | null;
203
212
  /**
204
213
  * Walks a persona's journey from its first screen: each screen gets the answers it asks, then
205
214
  * Continue, until the persona's screen or the first screen the walk cannot finish.
@@ -212,6 +221,18 @@ export declare function takePendingLoad(storage: Pick<Storage, 'getItem' | 'remo
212
221
  * @example await walkPersona(client, persona, settings)
213
222
  */
214
223
  export declare function walkPersona(client: WalkClient, persona: Persona, settings: WalkSettings, timing?: WalkTiming): Promise<WalkOutcome>;
224
+ /**
225
+ * Gets a walk-through going once the page has started: the page's own experience when one is
226
+ * open, else the persona's first one.
227
+ *
228
+ * @param {WalkClient} client - The client, on a page that has just loaded.
229
+ * @param {Persona} persona - The persona.
230
+ * @param {WalkSettings} settings - Which experiences the devtools never follow.
231
+ * @param {WalkTiming} [timing] - How long to wait; tests pass a short one.
232
+ * @returns {Promise<boolean>} `false` when no experience is open at the end.
233
+ * @example await openPersonaJourney(client, persona, settings)
234
+ */
235
+ export declare function openPersonaJourney(client: WalkClient, persona: Persona, settings: Pick<WalkSettings, 'exclude'>, timing?: WalkTiming): Promise<boolean>;
215
236
  /**
216
237
  * What the reviewer reads once a walk ends.
217
238
  *
@@ -1 +1 @@
1
- import{deslug as e,screenName as t}from"../../derive/plain-language/plain-language.js";import{pickJourney as n}from"../../derive/journey-pick/journey-pick.js";import"../../derive/index.js";import{getStepFields as r}from"@picked/client/form";var i=`picked-devtools-persona-load`,a=30,o={sleep:e=>new Promise(t=>{setTimeout(t,e)}),bootTries:500,openTries:20,startTries:100,advanceTries:500,returnTries:33,selfAdvanceTries:1e3,maxHops:60};function s(e){return`${e.experienceKey}/${e.stepKey}`}async function c(e,t,n){for(let r=0;r<=t;r++){let i=e();if(i!==null)return i;r<t&&await n.sleep(a)}return null}function l(e,r,i){let a=e.experienceInstances.listStore.getState().filter(e=>r.has(e.experienceKey)),o=n(a,{exclude:i.exclude,precedence:i.precedence,openOrder:i.openOrder()}),s=o?.currentStepStore.getState();return o===void 0||s==null||o.currentNodeStore.getState()?.type!==`step`||o.stateStore.getState()!==`ready`?null:{instance:o,screen:{experienceKey:o.experienceKey,stepKey:s.key},fieldKeys:s.fieldKeys,stop:{experienceName:o.experienceName,stepName:t(s.name,s.key)}}}async function u(e,t,n){await e.openExperienceByKey(t),await c(()=>e.experienceInstances.listStore.getState().some(e=>e.experienceKey===t&&e.isOpenStore.getState())?!0:null,n.openTries,n)===null&&await e.launchExperienceByKey(t,{bypassCondition:!0,initialiseOpen:!0})}function d(e,t){let n={};for(let r of t)Object.hasOwn(e,r)&&(n[r]=e[r]);return n}async function f(e,t,n){let i=e.fields.listStore.getState(),a=t.flatMap(e=>i.filter(t=>t.key===e));return(await r(e,a)).map(e=>e.key).filter(e=>!n.has(e))}function p(e,t,n){try{n.storage.setItem(i,t.id)}catch{return!1}return e.responses.clearAll(),e.history.restore({entries:[]}),n.navigate(n.url),!0}function m(e,t){let n=new URL(e??`${t.pathname}${t.search}`,`https://entry.invalid`);for(let[e,r]of new URLSearchParams(t.search))e.startsWith(`picked-`)&&!n.searchParams.has(e)&&n.searchParams.append(e,r);return`${n.pathname}${n.search}`}function h(e){try{let t=e.getItem(i);return e.removeItem(i),t!==null&&t.length>0?t:null}catch{return null}}function g(e,t){return e===null||e.experienceKey!==t.experienceKey?!1:e.stepKey===null||e.stepKey===t.stepKey}async function _(e,t,n){let r=!1,i=0;return await c(()=>{let n=e.resting();return n===null?(r=!0,i=0,null):s(n.screen)!==t||(r&&(i+=1),i>e.timing.returnTries?!1:null)},n,e.timing)===!0}async function v(e,t){let n=await e.settings.askedOn(t.fieldKeys),r=n.filter(t=>Object.hasOwn(e.answers,t));if(r.length>0){let{errors:n}=await e.client.responses.commit(d(e.answers,r));if(n.length>0)return{end:`invalid`,at:t.stop}}if(g(e.landing,t.screen))return{end:`arrived`,at:t.stop};let i=s(t.screen);return e.settings.selfAdvancing.has(t.screen.stepKey)?await _(e,i,e.timing.selfAdvanceTries)?null:{end:`stuck`,at:t.stop}:(n.length>0?r.length===n.length:e.passed.has(i))?(t.instance.goToNextStep(),await _(e,i,e.timing.advanceTries)?null:{end:`stuck`,at:t.stop}):{end:`needs-answer`,at:t.stop}}async function y(e,t,n,r=o){let{landing:i}=t,a=t.passed.at(0)?.experienceKey??i?.experienceKey;if(a===void 0)return Object.keys(t.responses).length>0&&e.responses.register({...t.responses}),{end:`answers-only`,at:null};let d=new Set(t.passed.map(e=>e.experienceKey));i!==null&&d.add(i.experienceKey);let f={client:e,settings:n,timing:r,landing:i,passed:new Set(t.passed.map(s)),answers:{...t.responses,...n.responses},resting:()=>l(e,d,n)};await c(()=>e.readyStore.getState()?!0:null,r.bootTries,r),await c(f.resting,r.startTries,r)===null&&await u(e,a,r);for(let e=0;e<r.maxHops;e++){let e=await c(f.resting,r.startTries,r);if(e===null)return{end:`no-journey`,at:null};let t=await v(f,e);if(t!==null)return t}return{end:`hop-limit`,at:null}}function b(t,n){let{name:r}=t,i=n.at===null?``:`“${n.at.stepName}”`;switch(n.end){case`arrived`:return{status:`success`,text:`Loaded ${r} on ${i}.`};case`answers-only`:return{status:`success`,text:`Loaded ${r}’s responses.`};case`needs-answer`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. ${r} has no response for a field there.`};case`invalid`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. A response there no longer passes its check.`};case`stuck`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. The experience did not move on from there.`};case`no-journey`:{let n=t.passed.at(0)?.experienceKey??t.landing?.experienceKey;return{status:`error`,text:`${r} did not load: ${t.landing?.experienceName??(n===void 0?`its experience`:e(n))} didn't open on this page.`}}case`hop-limit`:return{status:`warning`,text:`Stopped loading ${r} after too many steps. The flow may be going round in a loop.`}}}export{i as PENDING_LOAD_KEY,m as loadUrl,f as screenQuestions,p as startPersonaLoad,h as takePendingLoad,b as walkNotice,y as walkPersona};
1
+ import{deslug as e,screenName as t}from"../../derive/plain-language/plain-language.js";import{readLanding as n,readPassed as r,startOf as i}from"../../derive/personas/personas.js";import{pickJourney as a}from"../../derive/journey-pick/journey-pick.js";import"../../derive/index.js";import{getStepFields as o}from"@picked/client/form";var s=`picked-devtools-persona-load`,c=30,l={sleep:e=>new Promise(t=>{setTimeout(t,e)}),bootTries:500,openTries:20,startTries:100,advanceTries:500,returnTries:33,selfAdvanceTries:1e3,maxHops:60};function u(e){return`${e.experienceKey}/${e.stepKey}`}async function d(e,t,n){for(let r=0;r<=t;r++){let i=e();if(i!==null)return i;r<t&&await n.sleep(c)}return null}function f(e,n,r){let i=e.experienceInstances.listStore.getState().filter(e=>n.has(e.experienceKey)),o=a(i,{exclude:r.exclude,precedence:r.precedence,openOrder:r.openOrder()}),s=o?.currentStepStore.getState();return o===void 0||s==null||o.currentNodeStore.getState()?.type!==`step`||o.stateStore.getState()!==`ready`?null:{instance:o,screen:{experienceKey:o.experienceKey,stepKey:s.key},fieldKeys:s.fieldKeys,stop:{experienceName:o.experienceName,stepName:t(s.name,s.key)}}}async function p(e,t,n){await e.openExperienceByKey(t),await d(()=>e.experienceInstances.listStore.getState().some(e=>e.experienceKey===t&&e.isOpenStore.getState())?!0:null,n.openTries,n)===null&&await e.launchExperienceByKey(t,{bypassCondition:!0,initialiseOpen:!0})}function m(e,t){let n={};for(let r of t)Object.hasOwn(e,r)&&(n[r]=e[r]);return n}async function h(e,t,n){let r=e.fields.listStore.getState(),i=t.flatMap(e=>r.filter(t=>t.key===e));return(await o(e,i)).map(e=>e.key).filter(e=>!n.has(e))}function g(e,t,n){try{n.storage.setItem(s,JSON.stringify(t))}catch{return!1}return e.responses.clearAll(),e.history.restore({entries:[]}),n.navigate(n.url),!0}function _(e,t){let n=new URL(e??`${t.pathname}${t.search}`,`https://entry.invalid`);for(let[e,r]of new URLSearchParams(t.search))e.startsWith(`picked-`)&&!n.searchParams.has(e)&&n.searchParams.append(e,r);return`${n.pathname}${n.search}`}function v(e){let t;try{t=JSON.parse(e)}catch{return null}if(typeof t!=`object`||!t||Array.isArray(t))return null;let i=Reflect.get(t,`id`);return typeof i!=`string`||i.length===0?null:{id:i,stop:n(Reflect.get(t,`stop`)),passed:r(Reflect.get(t,`passed`))}}function y(e){try{let t=e.getItem(s);return e.removeItem(s),t===null?null:v(t)}catch{return null}}function b(e,t){return e===null||e.experienceKey!==t.experienceKey?!1:e.stepKey===null||e.stepKey===t.stepKey}async function x(e,t,n){let r=!1,i=0;return await d(()=>{let n=e.resting();return n===null?(r=!0,i=0,null):u(n.screen)!==t||(r&&(i+=1),i>e.timing.returnTries?!1:null)},n,e.timing)===!0}async function S(e,t){let n=await e.settings.askedOn(t.fieldKeys),r=n.filter(t=>Object.hasOwn(e.answers,t));if(r.length>0){let{errors:n}=await e.client.responses.commit(m(e.answers,r));if(n.length>0)return{end:`invalid`,at:t.stop}}if(b(e.landing,t.screen))return{end:`arrived`,at:t.stop};let i=u(t.screen);return e.settings.selfAdvancing.has(t.screen.stepKey)?await x(e,i,e.timing.selfAdvanceTries)?null:{end:`stuck`,at:t.stop}:(n.length>0?r.length===n.length:e.passed.has(i))?(t.instance.goToNextStep(),await x(e,i,e.timing.advanceTries)?null:{end:`stuck`,at:t.stop}):{end:`needs-answer`,at:t.stop}}async function C(e,t,n,r=l){let{landing:a}=t,o=i(t);if(o===null)return Object.keys(t.responses).length>0&&e.responses.register({...t.responses}),{end:`answers-only`,at:null};let s=new Set([o,...t.passed.map(e=>e.experienceKey)]);a!==null&&s.add(a.experienceKey);let c={client:e,settings:n,timing:r,landing:a,passed:new Set(t.passed.map(u)),answers:{...t.responses,...n.responses},resting:()=>f(e,s,n)};await d(()=>e.readyStore.getState()?!0:null,r.bootTries,r),await d(c.resting,r.startTries,r)===null&&await p(e,o,r);for(let e=0;e<r.maxHops;e++){let e=await d(c.resting,r.startTries,r);if(e===null)return{end:`no-journey`,at:null};let t=await S(c,e);if(t!==null)return t}return{end:`hop-limit`,at:null}}async function w(e,t,n,r=l){let a=()=>e.experienceInstances.listStore.getState().some(e=>!n.exclude.has(e.experienceKey)&&e.isOpenStore.getState())?!0:null;if(await d(()=>e.readyStore.getState()?!0:null,r.bootTries,r),await d(a,r.startTries,r)!==null)return!0;let o=i(t);return o!==null&&(await p(e,o,r),await d(a,r.openTries,r)!==null)}function T(t,n){let{name:r}=t,a=n.at===null?``:`“${n.at.stepName}”`;switch(n.end){case`arrived`:return{status:`success`,text:`Jumped to ${a} as ${r}.`};case`answers-only`:return{status:`success`,text:`Loaded ${r}’s responses.`};case`needs-answer`:return{status:`warning`,text:`Walked as ${r} as far as ${a}. ${r} has no response for a field there.`};case`invalid`:return{status:`warning`,text:`Walked as ${r} as far as ${a}. A response there no longer passes its check.`};case`stuck`:return{status:`warning`,text:`Walked as ${r} as far as ${a}. The experience did not move on from there.`};case`no-journey`:{let n=i(t);return{status:`error`,text:`${r} did not load: ${t.landing?.experienceName??(n===null?`its experience`:e(n))} didn't open on this page.`}}case`hop-limit`:return{status:`warning`,text:`Stopped walking as ${r} after too many steps. The flow may be going round in a loop.`}}}export{s as PENDING_LOAD_KEY,_ as loadUrl,w as openPersonaJourney,h as screenQuestions,g as startPersonaLoad,y as takePendingLoad,T as walkNotice,C as walkPersona};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @file A persona's route: the screens its responses take it through, worked out over the real
3
+ * flow graph and the workspace's own rules without touching the live session. Jump to step lists
4
+ * them, and the panel's builder draws them.
5
+ */
6
+ import type { FieldValue, Picked } from '@picked/client';
7
+ import { evaluateRuleLogic } from '@picked/client';
8
+ import { type BuilderWalk, type Persona, type PersonaLanding } from '../../derive';
9
+ /** The rule type the client evaluates. */
10
+ export type Rule = Parameters<typeof evaluateRuleLogic>[1];
11
+ /** The parts of a page's address a rule on `location` reads. */
12
+ export interface EntryLocation {
13
+ hash: string;
14
+ host: string;
15
+ hostname: string;
16
+ href: string;
17
+ origin: string;
18
+ pathname: string;
19
+ port: string;
20
+ protocol: string;
21
+ search: string;
22
+ }
23
+ /**
24
+ * The entry page's address, as a rule on `location` reads it.
25
+ *
26
+ * @param {string} entryUrl - The entry page: a path on this site, with its query.
27
+ * @param {string} origin - This site's origin.
28
+ * @returns {EntryLocation} The address.
29
+ * @example entryLocation('/sale?utm=spring', 'https://shop.example').pathname // '/sale'
30
+ */
31
+ export declare function entryLocation(entryUrl: string, origin: string): EntryLocation;
32
+ /**
33
+ * Evaluates rules against draft responses, on an entry page.
34
+ *
35
+ * @param {Picked} client - The client.
36
+ * @param {EntryLocation} location - The entry page's address.
37
+ * @returns {(rule: Rule, responses: Readonly<Record<string, FieldValue>>) => Promise<boolean>} The evaluator.
38
+ * @example await ruleEvaluator(client, location)(condition.logic, { purpose: 'home' })
39
+ */
40
+ export declare function ruleEvaluator(client: Picked, location: EntryLocation): (rule: Rule, responses: Readonly<Record<string, FieldValue>>) => Promise<boolean>;
41
+ /** What a route reads beyond the persona. */
42
+ export interface RouteSettings {
43
+ /** Every key a journey's screens ask. */
44
+ asked: ReadonlySet<string>;
45
+ codeOwned: ReadonlySet<string>;
46
+ selfAdvancing: ReadonlySet<string>;
47
+ /** Actions that open another journey, by action key. */
48
+ handoffs: Readonly<Record<string, string>>;
49
+ /** Experience keys never walked. */
50
+ hiddenKeys: ReadonlySet<string>;
51
+ /** Responses every walk gives when a step asks for them, over the persona's own. */
52
+ responses: Readonly<Record<string, FieldValue>>;
53
+ }
54
+ /**
55
+ * The route a persona's responses take through the experiences on this page.
56
+ *
57
+ * @param {Picked} client - The client.
58
+ * @param {Persona} persona - The persona.
59
+ * @param {RouteSettings} settings - What the walk reads beyond the persona.
60
+ * @param {{ origin: string; pathname: string; search: string }} here - This page's address, for a persona with no entry page.
61
+ * @param {string} here.origin - Its origin.
62
+ * @param {string} here.pathname - Its path.
63
+ * @param {string} here.search - Its query.
64
+ * @returns {Promise<BuilderWalk | null>} The walk, or `null` for a persona with no journey.
65
+ * @example await personaRoute(client, persona, settings, window.location)
66
+ */
67
+ export declare function personaRoute(client: Picked, persona: Persona, settings: RouteSettings, here: {
68
+ origin: string;
69
+ pathname: string;
70
+ search: string;
71
+ }): Promise<BuilderWalk | null>;
72
+ /** A screen on a persona's route. */
73
+ export interface RouteScreen extends PersonaLanding {
74
+ stepKey: string;
75
+ /** `true` on the screen the persona stops on. */
76
+ isStop: boolean;
77
+ }
78
+ /**
79
+ * The screens on a route, in order, marking the persona's own stop: the screen it names, else
80
+ * where its responses run out.
81
+ *
82
+ * @param {BuilderWalk} walk - The route.
83
+ * @param {PersonaLanding | null} landing - Where the persona stops.
84
+ * @returns {RouteScreen[]} The screens.
85
+ * @example routeScreens(walk, persona.landing)
86
+ */
87
+ export declare function routeScreens(walk: BuilderWalk, landing: PersonaLanding | null): RouteScreen[];
@@ -0,0 +1 @@
1
+ import{startOf as e}from"../../derive/personas/personas.js";import{builderGraph as t,responseKeysOf as n,walkDraft as r}from"../../derive/persona-builder/persona-builder.js";import"../../derive/index.js";import{evaluateRuleLogic as i}from"@picked/client";function a(e,t){let n=new URL(e,t);return{hash:n.hash,host:n.host,hostname:n.hostname,href:n.href,origin:n.origin,pathname:n.pathname,port:n.port,protocol:n.protocol,search:n.search}}function o(e,t){return async(n,r)=>!!await i(e,n,{responses:{...r},location:{...t}})}async function s(i,s,c,l){let u=e(s);if(u===null)return null;let d=new Map(i.fields.listStore.getState().map(e=>[e.key,e])),f=a(s.entryUrl??`${l.pathname}${l.search}`,l.origin);return r({...t(i.experiences.listStore.getState(),c.hiddenKeys),start:u,answers:{...s.responses,...c.responses},choices:s.choices,asked:c.asked,codeOwned:c.codeOwned,selfAdvancing:c.selfAdvancing,handoffs:c.handoffs,field:e=>d.get(e),evaluate:o(i,f),readsOf:n})}function c(e,t){let n=t?.stepKey==null?e.landing:t;return e.rows.flatMap(e=>e.kind===`screen`?[{experienceKey:e.experienceKey,experienceName:e.experienceName,stepKey:e.stepKey,stepName:e.stepName,isStop:n?.experienceKey===e.experienceKey&&n.stepKey===e.stepKey}]:[])}export{a as entryLocation,s as personaRoute,c as routeScreens,o as ruleEvaluator};