@lyeve-labs/ui-kit 0.11.0 → 0.12.1

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/README.md CHANGED
@@ -188,7 +188,7 @@ src/
188
188
  breaking changes bump the **minor** version; additive changes bump the **patch**.
189
189
  Every release is logged in [`CHANGELOG.md`](CHANGELOG.md) and on the docs site.
190
190
 
191
- Maintainers: see [`PUBLISHING.md`](PUBLISHING.md) for the release workflow.
191
+ Maintainers: the release workflow is [`CONTRIBUTING.md`](CONTRIBUTING.md#releases).
192
192
 
193
193
  ## Contributing
194
194
 
@@ -35,7 +35,7 @@
35
35
  aria-controls={panelId}
36
36
  class="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium
37
37
  text-fg transition-colors duration-150 hover:bg-surface-2/50
38
- outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/50
38
+ outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand
39
39
  {open ? 'bg-surface-2/40' : ''}"
40
40
  >
41
41
  <span>{title}</span>
@@ -1,11 +1,22 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
3
4
  import Spinner from './Spinner.svelte';
4
5
 
5
6
  type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
6
7
  type Size = 'sm' | 'md' | 'lg';
7
8
 
8
- interface Props {
9
+ /**
10
+ * Attributes forwarded to whichever element renders. Naming them, rather than
11
+ * accepting an open index signature, means a prop the component does not
12
+ * define fails the build instead of reaching the DOM as a stray attribute.
13
+ */
14
+ type Forwarded = Omit<
15
+ HTMLButtonAttributes & HTMLAnchorAttributes,
16
+ 'type' | 'href' | 'disabled' | 'class' | 'onclick' | 'children'
17
+ >;
18
+
19
+ interface Props extends Forwarded {
9
20
  variant?: Variant;
10
21
  size?: Size;
11
22
  disabled?: boolean;
@@ -16,7 +27,6 @@
16
27
  class?: string;
17
28
  onclick?: (e: MouseEvent) => void;
18
29
  children: Snippet;
19
- [key: string]: unknown;
20
30
  }
21
31
 
22
32
  let {
@@ -37,10 +47,10 @@
37
47
  primary: 'bg-brand text-ink hover:bg-brand-light active:bg-brand shadow-sm shadow-brand/20',
38
48
  violet:
39
49
  'bg-violet text-ink hover:brightness-110 active:brightness-100 shadow-sm shadow-violet/20',
40
- secondary: 'bg-surface-2 text-fg border border-line hover:bg-line',
50
+ secondary: 'bg-surface-2 text-fg border border-line-strong hover:bg-line',
41
51
  danger: 'bg-danger text-ink hover:brightness-110 active:brightness-100',
42
52
  ghost: 'text-muted hover:bg-surface-2 hover:text-fg',
43
- outline: 'border border-line text-fg hover:border-brand hover:text-brand',
53
+ outline: 'border border-line-strong text-fg hover:border-brand hover:text-brand',
44
54
  };
45
55
 
46
56
  const sizes: Record<Size, string> = {
@@ -58,15 +68,37 @@
58
68
 
59
69
  // Reject javascript: and data: URIs - only allow standard schemes and relative URLs.
60
70
  let safeHref = $derived(href && !/^(javascript|data):/i.test(href) ? href : undefined);
71
+
72
+ /**
73
+ * An anchor has no `disabled`. The prop was accepted and then dropped on this
74
+ * branch, so `<Button href="..." disabled>` rendered a link that looked
75
+ * ordinary and navigated on click, and `disabled:opacity-50` never matched
76
+ * because that pseudo-class does not apply to `a`. Dropping the href is what
77
+ * actually takes it out of the tab order and stops activation.
78
+ */
79
+ const inert = $derived(disabled || loading);
61
80
  </script>
62
81
 
63
82
  {#if href}
64
- <a href={safeHref} class={cls} {...rest}>
83
+ <a
84
+ href={inert ? undefined : safeHref}
85
+ class="{cls} {inert ? 'pointer-events-none opacity-50' : ''}"
86
+ aria-disabled={inert ? 'true' : undefined}
87
+ tabindex={inert ? -1 : undefined}
88
+ {...rest}
89
+ >
65
90
  {#if loading}<Spinner size={spinnerSize} />{/if}
66
91
  {@render children()}
67
92
  </a>
68
93
  {:else}
69
- <button {type} disabled={disabled || loading} {onclick} class={cls} {...rest}>
94
+ <button
95
+ {type}
96
+ disabled={disabled || loading}
97
+ aria-busy={loading ? 'true' : undefined}
98
+ {onclick}
99
+ class={cls}
100
+ {...rest}
101
+ >
70
102
  {#if loading}<Spinner size={spinnerSize} />{/if}
71
103
  {@render children()}
72
104
  </button>
@@ -1,7 +1,14 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
2
3
  type Variant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' | 'violet';
3
4
  type Size = 'sm' | 'md' | 'lg';
4
- interface Props {
5
+ /**
6
+ * Attributes forwarded to whichever element renders. Naming them, rather than
7
+ * accepting an open index signature, means a prop the component does not
8
+ * define fails the build instead of reaching the DOM as a stray attribute.
9
+ */
10
+ type Forwarded = Omit<HTMLButtonAttributes & HTMLAnchorAttributes, 'type' | 'href' | 'disabled' | 'class' | 'onclick' | 'children'>;
11
+ interface Props extends Forwarded {
5
12
  variant?: Variant;
6
13
  size?: Size;
7
14
  disabled?: boolean;
@@ -12,7 +19,6 @@ interface Props {
12
19
  class?: string;
13
20
  onclick?: (e: MouseEvent) => void;
14
21
  children: Snippet;
15
- [key: string]: unknown;
16
22
  }
17
23
  declare const Button: import("svelte").Component<Props, {}, "">;
18
24
  type Button = ReturnType<typeof Button>;
@@ -29,6 +29,23 @@
29
29
  children,
30
30
  }: Props = $props();
31
31
 
32
+ /**
33
+ * A clickable Card advertises itself as a button and used to do nothing when
34
+ * you pressed one. It took `role="button"` and `tabindex="0"` from `onclick`
35
+ * alone, so it entered the tab order and then ignored Enter and Space unless
36
+ * the page happened to pass its own `onkeydown`. Native button semantics are
37
+ * the contract here, so the component honours them itself and still forwards
38
+ * whatever the consumer supplied.
39
+ */
40
+ function activate(e: KeyboardEvent) {
41
+ onkeydown?.(e);
42
+ if (!onclick || e.defaultPrevented) return;
43
+ if (e.key !== 'Enter' && e.key !== ' ') return;
44
+ // Space scrolls the page on a non-button element.
45
+ e.preventDefault();
46
+ (e.currentTarget as HTMLElement).click();
47
+ }
48
+
32
49
  const pads: Record<Pad, string> = {
33
50
  none: '',
34
51
  sm: 'p-4',
@@ -47,7 +64,7 @@
47
64
  role={onclick ? 'button' : undefined}
48
65
  tabindex={onclick ? 0 : undefined}
49
66
  {onclick}
50
- {onkeydown}
67
+ onkeydown={onclick ? activate : onkeydown}
51
68
  >
52
69
  {#if header || title}
53
70
  <div class="px-5 py-4 border-b border-line">
@@ -45,13 +45,17 @@
45
45
  {disabled}
46
46
  {checked}
47
47
  onchange={handleChange}
48
- class="peer sr-only"
48
+ class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
49
49
  />
50
+ <!-- The focus ring is stated once, outside the checked branch. It used to
51
+ live only on the unchecked classes, so ticking a box removed the only
52
+ indicator a keyboard user had; in a permissions matrix of a hundred
53
+ boxes that loses your place entirely. A ring rather than a border
54
+ colour, because when checked the border is already brand. -->
50
55
  <span
51
- class="w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center
52
- {checked
53
- ? 'bg-brand border-brand'
54
- : 'bg-surface-2 border-line peer-focus-visible:border-brand'}"
56
+ class="pointer-events-none w-4 h-4 rounded border transition-colors duration-150 flex items-center justify-center
57
+ peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2
58
+ {checked ? 'bg-brand border-brand' : 'bg-surface-2 border-line-strong'}"
55
59
  >
56
60
  {#if checked}
57
61
  <svg width="10" height="8" viewBox="0 0 10 8" fill="none" aria-hidden="true">
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { overlay } from '../internal/overlay.js';
3
4
 
4
5
  type Side = 'left' | 'right';
5
6
  type Size = 'sm' | 'md' | 'lg' | 'xl';
@@ -33,6 +34,9 @@
33
34
  xl: 'w-[480px]',
34
35
  };
35
36
 
37
+ const headingId = $props.id();
38
+ const descriptionId = `${headingId}-description`;
39
+
36
40
  function close() {
37
41
  open = false;
38
42
  onclose?.();
@@ -52,25 +56,32 @@
52
56
  <div class="fixed inset-0 z-50 flex {side === 'right' ? 'justify-end' : 'justify-start'}">
53
57
  <button
54
58
  type="button"
59
+ tabindex="-1"
60
+ aria-hidden="true"
55
61
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
56
- aria-label="Close"
57
62
  onclick={close}
58
63
  ></button>
59
64
 
60
65
  <div
61
- class="relative flex h-full flex-col {widths[size]} bg-surface shadow-2xl
66
+ use:overlay
67
+ class="relative flex h-full max-w-full flex-col {widths[size]} bg-surface shadow-2xl
62
68
  {side === 'right' ? 'border-l' : 'border-r'} border-line
63
69
  {side === 'right'
64
70
  ? 'animate-[drawer-in-right_150ms_ease-out]'
65
71
  : 'animate-[drawer-in-left_150ms_ease-out]'}"
66
72
  role="dialog"
67
73
  aria-modal="true"
74
+ aria-labelledby={title ? headingId : undefined}
75
+ aria-describedby={description ? descriptionId : undefined}
76
+ aria-label={title ? undefined : 'Panel'}
68
77
  >
69
78
  {#if title}
70
79
  <div class="flex shrink-0 items-start justify-between gap-4 border-b border-line px-5 py-4">
71
80
  <div>
72
- <h2 class="font-semibold text-fg">{title}</h2>
73
- {#if description}<p class="mt-0.5 text-sm text-muted">{description}</p>{/if}
81
+ <h2 id={headingId} class="font-semibold text-fg">{title}</h2>
82
+ {#if description}
83
+ <p id={descriptionId} class="mt-0.5 text-sm text-muted">{description}</p>
84
+ {/if}
74
85
  </div>
75
86
  <button
76
87
  type="button"
@@ -8,8 +8,19 @@
8
8
  controlBorder,
9
9
  describedBy,
10
10
  } from '../internal/field.js';
11
+ import type { HTMLInputAttributes } from 'svelte/elements';
11
12
 
12
- interface Props {
13
+ /**
14
+ * Attributes forwarded to the input. Naming them, rather than accepting an
15
+ * open index signature, means a prop the component does not define fails the
16
+ * build instead of reaching the DOM as a stray attribute.
17
+ */
18
+ type Forwarded = Omit<
19
+ HTMLInputAttributes,
20
+ 'value' | 'type' | 'placeholder' | 'disabled' | 'required' | 'id' | 'name' | 'class'
21
+ >;
22
+
23
+ interface Props extends Forwarded {
13
24
  value?: string;
14
25
  label?: string;
15
26
  type?: string;
@@ -21,7 +32,6 @@
21
32
  id?: string;
22
33
  name?: string;
23
34
  class?: string;
24
- [key: string]: unknown;
25
35
  }
26
36
 
27
37
  let {
@@ -1,4 +1,11 @@
1
- interface Props {
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ /**
3
+ * Attributes forwarded to the input. Naming them, rather than accepting an
4
+ * open index signature, means a prop the component does not define fails the
5
+ * build instead of reaching the DOM as a stray attribute.
6
+ */
7
+ type Forwarded = Omit<HTMLInputAttributes, 'value' | 'type' | 'placeholder' | 'disabled' | 'required' | 'id' | 'name' | 'class'>;
8
+ interface Props extends Forwarded {
2
9
  value?: string;
3
10
  label?: string;
4
11
  type?: string;
@@ -10,7 +17,6 @@ interface Props {
10
17
  id?: string;
11
18
  name?: string;
12
19
  class?: string;
13
- [key: string]: unknown;
14
20
  }
15
21
  declare const Input: import("svelte").Component<Props, {}, "value">;
16
22
  type Input = ReturnType<typeof Input>;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { overlay } from '../internal/overlay.js';
3
4
 
4
5
  type Size = 'sm' | 'md' | 'lg';
5
6
 
@@ -29,6 +30,11 @@
29
30
  lg: 'max-w-2xl',
30
31
  };
31
32
 
33
+ // aria-labelledby needs an id that is unique per instance, because two modals
34
+ // can be mounted at once while one animates out.
35
+ const headingId = $props.id();
36
+ const descriptionId = `${headingId}-description`;
37
+
32
38
  function close() {
33
39
  open = false;
34
40
  onclose?.();
@@ -48,27 +54,38 @@
48
54
  <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
49
55
  <button
50
56
  type="button"
57
+ tabindex="-1"
58
+ aria-hidden="true"
51
59
  class="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
52
- aria-label="Close"
53
60
  onclick={close}
54
61
  ></button>
55
62
 
56
63
  <div
57
- class="relative w-full {widths[size]} rounded-xl border border-line bg-surface shadow-2xl
64
+ use:overlay
65
+ class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
66
+ overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
58
67
  animate-[modal-in_120ms_ease-out]"
59
68
  role="dialog"
60
69
  aria-modal="true"
70
+ aria-labelledby={title ? headingId : undefined}
71
+ aria-describedby={description ? descriptionId : undefined}
72
+ aria-label={title ? undefined : 'Dialog'}
61
73
  >
62
74
  {#if title}
63
- <div class="flex items-start justify-between gap-4 px-5 py-4 border-b border-line">
75
+ <div
76
+ class="flex shrink-0 items-start justify-between gap-4 px-5 py-4 border-b border-line"
77
+ >
64
78
  <div>
65
- <h2 class="font-semibold text-fg">{title}</h2>
66
- {#if description}<p class="text-sm text-muted mt-0.5">{description}</p>{/if}
79
+ <h2 id={headingId} class="font-semibold text-fg">{title}</h2>
80
+ {#if description}
81
+ <p id={descriptionId} class="text-sm text-muted mt-0.5">{description}</p>
82
+ {/if}
67
83
  </div>
68
84
  <button
69
85
  type="button"
70
86
  onclick={close}
71
- class="shrink-0 text-faint transition-colors duration-150 hover:text-fg"
87
+ class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg
88
+ outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
72
89
  aria-label="Close"
73
90
  >
74
91
  <svg
@@ -85,13 +102,13 @@
85
102
  </div>
86
103
  {/if}
87
104
 
88
- <div class="px-5 py-4">
105
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
89
106
  {@render children()}
90
107
  </div>
91
108
 
92
109
  {#if footer}
93
110
  <div
94
- class="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
111
+ class="flex shrink-0 items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface-2/40"
95
112
  >
96
113
  {@render footer()}
97
114
  </div>
@@ -197,7 +197,7 @@
197
197
  onclick={() => toggle(opt)}
198
198
  class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-fg transition-colors duration-150
199
199
  hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed
200
- outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/50"
200
+ outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
201
201
  >
202
202
  <span
203
203
  class="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors duration-150
@@ -37,20 +37,25 @@
37
37
  'inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors duration-150';
38
38
  </script>
39
39
 
40
- {#if totalPages > 1}
41
- <div class="flex items-center gap-3 {cls}">
42
- <span class="text-xs text-faint shrink-0">
43
- {total === 0 ? 'No results' : `${from}–${to} of ${total}`}
44
- </span>
40
+ <!-- The summary renders whenever there is a count to state, and the page
41
+ buttons only when there is more than one page. The whole component used to
42
+ be behind `totalPages > 1`, and an empty list has one page, so the 'No
43
+ results' line below could never appear and a single page of results showed
44
+ no count at all. -->
45
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-2 {cls}">
46
+ <span class="text-xs text-faint shrink-0">
47
+ {safeTotal === 0 ? 'No results' : `${from} to ${to} of ${safeTotal}`}
48
+ </span>
45
49
 
46
- <div class="flex items-center gap-0.5 ml-auto">
50
+ {#if totalPages > 1}
51
+ <div class="flex flex-wrap items-center gap-0.5 ml-auto">
47
52
  <button
48
53
  type="button"
49
54
  disabled={safePage <= 1}
50
55
  onclick={() => onchange(safePage - 1)}
51
56
  aria-label="Previous page"
52
57
  class="{btnBase} text-muted hover:text-fg hover:bg-surface-2
53
- disabled:opacity-30 disabled:cursor-not-allowed"
58
+ disabled:opacity-30 disabled:cursor-not-allowed"
54
59
  >
55
60
  <svg
56
61
  width="14"
@@ -76,7 +81,7 @@
76
81
  onclick={() => onchange(n as number)}
77
82
  aria-current={safePage === n ? 'page' : undefined}
78
83
  class="{btnBase}
79
- {safePage === n
84
+ {safePage === n
80
85
  ? 'bg-brand text-ink'
81
86
  : 'text-muted hover:text-fg hover:bg-surface-2'}"
82
87
  >
@@ -108,5 +113,5 @@
108
113
  </svg>
109
114
  </button>
110
115
  </div>
111
- </div>
112
- {/if}
116
+ {/if}
117
+ </div>
@@ -46,13 +46,16 @@
46
46
  {disabled}
47
47
  checked={group === value}
48
48
  onchange={handleChange}
49
- class="peer sr-only"
49
+ class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
50
50
  />
51
+ <!-- The focus ring is stated once, outside the selected branch. It used to
52
+ sit only on the unselected classes, so choosing an option removed the
53
+ only indicator a keyboard user had. A ring rather than a border colour,
54
+ because when selected the border is already brand. -->
51
55
  <span
52
- class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
53
- {group === value
54
- ? 'border-brand bg-surface-2'
55
- : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
56
+ class="pointer-events-none flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
57
+ peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-brand peer-focus-visible:outline-offset-2
58
+ {group === value ? 'border-brand bg-surface-2' : 'border-line-strong bg-surface-2'}"
56
59
  >
57
60
  {#if group === value}
58
61
  <span class="h-2 w-2 rounded-full bg-brand"></span>
@@ -68,10 +68,10 @@
68
68
  disabled={disabled || opt.disabled}
69
69
  checked={value === opt.value}
70
70
  onchange={() => select(opt)}
71
- class="peer sr-only"
71
+ class="peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
72
72
  />
73
73
  <span
74
- class="flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
74
+ class="pointer-events-none flex h-4 w-4 items-center justify-center rounded-full border-2 transition-colors duration-150
75
75
  {value === opt.value
76
76
  ? 'border-brand bg-surface-2'
77
77
  : 'border-line bg-surface-2 peer-focus-visible:border-brand'}"
@@ -49,11 +49,6 @@
49
49
  } = $props();
50
50
 
51
51
  const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
52
-
53
- function handleInput(e: TAE) {
54
- value = e.currentTarget.value;
55
- oninput?.(e);
56
- }
57
52
  </script>
58
53
 
59
54
  <div class="{FIELD_WRAP} {cls}">
@@ -75,9 +70,10 @@
75
70
  aria-invalid={error ? 'true' : undefined}
76
71
  aria-describedby={describedBy(fieldId, error, hint)}
77
72
  class="{CONTROL_MULTILINE} {controlBorder(!!error)} {resize ? 'resize-y' : 'resize-none'}"
78
- oninput={handleInput}
79
- {onblur}>{value}</textarea
80
- >
73
+ bind:value
74
+ {oninput}
75
+ {onblur}
76
+ ></textarea>
81
77
 
82
78
  {#if error}
83
79
  <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
@@ -11,14 +11,21 @@
11
11
  };
12
12
  </script>
13
13
 
14
+ <!-- The live region is this container, which is mounted for the life of the
15
+ app and empty most of the time. Marking each toast `role="status"` instead
16
+ announced nothing: assistive technology watches an existing region for
17
+ changes, and a region that arrives already holding its text is not a
18
+ change. -->
14
19
  <div
15
20
  class="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
21
+ role="status"
22
+ aria-live="polite"
23
+ aria-atomic="false"
16
24
  >
17
25
  {#each toast.items as t (t.id)}
18
26
  <div
19
27
  class="pointer-events-auto flex items-start gap-3 overflow-hidden rounded-lg border border-line
20
28
  bg-surface pl-0 pr-3 py-3 shadow-xl animate-[toast-in_140ms_ease-out]"
21
- role="status"
22
29
  >
23
30
  <span class="w-1 self-stretch shrink-0 {styles[statusTone(t.tone)].bar}"></span>
24
31
  <span
@@ -43,7 +50,7 @@
43
50
  <p class="flex-1 text-sm text-fg leading-snug">{t.message}</p>
44
51
  <button
45
52
  type="button"
46
- class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/50"
53
+ class="shrink-0 rounded text-faint transition-colors duration-150 hover:text-fg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
47
54
  aria-label="Dismiss"
48
55
  onclick={() => toast.dismiss(t.id)}
49
56
  >
@@ -6,6 +6,7 @@
6
6
  hint,
7
7
  disabled = false,
8
8
  size = 'md',
9
+ id,
9
10
  class: cls = '',
10
11
  onchange,
11
12
  }: {
@@ -14,6 +15,8 @@
14
15
  hint?: string;
15
16
  disabled?: boolean;
16
17
  size?: 'sm' | 'md';
18
+ /** Lets a `<Label for=...>` outside the component target the switch. */
19
+ id?: string;
17
20
  class?: string;
18
21
  onchange?: (checked: boolean) => void;
19
22
  } = $props();
@@ -47,13 +50,14 @@
47
50
  >
48
51
  <button
49
52
  type="button"
53
+ {id}
50
54
  role="switch"
51
55
  aria-checked={checked}
52
56
  aria-label={label ?? 'Toggle'}
53
57
  {disabled}
54
58
  onclick={handleClick}
55
59
  class="relative shrink-0 rounded-full transition-colors duration-150 outline-none
56
- focus-visible:ring-2 focus-visible:ring-brand/50 focus-visible:ring-offset-1
60
+ focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
57
61
  focus-visible:ring-offset-ink mt-0.5
58
62
  {track[size]}
59
63
  {checked ? 'bg-brand' : 'bg-surface-2'}"
@@ -4,6 +4,8 @@ type $$ComponentProps = {
4
4
  hint?: string;
5
5
  disabled?: boolean;
6
6
  size?: 'sm' | 'md';
7
+ /** Lets a `<Label for=...>` outside the component target the switch. */
8
+ id?: string;
7
9
  class?: string;
8
10
  onchange?: (checked: boolean) => void;
9
11
  };
@@ -13,6 +13,9 @@
13
13
  let { text, position = 'top', class: cls = '', children }: Props = $props();
14
14
 
15
15
  let visible = $state(false);
16
+ let wrapper = $state<HTMLElement>();
17
+
18
+ const id = $props.id();
16
19
 
17
20
  const pos: Record<Position, string> = {
18
21
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
@@ -20,25 +23,58 @@
20
23
  left: 'right-full top-1/2 -translate-y-1/2 mr-2',
21
24
  right: 'left-full top-1/2 -translate-y-1/2 ml-2',
22
25
  };
26
+
27
+ /**
28
+ * `role="tooltip"` on its own is inert: assistive technology reads a tooltip
29
+ * only because the element it describes points at it. The trigger arrives as
30
+ * a snippet, so the component cannot put the attribute on it at compile time
31
+ * and wires it to the first focusable descendant instead. Without this the
32
+ * text was visible to a sighted mouse user and to nobody else.
33
+ */
34
+ $effect(() => {
35
+ const trigger = wrapper?.querySelector<HTMLElement>(
36
+ 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
37
+ );
38
+ if (!trigger) return;
39
+ trigger.setAttribute('aria-describedby', id);
40
+ return () => trigger.removeAttribute('aria-describedby');
41
+ });
42
+
43
+ function onkeydown(e: KeyboardEvent) {
44
+ // SC 1.4.13 requires content shown on hover or focus to be dismissible
45
+ // without moving the pointer or the focus.
46
+ if (e.key === 'Escape' && visible) {
47
+ e.stopPropagation();
48
+ visible = false;
49
+ }
50
+ }
23
51
  </script>
24
52
 
53
+ <!-- focusin and focusout, not focus and blur. focus and blur do not bubble, so
54
+ bound to this wrapper they never fired for the trigger inside it and the
55
+ tooltip was unreachable by keyboard. -->
25
56
  <!-- svelte-ignore a11y_no_static_element_interactions -->
26
57
  <span
58
+ bind:this={wrapper}
27
59
  class="relative inline-flex {cls}"
28
60
  onmouseenter={() => (visible = true)}
29
61
  onmouseleave={() => (visible = false)}
30
- onfocus={() => (visible = true)}
31
- onblur={() => (visible = false)}
62
+ onfocusin={() => (visible = true)}
63
+ onfocusout={() => (visible = false)}
64
+ {onkeydown}
32
65
  >
33
66
  {@render children()}
34
- {#if visible}
35
- <span
36
- role="tooltip"
37
- class="pointer-events-none absolute {pos[position]} z-50
38
- whitespace-nowrap rounded-lg border border-line bg-surface-2
39
- px-2.5 py-1.5 text-xs text-fg shadow-xl"
40
- >
41
- {text}
42
- </span>
43
- {/if}
67
+ <!-- Always rendered, hidden by the `hidden` attribute rather than removed:
68
+ aria-describedby resolves the text of a hidden element, so the trigger
69
+ keeps a stable target. Left hoverable rather than pointer-events-none, so
70
+ moving onto it does not dismiss it, which SC 1.4.13 also requires. -->
71
+ <span
72
+ {id}
73
+ role="tooltip"
74
+ hidden={!visible}
75
+ class="absolute {pos[position]} z-50 max-w-[min(16rem,calc(100vw-2rem))]
76
+ rounded-lg border border-line bg-surface-2 px-2.5 py-1.5 text-xs text-fg shadow-xl"
77
+ >
78
+ {text}
79
+ </span>
44
80
  </span>
@@ -9,15 +9,14 @@
9
9
  * Not meant to be used directly - use openDialog() from dialog-manager.
10
10
  */
11
11
  import { X } from '@lucide/svelte';
12
- import { onMount, tick } from 'svelte';
12
+ import { onMount } from 'svelte';
13
13
  import type { Snippet } from 'svelte';
14
14
  import type { DialogEntry } from './types.js';
15
15
  import { sizeClass } from './types.js';
16
+ import { overlay } from '../../internal/overlay.js';
16
17
  import {
17
18
  closeDialog,
18
19
  dismissDialog,
19
- _lockBodyScroll,
20
- _unlockBodyScroll,
21
20
  } from './dialog-manager.svelte';
22
21
  import ConfirmDialog from './ConfirmDialog.svelte';
23
22
 
@@ -45,45 +44,18 @@
45
44
  // ──────────────────────────────────────────────────────
46
45
 
47
46
  let dialogEl = $state<HTMLDivElement>();
48
- let previousFocus = $state<HTMLElement | null>(null);
49
- let focusableSelector =
50
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
51
47
 
52
48
  // ──────────────────────────────────────────────────────
53
49
  // Lifecycle
54
50
  // ──────────────────────────────────────────────────────
55
51
 
52
+ // Focus entry, the Tab trap, the body scroll lock and focus restore all come
53
+ // from `use:overlay` on the panel below, which is the same implementation
54
+ // Modal and Drawer use. This is only the enter animation.
56
55
  onMount(() => {
57
- // Save previously focused element for restore on unmount
58
- const el = document.activeElement;
59
- previousFocus = el instanceof HTMLElement ? el : null;
60
-
61
- // Trigger enter animation on next frame
62
56
  requestAnimationFrame(() => {
63
57
  visible = true;
64
58
  });
65
-
66
- // Focus trap: focus first focusable element, or the dialog itself
67
- tick().then(() => {
68
- if (dialogEl) {
69
- const focusable = dialogEl.querySelectorAll(focusableSelector);
70
- if (focusable.length > 0) {
71
- (focusable[0] as HTMLElement).focus();
72
- } else {
73
- dialogEl.focus();
74
- }
75
- }
76
- });
77
-
78
- // Lock body scroll (counter-based: only unlock when no dialogs remain)
79
- _lockBodyScroll();
80
- const unlock = () => _unlockBodyScroll();
81
-
82
- return () => {
83
- unlock();
84
- // Restore focus
85
- previousFocus?.focus?.();
86
- };
87
59
  });
88
60
 
89
61
  // ──────────────────────────────────────────────────────
@@ -104,30 +76,6 @@
104
76
  handleDismiss();
105
77
  }
106
78
  }
107
-
108
- // Focus trap
109
- if (e.key === 'Tab' && dialogEl) {
110
- const focusable = dialogEl.querySelectorAll(focusableSelector);
111
- if (focusable.length === 0) {
112
- e.preventDefault();
113
- return;
114
- }
115
-
116
- const first = focusable[0] as HTMLElement;
117
- const last = focusable[focusable.length - 1] as HTMLElement;
118
-
119
- if (e.shiftKey) {
120
- if (document.activeElement === first) {
121
- e.preventDefault();
122
- last.focus();
123
- }
124
- } else {
125
- if (document.activeElement === last) {
126
- e.preventDefault();
127
- first.focus();
128
- }
129
- }
130
- }
131
79
  }
132
80
 
133
81
  async function handleDismiss(): Promise<void> {
@@ -145,7 +93,15 @@
145
93
  </script>
146
94
 
147
95
  <!-- svelte-ignore a11y_no_static_element_interactions -->
148
- <div class="fixed inset-0 z-[{zIndex}] flex items-center justify-center" role="presentation">
96
+ <!-- z-index is an inline style, not a `z-[...]` class. Tailwind scans source text
97
+ for complete class names, so a class built from a runtime value matches no
98
+ candidate and no rule is ever generated: every stacked dialog rendered at
99
+ `z-index: auto` and the stacking order came down to DOM order. -->
100
+ <div
101
+ class="fixed inset-0 flex items-center justify-center"
102
+ style="z-index: {zIndex}"
103
+ role="presentation"
104
+ >
149
105
  <!-- Backdrop -->
150
106
  <!-- svelte-ignore a11y_no_static_element_interactions -->
151
107
  <div
@@ -163,6 +119,7 @@
163
119
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
164
120
  <div
165
121
  bind:this={dialogEl}
122
+ use:overlay
166
123
  class="relative w-full {sizeClass(entry.options.size ?? 'md')} mx-4
167
124
  bg-surface border border-line rounded-xl shadow-2xl
168
125
  transition-all duration-200 ease-out
@@ -15,8 +15,8 @@
15
15
  * if (ok) { /* ... *\/ }
16
16
  */
17
17
  import type { DialogOptions, DialogEntry } from './types.js';
18
- export declare function _lockBodyScroll(): void;
19
- export declare function _unlockBodyScroll(): void;
18
+ import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
19
+ export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
20
20
  /** Readonly snapshot for components */
21
21
  export declare function getDialogStack(): readonly DialogEntry<any>[];
22
22
  /**
@@ -14,6 +14,7 @@
14
14
  * const ok = await confirm('Delete this item?', 'This action cannot be undone.');
15
15
  * if (ok) { /* ... *\/ }
16
16
  */
17
+ import { lockBodyScroll, unlockBodyScroll } from '../../internal/overlay.js';
17
18
  // ──────────────────────────────────────────────────────────
18
19
  // Module-level reactive state
19
20
  // ──────────────────────────────────────────────────────────
@@ -23,22 +24,13 @@ let stack = $state([]);
23
24
  // ──────────────────────────────────────────────────────────
24
25
  // Body scroll lock (counter-based - handles stacked dialogs)
25
26
  // ──────────────────────────────────────────────────────────
26
- let bodyLockCount = 0;
27
- export function _lockBodyScroll() {
28
- bodyLockCount++;
29
- if (typeof document !== 'undefined') {
30
- document.body.style.overflow = 'hidden';
31
- }
32
- }
33
- export function _unlockBodyScroll() {
34
- bodyLockCount--;
35
- if (bodyLockCount <= 0) {
36
- bodyLockCount = 0;
37
- if (typeof document !== 'undefined') {
38
- document.body.style.overflow = '';
39
- }
40
- }
41
- }
27
+ /*
28
+ * The count lives in internal/overlay.ts, which is also what Modal and Drawer
29
+ * lock through. Two counters meant a Dialog opened over a Drawer restored
30
+ * scrolling to the page as soon as either one closed, because each believed it
31
+ * held the only lock.
32
+ */
33
+ export { lockBodyScroll as _lockBodyScroll, unlockBodyScroll as _unlockBodyScroll };
42
34
  /** Readonly snapshot for components */
43
35
  export function getDialogStack() {
44
36
  return stack;
@@ -132,10 +124,9 @@ export function dismissDialog(id) {
132
124
  export function dismissAllDialogs() {
133
125
  const copy = [...stack];
134
126
  stack = [];
135
- bodyLockCount = 0;
136
- if (typeof document !== 'undefined') {
137
- document.body.style.overflow = '';
138
- }
127
+ // The manager never locks. Each overlay takes one lock on mount and releases
128
+ // it on unmount, so clearing the stack unmounts the dialogs and the count
129
+ // unwinds itself. Zeroing it here released locks a Modal or Drawer still held.
139
130
  for (const entry of copy) {
140
131
  entry.reject(new DOMException('All dialogs dismissed', 'AbortError'));
141
132
  }
@@ -150,7 +141,16 @@ export function confirm(title, message) {
150
141
  const id = `confirm-${++idCounter}`;
151
142
  const promise = openDialog({ id, size: 'sm', title });
152
143
  setDialogMeta(id, { confirmTitle: title, confirmMessage: message ?? '' });
153
- return promise;
144
+ // Cancelling a dialog dismisses it, and dismissal rejects. Callers write
145
+ // `if (await confirm(...))`, so a rejection on Cancel is an unhandled
146
+ // rejection on the ordinary path rather than an error anyone meant to
147
+ // handle. Cancel is an answer, not a failure: it resolves false, which is
148
+ // what this function has always documented.
149
+ return promise.catch((err) => {
150
+ if (err instanceof DOMException && err.name === 'AbortError')
151
+ return false;
152
+ throw err;
153
+ });
154
154
  }
155
155
  /**
156
156
  * Set metadata on a dialog entry after it's opened.
package/dist/index.d.ts CHANGED
@@ -60,4 +60,4 @@ export { openDialog, closeDialog, dismissDialog, dismissAllDialogs, confirm, set
60
60
  export type { DialogOptions, DialogEntry, DialogSize } from './components/dialog/types.js';
61
61
  export { cn, type ClassValue } from './utils/cn.js';
62
62
  export { getTheme, setTheme, toggleTheme, themeBootScript, type Theme } from './utils/theme.js';
63
- export declare const VERSION = "0.11.0";
63
+ export declare const VERSION = "0.12.1";
package/dist/index.js CHANGED
@@ -71,4 +71,4 @@ export { getTheme, setTheme, toggleTheme, themeBootScript } from './utils/theme.
71
71
  // ── Version ────────────────────────────────────────────────────────────────
72
72
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
73
73
  // this line; the build and the test suite fail when the two disagree.
74
- export const VERSION = '0.11.0';
74
+ export const VERSION = '0.12.1';
@@ -41,7 +41,10 @@ export const CONTROL_MULTILINE = 'w-full min-h-control rounded-lg bg-surface-2 b
41
41
  * `border-danger/70`.
42
42
  */
43
43
  export function controlBorder(error) {
44
- return error ? 'border-danger focus:border-danger' : 'border-line focus:border-brand';
44
+ // line-strong, not line: at 1.25:1 against the page the resting border was
45
+ // the only thing marking the control and it failed SC 1.4.11. line stays the
46
+ // divider colour, where there is no control to identify.
47
+ return error ? 'border-danger focus:border-danger' : 'border-line-strong focus:border-brand';
45
48
  }
46
49
  /**
47
50
  * Wires a control to whichever of its hint or error is on screen.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The behaviour every modal surface owes a keyboard and screen reader user.
3
+ *
4
+ * Dialog carried a correct implementation and Modal and Drawer carried none:
5
+ * both declared `aria-modal="true"` while leaving focus behind them in the
6
+ * page, so a screen reader user was told a modal had opened and then went on
7
+ * reading the document underneath it, and a keyboard user tabbed straight out
8
+ * of the panel with no way back. The behaviour lives here now so a fourth
9
+ * overlay cannot ship without it.
10
+ *
11
+ * Not exported from the package entry point - this is an implementation detail.
12
+ */
13
+ export declare function lockBodyScroll(): void;
14
+ export declare function unlockBodyScroll(): void;
15
+ /**
16
+ * Svelte action for the panel element of a modal overlay.
17
+ *
18
+ * <div use:overlay role="dialog" aria-modal="true">
19
+ *
20
+ * Moves focus in on mount, keeps Tab inside the panel, locks the page behind
21
+ * it, and returns focus to whatever opened it on unmount.
22
+ */
23
+ export declare function overlay(node: HTMLElement): {
24
+ destroy(): void;
25
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The behaviour every modal surface owes a keyboard and screen reader user.
3
+ *
4
+ * Dialog carried a correct implementation and Modal and Drawer carried none:
5
+ * both declared `aria-modal="true"` while leaving focus behind them in the
6
+ * page, so a screen reader user was told a modal had opened and then went on
7
+ * reading the document underneath it, and a keyboard user tabbed straight out
8
+ * of the panel with no way back. The behaviour lives here now so a fourth
9
+ * overlay cannot ship without it.
10
+ *
11
+ * Not exported from the package entry point - this is an implementation detail.
12
+ */
13
+ /**
14
+ * Elements that can hold focus. `[tabindex="-1"]` is excluded because it is
15
+ * programmatically focusable but not part of the tab sequence, which is what
16
+ * the trap is wrapping.
17
+ */
18
+ const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
19
+ /**
20
+ * Counted rather than boolean: a dialog opened from inside a drawer must not
21
+ * restore scrolling when only the inner one closes.
22
+ */
23
+ let bodyLockCount = 0;
24
+ export function lockBodyScroll() {
25
+ bodyLockCount++;
26
+ if (typeof document !== 'undefined') {
27
+ document.body.style.overflow = 'hidden';
28
+ }
29
+ }
30
+ export function unlockBodyScroll() {
31
+ bodyLockCount--;
32
+ if (bodyLockCount <= 0) {
33
+ bodyLockCount = 0;
34
+ if (typeof document !== 'undefined') {
35
+ document.body.style.overflow = '';
36
+ }
37
+ }
38
+ }
39
+ function focusable(node) {
40
+ return [...node.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement);
41
+ }
42
+ /**
43
+ * Svelte action for the panel element of a modal overlay.
44
+ *
45
+ * <div use:overlay role="dialog" aria-modal="true">
46
+ *
47
+ * Moves focus in on mount, keeps Tab inside the panel, locks the page behind
48
+ * it, and returns focus to whatever opened it on unmount.
49
+ */
50
+ export function overlay(node) {
51
+ const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
52
+ lockBodyScroll();
53
+ // A panel with nothing focusable still has to receive focus, or the screen
54
+ // reader stays on the element behind the overlay and reads the wrong thing.
55
+ const first = focusable(node)[0];
56
+ if (first) {
57
+ first.focus();
58
+ }
59
+ else {
60
+ node.tabIndex = -1;
61
+ node.focus();
62
+ }
63
+ function onkeydown(e) {
64
+ if (e.key !== 'Tab')
65
+ return;
66
+ const items = focusable(node);
67
+ if (items.length === 0) {
68
+ e.preventDefault();
69
+ return;
70
+ }
71
+ const head = items[0];
72
+ const tail = items[items.length - 1];
73
+ if (e.shiftKey && document.activeElement === head) {
74
+ e.preventDefault();
75
+ tail.focus();
76
+ }
77
+ else if (!e.shiftKey && document.activeElement === tail) {
78
+ e.preventDefault();
79
+ head.focus();
80
+ }
81
+ }
82
+ node.addEventListener('keydown', onkeydown);
83
+ return {
84
+ destroy() {
85
+ node.removeEventListener('keydown', onkeydown);
86
+ unlockBodyScroll();
87
+ // The opener can be gone by now, for instance a row action whose row the
88
+ // dialog just deleted, so this is deliberately best effort.
89
+ previous?.focus?.();
90
+ },
91
+ };
92
+ }
@@ -36,6 +36,12 @@
36
36
  --color-surface: #101a2b;
37
37
  --color-surface-2: #162233;
38
38
  --color-line: #1e2d40;
39
+ /* line reads 1.25:1 against surface. That is fine for a divider, which
40
+ * carries no information, and fails SC 1.4.11 for anything whose boundary
41
+ * is the only thing identifying it as a control. Inputs, selects,
42
+ * checkboxes, radios and toggles use line-strong, which clears 3:1 on
43
+ * every surface in the palette. */
44
+ --color-line-strong: #616d80;
39
45
  --color-fg: #e6e8ee;
40
46
  --color-muted: #9aa1ad;
41
47
  /* #525866 failed WCAG AA against surface/ink (~2.5:1); every caption and
@@ -46,13 +52,24 @@
46
52
  --color-brand: #00d4ff;
47
53
  --color-brand-light: #5fd9f4;
48
54
  --color-brand-deep: #0099c4;
49
- --color-violet: #7c6aef;
55
+ /* #7c6aef reads 4.29:1 on surface and 3.83:1 inside its own bg-violet/10
56
+ * badge, both under the 4.5:1 floor. #9587f2 clears every surface the kit
57
+ * paints it on and the badge as well. */
58
+ --color-violet: #9587f2;
50
59
 
51
60
  /* #15803d and #b45309 sit at ~3.7:1 on ink, below the 4.5:1 AA floor
52
61
  * for the text-size status labels that use them. */
53
62
  --color-success: #22c55e;
54
63
  --color-warn: #f59e0b;
55
- --color-danger: #b91c1c;
64
+ /* #b91c1c reads 2.85:1 on ink and 2.61:1 inside bg-danger/10, so every
65
+ * form error message and every validation hint failed AA. A dark red
66
+ * cannot work as a foreground on a dark surface.
67
+ *
68
+ * One token serves both roles because it inverts with `ink`. As text on a
69
+ * dark surface #f87171 reads 6.30:1; as the fill behind a `text-ink` label
70
+ * on a filled destructive button it reads 6.67:1. The light palette does
71
+ * the same in reverse with a dark red and a near-white ink. */
72
+ --color-danger: #f87171;
56
73
 
57
74
  /* ── Radius ─────────────────────────────────────────────────── */
58
75
  --radius-xs: 4px;
@@ -125,20 +142,30 @@ html[data-theme='light'] {
125
142
  --color-surface: #ffffff;
126
143
  --color-surface-2: #e6e8ee;
127
144
  --color-line: #d6d9e0;
145
+ --color-line-strong: #767d8e;
128
146
  --color-fg: #111827;
129
147
  --color-muted: #525866;
130
- /* #9aa1ad on white is ~3.1:1, below the 4.5:1 AA floor; #6b7280 sits
131
- * at ~4.8:1 while staying visually tertiary. */
132
- --color-faint: #6b7280;
133
-
134
- --color-brand: #007ea6;
135
- --color-brand-light: #00b4dd;
136
- --color-brand-deep: #006a8a;
137
- --color-violet: #6b5cdf;
138
-
139
- --color-success: #15803d;
140
- --color-warn: #b45309;
141
- --color-danger: #b91c1c;
148
+ /* Every value below is the lightest colour on its own hue that clears
149
+ * 4.5:1 against the worst background the kit actually paints it on. That
150
+ * worst case is rarely the bare canvas: status text and brand chips sit
151
+ * inside a tint of their own token (bg-warn/10 text-warn), and the tint
152
+ * drags the background toward the text. Values chosen against white alone
153
+ * fail inside the tint and on surface-2. */
154
+ --color-faint: #616774;
155
+
156
+ --color-brand: #005a77;
157
+ /* brand-light is the hover step for brand links and for the filled brand
158
+ * button. In a light theme emphasis reads as deeper, not lighter, so it
159
+ * sits below brand here and brand-deep below that. The names describe
160
+ * their place in the default dark ramp. #00b4dd put near-white text at
161
+ * 2.45:1 on the primary button's hover state. */
162
+ --color-brand-light: #004f69;
163
+ --color-brand-deep: #00455c;
164
+ --color-violet: #5544da;
165
+
166
+ --color-success: #126c34;
167
+ --color-warn: #904207;
168
+ --color-danger: #ab1a1a;
142
169
  }
143
170
 
144
171
  @layer base {
@@ -163,8 +190,14 @@ html[data-theme='light'] {
163
190
  background-color: color-mix(in srgb, var(--color-brand) 30%, transparent);
164
191
  }
165
192
 
193
+ /*
194
+ * SC 1.4.11 puts a 3:1 floor on the focus indicator. Mixing brand down to
195
+ * 60% alpha left it at 2.39:1 on the light palette, so the one affordance a
196
+ * keyboard user has for locating themselves was the element that failed.
197
+ * Solid brand reads 9.85:1 on dark and 7.70:1 on light.
198
+ */
166
199
  :focus-visible {
167
- outline: 2px solid color-mix(in srgb, var(--color-brand) 60%, transparent);
200
+ outline: 2px solid var(--color-brand);
168
201
  outline-offset: 2px;
169
202
  }
170
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",
@@ -36,6 +36,12 @@
36
36
  --color-surface: #101a2b;
37
37
  --color-surface-2: #162233;
38
38
  --color-line: #1e2d40;
39
+ /* line reads 1.25:1 against surface. That is fine for a divider, which
40
+ * carries no information, and fails SC 1.4.11 for anything whose boundary
41
+ * is the only thing identifying it as a control. Inputs, selects,
42
+ * checkboxes, radios and toggles use line-strong, which clears 3:1 on
43
+ * every surface in the palette. */
44
+ --color-line-strong: #616d80;
39
45
  --color-fg: #e6e8ee;
40
46
  --color-muted: #9aa1ad;
41
47
  /* #525866 failed WCAG AA against surface/ink (~2.5:1); every caption and
@@ -46,13 +52,24 @@
46
52
  --color-brand: #00d4ff;
47
53
  --color-brand-light: #5fd9f4;
48
54
  --color-brand-deep: #0099c4;
49
- --color-violet: #7c6aef;
55
+ /* #7c6aef reads 4.29:1 on surface and 3.83:1 inside its own bg-violet/10
56
+ * badge, both under the 4.5:1 floor. #9587f2 clears every surface the kit
57
+ * paints it on and the badge as well. */
58
+ --color-violet: #9587f2;
50
59
 
51
60
  /* #15803d and #b45309 sit at ~3.7:1 on ink, below the 4.5:1 AA floor
52
61
  * for the text-size status labels that use them. */
53
62
  --color-success: #22c55e;
54
63
  --color-warn: #f59e0b;
55
- --color-danger: #b91c1c;
64
+ /* #b91c1c reads 2.85:1 on ink and 2.61:1 inside bg-danger/10, so every
65
+ * form error message and every validation hint failed AA. A dark red
66
+ * cannot work as a foreground on a dark surface.
67
+ *
68
+ * One token serves both roles because it inverts with `ink`. As text on a
69
+ * dark surface #f87171 reads 6.30:1; as the fill behind a `text-ink` label
70
+ * on a filled destructive button it reads 6.67:1. The light palette does
71
+ * the same in reverse with a dark red and a near-white ink. */
72
+ --color-danger: #f87171;
56
73
 
57
74
  /* ── Radius ─────────────────────────────────────────────────── */
58
75
  --radius-xs: 4px;
@@ -125,20 +142,30 @@ html[data-theme='light'] {
125
142
  --color-surface: #ffffff;
126
143
  --color-surface-2: #e6e8ee;
127
144
  --color-line: #d6d9e0;
145
+ --color-line-strong: #767d8e;
128
146
  --color-fg: #111827;
129
147
  --color-muted: #525866;
130
- /* #9aa1ad on white is ~3.1:1, below the 4.5:1 AA floor; #6b7280 sits
131
- * at ~4.8:1 while staying visually tertiary. */
132
- --color-faint: #6b7280;
133
-
134
- --color-brand: #007ea6;
135
- --color-brand-light: #00b4dd;
136
- --color-brand-deep: #006a8a;
137
- --color-violet: #6b5cdf;
138
-
139
- --color-success: #15803d;
140
- --color-warn: #b45309;
141
- --color-danger: #b91c1c;
148
+ /* Every value below is the lightest colour on its own hue that clears
149
+ * 4.5:1 against the worst background the kit actually paints it on. That
150
+ * worst case is rarely the bare canvas: status text and brand chips sit
151
+ * inside a tint of their own token (bg-warn/10 text-warn), and the tint
152
+ * drags the background toward the text. Values chosen against white alone
153
+ * fail inside the tint and on surface-2. */
154
+ --color-faint: #616774;
155
+
156
+ --color-brand: #005a77;
157
+ /* brand-light is the hover step for brand links and for the filled brand
158
+ * button. In a light theme emphasis reads as deeper, not lighter, so it
159
+ * sits below brand here and brand-deep below that. The names describe
160
+ * their place in the default dark ramp. #00b4dd put near-white text at
161
+ * 2.45:1 on the primary button's hover state. */
162
+ --color-brand-light: #004f69;
163
+ --color-brand-deep: #00455c;
164
+ --color-violet: #5544da;
165
+
166
+ --color-success: #126c34;
167
+ --color-warn: #904207;
168
+ --color-danger: #ab1a1a;
142
169
  }
143
170
 
144
171
  @layer base {
@@ -163,8 +190,14 @@ html[data-theme='light'] {
163
190
  background-color: color-mix(in srgb, var(--color-brand) 30%, transparent);
164
191
  }
165
192
 
193
+ /*
194
+ * SC 1.4.11 puts a 3:1 floor on the focus indicator. Mixing brand down to
195
+ * 60% alpha left it at 2.39:1 on the light palette, so the one affordance a
196
+ * keyboard user has for locating themselves was the element that failed.
197
+ * Solid brand reads 9.85:1 on dark and 7.70:1 on light.
198
+ */
166
199
  :focus-visible {
167
- outline: 2px solid color-mix(in srgb, var(--color-brand) 60%, transparent);
200
+ outline: 2px solid var(--color-brand);
168
201
  outline-offset: 2px;
169
202
  }
170
203