@lyeve-labs/ui-kit 0.44.0 → 0.46.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.
Files changed (36) hide show
  1. package/dist/components/AccountMenu.svelte +4 -1
  2. package/dist/components/AppShell.svelte +42 -10
  3. package/dist/components/AppShell.svelte.d.ts +8 -0
  4. package/dist/components/AuthShell.svelte +9 -2
  5. package/dist/components/Badge.svelte +7 -6
  6. package/dist/components/Button.svelte +29 -5
  7. package/dist/components/Card.svelte +9 -4
  8. package/dist/components/Drawer.svelte +1 -1
  9. package/dist/components/EmptyState.svelte +9 -3
  10. package/dist/components/Modal.svelte +1 -1
  11. package/dist/components/PageHeader.svelte +10 -4
  12. package/dist/components/Pagination.svelte +9 -22
  13. package/dist/components/PluginCart.svelte +32 -10
  14. package/dist/components/PluginCart.svelte.d.ts +8 -0
  15. package/dist/components/Progress.svelte +17 -3
  16. package/dist/components/SegmentedControl.svelte +2 -2
  17. package/dist/components/SidebarNav.svelte +14 -1
  18. package/dist/components/SortableList.svelte +1 -1
  19. package/dist/components/Stat.svelte +13 -3
  20. package/dist/components/Table.svelte +20 -9
  21. package/dist/components/Tabs.svelte +77 -10
  22. package/dist/components/Toaster.svelte +1 -1
  23. package/dist/components/Tooltip.svelte +1 -1
  24. package/dist/components/dialog/Dialog.svelte +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/internal/field.js +12 -1
  28. package/dist/internal/layout.d.ts +29 -4
  29. package/dist/internal/layout.js +37 -5
  30. package/dist/internal/panel.d.ts +4 -1
  31. package/dist/internal/panel.js +4 -1
  32. package/dist/internal/scroll-edges.d.ts +9 -8
  33. package/dist/internal/scroll-edges.js +9 -8
  34. package/dist/styles/theme.css +203 -0
  35. package/package.json +1 -1
  36. package/src/lib/styles/theme.css +203 -0
@@ -94,7 +94,10 @@
94
94
  </script>
95
95
 
96
96
  <details bind:this={root} class="group relative {klass}">
97
+ <!-- The name beside the avatar is hidden below sm, and the avatar is paint,
98
+ so the trigger carries its name itself. -->
97
99
  <summary
100
+ aria-label="{name}, account menu"
98
101
  class="{TOUCH_GROW} flex cursor-pointer list-none items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
99
102
  >
100
103
  <span
@@ -117,7 +120,7 @@
117
120
  rule are the card treatment, because lifted off the header it is a
118
121
  surface of its own. -->
119
122
  <div
120
- class="absolute end-0 top-full z-dropdown mt-2 flex w-64 origin-top-right flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-2xl animate-popover-in"
123
+ class="absolute end-0 top-full z-dropdown mt-2 flex w-64 origin-top-right flex-col gap-1 rounded-xl border border-line bg-surface p-2 shadow-popover inset-shadow-highlight animate-popover-in"
121
124
  >
122
125
  <div class="border-b border-line px-2 pb-2">
123
126
  <p class="truncate text-sm font-medium text-fg">{name}</p>
@@ -9,6 +9,15 @@
9
9
  export interface SidebarState {
10
10
  rail: boolean;
11
11
  }
12
+
13
+ /**
14
+ * How long a pointer has to rest over the rail before it opens. This waits
15
+ * for the reader, not for an animation. A pointer crossing the 56px rail on
16
+ * its way to the page is still moving and never rests there, so the column
17
+ * does not open over the control it is heading for. One that stops on the
18
+ * rail means to read it.
19
+ */
20
+ export const RAIL_REST_MS = 100;
12
21
  </script>
13
22
 
14
23
  <script lang="ts">
@@ -42,6 +51,7 @@
42
51
  APP_HEADER,
43
52
  APP_SIDEBAR,
44
53
  APP_SIDEBAR_BAND,
54
+ APP_SIDEBAR_EDGE,
45
55
  APP_SIDEBAR_RAIL,
46
56
  APP_SIDEBAR_WIDE,
47
57
  } from '../internal/layout.js';
@@ -161,15 +171,31 @@
161
171
  * The rail: the icon column, and the labels on demand.
162
172
  *
163
173
  * While the sidebar is the rail it is 56px wide and the nav is told so. A
