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

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 (40) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/plugins/devtools/DevToolsExperience.svelte +46 -15
  3. package/dist/plugins/devtools/components/AdaptedToolHost.svelte +9 -3
  4. package/dist/plugins/devtools/customTools.d.ts +16 -7
  5. package/dist/plugins/devtools/customTools.js +1 -1
  6. package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +1 -1
  7. package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -1
  8. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
  9. package/dist/plugins/devtools/derive/journey-outline/journey-outline.js +1 -1
  10. package/dist/plugins/devtools/derive/journey-tree/journey-tree.js +1 -1
  11. package/dist/plugins/devtools/derive/persona-builder/persona-builder.js +1 -1
  12. package/dist/plugins/devtools/derive/personas/personas.d.ts +8 -1
  13. package/dist/plugins/devtools/derive/personas/personas.js +1 -1
  14. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +20 -23
  15. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
  16. package/dist/plugins/devtools/derive/plain-logic/plain-logic.d.ts +1 -1
  17. package/dist/plugins/devtools/derive/plain-logic/plain-logic.js +1 -1
  18. package/dist/plugins/devtools/derive/safety-posture/safety-posture.js +1 -1
  19. package/dist/plugins/devtools/options.d.ts +22 -21
  20. package/dist/plugins/devtools/options.js +1 -1
  21. package/dist/plugins/devtools/review/answers/AnswersPanel.svelte +14 -14
  22. package/dist/plugins/devtools/review/guide/GuidePanel.svelte +1 -1
  23. package/dist/plugins/devtools/review/index.d.ts +2 -2
  24. package/dist/plugins/devtools/review/journey/JourneyMap.svelte +16 -14
  25. package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +20 -20
  26. package/dist/plugins/devtools/review/journey/JourneyTimeline.svelte +22 -21
  27. package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +12 -12
  28. package/dist/plugins/devtools/review/personas/PersonaCard.svelte +7 -2
  29. package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +77 -15
  30. package/dist/plugins/devtools/review/personas/load-persona.d.ts +2 -2
  31. package/dist/plugins/devtools/review/personas/load-persona.js +1 -1
  32. package/dist/plugins/devtools/review/session/SessionPanel.svelte +11 -11
  33. package/dist/plugins/devtools/review/variations/VariationsPanel.svelte +12 -13
  34. package/dist/plugins/devtools/screens/custom-tools/CustomToolsScreen.svelte +14 -22
  35. package/dist/plugins/devtools/screens/experiences/ExperienceDetailsScreen.svelte +2 -2
  36. package/dist/plugins/devtools/screens/features/FeaturesListScreen.svelte +3 -3
  37. package/dist/plugins/devtools/sidebar/Sidebar.svelte +38 -4
  38. package/dist/plugins/devtools/sidebar/nav.d.ts +32 -10
  39. package/dist/plugins/devtools/sidebar/nav.js +1 -1
  40. package/package.json +2 -2
@@ -1,19 +1,19 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * @file The Journey map tab: any journey's flow in plain English — the Developer group's
4
- * experience tree without the node ids or the raw JSON Logic. It opens on the journey in front
5
- * of the reviewer, and the picker reaches every other experience in the workspace.
3
+ * @file The Flow tab: any experience's flow in plain English — the Developer group's
4
+ * experience tree without the node ids or the raw JSON Logic. It opens on the experience in
5
+ * front of the reviewer, and the picker reaches every other experience in the workspace.
6
6
  *
7
- * Navigation is BACKWARDS ONLY, and only in the journey that is OPEN. A screen already visited is
8
- * a plain `goToStepByKey`: the flow has been through every action in between, so returning to it
9
- * is safe. A screen AHEAD is rendered but not clickable — jumping forward skips the actions that
10
- * make it renderable, and the screen it lands on can render an error state no visitor could
11
- * reach. Forward motion belongs to the journey itself, or to a custom tool that replays one.
7
+ * Navigation is BACKWARDS ONLY, and only in the experience that is OPEN. A step already visited
8
+ * is a plain `goToStepByKey`: the flow has been through every action in between, so returning to
9
+ * it is safe. A step AHEAD is rendered but not clickable — jumping forward skips the actions that
10
+ * make it renderable, and the step it lands on can render an error state no visitor could reach.
11
+ * Forward motion belongs to the experience itself, or to a custom tool that replays one.
12
12
  *
