@nqmcreative/ui 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +1 -1
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/core/tones.d.ts +10 -0
  5. package/dist/core/tones.js +19 -0
  6. package/dist/core/trigger.d.ts +8 -0
  7. package/dist/core/trigger.js +11 -0
  8. package/dist/styles/matte/Alert.svelte +1 -1
  9. package/dist/styles/matte/Avatar.svelte +16 -3
  10. package/dist/styles/matte/Button.svelte +6 -2
  11. package/dist/styles/matte/Checkbox.svelte +49 -30
  12. package/dist/styles/matte/CheckboxGroup.svelte +15 -4
  13. package/dist/styles/matte/Combobox.svelte +11 -2
  14. package/dist/styles/matte/ConfirmDialog.svelte +10 -2
  15. package/dist/styles/matte/DatePicker.svelte +9 -2
  16. package/dist/styles/matte/Divider.svelte +1 -1
  17. package/dist/styles/matte/Drawer.svelte +10 -2
  18. package/dist/styles/matte/Field.svelte +59 -3
  19. package/dist/styles/matte/Footer.svelte +14 -3
  20. package/dist/styles/matte/Input.svelte +9 -2
  21. package/dist/styles/matte/Label.svelte +3 -1
  22. package/dist/styles/matte/Link.svelte +13 -2
  23. package/dist/styles/matte/MenuItem.svelte +11 -4
  24. package/dist/styles/matte/Modal.svelte +10 -2
  25. package/dist/styles/matte/Navbar.svelte +2 -2
  26. package/dist/styles/matte/Radio.svelte +41 -22
  27. package/dist/styles/matte/RadioGroup.svelte +15 -4
  28. package/dist/styles/matte/Select.svelte +4 -2
  29. package/dist/styles/matte/Skeleton.svelte +2 -1
  30. package/dist/styles/matte/Stat.svelte +27 -3
  31. package/dist/styles/matte/Switch.svelte +6 -1
  32. package/dist/styles/matte/Tabs.svelte +1 -1
  33. package/dist/styles/matte/Textarea.svelte +4 -2
  34. package/dist/styles/matte/Toaster.svelte +21 -3
  35. package/dist/styles/matte/Tooltip.svelte +8 -1
  36. package/dist/styles/matte/index.d.ts +1 -1
  37. package/dist/styles/matte/index.js +1 -1
  38. package/dist/styles/matte/theme.css +21 -0
  39. package/dist/styles/paper/Alert.svelte +2 -2
  40. package/dist/styles/paper/Avatar.svelte +14 -3
  41. package/dist/styles/paper/Button.svelte +5 -1
  42. package/dist/styles/paper/Card.svelte +1 -1
  43. package/dist/styles/paper/Checkbox.svelte +55 -36
  44. package/dist/styles/paper/CheckboxGroup.svelte +15 -4
  45. package/dist/styles/paper/Combobox.svelte +11 -2
  46. package/dist/styles/paper/ConfirmDialog.svelte +8 -2
  47. package/dist/styles/paper/DatePicker.svelte +9 -2
  48. package/dist/styles/paper/Divider.svelte +1 -1
  49. package/dist/styles/paper/Drawer.svelte +8 -2
  50. package/dist/styles/paper/Field.svelte +59 -3
  51. package/dist/styles/paper/Footer.svelte +14 -3
  52. package/dist/styles/paper/Input.svelte +9 -2
  53. package/dist/styles/paper/Label.svelte +3 -1
  54. package/dist/styles/paper/Link.svelte +13 -2
  55. package/dist/styles/paper/MenuItem.svelte +11 -4
  56. package/dist/styles/paper/Modal.svelte +8 -2
  57. package/dist/styles/paper/Navbar.svelte +2 -2
  58. package/dist/styles/paper/Radio.svelte +41 -22
  59. package/dist/styles/paper/RadioGroup.svelte +15 -4
  60. package/dist/styles/paper/SegmentedControl.svelte +2 -2
  61. package/dist/styles/paper/Select.svelte +4 -2
  62. package/dist/styles/paper/Skeleton.svelte +2 -1
  63. package/dist/styles/paper/Stat.svelte +24 -2
  64. package/dist/styles/paper/Switch.svelte +6 -1
  65. package/dist/styles/paper/Tabs.svelte +1 -1
  66. package/dist/styles/paper/Textarea.svelte +4 -2
  67. package/dist/styles/paper/Toaster.svelte +19 -3
  68. package/dist/styles/paper/Tooltip.svelte +8 -1
  69. package/dist/styles/paper/index.d.ts +1 -1
  70. package/dist/styles/paper/index.js +1 -1
  71. package/dist/styles/paper/theme.css +21 -0
  72. package/dist/styles/sprout/Alert.svelte +3 -3
  73. package/dist/styles/sprout/Avatar.svelte +14 -3
  74. package/dist/styles/sprout/Breadcrumb.svelte +1 -1
  75. package/dist/styles/sprout/Button.svelte +8 -4
  76. package/dist/styles/sprout/Calendar.svelte +1 -1
  77. package/dist/styles/sprout/Card.svelte +2 -2
  78. package/dist/styles/sprout/Checkbox.svelte +55 -36
  79. package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
  80. package/dist/styles/sprout/ColorInput.svelte +1 -1
  81. package/dist/styles/sprout/Combobox.svelte +12 -3
  82. package/dist/styles/sprout/ConfirmDialog.svelte +8 -2
  83. package/dist/styles/sprout/DatePicker.svelte +11 -4
  84. package/dist/styles/sprout/Divider.svelte +1 -1
  85. package/dist/styles/sprout/Drawer.svelte +8 -2
  86. package/dist/styles/sprout/Dropzone.svelte +1 -1
  87. package/dist/styles/sprout/Field.svelte +59 -3
  88. package/dist/styles/sprout/FileInput.svelte +3 -3
  89. package/dist/styles/sprout/Footer.svelte +14 -3
  90. package/dist/styles/sprout/Input.svelte +9 -2
  91. package/dist/styles/sprout/Label.svelte +3 -1
  92. package/dist/styles/sprout/Link.svelte +13 -2
  93. package/dist/styles/sprout/LogoCloud.svelte +1 -1
  94. package/dist/styles/sprout/MenuItem.svelte +11 -4
  95. package/dist/styles/sprout/Modal.svelte +8 -2
  96. package/dist/styles/sprout/MultiSelect.svelte +3 -3
  97. package/dist/styles/sprout/Navbar.svelte +2 -2
  98. package/dist/styles/sprout/Radio.svelte +41 -22
  99. package/dist/styles/sprout/RadioGroup.svelte +15 -4
  100. package/dist/styles/sprout/SearchInput.svelte +1 -1
  101. package/dist/styles/sprout/SegmentedControl.svelte +2 -2
  102. package/dist/styles/sprout/Select.svelte +4 -2
  103. package/dist/styles/sprout/Sidebar.svelte +2 -1
  104. package/dist/styles/sprout/Skeleton.svelte +2 -1
  105. package/dist/styles/sprout/Stat.svelte +24 -2
  106. package/dist/styles/sprout/Switch.svelte +6 -1
  107. package/dist/styles/sprout/Table.svelte +1 -1
  108. package/dist/styles/sprout/Tabs.svelte +1 -1
  109. package/dist/styles/sprout/TagsInput.svelte +1 -1
  110. package/dist/styles/sprout/Textarea.svelte +4 -2
  111. package/dist/styles/sprout/TimeInput.svelte +1 -1
  112. package/dist/styles/sprout/Toaster.svelte +22 -6
  113. package/dist/styles/sprout/Tooltip.svelte +8 -1
  114. package/dist/styles/sprout/index.d.ts +2 -2
  115. package/dist/styles/sprout/index.js +2 -2
  116. package/dist/styles/sprout/lift.d.ts +14 -0
  117. package/dist/styles/sprout/lift.js +23 -0
  118. package/dist/styles/sprout/theme.css +50 -24
  119. package/package.json +1 -1
  120. package/registry.json +4 -2
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLButtonAttributes } from 'svelte/elements';
4
- import { toneSoftHover, toneText, type Tone } from '../../core/tones.js';
4
+ import { toneSoft, toneSoftHover, toneText, type Tone } from '../../core/tones.js';
5
5
  import { iconMd } from './icon.js';
