@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/plugins/devtools/DevToolsExperience.svelte +142 -30
- package/dist/plugins/devtools/customTools.d.ts +24 -5
- package/dist/plugins/devtools/customTools.js +1 -1
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
- package/dist/plugins/devtools/derive/index.d.ts +4 -0
- package/dist/plugins/devtools/derive/index.js +1 -1
- package/dist/plugins/devtools/derive/persona-builder/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/persona-builder/index.js +1 -0
- package/dist/plugins/devtools/derive/persona-builder/persona-builder.d.ts +177 -0
- package/dist/plugins/devtools/derive/persona-builder/persona-builder.js +1 -0
- package/dist/plugins/devtools/derive/personas/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/personas/index.js +1 -0
- package/dist/plugins/devtools/derive/personas/personas.d.ts +197 -0
- package/dist/plugins/devtools/derive/personas/personas.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +2 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
- package/dist/plugins/devtools/index.d.ts +2 -1
- package/dist/plugins/devtools/options.d.ts +33 -1
- package/dist/plugins/devtools/options.js +1 -1
- package/dist/plugins/devtools/plugin.d.ts +2 -1
- package/dist/plugins/devtools/review/index.d.ts +5 -1
- package/dist/plugins/devtools/review/index.js +1 -1
- package/dist/plugins/devtools/review/personas/ChoiceButton.svelte +50 -0
- package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +450 -0
- package/dist/plugins/devtools/review/personas/PersonaCard.svelte +86 -0
- package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +546 -0
- package/dist/plugins/devtools/review/personas/load-persona.d.ts +226 -0
- package/dist/plugins/devtools/review/personas/load-persona.js +1 -0
- package/dist/plugins/devtools/session/session.js +1 -1
- package/dist/plugins/devtools/sidebar/SidebarFooter.svelte +114 -0
- package/dist/plugins/devtools/sidebar/ToolIcon.svelte +32 -0
- package/dist/plugins/devtools/sidebar/nav.d.ts +19 -9
- package/dist/plugins/devtools/sidebar/nav.js +1 -1
- package/dist/plugins/devtools/ui/forms/TextField.svelte +33 -6
- 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>
|