@picked/svelte 0.0.1-dev.1778730134956 → 0.0.1-dev.1780446300503

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.
@@ -88,7 +88,7 @@
88
88
  if (isConvincelyDomain(url)) {
89
89
  const encodedUrl = encodeURIComponent(url.toString())
90
90
  const optimizedSize = findClosestSize(targetWidth)
91
- return `https://img.convincely.io/_next/image?url=${encodedUrl}&w=${optimizedSize}&q=75`
91
+ return `https://img.picked-content.com/_next/image?url=${encodedUrl}&w=${optimizedSize}&q=75`
92
92
  }
93
93
 
94
94
  // For other domains, return the original URL
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file Slot-based experience renderer. Renders every registered experience that targets the
4
+ * named slot and whose condition is active, ordered by placement. Active is read synchronously
5
+ * from the seeded condition-results store (sync on SSR / hydration first paint); on a cache miss
6
+ * (standalone, no snapshot) the condition is evaluated asynchronously and the store update
7
+ * re-renders reactively.
8
+ */
9
+ import type { ExperienceStore, ExperienceTarget } from '@picked/client'
10
+ import { evaluateConditionMemo, hashRule } from '@picked/client/server'
11
+ import { getPicked, useUnit } from '../hooks'
12
+ import ExperienceHost from './ExperienceHost.svelte'
13
+
14
+ type ExperienceConfig = ReturnType<ExperienceStore['configStore']['getState']>
15
+ type ConditionRule = ExperienceConfig['decisionLogic']
16
+
17
+ interface Props {
18
+ /**
19
+ * Slot name. Maps to the `.picked-slot-<name>` selector that targeted experiences declare.
20
+ * Named `name` rather than `slot` because Svelte's compiler reserves the `slot` attribute for
21
+ * legacy named-slot assignment, so it cannot be used as a component prop.
22
+ */
23
+ name: string
24
+ }
25
+
26
+ const { name }: Props = $props()
27
+
28
+ const client = getPicked()
29
+ const selector = `.picked-slot-${name}`
30
+
31
+ const PLACEMENT_ORDER: ExperienceTarget['placement'][] = ['before', 'prepend', 'append', 'after']
32
+
33
+ interface Candidate {
34
+ experience: ExperienceStore
35
+ placement: ExperienceTarget['placement']
36
+ condition: ConditionRule
37
+ hash: string
38
+ }
39
+
40
+ const experiences = useUnit(client.experiences.listStore)
41
+ const conditionResults = useUnit(client.conditionResults.store)
42
+
43
+ /**
44
+ * Returns the sort rank for a placement; unknown placements sort last.
45
+ *
46
+ * @param {ExperienceTarget['placement']} placement - The target placement.
47
+ * @returns {number} The placement's index in the precedence order.
48
+ * @example
49
+ * placementRank('append')
50
+ */
51
+ function placementRank(placement: ExperienceTarget['placement']): number {
52
+ const index = PLACEMENT_ORDER.indexOf(placement)
53
+ return index === -1 ? PLACEMENT_ORDER.length : index
54
+ }
55
+
56
+ /**
57
+ * Maps an experience to a slot candidate, or null when it does not target this slot.
58
+ *
59
+ * @param {ExperienceStore} experience - The experience to inspect.
60
+ * @returns {Candidate | null} The candidate, or null when the experience does not target this slot.
61
+ * @example
62
+ * toCandidate(experienceStore)
63
+ */
64
+ function toCandidate(experience: ExperienceStore): Candidate | null {
65
+ const config = experience.configStore.getState()
66
+ const target = config.targetSelectors.find(
67
+ (selectorTarget) => selectorTarget.selector === selector,
68
+ )
69
+ if (!target) {
70
+ return null
71
+ }
72
+
73
+ return {
74
+ experience,
75
+ placement: target.placement,
76
+ condition: config.decisionLogic,
77
+ hash: hashRule(config.decisionLogic),
78
+ }
79
+ }
80
+
81
+ const candidates = $derived(
82
+ $experiences
83
+ .map(toCandidate)
84
+ .filter((candidate): candidate is Candidate => candidate !== null)
85
+ .sort((a, b) => placementRank(a.placement) - placementRank(b.placement)),
86
+ )
87
+
88
+ // Referencing `$conditionResults` keeps this recompute reactive to late condition writes
89
+ // (the standalone cache-miss path), so a `peek` that returned `undefined` re-renders once
90
+ // the async evaluation resolves and writes the store.
91
+ const active = $derived(
92
+ candidates.filter((candidate) => {
93
+ void $conditionResults
94
+ return client.conditionResults.peek(candidate.hash) === true
95
+ }),
96
+ )
97
+
98
+ /**
99
+ * Builds a plain, JSON-serialisable subset of the current URL for the evaluation context.
100
+ * The live `Location` object is intentionally not passed: json-logic predicates read plain
101
+ * values, and this mirrors the `SerializeLocation` shape the server selects against.
102
+ *
103
+ * @returns {Record<string, string>} The plain location subset.
104
+ * @example
105
+ * buildLocationContext()
106
+ */
107
+ function buildLocationContext(): Record<string, string> {
108
+ const { href, pathname, search, hostname, origin, protocol, port, host, hash } = window.location
109
+ return { href, pathname, search, hostname, origin, protocol, port, host, hash }
110
+ }
111
+
112
+ $effect(() => {
113
+ for (const candidate of candidates) {
114
+ if (client.conditionResults.peek(candidate.hash) === undefined) {
115
+ void evaluateConditionMemo(client, candidate.condition, {
116
+ context: { location: buildLocationContext() },
117
+ })
118
+ }
119
+ }
120
+ })
121
+ </script>
122
+
123
+ {#each active as candidate (candidate.experience.id)}
124
+ <ExperienceHost experience={candidate.experience} {client} />
125
+ {/each}
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file Internal host that instantiates a single experience, exposes the instance via context,
4
+ * and renders the resolved Svelte component.
5
+ *
6
+ * Lazy loaders and promise-wrapped components load asynchronously via `{#await}`: they render
7
+ * nothing on the server and on the hydration first paint, then swap in once resolved on the
8
+ * client, so the server and the client's first paint agree.
9
+ */
10
+ import { setContext } from 'svelte'
11
+ import type { Component } from 'svelte'
12
+ import type { ExperienceStore, Picked } from '@picked/client'
13
+ import { createExperienceInstance } from '@picked/client/server'
14
+ import { isLazyLoader } from '@picked/client'
15
+ import { EXPERIENCE_CONTEXT_KEY } from '../context'
16
+ import { useUnit } from '../hooks'
17
+
18
+ interface Props {
19
+ experience: ExperienceStore
20
+ client: Picked
21
+ }
22
+
23
+ const { experience, client }: Props = $props()
24
+
25
+ // `client` and `experience` are stable for this host's lifetime — `Experience` keys each host
26
+ // by `experience.id`, so a different experience mounts a new host rather than mutating props.
27
+ // Capturing their initial values is therefore intentional.
28
+ // svelte-ignore state_referenced_locally
29
+ const instance = createExperienceInstance({ client, experience })
30
+
31
+ setContext(EXPERIENCE_CONTEXT_KEY, instance)
32
+
33
+ const resolved = useUnit(instance.componentAndFrameworkStore)
34
+ // svelte-ignore state_referenced_locally
35
+ const ready = useUnit(client.readyStore)
36
+
37
+ // Slot-rendered experiences have no DOM target, so the normal lifecycle never loads their CMS
38
+ // content. Once the client is ready (so `client.contentLoader` is set by `start()`), load it
39
+ // here; `getStepContent` picks it up reactively. `$effect` does not run during SSR, where
40
+ // content arrives pre-registered via the snapshot.
41
+ $effect(() => {
42
+ if ($ready) {
43
+ void experience.prepareExperienceContentFx()
44
+ }
45
+ })
46
+
47
+ /**
48
+ * Narrows to a directly-renderable Svelte component, excluding lazy loaders and promises.
49
+ *
50
+ * @param {unknown} value - The adapted component's `component` field.
51
+ * @returns {boolean} True when the value is a direct Svelte component.
52
+ * @example
53
+ * isDirectSvelteComponent(SomeComponent)
54
+ */
55
+ function isDirectSvelteComponent(value: unknown): value is Component {
56
+ return typeof value === 'function' && !isLazyLoader(value) && !(value instanceof Promise)
57
+ }
58
+
59
+ /**
60
+ * Returns `value` when it is a directly-renderable Svelte component.
61
+ *
62
+ * @param {unknown} value - The candidate component (a loaded module's default export).
63
+ * @returns {Component} The Svelte component.
64
+ * @throws {Error} When the value is not a Svelte component.
65
+ * @example
66
+ * toComponent((await loader.load()).default)
67
+ */
68
+ function toComponent(value: unknown): Component {
69
+ if (isDirectSvelteComponent(value)) {
70
+ return value
71
+ }
72
+
73
+ throw new Error('Lazy experience component did not resolve to a Svelte component.')
74
+ }
75
+
76
+ /**
77
+ * Returns a promise resolving to the component for a lazy loader or promise-wrapped component,
78
+ * or null for a direct component (or when there is no component).
79
+ *
80
+ * @param {unknown} value - The adapted component's `component` field.
81
+ * @returns {Promise<Component> | null} The loader promise, or null.
82
+ * @example
83
+ * toComponentLoader(lazyLoader)
84
+ */
85
+ function toComponentLoader(value: unknown): Promise<Component> | null {
86
+ if (isLazyLoader(value)) {
87
+ return value.load().then((mod) => toComponent(mod.default))
88
+ }
89
+
90
+ if (value instanceof Promise) {
91
+ return value.then((mod) => toComponent(mod.default))
92
+ }
93
+
94
+ return null
95
+ }
96
+
97
+ const adapted = $derived($resolved?.component.component)
98
+ const svelteComponent = $derived(adapted ? adapted.component : undefined)
99
+ const componentLoader = $derived(toComponentLoader(svelteComponent))
100
+ </script>
101
+
102
+ {#if isDirectSvelteComponent(svelteComponent)}
103
+ {@const Comp = svelteComponent}
104
+ <Comp {client} experience={instance} />
105
+ {:else if componentLoader}
106
+ {#await componentLoader then Comp}
107
+ <Comp {client} experience={instance} />
108
+ {/await}
109
+ {/if}
@@ -0,0 +1,27 @@
1
+ import { CreateClientOptions, Picked } from '@picked/client';
2
+ import { PickedSnapshot } from '@picked/client/server';
3
+ type SetPickedContextParams = {
4
+ client: Picked;
5
+ } | {
6
+ options: CreateClientOptions;
7
+ snapshot?: PickedSnapshot;
8
+ };
9
+ /**
10
+ * Provides a Picked client to descendant components through Svelte context, starting it in the browser.
11
+ *
12
+ * Call this once at the top of a component's `<script>` so `setContext` and `onMount` run during
13
+ * component initialisation. Pass `options` to create the client and synchronously hydrate it from an
14
+ * optional SSR snapshot (a `versionId` mismatch is tolerated; the client reconciles via `start()`),
15
+ * or pass an existing `client` (such as the app's already-running global client) to render
16
+ * experiences against it. In the browser the client is started from `onMount`; `start()` is
17
+ * idempotent, so a client that was already started is left untouched.
18
+ *
19
+ * @param {SetPickedContextParams} params - Either `{ client }` or `{ options, snapshot? }`.
20
+ * @returns {Picked} The client placed into context.
21
+ * @example
22
+ * setPickedContext({ options })
23
+ * @example
24
+ * setPickedContext({ client: globalClient })
25
+ */
26
+ export declare function setPickedContext(params: SetPickedContextParams): Picked;
27
+ export {};
@@ -0,0 +1 @@
1
+ import{CLIENT_CONTEXT_KEY as e}from"../context.js";import{onMount as t,setContext as n}from"svelte";import{createClient as r,supportsWindow as i}from"@picked/client";import{registerProjectConfig as a}from"@picked/client/server";function o(e,t){let n=r(e);if(t){let e=n.session.store.getState().id;a(n,t.projectConfig,{isPreview:!1,sessionId:e}),t.content.forEach(e=>n.registerContent(e)),n.conditionResults.seed(t.conditionResults)}return n}function s(r){let a=`client`in r?r.client:o(r.options,r.snapshot);return n(e,a),t(()=>{i()&&a.start()}),a}export{s as setPickedContext};
@@ -1 +1 @@
1
- import{getPicked as e}from"./client.js";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";import"@picked/client";function o(t,n){return e().actions.getEvokableAction(t,n)}function s(e,s){let c=o(e,s),l=i(void 0,{skipVoid:!1}),u=r(),d=t({source:c,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return await e()})});return a({clock:u,target:d}),a({clock:d.doneData,target:l}),u(),a({clock:c,target:u}),l}export{o as getAction,s as getActionResult};
1
+ import{getPicked as e}from"./client.js";import"@picked/client";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";function o(t,n){return e().actions.getEvokableAction(t,n)}function s(e,s){let c=o(e,s),l=i(void 0,{skipVoid:!1}),u=r(),d=t({source:c,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return await e()})});return a({clock:u,target:d}),a({clock:d.doneData,target:l}),u(),a({clock:c,target:u}),l}export{o as getAction,s as getActionResult};
@@ -1,25 +1,13 @@
1
1
  import { Picked, ExperienceInstance } from '@picked/client';
2
2
  /**
3
- * Retrieves the Picked Client for the Experience Instance.
3
+ * Retrieves the Picked Client for the Experience Instance
4
4
  *
5
- * @returns The Picked client instance that provides access to experiences, steps, and other Picked functionality.
6
- * @example
7
- * const client = getPicked()
5
+ * @returns The Picked client instance that provides access to experiences, steps, and other Picked functionality
8
6
  */
9
7
  export declare function getPicked(): Picked;
10
8
  /**
11
- * Retrieves the Experience Store for the current Experience Instance.
9
+ * Retrieves the Experience Store for the current Experience Instance
12
10
  *
13
- * The returned instance exposes `restart` and `isResumableStore` for SDK-native
14
- * resume/restart flows: call `instance.restart()` to atomically clear this
15
- * experience's history and owned responses, and subscribe to
16
- * `instance.isResumableStore` (via the `$` prefix) to render a
17
- * "continue / start over" prompt when the user has in-progress state.
18
- *
19
- * @returns The Experience store instance that provides access to experience configuration and state.
20
- * @example
21
- * const experience = getExperience()
22
- * const canResume = experience.isResumableStore
23
- * // in markup: {#if $canResume}…{/if}
11
+ * @returns The Experience store instance that provides access to experience configuration and state
24
12
  */
25
13
  export declare function getExperience(): ExperienceInstance;
@@ -1 +1 @@
1
- import{getPicked as e}from"./client.js";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";import"@picked/client";function o(t){return e().predicates.getEvokablePredicate(t)}function s(e){let s=o(e),c=i(void 0,{skipVoid:!1}),l=r(),u=t({source:s,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return await e()})});return a({clock:l,target:u}),a({clock:u.doneData,target:c}),l(),a({clock:s,target:l}),c}export{o as getPredicate,s as getPredicateResult};
1
+ import{getPicked as e}from"./client.js";import"@picked/client";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";function o(t){return e().predicates.getEvokablePredicate(t)}function s(e){let s=o(e),c=i(void 0,{skipVoid:!1}),l=r(),u=t({source:s,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return await e()})});return a({clock:l,target:u}),a({clock:u.doneData,target:c}),l(),a({clock:s,target:l}),c}export{o as getPredicate,s as getPredicateResult};
@@ -1 +1 @@
1
- import{STEP_CONTEXT_KEY as e}from"../context.js";import{getPicked as t}from"./client.js";import{getStepRouter as n}from"../router/context.js";import{combine as r}from"effector";import{getContext as i,setContext as a}from"svelte";import{createForm as o}from"../form/create-form.svelte";function s(i){let s=t(),{activeStep:c}=n(),l=c.map(e=>e?.key===i),u=s.steps.listStore.map(e=>e.find(e=>e.key===i)??{id:i,key:i,name:``,fieldKeys:[]}),d=o(i,u.map(e=>e.name),u.map(e=>e.fieldKeys));return a(e,{step:u,layoutContentStore:r(u,s.contents.indexStore,(e,t)=>e.layoutContentId?t[e.layoutContentId]??null:null,{skipVoid:!1}),isActive:l,isActiveStep:l,...d})}function c(){return i(e)}function l(){let{layoutContentStore:e}=c();return e.map(e=>e?.data??null,{skipVoid:!1})}export{c as getStep,l as getStepContent,s as setStepContext};
1
+ import{STEP_CONTEXT_KEY as e}from"../context.js";import{getExperience as t,getPicked as n}from"./client.js";import{getStepRouter as r}from"../router/context.js";import{getContext as i,setContext as a}from"svelte";import{combine as o}from"effector";import{createForm as s}from"../form/create-form.svelte";function c(t){let i=n(),{activeStep:c}=r(),l=c.map(e=>e?.key===t),u=i.steps.listStore.map(e=>e.find(e=>e.key===t)??{id:t,key:t,name:``,fieldKeys:[]}),d=s(t,u.map(e=>e.name),u.map(e=>e.fieldKeys));return a(e,{step:u,layoutContentStore:o(u,i.contents.indexStore,(e,t)=>e.layoutContentId?t[e.layoutContentId]??null:null,{skipVoid:!1}),isActive:l,isActiveStep:l,...d})}function l(){return i(e)}function u(){return(i(e)?.layoutContentStore??d()).map(e=>e?.data??null,{skipVoid:!1})}function d(){let e=t(),r=n();return o(e.treeStore,r.contents.indexStore,(e,t)=>{let n=e.steps.find(e=>e.layoutContentId);return n?.layoutContentId?t[n.layoutContentId]??null:null})}export{l as getStep,u as getStepContent,c as setStepContext};
@@ -1 +1 @@
1
- import{SCOPE_CONTEXT_KEY as e}from"../context.js";import{createWatch as t,fork as n,is as r,scopeBind as i}from"effector";import{getContext as a,setContext as o}from"svelte";var s=t=>o(e,n(t)),c=()=>a(e);function l(e){let n=c();return n?r.effect(e)||r.event(e)&&typeof e==`function`?i(e,{scope:n}):{subscribe:r=>(r(n.getState(e)),t({unit:e,fn:r,scope:n}))}:e}export{c as getContextScope,s as setContextScope,l as useUnit};
1
+ import{SCOPE_CONTEXT_KEY as e}from"../context.js";import{getContext as t,setContext as n}from"svelte";import{createWatch as r,fork as i,is as a,scopeBind as o}from"effector";var s=t=>n(e,i(t)),c=()=>t(e);function l(e){let t=c();return t?a.effect(e)||a.event(e)&&typeof e==`function`?o(e,{scope:t}):{subscribe:n=>(n(t.getState(e)),r({unit:e,fn:n,scope:t}))}:e}export{c as getContextScope,s as setContextScope,l as useUnit};
package/dist/index.d.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @file Public entry for the `@picked/svelte` package.
3
+ */
1
4
  export { adaptSvelteComponent, isValidAdaptedComponent } from './component';
2
5
  export type { ComponentWithProps } from './component';
3
6
  export { LazyImage } from './components';
7
+ export { setPickedContext } from './experience/set-picked-context';
8
+ export { default as Experience } from './experience/Experience.svelte';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{adaptSvelteComponent as e,isValidAdaptedComponent as t}from"./component.js";import{LazyImage as n}from"./components/index.js";export{n as LazyImage,e as adaptSvelteComponent,t as isValidAdaptedComponent};
1
+ import{adaptSvelteComponent as e,isValidAdaptedComponent as t}from"./component.js";import{LazyImage as n}from"./components/index.js";import{setPickedContext as r}from"./experience/set-picked-context.js";import i from"./experience/Experience.svelte";export{i as Experience,n as LazyImage,e as adaptSvelteComponent,t as isValidAdaptedComponent,r as setPickedContext};
@@ -15,8 +15,12 @@
15
15
  import ComponentsListScreen from './screens/components/ComponentsListScreen.svelte'
16
16
  import FrameworksListScreen from './screens/frameworks/FrameworksListScreen.svelte'
17
17
  import ProjectScreen from './screens/project/ProjectScreen.svelte'
18
+ import ConfigScreen from './screens/config/ConfigScreen.svelte'
19
+ import IdentityScreen from './screens/identity/IdentityScreen.svelte'
18
20
  import HistoryEntriesListScreen from './screens/history/HistoryEntriesListScreen.svelte'
19
21
  import ResponsesListScreen from './screens/responses/ResponsesListScreen.svelte'
22
+ import FieldsListScreen from './screens/fields/FieldsListScreen.svelte'
23
+ import ContentListScreen from './screens/content/ContentListScreen.svelte'
20
24
  import FeaturesListScreen from './screens/features/FeaturesListScreen.svelte'
21
25
  import EventsListScreen from './screens/events/EventsListScreen.svelte'
22
26
  import PickedIcon from './PickedIcon.svelte'
@@ -35,21 +39,27 @@
35
39
  props: {},
36
40
  },
37
41
  {
38
- id: 'history',
39
- label: 'History',
40
- component: HistoryEntriesListScreen,
42
+ id: 'components',
43
+ label: 'Components',
44
+ component: ComponentsListScreen,
41
45
  props: {},
42
46
  },
43
47
  {
44
- id: 'events',
45
- label: 'Events',
46
- component: EventsListScreen,
48
+ id: 'config',
49
+ label: 'Config',
50
+ component: ConfigScreen,
47
51
  props: {},
48
52
  },
49
53
  {
50
- id: 'responses',
51
- label: 'Responses',
52
- component: ResponsesListScreen,
54
+ id: 'content',
55
+ label: 'Content',
56
+ component: ContentListScreen,
57
+ props: {},
58
+ },
59
+ {
60
+ id: 'events',
61
+ label: 'Events',
62
+ component: EventsListScreen,
53
63
  props: {},
54
64
  },
55
65
  {
@@ -64,12 +74,6 @@
64
74
  component: ExperiencesListScreen,
65
75
  props: {},
66
76
  },
67
- {
68
- id: 'predicates',
69
- label: 'Predicates',
70
- component: PredicatesListScreen,
71
- props: {},
72
- },
73
77
  {
74
78
  id: 'features',
75
79
  label: 'Features',
@@ -77,9 +81,9 @@
77
81
  props: {},
78
82
  },
79
83
  {
80
- id: 'components',
81
- label: 'Components',
82
- component: ComponentsListScreen,
84
+ id: 'fields',
85
+ label: 'Fields',
86
+ component: FieldsListScreen,
83
87
  props: {},
84
88
  },
85
89
  {
@@ -88,6 +92,30 @@
88
92
  component: FrameworksListScreen,
89
93
  props: {},
90
94
  },
95
+ {
96
+ id: 'history',
97
+ label: 'History',
98
+ component: HistoryEntriesListScreen,
99
+ props: {},
100
+ },
101
+ {
102
+ id: 'identity',
103
+ label: 'Identity',
104
+ component: IdentityScreen,
105
+ props: {},
106
+ },
107
+ {
108
+ id: 'predicates',
109
+ label: 'Predicates',
110
+ component: PredicatesListScreen,
111
+ props: {},
112
+ },
113
+ {
114
+ id: 'responses',
115
+ label: 'Responses',
116
+ component: ResponsesListScreen,
117
+ props: {},
118
+ },
91
119
  ]
92
120
 
93
121
  const isOpen = experience.isOpenStore
@@ -118,7 +146,7 @@
118
146
  </div>
119
147
  </header>
120
148
  <section class="main">
121
- <Tabs {tabs} initialTab={1} />
149
+ <Tabs {tabs} initialTab={0} />
122
150
  </section>
123
151
  </div>
124
152
  </div>
@@ -129,6 +157,7 @@
129
157
  {/if}
130
158
  <button
131
159
  class="floating-button"
160
+ class:has-preview-tag={isPreview}
132
161
  onclick={() => experience.open()}
133
162
  aria-label="Open Picked Devtools"
134
163
  title="Open Picked Devtools"
@@ -316,14 +345,22 @@
316
345
  .launcher {
317
346
  position: fixed;
318
347
  bottom: 1rem;
319
- right: 1.5rem;
348
+ right: 0;
320
349
  z-index: 999999;
321
350
  display: flex;
322
351
  align-items: stretch;
352
+ cursor: pointer;
353
+ transform: translateX(calc(100% - 8px));
354
+ transition: transform 180ms ease-out;
355
+
356
+ &:hover,
357
+ &:focus-within {
358
+ transform: translateX(0);
359
+ }
323
360
  }
324
361
 
325
362
  .floating-button {
326
- border-radius: 0 6px 6px 0;
363
+ border-radius: 6px;
327
364
  background-color: #ffffff;
328
365
  display: grid;
329
366
  place-items: center;
@@ -337,6 +374,10 @@
337
374
  }
338
375
  }
