@picked/svelte 0.0.1-dev.1793776638554 → 0.0.1-dev.1793923852884

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 (50) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/experience/set-picked-context.js +1 -1
  3. package/dist/plugins/devtools/DevToolsExperience.svelte +155 -80
  4. package/dist/plugins/devtools/customTools.d.ts +2 -2
  5. package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +14 -0
  6. package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -1
  7. package/dist/plugins/devtools/derive/answer-display/index.d.ts +2 -2
  8. package/dist/plugins/devtools/derive/answer-display/index.js +1 -1
  9. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
  10. package/dist/plugins/devtools/derive/index.d.ts +5 -5
  11. package/dist/plugins/devtools/derive/index.js +1 -1
  12. package/dist/plugins/devtools/derive/personas/index.d.ts +2 -2
  13. package/dist/plugins/devtools/derive/personas/index.js +1 -1
  14. package/dist/plugins/devtools/derive/personas/personas.d.ts +88 -7
  15. package/dist/plugins/devtools/derive/personas/personas.js +1 -1
  16. package/dist/plugins/devtools/derive/plain-language/index.d.ts +1 -1
  17. package/dist/plugins/devtools/derive/plain-language/index.js +1 -1
  18. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +5 -5
  19. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
  20. package/dist/plugins/devtools/index.d.ts +1 -1
  21. package/dist/plugins/devtools/launcher/Launcher.svelte +13 -4
  22. package/dist/plugins/devtools/options.d.ts +6 -4
  23. package/dist/plugins/devtools/options.js +1 -1
  24. package/dist/plugins/devtools/review/journey/ExperienceDetails.svelte +129 -0
  25. package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +54 -8
  26. package/dist/plugins/devtools/review/personas/ChoiceButton.svelte +40 -6
  27. package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +69 -45
  28. package/dist/plugins/devtools/review/personas/PersonaCard.svelte +226 -43
  29. package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +171 -74
  30. package/dist/plugins/devtools/review/personas/load-persona.d.ts +41 -20
  31. package/dist/plugins/devtools/review/personas/load-persona.js +1 -1
  32. package/dist/plugins/devtools/review/personas/persona-route.d.ts +87 -0
  33. package/dist/plugins/devtools/review/personas/persona-route.js +1 -0
  34. package/dist/plugins/devtools/review/personas/persona-session.d.ts +94 -0
  35. package/dist/plugins/devtools/review/personas/persona-session.js +1 -0
  36. package/dist/plugins/devtools/sidebar/Sidebar.svelte +14 -48
  37. package/dist/plugins/devtools/sidebar/SidebarRow.svelte +2 -2
  38. package/dist/plugins/devtools/sidebar/nav.d.ts +26 -39
  39. package/dist/plugins/devtools/sidebar/nav.js +1 -1
  40. package/package.json +2 -2
  41. package/dist/plugins/devtools/screens/experience-instances/ExperienceInstancesListScreen.svelte +0 -10
  42. package/dist/plugins/devtools/screens/experiences/ExperienceDetailsScreen.svelte +0 -44
  43. package/dist/plugins/devtools/screens/experiences/ExperienceExperienceInstancesListScreen.svelte +0 -13
  44. package/dist/plugins/devtools/screens/experiences/ExperienceScreen.svelte +0 -32
  45. package/dist/plugins/devtools/screens/experiences/ExperienceTreeScreen.svelte +0 -500
  46. package/dist/plugins/devtools/screens/experiences/ExperiencesListScreen.svelte +0 -22
  47. package/dist/plugins/devtools/screens/features/FeaturesListScreen.svelte +0 -239
  48. package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +0 -286
  49. package/dist/plugins/devtools/screens/responses/ResponsesListScreen.svelte +0 -12
  50. package/dist/plugins/devtools/screens/responses/ResponsesTable.svelte +0 -88
@@ -20,18 +20,28 @@ export interface PersonaScreen {
20
20
  experienceKey: string;
21
21
  stepKey: string;
22
22
  }
23
- /** Loads a persona the host's own way, in place of walking its journey with its answers. */
24
- export type PersonaLoader = (client: Picked) => void | Promise<void>;
23
+ /**
24
+ * Puts the page in the state a persona needs before its experience starts, such as a stubbed
25
+ * account or a location. It runs on every page of the persona's session, before the walk.
26
+ */
27
+ export type PersonaSetup = (client: Picked) => void | Promise<void>;
25
28
  /** A customer or situation to try the journey as. */
26
29
  export interface Persona {
27
30
  id: string;
28
31
  name: string;
29
32
  description: string | null;
30
33
  source: PersonaSource;
34
+ /** The heading it is listed under; `null` lists it under its source's heading. */
35
+ group: string | null;
31
36
  /** The answers in place once it loads. */
32
37
  responses: Readonly<Record<string, FieldValue>>;
33
38
  /** Where a load stops; `null` loads the answers only. */
34
39
  landing: PersonaLanding | null;
40
+ /**
41
+ * The experience a walk starts in, such as the page's entry experience. `null` starts in the
42
+ * experience of the first screen moved on from, else the landing's.
43
+ */
44
+ start: string | null;
35
45
  /**
36
46
  * The screens moved on from when it was saved, in the order first left. A load presses Continue
37
47
  * on a screen that asks nothing only when it is listed here.
@@ -49,8 +59,10 @@ export interface Persona {
49
59
  entryUrl: string | null;
50
60
  /** When it was saved, as an ISO time; `null` for a host-defined persona. */
51
61
  savedAt: string | null;
52
- /** The host's own loader; `null` for the standard one. */
53
- load: PersonaLoader | null;
62
+ /** The host's setup; `null` when it needs none. */
63
+ setup: PersonaSetup | null;
64
+ /** `true` for a host-defined persona that a reviewer edited in this browser. */
65
+ edited: boolean;
54
66
  }
55
67
  /** A persona as the host defines it in the plugin's `personas` option. */