13
13
  * THREE VIEWS, ONE GRAPH. The outline answers "where am I, and why did it go there"; the map
14
14
  * answers "what shape is this, and where does each branch end"; the timeline answers "in what
15
15
  * order, and where do the branches meet again". All three read the same `treeStore` through the
16
- * same vocabulary modules, so a screen is named identically in each and they cannot drift.
16
+ * same vocabulary modules, so a step is named identically in each and they cannot drift.
17
17
  */
18
18
  import type { Picked } from '@picked/client'
19
19
  import { Badge, Banner, Button, Card, EmptyState } from '../../ui'
@@ -155,14 +155,14 @@
155
155
  * @param {OutlineRow} row - The row to explain.
156
156
  * @returns {string | undefined} The reason, or undefined when there is nothing to explain.
157
157
  * @example
158
- * jumpReason(aheadRow) // "You haven't answered enough to get here yet."
158
+ * jumpReason(aheadRow) // "Your responses don't reach this step yet."
159
159
  */
160
160
  function jumpReason(row: OutlineRow): string | undefined {
161
161
  if (row.kind !== 'screen') return undefined
162
162
  if (posture.reason !== null) return posture.reason
163
- if (!isOpen) return "This journey isn't open, so there is nowhere to go back to."
164
- if (row.state === 'current') return "You're on this screen."
165
- if (row.state === 'ahead') return "You haven't answered enough to get here yet."
163
+ if (!isOpen) return "This experience isn't open, so there is nowhere to go back to."
164
+ if (row.state === 'current') return "You're on this step."
165
+ if (row.state === 'ahead') return "Your responses don't reach this step yet."
166
166
  return undefined
167
167
  }
168
168
 
@@ -219,13 +219,13 @@
219
219
  <div class="journey">