339
376
 
377
+ .floating-button.has-preview-tag {
378
+ border-radius: 0 6px 6px 0;
379
+ }
380
+
340
381
  .preview-tag {
341
382
  display: flex;
342
383
  align-items: center;
@@ -1,30 +1,36 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="P extends Record<string, unknown> = Record<string, never>">
2
2
  /** @file Accessible tab container that renders dynamic Svelte components per tab. */
3
3
  import type { Component } from 'svelte'
4
4
 
5
5
  interface Tab {
6
6
  id: string
7
7
  label: string
8
- component: Component<Record<string, unknown>>
9
- props?: Record<string, unknown>
8
+ component: Component<P>
9
+ props: P
10
10
  }
11
11
 
12
12
  export let tabs: Tab[]
13
13
  export let initialTab = 0
14
14
 
15
15
  let activeTab = initialTab
16
+ // Keyed by tab index — written at render time via Svelte `bind:this`. A
17
+ // record rather than an array sidesteps `sonarjs/no-empty-collection`,
18
+ // whose flow analysis can't see the bind writes.
19
+ const tabButtons: Record<number, HTMLButtonElement | undefined> = {}
16
20
 
17
21
  /**
18
- * Sets the active tab to the given index.
22
+ * Sets the active tab to the given index and scrolls the corresponding button into the
23
+ * horizontal viewport so keyboard navigation never lands off-screen.
19
24
  *
20
25
  * @param {number} index - The zero-based tab index to activate.
21
26
  * @example
22
27
  * ```typescript
23
- * activateTab(2) // switches to the third tab
28
+ * activateTab(2) // switches to the third tab and scrolls it into view
24
29
  * ```
25
30
  */
26
31
  function activateTab(index: number) {
27
32
  activeTab = index
33
+ tabButtons[index]?.scrollIntoView({ inline: 'nearest', block: 'nearest', behavior: 'smooth' })
28
34
  }
29
35
 
30
36
  /**
@@ -66,6 +72,7 @@
66
72
  <div role="tablist" tabindex="0" on:keydown={handleKeydown} class="tab-list">
67
73
  {#each tabs as tab, i (tab.id)}
68
74
  <button
75
+ bind:this={tabButtons[i]}
69
76
  role="tab"
70
77
  id="tab-{tab.id}"
71
78
  aria-controls="panel-{tab.id}"
@@ -90,7 +97,7 @@
90
97
  hidden={activeTab !== i}
91
98
  >
92
99
  {#if activeTab === i}
93
- <svelte:component this={tab.component} {...tab.props || {}} />
100
+ <svelte:component this={tab.component} {...tab.props} />
94
101
  {/if}
95
102
  </div>
96
103
  {/each}
@@ -110,6 +117,23 @@
110
117
  border-bottom: 1px solid var(--color-gray-200);
111
118
  flex-shrink: 0;
112
119
  overflow-x: auto;
120
+ overflow-y: hidden;
121
+ scrollbar-width: thin;
122
+ scrollbar-color: var(--color-gray-300) transparent;
123
+ scroll-behavior: smooth;
124
+ }
125
+
126
+ .tab-list::-webkit-scrollbar {
127
+ height: 6px;
128
+ }
129
+
130
+ .tab-list::-webkit-scrollbar-thumb {
131
+ background-color: var(--color-gray-300);
132
+ border-radius: var(--radius-full);
133
+ }
134
+
135
+ .tab-list::-webkit-scrollbar-track {
136
+ background-color: transparent;
113
137
  }
114
138
 
115
139
  .tab-button {