56
68
  export interface PersonaDefinition {
@@ -59,16 +71,20 @@ export interface PersonaDefinition {
59
71
  name: string;
60
72
  /** Who they are, in a sentence. */
61
73
  description?: string;
74
+ /** The heading it is listed under, such as `Moving`. */
75
+ group?: string;
62
76
  /** The answers in place once it loads. */
63
77
  responses?: Readonly<Record<string, FieldValue>>;
64
78
  /** The journey it opens. */
65
79
  experienceKey?: string;
66
80
  /** The screen in that journey it lands on. */
67
81
  stepKey?: string;
82
+ /** The experience a walk starts in, such as the page's entry experience; `experienceKey` when absent. */
83
+ startExperienceKey?: string;
68
84
  /** The page a load starts on, such as a campaign landing; the page in front when absent. */
69
85
  entryUrl?: string;
70
- /** Loads it the host's own way, in place of the standard answers-then-journey load. */
71
- load?: PersonaLoader;
86
+ /** Prepares the page for it, such as stubbing an account, before each walk. */
87
+ setup?: PersonaSetup;
72
88
  }
73
89
  /** What the reviewer types when saving where they are. */
74
90
  export interface PersonaDraft {
@@ -117,6 +133,22 @@ export declare function personaStorageKey(projectId: string): string;
117
133
  * @example readAnswers({ age: 42 }) // { age: 42 }
118
134
  */
119
135
  export declare function readAnswers(value: unknown): Record<string, FieldValue>;
136
+ /**
137
+ * Reads a stored landing.
138
+ *
139
+ * @param {unknown} value - The value to read.
140
+ * @returns {PersonaLanding | null} The landing, or `null` when it names no journey.
141
+ * @example readLanding({ experienceKey: 'needs-analysis' })
142
+ */
143
+ export declare function readLanding(value: unknown): PersonaLanding | null;
144
+ /**
145
+ * Reads a stored list of passed screens, keeping only the well-formed entries.
146
+ *
147
+ * @param {unknown} value - The stored list.
148
+ * @returns {PersonaScreen[]} The screens, in order.
149
+ * @example readPassed([{ experienceKey: 'funnel', stepKey: 'postcode' }])
150
+ */
151
+ export declare function readPassed(value: unknown): PersonaScreen[];
120
152
  /**
121
153
  * Reads an entry URL, or `null` when it is not a path on this site.
122
154
  *
@@ -186,7 +218,7 @@ export declare function customerAnswers(responses: Readonly<Record<string, Field
186
218
  */
187
219
  export declare function buildPersona(draft: PersonaDraft, capture: PersonaCapture): BuildPersonaResult;
188
220
  /**
189
- * Reads the host's persona definitions, dropping any without an id or name. A host loader is
221
+ * Reads the host's persona definitions, dropping any without an id or name. A host setup is
190
222
  * wrapped so it can be any function the host passed.
191
223
  *
192
224
  * @param {unknown} definitions - The plugin's `personas` option.
@@ -202,3 +234,52 @@ export declare function sitePersonas(definitions: unknown): Persona[];
202
234
  * @example describeLanding(null) // 'Responses only'
203
235
  */
204
236
  export declare function describeLanding(landing: PersonaLanding | null): string;
237
+ /**
238
+ * The experience a persona's walk starts in.
239
+ *
240
+ * @param {Pick<Persona, 'passed' | 'start' | 'landing'>} persona - The persona.
241
+ * @returns {string | null} The experience key, or `null` for a persona with no journey.
242
+ * @example startOf(persona) // 'needs-analysis'
243
+ */
244
+ export declare function startOf(persona: Pick<Persona, 'passed' | 'start' | 'landing'>): string | null;
245
+ /** The host's personas with this browser's edits applied, and the reviewer's own. */
246
+ export interface EditedPersonas {
247
+ /** The host's personas, each replaced by its edit when this browser holds one. */
248
+ site: Persona[];
249
+ /** The personas this browser saved that edit none of the host's. */
250
+ own: Persona[];
251
+ }
252
+ /**
253
+ * Applies this browser's edits to the host's personas. An edit is a saved persona with a host
254
+ * persona's id; it keeps the host's heading, start and setup.
255
+ *
256
+ * @param {readonly Persona[]} site - The host's personas.
257
+ * @param {readonly Persona[]} saved - The personas saved in this browser.
258
+ * @returns {EditedPersonas} The host's personas as edited, and the reviewer's own.
259
+ * @example applyEdits(options.personas, readSavedPersonas(getStorage, projectId))
260
+ */
261
+ export declare function applyEdits(site: readonly Persona[], saved: readonly Persona[]): EditedPersonas;
262
+ /** Personas listed under one heading. */
263
+ export interface PersonaGroup {
264
+ title: string;
265
+ personas: Persona[];
266
+ }
267
+ /**
268
+ * Lists personas under their headings, in the order each heading first appears.
269
+ *
270
+ * @param {readonly Persona[]} personas - The personas.
271
+ * @param {string} fallback - The heading for a persona with no group.
272
+ * @returns {PersonaGroup[]} The headings and their personas.
273
+ * @example groupPersonas(personas, 'For this site')
274
+ */
275
+ export declare function groupPersonas(personas: readonly Persona[], fallback: string): PersonaGroup[];
276
+ /**
277
+ * Whether a persona matches what the reviewer searched for: every word appears in its name,
278
+ * description or heading.
279
+ *
280
+ * @param {Persona} persona - The persona.
281
+ * @param {string} search - What was typed.
282
+ * @returns {boolean} `true` when it matches, and always for an empty search.
283
+ * @example matchesSearch(persona, 'moving gas')
284
+ */
285
+ export declare function matchesSearch(persona: Persona, search: string): boolean;
@@ -1 +1 @@
1
- import{isBlank as e}from"../answer-history/answer-history.js";import{deslug as t}from"../plain-language/plain-language.js";var n=`picked-devtools-personas`,r=1;function i(e){return`${n}:${e}`}function a(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function o(e){return e===null||typeof e==`string`||typeof e==`boolean`?!0:typeof e==`number`?Number.isFinite(e):Array.isArray(e)?e.every(o):a(e)&&Object.values(e).every(o)}function s(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))o(r)&&(t[n]=r);return t}function c(e){if(typeof e!=`string`)return null;let t=e.trim();return t.length>0?t:null}function l(e){if(!a(e))return null;let t=c(e.experienceKey);return t===null?null:{experienceKey:t,experienceName:c(e.experienceName),stepKey:c(e.stepKey),stepName:c(e.stepName)}}function u(e){return Array.isArray(e)?e.flatMap(e=>{if(!a(e))return[];let t=c(e.experienceKey),n=c(e.stepKey);return t===null||n===null?[]:[{experienceKey:t,stepKey:n}]}):[]}function d(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))typeof r==`boolean`&&(t[n]=r);return t}function f(e){let t=c(e);return t!==null&&t.startsWith(`/`)&&!t.startsWith(`//`)?t:null}function p(e,t){let n=e.trim();if(n.length===0)return null;try{let e=new URL(n,t);return e.origin===t?`${e.pathname}${e.search}`:null}catch{return null}}function m(e){if(!a(e))return null;let t=c(e.id),n=c(e.name);return t===null||n===null?null:{id:t,name:n,description:c(e.description),source:`saved`,responses:s(e.responses),landing:l(e.landing),passed:u(e.passed),choices:d(e.choices),entryUrl:f(e.entryUrl),savedAt:c(e.savedAt),load:null}}function h(e,t){let n;try{n=e().getItem(i(t))}catch{return[]}if(n===null)return[];let r;try{r=JSON.parse(n)}catch{return[]}return!a(r)||!Array.isArray(r.personas)?[]:r.personas.map(m).filter(e=>e!==null)}function g(e,t,n){let a=n.map(e=>({id:e.id,name:e.name,description:e.description,responses:e.responses,landing:e.landing,passed:e.passed,choices:e.choices,entryUrl:e.entryUrl,savedAt:e.savedAt}));try{return e().setItem(i(t),JSON.stringify({version:r,personas:a})),!0}catch{return!1}}function _(e,t){let n=new Set,r=[];for(let{journey:i,screen:a}of e){let e=`${i}/${a}`;t.has(i)||n.has(e)||(n.add(e),r.push({experienceKey:i,stepKey:a}))}return r}function v(t,n){let r={};for(let[i,a]of Object.entries(t))n.has(i)&&!e(a)&&(r[i]=a);return r}function y(e,t){let n=c(e.name);return n===null?{ok:!1,error:`Give it a name.`}:Object.keys(t.responses).length===0&&t.journey===null?{ok:!1,error:`Nothing to save yet. Give a response first.`}:{ok:!0,persona:{id:t.id,name:n,description:c(e.description),source:`saved`,responses:s(t.responses),landing:t.journey,passed:t.passed,choices:d(t.choices),entryUrl:f(t.entryUrl),savedAt:t.savedAt,load:null}}}function b(e){return Array.isArray(e)?e.flatMap(e=>{if(!a(e))return[];let t=c(e.id),n=c(e.name);if(t===null||n===null)return[];let r=c(e.experienceKey),i=e.load;return[{id:t,name:n,description:c(e.description),source:`site`,responses:s(e.responses),landing:r===null?null:{experienceKey:r,experienceName:null,stepKey:c(e.stepKey),stepName:null},passed:[],choices:{},entryUrl:f(e.entryUrl),savedAt:null,load:typeof i==`function`?async e=>{await Reflect.apply(i,void 0,[e])}:null}]}):[]}function x(e){if(e===null)return`Responses only`;let n=e.experienceName??t(e.experienceKey);return e.stepKey===null?`Opens ${n}`:`Lands on ${e.stepName??t(e.stepKey)} in ${n}`}export{n as PERSONA_STORAGE_PREFIX,y as buildPersona,v as customerAnswers,x as describeLanding,p as entryUrlOf,_ as passedScreens,i as personaStorageKey,s as readAnswers,f as readEntryUrl,h as readSavedPersonas,b as sitePersonas,g as writeSavedPersonas};
1
+ import{isBlank as e}from"../answer-history/answer-history.js";import{deslug as t}from"../plain-language/plain-language.js";var n=`picked-devtools-personas`,r=1;function i(e){return`${n}:${e}`}function a(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function o(e){return e===null||typeof e==`string`||typeof e==`boolean`?!0:typeof e==`number`?Number.isFinite(e):Array.isArray(e)?e.every(o):a(e)&&Object.values(e).every(o)}function s(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))o(r)&&(t[n]=r);return t}function c(e){if(typeof e!=`string`)return null;let t=e.trim();return t.length>0?t:null}function l(e){if(!a(e))return null;let t=c(e.experienceKey);return t===null?null:{experienceKey:t,experienceName:c(e.experienceName),stepKey:c(e.stepKey),stepName:c(e.stepName)}}function u(e){return Array.isArray(e)?e.flatMap(e=>{if(!a(e))return[];let t=c(e.experienceKey),n=c(e.stepKey);return t===null||n===null?[]:[{experienceKey:t,stepKey:n}]}):[]}function d(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))typeof r==`boolean`&&(t[n]=r);return t}function f(e){let t=c(e);return t!==null&&t.startsWith(`/`)&&!t.startsWith(`//`)?t:null}function p(e,t){let n=e.trim();if(n.length===0)return null;try{let e=new URL(n,t);return e.origin===t?`${e.pathname}${e.search}`:null}catch{return null}}function m(e){if(!a(e))return null;let t=c(e.id),n=c(e.name);return t===null||n===null?null:{id:t,name:n,description:c(e.description),source:`saved`,group:null,responses:s(e.responses),landing:l(e.landing),start:null,passed:u(e.passed),choices:d(e.choices),entryUrl:f(e.entryUrl),savedAt:c(e.savedAt),setup:null,edited:!1}}function h(e,t){let n;try{n=e().getItem(i(t))}catch{return[]}if(n===null)return[];let r;try{r=JSON.parse(n)}catch{return[]}return!a(r)||!Array.isArray(r.personas)?[]:r.personas.map(m).filter(e=>e!==null)}function g(e,t,n){let a=n.map(e=>({id:e.id,name:e.name,description:e.description,responses:e.responses,landing:e.landing,passed:e.passed,choices:e.choices,entryUrl:e.entryUrl,savedAt:e.savedAt}));try{return e().setItem(i(t),JSON.stringify({version:r,personas:a})),!0}catch{return!1}}function _(e,t){let n=new Set,r=[];for(let{journey:i,screen:a}of e){let e=`${i}/${a}`;t.has(i)||n.has(e)||(n.add(e),r.push({experienceKey:i,stepKey:a}))}return r}function v(t,n){let r={};for(let[i,a]of Object.entries(t))n.has(i)&&!e(a)&&(r[i]=a);return r}function y(e,t){let n=c(e.name);return n===null?{ok:!1,error:`Give it a name.`}:Object.keys(t.responses).length===0&&t.journey===null?{ok:!1,error:`Nothing to save yet. Give a response first.`}:{ok:!0,persona:{id:t.id,name:n,description:c(e.description),source:`saved`,group:null,responses:s(t.responses),landing:t.journey,start:null,passed:t.passed,choices:d(t.choices),entryUrl:f(t.entryUrl),savedAt:t.savedAt,setup:null,edited:!1}}}function b(e){return Array.isArray(e)?e.flatMap(e=>{if(!a(e))return[];let t=c(e.id),n=c(e.name);if(t===null||n===null)return[];let r=c(e.experienceKey),i=e.setup;return[{id:t,name:n,description:c(e.description),source:`site`,group:c(e.group),responses:s(e.responses),landing:r===null?null:{experienceKey:r,experienceName:null,stepKey:c(e.stepKey),stepName:null},start:c(e.startExperienceKey),passed:[],choices:{},entryUrl:f(e.entryUrl),savedAt:null,setup:typeof i==`function`?async e=>{await Reflect.apply(i,void 0,[e])}:null,edited:!1}]}):[]}function x(e){if(e===null)return`Responses only`;let n=e.experienceName??t(e.experienceKey);return e.stepKey===null?`Opens ${n}`:`Lands on ${e.stepName??t(e.stepKey)} in ${n}`}function S(e){return e.passed.at(0)?.experienceKey??e.start??e.landing?.experienceKey??null}function C(e,t){let n=new Map(t.map(e=>[e.id,e])),r=new Set(e.map(e=>e.id));return{site:e.map(e=>{let t=n.get(e.id);return t===void 0?e:{...t,source:`site`,group:e.group,start:e.start,setup:e.setup,edited:!0}}),own:t.filter(e=>!r.has(e.id))}}function w(e,t){let n=new Map;for(let r of e){let e=r.group??t;n.set(e,[...n.get(e)??[],r])}return[...n].map(([e,t])=>({title:e,personas:t}))}function T(e,t){let n=[e.name,e.description,e.group].join(` `).toLowerCase();return t.toLowerCase().split(/\s+/).filter(e=>e.length>0).every(e=>n.includes(e))}export{n as PERSONA_STORAGE_PREFIX,C as applyEdits,y as buildPersona,v as customerAnswers,x as describeLanding,p as entryUrlOf,w as groupPersonas,T as matchesSearch,_ as passedScreens,i as personaStorageKey,s as readAnswers,f as readEntryUrl,l as readLanding,u as readPassed,h as readSavedPersonas,b as sitePersonas,S as startOf,g as writeSavedPersonas};
@@ -1,3 +1,3 @@
1
1
  /** @file Public entrypoint for the review tools' vocabulary contract. */
