@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,50 @@
1
+ <script lang="ts">
2
+ /** @file One choice in the persona builder: a toggle that shows whether it is picked. */
3
+ interface ChoiceButtonProps {
4
+ label: string
5
+ selected: boolean
6
+ testId: string
7
+ onclick: () => void
8
+ }
9
+
10
+ const { label, selected, testId, onclick }: ChoiceButtonProps = $props()
11
+ </script>
12
+
13
+ <button
14
+ type="button"
15
+ class="choice"
16
+ class:selected
17
+ aria-pressed={selected}
18
+ data-testid={testId}
19
+ {onclick}
20
+ >
21
+ {label}
22
+ </button>
23
+
24
+ <style>
25
+ .choice {
26
+ padding: var(--space-1) var(--space-3);
27
+ border: 1px solid var(--color-gray-300);
28
+ border-radius: var(--radius-md);
29
+ background: var(--color-white);
30
+ color: var(--color-gray-900);
31
+ font: inherit;
32
+ font-size: var(--text-sm);
33
+ cursor: pointer;
34
+ }
35
+
36
+ .choice:hover {
37
+ border-color: var(--color-gray-500);
38
+ }
39
+
40
+ .choice.selected {
41
+ border-color: var(--color-blue-600);
42
+ background: color-mix(in srgb, var(--color-blue-600) 8%, var(--color-white));
43
+ color: var(--color-blue-700);
44
+ }
45
+
46
+ .choice:focus-visible {
47
+ outline: 2px solid var(--color-blue-500);
48
+ outline-offset: 1px;
49
+ }
50
+ </style>
@@ -0,0 +1,450 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file Builds a persona in the panel: the journey's screens, one after another, each with the
4
+ * questions a customer is asked there. Answering a screen shows the next one below it, and
5
+ * changing an earlier answer re-routes the screens after it. The walk is `walkDraft`'s, over the
6
+ * real flow graph and the workspace's own rules; nothing reaches the live session.
7
+ */
8
+ import type { Field, FieldValue, Picked } from '@picked/client'
9
+ import { evaluateRuleLogic } from '@picked/client'
10
+ import { Select, Text, TextField } from '../../ui'
11
+ import {
12
+ builderGraph,
13
+ kindOf,
14
+ questionOf,
15
+ responseKeysOf,
16
+ screenName,
17
+ walkDraft,
18
+ type BuilderRow,
19
+ type BuilderWalk,
20
+ } from '../../derive'
21
+ import ChoiceButton from './ChoiceButton.svelte'
22
+
23
+ /** The rule type the client evaluates. */
24
+ type Rule = Parameters<typeof evaluateRuleLogic>[1]
25
+
26
+ interface PersonaBuilderProps {
27
+ client: Picked
28
+ /** The journey the walk starts in. */
29
+ start: string
30
+ /** The draft answers, by field key. */
31
+ answers: Readonly<Record<string, FieldValue>>
32
+ /** The reviewer's pick on each outcome the site's code decides, by row id. */
33
+ choices: Readonly<Record<string, boolean>>
34
+ /** The page a load starts on, so a rule on the page's address reads that page. */
35
+ entryUrl: string
36
+ /** Every key a journey's screens ask. */
37
+ asked: ReadonlySet<string>
38
+ codeOwned: ReadonlySet<string>
39
+ selfAdvancing: ReadonlySet<string>
40
+ /** Actions that open another journey, by action key. */
41
+ handoffs: Readonly<Record<string, string>>
42
+ /** Experience keys never built in. */
43
+ hiddenKeys: ReadonlySet<string>
44
+ onStartChange: (key: string) => void
45
+ onAnswersChange: (answers: Record<string, FieldValue>) => void
46
+ onChoicesChange: (choices: Record<string, boolean>) => void
47
+ /** Hears each walk as it lands. */
48
+ onWalk: (walk: BuilderWalk) => void
49
+ }
50
+
51
+ const {
52
+ client,
53
+ start,
54
+ answers,
55
+ choices,
56
+ entryUrl,
57
+ asked,
58
+ codeOwned,
59
+ selfAdvancing,
60
+ handoffs,
61
+ hiddenKeys,
62
+ onStartChange,
63
+ onAnswersChange,
64
+ onChoicesChange,
65
+ onWalk,
66
+ }: PersonaBuilderProps = $props()
67
+
68
+ const experiencesStore = $derived(client.experiences.listStore)
69
+ const fieldsStore = $derived(client.fields.listStore)
70
+ const fields = $derived(new Map($fieldsStore.map((field) => [field.key, field])))
71
+ const graph = $derived(builderGraph<Rule>($experiencesStore, hiddenKeys))
72
+ const journeyOptions = $derived(
73
+ [...graph.journeys.values()].map((journey) => ({ value: journey.key, label: journey.name })),
74
+ )
75
+
76
+ /** The entry page's address, as a rule on `location` reads it. */
77
+ const location = $derived.by(() => {
78
+ const url = new URL(entryUrl, window.location.origin)
79
+ return {
80
+ hash: url.hash,
81
+ host: url.host,
82
+ hostname: url.hostname,
83
+ href: url.href,
84
+ origin: url.origin,
85
+ pathname: url.pathname,
86
+ port: url.port,
87
+ protocol: url.protocol,
88
+ search: url.search,
89
+ }
90
+ })
91
+
92
+ /**
93
+ * Evaluates a rule against draft answers, on the entry page.
94
+ *
95
+ * @param {Rule} rule - The rule.
96
+ * @param {Readonly<Record<string, FieldValue>>} responses - The answers it reads.
97
+ * @returns {Promise<boolean>} Whether it holds.
98
+ * @example await evaluate(condition.logic, { purpose: 'home' })
99
+ */
100
+ async function evaluate(
101
+ rule: Rule,
102
+ responses: Readonly<Record<string, FieldValue>>,
103
+ ): Promise<boolean> {
104
+ const result = await evaluateRuleLogic(client, rule, { responses: { ...responses }, location })
105
+ return Boolean(result)
106
+ }
107
+
108
+ let walk = $state.raw<BuilderWalk | null>(null)
109
+
110
+ // Each change walks again; a slower, older walk never lands over a newer one.
111
+ let ticket = 0
112
+ $effect(() => {
113
+ const mine = ++ticket
114
+ void walkDraft<Rule>({
115
+ ...graph,
116
+ start,
117
+ answers,
118
+ choices,
119
+ asked,
120
+ codeOwned,
121
+ selfAdvancing,
122
+ handoffs,
123
+ field: (key) => fields.get(key),
124
+ evaluate,
125
+ readsOf: responseKeysOf,
126
+ }).then((result) => {
127
+ if (mine !== ticket) return
128
+ walk = result
129
+ onWalk(result)
130
+ })
131
+ })
132
+
133
+ const rows: BuilderRow[] = $derived(walk?.rows ?? [])
134
+ const nextId = $derived(
135
+ rows.findLast((row) => row.kind === 'screen' && !row.complete)?.id ?? null,
136
+ )
137
+
138
+ // The next screen appears below the one just answered, often under the panel's fold.
139
+ const rowElements: Record<string, HTMLLIElement | undefined> = $state({})
140
+ $effect(() => {
141
+ if (nextId !== null) rowElements[nextId]?.scrollIntoView({ block: 'nearest' })
142
+ })
143
+
144
+ let errors = $state<Record<string, string>>({})
145
+
146
+ /**
147
+ * Sets one draft answer, or clears it.
148
+ *
149
+ * @param {string} key - The field key.
150
+ * @param {FieldValue | undefined} value - The answer; `undefined` clears it.
151
+ * @example setAnswer('purpose', 'home')
152
+ */
153
+ function setAnswer(key: string, value: FieldValue | undefined): void {
154
+ const next = Object.fromEntries(Object.entries(answers).filter(([entry]) => entry !== key))
155
+ if (value !== undefined) next[key] = value
156
+ onAnswersChange(next)
157
+ errors = Object.fromEntries(Object.entries(errors).filter(([entry]) => entry !== key))
158
+ }
159
+
160
+ /**
161
+ * Picks a choice: the answer for a single choice, toggled in or out of a multiple one.
162
+ *
163
+ * @param {Field} field - The question.
164
+ * @param {FieldValue} value - The choice's value.
165
+ * @example choose(field, 'family')
166
+ */
167
+ function choose(field: Field, value: FieldValue): void {
168
+ if (!field.multiple) {
169
+ setAnswer(field.key, value)
170
+ return
171
+ }
172
+
173
+ const current = answers[field.key]
174
+ const list = Array.isArray(current) ? current : []
175
+ setAnswer(
176
+ field.key,
177
+ list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value],
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Whether a choice is picked.
183
+ *
184
+ * @param {Field} field - The question.
185
+ * @param {unknown} value - The choice's value.
186
+ * @returns {boolean} `true` when it is.
187
+ * @example isChosen(field, 'family')
188
+ */
189
+ function isChosen(field: Field, value: unknown): boolean {
190
+ const answer = answers[field.key]
191
+ return Array.isArray(answer) ? answer.some((entry) => entry === value) : answer === value
192
+ }
193
+
194
+ /**
195
+ * Takes a typed answer once the field's own rule accepts it, as its screen would.
196
+ *
197
+ * @param {Field} field - The question.
198
+ * @param {string} raw - What was typed.
199
+ * @returns {Promise<void>} Settles once it is taken or refused.
200
+ * @example await typeAnswer(field, '3000')
201
+ */
202
+ async function typeAnswer(field: Field, raw: string): Promise<void> {
203
+ const text = raw.trim()
204
+ if (text.length === 0) {
205
+ setAnswer(field.key, undefined)
206
+ return
207
+ }
208
+
209
+ const value = field.type === 'number' ? Number(text) : text
210
+ if (field.validation != null) {
211
+ const result = await evaluateRuleLogic(client, field.validation, {
212
+ $input: value,
213
+ responses: { ...(walk?.answers ?? {}) },
214
+ })
215
+ if (typeof result === 'string' || result === false) {
216
+ errors = {
217
+ ...errors,
218
+ [field.key]: typeof result === 'string' ? result : 'This answer is not accepted.',
219
+ }
220
+ return
221
+ }
222
+ }
223
+
224
+ setAnswer(field.key, value)
225
+ }
226
+
227
+ /**
228
+ * The text an answer shows in its box.
229
+ *
230
+ * @param {FieldValue | undefined} value - The answer.
231
+ * @returns {string} Its text, or nothing for an answer that is not text or a number.
232
+ * @example typedText(3000) // '3000'
233
+ */
234
+ function typedText(value: FieldValue | undefined): string {
235
+ return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
236
+ }
237
+
238
+ /**
239
+ * What an end row says.
240
+ *
241
+ * @param {Extract<BuilderRow, { kind: 'end' }>} row - The row.
242
+ * @returns {string} The sentence.
243
+ * @example endText({ kind: 'end', id: 'x', reason: 'end', name: null })
244
+ */
245
+ function endText(row: Extract<BuilderRow, { kind: 'end' }>): string {
246
+ switch (row.reason) {
247
+ case 'loop':
248
+ return `The journey goes back to “${row.name ?? 'an earlier screen'}” here.`
249
+ case 'handoff':
250
+ return `${row.name ?? 'Another journey'} takes over here, outside this build.`
251
+ case 'limit':
252
+ return 'The journey is too long to walk here, so it stops.'
253
+ case 'end':
254
+ return 'The journey ends here.'
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The journey's name above a screen that starts it.
260
+ *
261
+ * @param {number} index - The row's position.
262
+ * @returns {string | null} The name, or `null` when the screen before is in the same journey.
263
+ * @example journeyChange(0) // 'Entry card'
264
+ */
265
+ function journeyChange(index: number): string | null {
266
+ const row = rows.at(index)
267
+ if (row?.kind !== 'screen') return null
268
+ const previous = rows.slice(0, index).findLast((entry) => entry.kind === 'screen')
269
+ return previous?.kind === 'screen' && previous.experienceKey === row.experienceKey
270
+ ? null
271
+ : row.experienceName
272
+ }
273
+ </script>
274
+
275
+ <div class="builder" data-testid="devtools-persona-builder">
276
+ {#if journeyOptions.length === 0}
277
+ <Text size="small" color="subdued">This page has no journeys to build a persona in.</Text>
278
+ {:else}
279
+ <Select
280
+ label="Starts in"
281
+ name="devtools-builder-start"
282
+ options={journeyOptions}
283
+ value={start}
284
+ placeholder="Pick a journey"
285
+ onchange={onStartChange}
286
+ />
287
+ <Text size="small" color="subdued">
288
+ Answer each screen; the next one appears below. Where the site's own code decides the route,
289
+ pick the outcome to follow. The real outcome decides when the persona loads.
290
+ </Text>
291
+
292
+ <ol class="rows">
293
+ {#each rows as row, index (row.id)}
294
+ {#if row.kind === 'screen'}
295
+ <li
296
+ bind:this={rowElements[row.id]}
297
+ class="screen"
298
+ class:next={row.id === nextId}
299
+ data-testid="devtools-builder-screen-{row.stepKey}"
300
+ >
301
+ {#if journeyChange(index) !== null}
302
+ <Text size="caption" color="subdued" weight="semibold">{journeyChange(index)}</Text>
303
+ {/if}
304
+ <Text weight="semibold">{screenName(row.stepName ?? undefined, row.stepKey)}</Text>
305
+ {#each row.fields as key (key)}
306
+ {@const field = fields.get(key)}
307
+ {#if field !== undefined}
308
+ {@const kind = kindOf(field)}
309
+ <div class="question" data-testid="devtools-builder-question-{key}">
310
+ {#if kind === 'text' || kind === 'number'}
311
+ <TextField
312
+ label={questionOf(field)}
313
+ name="devtools-builder-{key}"
314
+ type={kind}
315
+ placeholder={field.placeholder}
316
+ value={typedText(answers[key])}
317
+ error={errors[key]}
318
+ onchange={(value) => void typeAnswer(field, value)}
319
+ />
320
+ {:else}
321
+ <Text size="small" weight="medium">{questionOf(field)}</Text>
322
+ {/if}
323
+ {#if kind === 'options'}
324
+ <div class="choices">
325
+ {#each row.options[key] ?? [] as position (position)}
326
+ {@const option = field.options?.at(position)}
327
+ {#if option !== undefined}
328
+ <ChoiceButton
329
+ label={option.label}
330
+ selected={isChosen(field, option.value)}
331
+ testId="devtools-builder-choice-{key}-{position}"
332
+ onclick={() => choose(field, option.value)}
333
+ />
334
+ {/if}
335
+ {/each}
336
+ </div>
337
+ {:else if kind === 'boolean'}
338
+ <div class="choices">
339
+ <ChoiceButton
340
+ label="Yes"
341
+ selected={answers[key] === true}
342
+ testId="devtools-builder-choice-{key}-yes"
343
+ onclick={() => setAnswer(key, true)}
344
+ />
345
+ <ChoiceButton
346
+ label="No"
347
+ selected={answers[key] === false}
348
+ testId="devtools-builder-choice-{key}-no"
349
+ onclick={() => setAnswer(key, false)}
350
+ />
351
+ </div>
352
+ {:else if kind === 'system'}
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.
356
+ </Text>
357
+ {/if}
358
+ </div>
359
+ {/if}
360
+ {:else}
361
+ <Text size="small" color="subdued">
362
+ Nothing to answer here. The customer continues.
363
+ </Text>
364
+ {/each}
365
+ </li>
366
+ {:else if row.kind === 'choice'}
367
+ <li class="decision" data-testid="devtools-builder-decision">
368
+ <Text size="small">The site decides: {row.question}</Text>
369
+ <div class="choices">
370
+ <ChoiceButton
371
+ label="Yes"
372
+ selected={row.value}
373
+ testId="devtools-builder-decision-{index}-yes"
374
+ onclick={() => onChoicesChange({ ...choices, [row.id]: true })}
375
+ />
376
+ <ChoiceButton
377
+ label="No"
378
+ selected={!row.value}
379
+ testId="devtools-builder-decision-{index}-no"
380
+ onclick={() => onChoicesChange({ ...choices, [row.id]: false })}
381
+ />
382
+ </div>
383
+ </li>
384
+ {:else}
385
+ <li class="end" data-testid="devtools-builder-end">
386
+ <Text size="small" weight="medium">{endText(row)}</Text>
387
+ </li>
388
+ {/if}
389
+ {/each}
390
+ </ol>
391
+ {/if}
392
+ </div>
393
+
394
+ <style>
395
+ .builder {
396
+ display: flex;
397
+ flex-direction: column;
398
+ gap: var(--space-3);
399
+ }
400
+
401
+ .rows {
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: var(--space-2);
405
+ margin: 0;
406
+ padding: 0;
407
+ list-style: none;
408
+ }
409
+
410
+ .screen,
411
+ .decision,
412
+ .end {
413
+ display: flex;
414
+ flex-direction: column;
415
+ gap: var(--space-2);
416
+ padding: var(--space-3);
417
+ border: 1px solid var(--color-gray-200);
418
+ border-radius: var(--radius-md);
419
+ }
420
+
421
+ .screen.next {
422
+ border-color: var(--color-blue-500);
423
+ box-shadow: 0 0 0 1px var(--color-blue-500);
424
+ }
425
+
426
+ .decision {
427
+ flex-direction: row;
428
+ flex-wrap: wrap;
429
+ align-items: center;
430
+ justify-content: space-between;
431
+ padding: var(--space-2) var(--space-3);
432
+ background: var(--color-gray-50);
433
+ }
434
+
435
+ .end {
436
+ border-style: dashed;
437
+ }
438
+
439
+ .question {
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: var(--space-1);
443
+ }
444
+
445
+ .choices {
446
+ display: flex;
447
+ flex-wrap: wrap;
448
+ gap: var(--space-1);
449
+ }
450
+ </style>
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ /** @file One persona in the Personas & Scenarios tab: who it is, where it lands, Load and Delete. */
3
+ import { Button, Card, Text } from '../../ui'
4
+ import { describeLanding, type Persona } from '../../derive'
5
+
6
+ interface PersonaCardProps {
7
+ persona: Persona
8
+ /** Why Load is withheld; `undefined` while it is allowed. */
9
+ loadReason: string | undefined
10
+ /** True while this persona is loading. */
11
+ loading: boolean
12
+ onLoad: () => void
13
+ /** Deletes it; absent for a persona the site defines. */
14
+ onDelete?: () => void
15
+ }
16
+
17
+ const { persona, loadReason, loading, onLoad, onDelete }: PersonaCardProps = $props()
18
+
19
+ let confirming = $state(false)
20
+
21
+ const answers = $derived(Object.keys(persona.responses).length)
22
+ const answerLabel = $derived(answers === 1 ? '1 answer' : `${answers} answers`)
23
+ const summary = $derived(
24
+ persona.load === null
25
+ ? `${answerLabel} · ${describeLanding(persona.landing)}`
26
+ : 'Set up by this site',
27
+ )
28
+ </script>
29
+
30
+ <Card>
31
+ <div class="persona" data-testid="devtools-persona" data-persona-id={persona.id}>
32
+ <div class="details">
33
+ <Text weight="semibold">{persona.name}</Text>
34
+ {#if persona.description}
35
+ <Text size="small">{persona.description}</Text>
36
+ {/if}
37
+ <Text size="caption" color="subdued">{summary}</Text>
38
+ </div>
39
+ <div class="actions">
40
+ {#if confirming && onDelete}
41
+ <Text size="small">Delete {persona.name}?</Text>
42
+ <Button variant="destructive" size="small" onclick={onDelete}>Yes, delete</Button>
43
+ <Button variant="ghost" size="small" onclick={() => (confirming = false)}>Keep</Button>
44
+ {:else}
45
+ <Button
46
+ variant="primary"
47
+ size="small"
48
+ disabled={loadReason !== undefined}
49
+ title={loadReason}
50
+ {loading}
51
+ onclick={onLoad}
52
+ >
53
+ Load
54
+ </Button>
55
+ {#if onDelete}
56
+ <Button variant="ghost" size="small" onclick={() => (confirming = true)}>Delete</Button>
57
+ {/if}
58
+ {/if}
59
+ </div>
60
+ </div>
61
+ </Card>
62
+
63
+ <style>
64
+ .persona {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ align-items: center;
68
+ justify-content: space-between;
69
+ gap: var(--space-3);
70
+ }
71
+
72
+ .details {
73
+ display: flex;
74
+ flex: 1 1 16rem;
75
+ flex-direction: column;
76
+ gap: var(--space-0-5);
77
+ min-width: 0;
78
+ }
79
+
80
+ .actions {
81
+ display: flex;
82
+ flex-wrap: wrap;
83
+ align-items: center;
84
+ gap: var(--space-2);
85
+ }
86
+ </style>