@nqmcreative/ui 0.4.1 → 0.4.3

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 (132) 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 +9 -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 +12 -3
  14. package/dist/styles/matte/CommandPalette.svelte +1 -1
  15. package/dist/styles/matte/ConfirmDialog.svelte +10 -2
  16. package/dist/styles/matte/ContextMenu.svelte +1 -1
  17. package/dist/styles/matte/DatePicker.svelte +10 -3
  18. package/dist/styles/matte/Divider.svelte +1 -1
  19. package/dist/styles/matte/Drawer.svelte +10 -2
  20. package/dist/styles/matte/Dropdown.svelte +1 -1
  21. package/dist/styles/matte/Field.svelte +59 -3
  22. package/dist/styles/matte/Footer.svelte +14 -3
  23. package/dist/styles/matte/Input.svelte +9 -2
  24. package/dist/styles/matte/Label.svelte +3 -1
  25. package/dist/styles/matte/Link.svelte +13 -2
  26. package/dist/styles/matte/MenuItem.svelte +11 -4
  27. package/dist/styles/matte/Modal.svelte +11 -3
  28. package/dist/styles/matte/MultiSelect.svelte +1 -1
  29. package/dist/styles/matte/Navbar.svelte +2 -2
  30. package/dist/styles/matte/Popover.svelte +1 -1
  31. package/dist/styles/matte/Radio.svelte +41 -22
  32. package/dist/styles/matte/RadioGroup.svelte +15 -4
  33. package/dist/styles/matte/Select.svelte +4 -2
  34. package/dist/styles/matte/Skeleton.svelte +2 -1
  35. package/dist/styles/matte/Stat.svelte +27 -3
  36. package/dist/styles/matte/Switch.svelte +6 -1
  37. package/dist/styles/matte/Tabs.svelte +1 -1
  38. package/dist/styles/matte/Textarea.svelte +4 -2
  39. package/dist/styles/matte/Toaster.svelte +21 -3
  40. package/dist/styles/matte/Tooltip.svelte +8 -1
  41. package/dist/styles/matte/index.d.ts +1 -1
  42. package/dist/styles/matte/index.js +1 -1
  43. package/dist/styles/matte/theme.css +64 -1
  44. package/dist/styles/paper/AccordionItem.svelte +38 -11
  45. package/dist/styles/paper/AccordionItem.svelte.d.ts +1 -1
  46. package/dist/styles/paper/Alert.svelte +2 -2
  47. package/dist/styles/paper/Avatar.svelte +14 -3
  48. package/dist/styles/paper/Button.svelte +9 -2
  49. package/dist/styles/paper/Card.svelte +1 -1
  50. package/dist/styles/paper/Checkbox.svelte +55 -36
  51. package/dist/styles/paper/CheckboxGroup.svelte +15 -4
  52. package/dist/styles/paper/Combobox.svelte +12 -3
  53. package/dist/styles/paper/CommandPalette.svelte +1 -1
  54. package/dist/styles/paper/ConfirmDialog.svelte +8 -2
  55. package/dist/styles/paper/ContextMenu.svelte +1 -1
  56. package/dist/styles/paper/DatePicker.svelte +10 -3
  57. package/dist/styles/paper/Divider.svelte +1 -1
  58. package/dist/styles/paper/Drawer.svelte +8 -2
  59. package/dist/styles/paper/Dropdown.svelte +1 -1
  60. package/dist/styles/paper/Field.svelte +59 -3
  61. package/dist/styles/paper/Footer.svelte +14 -3
  62. package/dist/styles/paper/Input.svelte +9 -2
  63. package/dist/styles/paper/Label.svelte +3 -1
  64. package/dist/styles/paper/Link.svelte +13 -2
  65. package/dist/styles/paper/MenuItem.svelte +11 -4
  66. package/dist/styles/paper/Modal.svelte +9 -3
  67. package/dist/styles/paper/MultiSelect.svelte +1 -1
  68. package/dist/styles/paper/Navbar.svelte +2 -2
  69. package/dist/styles/paper/Popover.svelte +1 -1
  70. package/dist/styles/paper/Radio.svelte +41 -22
  71. package/dist/styles/paper/RadioGroup.svelte +15 -4
  72. package/dist/styles/paper/SegmentedControl.svelte +2 -2
  73. package/dist/styles/paper/Select.svelte +4 -2
  74. package/dist/styles/paper/Skeleton.svelte +2 -1
  75. package/dist/styles/paper/Stat.svelte +24 -2
  76. package/dist/styles/paper/Switch.svelte +6 -1
  77. package/dist/styles/paper/Tabs.svelte +1 -1
  78. package/dist/styles/paper/Textarea.svelte +4 -2
  79. package/dist/styles/paper/Toaster.svelte +19 -3
  80. package/dist/styles/paper/Tooltip.svelte +8 -1
  81. package/dist/styles/paper/index.d.ts +1 -1
  82. package/dist/styles/paper/index.js +1 -1
  83. package/dist/styles/paper/theme.css +85 -13
  84. package/dist/styles/sprout/Alert.svelte +3 -3
  85. package/dist/styles/sprout/Avatar.svelte +14 -3
  86. package/dist/styles/sprout/Breadcrumb.svelte +1 -1
  87. package/dist/styles/sprout/Button.svelte +8 -4
  88. package/dist/styles/sprout/Calendar.svelte +1 -1
  89. package/dist/styles/sprout/Card.svelte +2 -2
  90. package/dist/styles/sprout/Checkbox.svelte +55 -36
  91. package/dist/styles/sprout/CheckboxGroup.svelte +15 -4
  92. package/dist/styles/sprout/ColorInput.svelte +1 -1
  93. package/dist/styles/sprout/Combobox.svelte +12 -3
  94. package/dist/styles/sprout/ConfirmDialog.svelte +8 -2
  95. package/dist/styles/sprout/DatePicker.svelte +11 -4
  96. package/dist/styles/sprout/Divider.svelte +1 -1
  97. package/dist/styles/sprout/Drawer.svelte +8 -2
  98. package/dist/styles/sprout/Dropzone.svelte +1 -1
  99. package/dist/styles/sprout/Field.svelte +59 -3
  100. package/dist/styles/sprout/FileInput.svelte +3 -3
  101. package/dist/styles/sprout/Footer.svelte +14 -3
  102. package/dist/styles/sprout/Input.svelte +9 -2
  103. package/dist/styles/sprout/Label.svelte +3 -1
  104. package/dist/styles/sprout/Link.svelte +13 -2
  105. package/dist/styles/sprout/LogoCloud.svelte +1 -1
  106. package/dist/styles/sprout/MenuItem.svelte +11 -4
  107. package/dist/styles/sprout/Modal.svelte +8 -2
  108. package/dist/styles/sprout/MultiSelect.svelte +3 -3
  109. package/dist/styles/sprout/Navbar.svelte +2 -2
  110. package/dist/styles/sprout/Radio.svelte +41 -22
  111. package/dist/styles/sprout/RadioGroup.svelte +15 -4
  112. package/dist/styles/sprout/SearchInput.svelte +1 -1
  113. package/dist/styles/sprout/SegmentedControl.svelte +2 -2
  114. package/dist/styles/sprout/Select.svelte +4 -2
  115. package/dist/styles/sprout/Sidebar.svelte +2 -1
  116. package/dist/styles/sprout/Skeleton.svelte +2 -1
  117. package/dist/styles/sprout/Stat.svelte +24 -2
  118. package/dist/styles/sprout/Switch.svelte +6 -1
  119. package/dist/styles/sprout/Table.svelte +1 -1
  120. package/dist/styles/sprout/Tabs.svelte +1 -1
  121. package/dist/styles/sprout/TagsInput.svelte +1 -1
  122. package/dist/styles/sprout/Textarea.svelte +4 -2
  123. package/dist/styles/sprout/TimeInput.svelte +1 -1
  124. package/dist/styles/sprout/Toaster.svelte +22 -6
  125. package/dist/styles/sprout/Tooltip.svelte +8 -1
  126. package/dist/styles/sprout/index.d.ts +2 -2
  127. package/dist/styles/sprout/index.js +2 -2
  128. package/dist/styles/sprout/lift.d.ts +14 -0
  129. package/dist/styles/sprout/lift.js +23 -0
  130. package/dist/styles/sprout/theme.css +68 -24
  131. package/package.json +1 -1
  132. package/registry.json +4 -2