2
- export { DEVELOPER_LABEL, NODE_LABELS, TAB_INTROS, TAB_LABELS, configSourceLabel, deslug, screenName, } from './plain-language';
2
+ export { NODE_LABELS, TAB_INTROS, ADVANCED_LABEL, TAB_LABELS, configSourceLabel, deslug, screenName, } from './plain-language';
3
3
  export type { ReviewTabId } from './plain-language';
@@ -1 +1 @@
1
- export{DEVELOPER_LABEL,NODE_LABELS,TAB_INTROS,TAB_LABELS,configSourceLabel,deslug,screenName}from"./plain-language.js";
1
+ export{ADVANCED_LABEL,NODE_LABELS,TAB_INTROS,TAB_LABELS,configSourceLabel,deslug,screenName}from"./plain-language.js";
@@ -53,20 +53,20 @@ export declare const TAB_LABELS: {
53
53
  readonly guide: 'How to use';
54
54
  readonly session: 'This session';
55
55
  readonly answers: 'Responses';
56
- readonly journey: 'Flow';
56
+ readonly journey: 'Experiences';
57
57
  readonly personas: 'Personas & Scenarios';
58
58
  readonly variations: 'Feature flags';
59
59
  };
60
60
  /** The id of one of the review tools' own tabs. */
