@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,226 @@
1
+ /**
2
+ * @file Loads a persona the way a customer arrives: the page reloads clean, then the persona's
3
+ * journey is walked from its first screen. Each screen gets its own answers as the walk reaches it,
4
+ * then Continue, so every condition and action between the screens runs for real. A host persona
5
+ * with its own `load` skips this.
6
+ *
7
+ * Load has two halves on either side of the reload. The first, {@link startPersonaLoad}, clears the
8
+ * answers and the journey history the SDK keeps, notes the persona in `sessionStorage` and
9
+ * reloads. The second, {@link walkPersona}, runs on the next page, once the devtools mount there
10
+ * and pick up the note.
11
+ *
12
+ * The walk stops at the persona's screen, or at the first screen it cannot finish: a question with
13
+ * no answer, an answer that fails its check, a screen that asks nothing and was not moved on from
14
+ * when the persona was saved, or a Continue that lands nowhere.
15
+ */
16
+ import type { FieldValue, Picked } from '@picked/client';
17
+ import { type Persona } from '../../derive';
18
+ /** The part of an experience instance a walk reads and moves. */
19
+ export interface WalkInstance {
20
+ id: string;
21
+ experienceKey: string;
22
+ experienceName: string;
23
+ isOpenStore: {
24
+ getState: () => boolean;
25
+ };
26
+ stateStore: {
27
+ getState: () => 'transitioning' | 'ready';
28
+ };
29
+ currentNodeStore: {
30
+ getState: () => {
31
+ type: string;
32
+ } | null;
33
+ };
34
+ currentStepStore: {
35
+ getState: () => {
36
+ key: string;
37
+ name?: string;
38
+ fieldKeys: readonly string[];
39
+ } | null;
40
+ };
41
+ goToNextStep: () => void;
42
+ }
43
+ /** The part of the client a walk uses. */
44
+ export interface WalkClient {
45
+ readyStore: {
46
+ getState: () => boolean;
47
+ };
48
+ responses: {
49
+ register: (payload: Record<string, FieldValue>) => void;
50
+ commit: (values: Record<string, FieldValue>) => Promise<{
51
+ errors: readonly unknown[];
52
+ }>;
53
+ };
54
+ experienceInstances: {
55
+ listStore: {
56
+ getState: () => readonly WalkInstance[];
57
+ };
58
+ };
59
+ openExperienceByKey: (key: string) => Promise<void>;
60
+ launchExperienceByKey: (key: string, options: {
61
+ bypassCondition: boolean;
62
+ initialiseOpen: boolean;
63
+ }) => Promise<void>;
64
+ }
65
+ /** The part of the client {@link startPersonaLoad} clears. */
66
+ export interface ResetClient {
67
+ responses: {
68
+ clearAll: () => void;
69
+ };
70
+ history: {
71
+ restore: (payload: {
72
+ entries: [];
73
+ }) => void;
74
+ };
75
+ }
76
+ /** How a walk reads the page around it. */
77
+ export interface WalkSettings {
78
+ /** Experience keys never walked: the devtools themselves, and any the host hides. */
79
+ exclude: ReadonlySet<string>;
80
+ /** Experience keys, topmost first, that are in front whenever they are open. */
81
+ precedence: readonly string[];
82
+ /** Instance ids in the order they opened, oldest first, read fresh at every screen. */
83
+ openOrder: () => readonly string[];
84
+ /** Step keys that move on by themselves, such as a loader. The walk waits on them. */
85
+ selfAdvancing: ReadonlySet<string>;
86
+ /** Answers every walk gives when a screen asks them, over the persona's own. */
87
+ answers: Readonly<Record<string, FieldValue>>;
88
+ /**
89
+ * The keys a screen asks: its fields that show now, minus the code-owned ones.
90
+ *
91
+ * @param fieldKeys - Every field the screen lists.
92
+ */
93
+ askedOn: (fieldKeys: readonly string[]) => Promise<readonly string[]>;
94
+ }
95
+ /** How long a walk waits at each point, in polls 30ms apart. */
96
+ export interface WalkTiming {
97
+ sleep: (ms: number) => Promise<void>;
98
+ /** For the page to finish starting. */
99
+ bootTries: number;
100
+ /** For a journey asked to open to report open. */
101
+ openTries: number;
102
+ /** For a journey to show its first screen. */
103
+ startTries: number;
104
+ /** For one Continue to land; an action between two screens can call a live API. */
105
+ advanceTries: number;
106
+ /**
107
+ * For a journey back at rest on the screen it left to stay there before the walk calls it
108
+ * stuck. A journey that hands off to another can rest on its own screen again first.
109
+ */
110
+ returnTries: number;
111
+ /** For a screen that moves itself on, such as a loader that fetches. */
112
+ selfAdvanceTries: number;
113
+ /** Screens walked before the walk gives up, so a loop cannot run forever. */
114
+ maxHops: number;
115
+ }
116
+ /** A screen the walk stopped on. */
117
+ export interface WalkStop {
118
+ experienceName: string;
119
+ stepName: string;
120
+ }
121
+ /** How a walk ended. */
122
+ export type WalkEnd =
123
+ /** On the persona's screen. */
124
+ 'arrived'
125
+ /** The persona has no journey, so its answers went in on their own. */
126
+ | 'answers-only'
127
+ /** A screen asks something the persona has no answer for. */
128
+ | 'needs-answer'
129
+ /** An answer fails its check. */
130
+ | 'invalid'
131
+ /** A Continue that landed nowhere: the journey goes differently now. */
132
+ | 'stuck'
133
+ /** The journey never showed a screen. */
134
+ | 'no-journey'
135
+ /** Too many screens without reaching the persona's. */
136
+ | 'hop-limit';
137
+ /** How a walk ended, and where. */
138
+ export interface WalkOutcome {
139
+ end: WalkEnd;
140
+ /** The screen it stopped on; `null` when it never reached one. */
141
+ at: WalkStop | null;
142
+ }
143
+ /** The `sessionStorage` key that carries a load across the reload. */
144
+ export declare const PENDING_LOAD_KEY = "picked-devtools-persona-load";
145
+ /** Where a load reloads, and how. */
146
+ export interface PersonaReload {
147
+ /** The tab's `sessionStorage`, which carries the load across the reload. */
148
+ storage: Pick<Storage, 'setItem'>;
149
+ /** The page to load on. */
150
+ url: string;
151
+ /**
152
+ * Goes to a URL.
153
+ *
154
+ * @param url - The URL.
155
+ */
156
+ navigate: (url: string) => void;
157
+ }
158
+ /**
159
+ * The keys a screen asks now: its fields whose own condition shows them, minus the code-owned ones.
160
+ *
161
+ * @param {Picked} client - The client.
162
+ * @param {readonly string[]} fieldKeys - Every field the screen lists.
163
+ * @param {ReadonlySet<string>} codeOwned - Keys a screen carries only for its code.
164
+ * @returns {Promise<string[]>} The keys, in the screen's order.
165
+ * @example await screenQuestions(client, step.fieldKeys, new Set())
166
+ */
167
+ export declare function screenQuestions(client: Picked, fieldKeys: readonly string[], codeOwned: ReadonlySet<string>): Promise<string[]>;
168
+ /**
169
+ * Clears what the session holds and reloads, so the next page can walk the persona from the start.
170
+ * The answers go, and so does the journey history, which a host may keep across reloads.
171
+ *
172
+ * @param {ResetClient} client - The client.
173
+ * @param {Persona} persona - The persona to load.
174
+ * @param {PersonaReload} reload - Where the load reloads, and how.
175
+ * @returns {boolean} `false` when the load could not be noted, so nothing was cleared.
176
+ * @example startPersonaLoad(client, persona, { storage: sessionStorage, url: '/', navigate })
177
+ */
178
+ export declare function startPersonaLoad(client: ResetClient, persona: Persona, reload: PersonaReload): boolean;
179
+ /**
180
+ * The URL a load goes to: the persona's entry page, or this page when it has none. This page's
181
+ * `picked-` parameters (a preview token, a devtools switch) ride along where the entry URL does not
182
+ * set them, so the load reads the same configuration this page does.
183
+ *
184
+ * @param {string | null} entryUrl - The persona's entry page.
185
+ * @param {{ pathname: string; search: string }} here - This page's address.
186
+ * @param {string} here.pathname - Its path.
187
+ * @param {string} here.search - Its query.
188
+ * @returns {string} The path and query to load.
189
+ * @example loadUrl('/sale', { pathname: '/', search: '?picked-preview-token=abc' }) // '/sale?picked-preview-token=abc'
190
+ */
191
+ export declare function loadUrl(entryUrl: string | null, here: {
192
+ pathname: string;
193
+ search: string;
194
+ }): string;
195
+ /**
196
+ * Takes the persona a reload was for off `sessionStorage`, so it loads once.
197
+ *
198
+ * @param {Pick<Storage, 'getItem' | 'removeItem'>} storage - The tab's `sessionStorage`.
199
+ * @returns {string | null} The persona's id, or `null` when no load is waiting.
200
+ * @example takePendingLoad(sessionStorage)
201
+ */
202
+ export declare function takePendingLoad(storage: Pick<Storage, 'getItem' | 'removeItem'>): string | null;
203
+ /**
204
+ * Walks a persona's journey from its first screen: each screen gets the answers it asks, then
205
+ * Continue, until the persona's screen or the first screen the walk cannot finish.
206
+ *
207
+ * @param {WalkClient} client - The client, on a page that has just loaded.
208
+ * @param {Persona} persona - The persona.
209
+ * @param {WalkSettings} settings - How to read the page.
210
+ * @param {WalkTiming} [timing] - How long to wait; tests pass a short one.
211
+ * @returns {Promise<WalkOutcome>} How the walk ended, and where.
212
+ * @example await walkPersona(client, persona, settings)
213
+ */
214
+ export declare function walkPersona(client: WalkClient, persona: Persona, settings: WalkSettings, timing?: WalkTiming): Promise<WalkOutcome>;
215
+ /**
216
+ * What the reviewer reads once a walk ends.
217
+ *
218
+ * @param {Persona} persona - The persona that was walked.
219
+ * @param {WalkOutcome} outcome - How the walk ended.
220
+ * @returns {{ status: 'success' | 'warning' | 'error'; text: string }} The banner's tone and words.
221
+ * @example walkNotice(persona, { end: 'arrived', at: { experienceName: 'Funnel', stepName: 'Plans' } })
222
+ */
223
+ export declare function walkNotice(persona: Persona, outcome: WalkOutcome): {
224
+ status: 'success' | 'warning' | 'error';
225
+ text: string;
226
+ };
@@ -0,0 +1 @@
1
+ import{deslug as e,screenName as t}from"../../derive/plain-language/plain-language.js";import{pickJourney as n}from"../../derive/journey-pick/journey-pick.js";import"../../derive/index.js";import{getStepFields as r}from"@picked/client/form";var i=`picked-devtools-persona-load`,a=30,o={sleep:e=>new Promise(t=>{setTimeout(t,e)}),bootTries:500,openTries:20,startTries:100,advanceTries:500,returnTries:33,selfAdvanceTries:1e3,maxHops:60};function s(e){return`${e.experienceKey}/${e.stepKey}`}async function c(e,t,n){for(let r=0;r<=t;r++){let i=e();if(i!==null)return i;r<t&&await n.sleep(a)}return null}function l(e,r,i){let a=e.experienceInstances.listStore.getState().filter(e=>r.has(e.experienceKey)),o=n(a,{exclude:i.exclude,precedence:i.precedence,openOrder:i.openOrder()}),s=o?.currentStepStore.getState();return o===void 0||s==null||o.currentNodeStore.getState()?.type!==`step`||o.stateStore.getState()!==`ready`?null:{instance:o,screen:{experienceKey:o.experienceKey,stepKey:s.key},fieldKeys:s.fieldKeys,stop:{experienceName:o.experienceName,stepName:t(s.name,s.key)}}}async function u(e,t,n){await e.openExperienceByKey(t),await c(()=>e.experienceInstances.listStore.getState().some(e=>e.experienceKey===t&&e.isOpenStore.getState())?!0:null,n.openTries,n)===null&&await e.launchExperienceByKey(t,{bypassCondition:!0,initialiseOpen:!0})}function d(e,t){let n={};for(let r of t)Object.hasOwn(e,r)&&(n[r]=e[r]);return n}async function f(e,t,n){let i=e.fields.listStore.getState(),a=t.flatMap(e=>i.filter(t=>t.key===e));return(await r(e,a)).map(e=>e.key).filter(e=>!n.has(e))}function p(e,t,n){try{n.storage.setItem(i,t.id)}catch{return!1}return e.responses.clearAll(),e.history.restore({entries:[]}),n.navigate(n.url),!0}function m(e,t){let n=new URL(e??`${t.pathname}${t.search}`,`https://entry.invalid`);for(let[e,r]of new URLSearchParams(t.search))e.startsWith(`picked-`)&&!n.searchParams.has(e)&&n.searchParams.append(e,r);return`${n.pathname}${n.search}`}function h(e){try{let t=e.getItem(i);return e.removeItem(i),t!==null&&t.length>0?t:null}catch{return null}}function g(e,t){return e===null||e.experienceKey!==t.experienceKey?!1:e.stepKey===null||e.stepKey===t.stepKey}async function _(e,t,n){let r=!1,i=0;return await c(()=>{let n=e.resting();return n===null?(r=!0,i=0,null):s(n.screen)!==t||(r&&(i+=1),i>e.timing.returnTries?!1:null)},n,e.timing)===!0}async function v(e,t){let n=await e.settings.askedOn(t.fieldKeys),r=n.filter(t=>Object.hasOwn(e.answers,t));if(r.length>0){let{errors:n}=await e.client.responses.commit(d(e.answers,r));if(n.length>0)return{end:`invalid`,at:t.stop}}if(g(e.landing,t.screen))return{end:`arrived`,at:t.stop};let i=s(t.screen);return e.settings.selfAdvancing.has(t.screen.stepKey)?await _(e,i,e.timing.selfAdvanceTries)?null:{end:`stuck`,at:t.stop}:(n.length>0?r.length===n.length:e.passed.has(i))?(t.instance.goToNextStep(),await _(e,i,e.timing.advanceTries)?null:{end:`stuck`,at:t.stop}):{end:`needs-answer`,at:t.stop}}async function y(e,t,n,r=o){let{landing:i}=t,a=t.passed.at(0)?.experienceKey??i?.experienceKey;if(a===void 0)return Object.keys(t.responses).length>0&&e.responses.register({...t.responses}),{end:`answers-only`,at:null};let d=new Set(t.passed.map(e=>e.experienceKey));i!==null&&d.add(i.experienceKey);let f={client:e,settings:n,timing:r,landing:i,passed:new Set(t.passed.map(s)),answers:{...t.responses,...n.answers},resting:()=>l(e,d,n)};await c(()=>e.readyStore.getState()?!0:null,r.bootTries,r),await c(f.resting,r.startTries,r)===null&&await u(e,a,r);for(let e=0;e<r.maxHops;e++){let e=await c(f.resting,r.startTries,r);if(e===null)return{end:`no-journey`,at:null};let t=await v(f,e);if(t!==null)return t}return{end:`hop-limit`,at:null}}function b(t,n){let{name:r}=t,i=n.at===null?``:`“${n.at.stepName}”`;switch(n.end){case`arrived`:return{status:`success`,text:`Loaded ${r} on ${i}.`};case`answers-only`:return{status:`success`,text:`Loaded ${r}’s answers.`};case`needs-answer`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. ${r} has no answer for that screen.`};case`invalid`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. An answer there no longer passes its check.`};case`stuck`:return{status:`warning`,text:`Loaded ${r} as far as ${i}. The journey did not move on from there.`};case`no-journey`:{let n=t.passed.at(0)?.experienceKey??t.landing?.experienceKey;return{status:`error`,text:`${r} did not load: ${t.landing?.experienceName??(n===void 0?`its journey`:e(n))} didn't open on this page.`}}case`hop-limit`:return{status:`warning`,text:`Stopped loading ${r} after too many screens. The journey may be going round in a loop.`}}}export{i as PENDING_LOAD_KEY,m as loadUrl,f as screenQuestions,p as startPersonaLoad,h as takePendingLoad,b as walkNotice,y as walkPersona};
@@ -1 +1 @@
1
- import{askedFieldKeys as e}from"../derive/answer-display/answer-display.js";import{nextHistory as t}from"../derive/answer-history/answer-history.js";import{nextOpenOrder as n,pickJourney as r}from"../derive/journey-pick/journey-pick.js";import{nextTrails as i}from"../derive/visited-steps/visited-steps.js";import{nextRoute as a}from"../derive/walk-route/walk-route.js";import"../derive/index.js";import{clearNode as o,combine as s,createEvent as c,createNode as l,createStore as u,sample as d,withRegion as f}from"effector";var p=new Set([`step_enter`,`experience_open`,`experience_close`]);function m(m,h){let g=l(),_=[],v=(e,t)=>{let n=r(e,{exclude:h.exclude,precedence:h.precedence,openOrder:t});if(n===void 0)return null;let i=n.currentStepStore.getState(),a=n.currentNodeStore.getState();return{experienceKey:n.experienceKey,experienceName:n.experienceName,stepKey:i?.key??null,stepName:i?.name??null,inTransit:a!==null&&a.type!==`step`,goToStepByKey:e=>{n.goToStepByKey(e)}}};return{...f(g,()=>{let r=m.eventBus,o=u(new Map).on(r,i),l=u([]);d({clock:r,source:{history:l,responses:m.responses.getAllStore},fn:({history:e,responses:n},r)=>t(e,n,r,h.codeOwned),target:l});let f=u([]);d({clock:r,source:{route:f,responses:m.responses.getAllStore},fn:({route:e,responses:t},n)=>a(e,t,n,h.codeOwned),target:f});let g=c(),y=e=>e.map(e=>e.treeStore.getState().nodes),b=u(y(m.experiences.listStore.getState()));d({clock:[m.experiences.listStore,g],source:m.experiences.listStore,fn:y,target:b}),m.experiences.listStore.watch(e=>{for(let e of _)e.unsubscribe();_=e.map(e=>e.treeStore.updates.watch(g))});let x=s(m.steps.listStore,b,(t,n)=>e(t,n,h.codeOwned)),S=u([]).on(r,n),C=d({clock:r,filter:e=>p.has(e.type)}),w=c(),T=u(v(m.experienceInstances.listStore.getState(),S.getState()));return d({clock:[m.experienceInstances.listStore,S,C,w],source:{instances:m.experienceInstances.listStore,openOrder:S},fn:({instances:e,openOrder:t})=>v(e,t),target:T}),{trailsStore:o,historyStore:l,routeStore:f,askedStore:x,openOrderStore:S,journeyStore:T,refreshJourney:w}}),destroy:()=>{for(let e of _)e.unsubscribe();_=[],o(g)}}}export{m as createSessionData};
1
+ import{nextHistory as e}from"../derive/answer-history/answer-history.js";import{askedFieldKeys as t}from"../derive/answer-display/answer-display.js";import{nextOpenOrder as n,pickJourney as r}from"../derive/journey-pick/journey-pick.js";import{nextTrails as i}from"../derive/visited-steps/visited-steps.js";import{nextRoute as a}from"../derive/walk-route/walk-route.js";import"../derive/index.js";import{clearNode as o,combine as s,createEvent as c,createNode as l,createStore as u,sample as d,withRegion as f}from"effector";var p=new Set([`step_enter`,`experience_open`,`experience_close`]);function m(m,h){let g=l(),_=[],v=(e,t)=>{let n=r(e,{exclude:h.exclude,precedence:h.precedence,openOrder:t});if(n===void 0)return null;let i=n.currentStepStore.getState(),a=n.currentNodeStore.getState();return{experienceKey:n.experienceKey,experienceName:n.experienceName,stepKey:i?.key??null,stepName:i?.name??null,inTransit:a!==null&&a.type!==`step`,goToStepByKey:e=>{n.goToStepByKey(e)}}};return{...f(g,()=>{let r=m.eventBus,o=u(new Map).on(r,i),l=u([]);d({clock:r,source:{history:l,responses:m.responses.getAllStore},fn:({history:t,responses:n},r)=>e(t,n,r,h.codeOwned),target:l});let f=u([]);d({clock:r,source:{route:f,responses:m.responses.getAllStore},fn:({route:e,responses:t},n)=>a(e,t,n,h.codeOwned),target:f});let g=c(),y=e=>e.map(e=>e.treeStore.getState().nodes),b=u(y(m.experiences.listStore.getState()));d({clock:[m.experiences.listStore,g],source:m.experiences.listStore,fn:y,target:b}),m.experiences.listStore.watch(e=>{for(let e of _)e.unsubscribe();_=e.map(e=>e.treeStore.updates.watch(g))});let x=s(m.steps.listStore,b,(e,n)=>t(e,n,h.codeOwned)),S=u([]).on(r,n),C=d({clock:r,filter:e=>p.has(e.type)}),w=c(),T=u(v(m.experienceInstances.listStore.getState(),S.getState()));return d({clock:[m.experienceInstances.listStore,S,C,w],source:{instances:m.experienceInstances.listStore,openOrder:S},fn:({instances:e,openOrder:t})=>v(e,t),target:T}),{trailsStore:o,historyStore:l,routeStore:f,askedStore:x,openOrderStore:S,journeyStore:T,refreshJourney:w}}),destroy:()=>{for(let e of _)e.unsubscribe();_=[],o(g)}}}export{m as createSessionData};
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ /** @file The foot of the devtools sidebar: the auto-hide switch, then each footer tool as an icon. */
3
+ import { Switch, Tooltip } from '../ui'
4
+ import type { NavItem } from './nav'
5
+ import ToolIcon from './ToolIcon.svelte'
6
+
7
+ interface Props {
8
+ /** The footer tools, each with its glyph. */
9
+ tools: readonly NavItem[]
10
+ /** The selected tab's id. */
11
+ selected: string
12
+ /** Whether the Picked button auto-hides. */
13
+ autoHide: boolean
14
+ onSelect: (id: string) => void
15
+ onAutoHideChange: (value: boolean) => void
16
+ }
17
+
18
+ const { tools, selected, autoHide, onSelect, onAutoHideChange }: Props = $props()
19
+ </script>
20
+
21
+ <div class="footer" data-testid="devtools-sidebar-footer">
22
+ <span class="auto-hide" data-testid="devtools-auto-hide">
23
+ <Switch
24
+ label="Auto-hide button"
25
+ name="devtools-auto-hide"
26
+ size="small"
27
+ checked={autoHide}
28
+ onchange={onAutoHideChange}
29
+ />
30
+ </span>
31
+ {#each tools as tool (tool.id)}
32
+ <Tooltip content={tool.label}>
33
+ <button
34
+ type="button"
35
+ class="icon-button"
36
+ class:active={selected === tool.id}
37
+ aria-label={tool.label}
38
+ aria-current={selected === tool.id ? 'page' : undefined}
39
+ data-testid="devtools-nav-{tool.id}"
40
+ onclick={() => onSelect(tool.id)}
41
+ >
42
+ <ToolIcon name={tool.icon ?? 'wrench'} />
43
+ </button>
44
+ </Tooltip>
45
+ {/each}
46
+ </div>
47
+
48
+ <style>
49
+ .footer {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: var(--space-1);
53
+ padding: var(--space-2);
54
+ border-top: 1px solid var(--color-gray-200);
55
+ color: var(--color-gray-900);
56
+ }
57
+
58
+ .auto-hide {
59
+ flex: 1;
60
+ min-width: 0;
61
+ }
62
+
63
+ /* The switch shares a 200px column with the icons, so its label is small and never wraps. */
64
+ .auto-hide :global(.switch-label) {
65
+ gap: var(--space-2);
66
+ min-width: 0;
67
+ }
68
+
69
+ .auto-hide :global(.label-text) {
70
+ min-width: 0;
71
+ overflow: hidden;
72
+ font-size: var(--text-xs);
73
+ text-overflow: ellipsis;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .icon-button {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ width: 28px;
82
+ height: 28px;
83
+ padding: 0;
84
+ border: 0;
85
+ border-radius: var(--radius-md);
86
+ background: none;
87
+ color: color-mix(in srgb, currentColor 80%, transparent);
88
+ cursor: pointer;
89
+ transition:
90
+ background-color 0.15s ease,
91
+ color 0.15s ease;
92
+ }
93
+
94
+ .icon-button:hover {
95
+ color: var(--color-gray-900);
96
+ background-color: color-mix(in srgb, currentColor 5%, transparent);
97
+ }
98
+
99
+ .icon-button.active {
100
+ color: var(--color-gray-900);
101
+ background-color: color-mix(in srgb, currentColor 10%, transparent);
102
+ }
103
+
104
+ .icon-button:focus-visible {
105
+ outline: 2px solid currentColor;
106
+ outline-offset: -2px;
107
+ }
108
+
109
+ @media (prefers-reduced-motion: reduce) {
110
+ .icon-button {
111
+ transition: none;
112
+ }
113
+ }
114
+ </style>
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ /** @file The glyph of a footer tool, drawn in the current text colour. */
3
+ import type { CustomToolIcon } from '../customTools'
4
+
5
+ interface Props {
6
+ name: CustomToolIcon
7
+ }
8
+
9
+ const { name }: Props = $props()
10
+ </script>
11
+
12
+ <svg
13
+ width="16"
14
+ height="16"
15
+ viewBox="0 0 16 16"
16
+ fill="none"
17
+ stroke="currentColor"
18
+ stroke-width="1.4"
19
+ stroke-linecap="round"
20
+ stroke-linejoin="round"
21
+ aria-hidden="true"
22
+ >
23
+ {#if name === 'bug'}
24
+ <path d="M5.5 4.5 4.5 3M10.5 4.5l1-1.5" />
25
+ <path d="M5 7a3 3 0 0 1 6 0v3a3 3 0 0 1-6 0Z" />
26
+ <path d="M8 7.5V13M5 8.5H2.5M11 8.5h2.5M5 11l-2 1.5M11 11l2 1.5" />
27
+ {:else}
28
+ <path
29
+ d="M10.2 2.6a3.2 3.2 0 0 0-3.5 4.3l-4 4a1.3 1.3 0 0 0 1.9 1.9l4-4a3.2 3.2 0 0 0 4.3-3.5L11 7.2 9.4 6.6 8.8 5Z"
30
+ />
31
+ {/if}
32
+ </svg>
@@ -1,21 +1,25 @@
1
1
  /** @file The devtools sidebar's tab list, its remembered tab, and its keyboard moves. */
2
+ import type { CustomToolIcon } from '../customTools';
2
3
  import type { StorageGetter } from '../launcher/launcher';
3
4
  /**
4
- * What a sidebar row opens: a review tab, a review-placed custom tool, the Custom Tools tab, or a
5
- * developer screen.
5
+ * What a sidebar entry opens: a review tab, a review- or footer-placed custom tool, the Custom Tools
6
+ * tab, or a developer screen.
6
7
  */
7
8
  export type NavKind = 'review' | 'tool' | 'custom-tools' | 'developer';
8
- /** One row of the sidebar. */
9
+ /** One entry of the sidebar: a row, or an icon at its foot. */
9
10
  export interface NavItem {
10
11
  /** Unique across the whole sidebar. */
11
12
  id: string;
12
13
  label: string;
13
14
  kind: NavKind;
15
+ /** The glyph of an icon at the sidebar's foot. */
16
+ icon?: CustomToolIcon;
14
17
  }
15
- /** The sidebar's rows: the top-level tabs, and the Developer group's screens. */
18
+ /** The sidebar's entries: the top-level tabs, the Developer group's screens, and the foot's icons. */
16
19
  export interface NavModel {
17
20
  primary: readonly NavItem[];
18
21
  developer: readonly NavItem[];
22
+ footer: readonly NavItem[];
19
23
  }
20
24
  /** `sessionStorage` key holding the selected tab's id. */
21
25
  export declare const TAB_STORAGE_KEY = "picked-devtools-tab";
@@ -23,7 +27,7 @@ export declare const TAB_STORAGE_KEY = "picked-devtools-tab";
23
27
  export declare const DEVELOPER_OPEN_STORAGE_KEY = "picked-devtools-developer-open";
24
28
  /** The tab a first open lands on, and where a vanished tab falls back to. */
25
29
  export declare const DEFAULT_TAB = "guide";
26
- /** The id of the Custom Tools tab, which holds every tool not placed `'review'`. */
30
+ /** The id of the Custom Tools tab, which holds every tool placed neither `'review'` nor `'footer'`. */
27
31
  export declare const CUSTOM_TOOLS_TAB = "custom-tools";
28
32
  /** The developer screens, in the order the Developer group lists them. */
29
33
  export declare const DEVELOPER_SCREENS: readonly {
@@ -31,7 +35,7 @@ export declare const DEVELOPER_SCREENS: readonly {
31
35
  label: string;
32
36
  }[];
33
37
  /**
34
- * The sidebar id of a review-placed custom tool.
38
+ * The sidebar id of a review- or footer-placed custom tool.
35
39
  *
36
40
  * @param {string} toolId - The tool's registered id.
37
41
  * @returns {string} Its tab id, prefixed so it never collides with a review tab.
@@ -57,13 +61,19 @@ export interface NavInput {
57
61
  }[];
58
62
  /** Whether any tool sits in the Custom Tools tab, which lists it. */
59
63
  customTools: boolean;
64
+ /** The footer-placed custom tools, in registration order, each an icon at the sidebar's foot. */
65
+ footerTools: readonly {
66
+ id: string;
67
+ label: string;
68
+ icon?: CustomToolIcon;
69
+ }[];
60
70
  }
61
71
  /**
62
- * Builds the sidebar's rows.
72
+ * Builds the sidebar's entries.
63
73
  *
64
74
  * @param {NavInput} input - What this session lists.
65
- * @returns {NavModel} The top-level tabs and the Developer group's screens.
66
- * @example buildNav({ variations: true, tools: [], customTools: false })
75
+ * @returns {NavModel} The top-level tabs, the Developer group's screens and the foot's icons.
76
+ * @example buildNav({ variations: true, tools: [], customTools: false, footerTools: [] })
67
77
  */
68
78
  export declare function buildNav(input: NavInput): NavModel;
69
79
  /**
@@ -1 +1 @@
1
- import{TAB_LABELS as e}from"../derive/plain-language/plain-language.js";var t=`picked-devtools-tab`,n=`picked-devtools-developer-open`,r=`guide`,i=`custom-tools`,a=[{id:`project`,label:`Project`},{id:`components`,label:`Components`},{id:`config`,label:`Config`},{id:`content`,label:`Content`},{id:`events`,label:`Events`},{id:`experience-instances`,label:`Experience Instances`},{id:`experiences`,label:`Experiences`},{id:`features`,label:`Features`},{id:`fields`,label:`Fields`},{id:`frameworks`,label:`Frameworks`},{id:`history`,label:`History`},{id:`identity`,label:`Identity`},{id:`predicates`,label:`Predicates`},{id:`responses`,label:`Responses`}];function o(e){return`tool:${e}`}function s(e){return`dev:${e}`}function c(t){let n=[{id:`guide`,label:e.guide,kind:`review`},{id:`session`,label:e.session,kind:`review`},{id:`answers`,label:e.answers,kind:`review`},{id:`journey`,label:e.journey,kind:`review`}];t.variations&&n.push({id:`variations`,label:e.variations,kind:`review`});let r=t.tools.map(e=>({id:o(e.id),label:e.label,kind:`tool`})),c=a.map(e=>({id:s(e.id),label:e.label,kind:`developer`})),l=t.customTools?[{id:i,label:`Custom Tools`,kind:`custom-tools`}]:[];return{primary:[...n,...r,...l],developer:c}}function l(e,t){return e===null?r:[...t.primary,...t.developer].some(t=>t.id===e)?e:r}function u(e,t){return t.developer.some(t=>t.id===e)}function d(e,t,n){let r=Math.max(0,n-1);switch(e){case`ArrowDown`:return Math.min(t+1,r);case`ArrowUp`:return Math.max(t-1,0);case`Home`:return 0;case`End`:return r;default:return null}}function f(e,t){try{return e().getItem(t)}catch{return null}}function p(e,t,n){try{e().setItem(t,n)}catch{}}export{i as CUSTOM_TOOLS_TAB,r as DEFAULT_TAB,n as DEVELOPER_OPEN_STORAGE_KEY,a as DEVELOPER_SCREENS,t as TAB_STORAGE_KEY,c as buildNav,s as developerTabId,u as isInDeveloper,d as nextFocusIndex,f as readStored,l as resolveTab,o as toolTabId,p as writeStored};
1
+ import{TAB_LABELS as e}from"../derive/plain-language/plain-language.js";var t=`picked-devtools-tab`,n=`picked-devtools-developer-open`,r=`guide`,i=`custom-tools`,a=[{id:`project`,label:`Project`},{id:`components`,label:`Components`},{id:`config`,label:`Config`},{id:`content`,label:`Content`},{id:`events`,label:`Events`},{id:`experience-instances`,label:`Experience Instances`},{id:`experiences`,label:`Experiences`},{id:`features`,label:`Features`},{id:`fields`,label:`Fields`},{id:`frameworks`,label:`Frameworks`},{id:`history`,label:`History`},{id:`identity`,label:`Identity`},{id:`predicates`,label:`Predicates`},{id:`responses`,label:`Responses`}];function o(e){return`tool:${e}`}function s(e){return`dev:${e}`}function c(t){let n=[{id:`guide`,label:e.guide,kind:`review`},{id:`session`,label:e.session,kind:`review`},{id:`answers`,label:e.answers,kind:`review`},{id:`journey`,label:e.journey,kind:`review`},{id:`personas`,label:e.personas,kind:`review`}];t.variations&&n.push({id:`variations`,label:e.variations,kind:`review`});let r=t.tools.map(e=>({id:o(e.id),label:e.label,kind:`tool`})),c=a.map(e=>({id:s(e.id),label:e.label,kind:`developer`})),l=t.customTools?[{id:i,label:`Custom Tools`,kind:`custom-tools`}]:[],u=t.footerTools.map(e=>({id:o(e.id),label:e.label,kind:`tool`,icon:e.icon??`wrench`}));return{primary:[...n,...r,...l],developer:c,footer:u}}function l(e,t){return e===null?r:[...t.primary,...t.developer,...t.footer].some(t=>t.id===e)?e:r}function u(e,t){return t.developer.some(t=>t.id===e)}function d(e,t,n){let r=Math.max(0,n-1);switch(e){case`ArrowDown`:return Math.min(t+1,r);case`ArrowUp`:return Math.max(t-1,0);case`Home`:return 0;case`End`:return r;default:return null}}function f(e,t){try{return e().getItem(t)}catch{return null}}function p(e,t,n){try{e().setItem(t,n)}catch{}}export{i as CUSTOM_TOOLS_TAB,r as DEFAULT_TAB,n as DEVELOPER_OPEN_STORAGE_KEY,a as DEVELOPER_SCREENS,t as TAB_STORAGE_KEY,c as buildNav,s as developerTabId,u as isInDeveloper,d as nextFocusIndex,f as readStored,l as resolveTab,o as toolTabId,p as writeStored};
@@ -12,34 +12,60 @@
12
12
  placeholder?: string
13
13
  value?: string
14
14
  oninput?: (value: string) => void
15
+ /** Fires once the value is settled: on blur, or on Enter. */
16
+ onchange?: (value: string) => void
15
17
  error?: string
16
18
  helpText?: string
17
19
  disabled?: boolean
18
20
  required?: boolean
19
21
  }
20
22
 
21
- let {
23
+ const {
22
24
  label,
23
25
  name,
24
26
  type = 'text',
25
27
  placeholder,
26
28
  value = '',
27
29
  oninput,
30
+ onchange,
28
31
  error,
29
32
  helpText,
30
33
  disabled = false,
31
34
  required = false,
32
35
  }: Props = $props()
33
36
 
34
- let inputId = $derived(`field-${name}`)
35
- let errorId = $derived(`${inputId}-error`)
36
- let helpId = $derived(`${inputId}-help`)
37
+ const inputId = $derived(`field-${name}`)
38
+ const errorId = $derived(`${inputId}-error`)
39
+ const helpId = $derived(`${inputId}-help`)
40
+ /** The note the input is described by: its error, else its help text. */
41
+ const describedBy = $derived.by(() => {
42
+ if (error) return errorId
43
+ return helpText ? helpId : undefined
44
+ })
37
45
 
46
+ /**
47
+ * Hands each keystroke's value on.
48
+ *
49
+ * @param {Event} event - The input event.
50
+ * @example handleInput(event)
51
+ */
38
52
  function handleInput(event: Event): void {
39
53
  if (event.target instanceof HTMLInputElement) {
40
54
  oninput?.(event.target.value)
41
55
  }
42
56
  }
57
+
58
+ /**
59
+ * Hands the settled value on.
60
+ *
61
+ * @param {Event} event - The change event.
62
+ * @example handleChange(event)
63
+ */
64
+ function handleChange(event: Event): void {
65
+ if (event.target instanceof HTMLInputElement) {
66
+ onchange?.(event.target.value)
67
+ }
68
+ }
43
69
  </script>
44
70
 
45
71
  <div class="text-field">
@@ -50,7 +76,7 @@
50
76
  <input
51
77
  id={inputId}
52
78
  class="input"
53
- class:has-error={!!error}
79
+ class:has-error={Boolean(error)}
54
80
  {type}
55
81
  {name}
56
82
  {placeholder}
@@ -58,8 +84,9 @@
58
84
  {disabled}
59
85
  {required}
60
86
  aria-invalid={error ? true : undefined}
61
- aria-describedby={error ? errorId : helpText ? helpId : undefined}
87
+ aria-describedby={describedBy}
62
88
  oninput={handleInput}
89
+ onchange={handleChange}
63
90
  />
64
91
  {#if error}
65
92
  <p id={errorId} class="error-text">{error}</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@picked/svelte",
3
- "version": "0.0.1-dev.1793758300441",
3
+ "version": "0.0.1-dev.1793769511104",
4
4
  "dependencies": {
5
5
  "d3": "^7.9.0"
6
6
  },
@@ -113,7 +113,7 @@
113
113
  "license": "SEE LICENSE IN LICENSE",
114
114
  "module": "./dist/index.js",
115
115
  "peerDependencies": {
116
- "@picked/client": "0.0.1-dev.1793758300441",
116
+ "@picked/client": "0.0.1-dev.1793769511104",
117
117
  "effector": "^23.4.4",
118
118
  "svelte": "^5.55.7"
119
119
  },