@picked/svelte 0.0.62-next.9 → 0.0.63-next.1

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,112 @@
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
+ // Content fetch failures (e.g. a 404 when the content endpoint is unavailable) are already
44
+ // swallowed inside the effect; the explicit catch guarantees no unhandled rejection even if
45
+ // that contract changes, so a missing/erroring content endpoint never breaks rendering.
46
+ experience.prepareExperienceContentFx().catch(() => undefined)
47
+ }
48
+ })
49
+
50
+ /**
51
+ * Narrows to a directly-renderable Svelte component, excluding lazy loaders and promises.
52
+ *
53
+ * @param {unknown} value - The adapted component's `component` field.
54
+ * @returns {boolean} True when the value is a direct Svelte component.
55
+ * @example
56
+ * isDirectSvelteComponent(SomeComponent)
57
+ */
58
+ function isDirectSvelteComponent(value: unknown): value is Component {
59
+ return typeof value === 'function' && !isLazyLoader(value) && !(value instanceof Promise)
60
+ }
61
+
62
+ /**
63
+ * Returns `value` when it is a directly-renderable Svelte component.
64
+ *
65
+ * @param {unknown} value - The candidate component (a loaded module's default export).
66
+ * @returns {Component} The Svelte component.
67
+ * @throws {Error} When the value is not a Svelte component.
68
+ * @example
69
+ * toComponent((await loader.load()).default)
70
+ */
71
+ function toComponent(value: unknown): Component {
72
+ if (isDirectSvelteComponent(value)) {
73
+ return value
74
+ }
75
+
76
+ throw new Error('Lazy experience component did not resolve to a Svelte component.')
77
+ }
78
+
79
+ /**
80
+ * Returns a promise resolving to the component for a lazy loader or promise-wrapped component,
81
+ * or null for a direct component (or when there is no component).
82
+ *
83
+ * @param {unknown} value - The adapted component's `component` field.
84
+ * @returns {Promise<Component> | null} The loader promise, or null.
85
+ * @example
86
+ * toComponentLoader(lazyLoader)
87
+ */
88
+ function toComponentLoader(value: unknown): Promise<Component> | null {
89
+ if (isLazyLoader(value)) {
90
+ return value.load().then((mod) => toComponent(mod.default))
91
+ }
92
+
93
+ if (value instanceof Promise) {
94
+ return value.then((mod) => toComponent(mod.default))
95
+ }
96
+
97
+ return null
98
+ }
99
+
100
+ const adapted = $derived($resolved?.component.component)
101
+ const svelteComponent = $derived(adapted ? adapted.component : undefined)
102
+ const componentLoader = $derived(toComponentLoader(svelteComponent))
103
+ </script>
104
+
105
+ {#if isDirectSvelteComponent(svelteComponent)}
106
+ {@const Comp = svelteComponent}
107
+ <Comp {client} experience={instance} />
108
+ {:else if componentLoader}
109
+ {#await componentLoader then Comp}
110
+ <Comp {client} experience={instance} />
111
+ {/await}
112
+ {/if}
@@ -0,0 +1,143 @@
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, ruleReferencesAction } 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 = $derived(`.picked-slot-${name}`)
30
+
31
+ const PLACEMENT_ORDER: ExperienceTarget['placement'][] = [
32
+ 'replace',
33
+ 'before',
34
+ 'prepend',
35
+ 'append',
36
+ 'after',
37
+ ]
38
+
39
+ interface Candidate {
40
+ experience: ExperienceStore
41
+ placement: ExperienceTarget['placement']
42
+ condition: ConditionRule
43
+ hash: string
44
+ }
45
+
46
+ const experiences = useUnit(client.experiences.listStore)
47
+ const conditionResults = useUnit(client.conditionResults.store)
48
+
49
+ /**
50
+ * Returns the sort rank for a placement; unknown placements sort last.
51
+ *
52
+ * @param {ExperienceTarget['placement']} placement - The target placement.
53
+ * @returns {number} The placement's index in the precedence order.
54
+ * @example
55
+ * placementRank('append')
56
+ */
57
+ function placementRank(placement: ExperienceTarget['placement']): number {
58
+ const index = PLACEMENT_ORDER.indexOf(placement)
59
+ return index === -1 ? PLACEMENT_ORDER.length : index
60
+ }
61
+
62
+ /**
63
+ * Maps an experience to a slot candidate, or null when it does not target this slot or carries
64
+ * an action-referencing condition.
65
+ *
66
+ * @param {ExperienceStore} experience - The experience to inspect.
67
+ * @returns {Candidate | null} The candidate, or null when the experience is not an eligible slot match.
68
+ * @example
69
+ * toCandidate(experienceStore)
70
+ */
71
+ function toCandidate(experience: ExperienceStore): Candidate | null {
72
+ const config = experience.configStore.getState()
73
+ const target = config.targetSelectors.find(
74
+ (selectorTarget) => selectorTarget.selector === selector,
75
+ )
76
+ if (!target) {
77
+ return null
78
+ }
79
+
80
+ // Action-referencing conditions are never cached (their side effect must re-run), so they can
81
+ // never resolve through the synchronous `peek` the slot path relies on. Slots do not support
82
+ // action-gated conditions in v1; excluding them also stops the selection effect from re-firing
83
+ // the action on every render.
84
+ if (ruleReferencesAction(config.decisionLogic)) {
85
+ return null
86
+ }
87
+
88
+ return {
89
+ experience,
90
+ placement: target.placement,
91
+ condition: config.decisionLogic,
92
+ hash: hashRule(config.decisionLogic),
93
+ }
94
+ }
95
+
96
+ const candidates = $derived(
97
+ $experiences
98
+ .map(toCandidate)
99
+ .filter((candidate): candidate is Candidate => candidate !== null)
100
+ .sort((a, b) => placementRank(a.placement) - placementRank(b.placement)),
101
+ )
102
+
103
+ // Referencing `$conditionResults` keeps this recompute reactive to late condition writes
104
+ // (the standalone cache-miss path), so a `peek` that returned `undefined` re-renders once
105
+ // the async evaluation resolves and writes the store. An active `replace` experience wins:
106
+ // the first one (it sorts first) renders alone, replacing any other slot matches.
107
+ const active = $derived.by(() => {
108
+ void $conditionResults
109
+ const activeCandidates = candidates.filter(
110
+ (candidate) => client.conditionResults.peek(candidate.hash) === true,
111
+ )
112
+ const firstReplace = activeCandidates.find((candidate) => candidate.placement === 'replace')
113
+ return firstReplace ? [firstReplace] : activeCandidates
114
+ })
115
+
116
+ /**
117
+ * Builds a plain, JSON-serialisable subset of the current URL for the evaluation context.
118
+ * The live `Location` object is intentionally not passed: json-logic predicates read plain
119
+ * values, and this mirrors the `SerializeLocation` shape the server selects against.
120
+ *
121
+ * @returns {Record<string, string>} The plain location subset.
122
+ * @example
123
+ * buildLocationContext()
124
+ */
125
+ function buildLocationContext(): Record<string, string> {
126
+ const { href, pathname, search, hostname, origin, protocol, port, host, hash } = window.location
127
+ return { href, pathname, search, hostname, origin, protocol, port, host, hash }
128
+ }
129
+
130
+ $effect(() => {
131
+ for (const candidate of candidates) {
132
+ if (client.conditionResults.peek(candidate.hash) === undefined) {
133
+ void evaluateConditionMemo(client, candidate.condition, {
134
+ context: { location: buildLocationContext() },
135
+ })
136
+ }
137
+ }
138
+ })
139
+ </script>
140
+
141
+ {#each active as candidate (candidate.experience.id)}
142
+ <ExperienceHost experience={candidate.experience} {client} />
143
+ {/each}
@@ -0,0 +1,29 @@
1
+ import { CreateClientOptions, Picked } from '@picked/client';
2
+ import { PickedSnapshot } from '@picked/client/server';
3
+ type SetPickedContextParams = {
4
+ client: Picked;
5
+ snapshot?: PickedSnapshot;
6
+ } | {
7
+ options: CreateClientOptions;
8
+ snapshot?: PickedSnapshot;
9
+ };
10
+ /**
11
+ * Provides a Picked client to descendant components through Svelte context, starting it in the browser.
12
+ *
13
+ * Call this once at the top of a component's `<script>` so `setContext` and `onMount` run during
14
+ * component initialisation. Pass `options` to create the client and synchronously hydrate it from an
15
+ * optional SSR snapshot (a `versionId` mismatch is tolerated; the client reconciles via `start()`),
16
+ * or pass an existing `client` (such as the app's single global client) to render experiences
17
+ * against it — supply `snapshot` alongside `client` to SSR-seed that shared client rather than
18
+ * creating a second one. In the browser the client is started from `onMount`; `start()` is
19
+ * idempotent, so a client that was already started is left untouched.
20
+ *
21
+ * @param {SetPickedContextParams} params - `{ client, snapshot? }` or `{ options, snapshot? }`.
22
+ * @returns {Picked} The client placed into context.
23
+ * @example
24
+ * setPickedContext({ options })
25
+ * @example
26
+ * setPickedContext({ client: globalClient, snapshot })
27
+ */
28
+ export declare function setPickedContext(params: SetPickedContextParams): Picked;
29
+ 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=e.session.store.getState().id;a(e,t.projectConfig,{isPreview:!1,sessionId:n}),t.content.forEach(t=>e.registerContent(t)),e.conditionResults.seed(t.conditionResults)}function s(e,t){let n=r(e);return t&&o(n,t),n}function c(r){let a;return`client`in r?(r.snapshot&&o(r.client,r.snapshot),a=r.client):a=s(r.options,r.snapshot),n(e,a),t(()=>{i()&&a.start()}),a}export{c 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 +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 ExperienceSlot } from './experience/ExperienceSlot.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/ExperienceSlot.svelte";export{i as ExperienceSlot,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 {