61
61
  export type ReviewTabId = keyof typeof TAB_LABELS;
62
- /** The name of the sidebar section that holds the developer tools, apart from the review tabs. */
63
- export declare const DEVELOPER_LABEL = "Developer";
62
+ /** The name of the folding sidebar group that holds the SDK's own screens. */
63
+ export declare const ADVANCED_LABEL = "Advanced";
64
64
  /** The one-sentence purpose each tab leads with. Kept together so the tone stays consistent. */
65
65
  export declare const TAB_INTROS: {
66
66
  readonly guide: 'How to use each tab. Open a tab straight from its section.';
67
67
  readonly session: 'Where you are right now, and your responses on each step. Start here if something looks wrong.';
68
68
  readonly answers: 'Every field this experience can ask for, and the responses given. Change one to see what the visitor would see.';
69
- readonly journey: 'Every route this experience can take, with your route marked. Go back to any step you have already visited.';
70
- readonly personas: 'Visitors and situations to try this experience as. Load one to land where it would, or save where you are as a new one.';
69
+ readonly journey: 'Every experience on this site: the route each can take, with yours marked, and where and when it shows. Go back to any step you have already visited.';
70
+ readonly personas: 'Visitors and situations to try this experience as. Walk through as one, or jump to a step on its route.';
71
71
  readonly variations: 'The feature flags running on this site. Force a variant to review it, then copy the link to show someone else exactly what you saw.';
72
72
  };