6
6
 
7
7
  interface Props extends HTMLButtonAttributes {
@@ -28,6 +28,11 @@
28
28
  children,
29
29
  ...rest
30
30
  }: Props = $props();
31
+
32
+ // A chosen row speaks in the brand, the same as an active nav link — a tick
33
+ // alone leaves it the same grey as every row above it. An item that already
34
+ // carries a tone (a red `Delete`) keeps its own.
35
+ const chosen = $derived(toneSoft[tone === 'neutral' ? 'brand' : tone]);
31
36
  </script>
32
37
 
33
38
  <svelte:element
@@ -39,9 +44,11 @@
39
44
  aria-disabled={disabled ? 'true' : undefined}
40
45
  tabindex={disabled ? undefined : -1}
41
46
  class="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[14px] transition-colors duration-100 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-40
42
- {tone === 'neutral'
43
- ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
44
- : `${toneText[tone]} ${toneSoftHover[tone]}`}
47
+ {selected
48
+ ? `${chosen} font-medium`
49
+ : tone === 'neutral'
50
+ ? 'text-text-secondary hover:bg-bg-inset focus-visible:bg-bg-inset'
51
+ : `${toneText[tone]} ${toneSoftHover[tone]}`}
45
52
  {className}"
46
53
  {...rest}