164
- * pointer over it, or focus inside it, opens it to the full column over the
165
- * page rather than beside it, so the page keeps its width and the reader
166
- * still gets every label. It closes when the pointer or the focus leaves.
174
+ * pointer at rest over it, or focus inside it, opens it to the full column
175
+ * over the page rather than beside it, so the page keeps its width and the
176
+ * reader still gets every label. It closes when the pointer or the focus
177
+ * leaves. A pointer that only passes over it leaves it the rail: the open
178
+ * column covers the start of the page, and opening under a pointer bound
179
+ * for a control there held the column open over that control.
167
180
  * State rather than a hover rule, because the nav snippet has to be told
168
181
  * the labels are wanted, and a stylesheet cannot tell it.
169
182
  */
170
183
  const railMode = $derived(!isMobile && !railHidden && (rail || isNarrow));
171
184
  let railOpen = $state(false);
172
185
  const railIcons = $derived(railMode && !railOpen);
186
+ let railRest: ReturnType<typeof setTimeout> | undefined;
187
+
188
+ /** Every move starts the rest again, so only a pointer that stops opens the column. */
189
+ function onRailPointerMove() {
190
+ if (railOpen) return;
191
+ clearTimeout(railRest);
192
+ railRest = setTimeout(() => (railOpen = true), RAIL_REST_MS);
193
+ }
194
+
195
+ function onRailPointerLeave() {
196
+ clearTimeout(railRest);
197
+ railOpen = false;
198
+ }
173
199
 
174
200
  $effect(() => {
175
201
  const mobile = window.matchMedia('(max-width: 767px)');
@@ -178,12 +204,14 @@
178
204
  isMobile = mobile.matches;
179
205
  isNarrow = narrow.matches && !mobile.matches;
180
206
  if (!mobile.matches) navOpen = false;
207
+ clearTimeout(railRest);
181
208
  railOpen = false;
182
209
  };
183
210
  sync();
184
211
  mobile.addEventListener('change', sync);
185
212
  narrow.addEventListener('change', sync);
186
213
  return () => {
214
+ clearTimeout(railRest);
187
215
  mobile.removeEventListener('change', sync);
188
216
  narrow.removeEventListener('change', sync);
189
217
  };
@@ -250,7 +278,7 @@
250
278
  <div
251
279
  data-print="hide"
252
280
  transition:motion.drawer|global={{ side: startSide() }}
253
- class="fixed inset-y-0 start-0 z-drawer flex shadow-2xl"
281
+ class="fixed inset-y-0 start-0 z-drawer flex shadow-overlay"
254
282
  role="dialog"
255
283
  aria-modal="true"
256
284
  aria-label={drawerLabel}
@@ -269,14 +297,15 @@
269
297
  data-testid="app-rail"
270
298
  role="presentation"
271
299
  class="relative hidden {APP_SIDEBAR_RAIL} shrink-0 md:block"
272
- onpointerenter={() => (railOpen = true)}
273
- onpointerleave={() => (railOpen = false)}
300
+ onpointerenter={onRailPointerMove}
301
+ onpointermove={onRailPointerMove}
302
+ onpointerleave={onRailPointerLeave}
274
303
  onfocusin={() => (railOpen = true)}
275
304
  onfocusout={onRailFocusOut}
276
305
  >
277
306
  <div
278
307
  class="absolute inset-y-0 start-0 z-dropdown flex transition-shadow {railOpen
279
- ? 'shadow-2xl'
308
+ ? 'shadow-popover'
280
309
  : ''}"
281
310
  >
282
311
  {@render sidebar(isMobile, railIcons)}
@@ -288,7 +317,10 @@
288
317
  </div>
289
318
  {/if}
290
319
 