@@ -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,12 +66,14 @@
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) => {
70
74
  if (!dismissible) event.preventDefault();
71
75
  }}
72
- class="m-auto w-[calc(100%-2rem)] rounded-xl border border-hairline bg-bg p-0 text-text shadow-2xl backdrop:bg-text/30 backdrop:backdrop-blur-[1px] {sizes[
76
+ class="m-auto w-[calc(100%-2rem)] animate-enter rounded-xl border border-hairline bg-bg p-0 text-text shadow-2xl backdrop:bg-text/30 backdrop:backdrop-blur-[1px] {sizes[
73
77
  size
74
78
  ]} {className}"
75
79
  {...rest}
@@ -80,10 +84,12 @@
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-base font-semibold text-text">{title}</h2>
87
+ <h2 id="{uid}-title" class="font-heading text-base font-semibold text-text">{title}</h2>
84
88
  {/if}
85
89
  {#if description}
86
- <p class="font-sans text-[13px] text-text-muted">{description}</p>
90
+ <p id="{uid}-description" class="font-sans text-[13px] text-text-muted">
91
+ {description}
92
+ </p>
87
93
  {/if}
88
94
  </div>
89
95
  {#if closable}
@@ -232,7 +232,7 @@
232
232
  id="{id ?? 'multiselect'}-listbox"
233
233
  role="listbox"
234
234
  aria-multiselectable="true"
235
- class="z-50 max-h-64 overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg"
235
+ class="z-50 max-h-64 animate-reveal overflow-y-auto rounded-lg border border-hairline bg-bg py-1 font-sans shadow-lg"
236
236
  >
237
237
  {#if flat.length === 0}
238
238
  <p class="px-3 py-6 text-center text-sm text-text-muted">
@@ -111,7 +111,7 @@
111
111
  class="{link} {focusRing} {toneRing[tone]} {item.active
112
112
  ? toneSoft[tone]
113
113
  : 'text-text-secondary hover:bg-bg-inset hover:text-text'} {item.disabled
114
- ? 'pointer-events-none opacity-50'
114
+ ? 'pointer-events-none opacity-40'
115
115
  : ''}"
116
116
  >
117
117
  {#if item.icon}<span class={iconMd}>{@render item.icon()}</span>{/if}
@@ -164,7 +164,7 @@
164
164
  class="flex items-center justify-between gap-3 rounded-md px-3 py-2.5 font-sans text-sm font-medium {item.active
165
165
  ? `${toneSoft[tone]}`
166
166
  : 'text-text-secondary hover:bg-bg-inset'} {item.disabled
167
- ? 'pointer-events-none opacity-50'
167
+ ? 'pointer-events-none opacity-40'
168
168
  : ''}"
169
169
  >
170
170
  <span class="flex min-w-0 items-center gap-2.5">
@@ -77,7 +77,7 @@
77
77
  role="dialog"
78
78
  aria-label={title}
79
79
  tabindex="-1"
80
- class="z-50 flex max-w-xs flex-col gap-2 rounded-lg border border-hairline bg-bg p-4 font-sans shadow-lg {className}"
80
+ class="z-50 flex max-w-xs animate-reveal flex-col gap-2 rounded-lg border border-hairline bg-bg p-4 font-sans shadow-lg {className}"
81
81
  >
82
82
  {#if title}
83
83
  <p class="font-heading text-sm font-semibold text-text">{title}</p>
@@ -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-2.5 font-sans text-sm
31
- {disabled ? 'pointer-events-none opacity-50' : 'cursor-pointer'} {className}"
31
+ <div
32
+ class="inline-block font-sans text-sm {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 shadow-xs transition-colors duration-150 ease-brand-out
36
+ <label class="group inline-flex items-start gap-2.5 {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 shadow-xs 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-[13px] leading-snug text-text-muted">{description}</span>
52
- {/if}
68
+ id="{uid}-description"
69
+ class="mt-0.5 block pl-[28px] text-[13px] 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="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
62
+ {#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span><span
63
+ class="sr-only">(required)</span
64
+ >{/if}
54
65
  </legend>
55
66
  {/if}
56
67
 
@@ -94,8 +105,8 @@
94
105
  </div>
95
106
 
96
107
  {#if error}
97
- <p class="font-sans text-[13px] text-danger">{error}</p>
108
+ <p id="{uid}-error" role="alert" class="font-sans text-[13px] text-danger">{error}</p>
98
109
  {:else if hint}
99
- <p class="font-sans text-[13px] text-text-muted">{hint}</p>
110
+ <p id="{uid}-hint" class="font-sans text-[13px] text-text-muted">{hint}</p>
100
111
  {/if}
101
112
  </fieldset>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
- import { focusRing, toneRing, type Tone } from '../../core/tones.js';
4
+ import { focusRing, toneRing, toneText, type Tone } from '../../core/tones.js';
5
5
  import { iconMd } from './icon.js';
6
6
 
7
7
  export interface SegmentOption {
@@ -92,7 +92,7 @@
92
92
  onclick={() => select(option)}
93
93
  class="inline-flex items-center justify-center rounded-md font-sans font-medium whitespace-nowrap transition-all duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-40
94
94
  {iconOnly ? squares[size] : sizes[size]} {focusRing} {toneRing[tone]}
95
- {value === option.value ? 'bg-bg text-text shadow-xs' : 'text-text-muted hover:text-text'}"
95
+ {value === option.value ? `bg-bg ${toneText[tone]} shadow-xs` : 'text-text-muted hover:text-text'}"
96
96
  >
97
97
  {#if option.icon}<span class={iconMd}>{@render option.icon()}</span>{/if}
98
98
  {#if !iconOnly}{option.label}{/if}
@@ -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 rounded-md border bg-bg py-0 pr-9 pl-3 font-sans text-sm text-text shadow-xs 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 rounded-md border bg-bg py-0 pr-9 pl-3 font-sans text-sm text-text shadow-xs transition-colors duration-150 ease-brand-out disabled:pointer-events-none disabled:opacity-50
39
41
  {invalid ? 'border-danger' : `border-hairline-strong ${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 rounded-md 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}>
@@ -49,7 +57,21 @@
49
57
  </span>
50
58
  {#if delta}
51
59
  <span class="text-sm font-medium {toneText[trendTone[trend]]}">
52
- {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>
53
75
  {delta}
54
76
  </span>
55
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-sm">
58
61
  {#if label}<span class="leading-snug text-text">{label}</span>{/if}
59
62
  {#if description}
60
- <span class="text-[13px] leading-snug text-text-muted">{description}</span>
63
+ <span id="{uid}-description" class="text-[13px] 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-8 rounded-md px-3.5 ${
77
- active ? 'bg-bg text-text shadow-xs' : 'text-text-muted hover:text-text'
77
+ active ? `bg-bg ${toneText[tone]} shadow-xs` : 'text-text-muted 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 rounded-md border bg-bg px-3 py-2 font-sans text-sm text-text shadow-xs 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 rounded-md border bg-bg px-3 py-2 font-sans text-sm text-text shadow-xs 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-strong ${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
 
@@ -50,7 +66,7 @@
50
66
  would fight the page underneath.
51
67
  -->
52
68
  <div
53
- animate:flip={{ duration: 200 }}
69
+ animate:flip={{ duration: stillness ? 0 : 200 }}
54
70
  in:fly={{ ...enter, duration: 220 }}
55
71
  out:fly={{ ...enter, duration: 160 }}
56
72
  role={item.tone === 'danger' ? 'alert' : 'status'}
@@ -92,7 +108,7 @@
92
108
  </span>
93
109
 
94
110
  <div class="flex min-w-0 flex-1 flex-col gap-1">
95
- <p class="text-sm leading-snug font-semibold text-text">{item.title}</p>
111
+ <p class="font-heading text-sm leading-snug font-semibold text-text">{item.title}</p>
96
112
  {#if item.description}
97
113
  <p class="text-[13px] leading-relaxed text-text-secondary">{item.description}</p>
98
114
  {/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-1.5 -translate-x-1/2',
18
24
  bottom: 'top-full left-1/2 mt-1.5 -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 rounded-md bg-text px-2 py-1 font-sans text-xs font-medium whitespace-nowrap text-text-inverse opacity-0 shadow-lg 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';
@@ -29,10 +29,10 @@
29
29
  --color-text-inverse: #ffffff;
30
30
 
31
31
  /* --- brand / primary (indigo) --- */
32
- --color-brand: #4f46e5;
33
- --color-brand-hover: #4338ca;
34
- --color-brand-light: #eef2ff;
35
- --color-brand-border: #c7d2fe;
32
+ --color-brand: #15803d;
33
+ --color-brand-hover: #166534;
34
+ --color-brand-light: #e8f6ee;
35
+ --color-brand-border: #a7d9b9;
36
36
 
37
37
  /* --- accent (rose) --- */
38
38
  --color-accent: #e11d48;
@@ -95,9 +95,46 @@
95
95
  'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
96
96
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
97
97
 
98
- /* --- motion --- */
98
+ /* --- motion ---
99
+ * paper enters by rising and fading — depth is its language, so overlays
100
+ * settle onto the page rather than snapping or scaling in. One token per
101
+ * overlay kind: `enter` for dialog panels + backdrops, `reveal` for anchored
102
+ * menus and popovers. matte and sprout define these same two names their own
103
+ * way, so an overlay's markup never changes between styles — only the values.
104
+ */
99
105
  --ease-brand: cubic-bezier(0.4, 0, 0.2, 1);
100
106
  --ease-brand-out: cubic-bezier(0, 0, 0.2, 1);
107
+ --animate-enter: nqm-rise 200ms var(--ease-brand-out);
108
+ --animate-reveal: nqm-drop 180ms var(--ease-brand-out);
109
+ }
110
+
111
+ /* Enter-only, matching the Drawer: the native <dialog> and the {#if}-mounted
112
+ * popover each play these once on mount and leave instantly. */
113
+ @keyframes nqm-fade {
114
+ from {
115
+ opacity: 0;
116
+ }
117
+ }
118
+ @keyframes nqm-rise {
119
+ from {
120
+ opacity: 0;
121
+ /* dialogs rise and grow a hair — a gentle zoom onto the page */
122
+ transform: translateY(8px) scale(0.98);
123
+ /* one layer, matching the panel's shadow-2xl, so box-shadow interpolates
124
+ * to the resting shadow instead of snapping (a mismatched layer count is
125
+ * discrete). The shadow deepens as the panel settles. */
126
+ box-shadow: 0 8px 20px -12px rgb(0 0 0 / 0.18);
127
+ }
128
+ }
129
+ @keyframes nqm-drop {
130
+ from {
131
+ opacity: 0;
132
+ transform: translateY(-6px);
133
+ /* two layers, matching the menus' shadow-lg — same reason as above */
134
+ box-shadow:
135
+ 0 2px 6px -3px rgb(0 0 0 / 0.08),
136
+ 0 1px 3px -2px rgb(0 0 0 / 0.06);
137
+ }
101
138
  }
102
139
 
103
140
  /*
@@ -126,10 +163,10 @@
126
163
  --color-text-muted: #9ca3af;
127
164
  --color-text-inverse: #0b0f19;
128
165
 
129
- --color-brand: #818cf8;
130
- --color-brand-hover: #a5b4fc;
131
- --color-brand-light: #1e1b4b;
132
- --color-brand-border: #3730a3;
166
+ --color-brand: #46a06a;
167
+ --color-brand-hover: #5cb47e;
168
+ --color-brand-light: #12281b;
169
+ --color-brand-border: #255034;
133
170
 
134
171
  --color-accent: #fb7185;
135
172
  --color-accent-hover: #fda4af;
@@ -182,10 +219,10 @@
182
219
  --color-text-muted: #9ca3af;
183
220
  --color-text-inverse: #0b0f19;
184
221
 
185
- --color-brand: #818cf8;
186
- --color-brand-hover: #a5b4fc;
187
- --color-brand-light: #1e1b4b;
188
- --color-brand-border: #3730a3;
222
+ --color-brand: #46a06a;
223
+ --color-brand-hover: #5cb47e;
224
+ --color-brand-light: #12281b;
225
+ --color-brand-border: #255034;
189
226
 
190
227
  --color-accent: #fb7185;
191
228
  --color-accent-hover: #fda4af;
@@ -223,3 +260,38 @@
223
260
  --color-neutral-border: #374151;
224
261
  }
225
262
  }
263
+
264
+ /*
265
+ * The surfaces the browser draws for us.
266
+ *
267
+ * A caret and a highlight in the user-agent's blue belong to no design system,
268
+ * and they are the two the reader touches most: one sits in every field they
269
+ * type into, the other appears the moment they drag across a paragraph. The
270
+ * selection is mixed from the brand rather than pinned to a token so it stays
271
+ * a wash — the text underneath keeps its own colour and its own contrast.
272
+ */
273
+ @layer base {
274
+ /* The one overlay surface a class can't reach: every <dialog> backdrop
275
+ * fades with its panel. */
276
+ dialog::backdrop {
277
+ animation: nqm-fade 180ms var(--ease-brand-out);
278
+ }
279
+
280
+ @media (prefers-reduced-motion: reduce) {
281
+ .animate-enter,
282
+ .animate-reveal,
283
+ dialog::backdrop {
284
+ animation: none;
285
+ }
286
+ }
287
+
288
+ ::selection {
289
+ background-color: color-mix(in oklab, var(--color-brand) 24%, transparent);
290
+ }
291
+
292
+ input,
293
+ textarea,
294
+ [contenteditable] {
295
+ caret-color: var(--color-brand);
296
+ }
297
+ }
@@ -41,7 +41,7 @@
41
41
  const variants = $derived({
42
42
  soft: `${toneSurface[tone]} border ${toneBorderSoft[tone]}`,
43
43
  outline: `bg-bg ${edge} border ${toneBorderSoft[tone]}`,
44
- accent: `${toneSurface[tone]} border-l-4 ${toneBorder[tone]}`
44
+ accent: `${toneSurface[tone]} border-l-2 ${toneBorder[tone]}`
45
45
  });
46
46
 
47
47
  function dismiss() {
@@ -70,7 +70,7 @@
70
70
 
71
71
  <div class="flex min-w-0 flex-1 flex-col gap-1">
72
72
  {#if title}
73
- <p class="text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
73
+ <p class="font-heading text-sm leading-snug font-semibold {toneText[tone]}">{title}</p>
74
74
  {/if}
75
75
  {#if children}
76
76
  <div class="text-[13px] leading-relaxed text-text-secondary">{@render children()}</div>
@@ -82,7 +82,7 @@
82
82
  type="button"
83
83
  onclick={dismiss}
84
84
  aria-label="Dismiss"
85
- class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg/60 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
85
+ class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg/60 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
86
86
  >
87
87
  <svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
88
88
  <path
@@ -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-2xl' : 'rounded-full'} {sizes[size]} {src
69
+ {squared ? 'rounded-2xl' : '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}
@@ -30,7 +30,7 @@
30
30
  {#if item.href && i < items.length - 1}
31
31
  <a
32
32
  href={item.href}
33
- class="inline-flex items-center gap-1.5 rounded px-1 py-0.5 text-text-muted transition-colors duration-150 ease-brand-out hover:bg-bg-inset {toneHoverText[
33
+ class="inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-text-muted transition-colors duration-150 ease-brand-out hover:bg-bg-inset {toneHoverText[
34
34
  tone
35
35
  ]}"
36
36
  >