@lyeve-labs/ui-kit 0.32.0 → 0.33.0

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.
@@ -320,12 +320,17 @@
320
320
  the next Tab started again at the top of the document: the sidebar the
321
321
  reader had just skipped. No outline on it: the region is a landing, not
322
322
  a control, and the browser's default drew a box around the whole page
323
- body the moment the link was used. -->
323
+ body the moment the link was used.
324
+ relative because the region scrolls and overflow clips only what it
325
+ contains. An absolute descendant with no positioned ancestor (every
326
+ sr-only label) otherwise took the viewport as its containing block, and
327
+ one low on a long page stretched the document past the h-screen shell,
328
+ so the window scrolled the whole frame up and off. -->
324
329
  <main
325
330
  id="content"
326
331
  tabindex="-1"
327
332
  data-print="unclip"
328
- class="min-w-0 flex-1 overflow-auto bg-ink outline-none"
333
+ class="relative min-w-0 flex-1 overflow-auto bg-ink outline-none"
329
334
  >
330
335
  {@render children()}
331
336
  </main>
@@ -15,12 +15,14 @@
15
15
  import { X } from '@lucide/svelte';
16
16
  import Badge from './Badge.svelte';
17
17
  import Button from './Button.svelte';
18
- import Checkbox from './Checkbox.svelte';
19
18
  import SearchInput from './SearchInput.svelte';
20
- import SegmentedControl from './SegmentedControl.svelte';
21
19
  import Spinner from './Spinner.svelte';
20
+ import * as motion from '../motion.js';
22
21
  import { cn } from '../utils/cn.js';
22
+ import { CHOICE_FOCUS, CHOICE_INPUT, CHOICE_MARK, choiceWrap } from '../internal/choice.js';
23
+ import { HIT_AREA } from '../internal/touch.js';
23
24
  import {
25
+ betaOf,
24
26
  billedCount,
25
27
  formatCents,
26
28
  ladderState,
@@ -66,6 +68,12 @@
66
68
  actions?: Snippet<[{ skus: string[]; total: number | null }]>;
67
69
  /** Anything the host wants between the total and the actions. */
68
70
  notice?: Snippet;
71
+ /**
72
+ * How far below the top of the window the summary sticks on a wide
73
+ * screen, as a CSS length. A page with a sticky header passes its height,
74
+ * so the summary does not slide under it.
75
+ */
76
+ stickyTop?: string;
69
77
  class?: string;
70
78
  }
71
79
 
@@ -102,24 +110,28 @@
102
110
  onchange,
103
111
  actions,
104
112
  notice,
113
+ stickyTop = '1rem',
105
114
  class: klass = '',
106
115
  }: Props = $props();
107
116
 
117
+ const summaryId = $props.id();
108
118
  const heldSet = $derived(new Set(held));
109
119
  const known = $derived(new Set(plugins.map((p) => p.sku)));
110
120
 
111
121
  let query = $state('');
112
122
  let category = $state('all');
113
123
 
124
+ const sellable = $derived(plugins.filter((p) => p.status !== 'soon'));
125
+
126
+ // Every category with how many it holds, so a reader sees where the bulk of
127
+ // the catalog is before choosing one.
114
128
  const categories = $derived([
115
- { value: 'all', label: 'All' },
116
- ...[...new Set(plugins.map((p) => p.category).filter(Boolean) as string[])]
129
+ { value: 'all', label: 'All', count: sellable.length },
130
+ ...[...new Set(sellable.map((p) => p.category).filter(Boolean) as string[])]
117
131
  .sort()
118
- .map((c) => ({ value: c, label: c })),
132
+ .map((c) => ({ value: c, label: c, count: sellable.filter((p) => p.category === c).length })),
119
133
  ]);
120
134
 
