@picked/svelte 0.0.1-dev.1789541668638 → 0.0.1-dev.1793753513002
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/LICENSE +1 -0
- package/README.md +51 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/_virtual/_vite/preload-helper.js +1 -1
- package/dist/components/index.js +1 -1
- package/dist/experience/ExperienceHost.svelte +29 -13
- package/dist/experience/ExperienceSlot.svelte +21 -3
- package/dist/experience/ExperienceWrapper.svelte +23 -0
- package/dist/form/index.js +1 -1
- package/dist/hooks/action.d.ts +47 -18
- package/dist/hooks/action.js +1 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/step.d.ts +14 -0
- package/dist/hooks/step.js +1 -1
- package/dist/plugins/devtools/DevToolsExperience.svelte +545 -203
- package/dist/plugins/devtools/components/AdaptedToolHost.svelte +2 -2
- package/dist/plugins/devtools/constants.d.ts +5 -0
- package/dist/plugins/devtools/constants.js +1 -0
- package/dist/plugins/devtools/customTools.d.ts +42 -6
- package/dist/plugins/devtools/customTools.js +1 -1
- package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +112 -0
- package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -0
- package/dist/plugins/devtools/derive/answer-display/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/answer-display/index.js +1 -0
- package/dist/plugins/devtools/derive/answer-history/answer-history.d.ts +76 -0
- package/dist/plugins/devtools/derive/answer-history/answer-history.js +1 -0
- package/dist/plugins/devtools/derive/answer-history/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/answer-history/index.js +1 -0
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.d.ts +67 -0
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -0
- package/dist/plugins/devtools/derive/guide-sections/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/guide-sections/index.js +1 -0
- package/dist/plugins/devtools/derive/index.d.ts +28 -0
- package/dist/plugins/devtools/derive/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-outline/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-outline/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-outline/journey-outline.d.ts +86 -0
- package/dist/plugins/devtools/derive/journey-outline/journey-outline.js +1 -0
- package/dist/plugins/devtools/derive/journey-pick/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-pick/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-pick/journey-pick.d.ts +73 -0
- package/dist/plugins/devtools/derive/journey-pick/journey-pick.js +1 -0
- package/dist/plugins/devtools/derive/journey-timeline/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-timeline/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.d.ts +70 -0
- package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.js +1 -0
- package/dist/plugins/devtools/derive/journey-tree/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-tree/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-tree/journey-tree.d.ts +191 -0
- package/dist/plugins/devtools/derive/journey-tree/journey-tree.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/plain-language/index.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +73 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -0
- package/dist/plugins/devtools/derive/plain-logic/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/plain-logic/index.js +1 -0
- package/dist/plugins/devtools/derive/plain-logic/plain-logic.d.ts +48 -0
- package/dist/plugins/devtools/derive/plain-logic/plain-logic.js +1 -0
- package/dist/plugins/devtools/derive/safety-posture/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/safety-posture/index.js +1 -0
- package/dist/plugins/devtools/derive/safety-posture/safety-posture.d.ts +43 -0
- package/dist/plugins/devtools/derive/safety-posture/safety-posture.js +1 -0
- package/dist/plugins/devtools/derive/visited-steps/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/visited-steps/index.js +1 -0
- package/dist/plugins/devtools/derive/visited-steps/visited-steps.d.ts +35 -0
- package/dist/plugins/devtools/derive/visited-steps/visited-steps.js +1 -0
- package/dist/plugins/devtools/derive/walk-route/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/walk-route/index.js +1 -0
- package/dist/plugins/devtools/derive/walk-route/walk-route.d.ts +22 -0
- package/dist/plugins/devtools/derive/walk-route/walk-route.js +1 -0
- package/dist/plugins/devtools/index.d.ts +5 -2
- package/dist/plugins/devtools/index.js +1 -1
- package/dist/plugins/devtools/launcher/Launcher.svelte +150 -0
- package/dist/plugins/devtools/launcher/launcher.d.ts +36 -0
- package/dist/plugins/devtools/launcher/launcher.js +1 -0
- package/dist/plugins/devtools/options.d.ts +106 -0
- package/dist/plugins/devtools/options.js +1 -0
- package/dist/plugins/devtools/plugin.d.ts +8 -8
- package/dist/plugins/devtools/plugin.js +1 -1
- package/dist/plugins/devtools/posture-context.d.ts +15 -0
- package/dist/plugins/devtools/posture-context.js +1 -0
- package/dist/plugins/devtools/review/answers/AnswersPanel.svelte +526 -0
- package/dist/plugins/devtools/review/guide/GuideLine.svelte +20 -0
- package/dist/plugins/devtools/review/guide/GuidePanel.svelte +133 -0
- package/dist/plugins/devtools/review/index.d.ts +6 -0
- package/dist/plugins/devtools/review/index.js +1 -0
- package/dist/plugins/devtools/review/journey/JourneyMap.svelte +1076 -0
- package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +584 -0
- package/dist/plugins/devtools/review/journey/JourneyTimeline.svelte +750 -0
- package/dist/plugins/devtools/review/journey/TimelineMarker.svelte +59 -0
- package/dist/plugins/devtools/review/session/SessionPanel.svelte +214 -0
- package/dist/plugins/devtools/review/variations/VariationsPanel.svelte +279 -0
- package/dist/plugins/devtools/screens/config/ConfigScreen.svelte +6 -0
- package/dist/plugins/devtools/screens/custom-tools/CustomToolsScreen.svelte +3 -3
- package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +6 -0
- package/dist/plugins/devtools/session/index.d.ts +3 -0
- package/dist/plugins/devtools/session/index.js +1 -0
- package/dist/plugins/devtools/session/session.d.ts +90 -0
- package/dist/plugins/devtools/session/session.js +1 -0
- package/dist/plugins/devtools/session-access.d.ts +32 -0
- package/dist/plugins/devtools/session-access.js +1 -0
- package/dist/plugins/devtools/sidebar/Sidebar.svelte +105 -0
- package/dist/plugins/devtools/sidebar/SidebarRow.svelte +117 -0
- package/dist/plugins/devtools/sidebar/nav.d.ts +105 -0
- package/dist/plugins/devtools/sidebar/nav.js +1 -0
- package/dist/plugins/devtools/ui/actions/Button.svelte +10 -1
- package/dist/plugins/devtools/ui/actions/CopyButton.svelte +13 -1
- package/dist/plugins/devtools/ui/data/Badge.svelte +3 -2
- package/dist/plugins/devtools/ui/data/EmptyState.svelte +16 -1
- package/dist/plugins/devtools/ui/feedback/Banner.svelte +3 -2
- package/dist/plugins/framework/index.js +1 -1
- package/dist/render/browser.js +1 -1
- package/package.json +23 -12
- package/dist/form/__tests__/CreateFormHarness.svelte +0 -30
- package/dist/hooks/events.d.ts +0 -2
- package/dist/hooks/events.js +0 -1
- package/dist/vite-env.d.js +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<svelte:options namespace="svg" />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
/** @file A timeline node's marker: its kind as a shape, its branch's colour as the fill. */
|
|
5
|
+
import type { ScreenState, TreeNodeKind } from '../../derive'
|
|
6
|
+
|
|
7
|
+
interface TimelineMarkerProps {
|
|
8
|
+
kind: TreeNodeKind
|
|
9
|
+
/** The marker's centre. */
|
|
10
|
+
x: number
|
|
11
|
+
y: number
|
|
12
|
+
/** Half the marker's width. */
|
|
13
|
+
r: number
|
|
14
|
+
fill: string
|
|
15
|
+
/** Where the node sits on the visitor's route. Null draws the marker plain. */
|
|
16
|
+
state?: ScreenState | null
|
|
17
|
+
selected?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { kind, x, y, r, fill, state = null, selected = false }: TimelineMarkerProps = $props()
|
|
21
|
+
|
|
22
|
+
/** A seen or selected marker takes the dark rim. */
|
|
23
|
+
const marked = $derived(selected || state === 'visited' || state === 'current')
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if kind === 'decision'}
|
|
27
|
+
<polygon
|
|
28
|
+
class="marker"
|
|
29
|
+
class:marked
|
|
30
|
+
{fill}
|
|
31
|
+
points="{x},{y - r} {x + r},{y} {x},{y + r} {x - r},{y}"
|
|
32
|
+
/>
|
|
33
|
+
{:else if kind === 'journey'}
|
|
34
|
+
<rect
|
|
35
|
+
class="marker"
|
|
36
|
+
class:marked
|
|
37
|
+
{fill}
|
|
38
|
+
x={x - r}
|
|
39
|
+
y={y - r}
|
|
40
|
+
width={r * 2}
|
|
41
|
+
height={r * 2}
|
|
42
|
+
rx="1.5"
|
|
43
|
+
/>
|
|
44
|
+
{:else}
|
|
45
|
+
<circle class="marker" class:marked {fill} cx={x} cy={y} {r} />
|
|
46
|
+
{/if}
|
|
47
|
+
|
|
48
|
+
<style>
|
|
49
|
+
/* The fill is the branch's colour, set on the element. */
|
|
50
|
+
.marker {
|
|
51
|
+
stroke: var(--color-white);
|
|
52
|
+
stroke-width: 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.marker.marked {
|
|
56
|
+
stroke: #1e3a8a;
|
|
57
|
+
stroke-width: 2.5;
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @file The This session tab: where the visitor is, what the reviewer is looking at, and what has
|
|
4
|
+
* been answered so far. A reviewer's first question is "where am I and is this the right thing",
|
|
5
|
+
* so the tab answers that before it offers a single control.
|
|
6
|
+
*/
|
|
7
|
+
import type { Picked } from '@picked/client'
|
|
8
|
+
import { Badge, Button, Card, EmptyState, Stack, Text } from '../../ui'
|
|
9
|
+
import { configSourceLabel, describeAnswer, deslug } from '../../derive'
|
|
10
|
+
import type { HistoryEntry, SafetyPosture } from '../../derive'
|
|
11
|
+
import type { JourneyView } from '../../session'
|
|
12
|
+
|
|
13
|
+
interface SessionPanelProps {
|
|
14
|
+
client: Picked
|
|
15
|
+
posture: SafetyPosture
|
|
16
|
+
journey: JourneyView | null
|
|
17
|
+
/** Every screen submitted since the session record started, oldest first. */
|
|
18
|
+
history: readonly HistoryEntry[]
|
|
19
|
+
/** Opens the Answers tab, so the answer summary leads somewhere it can be acted on. */
|
|
20
|
+
onShowAnswers: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { client, posture, journey, history, onShowAnswers }: SessionPanelProps = $props()
|
|
24
|
+
|
|
25
|
+
// An SDK store read straight off the prop: effector stores meet Svelte's store contract, so `$`
|
|
26
|
+
// subscribes, and `$derived` keeps it on whichever client the panel is given.
|
|
27
|
+
const fields = $derived(client.fields.listStore)
|
|
28
|
+
const fieldsByKey = $derived(new Map($fields.map((field) => [field.key, field])))
|
|
29
|
+
|
|
30
|
+
const previewToken = $derived(client.searchParams['preview-token']?.[0])
|
|
31
|
+
const staged = $derived(previewToken !== undefined && previewToken.length > 0)
|
|
32
|
+
|
|
33
|
+
interface HistoryRow {
|
|
34
|
+
id: string
|
|
35
|
+
/** Set on an entry's first row only — the screen cell spans the rest. */
|
|
36
|
+
screen: { name: string; journey: string; span: number } | null
|
|
37
|
+
question: string
|
|
38
|
+
answer: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One row per answer, the screen named once per submit — read top to bottom, it is the journey. */
|
|
42
|
+
const rows = $derived<HistoryRow[]>(
|
|
43
|
+
history.flatMap((entry) =>
|
|
44
|
+
entry.answers.map((answer, index) => {
|
|
45
|
+
const field = fieldsByKey.get(answer.key)
|
|
46
|
+
const view = field === undefined ? null : describeAnswer(answer.value, field)
|
|
47
|
+
return {
|
|
48
|
+
id: `${entry.id}:${answer.key}`,
|
|
49
|
+
screen:
|
|
50
|
+
index === 0
|
|
51
|
+
? { name: entry.stepName, journey: entry.experienceName, span: entry.answers.length }
|
|
52
|
+
: null,
|
|
53
|
+
question: view?.noun ?? deslug(answer.key),
|
|
54
|
+
answer: view?.display ?? String(answer.value),
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<Stack direction="vertical" gap="medium">
|
|
62
|
+
<Card>
|
|
63
|
+
<Stack direction="vertical" gap="small">
|
|
64
|
+
<Text size="caption" color="subdued" weight="semibold">WHERE YOU ARE</Text>
|
|
65
|
+
{#if journey === null}
|
|
66
|
+
<Text size="medium">Nothing is open yet.</Text>
|
|
67
|
+
<Text size="small" color="subdued">
|
|
68
|
+
Start a journey on the page, and this will follow you.
|
|
69
|
+
</Text>
|
|
70
|
+
{:else}
|
|
71
|
+
<Stack direction="vertical" gap="none">
|
|
72
|
+
<Text size="large" weight="semibold">{journey.stepName ?? 'Moving between screens'}</Text>
|
|
73
|
+
<Text size="small" color="subdued">in {journey.experienceName}</Text>
|
|
74
|
+
</Stack>
|
|
75
|
+
{#if journey.inTransit}
|
|
76
|
+
<!-- The engine is transiting a condition or action node, so the recorded step is the one
|
|
77
|
+
just LEFT. Saying so stops a "stuck on loading" report being filed against it. -->
|
|
78
|
+
<Text size="small" color="subdued">
|
|
79
|
+
Still working — the next screen has not settled yet.
|
|
80
|
+
</Text>
|
|
81
|
+
{/if}
|
|
82
|
+
{/if}
|
|
83
|
+
</Stack>
|
|
84
|
+
</Card>
|
|
85
|
+
|
|
86
|
+
<Card>
|
|
87
|
+
<Stack direction="vertical" gap="small">
|
|
88
|
+
<Text size="caption" color="subdued" weight="semibold">WHAT YOU ARE LOOKING AT</Text>
|
|
89
|
+
<Stack direction="horizontal" gap="small" wrap>
|
|
90
|
+
{#if posture.label !== null}
|
|
91
|
+
<Badge status={posture.readOnly ? 'warning' : 'default'} testId="devtools-session-env">
|
|
92
|
+
{posture.label}
|
|
93
|
+
</Badge>
|
|
94
|
+
{/if}
|
|
95
|
+
<Badge status={staged ? 'info' : 'default'} testId="devtools-session-source">
|
|
96
|
+
{configSourceLabel(previewToken)}
|
|
97
|
+
</Badge>
|
|
98
|
+
</Stack>
|
|
99
|
+
{#if staged}
|
|
100
|
+
<Text size="small" color="subdued">
|
|
101
|
+
Content and settings here have not been published yet, so they can differ from the live
|
|
102
|
+
site. That difference is expected.
|
|
103
|
+
</Text>
|
|
104
|
+
{/if}
|
|
105
|
+
{#if posture.reason !== null}
|
|
106
|
+
<Text size="small" color="subdued">{posture.reason}</Text>
|
|
107
|
+
{/if}
|
|
108
|
+
</Stack>
|
|
109
|
+
</Card>
|
|
110
|
+
|
|
111
|
+
<Card>
|
|
112
|
+
<Stack direction="vertical" gap="small">
|
|
113
|
+
<Stack direction="horizontal" gap="small" align="space-between" justify="center">
|
|
114
|
+
<Text size="caption" color="subdued" weight="semibold">WHAT YOU ANSWERED</Text>
|
|
115
|
+
{#if rows.length > 0}
|
|
116
|
+
<Button variant="ghost" size="small" onclick={onShowAnswers}>Change an answer</Button>
|
|
117
|
+
{/if}
|
|
118
|
+
</Stack>
|
|
119
|
+
|
|
120
|
+
{#if rows.length === 0}
|
|
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."
|
|
124
|
+
/>
|
|
125
|
+
{:else}
|
|
126
|
+
<table class="history" data-testid="devtools-session-answers">
|
|
127
|
+
<thead>
|
|
128
|
+
<tr>
|
|
129
|
+
<th scope="col">Screen</th>
|
|
130
|
+
<th scope="col">Question</th>
|
|
131
|
+
<th scope="col">Answer</th>
|
|
132
|
+
</tr>
|
|
133
|
+
</thead>
|
|
134
|
+
<tbody>
|
|
135
|
+
{#each rows as row (row.id)}
|
|
136
|
+
<tr class:first={row.screen !== null}>
|
|
137
|
+
{#if row.screen !== null}
|
|
138
|
+
<th scope="rowgroup" rowspan={row.screen.span} class="screen">
|
|
139
|
+
<span class="screen-name">{row.screen.name}</span>
|
|
140
|
+
<span class="screen-journey">{row.screen.journey}</span>
|
|
141
|
+
</th>
|
|
142
|
+
{/if}
|
|
143
|
+
<td class="question">{row.question}</td>
|
|
144
|
+
<td class="answer">{row.answer}</td>
|
|
145
|
+
</tr>
|
|
146
|
+
{/each}
|
|
147
|
+
</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
{/if}
|
|
150
|
+
</Stack>
|
|
151
|
+
</Card>
|
|
152
|
+
</Stack>
|
|
153
|
+
|
|
154
|
+
<style>
|
|
155
|
+
.history {
|
|
156
|
+
width: 100%;
|
|
157
|
+
border-collapse: collapse;
|
|
158
|
+
table-layout: fixed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.history th,
|
|
162
|
+
.history td {
|
|
163
|
+
padding: var(--space-1-5) var(--space-2);
|
|
164
|
+
text-align: left;
|
|
165
|
+
vertical-align: top;
|
|
166
|
+
overflow-wrap: anywhere;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.history thead th {
|
|
170
|
+
font-size: var(--text-xs);
|
|
171
|
+
font-weight: var(--font-medium);
|
|
172
|
+
color: var(--color-gray-500);
|
|
173
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* A rule between submits, none between the answers of one screen. */
|
|
177
|
+
.history tbody tr.first {
|
|
178
|
+
border-top: 1px solid var(--color-gray-100);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.history thead th:first-child {
|
|
182
|
+
width: 34%;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.history thead th:nth-child(2) {
|
|
186
|
+
width: 28%;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.screen {
|
|
190
|
+
font-weight: normal;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.screen-name {
|
|
194
|
+
display: block;
|
|
195
|
+
font-size: var(--text-sm);
|
|
196
|
+
color: var(--color-gray-900);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.screen-journey {
|
|
200
|
+
display: block;
|
|
201
|
+
font-size: var(--text-xs);
|
|
202
|
+
color: var(--color-gray-500);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.question {
|
|
206
|
+
font-size: var(--text-xs);
|
|
207
|
+
color: var(--color-gray-500);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.answer {
|
|
211
|
+
font-size: var(--text-sm);
|
|
212
|
+
color: var(--color-gray-900);
|
|
213
|
+
}
|
|
214
|
+
</style>
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @file The Variations tab: features as named variations, aimed at someone deciding whether a
|
|
4
|
+
* variation looks right rather than someone debugging bucketing.
|
|
5
|
+
*
|
|
6
|
+
* The mechanism is the SDK's: `?picked-feature=<id>:<value>` forces an arm on the next page load.
|
|
7
|
+
* What changes is the framing — a variation is NAMED, its arms are radio buttons rather than an
|
|
8
|
+
* include checkbox plus a value dropdown, and the payoff is a link the reviewer can send to
|
|
9
|
+
* someone else so both people look at the same thing.
|
|
10
|
+
*/
|
|
11
|
+
import type { FeatureBucket, Picked } from '@picked/client'
|
|
12
|
+
import { Badge, Button, Card, CodeBlock, CopyButton, EmptyState, Stack, Text } from '../../ui'
|
|
13
|
+
|
|
14
|
+
interface VariationsPanelProps {
|
|
15
|
+
client: Picked
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { client }: VariationsPanelProps = $props()
|
|
19
|
+
|
|
20
|
+
/** The URL parameter the SDK reads its overrides from. */
|
|
21
|
+
const OVERRIDE_PARAM = 'picked-feature'
|
|
22
|
+
/** Every feature's control arm. */
|
|
23
|
+
const CONTROL_VALUE = 'false'
|
|
24
|
+
/** A boolean feature's two arms. */
|
|
25
|
+
const BOOLEAN_ARMS = ['true', 'false']
|
|
26
|
+
|
|
27
|
+
const features = $derived(client.features.listStore)
|
|
28
|
+
/** The arm the reviewer has chosen, by feature id. Absent means "leave this one alone". */
|
|
29
|
+
let chosen = $state<Record<string, string | undefined>>({})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The arm a feature is serving right now.
|
|
33
|
+
*
|
|
34
|
+
* @param {FeatureBucket} feature - The feature to read.
|
|
35
|
+
* @returns {string | null} The served arm as text, or null while the feature is undecided.
|
|
36
|
+
* @example
|
|
37
|
+
* servedArm(feature) // 'true'
|
|
38
|
+
*/
|
|
39
|
+
function servedArm(feature: FeatureBucket): string | null {
|
|
40
|
+
return feature.isResolved ? String(feature.getBucket().bucket) : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The arms a feature can be forced to: a published feature's own variants, else the two
|
|
45
|
+
* structural arms of a boolean one. A feature of unknown shape takes free text — the same
|
|
46
|
+
* classification the Developer group's Features screen makes, because it is the SDK's own
|
|
47
|
+
* bucketing rule.
|
|
48
|
+
*
|
|
49
|
+
* @param {FeatureBucket} feature - The feature to read.
|
|
50
|
+
* @returns {string[] | null} The arms' values as text, or null for free text.
|
|
51
|
+
* @example
|
|
52
|
+
* armsOf(booleanFeature) // ['true', 'false']
|
|
53
|
+
*/
|
|
54
|
+
function armsOf(feature: FeatureBucket): string[] | null {
|
|
55
|
+
const variants = feature.variants
|
|
56
|
+
if (variants !== undefined && variants.length > 0) {
|
|
57
|
+
return variants.map((variant) => String(variant.value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const served = servedArm(feature)
|
|
61
|
+
if (feature.valueType === 'text' || (served !== null && !BOOLEAN_ARMS.includes(served))) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return BOOLEAN_ARMS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What an arm is called on screen. `true` and `false` are structural, not words a reviewer uses.
|
|
70
|
+
*
|
|
71
|
+
* @param {FeatureBucket} feature - The feature the arm belongs to.
|
|
72
|
+
* @param {string} value - The arm's value, as text.
|
|
73
|
+
* @returns {string} The arm's name.
|
|
74
|
+
* @example
|
|
75
|
+
* armLabel(feature, 'true') // 'On'
|
|
76
|
+
*/
|
|
77
|
+
function armLabel(feature: FeatureBucket, value: string): string {
|
|
78
|
+
if (value === 'true') return 'On'
|
|
79
|
+
if (value === CONTROL_VALUE) return 'Off'
|
|
80
|
+
const variant = feature.variants?.find((entry) => String(entry.value) === value)
|
|
81
|
+
return variant?.key ?? value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolves a feature that has not bucketed yet. An unresolved feature answers a promise and a
|
|
86
|
+
* resolved one answers its bucket, so the result is only awaited when there is something to await.
|
|
87
|
+
*
|
|
88
|
+
* @param {FeatureBucket} feature - The feature to resolve.
|
|
89
|
+
* @example
|
|
90
|
+
* resolveBucket(feature) // the "Now: …" badge appears once it settles
|
|
91
|
+
*/
|
|
92
|
+
function resolveBucket(feature: FeatureBucket): void {
|
|
93
|
+
const result = feature.getBucket()
|
|
94
|
+
if (result instanceof Promise) result.catch(() => undefined)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Chooses an arm to force for a feature.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} featureId - The feature's id.
|
|
101
|
+
* @param {string} value - The arm's value, as text.
|
|
102
|
+
* @example
|
|
103
|
+
* choose('hero-layout', 'video-hero')
|
|
104
|
+
*/
|
|
105
|
+
function choose(featureId: string, value: string): void {
|
|
106
|
+
chosen = { ...chosen, [featureId]: value }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stops forcing a feature, so it buckets as normal again.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} featureId - The feature's id.
|
|
113
|
+
* @example
|
|
114
|
+
* release('hero-layout')
|
|
115
|
+
*/
|
|
116
|
+
function release(featureId: string): void {
|
|
117
|
+
chosen = Object.fromEntries(Object.entries(chosen).filter(([id]) => id !== featureId))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* This page's address with the chosen arms as overrides, replacing any already on it.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} href - The page's address.
|
|
124
|
+
* @param {Readonly<Record<string, string | undefined>>} arms - The chosen arm, by feature id.
|
|
125
|
+
* @returns {string} The address that reproduces the chosen arms.
|
|
126
|
+
* @example
|
|
127
|
+
* overrideUrl('https://example.com/', { hero: 'video' }) // 'https://example.com/?picked-feature=hero%3Avideo'
|
|
128
|
+
*/
|
|
129
|
+
function overrideUrl(href: string, arms: Readonly<Record<string, string | undefined>>): string {
|
|
130
|
+
const url = new URL(href)
|
|
131
|
+
url.searchParams.delete(OVERRIDE_PARAM)
|
|
132
|
+
for (const [id, value] of Object.entries(arms)) {
|
|
133
|
+
if (value === undefined) continue
|
|
134
|
+
// `searchParams` escapes a value holding a colon or an ampersand — that is what keeps the
|
|
135
|
+
// round trip exact.
|
|
136
|
+
url.searchParams.append(OVERRIDE_PARAM, `${id}:${value}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return url.toString()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const chosenCount = $derived(Object.keys(chosen).length)
|
|
143
|
+
const shareUrl = $derived(
|
|
144
|
+
typeof window === 'undefined' ? '' : overrideUrl(window.location.href, chosen),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reloads the page with the chosen arms forced.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* apply()
|
|
152
|
+
*/
|
|
153
|
+
function apply(): void {
|
|
154
|
+
window.location.href = shareUrl
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<Stack direction="vertical" gap="medium">
|
|
159
|
+
{#if $features.length === 0}
|
|
160
|
+
<EmptyState
|
|
161
|
+
message="No variations are running on this site"
|
|
162
|
+
hint="When an A/B test or a switched-on feature is published, it appears here and you can force either version."
|
|
163
|
+
/>
|
|
164
|
+
{:else}
|
|
165
|
+
<Card sectioned>
|
|
166
|
+
<ul class="features" data-testid="devtools-variations">
|
|
167
|
+
{#each $features as feature (feature.id)}
|
|
168
|
+
{@const arms = armsOf(feature)}
|
|
169
|
+
{@const served = servedArm(feature)}
|
|
170
|
+
<li class="feature" data-testid="devtools-variations-row" data-feature-id={feature.id}>
|
|
171
|
+
<div class="feature-head">
|
|
172
|
+
<Text size="medium" weight="medium">{feature.name}</Text>
|
|
173
|
+
{#if served !== null}
|
|
174
|
+
<Badge status="default" size="small">Now: {armLabel(feature, served)}</Badge>
|
|
175
|
+
{:else}
|
|
176
|
+
<Button variant="ghost" size="small" onclick={() => resolveBucket(feature)}>
|
|
177
|
+
Check which version
|
|
178
|
+
</Button>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{#if arms}
|
|
183
|
+
<div class="arms" role="group" aria-label="Version of {feature.name}">
|
|
184
|
+
{#each arms as arm, index (index)}
|
|
185
|
+
<label class="option-label">
|
|
186
|
+
<input
|
|
187
|
+
type="radio"
|
|
188
|
+
name="variation-{feature.id}"
|
|
189
|
+
checked={chosen[feature.id] === arm}
|
|
190
|
+
onchange={() => choose(feature.id, arm)}
|
|
191
|
+
/>
|
|
192
|
+
<Text size="small" inline>{armLabel(feature, arm)}</Text>
|
|
193
|
+
</label>
|
|
194
|
+
{/each}
|
|
195
|
+
</div>
|
|
196
|
+
{:else}
|
|
197
|
+
<input
|
|
198
|
+
class="inline-input"
|
|
199
|
+
type="text"
|
|
200
|
+
value={chosen[feature.id] ?? ''}
|
|
201
|
+
placeholder="Type the version to force"
|
|
202
|
+
aria-label="Version of {feature.name}"
|
|
203
|
+
oninput={(event) => choose(feature.id, event.currentTarget.value)}
|
|
204
|
+
/>
|
|
205
|
+
{/if}
|
|
206
|
+
|
|
207
|
+
{#if chosen[feature.id] !== undefined}
|
|
208
|
+
<Button variant="ghost" size="small" onclick={() => release(feature.id)}>
|
|
209
|
+
Leave this one alone
|
|
210
|
+
</Button>
|
|
211
|
+
{/if}
|
|
212
|
+
</li>
|
|
213
|
+
{/each}
|
|
214
|
+
</ul>
|
|
215
|
+
</Card>
|
|
216
|
+
|
|
217
|
+
<Card>
|
|
218
|
+
{#snippet header()}
|
|
219
|
+
<Text size="caption" color="subdued" weight="semibold">YOUR LINK</Text>
|
|
220
|
+
{/snippet}
|
|
221
|
+
<Stack direction="vertical" gap="small">
|
|
222
|
+
{#if chosenCount === 0}
|
|
223
|
+
<Text size="small" color="subdued">
|
|
224
|
+
Pick a version above. The page reloads showing it, and the link below reproduces exactly
|
|
225
|
+
what you saw for anyone you send it to.
|
|
226
|
+
</Text>
|
|
227
|
+
{:else}
|
|
228
|
+
<CodeBlock code={shareUrl} />
|
|
229
|
+
<Stack direction="horizontal" gap="small">
|
|
230
|
+
<Button variant="primary" size="small" onclick={apply} testId="devtools-variations-apply">
|
|
231
|
+
Show me this version
|
|
232
|
+
</Button>
|
|
233
|
+
<CopyButton
|
|
234
|
+
text={shareUrl}
|
|
235
|
+
label="Copy link"
|
|
236
|
+
showLabel
|
|
237
|
+
testId="devtools-variations-copy"
|
|
238
|
+
/>
|
|
239
|
+
</Stack>
|
|
240
|
+
{/if}
|
|
241
|
+
</Stack>
|
|
242
|
+
</Card>
|
|
243
|
+
{/if}
|
|
244
|
+
</Stack>
|
|
245
|
+
|
|
246
|
+
<style>
|
|
247
|
+
.features {
|
|
248
|
+
list-style: none;
|
|
249
|
+
margin: 0;
|
|
250
|
+
padding: 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.feature {
|
|
254
|
+
display: flex;
|
|
255
|
+
flex-direction: column;
|
|
256
|
+
gap: var(--space-2);
|
|
257
|
+
align-items: flex-start;
|
|
258
|
+
padding: var(--space-3);
|
|
259
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.feature:last-child {
|
|
263
|
+
border-bottom: none;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.feature-head {
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
justify-content: space-between;
|
|
270
|
+
gap: var(--space-2);
|
|
271
|
+
width: 100%;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.arms {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-wrap: wrap;
|
|
277
|
+
gap: var(--space-3);
|
|
278
|
+
}
|
|
279
|
+
</style>
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
? '—'
|
|
28
28
|
: `snapshot ${client.versionSkew.seeded} kept over resolved ${client.versionSkew.resolved}`,
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
label: 'Preview',
|
|
32
|
+
value: client.previewUnavailable
|
|
33
|
+
? 'Unavailable: the workspace no longer exists. Serving the published configuration.'
|
|
34
|
+
: '—',
|
|
35
|
+
},
|
|
30
36
|
]
|
|
31
37
|
|
|
32
38
|
const configColumns = [
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
/** @file Screen hosting
|
|
2
|
+
/** @file Screen hosting the developer-placed custom tools as nested tabs. */
|
|
3
3
|
import { getPicked } from '../../../../hooks'
|
|
4
4
|
import AdaptedToolHost from '../../components/AdaptedToolHost.svelte'
|
|
5
5
|
import Tabs from '../../components/Tabs.svelte'
|
|
6
|
-
import { readCustomTools } from '../../customTools'
|
|
6
|
+
import { developerTools, readCustomTools } from '../../customTools'
|
|
7
7
|
import { Button, EmptyState, Stack } from '../../ui'
|
|
8
8
|
|
|
9
9
|
const client = getPicked()
|
|
10
10
|
const docsUrl = `${client.documentationSiteUrl}/chrome-extension#custom-tools`
|
|
11
|
-
const tools = readCustomTools(client)
|
|
11
|
+
const tools = developerTools(readCustomTools(client))
|
|
12
12
|
const tabs = tools.map((tool) => ({
|
|
13
13
|
id: tool.id,
|
|
14
14
|
label: tool.label,
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import type { ResponseListItem, Field, FieldValue, JsonValue } from '@picked/client'
|
|
4
4
|
import { getPicked } from '../../../../hooks'
|
|
5
5
|
import { Button, Text, CodeBlock, Banner, Stack } from '../../ui'
|
|
6
|
+
import { readOnlyReasonFromContext } from '../../posture-context'
|
|
6
7
|
|
|
7
8
|
export let field: Field
|
|
8
9
|
export let response: ResponseListItem | null
|
|
9
10
|
|
|
10
11
|
const client = getPicked()
|
|
12
|
+
const readOnlyReason = readOnlyReasonFromContext()
|
|
11
13
|
|
|
12
14
|
let isEditing = false
|
|
13
15
|
let editValue = ''
|
|
@@ -266,6 +268,10 @@
|
|
|
266
268
|
<Button variant="primary" size="small" onclick={saveEdit}>Save</Button>
|
|
267
269
|
<Button variant="destructive" size="small" onclick={cancelEdit}>Cancel</Button>
|
|
268
270
|
</Stack>
|
|
271
|
+
{:else if readOnlyReason !== null}
|
|
272
|
+
<span title={readOnlyReason} data-testid="devtools-response-read-only">
|
|
273
|
+
<Text size="caption" color="subdued">Read-only</Text>
|
|
274
|
+
</span>
|
|
269
275
|
{:else}
|
|
270
276
|
<Stack direction="horizontal" gap="none" align="end">
|
|
271
277
|
<Button variant="secondary" size="small" onclick={startEdit}>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export{createSessionData}from"./session.js";
|