291
- <div class="flex min-w-0 flex-1 flex-col">
320
+ <!-- The column carries the ink and the light pooled at its top, so the
321
+ translucent header and the page under it share one canvas. The main
322
+ region paints nothing of its own for that reason. -->
323
+ <div class="flex min-w-0 flex-1 flex-col bg-ink bg-canvas">
292
324
  {#if !barHidden}
293
325
  <header data-print="hide" class={APP_HEADER}>
294
326
  <div class="flex min-w-0 flex-1 items-center gap-2">
@@ -348,7 +380,7 @@
348
380
  id="content"
349
381
  tabindex="-1"
350
382
  data-print="unclip"
351
- class="relative min-w-0 flex-1 overflow-auto bg-ink outline-none"
383
+ class="relative min-w-0 flex-1 overflow-auto outline-none"
352
384
  >
353
385
  {@render children()}
354
386
  </main>
@@ -363,7 +395,7 @@
363
395
  aria-label={sidebarLabel}
364
396
  data-print="hide"
365
397
  data-testid="app-sidebar"
366
- class="{APP_SIDEBAR} {rail ? APP_SIDEBAR_RAIL : APP_SIDEBAR_WIDE} flex"
398
+ class="{APP_SIDEBAR} {APP_SIDEBAR_EDGE} {rail ? APP_SIDEBAR_RAIL : APP_SIDEBAR_WIDE} flex"
367
399
  inert={hidden}
368
400
  aria-hidden={hidden ? 'true' : undefined}
369
401
  >
@@ -8,6 +8,14 @@
8
8
  export interface SidebarState {
9
9
  rail: boolean;
10
10
  }
11
+ /**
12
+ * How long a pointer has to rest over the rail before it opens. This waits
13
+ * for the reader, not for an animation. A pointer crossing the 56px rail on
14
+ * its way to the page is still moving and never rests there, so the column
15
+ * does not open over the control it is heading for. One that stops on the
16
+ * rail means to read it.
17
+ */
18
+ export declare const RAIL_REST_MS = 100;
11
19
  /**
12
20
  * The authed application frame: the sidebar, the header bar and the content
13
21
  * column, owned once so three apps cannot each invent their own.
@@ -59,8 +59,15 @@
59
59
  lockup, heading and card, and no two agreed: three heading sizes, a lockup
60
60
  on some pages, a theme control on others, and one class that named no
61
61
  token at all. Stated once here, the way AppShell states the signed-in frame.
62
+
63
+ The ground is the hero wash, ink into a tenth of violet, because this page
64
+ is the first one a reader sees. bg-ink stays under it for wherever the wash
65
+ is not drawn.
62
66
  -->
63
- <main class={cn('flex min-h-screen items-center justify-center bg-ink px-4 py-10', klass)} {style}>
67
+ <main
68
+ class={cn('flex min-h-screen items-center justify-center bg-ink bg-hero px-4 py-10', klass)}
69
+ {style}
70
+ >
64
71
  <div class="w-full {widths[width]}">
65
72
  {#if actions}
66
73
  <div class="mb-4 flex justify-end">{@render actions()}</div>
@@ -86,7 +93,7 @@
86
93
  {:else}
87
94
  <Logo size="lg" class="mb-5" />
88
95
  {/if}
89
- <h1 class="text-2xl font-bold text-fg">{title}</h1>
96
+ <h1 class="text-2xl font-semibold tracking-h2 text-fg">{title}</h1>
90
97
  {#if description}
91
98
  <p class="mt-2 text-muted">{description}</p>
92
99
  {/if}
@@ -23,13 +23,14 @@
23
23
  danger: 'bg-danger/10 text-danger border-danger/20',
24
24
  };
25
25
 
26
+ /** The dot carries a soft halo of its own tone. A ring is a shadow, so the dot and the label beside it do not move. */
26
27
  const dotColor: Record<AccentTone, string> = {
27
- neutral: 'bg-muted',
28
- brand: 'bg-brand',
29
- violet: 'bg-violet',
30
- success: 'bg-success',
31
- warn: 'bg-warn',
32
- danger: 'bg-danger',
28
+ neutral: 'bg-muted ring-2 ring-muted/20',
29
+ brand: 'bg-brand ring-2 ring-brand/25',
30
+ violet: 'bg-violet ring-2 ring-violet/25',
31
+ success: 'bg-success ring-2 ring-success/25',
32
+ warn: 'bg-warn ring-2 ring-warn/25',
33
+ danger: 'bg-danger ring-2 ring-danger/25',
33
34
  };
34
35
 
35
36
  const sizes: Record<Size, string> = {
@@ -82,15 +82,38 @@
82
82
  * below brand-light on the dark ramp and below it again on the light one, and
83
83
  * the filtered variants darken rather than brighten, so a press does not read
84
84
  * as one gesture in one theme and its opposite in the other.
85
+ *
86
+ * The filled and secondary variants are raised: a bright edge along the top
87
+ * and a short drop under them. The primary action alone glows, softly at
88
+ * rest and more under the pointer or held, so the one thing a surface wants
89
+ * done is the one thing that answers with light. Its fill is lifted, white
90
+ * falling off from the top over the brand color, and the hover and held
91
+ * colors change under the lift. The glow is a shadow, so it never moves the
92
+ * button or its neighbors, and the color change carries the state on its
93
+ * own where shadows and gradients are not drawn. A disabled primary drops
94
+ * its glow, which would otherwise invite the click it refuses.
95
+ *
96
+ * A filled button's boundary is its fill, and a forced colors scheme
97
+ * replaces the fill with the canvas, so the button read as loose text.
98
+ * There it draws an outline in the button text color, inset by its own
99
+ * width. An outline takes no space, so nothing moves, and the focus
100
+ * outline in theme.css replaces it while the button has focus.
85
101
  */
102
+ const FILLED_EDGE =
103
+ 'forced-colors:outline-solid forced-colors:outline-1 forced-colors:-outline-offset-1 ' +
104
+ 'forced-colors:outline-[color:ButtonText] forced-colors:disabled:outline-[color:GrayText]';
105
+
86
106
  const variants: Record<Variant, string> = {
87
107
  primary:
88
- 'bg-brand text-ink hover:bg-brand-light active:bg-brand-deep shadow-sm shadow-brand/20',
108
+ 'bg-brand bg-lift text-ink hover:bg-brand-light active:bg-brand-deep inset-shadow-bevel ' +
109
+ 'shadow-primary hover:shadow-primary-hover active:shadow-primary-hover ' +
110
+ `disabled:shadow-raised aria-disabled:shadow-raised ${FILLED_EDGE}`,
89
111
  violet:
90
- 'bg-violet text-ink hover:brightness-110 active:brightness-90 shadow-sm shadow-violet/20',
112
+ `bg-violet text-ink hover:brightness-110 active:brightness-90 inset-shadow-bevel shadow-raised ${FILLED_EDGE}`,
91
113
  secondary:
92
- 'bg-surface-2 text-fg border border-line-strong hover:bg-line active:bg-line-strong/30',
93
- danger: 'bg-danger text-ink hover:brightness-110 active:brightness-90',
114
+ 'bg-surface-2 text-fg border border-line-strong hover:bg-line active:bg-line-strong/30 inset-shadow-highlight shadow-raised',
115
+ danger:
116
+ `bg-danger text-ink hover:brightness-110 active:brightness-90 inset-shadow-bevel shadow-raised ${FILLED_EDGE}`,
94
117
  ghost: 'text-muted hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg',
95
118
  outline:
96
119
  'border border-line-strong text-fg hover:border-brand hover:text-brand active:bg-brand/10',
@@ -125,7 +148,8 @@
125
148
  * button its icon plus 16px.
126
149
  */
127
150
  const cls = $derived(
128
- `inline-flex items-center justify-center font-medium whitespace-nowrap transition-colors ` +
151
+ `inline-flex items-center justify-center font-medium whitespace-nowrap ` +
152
+ `transition-[color,background-color,border-color,box-shadow] focus-visible:shadow-glow ` +
129
153
  `disabled:opacity-50 disabled:cursor-not-allowed select-none ${TOUCH_GROW} ` +
130
154
  `${full ? 'w-full' : ''} ${variants[variant]} ${sizes[size]} ${klass}`,
131
155
  );
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Component, Snippet } from 'svelte';
3
- import { sectionHeading } from '../internal/layout.js';
3
+ import { CARD_SURFACE, sectionHeading } from '../internal/layout.js';
4
4
 
5
5
  type Pad = 'none' | 'sm' | 'md' | 'lg';
6
6
 
@@ -81,11 +81,16 @@
81
81
  <!-- tabindex is only set alongside role="button" (when onclick is provided),
82
82
  so the element is interactive. The compiler can't narrow the dynamic role. -->
83
83
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
84
+ <!-- The surface is the layout contract's, so a Card and a bordered Panel on
85
+ one page agree. A hover card, and any clickable one, takes a cyan border
86
+ and the focus glow under the pointer, so it answers the way a button does.
87
+ A clickable card glows on focus too: its own shadow would otherwise hide
88
+ the global focus glow. -->
84
89
  <div
85
90
  data-print="keep"
86
- class="bg-surface border border-line rounded-xl overflow-hidden transition-colors {hover
87
- ? 'hover:border-line/0 hover:ring-1 hover:ring-brand/30'
88
- : ''} {onclick ? 'cursor-pointer' : ''} {klass}"
91
+ class="{CARD_SURFACE} overflow-hidden transition-[border-color,box-shadow] {hover || onclick
92
+ ? 'hover:border-brand/50 hover:shadow-glow'
93
+ : ''} {onclick ? 'cursor-pointer focus-visible:shadow-glow' : ''} {klass}"
89
94
  role={onclick ? 'button' : undefined}