121
- const sellable = $derived(plugins.filter((p) => p.status !== 'soon'));
122
-
123
135
  const shown = $derived(
124
136
  sellable.filter((p) => {
125
137
  if (category !== 'all' && p.category !== category) return false;
@@ -161,6 +173,14 @@
161
173
 
162
174
  const perLabel = $derived(period === 'annual' ? '/yr' : '/mo');
163
175
 
176
+ // The volume ladder drawn as a meter: the rungs that take something off,
177
+ // placed along the count of the last one.
178
+ const rungs = $derived(
179
+ [...brackets].filter((b) => b.discount_pct > 0).sort((a, b) => a.plugins - b.plugins),
180
+ );
181
+ const ladderTop = $derived(rungs.length ? rungs[rungs.length - 1].plugins : 0);
182
+ const ladderFill = $derived(ladderTop ? Math.min(ladder.count / ladderTop, 1) * 100 : 0);
183
+
164
184
  function commit(next: Set<string>) {
165
185
  selected = next;
166
186
  onchange?.([...next]);
@@ -181,21 +201,21 @@
181
201
 
182
202
  <div class={cn('grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]', klass)}>
183
203
  <div class="min-w-0 space-y-4">
184
- <div class="flex flex-wrap items-center gap-3">
185
- <div class="min-w-48 flex-1">
186
- <SearchInput
187
- label="Search capabilities"
188
- placeholder="Search by name or what it does"
189
- bind:value={query}
190
- />
191
- </div>
192
- {#if quickPicks.length > 0}
204
+ <SearchInput
205
+ label="Search capabilities"
206
+ placeholder="Search by name or what it does"
207
+ bind:value={query}
208
+ />
209
+
210
+ {#if quickPicks.length > 0}
211
+ <div class="flex flex-wrap items-center gap-2">
212
+ <span class="text-xs font-medium text-muted">Start from</span>
193
213
  <div class="flex flex-wrap gap-1.5" role="group" aria-label="Quick picks">
194
214
  {#each quickPicks as qp (qp.id)}
195
215
  <Button
196
216
  type="button"
197
217
  size="sm"
198
- variant="ghost"
218
+ variant="secondary"
199
219
  onclick={() => pick(qp.skus)}
200
220
  disabled={qp.skus.length === 0}
201
221
  >
@@ -203,18 +223,31 @@
203
223
  </Button>
204
224
  {/each}
205
225
  </div>
206
- {/if}
207
- </div>
226
+ </div>
227
+ {/if}
208
228
 
209
229
  {#if categories.length > 2}
210
- <div class="-mx-1 overflow-x-auto px-1">
211
- <SegmentedControl
212
- options={categories}
213
- bind:value={category}
214
- label="Category"
215
- labelHidden
216
- size="sm"
217
- />
230
+ <!-- Chips that wrap rather than a strip that scrolls: every category is
231
+ in sight at every width, and none hides past the edge. -->
232
+ <div class="flex flex-wrap gap-1.5" role="group" aria-label="Category">
233
+ {#each categories as c (c.value)}
234
+ <button
235
+ type="button"
236
+ aria-pressed={category === c.value}
237
+ class={cn(
238
+ HIT_AREA,
239
+ 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
240
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand',
241
+ category === c.value
242
+ ? 'border-brand bg-brand/10 text-fg'
243
+ : 'border-line text-muted hover:border-line-strong hover:text-fg',
244
+ )}
245
+ onclick={() => (category = c.value)}
246
+ >
247
+ {c.label}
248
+ <span class="font-mono text-[10px] text-faint">{c.count}<span class="sr-only"> on sale</span></span>
249
+ </button>
250
+ {/each}
218
251
  </div>
219
252
  {/if}
220
253
 
@@ -226,25 +259,83 @@
226
259
  <div class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-3">
227
260
  {#each shown as plugin (plugin.sku)}
228
261
  {@const owned = heldSet.has(plugin.sku)}
229
- <Checkbox
230
- variant="card"
231
- name={fieldName}
232
- value={plugin.sku}
233
- label={plugin.name}
234
- description={owned
235
- ? 'Already on your subscription'
236
- : `${formatCents(plugin.price_cents, currency)}/mo${plugin.category ? `, ${plugin.category}` : ''}. ${plugin.blurb ?? ''}`}
237
- checked={selected.has(plugin.sku) || owned}
238
- disabled={owned}
239
- onchange={() => toggle(plugin.sku)}
240
- />
262
+ {@const on = selected.has(plugin.sku) || owned}
263
+ {@const blurb = betaOf(plugin.blurb, plugin.maturity)}
264
+ {@const id = `${summaryId}-${plugin.sku}`}
265
+ {@const tagged = Boolean(plugin.category || blurb.beta || owned)}
266
+ <!-- One card is one control: the input covers it, so the card is
267
+ what the focus outline reaches, the way the kit's card checkbox
268
+ works. The name names the box and the blurb describes it. -->
269
+ <label class="relative block h-full">
270
+ <input
271
+ type="checkbox"
272
+ class={CHOICE_INPUT}
273
+ name={fieldName}
274
+ value={plugin.sku}
275
+ checked={on}
276
+ disabled={owned}
277
+ aria-labelledby="{id}-name"
278
+ aria-describedby="{id}-price {id}-blurb{tagged ? ` ${id}-tags` : ''}"
279
+ onchange={() => toggle(plugin.sku)}
280
+ />
281
+ <span class={cn(choiceWrap('card', on, owned), CHOICE_FOCUS, 'h-full flex-col gap-2 p-4')}>
282
+ <span class="flex w-full items-start justify-between gap-3">
283
+ <span class="flex min-w-0 items-start gap-2.5">
284
+ <span
285
+ class={cn(
286
+ 'pointer-events-none mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-xs border transition-colors',
287
+ on ? 'border-brand bg-brand text-ink' : 'border-line-strong bg-surface-2',
288
+ )}
289
+ aria-hidden="true"
290
+ >
291
+ {#if on}
292
+ <svg viewBox="0 0 10 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d={CHOICE_MARK.check} /></svg>
293
+ {/if}
294
+ </span>
295
+ <span id="{id}-name" class="text-sm font-semibold leading-snug text-fg">{plugin.name}</span>
296
+ </span>
297
+ <span id="{id}-price" class="shrink-0 font-mono text-sm font-semibold text-fg">
298
+ {formatCents(plugin.price_cents, currency)}<span class="text-xs font-normal text-muted">/mo</span>
299
+ </span>
300
+ </span>
301
+ <span id="{id}-blurb" class="text-xs leading-relaxed text-muted">
302
+ {owned ? 'Already on your subscription.' : blurb.text}
303
+ </span>
304
+ {#if tagged}
305
+ <span id="{id}-tags" class="mt-auto flex flex-wrap gap-1.5 pt-1">
306
+ {#if plugin.category}<Badge tone="neutral" size="sm">{plugin.category}</Badge>{/if}
307
+ {#if blurb.beta}<Badge tone="violet" size="sm">Beta</Badge>{/if}
308
+ {#if owned}<Badge tone="success" size="sm">Owned</Badge>{/if}
309
+ </span>
310
+ {/if}
311
+ </span>
312
+ </label>
241
313
  {/each}
242
314
  </div>
243
315
  {/if}
316
+
317
+ {#if count > 0}
318
+ <!-- On a narrow screen the summary sits under every card. This keeps
319
+ the count and the price in sight and one tap from the summary. -->
320
+ <div class="sticky bottom-3 lg:hidden" transition:motion.toast|global>
321
+ <a
322
+ href="#{summaryId}"
323
+ class="flex items-center justify-between gap-3 rounded-xl border border-brand bg-surface px-4 py-3 text-sm shadow-lg transition-colors hover:bg-surface-2"
324
+ >
325
+ <span class="text-fg">
326
+ <span class="font-semibold">{count}</span> picked{#if total !== null}<span class="text-muted">
327
+ · </span><span class="font-mono">{formatCents(total, currency)}{perLabel}</span>{/if}
328
+ </span>
329
+ <span class="font-semibold text-brand">Review</span>
330
+ </a>
331
+ </div>
332
+ {/if}
244
333
  </div>
245
334
 
246
335
  <aside
247
- class="self-start rounded-xl border border-line bg-surface p-5 lg:sticky lg:top-4"
336
+ id={summaryId}
337
+ class="scroll-mt-20 self-start rounded-xl border border-line bg-surface p-5 lg:sticky"
338
+ style:top={stickyTop}
248
339
  aria-label="Your selection"
249
340
  >
250
341
  <div class="mb-4 flex items-center justify-between gap-2">
@@ -289,6 +380,35 @@
289
380
  {/each}
290
381
  </ul>
291
382
 
383
+ {#if rungs.length > 0}
384
+ <!-- The ladder as a meter, so "two more" is something a reader can
385
+ see the distance to. -->
386
+ <div class="mb-3" aria-hidden="true">
387
+ <div class="relative h-1.5 rounded-full bg-surface-2">
388
+ <span
389
+ class="absolute inset-y-0 start-0 rounded-full bg-brand transition-[width] duration-progress"
390
+ style:width="{ladderFill}%"
391
+ ></span>
392
+ {#each rungs as r (r.plugins)}
393
+ <span
394
+ class={cn(
395
+ 'absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors',
396
+ ladder.count >= r.plugins ? 'border-brand bg-brand' : 'border-line-strong bg-surface',
397
+ )}
398
+ style:left="{(r.plugins / ladderTop) * 100}%"
399
+ ></span>
400
+ {/each}
401
+ </div>
402
+ <div class="relative mt-2 h-4 text-[10px] text-muted">
403
+ {#each rungs as r (r.plugins)}
404
+ <span class="absolute -translate-x-1/2 whitespace-nowrap font-mono" style:left="{Math.min((r.plugins / ladderTop) * 100, 92)}%">
405
+ {r.plugins}+ · {r.discount_pct}%
406
+ </span>
407
+ {/each}
408
+ </div>
409
+ </div>
410
+ {/if}
411
+
292
412
  {#if ladder.reachedPct > 0}
293
413
  <p class="mb-2 text-xs text-success">
294
414
  Volume discount {ladder.reachedPct}% applied.
@@ -38,6 +38,12 @@ interface Props {
38
38
  }]>;
39
39
  /** Anything the host wants between the total and the actions. */
40
40
  notice?: Snippet;
41
+ /**
42
+ * How far below the top of the window the summary sticks on a wide
43
+ * screen, as a CSS length. A page with a sticky header passes its height,
44
+ * so the summary does not slide under it.
45
+ */
46
+ stickyTop?: string;
41
47
  class?: string;
42
48
  }
43
49
  declare const PluginCart: import("svelte").Component<Props, {}, "selected">;
package/dist/index.d.ts CHANGED
@@ -98,4 +98,4 @@ export { cn, type ClassValue } from './utils/cn.js';
98
98
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
99
99
  export * as motion from './motion.js';
100
100
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
101
- export declare const VERSION = "0.32.0";
101
+ export declare const VERSION = "0.33.0";
package/dist/index.js CHANGED
@@ -107,4 +107,4 @@ export * as motion from './motion.js';
107
107
  // ── Version ────────────────────────────────────────────────────────────────
108
108
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
109
109
  // this line. The build and the test suite fail when the two disagree.
110
- export const VERSION = '0.32.0';
110
+ export const VERSION = '0.33.0';
@@ -23,6 +23,8 @@ export interface CartPlugin {
23
23
  price_cents: number;
24
24
  category?: string;
25
25
  blurb?: string;
26
+ /** The catalog's maturity, such as "beta", once the API carries it. */
27
+ maturity?: string;
26
28
  /** `soon` is shown and cannot be bought. Anything else sells. */
27
29
  status?: string;
28
30
  }
@@ -108,3 +110,16 @@ export declare function ladderRows(brackets: DiscountBracket[]): {
108
110
  * them.
109
111
  */
110
112
  export declare function formatCents(cents: number, currency?: string): string;
113
+ /**
114
+ * A plugin's blurb split into what it does and whether it is still in beta.
115
+ *
116
+ * The catalog keeps maturity in its own column, and `maturity` wins once the
117
+ * API returns it. Until then the only signal is a sentence in the blurb: a
118
+ * "Beta." standing alone, or a "Beta:" that opens a caveat. A blurb that says
119
+ * neither comes back untouched and unbadged, so a change of wording costs a
120
+ * badge, never a claim.
121
+ */
122
+ export declare function betaOf(blurb: string | undefined, maturity?: string): {
123
+ text: string;
124
+ beta: boolean;
125
+ };
@@ -75,3 +75,24 @@ export function formatCents(cents, currency = 'USD') {
75
75
  maximumFractionDigits: 2,
76
76
  }).format(cents / 100);
77
77
  }
78
+ /**
79
+ * A plugin's blurb split into what it does and whether it is still in beta.
80
+ *
81
+ * The catalog keeps maturity in its own column, and `maturity` wins once the
82
+ * API returns it. Until then the only signal is a sentence in the blurb: a
83
+ * "Beta." standing alone, or a "Beta:" that opens a caveat. A blurb that says
84
+ * neither comes back untouched and unbadged, so a change of wording costs a
85
+ * badge, never a claim.
86
+ */
87
+ export function betaOf(blurb, maturity) {
88
+ const text = (blurb ?? '').trim();
89
+ if (maturity !== undefined) {
90
+ return { text: text.replace(/\s*Beta\.(?=\s|$)/, '').trim(), beta: maturity === 'beta' };
91
+ }
92
+ // "Beta." standing as a sentence of its own says nothing the badge does not,
93
+ // wherever it falls. A "Beta:" caveat says more, so it stays.
94
+ const alone = /(^|\s)Beta\.(?=\s|$)/;
95
+ if (alone.test(text))
96
+ return { text: text.replace(/\s*Beta\.(?=\s|$)/, '').trim(), beta: true };
97
+ return { text, beta: /(^|\s)Beta:/.test(text) };
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",