47
54
  >
@@ -39,6 +39,8 @@
39
39
  ...rest
40
40
  }: Props = $props();
41
41
 
42
+ const uid = $props.id();
43
+
42
44
  let dialog: HTMLDialogElement | null = $state(null);
43
45
 
44
46
  const closable = $derived(showClose ?? dismissible);
@@ -64,6 +66,8 @@
64
66
 
65
67
  <dialog
66
68
  bind:this={dialog}
69
+ aria-labelledby={title ? `${uid}-title` : undefined}
70
+ aria-describedby={description ? `${uid}-description` : undefined}
67
71
  onclose={close}
68
72
  onclick={onBackdrop}
69
73
  oncancel={(event) => {
@@ -80,10 +84,14 @@
80
84
  {#if icon}<span class={iconLg}>{@render icon()}</span>{/if}
81
85
  <div class="flex min-w-0 flex-1 flex-col gap-1">
82
86
  {#if title}
83
- <h2 class="font-heading text-lg font-medium tracking-tight">{title}</h2>
87
+ <h2 id="{uid}-title" class="font-heading text-lg font-medium tracking-tight">
88
+ {title}
89
+ </h2>
84
90
  {/if}
85
91
  {#if description}
86
- <p class="font-sans text-sm text-text-secondary">{description}</p>
92
+ <p id="{uid}-description" class="font-sans text-sm text-text-secondary">
93
+ {description}
94
+ </p>
87
95
  {/if}
88
96
  </div>
89
97
  {#if closable}
@@ -109,7 +109,7 @@
109
109
  class="{link} {focusRing} {toneRing[tone]} {item.active
110
110
  ? toneText[tone]
111
111
  : 'text-text-secondary hover:text-text'} {item.disabled
112
- ? 'pointer-events-none opacity-50'
112
+ ? 'pointer-events-none opacity-40'
113
113
  : ''}"
114
114
  >
115
115
  {#if item.icon}<span class={iconMd}>{@render item.icon()}</span>{/if}
@@ -156,7 +156,7 @@
156
156
  aria-current={item.active ? 'page' : undefined}
157
157
  class="flex items-center justify-between gap-3 py-3 font-sans text-[15px] {item.active
158
158
  ? toneText[tone]
159
- : 'text-text-secondary'} {item.disabled ? 'pointer-events-none opacity-50' : ''}"
159
+ : 'text-text-secondary'} {item.disabled ? 'pointer-events-none opacity-40' : ''}"
160
160
  >
161
161
  <span class="flex min-w-0 items-center gap-2.5">
162
162
  {#if item.icon}<span class={iconMd}>{@render item.icon()}</span>{/if}
@@ -24,32 +24,51 @@
24
24
  children,
25
25
  ...rest
26
26
  }: Props = $props();
27
+
28
+ const uid = $props.id();
27
29
  </script>
28
30
 
29
- <label
30
- class="group inline-flex items-start gap-3 font-sans text-[15px]
31
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
31
+ <div
32
+ class="inline-block font-sans text-[15px] {disabled
33
+ ? 'pointer-events-none opacity-50'
34
+ : ''} {className}"
32
35
  >
33
- <input type="radio" bind:group {value} {disabled} class="peer sr-only" {...rest} />
34
- <span
35
- class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg transition-colors duration-150 ease-brand-out
36
+ <label class="group inline-flex items-start gap-3 {disabled ? '' : 'cursor-pointer'}">
37
+ <input
38
+ type="radio"
39
+ bind:group
40
+ {value}
41
+ {disabled}
42
+ aria-describedby={description ? `${uid}-description` : undefined}
43
+ class="peer sr-only"
44
+ {...rest}
45
+ />
46
+ <span
47
+ class="mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border border-hairline-strong bg-bg transition-colors duration-150 ease-brand-out
36
48
  {tonePeerChecked[tone]} {peerFocusRing} {tonePeerFocus[tone]}"
37
- aria-hidden="true"
38
- >
49
+ aria-hidden="true"
50
+ >
51
+ <span
52
+ class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
53
+ ></span>
54
+ </span>
55
+ {#if label || children}
56
+ <span class="flex flex-col gap-0.5">
57
+ {#if children}
58
+ {@render children()}
59
+ {:else if label}
60
+ <span class="leading-snug text-text">{label}</span>
61
+ {/if}
62
+ </span>
63
+ {/if}
64
+ </label>
65
+ {#if description}
66
+ <!-- ponytail: the indent tracks the 18px dot plus the label's gap; move one, move both. -->
39
67
  <span
40
- class="size-1.5 rounded-full bg-text-inverse opacity-0 transition-opacity duration-150 group-has-[:checked]:opacity-100"
41
- ></span>
42
- </span>
43
- {#if label || description || children}
44
- <span class="flex flex-col gap-0.5">
45
- {#if children}
46
- {@render children()}
47
- {:else if label}
48
- <span class="leading-snug text-text">{label}</span>
49
- {/if}
50
- {#if description}
51
- <span class="text-sm leading-snug text-text-muted">{description}</span>
52
- {/if}
68
+ id="{uid}-description"
69
+ class="mt-0.5 block pl-[30px] text-sm leading-snug text-text-muted"
70
+ >
71
+ {description}
53
72
  </span>
54
73
  {/if}
55
- </label>
74
+ </div>
@@ -44,13 +44,24 @@
44
44
  children,
45
45
  ...rest
46
46
  }: Props = $props();
47
+
48
+ const uid = $props.id();
49
+ // The group's own hint and error sit outside every control in it, so the
50
+ // fieldset is what has to point at them.
51
+ const messageId = $derived(error ? `${uid}-error` : hint ? `${uid}-hint` : undefined);
47
52
  </script>
48
53
 
49
- <fieldset class="flex flex-col gap-3 border-0 p-0 {className}" {...rest}>
54
+ <fieldset
55
+ aria-describedby={messageId}
56
+ class="flex flex-col gap-3 border-0 p-0 {className}"
57
+ {...rest}
58
+ >
50
59
  {#if legend}
51
60
  <legend class="mb-1 font-sans text-sm font-medium text-text">
52
61
  {legend}
53
- {#if required}<span class="text-danger" aria-hidden="true">*</span>{/if}
62
+ {#if required}<span class="text-danger" aria-hidden="true">*</span><span class="sr-only"
63
+ >(required)</span
64
+ >{/if}
54
65
  </legend>
55
66
  {/if}
56
67
 
@@ -92,8 +103,8 @@
92
103
  </div>
93
104
 
94
105
  {#if error}
95
- <p class="font-sans text-sm text-danger">{error}</p>
106
+ <p id="{uid}-error" role="alert" class="font-sans text-sm text-danger">{error}</p>
96
107
  {:else if hint}
97
- <p class="font-sans text-sm text-text-muted">{hint}</p>
108
+ <p id="{uid}-hint" class="font-sans text-sm text-text-muted">{hint}</p>
98
109
  {/if}
99
110
  </fieldset>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLSelectAttributes } from 'svelte/elements';
4
- import { toneFocusBorder, type Tone } from '../../core/tones.js';
4
+ import { focusRing, toneFocusBorder, toneRing, type Tone } from '../../core/tones.js';
5
5
 
6
6
  export interface SelectOption {
7
7
  value: string;
@@ -35,7 +35,9 @@
35
35
  <select
36
36
  bind:value
37
37
  aria-invalid={invalid ? 'true' : undefined}
38
- class="h-10 w-full appearance-none border bg-bg py-0 pr-10 pl-3.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out focus:outline-none disabled:pointer-events-none disabled:opacity-50
38
+ class="{focusRing} {toneRing[
39
+ tone
40
+ ]} h-10 w-full appearance-none border bg-bg py-0 pr-10 pl-3.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-50
39
41
  {invalid ? 'border-danger' : `border-hairline ${toneFocusBorder[tone]}`} {className}"
40
42
  {...rest}
41
43
  >
@@ -29,7 +29,7 @@
29
29
  </script>
30
30
 
31
31
  {#if variant === 'text' && lines > 1}
32
- <div class="flex w-full flex-col gap-2 {className}" {...rest}>
32
+ <div aria-hidden="true" class="flex w-full flex-col gap-2 {className}" {...rest}>
33
33
  {#each { length: lines }, i (i)}
34
34
  <div
35
35
  class="h-4 animate-pulse bg-bg-inset {i === lines - 1 ? 'w-2/3' : 'w-full'}"
@@ -39,6 +39,7 @@
39
39
  </div>
40
40
  {:else}
41
41
  <div
42
+ aria-hidden="true"
42
43
  class="animate-pulse bg-bg-inset {variants[variant]} {className}"
43
44
  style="{width ? `width:${width};` : ''}{height ? `height:${height};` : ''}"
44
45
  {...rest}
@@ -32,7 +32,15 @@
32
32
  }: Props = $props();
33
33
 
34
34
  const trendTone: Record<StatTrend, Tone> = { up: 'success', down: 'danger', flat: 'neutral' };
35
- const trendGlyph: Record<StatTrend, string> = { up: '↑', down: '↓', flat: '→' };
35
+ // Drawn rather than typed. `↑` renders in whatever face the reader's system
36
+ // substitutes for it and is announced as "up arrow" or nothing at all, and
37
+ // the colour beside it says nothing either — so the word ships with it.
38
+ const trendPath: Record<StatTrend, string> = {
39
+ up: 'M7 11V3.5M7 3l3.5 3.5M7 3 3.5 6.5',
40
+ down: 'M7 3v7.5M7 11l3.5-3.5M7 11 3.5 7.5',
41
+ flat: 'M3 7h7.5M11 7 7.5 3.5M11 7l-3.5 3.5'
42
+ };
43
+ const trendWord: Record<StatTrend, string> = { up: 'up', down: 'down', flat: 'no change' };
36
44
  </script>
37
45
 
38
46
  <div class="flex flex-col gap-1 font-sans {className}" {...rest}>
@@ -41,13 +49,29 @@
41
49
  >
42
50
  <div class="flex items-baseline gap-2">
43
51
  <span
44
- class="font-heading text-3xl font-medium tracking-tight {tone ? toneText[tone] : 'text-text'}"
52
+ class="font-heading text-3xl font-medium tracking-tight tabular-nums {tone
53
+ ? toneText[tone]
54
+ : 'text-text'}"
45
55
  >
46
56
  {value}
47
57
  </span>
48
58
  {#if delta}
49
59
  <span class="font-mono text-xs {toneText[trendTone[trend]]}">
50
- {trendGlyph[trend]}
60
+ <svg
61
+ class="inline-block size-[1em] align-[-0.115em]"
62
+ viewBox="0 0 14 14"
63
+ fill="none"
64
+ aria-hidden="true"
65
+ >
66
+ <path
67
+ d={trendPath[trend]}
68
+ stroke="currentColor"
69
+ stroke-width="1.6"
70
+ stroke-linecap="round"
71
+ stroke-linejoin="round"
72
+ />
73
+ </svg>
74
+ <span class="sr-only">{trendWord[trend]}</span>
51
75
  {delta}
52
76
  </span>
53
77
  {/if}
@@ -30,6 +30,8 @@
30
30
  md: { track: 'h-6 w-11', thumb: 'size-5', travel: 'translate-x-5' }
31
31
  };
32
32
 
33
+ const uid = $props.id();
34
+
33
35
  function toggle() {
34
36
  checked = !checked;
35
37
  onchange?.(checked);
@@ -42,6 +44,7 @@
42
44
  role="switch"
43
45
  aria-checked={checked}
44
46
  aria-label={label}
47
+ aria-describedby={description ? `${uid}-description` : undefined}
45
48
  {disabled}
46
49
  onclick={toggle}
47
50
  class="relative mt-0.5 inline-flex shrink-0 items-center rounded-full p-0.5 transition-colors duration-200 ease-brand-out disabled:pointer-events-none
@@ -57,7 +60,9 @@
57
60
  <span class="flex flex-col gap-0.5 font-sans text-[15px]">
58
61
  {#if label}<span class="leading-snug text-text">{label}</span>{/if}
59
62
  {#if description}
60
- <span class="text-sm leading-snug text-text-muted">{description}</span>
63
+ <span id="{uid}-description" class="text-sm leading-snug text-text-muted"
64
+ >{description}</span
65
+ >
61
66
  {/if}
62
67
  </span>
63
68
  {/if}
@@ -74,7 +74,7 @@
74
74
  }`;
75
75
  }
76
76
  return `${base} h-9 px-4 ${
77
- active ? 'bg-bg font-medium text-text' : 'text-text-secondary hover:text-text'
77
+ active ? `bg-bg font-medium ${toneText[tone]}` : 'text-text-secondary hover:text-text'
78
78
  }`;
79
79
  }
80
80
  </script>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
- import { toneFocusBorder, type Tone } from '../../core/tones.js';
3
+ import { focusRing, toneFocusBorder, toneRing, type Tone } from '../../core/tones.js';
4
4
 
5
5
  interface Props extends HTMLTextareaAttributes {
6
6
  value?: string;
@@ -48,7 +48,9 @@
48
48
  bind:value
49
49
  {rows}
50
50
  aria-invalid={invalid ? 'true' : undefined}
51
- class="w-full border bg-bg px-3.5 py-2.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out placeholder:text-text-muted focus:outline-none disabled:pointer-events-none disabled:opacity-50
51
+ class="{focusRing} {toneRing[
52
+ tone
53
+ ]} w-full border bg-bg px-3.5 py-2.5 font-sans text-[15px] text-text transition-colors duration-150 ease-brand-out placeholder:text-text-muted disabled:pointer-events-none disabled:opacity-50
52
54
  {autoresize ? 'resize-none overflow-hidden' : 'resize-y'}
53
55
  {invalid ? 'border-danger' : `border-hairline ${toneFocusBorder[tone]}`} {className}"
54
56
  {...rest}></textarea>
@@ -30,8 +30,24 @@
30
30
  const fromLeft = $derived(position.endsWith('left'));
31
31
  const fromCenter = $derived(position.endsWith('center'));
32
32
 
33
+ /**
34
+ * A card sliding in from the edge of the screen, and a stack of them shuffling
35
+ * to make room, is exactly the motion this preference exists to stop. Under it
36
+ * the toast fades in place and the stack reorders instantly — the state change
37
+ * still reads, it just stops travelling.
38
+ *
39
+ * ponytail: read once at mount. Someone who flips the OS setting mid-session
40
+ * gets the new behaviour on the next load; a live listener is the upgrade.
41
+ */
42
+ const stillness =
43
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
44
+
33
45
  const enter = $derived(
34
- fromCenter ? { y: fromTop ? -16 : 16, x: 0 } : { x: fromLeft ? -16 : 16, y: 0 }
46
+ stillness
47
+ ? { x: 0, y: 0 }
48
+ : fromCenter
49
+ ? { y: fromTop ? -16 : 16, x: 0 }
50
+ : { x: fromLeft ? -16 : 16, y: 0 }
35
51
  );
36
52
  </script>
37
53
 
@@ -45,7 +61,7 @@
45
61
  >
46
62
  {#each toast.items as item (item.id)}
47
63
  <div
48
- animate:flip={{ duration: 200 }}
64
+ animate:flip={{ duration: stillness ? 0 : 200 }}
49
65
  in:fly={{ ...enter, duration: 220 }}
50
66
  out:fly={{ ...enter, duration: 160 }}
51
67
  role={item.tone === 'danger' ? 'alert' : 'status'}
@@ -75,7 +91,9 @@
75
91
  </span>
76
92
 
77
93
  <div class="flex min-w-0 flex-1 flex-col gap-1">
78
- <p class="text-[15px] leading-snug font-medium {toneText[item.tone]}">{item.title}</p>
94
+ <p class="font-heading text-[15px] leading-snug font-medium {toneText[item.tone]}">
95
+ {item.title}
96
+ </p>
79
97
  {#if item.description}
80
98
  <p class="text-sm leading-relaxed text-text-secondary">{item.description}</p>
81
99
  {/if}
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { describedBy } from '../../core/trigger.js';
3
4
  import type { HTMLAttributes } from 'svelte/elements';
4
5
 
5
6
  export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
@@ -13,6 +14,11 @@
13
14
 
14
15
  let { content, placement = 'top', class: className = '', children, ...rest }: Props = $props();
15
16
 
17
+ const uid = $props.id();
18
+ let wrapper: HTMLElement | null = $state(null);
19
+
20
+ $effect(() => describedBy(wrapper, `${uid}-tip`));
21
+
16
22
  const placements: Record<TooltipPlacement, string> = {
17
23
  top: 'bottom-full left-1/2 mb-2 -translate-x-1/2',
18
24
  bottom: 'top-full left-1/2 mt-2 -translate-x-1/2',
@@ -21,9 +27,10 @@
21
27
  };
22
28
  </script>
23
29
 
24
- <span class="group/tt relative inline-flex" {...rest}>
30
+ <span bind:this={wrapper} class="group/tt relative inline-flex" {...rest}>
25
31
  {@render children()}
26
32
  <span
33
+ id="{uid}-tip"
27
34
  role="tooltip"
28
35
  class="pointer-events-none invisible absolute z-50 bg-text px-2.5 py-1.5 font-sans text-xs whitespace-nowrap text-text-inverse opacity-0 transition-opacity duration-150 ease-brand-out group-focus-within/tt:visible group-focus-within/tt:opacity-100 group-hover/tt:visible group-hover/tt:opacity-100
29
36
  {placements[placement]} {className}"
@@ -5,7 +5,7 @@
5
5
  * `@nqmcreative/ui/core`, re-exported below so this one import covers
6
6
  * the whole style.
7
7
  */
8
- export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText, anchored, clickOutside, focusTrap, focusable, navigateList, portal, toast, applyTheme, storedTheme, toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from '../../core/index.js';
8
+ export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, focusWithinRing, toneFocusWithinRing, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText, anchored, clickOutside, focusTrap, focusable, navigateList, portal, toast, applyTheme, storedTheme, toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from '../../core/index.js';
9
9
  export type { Tone, Placement, AnchorOptions, ClickOutsideOptions, FocusTrapOptions, Toast, ToastOptions, ToastAction, Theme, DateFormat, TableSort, SortDirection, RejectedFile } from '../../core/index.js';
10
10
  export { default as Button } from './Button.svelte';
11
11
  export type { ButtonVariant, ButtonSize } from './Button.svelte';
@@ -6,7 +6,7 @@
6
6
  * the whole style.
7
7
  */
8
8
  /* --- shared behaviour --- */
9
- export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText, anchored, clickOutside, focusTrap, focusable, navigateList, portal, toast, applyTheme, storedTheme, toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from '../../core/index.js';
9
+ export { TONES, focusRing, peerFocusRing, toneBorder, toneBorderLeft, toneBorderSoft, toneFill, toneFocusBorder, toneFocusWithinBorder, focusWithinRing, toneFocusWithinRing, toneHoverBorder, toneHoverText, tonePeerChecked, tonePeerFocus, toneRing, toneSoft, toneSoftHover, toneSolid, toneSolidHover, toneSurface, toneText, anchored, clickOutside, focusTrap, focusable, navigateList, portal, toast, applyTheme, storedTheme, toISO, fromISO, today, isSameDay, addDays, addMonths, monthGrid, weekdayNames, monthLabel, isOutOfRange, formatISO, parseFormatted } from '../../core/index.js';
10
10
  /* --- actions --- */
11
11
  export { default as Button } from './Button.svelte';
12
12
  export { default as Link } from './Link.svelte';
@@ -207,3 +207,24 @@
207
207
  --color-neutral-border: #334155;
208
208
  }
209
209
  }
210
+
211
+ /*
212
+ * The surfaces the browser draws for us.
213
+ *
214
+ * A caret and a highlight in the user-agent's blue belong to no design system,
215
+ * and they are the two the reader touches most: one sits in every field they
216
+ * type into, the other appears the moment they drag across a paragraph. The
217
+ * selection is mixed from the brand rather than pinned to a token so it stays
218
+ * a wash — the text underneath keeps its own colour and its own contrast.
219
+ */
220
+ @layer base {
221
+ ::selection {
222
+ background-color: color-mix(in oklab, var(--color-brand) 24%, transparent);
223
+ }
224
+
225
+ input,
226
+ textarea,
227
+ [contenteditable] {
228
+ caret-color: var(--color-brand);
229
+ }
230
+ }
@@ -40,7 +40,7 @@
40
40
  const variants = $derived({
41
41
  soft: `${toneSurface[tone]} border ${toneBorderSoft[tone]}`,
42
42
  outline: `bg-bg shadow-xs border ${toneBorderSoft[tone]}`,
43
- accent: `${toneSurface[tone]} border-l-4 ${toneBorder[tone]}`
43
+ accent: `${toneSurface[tone]} border-l-2 ${toneBorder[tone]}`
44
44
  });
45
45
 
46
46
  function dismiss() {
@@ -69,7 +69,7 @@
69
69
 
70
70
  <div class="flex min-w-0 flex-1 flex-col gap-1">
71
71
  {#if title}
72
- <p class="text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
72
+ <p class="font-heading text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
73
73
  {/if}
74
74
  {#if children}
75
75
  <div class="text-[13px] leading-relaxed text-text-secondary">{@render children()}</div>
@@ -47,6 +47,12 @@
47
47
  xl: 'grid place-items-center *:size-8'
48
48
  };
49
49
 
50
+ // A src that 404s left the reader with the browser's broken-image mark while
51
+ // the initials sat right there unused. Holding the failed URL rather than a
52
+ // flag means a new src is tried again on its own.
53
+ let failed = $state('');
54
+ const broken = $derived(failed !== '' && failed === src);
55
+
50
56
  const initials = $derived(
51
57
  name
52
58
  .trim()
@@ -60,13 +66,18 @@
60
66
 
61
67
  <span
62
68
  class="inline-flex shrink-0 items-center justify-center overflow-hidden font-sans font-semibold select-none
63
- {squared ? 'rounded-lg' : 'rounded-full'} {sizes[size]} {src
69
+ {squared ? 'rounded-lg' : 'rounded-full'} {sizes[size]} {src && !broken
64
70
  ? 'bg-bg-inset'
65
71
  : toneSoft[tone]} {className}"
66
72
  {...rest}
67
73
  >
68
- {#if src}
69
- <img {src} alt={alt ?? name} class="size-full object-cover" />
74
+ {#if src && !broken}
75
+ <img
76
+ {src}
77
+ alt={alt ?? name}
78
+ onerror={() => (failed = src ?? '')}
79
+ class="size-full object-cover"
80
+ />
70
81
  {:else if fallback}
71
82
  <span class={glyphs[size]}>{@render fallback()}</span>
72
83
  {:else}
@@ -107,6 +107,10 @@
107
107
  const iconClass = $derived(size === 'sm' ? iconSm : iconMd);
108
108
 
109
109
  async function handleClick(event: MouseEvent) {
110
+ if (inert) {
111
+ event.preventDefault();
112
+ return;
113
+ }
110
114
  const result = onclick?.(event);
111
115
  // Anything else is a plain handler: nothing to wait for, nothing to lock.
112
116
  if (!(result instanceof Promise)) return;
@@ -125,7 +129,7 @@
125
129
  disabled={href ? undefined : inert}
126
130
  aria-disabled={href && inert ? 'true' : undefined}
127
131
  aria-busy={busy ? 'true' : undefined}
128
- role={href ? 'button' : undefined}
132
+ tabindex={href && inert ? -1 : undefined}
129
133
  class="{base} {focusRing} {toneRing[tone]} {variantClass} {isLink
130
134
  ? ''
131
135
  : iconOnly
@@ -48,7 +48,7 @@
48
48
 
49
49
  <div
50
50
  class="flex flex-col rounded-lg {variants[variant]}
51
- {accent ? `border-l-4 ${toneBorderLeft[tone]}` : ''}
51
+ {accent ? `border-l-2 ${toneBorderLeft[tone]}` : ''}
52
52
  {interactive
53
53
  ? `transition-shadow duration-150 ease-brand-out hover:shadow-md ${toneHoverBorder[tone]}`
54
54
  : ''}