@picked/svelte 0.0.1-dev.1789541668638 → 0.0.1-dev.1793756239746

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 (118) hide show
  1. package/LICENSE +1 -0
  2. package/README.md +51 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/_virtual/_vite/preload-helper.js +1 -1
  5. package/dist/components/index.js +1 -1
  6. package/dist/experience/ExperienceHost.svelte +29 -13
  7. package/dist/experience/ExperienceSlot.svelte +21 -3
  8. package/dist/experience/ExperienceWrapper.svelte +23 -0
  9. package/dist/form/index.js +1 -1
  10. package/dist/hooks/action.d.ts +47 -18
  11. package/dist/hooks/action.js +1 -1
  12. package/dist/hooks/index.d.ts +2 -2
  13. package/dist/hooks/index.js +1 -1
  14. package/dist/hooks/step.d.ts +14 -0
  15. package/dist/hooks/step.js +1 -1
  16. package/dist/plugins/devtools/DevToolsExperience.svelte +545 -203
  17. package/dist/plugins/devtools/components/AdaptedToolHost.svelte +2 -2
  18. package/dist/plugins/devtools/constants.d.ts +5 -0
  19. package/dist/plugins/devtools/constants.js +1 -0
  20. package/dist/plugins/devtools/customTools.d.ts +42 -6
  21. package/dist/plugins/devtools/customTools.js +1 -1
  22. package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +112 -0
  23. package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -0
  24. package/dist/plugins/devtools/derive/answer-display/index.d.ts +3 -0
  25. package/dist/plugins/devtools/derive/answer-display/index.js +1 -0
  26. package/dist/plugins/devtools/derive/answer-history/answer-history.d.ts +76 -0
  27. package/dist/plugins/devtools/derive/answer-history/answer-history.js +1 -0
  28. package/dist/plugins/devtools/derive/answer-history/index.d.ts +3 -0
  29. package/dist/plugins/devtools/derive/answer-history/index.js +1 -0
  30. package/dist/plugins/devtools/derive/guide-sections/guide-sections.d.ts +67 -0
  31. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -0
  32. package/dist/plugins/devtools/derive/guide-sections/index.d.ts +3 -0
  33. package/dist/plugins/devtools/derive/guide-sections/index.js +1 -0
  34. package/dist/plugins/devtools/derive/index.d.ts +28 -0
  35. package/dist/plugins/devtools/derive/index.js +1 -0
  36. package/dist/plugins/devtools/derive/journey-outline/index.d.ts +3 -0
  37. package/dist/plugins/devtools/derive/journey-outline/index.js +1 -0
  38. package/dist/plugins/devtools/derive/journey-outline/journey-outline.d.ts +86 -0
  39. package/dist/plugins/devtools/derive/journey-outline/journey-outline.js +1 -0
  40. package/dist/plugins/devtools/derive/journey-pick/index.d.ts +3 -0
  41. package/dist/plugins/devtools/derive/journey-pick/index.js +1 -0
  42. package/dist/plugins/devtools/derive/journey-pick/journey-pick.d.ts +73 -0
  43. package/dist/plugins/devtools/derive/journey-pick/journey-pick.js +1 -0
  44. package/dist/plugins/devtools/derive/journey-timeline/index.d.ts +3 -0
  45. package/dist/plugins/devtools/derive/journey-timeline/index.js +1 -0
  46. package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.d.ts +70 -0
  47. package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.js +1 -0
  48. package/dist/plugins/devtools/derive/journey-tree/index.d.ts +3 -0
  49. package/dist/plugins/devtools/derive/journey-tree/index.js +1 -0
  50. package/dist/plugins/devtools/derive/journey-tree/journey-tree.d.ts +191 -0
  51. package/dist/plugins/devtools/derive/journey-tree/journey-tree.js +1 -0
  52. package/dist/plugins/devtools/derive/plain-language/index.d.ts +3 -0
  53. package/dist/plugins/devtools/derive/plain-language/index.js +1 -0
  54. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +73 -0
  55. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -0
  56. package/dist/plugins/devtools/derive/plain-logic/index.d.ts +3 -0
  57. package/dist/plugins/devtools/derive/plain-logic/index.js +1 -0
  58. package/dist/plugins/devtools/derive/plain-logic/plain-logic.d.ts +48 -0
  59. package/dist/plugins/devtools/derive/plain-logic/plain-logic.js +1 -0
  60. package/dist/plugins/devtools/derive/safety-posture/index.d.ts +3 -0
  61. package/dist/plugins/devtools/derive/safety-posture/index.js +1 -0
  62. package/dist/plugins/devtools/derive/safety-posture/safety-posture.d.ts +43 -0
  63. package/dist/plugins/devtools/derive/safety-posture/safety-posture.js +1 -0
  64. package/dist/plugins/devtools/derive/visited-steps/index.d.ts +3 -0
  65. package/dist/plugins/devtools/derive/visited-steps/index.js +1 -0
  66. package/dist/plugins/devtools/derive/visited-steps/visited-steps.d.ts +35 -0
  67. package/dist/plugins/devtools/derive/visited-steps/visited-steps.js +1 -0
  68. package/dist/plugins/devtools/derive/walk-route/index.d.ts +3 -0
  69. package/dist/plugins/devtools/derive/walk-route/index.js +1 -0
  70. package/dist/plugins/devtools/derive/walk-route/walk-route.d.ts +22 -0
  71. package/dist/plugins/devtools/derive/walk-route/walk-route.js +1 -0
  72. package/dist/plugins/devtools/index.d.ts +5 -2
  73. package/dist/plugins/devtools/index.js +1 -1
  74. package/dist/plugins/devtools/launcher/Launcher.svelte +150 -0
  75. package/dist/plugins/devtools/launcher/launcher.d.ts +36 -0
  76. package/dist/plugins/devtools/launcher/launcher.js +1 -0
  77. package/dist/plugins/devtools/options.d.ts +106 -0
  78. package/dist/plugins/devtools/options.js +1 -0
  79. package/dist/plugins/devtools/plugin.d.ts +8 -8
  80. package/dist/plugins/devtools/plugin.js +1 -1
  81. package/dist/plugins/devtools/posture-context.d.ts +15 -0
  82. package/dist/plugins/devtools/posture-context.js +1 -0
  83. package/dist/plugins/devtools/review/answers/AnswersPanel.svelte +526 -0
  84. package/dist/plugins/devtools/review/guide/GuideLine.svelte +20 -0
  85. package/dist/plugins/devtools/review/guide/GuidePanel.svelte +133 -0
  86. package/dist/plugins/devtools/review/index.d.ts +6 -0
  87. package/dist/plugins/devtools/review/index.js +1 -0
  88. package/dist/plugins/devtools/review/journey/JourneyMap.svelte +1078 -0
  89. package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +608 -0
  90. package/dist/plugins/devtools/review/journey/JourneyTimeline.svelte +756 -0
  91. package/dist/plugins/devtools/review/journey/TimelineMarker.svelte +59 -0
  92. package/dist/plugins/devtools/review/session/SessionPanel.svelte +214 -0
  93. package/dist/plugins/devtools/review/variations/VariationsPanel.svelte +279 -0
  94. package/dist/plugins/devtools/screens/config/ConfigScreen.svelte +6 -0
  95. package/dist/plugins/devtools/screens/custom-tools/CustomToolsScreen.svelte +3 -3
  96. package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +6 -0
  97. package/dist/plugins/devtools/session/index.d.ts +3 -0
  98. package/dist/plugins/devtools/session/index.js +1 -0
  99. package/dist/plugins/devtools/session/session.d.ts +90 -0
  100. package/dist/plugins/devtools/session/session.js +1 -0
  101. package/dist/plugins/devtools/session-access.d.ts +32 -0
  102. package/dist/plugins/devtools/session-access.js +1 -0
  103. package/dist/plugins/devtools/sidebar/Sidebar.svelte +105 -0
  104. package/dist/plugins/devtools/sidebar/SidebarRow.svelte +117 -0
  105. package/dist/plugins/devtools/sidebar/nav.d.ts +105 -0
  106. package/dist/plugins/devtools/sidebar/nav.js +1 -0
  107. package/dist/plugins/devtools/ui/actions/Button.svelte +10 -1
  108. package/dist/plugins/devtools/ui/actions/CopyButton.svelte +13 -1
  109. package/dist/plugins/devtools/ui/data/Badge.svelte +3 -2
  110. package/dist/plugins/devtools/ui/data/EmptyState.svelte +16 -1
  111. package/dist/plugins/devtools/ui/feedback/Banner.svelte +3 -2
  112. package/dist/plugins/framework/index.js +1 -1
  113. package/dist/render/browser.js +1 -1
  114. package/package.json +23 -12
  115. package/dist/form/__tests__/CreateFormHarness.svelte +0 -30
  116. package/dist/hooks/events.d.ts +0 -2
  117. package/dist/hooks/events.js +0 -1
  118. 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 user-registered custom devtools tools as nested tabs. */
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,3 @@
1
+ /** @file Public entrypoint for the review tools' session model. */
2
+ export { createSessionData } from './session';
3
+ export type { BusEvent, JourneyView, SessionClient, SessionData, SessionInstance, SessionOptions, } from './session';
@@ -0,0 +1 @@
1
+ export{createSessionData}from"./session.js";