90
95
  tabindex={onclick ? 0 : undefined}
91
96
  {onclick}
@@ -115,7 +115,7 @@
115
115
  <div
116
116
  use:overlay
117
117
  transition:motion.drawer|global={{ side: edge() }}
118
- class="relative flex h-full w-full flex-col {OVERLAY_WIDTH[rung]} bg-surface shadow-2xl
118
+ class="relative flex h-full w-full flex-col {OVERLAY_WIDTH[rung]} bg-surface shadow-overlay
119
119
  {side === 'right' ? 'border-s' : 'border-e'} border-line"
120
120
  role="dialog"
121
121
  aria-modal="true"
@@ -29,19 +29,25 @@
29
29
  }: Props = $props();
30
30
  </script>
31
31
 
32
+ <!-- The hero wash: the page's ink into a tenth of violet, under a solid 1px
33
+ border. It was a dashed outline on a translucent surface, which read as a
34
+ slot left unfilled rather than as a place something will appear. The
35
+ medallion is a raised control in miniature, and its icon takes the brand
36
+ color. The contrast suite measures every text token against both ends of
37
+ the wash. -->
32
38
  <div
33
- class="flex flex-col items-center justify-center text-center rounded-xl border border-dashed border-line bg-surface/40 px-6 py-12 {klass}"
39
+ class="flex flex-col items-center justify-center text-center rounded-card border border-line bg-ink bg-hero inset-shadow-highlight px-6 py-12 {klass}"
34
40
  >
