@lyeve-labs/ui-kit 0.45.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.
@@ -120,7 +120,7 @@
120
120
  rule are the card treatment, because lifted off the header it is a
121
121
  surface of its own. -->
122
122
  <div
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-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"
124
124
  >
125
125
  <div class="border-b border-line px-2 pb-2">
126
126
  <p class="truncate text-sm font-medium text-fg">{name}</p>
@@ -51,6 +51,7 @@
51
51
  APP_HEADER,
52
52
  APP_SIDEBAR,
53
53
  APP_SIDEBAR_BAND,
54
+ APP_SIDEBAR_EDGE,
54
55
  APP_SIDEBAR_RAIL,
55
56
  APP_SIDEBAR_WIDE,
56
57
  } from '../internal/layout.js';
@@ -277,7 +278,7 @@
277
278
  <div
278
279
  data-print="hide"
279
280
  transition:motion.drawer|global={{ side: startSide() }}
280
- 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"
281
282
  role="dialog"
282
283
  aria-modal="true"
283
284
  aria-label={drawerLabel}
@@ -304,7 +305,7 @@
304
305
  >
305
306
  <div
306
307
  class="absolute inset-y-0 start-0 z-dropdown flex transition-shadow {railOpen
307
- ? 'shadow-2xl'
308
+ ? 'shadow-popover'
308
309
  : ''}"
309
310
  >
310
311
  {@render sidebar(isMobile, railIcons)}
@@ -316,7 +317,10 @@
316
317
  </div>
317
318
  {/if}
318
319
 