220
220
  {#if selectedKey === null}
221
221
  <EmptyState
222
- message="No journeys yet"
222
+ message="No experiences yet"
223
223
  hint="The workspace has not loaded its experiences. Reload the page if this does not change."
224
224
  />
225
225
  {:else}
226
226
  <!-- One row: how to look on the left, what to look at on the right. -->
227
227
  <div class="controls">
228
- <div class="views" role="group" aria-label="Journey view">
228
+ <div class="views" role="group" aria-label="Flow view">
229
229
  <button
230
230
  type="button"
231
231
  class="view"
@@ -261,12 +261,12 @@
261
261
  <div class="picker">
262
262
  {#if followed !== null && !isOpen}
263
263
  <Button variant="ghost" size="small" onclick={() => (picked = null)}>
264
- Back to the open journey
264
+ Back to the open experience
265
265
  </Button>
266
266
  {/if}
267
267
  <select
268
268
  class="picker-select"
269
- aria-label="Journey"
269
+ aria-label="Experience"
270
270
  value={selectedKey}
271
271
  onchange={pick}
272
272
  data-testid="devtools-journey-picker"
@@ -287,8 +287,8 @@
287
287
  {#if rows.length === 0}
288
288
  <Card sectioned>
289
289
  <EmptyState
290
- message="This journey has no screens yet"
291
- hint="Its flow has not loaded, or it is built entirely from background steps."
290
+ message="This experience has no steps yet"
291
+ hint="Its flow has not loaded, or it is built entirely from actions."
292
292
  />
293
293
  </Card>
294
294
  {:else if layout !== null}
@@ -343,7 +343,7 @@
343
343
  {:else if row.kind === 'arm'}
344
344
  <span class="arm">{row.label}</span>
345
345
  {:else if row.kind === 'end'}
346
- <span class="end-row">⇥ Ends: {row.label} (behind the scenes)</span>
346
+ <span class="end-row">⇥ Ends: {row.label} (action)</span>
347
347
  {:else if row.kind === 'journey'}
348
348
  <span class="journey-row">
349
349
  <span class="marker muted" aria-hidden="true"></span>
@@ -1,24 +1,25 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * @file The journey drawn as a TIMELINE — `git log --graph` for a flow. One row per node, top to
4
- * bottom; branches run in lanes beside the rows and MERGE back into the node they meet, so a
5
- * screen many branches reach is one row with lines converging on it, not a tree full of dashed
6
- * jumps. `derive/journey-timeline` owns the layout; this file owns selection and scrolling only.
3
+ * @file An experience's flow drawn as a TIMELINE — `git log --graph` for a flow. One row per
4
+ * node, top to bottom; branches run in lanes beside the rows and MERGE back into the node they
5
+ * meet, so a step many branches reach is one row with lines converging on it, not a tree full of
6
+ * dashed jumps. `derive/journey-timeline` owns the layout; this file owns selection and scrolling
7
+ * only.
7
8
  *
8
9
  * A LOOP is the one thing a git graph never has. It stays off the drawing and rides on its row as
9
10
  * a "↩ back to …" note, and the details pane can take the reviewer to its target.
10
11
  *
11
12
  * COLOUR IS THE BRANCH, as in a git tool: every line and dot takes its branch's colour, so the
12
13
  * eye can follow one route down the lanes. That frees the node KIND to be a SHAPE — a circle for
13
- * a screen, a diamond for a decision, a square for another journey. Actions are not drawn at all;
14
- * where a branch ends on one, the row names it.
14
+ * a step, a diamond for a condition, a square for another experience. Actions are not drawn at
15
+ * all; where a branch ends on one, the row names it.
15
16
  *
16
17
  * ROWS ARE HTML, LINES ARE SVG, the same split as the map: each row is a real `<button>` the full
17
18
  * width of the timeline, and the graph column is painted over its left edge with
18
19
  * `pointer-events: none`, so clicking a dot or its label selects the same row.
19
20
  */
20
21
  import { tick } from 'svelte'
21
- import { COLOURS, ROW_HEIGHT } from '../../derive'
22
+ import { COLOURS, NODE_LABELS, ROW_HEIGHT } from '../../derive'
22
23
  import type { TimelineLayout, TimelineNode, TreeNodeKind } from '../../derive'
23
24
  import TimelineMarker from './TimelineMarker.svelte'
24
25
 
@@ -32,11 +33,11 @@
32
33
  const { layout, canNavigate, onJump }: JourneyTimelineProps = $props()
33
34
 
34
35
  const KIND_LABELS: Record<TreeNodeKind, string> = {
35
- start: 'Start',
36
- screen: 'Screen',
37
- decision: 'Decision',
38
- system: 'Behind the scenes',
39
- journey: 'Opens another journey',
36
+ start: NODE_LABELS.origin,
37
+ screen: NODE_LABELS.step,
38
+ decision: NODE_LABELS.condition,
39
+ system: NODE_LABELS.action,
40
+ journey: NODE_LABELS.experience,
40
41
  }
41
42
 
42
43
  /** Marker size per kind — the start a little larger, as on the map. */
@@ -242,7 +243,7 @@
242
243
  >
243
244
  {/each}
244
245
  {#each node.ends as end (end.key)}
245
- <span class="end" title="Hands off to {end.label}, behind the scenes"
246
+ <span class="end" title="Hands off to the {end.label} action"
246
247
  >⇥ {end.armLabel === null ? '' : `${end.armLabel}: `}{end.label}</span
247
248
  >
248
249
  {/each}
@@ -331,13 +332,13 @@
331
332
  {#if end.armLabel !== null}
332
333
  <span class="arm arm-{end.arm ?? 'none'}">{end.armLabel}</span>
333
334
  {/if}
334
- <span class="end-text">Ends: {end.label} (behind the scenes)</span>
335
+ <span class="end-text">Ends: {end.label} (action)</span>
335
336
  </li>
336
337
  {/each}
337
338
  </ul>
338
339
  </div>
339
340
  {:else}
340
- <p class="text">The journey ends here.</p>
341
+ <p class="text">The experience ends here.</p>
341
342
  {/if}
342
343
 
343
344
  {#if selectedNode.rule !== null}
@@ -347,7 +348,7 @@
347
348
 
348
349
  {#if canJump(selectedNode)}
349
350
  <button type="button" class="action" onclick={() => goBack(selectedNode)}>
350
- Go back to this screen
351
+ Go back to this step
351
352
  </button>
352
353
  {/if}
353
354
  {/if}
@@ -355,14 +356,14 @@
355
356
  </div>
356
357
 
357
358
  <p class="legend">
358
- <span class="key-swatch kind-start"></span> Start
359
- <span class="key-swatch kind-screen"></span> Screen
360
- <span class="key-swatch kind-decision"></span> Decision
361
- <span class="key-swatch kind-journey"></span> Opens another journey
359
+ <span class="key-swatch kind-start"></span> {KIND_LABELS.start}
360
+ <span class="key-swatch kind-screen"></span> {KIND_LABELS.screen}
361
+ <span class="key-swatch kind-decision"></span> {KIND_LABELS.decision}
362
+ <span class="key-swatch kind-journey"></span> {KIND_LABELS.journey}
362
363
  <span class="key-swatch seen"></span> Seen
363
364
  <span class="key-swatch current"></span> You are here
364
365
  <span
365
- >· each colour is one branch · a decision's Yes runs straight on · ↩ loops back · ⇥ hands off</span
366
+ >· each colour is one branch · a condition's Yes runs straight on · ↩ loops back · ⇥ hands off</span
366
367
  >
367
368
  </p>
368
369
  </div>
@@ -215,7 +215,7 @@
215
215
  if (typeof result === 'string' || result === false) {
216
216
  errors = {
217
217
  ...errors,
218
- [field.key]: typeof result === 'string' ? result : 'This answer is not accepted.',
218
+ [field.key]: typeof result === 'string' ? result : 'This response is not accepted.',
219
219
  }
220
220
  return
221
221
  }
@@ -245,13 +245,13 @@
245
245
  function endText(row: Extract<BuilderRow, { kind: 'end' }>): string {
246
246
  switch (row.reason) {
247
247
  case 'loop':
248
- return `The journey goes back to “${row.name ?? 'an earlier screen'}” here.`
248
+ return `The flow goes back to “${row.name ?? 'an earlier step'}” here.`
249
249
  case 'handoff':
250
- return `${row.name ?? 'Another journey'} takes over here, outside this build.`
250
+ return `${row.name ?? 'Another experience'} takes over here, outside this build.`
251
251
  case 'limit':
252
- return 'The journey is too long to walk here, so it stops.'
252
+ return 'The flow is too long to walk here, so it stops.'
253
253
  case 'end':
254
- return 'The journey ends here.'
254
+ return 'The experience ends here.'
255
255
  }
256
256
  }
257
257
 
@@ -274,18 +274,18 @@
274
274
 
275
275
  <div class="builder" data-testid="devtools-persona-builder">
276
276
  {#if journeyOptions.length === 0}
277
- <Text size="small" color="subdued">This page has no journeys to build a persona in.</Text>
277
+ <Text size="small" color="subdued">This page has no experiences to build a persona in.</Text>
278
278
  {:else}
279
279
  <Select
280
280
  label="Starts in"
281
281
  name="devtools-builder-start"
282
282
  options={journeyOptions}
283
283
  value={start}
284
- placeholder="Pick a journey"
284
+ placeholder="Pick an experience"
285
285
  onchange={onStartChange}
286
286
  />
287
287
  <Text size="small" color="subdued">
288
- Answer each screen; the next one appears below. Where the site's own code decides the route,
288
+ Complete each step; the next one appears below. Where the site's own code decides the route,
289
289
  pick the outcome to follow. The real outcome decides when the persona loads.
290
290
  </Text>
291
291
 
@@ -351,21 +351,21 @@
351
351
  </div>
352
352
  {:else if kind === 'system'}
353
353
  <Text size="small" color="subdued">
354
- This is answered on its own screen. The persona stops here, so load it and
355
- answer it there.
354
+ This is set on its own step. The persona stops here, so load it and set it
355
+ there.
356
356
  </Text>
357
357
  {/if}
358
358
  </div>
359
359
  {/if}
360
360
  {:else}
361
361
  <Text size="small" color="subdued">
362
- Nothing to answer here. The customer continues.
362
+ Nothing to fill in here. The visitor continues.
363
363
  </Text>
364
364
  {/each}
365
365
  </li>
366
366
  {:else if row.kind === 'choice'}
367
367
  <li class="decision" data-testid="devtools-builder-decision">
368
- <Text size="small">The site decides: {row.question}</Text>
368
+ <Text size="small">The site's code decides: {row.question}</Text>
369
369
  <div class="choices">
370
370
  <ChoiceButton
371
371
  label="Yes"
@@ -10,16 +10,18 @@
10
10
  /** True while this persona is loading. */
11
11
  loading: boolean
12
12
  onLoad: () => void
13
+ /** Opens it in the form; absent for a persona the site defines. */
14
+ onEdit?: () => void
13
15
  /** Deletes it; absent for a persona the site defines. */
14
16
  onDelete?: () => void
15
17
  }
16
18
 
17
- const { persona, loadReason, loading, onLoad, onDelete }: PersonaCardProps = $props()
19
+ const { persona, loadReason, loading, onLoad, onEdit, onDelete }: PersonaCardProps = $props()
18
20
 
19
21
  let confirming = $state(false)
20
22
 
21
23
  const answers = $derived(Object.keys(persona.responses).length)
22
- const answerLabel = $derived(answers === 1 ? '1 answer' : `${answers} answers`)
24
+ const answerLabel = $derived(answers === 1 ? '1 response' : `${answers} responses`)
23
25
  const summary = $derived(
24
26
  persona.load === null
25
27
  ? `${answerLabel} · ${describeLanding(persona.landing)}`
@@ -52,6 +54,9 @@
52
54
  >
53
55
  Load
54
56
  </Button>
57
+ {#if onEdit}
58
+ <Button variant="ghost" size="small" onclick={onEdit}>Edit</Button>
59
+ {/if}
55
60
  {#if onDelete}
56
61
  <Button variant="ghost" size="small" onclick={() => (confirming = true)}>Delete</Button>
57
62
  {/if}
@@ -5,7 +5,8 @@
5
5
  * answering the journey's questions here, screen by screen, or from where they are on the page.
6
6
  * A persona keeps the answers a customer gives on fields they can see, the screens moved on from,
7
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.
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.
9
10
  */
10
11
  import type { FieldValue, Picked } from '@picked/client'
11
12
  import { getStepFields } from '@picked/client/form'
@@ -27,6 +28,8 @@
27
28
  type RouteEntry,
28
29
  type SafetyPosture,
29
30
  } from '../../derive'
31
+ import AdaptedToolHost from '../../components/AdaptedToolHost.svelte'
32
+ import type { CustomTool } from '../../customTools'
30
33
  import type { JourneyView } from '../../session'
31
34
  import { loadUrl, startPersonaLoad } from './load-persona'
32
35
  import PersonaBuilder from './PersonaBuilder.svelte'
@@ -52,6 +55,8 @@
52
55
  selfAdvancingSteps: ReadonlySet<string>
53
56
  handoffs: Readonly<Record<string, string>>
54
57
  }
58
+ /** The site's tools placed in this tab. */
59
+ tools?: readonly CustomTool[]
55
60
  /** How the last load's walk ended; `null` when no load ran on this page. */
56
61
  walkNotice: Notice | null
57
62
  onWalkNoticeDismiss: () => void
@@ -83,6 +88,7 @@
83
88
  hiddenKeys,
84
89
  codeOwned,
85
90
  personaWalk,
91
+ tools = [],
86
92
  walkNotice,
87
93
  onWalkNoticeDismiss,
88
94
  getStorage = () => window.localStorage,
@@ -108,6 +114,8 @@
108
114
 
109
115
  let saved = $state<Persona[]>(readSaved())
110
116
  let creating = $state(false)
117
+ /** The saved persona the form edits; `null` while it makes a new one. */
118
+ let editingId = $state<string | null>(null)
111
119
  /** How a new persona is made: answered here, or taken from where the reviewer is. */
112
120
  let mode = $state<'build' | 'capture'>('build')
113
121
  let name = $state('')
@@ -125,7 +133,7 @@
125
133
  /** The answers Save would keep; `null` while they are read. */
126
134
  let captured = $state<Record<string, FieldValue> | null>(null)
127
135
 
128
- const isEmpty = $derived(personas.length === 0 && saved.length === 0)
136
+ const isEmpty = $derived(personas.length === 0 && saved.length === 0 && tools.length === 0)
129
137
  const postureReason = $derived(posture.canEditAnswers ? undefined : (posture.reason ?? undefined))
130
138
 
131
139
  /**
@@ -154,19 +162,19 @@
154
162
  const buildSummary = $derived.by(() => {
155
163
  if (buildWalk === null) return 'Working out the route…'
156
164
  const count = Object.keys(buildWalk.answers).length
157
- const answers = count === 1 ? '1 answer' : `${count} answers`
165
+ const answers = count === 1 ? '1 response' : `${count} responses`
158
166
  const stop = buildWalk.landing
159
167
  if (stop === null) return `Saves ${answers}.`
160
168
  const screen = stop.stepName ?? deslug(stop.stepKey ?? '')
161
- return `Saves ${answers}. Loading it walks the journey to ${screen}.`
169
+ return `Saves ${answers}. Loading it walks the experience to ${screen}.`
162
170
  })
163
171
 
164
172
  const captureSummary = $derived.by(() => {
165
- if (captured === null) return 'Reading your answers…'
173
+ if (captured === null) return 'Reading your responses…'
166
174
  const count = Object.keys(captured).length
167
- const answers = count === 1 ? '1 answer' : `${count} answers`
175
+ const answers = count === 1 ? '1 response' : `${count} responses`
168
176
  if (landing === null)
169
- return `Saves ${answers}. No journey is open, so it loads the answers only.`
177
+ return `Saves ${answers}. No experience is open, so it loads the responses only.`
170
178
  const journeyName = landing.experienceName ?? deslug(landing.experienceKey)
171
179
  if (landing.stepKey === null) return `Saves ${answers}. Loading it walks ${journeyName}.`
172
180
  const screen = landing.stepName ?? deslug(landing.stepKey)
@@ -240,6 +248,7 @@
240
248
  const responses = client.responses.getAllStore.getState()
241
249
  const answeredHere = [...asked].some((key) => !isBlank(responses[key]))
242
250
  creating = true
251
+ editingId = null
243
252
  mode = answeredHere ? 'capture' : 'build'
244
253
  name = ''
245
254
  description = ''
@@ -254,6 +263,42 @@
254
263
  buildWalk = null
255
264
  }
256
265
 
266
+ /**
267
+ * Opens the form on a saved persona, to answer its questions again here. The walk starts in its
268
+ * first journey with its answers and its picks for the site's own decisions, so changing an
269
+ * answer re-routes the screens after it.
270
+ *
271
+ * @param {Persona} persona - The persona to edit.
272
+ * @example startEdit(persona)
273
+ */
274
+ function startEdit(persona: Persona): void {
275
+ creating = true
276
+ editingId = persona.id
277
+ mode = 'build'
278
+ name = persona.name
279
+ description = persona.description ?? ''
280
+ entryText = persona.entryUrl ?? `${window.location.pathname}${window.location.search}`
281
+ nameError = null
282
+ entryError = null
283
+ notice = null
284
+ captured = null
285
+ buildStart =
286
+ persona.passed.at(0)?.experienceKey ?? persona.landing?.experienceKey ?? defaultStart()
287
+ buildAnswers = { ...persona.responses }
288
+ buildChoices = { ...persona.choices }
289
+ buildWalk = null
290
+ }
291
+
292
+ /**
293
+ * Closes the form without saving.
294
+ *
295
+ * @example cancel()
296
+ */
297
+ function cancel(): void {
298
+ creating = false
299
+ editingId = null
300
+ }
301
+
257
302
  /**
258
303
  * What Save keeps, from the build or from where the reviewer is.
259
304
  *
@@ -262,7 +307,7 @@
262
307
  * @example await capture('/compare')
263
308
  */
264
309
  async function capture(entryUrl: string | null): Promise<PersonaCapture | null> {
265
- const id = newId()
310
+ const id = editingId ?? newId()
266
311
  const savedAt = new Date().toISOString()
267
312
  if (mode === 'build') {
268
313
  if (buildWalk === null) return null
@@ -270,6 +315,7 @@
270
315
  responses: buildWalk.answers,
271
316
  journey: buildWalk.landing,
272
317
  passed: buildWalk.passed,
318
+ choices: buildChoices,
273
319
  entryUrl: entryUrl ?? '',
274
320
  id,
275
321
  savedAt,
@@ -287,7 +333,8 @@
287
333
  }
288
334
 
289
335
  /**
290
- * Saves where the reviewer is as a new persona, in this browser.
336
+ * Saves the form's persona in this browser: a new one at the end of the list, an edited one in
337
+ * its place.
291
338
  *
292
339
  * @returns {Promise<void>} Settles once it is saved or refused.
293
340
  * @example await save()
@@ -307,11 +354,15 @@
307
354
  return
308
355
  }
309
356
 
310
- saved = [...saved, result.persona]
357
+ const edited = editingId !== null
358
+ saved = edited
359
+ ? saved.map((entry) => (entry.id === result.persona.id ? result.persona : entry))
360
+ : [...saved, result.persona]
311
361
  creating = false
362
+ editingId = null
312
363
  const stored = writeSavedPersonas(getStorage, client.projectId, saved)
313
364
  notice = stored
314
- ? { status: 'success', text: `Saved ${result.persona.name}.` }
365
+ ? { status: 'success', text: `${edited ? 'Updated' : 'Saved'} ${result.persona.name}.` }
315
366
  : {
316
367
  status: 'warning',
317
368
  text: `${result.persona.name} is here until you reload. This browser would not let it be saved.`,
@@ -387,14 +438,14 @@
387
438
  <Card>
388
439
  <div class="form">
389
440
  <Stack direction="vertical" gap="medium">
390
- <Text weight="semibold">New persona</Text>
441
+ <Text weight="semibold">{editingId === null ? 'New persona' : 'Edit persona'}</Text>
391
442
  <RadioGroup
392
443
  label="Make it by"
393
444
  name="persona-mode"
394
445
  orientation="horizontal"
395
446
  value={mode}
396
447
  options={[
397
- { value: 'build', label: 'Answering the questions here' },
448
+ { value: 'build', label: 'Filling in the fields here' },
398
449
  { value: 'capture', label: 'Using where I am now' },
399
450
  ]}
400
451
  onchange={(value) => (mode = value === 'capture' ? 'capture' : 'build')}
@@ -452,7 +503,7 @@
452
503
  </Text>
453
504
  <div class="form-actions">
454
505
  <Button variant="primary" size="small" onclick={() => void save()}>Save</Button>
455
- <Button variant="ghost" size="small" onclick={() => (creating = false)}>Cancel</Button>
506
+ <Button variant="ghost" size="small" onclick={cancel}>Cancel</Button>
456
507
  </div>
457
508
  </Stack>
458
509
  </div>
@@ -461,7 +512,7 @@
461
512
  <div class="empty">
462
513
  <EmptyState
463
514
  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."
515
+ hint="Walk the experience the way a visitor would, then save it here to load it again in one click."
465
516
  />
466
517
  <Button variant="primary" onclick={startCreate}>Create</Button>
467
518
  </div>
@@ -494,11 +545,22 @@
494
545
  loadReason={loadReason(persona)}
495
546
  loading={loadingId === persona.id}
496
547
  onLoad={() => void load(persona)}
548
+ onEdit={() => startEdit(persona)}
497
549
  onDelete={() => remove(persona)}
498
550
  />
499
551
  {/each}
500
552
  </section>
501
553
  {/if}
554
+
555
+ {#each tools as tool (tool.id)}
556
+ <section class="group" aria-label={tool.label} data-testid="devtools-persona-tool-{tool.id}">
557
+ <h3 class="group-title">{tool.label}</h3>
558
+ {#if tool.description}
559
+ <Text size="small" color="subdued">{tool.description}</Text>
560
+ {/if}
561
+ <AdaptedToolHost {tool} flush />
562
+ </section>
563
+ {/each}
502
564
  </div>
503
565
 
504
566
  <style>
@@ -83,8 +83,8 @@ export interface WalkSettings {
83
83
  openOrder: () => readonly string[];
84
84
  /** Step keys that move on by themselves, such as a loader. The walk waits on them. */
85
85
  selfAdvancing: ReadonlySet<string>;
86
- /** Answers every walk gives when a screen asks them, over the persona's own. */
87
- answers: Readonly<Record<string, FieldValue>>;
86
+ /** Responses every walk gives when a step asks for their fields, over the persona's own. */
87
+ responses: Readonly<Record<string, FieldValue>>;
88
88
  /**
89
89
  * The keys a screen asks: its fields that show now, minus the code-owned ones.
90
90
  *
@@ -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.answers},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 answers.`};case`needs-answer`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. ${r} has no answer for that screen.`};case`invalid`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. An answer there no longer passes its check.`};case`stuck`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. The journey 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 journey`:e(n))} didn't open on this page.`}}case`hop-limit`:return{status:`warning`,text:`Stopped loading ${r} after too many screens. The journey 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{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};
@@ -16,7 +16,7 @@
16
16
  journey: JourneyView | null
17
17
  /** Every screen submitted since the session record started, oldest first. */
18
18
  history: readonly HistoryEntry[]
19
- /** Opens the Answers tab, so the answer summary leads somewhere it can be acted on. */
19
+ /** Opens the Responses tab, so the response summary leads somewhere it can be acted on. */
20
20
  onShowAnswers: () => void
21
21
  }
22
22
 
@@ -65,18 +65,18 @@
65
65
  {#if journey === null}
66
66
  <Text size="medium">Nothing is open yet.</Text>
67
67
  <Text size="small" color="subdued">
68
- Start a journey on the page, and this will follow you.
68
+ Start an experience on the page, and this will follow you.
69
69
  </Text>
70
70
  {:else}
71
71
  <Stack direction="vertical" gap="none">
72
- <Text size="large" weight="semibold">{journey.stepName ?? 'Moving between screens'}</Text>
72
+ <Text size="large" weight="semibold">{journey.stepName ?? 'Moving between steps'}</Text>
73
73
  <Text size="small" color="subdued">in {journey.experienceName}</Text>
74
74
  </Stack>
75
75
  {#if journey.inTransit}
76
76
  <!-- The engine is transiting a condition or action node, so the recorded step is the one
77
77
  just LEFT. Saying so stops a "stuck on loading" report being filed against it. -->
78
78
  <Text size="small" color="subdued">
79
- Still working — the next screen has not settled yet.
79
+ Still working — the next step has not settled yet.
80
80
  </Text>
81
81
  {/if}
82
82
  {/if}
@@ -111,24 +111,24 @@
111
111
  <Card>
112
112
  <Stack direction="vertical" gap="small">
113
113
  <Stack direction="horizontal" gap="small" align="space-between" justify="center">
114
- <Text size="caption" color="subdued" weight="semibold">WHAT YOU ANSWERED</Text>
114
+ <Text size="caption" color="subdued" weight="semibold">YOUR RESPONSES</Text>
115
115
  {#if rows.length > 0}
116
- <Button variant="ghost" size="small" onclick={onShowAnswers}>Change an answer</Button>
116
+ <Button variant="ghost" size="small" onclick={onShowAnswers}>Change a response</Button>
117
117
  {/if}
118
118
  </Stack>
119
119
 
120
120
  {#if rows.length === 0}
121
121
  <EmptyState
122
- message="Nothing answered yet"
123
- hint="Each screen you answer is listed here, in order. Answers from before this page loaded are on the Answers tab."
122
+ message="No responses yet"
123
+ hint="Each step you submit is listed here, in order. Responses from before this page loaded are on the Responses tab."
124
124
  />
125
125
  {:else}
126
126
  <table class="history" data-testid="devtools-session-answers">
127
127
  <thead>
128
128
  <tr>
129
- <th scope="col">Screen</th>
130
- <th scope="col">Question</th>
131
- <th scope="col">Answer</th>
129
+ <th scope="col">Step</th>
130
+ <th scope="col">Field</th>
131
+ <th scope="col">Response</th>
132
132
  </tr>
133
133
  </thead>
134
134
  <tbody>