35
41
  {#if iconSnippet}
36
42
  <div
37
- class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-muted"
43
+ class="mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-line bg-surface-2 text-brand inset-shadow-highlight shadow-raised"
38
44
  aria-hidden="true"
39
45
  >
40
46
  {@render iconSnippet()}
41
47
  </div>
42
48
  {:else if icon}
43
49
  <div
44
- class="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2 text-2xl"
50
+ class="mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-line bg-surface-2 text-2xl inset-shadow-highlight shadow-raised"
45
51
  aria-hidden="true"
46
52
  >
47
53
  {icon}
@@ -87,7 +87,7 @@
87
87
  use:overlay
88
88
  transition:motion.dialog|global
89
89
  class="relative flex max-h-[calc(100dvh-2rem)] w-full {OVERLAY_WIDTH[rung]} flex-col
90
- overflow-hidden rounded-xl border border-line bg-surface shadow-2xl"
90
+ overflow-hidden rounded-card border border-line bg-surface bg-card shadow-overlay inset-shadow-highlight"
91
91
  role="dialog"
92
92
  aria-modal="true"
93
93
  aria-labelledby={title ? headingId : undefined}
@@ -55,9 +55,15 @@
55
55
  a 390px screen is the ramp applied rather than adopted. 32px is the step
56
56
  the ramp does hold and the step this was already reaching for.
57
57
 
58
- The weight stays at 700, which is the ramp's H1 weight, because this is
59
- still the page's h1. Taking H2's 600 along with its size would move the
60
- title twice for one decision.
58
+ The weight is H2's 600 with H2's -0.02em tracking, on a 38px line
59
+ (leading-title). A 700 title at 32px read heavy beside the semibold
60
+ card headings under it, and the tighter tracking is what makes a large
61
+ title read as set rather than typed.
62
+
63
+ Below the sm breakpoint it drops to the console's 24/32 step, which
64
+ keeps a long title to fewer lines on a phone. A media query is settled
65
+ before the first paint, so the title never changes size while the
66
+ reader watches.
61
67
 
62
68
  Each size token names its own leading and tracking. A `text-*` token
63
69
  built from `--text-*` alone sets font-size and leaves line-height to
@@ -71,7 +77,7 @@
71
77
  data-testid="page-title"
72
78
  class="[overflow-wrap:anywhere] {compact
73
79
  ? 'text-sm font-semibold text-fg'
74
- : 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}"
80
+ : 'text-2xl tracking-h2 font-semibold text-fg sm:text-h2 sm:leading-title'}"
75
81
  >
76
82
  {title}