@@ -1 +1 @@
1
- var e={origin:`Origin`,step:`Step`,condition:`Condition`,action:`Action`,experience:`Opens another experience`};function t(e){let t=e.replace(/[-_]+/g,` `).trim();return t.length===0?e:t.charAt(0).toUpperCase()+t.slice(1)}function n(e,n){let r=e?.trim();return r!==void 0&&r.length>0?r:n!==void 0&&n.length>0?t(n):`Untitled step`}function r(e){return e!==void 0&&e.length>0?`Viewing unpublished changes`:`Viewing published content`}var i={guide:`How to use`,session:`This session`,answers:`Responses`,journey:`Flow`,personas:`Personas & Scenarios`,variations:`Feature flags`},a=`Developer`,o={guide:`How to use each tab. Open a tab straight from its section.`,session:`Where you are right now, and your responses on each step. Start here if something looks wrong.`,answers:`Every field this experience can ask for, and the responses given. Change one to see what the visitor would see.`,journey:`Every route this experience can take, with your route marked. Go back to any step you have already visited.`,personas:`Visitors and situations to try this experience as. Load one to land where it would, or save where you are as a new one.`,variations:`The feature flags running on this site. Force a variant to review it, then copy the link to show someone else exactly what you saw.`};export{a as DEVELOPER_LABEL,e as NODE_LABELS,o as TAB_INTROS,i as TAB_LABELS,r as configSourceLabel,t as deslug,n as screenName};
1
+ var e={origin:`Origin`,step:`Step`,condition:`Condition`,action:`Action`,experience:`Opens another experience`};function t(e){let t=e.replace(/[-_]+/g,` `).trim();return t.length===0?e:t.charAt(0).toUpperCase()+t.slice(1)}function n(e,n){let r=e?.trim();return r!==void 0&&r.length>0?r:n!==void 0&&n.length>0?t(n):`Untitled step`}function r(e){return e!==void 0&&e.length>0?`Viewing unpublished changes`:`Viewing published content`}var i={guide:`How to use`,session:`This session`,answers:`Responses`,journey:`Experiences`,personas:`Personas & Scenarios`,variations:`Feature flags`},a=`Advanced`,o={guide:`How to use each tab. Open a tab straight from its section.`,session:`Where you are right now, and your responses on each step. Start here if something looks wrong.`,answers:`Every field this experience can ask for, and the responses given. Change one to see what the visitor would see.`,journey:`Every experience on this site: the route each can take, with yours marked, and where and when it shows. Go back to any step you have already visited.`,personas:`Visitors and situations to try this experience as. Walk through as one, or jump to a step on its route.`,variations:`The feature flags running on this site. Force a variant to review it, then copy the link to show someone else exactly what you saw.`};export{a as ADVANCED_LABEL,e as NODE_LABELS,o as TAB_INTROS,i as TAB_LABELS,r as configSourceLabel,t as deslug,n as screenName};
@@ -5,4 +5,4 @@ export type * from './plugin';
5
5
  export type * from './options';
6
6
  export type { CustomTool, CustomToolGuide, CustomToolIcon, CustomToolPlacement, } from './customTools';
7
7
  export type { DevtoolsSession } from './session-access';
8
- export type { PersonaDefinition, PersonaLoader } from './derive/personas';
8
+ export type { PersonaDefinition, PersonaSetup } from './derive/personas';
@@ -11,13 +11,20 @@
11
11
  autoHide: boolean
12
12
  /** The tag beside the button: a preview session, a preview whose workspace is gone, or none. */
13
13
  preview: 'preview' | 'unavailable' | null
14
+ /** The persona the tab walks through as, named in the tag; `null` outside a persona session. */
15
+ walkingAs?: string | null
14
16
  /** Opens the panel. */
15
17
  onOpen: () => void
16
18
  }
17
19
 
18
- const { position, autoHide, preview, onOpen }: Props = $props()
20
+ const { position, autoHide, preview, walkingAs = null, onOpen }: Props = $props()
19
21
 
20
22
  const edges = $derived(launcherEdges(position))
23
+ const tag = $derived(
24
+ [preview === 'preview' ? 'Preview mode' : '', walkingAs === null ? '' : `As ${walkingAs}`]
25
+ .filter((part) => part.length > 0)
26
+ .join(' · '),
27
+ )
21
28
  </script>
22
29
 
23
30
  <div
@@ -33,12 +40,14 @@
33
40
  aria-live="polite"
34
41
  title="This preview link's workspace no longer exists. The published site is shown."
35
42
  >Preview unavailable — showing published</span>
36
- {:else if preview === 'preview'}
37
- <span class="preview-tag" role="status" aria-live="polite">Preview mode</span>
43
+ {:else if tag.length > 0}
44
+ <span class="preview-tag" role="status" aria-live="polite" data-testid="devtools-launcher-tag">
45
+ {tag}
46
+ </span>
38
47
  {/if}
39
48
  <button
40
49
  class="floating-button"
41
- class:has-preview-tag={preview !== null}
50
+ class:has-preview-tag={preview === 'unavailable' || tag.length > 0}
42
51
  onclick={onOpen}
43
52
  aria-label="Open Picked Devtools"
44
53
  title="Open Picked Devtools"
@@ -97,10 +97,11 @@ export interface DevtoolsPanelOptions {
97
97
  /** The floating button's corner and auto-hide. */
98
98
  launcher?: LauncherOptions;
99
99
  /**
100
- * Visitors and situations every viewer can load from the Personas & Scenarios tab. Reviewers
101
- * also save their own there, in their browser.
100
+ * Visitors and situations every viewer can load from the Personas & Scenarios tab, or a function
101
+ * that loads them, such as a dynamic import a production build can drop. Reviewers also save
102
+ * their own there, in their browser.
102
103
  */
103
- personas?: readonly PersonaDefinition[];
104
+ personas?: readonly PersonaDefinition[] | (() => Promise<readonly PersonaDefinition[]>);
104
105
  /** How loading a persona walks an experience. */
105
106
  personaWalk?: PersonaWalkOptions;
106
107
  }
@@ -118,7 +119,8 @@ export interface ResolvedDevtoolsOptions {
118
119
  fixes: DevtoolsGuideFix[];
119
120
  };
120
121
  launcher: Required<LauncherOptions>;