319
- <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">
320
324
  {#if !barHidden}
321
325
  <header data-print="hide" class={APP_HEADER}>
322
326
  <div class="flex min-w-0 flex-1 items-center gap-2">
@@ -376,7 +380,7 @@
376
380
  id="content"
377
381
  tabindex="-1"
378
382
  data-print="unclip"
379
- 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"
380
384
  >
381
385
  {@render children()}
382
386
  </main>
@@ -391,7 +395,7 @@
391
395
  aria-label={sidebarLabel}
392
396
  data-print="hide"
393
397
  data-testid="app-sidebar"
394
- 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"
395
399
  inert={hidden}
396
400
  aria-hidden={hidden ? 'true' : undefined}
397
401
  >
@@ -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>
@@ -281,9 +281,22 @@
281
281
  {/if}
282
282
 
283
283
  {#if shown.length === 0}
284
- <p class="rounded-xl border border-dashed border-line p-8 text-center text-sm text-muted">
285
- Nothing matches that search.
286
- </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>
287
300
  {:else}
288
301
  <div class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-3">
289
302
  {#each shown as plugin (plugin.sku)}
@@ -368,7 +381,7 @@
368
381
  <div class="sticky bottom-3 lg:hidden" transition:motion.toast|global>
369
382
  <a
370
383
  href="#{summaryId}"
371
- 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"
372
385
  >
373
386
  <span class="text-fg">
374
387
  <span class="font-semibold">{count}</span> picked{#if total !== null}{' '}<span
@@ -384,7 +397,7 @@
384
397
 
385
398
  <aside
386
399
  id={summaryId}
387
- 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"
388
401
  style:top={stickyTop}
389
402
  aria-label={heading}
390
403
  >
@@ -415,7 +428,8 @@
415
428
 
416
429
  {#if count === 0}
417
430
  <p class="py-6 text-center text-sm text-muted">
418
- 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.
419
433
  </p>
420
434
  {:else}
421
435
  <ul class="mb-4 max-h-56 space-y-1 overflow-y-auto pe-1">
@@ -482,7 +496,7 @@
482
496
  {/if}
483
497
  {#if ladder.next}
484
498
  <p class="mb-3 text-xs text-muted">
485
- {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.
486
500
  </p>
487
501
  {/if}
488
502
 
@@ -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
  );
@@ -8,6 +8,7 @@
8
8
  * use this one instead.
9
9
  */
10
10
  import type { StatusTone } from '../internal/tone.js';
11
+ import { CARD_SURFACE } from '../internal/layout.js';
11
12
 
12
13
  type Trend = 'up' | 'down' | 'flat';
13
14
  type Accent = 'brand' | 'violet' | 'success' | 'neutral';
@@ -74,8 +75,8 @@
74
75
 
75
76
  const pads: Record<Size, string> = { sm: 'p-4', md: 'p-5' };
76
77
  const values: Record<Size, string> = {
77
- sm: 'mt-1 text-lg font-semibold',
78
- md: 'mt-2 text-2xl font-bold',
78
+ sm: 'mt-1 text-lg font-semibold tracking-tight',
79
+ md: 'mt-2 text-2xl font-bold tracking-tight',
79
80
  };
80
81
 
81
82
  const valueColor = $derived(tone ? tones[tone] : accents[accent]);
@@ -84,7 +85,16 @@
84
85
  const trendMark = { up: '↑', down: '↓', flat: '→' } as const;
85
86
  </script>
86
87
 
87
- <div class="rounded-xl border border-line bg-surface {pads[size]} {klass}">
88
+ <!-- A brand to violet line along the top edge, so a row of figures reads as a
89
+ set. It is a mark and carries nothing a reader needs, so where gradients
90
+ are not drawn it falls back to solid brand or to nothing at all. It is
91
+ mirrored under right-to-left, so cyan stays at the start edge.
92
+ overflow-hidden lets the card's corners trim it. -->
93
+ <div
94
+ class="{CARD_SURFACE} relative overflow-hidden before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-brand before:bg-accent rtl:before:-scale-x-100 {pads[
95
+ size
96
+ ]} {klass}"
97
+ >
88
98
  <p class="text-xs font-medium uppercase tracking-wide text-faint">{label}</p>
89
99
  <p class="{values[size]} {valueColor} {mono ? 'font-mono tabular-nums' : ''}">{value}</p>
90
100
  <div class="mt-1 flex items-center gap-2">
@@ -17,6 +17,7 @@
17
17
  SCROLL_EDGE_START,
18
18
  scrollEdges,
19
19
  } from '../internal/scroll-edges.js';
20
+ import { CARD_SURFACE } from '../internal/layout.js';
20
21
 
21
22
  interface Props {
22
23
  striped?: boolean;
@@ -71,7 +72,7 @@
71
72
  * start cell in either direction and the two agree in left-to-right text.
72
73
  */
73
74
  const HOVER_RING = [
74
- '[&_tbody_tr:hover]:bg-surface-2/60',
75
+ '[&_tbody_tr:hover]:bg-tint',
75
76
  '[&_tbody_tr:hover>td]:shadow-[inset_0_1px_var(--color-brand),inset_0_-1px_var(--color-brand)]',
76
77
  '[&_tbody_tr:hover>td:first-child]:shadow-[inset_1px_1px_var(--color-brand),inset_1px_-1px_var(--color-brand)]',
77
78
  '[&_tbody_tr:hover>td:last-child]:shadow-[inset_-1px_1px_var(--color-brand),inset_-1px_-1px_var(--color-brand)]',
@@ -240,10 +241,15 @@
240
241
 
241
242
  <!-- overflow-hidden on the frame, not only on the scroller: the hovered row's
242
243
  ring is square and the frame is not, and the last row's corners drew past
243
- the frame's rounded ones. -->
244
+ the frame's rounded ones.
245
+
246
+ The frame is a card: the same surface, radius and shadows, so a table and
247
+ the cards above it sit at one depth. It paints its own surface rather than
248
+ showing the page through, which the shadow under it needs to read as a
249
+ raised sheet. -->
244
250
  <div
245
251
  data-testid="table-frame"
246
- class="relative w-full overflow-hidden rounded-xl border border-line {cls}"
252
+ class="relative w-full overflow-hidden {CARD_SURFACE} {cls}"
247
253
  >
248
254
  <!--
249
255
  data-print: the box exists to clip. On paper there is no viewport to clip
@@ -256,7 +262,7 @@
256
262
  onfocusin={reveal}
257
263
  data-print="unclip"
258
264
  data-testid="table-scroll"
259
- class="w-full overflow-x-auto rounded-xl"
265
+ class="w-full overflow-x-auto rounded-card"
260
266
  {...scrollAttrs}
261
267
  >
262
268
  <!--
@@ -274,21 +280,25 @@
274
280
  which is why the lines moved down to the cells they were already drawn
275
281
  between. With border-spacing at zero the result is the same line in the
276
282
  same place.
283
+
284
+ The head row and a hovered row take the brand tint. It is one opaque
285
+ color, so the contrast suite measures the head's labels and a row's text
286
+ on it, and a hovered row is told apart by more than its ring.
277
287
  -->
278
288
  <table
279
289
  style="--cell-truncate: {truncateAt}"
280
290
  class="w-full border-separate border-spacing-0 text-sm text-start
281
291
  {fixed ? 'table-fixed' : ''}
282
292
  {CELL_FIT[cell]} {CELL_NAMED} {COLUMN_ALIGN} {HEAD_CONTROLS}
283
- [&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-surface-2/40
293
+ [&_thead_th]:border-b [&_thead_th]:border-line [&_thead]:bg-tint
284
294
  [&_thead_th]:px-4 [&_thead_th]:py-3 [&_thead_th]:text-xs
285
295
  [&_thead_th]:font-medium [&_thead_th]:text-faint
286
296
  [&_thead_th]:uppercase [&_thead_th]:tracking-wider [&_thead_th]:whitespace-nowrap
287
297
  [&_tbody_td]:px-4 [&_tbody_td]:py-3 [&_tbody_td]:text-fg [&_tbody_td]:align-middle
288
298
  [&_tbody_td]:border-b [&_tbody_td]:border-line [&_tbody_tr:last-child_td]:border-0
289
299
  {striped ? '[&_tbody_tr:nth-child(even)]:bg-surface-2/40' : ''}
290
- [&_tbody_tr:last-child>td:first-child]:rounded-es-[calc(var(--radius-xl)-1px)]
291
- [&_tbody_tr:last-child>td:last-child]:rounded-ee-[calc(var(--radius-xl)-1px)]
300
+ [&_tbody_tr:last-child>td:first-child]:rounded-es-[calc(var(--radius-card)-1px)]
301
+ [&_tbody_tr:last-child>td:last-child]:rounded-ee-[calc(var(--radius-card)-1px)]
292
302
  {hoverable ? HOVER_RING : ''}"
293
303
  >
294
304
  {@render children()}
@@ -306,7 +316,7 @@
306
316
  aria-hidden="true"
307
317
  data-print="hide"
308
318
  data-testid="table-more-before"
309
- class="{SCROLL_EDGE} {SCROLL_EDGE_START} rounded-s-xl"
319
+ class="{SCROLL_EDGE} {SCROLL_EDGE_START} rounded-s-card"
310
320
  ></div>
311
321
  {/if}
312
322
  {#if moreAfter}
@@ -314,7 +324,7 @@
314
324
  aria-hidden="true"
315
325
  data-print="hide"
316
326
  data-testid="table-more-after"
317
- class="{SCROLL_EDGE} {SCROLL_EDGE_END} rounded-e-xl"
327
+ class="{SCROLL_EDGE} {SCROLL_EDGE_END} rounded-e-card"
318
328
  ></div>
319
329
  {/if}
320
330
  </div>
@@ -158,6 +158,8 @@
158
158
  data-testid="tabs-scroll"
159
159
  class="flex gap-0.5 overflow-x-auto border-b border-line [scrollbar-width:none]"
160
160
  >
161
+ <!-- The active indicator is the cyan to violet accent, with solid brand
162
+ under it for wherever gradients are not drawn. -->
161
163
  {#each items as tab, i (tab.id)}
162
164
  <button
163
165
  type="button"
@@ -170,7 +172,7 @@
170
172
  onkeydown={(e) => onTabKeydown(e, i)}
171
173
  class="{TAB}
172
174
  {active === tab.id
173
- ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:rounded-t'
175
+ ? 'text-fg after:absolute after:bottom-0 after:inset-x-0 after:h-0.5 after:bg-brand after:bg-accent after:rounded-t rtl:after:-scale-x-100'
174
176
  : 'text-faint hover:text-fg'}"
175
177
  >
176
178
  {tab.label}
@@ -30,7 +30,7 @@
30
30
  transition:motion.toast|global
31
31
  animate:motion.reorder
32
32
  class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
33
- bg-surface ps-0 pe-3 py-3 shadow-xl"
33
+ bg-surface ps-0 pe-3 py-3 shadow-popover inset-shadow-highlight"
34
34
  >
35
35
  <span class="w-1 self-stretch shrink-0 {styles[statusTone(t.tone)].bar}"></span>
36
36
  <span
@@ -138,7 +138,7 @@
138
138
  aria-hidden={describe ? undefined : 'true'}
139
139
  style="top: {anchor.top}px; left: {anchor.left}px"
140
140
  class="fixed {pos[position]} z-tooltip w-max max-w-[min(16rem,calc(100vw-2rem))]
141
- rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl
141
+ rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-popover inset-shadow-highlight
142
142
  transition {visible
143
143
  ? 'duration-base ease-enter'
144
144
  : 'invisible scale-[0.96] opacity-0 duration-fast ease-exit'}"
@@ -110,7 +110,7 @@
110
110
  use:overlay
111
111
  transition:motion.dialog|global
112
112
  class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
113
- bg-surface border border-line rounded-xl shadow-2xl
113
+ bg-surface bg-card border border-line rounded-card shadow-overlay inset-shadow-highlight
114
114
  transition-transform duration-slow
115
115
  focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
116
116
  style="transform: scale({1 - offset * 0.03}) translateY({offset * 16}px);
package/dist/index.d.ts CHANGED
@@ -113,4 +113,4 @@ export { cn, type ClassValue } from './utils/cn.js';
113
113
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
114
114
  export * as motion from './motion.js';
115
115
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
116
- export declare const VERSION = "0.45.0";
116
+ export declare const VERSION = "0.46.0";
package/dist/index.js CHANGED
@@ -122,4 +122,4 @@ export * as motion from './motion.js';
122
122
  // ── Version ────────────────────────────────────────────────────────────────
123
123
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
124
124
  // this line. The build and the test suite fail when the two disagree.
125
- export const VERSION = '0.45.0';
125
+ export const VERSION = '0.46.0';