77
83
  </h1>
@@ -220,9 +220,9 @@
220
220
  return label ? `${label} ${range}` : range;
221
221
  });
222
222
 
223
- /** The target of a control, sanitized, or nothing when the step is unavailable. */
224
- function linkTo(target: number, enabled: boolean): string | undefined {
225
- return href && enabled ? safeHref(href(target)) : undefined;
223
+ /** The target of a control, sanitized, or nothing when the builder's URL is refused. */
224
+ function linkTo(target: number): string | undefined {
225
+ return href ? safeHref(href(target)) : undefined;
226
226
  }
227
227
 
228
228
  /**
@@ -250,16 +250,6 @@
250
250
  const btnRest = 'text-muted hover:text-fg hover:bg-surface-2 active:bg-line active:text-fg';
251
251
 
252
252
  const btnCurrent = 'bg-brand text-ink';
253
-
254
- /**
255
- * A link with nowhere to go.
256
- *
257
- * An anchor has no `disabled`, and `disabled:` never matches one, so the two
258
- * states have to be painted rather than declared. Dropping the href is what
259
- * takes it out of the tab order and stops activation. The class is only the
260
- * part a reader can see, at the same 30% the disabled buttons use.
261
- */
262
- const linkInert = 'pointer-events-none opacity-30';
263
253
  </script>
264
254
 
265
255
  {#snippet arrow(d: string)}
@@ -281,15 +271,12 @@
281
271
 
282
272
  <!-- Previous and next, as a link when the caller builds hrefs and as a button
283
273
  when it hands over a callback. The two branches carry the same classes and
284
- the same label, so the pager reads and sounds the same either way. -->
274
+ the same label, so the pager reads and sounds the same either way. A step
275
+ with nowhere to go is a disabled button in both: an anchor without an href
276
+ is a plain element, and the accessibility tree refuses a label on one. -->
285
277
  {#snippet step(target: number, label: string, enabled: boolean, d: string)}
286
- {#if href}
287
- <a
288
- href={linkTo(target, enabled)}
289
- aria-label={label}
290
- aria-disabled={enabled ? undefined : 'true'}
291
- class="{btnBase} {btnRest} {enabled ? '' : linkInert}"
292
- >
278
+ {#if href && enabled}
279
+ <a href={linkTo(target)} aria-label={label} class="{btnBase} {btnRest}">
293
280
  {@render arrow(d)}
294
281
  </a>
295
282
  {:else}
@@ -342,7 +329,7 @@
342
329
  </span>
343
330
  {:else if href}
344
331
  <a
345
- href={linkTo(n, true)}
332
+ href={linkTo(n)}
346
333
  aria-current={safePage === n ? 'page' : undefined}
347
334
  class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
348
335
  ? btnCurrent
@@ -60,6 +60,14 @@
60
60
  * `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
61
61
  */
62
62
  per?: BillingPeriod | 'once';
63
+ /**
64
+ * What the order charges, in cents, when the host prices more than the
65
+ * selection, such as a discount code. It stands in for the quote's total
66
+ * in the bar and the default summary, and is what `summary` and `actions`
67
+ * are handed. It counts only while `quote` describes the selection, so it
68
+ * never outlives the cart it was priced for.
69
+ */
70
+ charged?: number | null;
63
71
  /** True while a fresh price is in flight. */
64
72
  quoting?: boolean;
65
73
  /** The count the bill will carry, when the host knows better than the cart. */
@@ -124,6 +132,7 @@
124
132
  quotePeriod = null,
125
133
  period = 'monthly',
126
134
  per,
135
+ charged = null,
127
136
  quoting = false,
128
137
  billedSkus = null,
129
138
  currency = 'USD',
@@ -187,9 +196,8 @@
187
196
 
188
197
  const total = $derived(
189
198
  quoted
190
- ? period === 'annual'
191
- ? (quoted.annual_total_cents ?? null)
192
- : quoted.total_cents
199
+ ? (charged ??
200
+ (period === 'annual' ? (quoted.annual_total_cents ?? null) : quoted.total_cents))
193
201
  : null,
194
202
  );
195
203
 
@@ -273,9 +281,22 @@
273
281
  {/if}
274
282
 
275
283
  {#if shown.length === 0}
276
- <p class="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">
277
- Nothing matches that search.
278
- </p>
284
+ <div
285
+ class="flex flex-col items-center gap-3 rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted"
286
+ >
287
+ <p>No capabilities match these filters.</p>
288
+ <Button
289
+ type="button"
290
+ size="sm"
291
+ variant="secondary"
292
+ onclick={() => {
293
+ query = '';
294
+ category = 'all';
295
+ }}
296
+ >
297
+ Clear filters
298
+ </Button>
299
+ </div>
279
300
  {:else}
280
301
  <div class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-3">
281
302
  {#each shown as plugin (plugin.sku)}
@@ -360,7 +381,7 @@
360
381
  <div class="sticky bottom-3 lg:hidden" transition:motion.toast|global>
361
382
  <a
362
383
  href="#{summaryId}"
363
- 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"
384
+ class="flex items-center justify-between gap-3 rounded-xl border border-brand bg-surface px-4 py-3 text-sm shadow-popover inset-shadow-highlight transition-colors hover:bg-surface-2"
364
385
  >
365
386
  <span class="text-fg">
366
387
  <span class="font-semibold">{count}</span> picked{#if total !== null}{' '}<span
@@ -376,7 +397,7 @@
376
397
 
377
398
  <aside
378
399
  id={summaryId}
379
- class="scroll-mt-20 self-start rounded-xl border border-line bg-surface p-5 lg:sticky"
400
+ class="scroll-mt-20 self-start rounded-card border border-line bg-surface bg-card p-5 shadow-card inset-shadow-highlight lg:sticky"
380
401
  style:top={stickyTop}
381
402
  aria-label={heading}
382
403
  >
@@ -407,7 +428,8 @@
407
428
 
408
429
  {#if count === 0}
409
430
  <p class="py-6 text-center text-sm text-muted">
410
- Nothing picked yet. With none of these you are on the free tier, which needs no checkout.
431
+ No capabilities picked yet. Without any, you stay on the free tier, which needs no
432
+ checkout.
411
433
  </p>
412
434
  {:else}
413
435
  <ul class="mb-4 max-h-56 space-y-1 overflow-y-auto pe-1">
@@ -474,7 +496,7 @@
474
496
  {/if}
475
497
  {#if ladder.next}
476
498
  <p class="mb-3 text-xs text-muted">
477
- {ladder.next.more} more and the discount is {ladder.next.discount_pct}%.
499
+ Add {ladder.next.more} more for a {ladder.next.discount_pct}% discount.
478
500
  </p>
479
501
  {/if}
480
502
 
@@ -26,6 +26,14 @@ interface Props {
26
26
  * `'once'` and the total reads "$6.00 once" rather than "$6.00/mo".
27
27
  */
28
28
  per?: BillingPeriod | 'once';
29
+ /**
30
+ * What the order charges, in cents, when the host prices more than the
31
+ * selection, such as a discount code. It stands in for the quote's total
32
+ * in the bar and the default summary, and is what `summary` and `actions`
33
+ * are handed. It counts only while `quote` describes the selection, so it
34
+ * never outlives the cart it was priced for.
35
+ */
36
+ charged?: number | null;
29
37
  /** True while a fresh price is in flight. */
30
38
  quoting?: boolean;
31
39
  /** The count the bill will carry, when the host knows better than the cart. */
@@ -30,9 +30,21 @@
30
30
  : 0,
31
31
  );
32
32
 
33
+ /**
34
+ * The brand fill runs cyan into violet, the accent the kit keeps for a mark
35
+ * a few pixels wide, and both ends clear 3:1 against the track in both
36
+ * palettes. bg-brand stays under it for paper, which draws no gradient. It
37
+ * runs from the start edge, so under right-to-left the fill is mirrored and
38
+ * its cyan end still sits where the bar starts.
39
+ *
40
+ * A forced colors scheme replaces every background color with the canvas,
41
+ * which erased the track and the fill alike. There the track draws a border
42
+ * and the fill opts out of the forced palette to paint in the system text
43
+ * color, so the value is still there to read.
44
+ */
33
45
  const tones: Record<AccentTone, string> = {
34
46
  neutral: 'bg-muted',
35
- brand: 'bg-brand',
47
+ brand: 'bg-brand bg-accent',
36
48
  violet: 'bg-violet',
37
49
  success: 'bg-success',
38
50
  warn: 'bg-warn',
@@ -54,7 +66,8 @@
54
66
  </div>
55
67
  {/if}
56
68
  <div
57
- class="w-full overflow-hidden rounded-full bg-surface-2 {sizes[size]}"
69
+ class="w-full overflow-hidden rounded-full bg-surface-2 inset-shadow-well {sizes[size]}
70
+ forced-colors:border forced-colors:border-[color:CanvasText]"
58
71
  role="progressbar"
59
72
  aria-valuenow={value}
60
73
  aria-valuemin={0}
@@ -62,7 +75,8 @@
62
75
  aria-label={label}
63
76
  >
64
77
  <div
65
- class="h-full rounded-full transition-[width] duration-progress
78
+ class="h-full rounded-full transition-[width] duration-progress rtl:-scale-x-100
79
+ forced-colors:forced-color-adjust-none forced-colors:bg-[color:CanvasText]
66
80
  {tones[tone]}
67
81
  {animated ? 'animate-pulse' : ''}"
68
82
  style="width: {pct}%"
@@ -265,7 +265,7 @@
265
265
  onfocusout={linked ? onGroupFocusOut : undefined}
266
266
  class="inline-flex items-stretch rounded-lg border {error
267
267
  ? 'border-danger coarse:outline-danger'
268
- : 'border-line-strong coarse:outline-line-strong'} bg-surface-2 p-0.5 coarse:border-0 coarse:p-0.75 coarse:outline coarse:outline-1 coarse:-outline-offset-1 {SIZES[
268
+ : 'border-line-strong coarse:outline-line-strong'} bg-surface-2 inset-shadow-well p-0.5 coarse:border-0 coarse:p-0.75 coarse:outline coarse:outline-1 coarse:-outline-offset-1 {SIZES[
269
269
  size
270
270
  ].row} {disabled ? 'opacity-50' : ''}"
271
271
  >
@@ -276,7 +276,7 @@
276
276
  `${HIT_AREA} inline-flex coarse:min-w-control items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
277
277
  SIZES[size].segment +
278
278
  (selected
279
- ? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
279
+ ? ' bg-surface text-brand shadow-raised inset-shadow-highlight ring-1 ring-inset ring-brand/40'
280
280
  : ' text-muted hover:text-fg active:bg-surface active:text-fg')}
281
281
  {#snippet content()}
282
282
  {#if Icon}
@@ -83,10 +83,23 @@
83
83
  * inside `@media (hover: hover)` and never matches a finger. A sidebar is
84
84
  * mostly used on a phone as a drawer, where the tap that closed it was the
85
85
  * one gesture in the component with nothing on screen behind it.
86
+ *
87
+ * The current row's mark is a short cyan to violet bar laid over its start
88
+ * border, inset from the rounded corners, rather than the border itself: a
89
+ * gradient border cannot follow a radius. The border goes transparent under
90
+ * it and returns as the system text color in a forced colors scheme, which
91
+ * draws neither the bar nor the row's fill, so the row keeps a mark there.
92
+ * The row's fill is the brand tint and the bar carries a few pixels of
93
+ * glow, so the row reads as the place you are rather than one more entry.
86
94
  */
87
95
  function rowPaint(state: 'current' | 'ancestor' | 'rest'): string {
88
96
  if (state === 'current')
89
- return 'border-brand bg-surface-2 font-medium text-brand hover:bg-line active:bg-line';
97
+ return (
98
+ 'relative border-transparent forced-colors:border-[CanvasText] bg-tint ' +
99
+ 'inset-shadow-highlight font-medium text-brand hover:bg-line active:bg-line ' +
100
+ 'after:absolute after:inset-y-1.5 after:-start-0.5 after:w-0.5 after:rounded-full ' +
101
+ 'after:bg-brand after:bg-accent-y after:shadow-mark'
102
+ );
90
103
  if (state === 'ancestor') return 'border-brand text-fg hover:bg-surface-2 active:bg-line';
91
104
  return 'border-transparent text-muted hover:bg-surface-2 hover:text-fg active:bg-line active:text-fg';
92
105
  }
@@ -447,7 +447,7 @@
447
447
  const mark = line?.index === index ? LINE[line.edge as 'before' | 'after'] : null;
448
448
  return cn(
449
449
  'relative flex items-center gap-1 rounded-lg',
450
- drag?.id === id && 'z-dropdown bg-surface shadow-lg',
450
+ drag?.id === id && 'z-dropdown bg-surface shadow-popover',
451
451
  settling === id && 'transition-[translate] duration-base ease-move',
452
452
  mark && `${mark.shape} ${line?.refused ? mark.refused : mark.ok}`,
453
453
  );