121
- personas: Persona[];
122
+ /** The site's personas, loaded on the first call. */
123
+ personas: () => Promise<Persona[]>;
122
124
  personaWalk: {
123
125
  selfAdvancingSteps: ReadonlySet<string>;
124
126
  responses: Readonly<Record<string, FieldValue>>;
@@ -1 +1 @@
1
- import{DEVTOOLS_EXPERIENCE_KEY as e,DEVTOOLS_PLUGIN_NAME as t}from"./constants.js";import{readAnswers as n,sitePersonas as r}from"./derive/personas/personas.js";var i=[`top-left`,`top-right`,`bottom-left`,`bottom-right`];function a(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function o(e){return Array.isArray(e)?e.filter(e=>typeof e==`string`):[]}function s(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))typeof r==`string`&&(t[n]=r);return t}function c(e){return i.some(t=>t===e)}function l(e){if(!a(e))return null;let{id:t,title:n,steps:r,notes:i}=e;return typeof t!=`string`||typeof n!=`string`||!Array.isArray(r)?null:{id:t,title:n,steps:o(r),notes:o(i)}}function u(e){if(!a(e))return null;let{problem:t,fix:n}=e;return typeof t!=`string`||typeof n!=`string`?null:{problem:t,fix:n}}function d(e){if(!a(e))return null;let{label:t,run:n}=e;return typeof t!=`string`||typeof n!=`function`?null:{label:t,run:e=>{Reflect.apply(n,void 0,[e])}}}function f(i){let f=i.getPluginConfig(t).options,p=a(f)?f:{},m=a(p.guide)?p.guide:{},h=a(p.launcher)?p.launcher:{},g=a(p.personaWalk)?p.personaWalk:{},_=p.environmentLabel;return{readOnly:p.readOnly===!0,environmentLabel:typeof _==`string`&&_.length>0?_:void 0,hiddenExperienceKeys:new Set([e,...o(p.hiddenExperienceKeys)]),experiencePrecedence:o(p.experiencePrecedence),codeOwnedFields:new Set(o(p.codeOwnedFields)),responseFollowUp:d(p.responseFollowUp),guide:{sections:(Array.isArray(m.sections)?m.sections:[]).map(l).filter(e=>e!==null),fixes:(Array.isArray(m.fixes)?m.fixes:[]).map(u).filter(e=>e!==null)},launcher:{position:c(h.position)?h.position:`bottom-right`,autoHide:typeof h.autoHide!=`boolean`||h.autoHide},personas:r(p.personas),personaWalk:{selfAdvancingSteps:new Set(o(g.selfAdvancingSteps)),responses:n(g.responses),handoffs:s(g.handoffs)}}}export{e as DEVTOOLS_EXPERIENCE_KEY,i as LAUNCHER_POSITIONS,f as readDevtoolsOptions};
1
+ import{DEVTOOLS_EXPERIENCE_KEY as e,DEVTOOLS_PLUGIN_NAME as t}from"./constants.js";import{readAnswers as n,sitePersonas as r}from"./derive/personas/personas.js";var i=[`top-left`,`top-right`,`bottom-left`,`bottom-right`];function a(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function o(e){return Array.isArray(e)?e.filter(e=>typeof e==`string`):[]}function s(e){if(!a(e))return{};let t={};for(let[n,r]of Object.entries(e))typeof r==`string`&&(t[n]=r);return t}function c(e){return i.some(t=>t===e)}function l(e){if(!a(e))return null;let{id:t,title:n,steps:r,notes:i}=e;return typeof t!=`string`||typeof n!=`string`||!Array.isArray(r)?null:{id:t,title:n,steps:o(r),notes:o(i)}}function u(e){if(!a(e))return null;let{problem:t,fix:n}=e;return typeof t!=`string`||typeof n!=`string`?null:{problem:t,fix:n}}function d(e){if(!a(e))return null;let{label:t,run:n}=e;return typeof t!=`string`||typeof n!=`function`?null:{label:t,run:e=>{Reflect.apply(n,void 0,[e])}}}function f(e){if(typeof e!=`function`){let t=r(e);return()=>Promise.resolve(t)}let t=null;return()=>(t??=Promise.resolve().then(()=>Reflect.apply(e,void 0,[])).then(e=>r(e)).catch(()=>[]),t)}function p(r){let i=r.getPluginConfig(t).options,p=a(i)?i:{},m=a(p.guide)?p.guide:{},h=a(p.launcher)?p.launcher:{},g=a(p.personaWalk)?p.personaWalk:{},_=p.environmentLabel;return{readOnly:p.readOnly===!0,environmentLabel:typeof _==`string`&&_.length>0?_:void 0,hiddenExperienceKeys:new Set([e,...o(p.hiddenExperienceKeys)]),experiencePrecedence:o(p.experiencePrecedence),codeOwnedFields:new Set(o(p.codeOwnedFields)),responseFollowUp:d(p.responseFollowUp),guide:{sections:(Array.isArray(m.sections)?m.sections:[]).map(l).filter(e=>e!==null),fixes:(Array.isArray(m.fixes)?m.fixes:[]).map(u).filter(e=>e!==null)},launcher:{position:c(h.position)?h.position:`bottom-right`,autoHide:typeof h.autoHide!=`boolean`||h.autoHide},personas:f(p.personas),personaWalk:{selfAdvancingSteps:new Set(o(g.selfAdvancingSteps)),responses:n(g.responses),handoffs:s(g.handoffs)}}}export{e as DEVTOOLS_EXPERIENCE_KEY,i as LAUNCHER_POSITIONS,p as readDevtoolsOptions};
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file The Experiences tab's Details view: what one experience is, where on the page it shows,
4
+ * when it starts, and its instances on this page.
5
+ */
6
+ import type { ExperienceStore, Picked } from '@picked/client'
7
+ import { Card, CodeBlock, Text } from '../../ui'
8
+ import { describeRule, type FieldLookup } from '../../derive'
9
+ import ExperienceInstancesTable from '../../components/ExperienceInstancesTable.svelte'
10
+
11
+ interface ExperienceDetailsProps {
12
+ client: Picked
13
+ experience: ExperienceStore
14
+ /** Names a field the condition reads. */
15
+ fieldLookup: FieldLookup
16
+ }
17
+
18
+ const { client, experience, fieldLookup }: ExperienceDetailsProps = $props()
19
+
20
+ const config = $derived(experience.configStore)
21
+ const component = $derived(experience.componentStore)
22
+ const instancesStore = $derived(client.experienceInstances.listStore)
23
+ const instances = $derived(
24
+ $instancesStore.filter((instance) => instance.experienceKey === experience.key),
25
+ )
26
+
27
+ /** How a placement puts the experience on the page, by placement. */
28
+ const PLACEMENTS = {
29
+ replace: 'In place of',
30
+ append: 'At the end of',
31
+ prepend: 'At the start of',
32
+ before: 'Before',
33
+ after: 'After',
34
+ } as const
35
+
36
+ /** The condition's verbatim rule is shown while this is `true`. */
37
+ let showRule = $state(false)
38
+ </script>
39
+
40
+ <div class="details" data-testid="devtools-experience-details">
41
+ <Card sectioned>
42
+ <dl class="facts">
43
+ <dt>Name</dt>
44
+ <dd>{experience.name}</dd>
45
+ <dt>Key</dt>
46
+ <dd><code>{experience.key}</code></dd>
47
+ <dt>Component</dt>
48
+ <dd>{$component?.name ?? 'None'}</dd>
49
+ <dt>Shows on the page</dt>
50
+ <dd>
51
+ {#if $config.targetSelectors.length === 0}
52
+ Nowhere by itself. Code, or another experience, opens it.
53
+ {:else}
54
+ <ul class="targets">
55
+ {#each $config.targetSelectors as target (`${target.selector}/${target.placement}`)}
56
+ <li>{PLACEMENTS[target.placement]} <code>{target.selector}</code></li>
57
+ {/each}
58
+ </ul>
59
+ {/if}
60
+ </dd>
61
+ <dt>Starts when</dt>
62
+ <dd>
63
+ <span data-testid="devtools-experience-condition">
64
+ {describeRule($config.decisionLogic, fieldLookup)}
65
+ </span>
66
+ <button type="button" class="disclosure" onclick={() => (showRule = !showRule)}>
67
+ {showRule ? 'Hide rule' : 'Show rule'}
68
+ </button>
69
+ {#if showRule}
70
+ <CodeBlock code={JSON.stringify($config.decisionLogic ?? null, null, 2)} />
71
+ {/if}
72
+ </dd>
73
+ </dl>
74
+ </Card>
75
+
76
+ <section class="instances" aria-label="On this page">
77
+ <Text weight="semibold">On this page</Text>
78
+ <ExperienceInstancesTable experienceInstances={instances} />
79
+ </section>
80
+ </div>
81
+
82
+ <style>
83
+ .details {
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: var(--space-4);
87
+ }
88
+
89
+ .facts {
90
+ display: grid;
91
+ grid-template-columns: max-content 1fr;
92
+ gap: var(--space-2) var(--space-4);
93
+ margin: 0;
94
+ padding: var(--space-4);
95
+ font-size: var(--text-sm);
96
+ }
97
+
98
+ .facts dt {
99
+ color: var(--color-gray-500);
100
+ }
101
+
102
+ .facts dd {
103
+ margin: 0;
104
+ min-width: 0;
105
+ color: var(--color-gray-900);
106
+ }
107
+
108
+ .targets {
109
+ margin: 0;
110
+ padding-left: var(--space-4);
111
+ }
112
+
113
+ .disclosure {
114
+ margin-left: var(--space-2);
115
+ padding: 0;
116
+ border: 0;
117
+ background: none;
118
+ color: var(--color-blue-600);
119
+ font: inherit;
120
+ font-size: var(--text-xs);
121
+ cursor: pointer;
122
+ }
123
+
124
+ .instances {
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: var(--space-2);
128
+ }
129
+ </style>
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * @file The Flow tab: any experience's flow in plain English — the Developer group's
4
- * experience tree without the node ids or the raw JSON Logic. It opens on the experience in
5
- * front of the reviewer, and the picker reaches every other experience in the workspace.
3
+ * @file The Experiences tab: any experience's flow in plain English, without node ids or raw
4
+ * JSON Logic, and its details. It opens on the experience in front of the reviewer, the picker
5
+ * reaches every other experience in the workspace, and Launch experience opens the one picked.
6
6
  *
7
7
  * Navigation is BACKWARDS ONLY, and only in the experience that is OPEN. A step already visited
8
8
  * is a plain `goToStepByKey`: the flow has been through every action in between, so returning to
@@ -13,13 +13,15 @@
13
13
  * THREE VIEWS, ONE GRAPH. The outline answers "where am I, and why did it go there"; the map
14
14
  * answers "what shape is this, and where does each branch end"; the timeline answers "in what
15
15
  * order, and where do the branches meet again". All three read the same `treeStore` through the
16
- * same vocabulary modules, so a step is named identically in each and they cannot drift.
16
+ * same vocabulary modules, so a step is named identically in each and they cannot drift. The
17
+ * fourth view, Details, says where the experience shows, when it starts, and its instances.
17
18
  */
18
19
  import type { Picked } from '@picked/client'
19
20
  import { Badge, Banner, Button, Card, EmptyState } from '../../ui'
20
21
  import { buildOutline, buildTimeline, buildTree } from '../../derive'
21
22
  import type { FieldLookup, OutlineRow, SafetyPosture, Trails } from '../../derive'
22
23
  import type { JourneyView } from '../../session'
24
+ import ExperienceDetails from './ExperienceDetails.svelte'
23
25
  import JourneyMap from './JourneyMap.svelte'
24
26
  import JourneyTimeline from './JourneyTimeline.svelte'
25
27
 
@@ -62,7 +64,7 @@
62
64
  // The union goes on `$state`, not on the `let`. With the annotation alone TypeScript narrows the
63
65
  // variable to its initial literal and the `view === 'map'` below reads as a comparison that can
64
66
  // never hold — `$derived` is an expression in this same scope, so nothing widens it back.
65
- let view = $state<'outline' | 'map' | 'timeline'>('map')
67
+ let view = $state<'outline' | 'map' | 'timeline' | 'details'>('map')
66
68
 
67
69
  /** The open journey, unless it is one the tab never follows. */
68
70
  const followed = $derived(
@@ -98,11 +100,12 @@
98
100
  //
99
101
  // It reads the shared `listStore` rather than `experiences.getByKey`, which mints a NEW derived
100
102
  // store per call and never releases it.
101
- const treeStore = $derived(
103
+ const selectedExperience = $derived(
102
104
  selectedKey === null
103
105
  ? undefined
104
- : $experiences.find((experience) => experience.key === selectedKey)?.treeStore,
106
+ : $experiences.find((experience) => experience.key === selectedKey),
105
107
  )
108
+ const treeStore = $derived(selectedExperience?.treeStore)
106
109
  const graph: FlowGraph = $derived($treeStore ?? EMPTY_GRAPH)
107
110
 
108
111
  // Fields arrive asynchronously and AFTER the graph does. Read reactively, so the views re-render
@@ -204,6 +207,17 @@
204
207
  picked = key === followed?.experienceKey ? null : key
205
208
  }
206
209
 
210
+ /**
211
+ * Opens the experience picked in the picker, past its own condition.
212
+ *
213
+ * @example
214
+ * launch()
215
+ */
216
+ function launch(): void {
217
+ if (selectedKey === null) return
218
+ void client.experiences.open({ key: selectedKey, options: { bypassCondition: true } })
219
+ }
220
+
207
221
  /**
208
222
  * Shows a decision's verbatim rule, or hides it again.
209
223
  *
@@ -223,6 +237,19 @@
223
237
  hint="The workspace has not loaded its experiences. Reload the page if this does not change."
224
238
  />
225
239
  {:else}
240
+ <div class="launch">
241
+ <Button
242
+ variant="primary"
243
+ size="small"
244
+ disabled={!posture.canNavigate}
245
+ title={posture.canNavigate ? undefined : (posture.reason ?? undefined)}
246
+ testId="devtools-journey-launch"
247
+ onclick={launch}
248
+ >
249
+ Launch experience
250
+ </Button>
251
+ </div>
252
+
226
253
  <!-- One row: how to look on the left, what to look at on the right. -->
227
254
  <div class="controls">
228
255
  <div class="views" role="group" aria-label="Flow view">
@@ -256,6 +283,16 @@
256
283
  >
257
284
  Timeline
258
285
  </button>
286
+ <button
287
+ type="button"
288
+ class="view"
289
+ class:selected={view === 'details'}
290
+ aria-pressed={view === 'details'}
291
+ data-testid="devtools-journey-view-details"
292
+ onclick={() => (view = 'details')}
293
+ >
294
+ Details
295
+ </button>
259
296
  </div>
260
297
 
261
298
  <div class="picker">
@@ -284,7 +321,11 @@
284
321
  <Banner status="warning" testId="devtools-journey-readonly">{posture.reason}</Banner>
285
322
  {/if}
286
323
 
287
- {#if rows.length === 0}
324
+ {#if view === 'details'}
325
+ {#if selectedExperience !== undefined}
326
+ <ExperienceDetails {client} experience={selectedExperience} {fieldLookup} />
327
+ {/if}
328
+ {:else if rows.length === 0}
288
329
  <Card sectioned>
289
330
  <EmptyState
290
331
  message="This experience has no steps yet"
@@ -380,6 +421,11 @@
380
421
  flex-direction: column;
381
422
  }
382
423
 
424
+ .launch {
425
+ display: flex;
426
+ justify-content: flex-end;
427
+ }
428
+
383
429
  .controls {
384
430
  display: flex;
385
431
  align-items: center;
@@ -1,38 +1,72 @@
1
1
  <script lang="ts">
2
- /** @file One choice in the persona builder: a toggle that shows whether it is picked. */
2
+ /**
3
+ * @file One choice in the persona builder: a toggle that shows whether it is picked. A tile is a
4
+ * large card, as a step draws a short list of choices; a pill is a compact chip, as it draws a
5
+ * long one.
6
+ */
3
7
  interface ChoiceButtonProps {
4
8
  label: string
5
9
  selected: boolean
6
10
  testId: string
7
11
  onclick: () => void
12
+ /** How it is drawn. */
13
+ variant?: 'pill' | 'tile'
14
+ /** A line under the label, from the option's own description. */
15
+ detail?: string
8
16
  }
9
17
 
10
- const { label, selected, testId, onclick }: ChoiceButtonProps = $props()
18
+ const { label, selected, testId, onclick, variant = 'pill', detail }: ChoiceButtonProps =
19
+ $props()
11
20
  </script>
12
21
 
13
22
  <button
14
23
  type="button"
15
- class="choice"
24
+ class="choice {variant}"
16
25
  class:selected
17
26
  aria-pressed={selected}
18
27
  data-testid={testId}
19
28
  {onclick}
20
29
  >
21
- {label}
30
+ <span class="label">{label}</span>
31
+ {#if detail}
32
+ <span class="detail">{detail}</span>
33
+ {/if}
22
34
  </button>
23
35
 
24
36
  <style>
25
37
  .choice {
26
- padding: var(--space-1) var(--space-3);
38
+ display: inline-flex;
39
+ flex-direction: column;
40
+ align-items: center;
41
+ justify-content: center;
42
+ gap: var(--space-0-5);
27
43
  border: 1px solid var(--color-gray-300);
28
- border-radius: var(--radius-md);
29
44
  background: var(--color-white);
30
45
  color: var(--color-gray-900);
31
46
  font: inherit;
32
47
  font-size: var(--text-sm);
48
+ text-align: center;
33
49
  cursor: pointer;
34
50
  }
35
51
 
52
+ .pill {
53
+ padding: var(--space-1) var(--space-3);
54
+ border-radius: 999px;
55
+ }
56
+
57
+ .tile {
58
+ min-height: 4.5rem;
59
+ padding: var(--space-3);
60
+ border-radius: var(--radius-md);
61
+ font-weight: var(--font-medium);
62
+ }
63
+
64
+ .detail {
65
+ color: var(--color-gray-500);
66
+ font-size: var(--text-xs);
67
+ font-weight: normal;
68
+ }
69
+
36
70
  .choice:hover {
37
71
  border-color: var(--color-gray-500);
38
72
  }