@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.
- package/dist/components/AppShell.svelte +7 -2
- package/dist/components/PluginCart.svelte +160 -40
- package/dist/components/PluginCart.svelte.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/pricing.d.ts +15 -0
- package/dist/internal/pricing.js +21 -0
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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="
|
|
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
|
-
|
|
207
|
-
|
|
226
|
+
</div>
|
|
227
|
+
{/if}
|
|
208
228
|
|
|
209
229
|
{#if categories.length > 2}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
};
|
package/dist/internal/pricing.js
CHANGED
|
@@ -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