@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.
- package/dist/components/AccountMenu.svelte +4 -1
- package/dist/components/AppShell.svelte +42 -10
- package/dist/components/AppShell.svelte.d.ts +8 -0
- package/dist/components/AuthShell.svelte +9 -2
- package/dist/components/Badge.svelte +7 -6
- package/dist/components/Button.svelte +29 -5
- package/dist/components/Card.svelte +9 -4
- package/dist/components/Drawer.svelte +1 -1
- package/dist/components/EmptyState.svelte +9 -3
- package/dist/components/Modal.svelte +1 -1
- package/dist/components/PageHeader.svelte +10 -4
- package/dist/components/Pagination.svelte +9 -22
- package/dist/components/PluginCart.svelte +32 -10
- package/dist/components/PluginCart.svelte.d.ts +8 -0
- package/dist/components/Progress.svelte +17 -3
- package/dist/components/SegmentedControl.svelte +2 -2
- package/dist/components/SidebarNav.svelte +14 -1
- package/dist/components/SortableList.svelte +1 -1
- package/dist/components/Stat.svelte +13 -3
- package/dist/components/Table.svelte +20 -9
- package/dist/components/Tabs.svelte +77 -10
- package/dist/components/Toaster.svelte +1 -1
- package/dist/components/Tooltip.svelte +1 -1
- package/dist/components/dialog/Dialog.svelte +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/field.js +12 -1
- package/dist/internal/layout.d.ts +29 -4
- package/dist/internal/layout.js +37 -5
- package/dist/internal/panel.d.ts +4 -1
- package/dist/internal/panel.js +4 -1
- package/dist/internal/scroll-edges.d.ts +9 -8
- package/dist/internal/scroll-edges.js +9 -8
- package/dist/styles/theme.css +203 -0
- package/package.json +1 -1
- 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-
|
|
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
|
|
165
|
-
* page rather than beside it, so the page keeps its width and the
|
|
166
|
-
* still gets every label. It closes when the pointer or the focus
|
|
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-
|
|
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={
|
|
273
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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="
|
|
87
|
-
? 'hover:border-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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-
|
|
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
|
|
224
|
-
function linkTo(target: number
|
|
225
|
-
return href
|
|
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
|
|
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
|
-
?
|
|
191
|
-
|
|
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
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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-
|
|
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
|
);
|