@lyeve-labs/ui-kit 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +27 -17
  2. package/dist/components/AccordionItem.svelte +2 -2
  3. package/dist/components/AccountMenu.svelte +1 -1
  4. package/dist/components/AppShell.svelte +22 -8
  5. package/dist/components/Autocomplete.svelte +13 -4
  6. package/dist/components/AvatarGroup.svelte +2 -2
  7. package/dist/components/Breadcrumb.svelte +1 -1
  8. package/dist/components/Button.svelte +34 -9
  9. package/dist/components/ButtonGroup.svelte +3 -3
  10. package/dist/components/Card.svelte +2 -1
  11. package/dist/components/Checkbox.svelte +1 -1
  12. package/dist/components/CheckboxGroup.svelte +1 -1
  13. package/dist/components/Collapsible.svelte +4 -4
  14. package/dist/components/CopyButton.svelte +1 -1
  15. package/dist/components/DatePicker.svelte +3 -3
  16. package/dist/components/DateTimePicker.svelte +1 -1
  17. package/dist/components/Drawer.svelte +7 -27
  18. package/dist/components/Dropdown.svelte +197 -11
  19. package/dist/components/Dropdown.svelte.d.ts +12 -0
  20. package/dist/components/Field.svelte +1 -1
  21. package/dist/components/Input.svelte +1 -1
  22. package/dist/components/Label.svelte +1 -1
  23. package/dist/components/Logo.svelte +77 -0
  24. package/dist/components/Logo.svelte.d.ts +35 -0
  25. package/dist/components/Modal.svelte +2 -15
  26. package/dist/components/MultiSelect.svelte +16 -7
  27. package/dist/components/NumberInput.svelte +3 -3
  28. package/dist/components/PageHeader.svelte +23 -1
  29. package/dist/components/Pagination.svelte +164 -65
  30. package/dist/components/Pagination.svelte.d.ts +39 -2
  31. package/dist/components/Panel.svelte +1 -1
  32. package/dist/components/PasswordInput.svelte +4 -4
  33. package/dist/components/Radio.svelte +1 -1
  34. package/dist/components/RadioGroup.svelte +1 -1
  35. package/dist/components/SearchInput.svelte +13 -4
  36. package/dist/components/SegmentedControl.svelte +3 -1
  37. package/dist/components/Select.svelte +6 -6
  38. package/dist/components/SidebarNav.svelte +20 -5
  39. package/dist/components/Table.svelte +256 -10
  40. package/dist/components/Table.svelte.d.ts +44 -0
  41. package/dist/components/Tabs.svelte +1 -1
  42. package/dist/components/Tag.svelte +1 -1
  43. package/dist/components/Textarea.svelte +1 -1
  44. package/dist/components/ThemeToggle.svelte +76 -13
  45. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  46. package/dist/components/TimePicker.svelte +2 -2
  47. package/dist/components/Toaster.svelte +3 -15
  48. package/dist/components/Toggle.svelte +10 -4
  49. package/dist/components/Tooltip.svelte +1 -1
  50. package/dist/components/TreeView.svelte +4 -4
  51. package/dist/components/dialog/Dialog.svelte +10 -5
  52. package/dist/components/dialog/dialog-manager.svelte.d.ts +20 -1
  53. package/dist/components/dialog/dialog-manager.svelte.js +13 -2
  54. package/dist/index.d.ts +5 -2
  55. package/dist/index.js +6 -2
  56. package/dist/internal/href.d.ts +13 -0
  57. package/dist/internal/href.js +27 -0
  58. package/dist/internal/layout.js +11 -2
  59. package/dist/internal/panel.d.ts +1 -1
  60. package/dist/internal/panel.js +2 -2
  61. package/dist/styles/theme.css +288 -0
  62. package/dist/utils/theme.d.ts +68 -3
  63. package/dist/utils/theme.js +118 -3
  64. package/package.json +20 -22
  65. package/src/lib/styles/theme.css +288 -0
@@ -1,4 +1,16 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * A menu behind a trigger of the caller's own.
4
+ *
5
+ * The roles were the whole implementation: `menu` and `menuitem` were stated
6
+ * and nothing else was. A screen reader takes those roles at their word and
7
+ * tells the user to arrow through the items, so it announced a keyboard
8
+ * contract the component did not keep, and the rows were left in the tab
9
+ * sequence instead. That is worse than a plain list of buttons, which at
10
+ * least announces what it is. The contract is kept here: focus moves into the
11
+ * menu when it opens, the arrows and Home and End move it between the items,
12
+ * and Escape and Tab both close and hand focus back to the trigger.
13
+ */
2
14
  import type { Component, Snippet } from 'svelte';
3
15
 
4
16
  interface DropdownItem {
@@ -23,52 +35,226 @@
23
35
 
24
36
  let open = $state(false);
25
37
  let containerEl: HTMLDivElement | undefined = $state();
38
+ let menuEl: HTMLDivElement | undefined = $state();
39
+ /** The item focus is resting on. Indexes `items`, disabled rows included. */
40
+ let active = $state(-1);
41
+
42
+ /**
43
+ * What counts as the trigger: the first thing in the tab sequence that is not
44
+ * inside the menu. The trigger is a snippet the caller fills, so there is no
45
+ * element here to bind to, and the rows are excluded by position rather than
46
+ * by selector because a caller can put anything focusable in that snippet.
47
+ */
48
+ const FOCUSABLE =
49
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
50
+
51
+ function triggerNode(): HTMLElement | null {
52
+ if (containerEl === undefined) return null;
53
+ for (const node of containerEl.querySelectorAll<HTMLElement>(FOCUSABLE)) {
54
+ if (menuEl?.contains(node) === true) continue;
55
+ return node;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function itemNodes(): HTMLButtonElement[] {
61
+ if (menuEl === undefined) return [];
62
+ return [...menuEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
63
+ }
64
+
65
+ function enabled(index: number): boolean {
66
+ return items[index] !== undefined && items[index].disabled !== true;
67
+ }
68
+
69
+ function first(): number {
70
+ for (let i = 0; i < items.length; i++) if (enabled(i)) return i;
71
+ return -1;
72
+ }
73
+
74
+ function last(): number {
75
+ for (let i = items.length - 1; i >= 0; i--) if (enabled(i)) return i;
76
+ return -1;
77
+ }
78
+
79
+ /**
80
+ * The next item in one direction, wrapping past the ends.
81
+ *
82
+ * A menu wraps where a listbox does not: arrowing down from the last item
83
+ * reaches the first, which is what the pattern says and what a reader that
84
+ * has just been told this is a menu will expect. Disabled rows are stepped
85
+ * over, because a disabled button refuses focus and Enter refuses to run it.
86
+ */
87
+ function step(from: number, direction: 1 | -1): number {
88
+ const count = items.length;
89
+ if (count === 0) return -1;
90
+ let cursor = from < 0 ? (direction === 1 ? -1 : 0) : from;
91
+ for (let taken = 0; taken < count; taken++) {
92
+ cursor = (cursor + direction + count) % count;
93
+ if (enabled(cursor)) return cursor;
94
+ }
95
+ return -1;
96
+ }
97
+
98
+ function openMenu(index: number): void {
99
+ active = index;
100
+ open = true;
101
+ }
102
+
103
+ function closeMenu(): void {
104
+ open = false;
105
+ active = -1;
106
+ }
107
+
108
+ /**
109
+ * Closes and puts focus back where the user left it.
110
+ *
111
+ * Focus is on a row that is about to be unmounted, so without this it falls
112
+ * to the body and the next Tab starts again from the top of the page.
113
+ */
114
+ function closeAndReturn(): void {
115
+ const back = triggerNode();
116
+ closeMenu();
117
+ back?.focus();
118
+ }
26
119
 
27
120
  function toggle() {
28
- open = !open;
121
+ if (open) closeMenu();
122
+ else openMenu(first());
29
123
  }
30
124
 
31
125
  function handleItemClick(item: DropdownItem) {
32
126
  if (item.disabled) return;
33
- open = false;
127
+ const back = triggerNode();
128
+ closeMenu();
129
+ back?.focus();
130
+ // Focus is settled before the action runs: the callback may open a dialog
131
+ // that moves focus itself, and it must not be moved again afterwards.
34
132
  item.onclick();
35
133
  }
36
134
 
37
135
  function handleOutsideClick(e: MouseEvent) {
38
- if (containerEl && !containerEl.contains(e.target as Node)) open = false;
136
+ // No focus is returned here. The pointer has already chosen where to go.
137
+ if (containerEl && !containerEl.contains(e.target as Node)) closeMenu();
138
+ }
139
+
140
+ /**
141
+ * The last resort for Escape, for the case where focus has left the menu
142
+ * without closing it. The handler below consumes the key whenever focus is
143
+ * still inside, so the two never both fire.
144
+ */
145
+ function handleDocumentKeydown(e: KeyboardEvent) {
146
+ if (e.key === 'Escape') closeAndReturn();
147
+ }
148
+
149
+ function onKeydown(event: KeyboardEvent) {
150
+ if (!open) {
151
+ // The menu-button pattern: either arrow opens the menu, one at the top of
152
+ // the list and the other at the bottom.
153
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
154
+ event.preventDefault();
155
+ openMenu(event.key === 'ArrowDown' ? first() : last());
156
+ return;
157
+ }
158
+
159
+ switch (event.key) {
160
+ case 'ArrowDown':
161
+ event.preventDefault();
162
+ active = step(active, 1);
163
+ break;
164
+
165
+ case 'ArrowUp':
166
+ event.preventDefault();
167
+ active = step(active, -1);
168
+ break;
169
+
170
+ case 'Home':
171
+ event.preventDefault();
172
+ active = first();
173
+ break;
174
+
175
+ case 'End':
176
+ event.preventDefault();
177
+ active = last();
178
+ break;
179
+
180
+ case 'Escape':
181
+ event.preventDefault();
182
+ // Stopped, or one press closes both this menu and the modal holding it.
183
+ event.stopPropagation();
184
+ closeAndReturn();
185
+ break;
186
+
187
+ case 'Tab':
188
+ // Never preventDefault: Tab is how focus leaves the menu. Focus is
189
+ // moved to the trigger first and synchronously, so the browser walks on
190
+ // from there rather than from a row it is about to lose.
191
+ closeAndReturn();
192
+ break;
193
+
194
+ default:
195
+ break;
196
+ }
39
197
  }
40
198
 
41
- function handleKeydown(e: KeyboardEvent) {
42
- if (e.key === 'Escape') open = false;
199
+ /**
200
+ * The wrapper's keydown, registered as an action rather than written on the
201
+ * element.
202
+ *
203
+ * The keys have to be taken above the trigger, because the trigger is a
204
+ * snippet the caller fills and a closed menu is opened from it. Written on
205
+ * the element, that reads to the compiler as a plain div being made
206
+ * interactive without a role, which is a defect everywhere except here: the
207
+ * role that describes this subtree, `menu`, is on the panel inside it, and
208
+ * the wrapper is not the thing being operated.
209
+ */
210
+ function keys(node: HTMLElement): { destroy(): void } {
211
+ node.addEventListener('keydown', onKeydown);
212
+ return {
213
+ destroy() {
214
+ node.removeEventListener('keydown', onKeydown);
215
+ },
216
+ };
43
217
  }
44
218
 
45
219
  $effect(() => {
46
220
  if (open) {
47
221
  document.addEventListener('click', handleOutsideClick, { capture: true });
48
- document.addEventListener('keydown', handleKeydown);
222
+ document.addEventListener('keydown', handleDocumentKeydown);
49
223
  }
50
224
  return () => {
51
225
  document.removeEventListener('click', handleOutsideClick, { capture: true });
52
- document.removeEventListener('keydown', handleKeydown);
226
+ document.removeEventListener('keydown', handleDocumentKeydown);
53
227
  };
54
228
  });
229
+
230
+ /*
231
+ * Roving focus, as real focus rather than as aria-activedescendant: the rows
232
+ * are buttons the user activates with Enter and Space, and a button that is
233
+ * described as focused without being focused does not take either key.
234
+ */
235
+ $effect(() => {
236
+ if (!open) return;
237
+ itemNodes()[active]?.focus();
238
+ });
55
239
  </script>
56
240
 
57
- <div bind:this={containerEl} class="relative inline-block {cls}">
241
+ <div bind:this={containerEl} use:keys class="relative inline-block {cls}">
58
242
  {@render trigger({ open, toggle })}
59
243
 
60
244
  {#if open}
61
245
  <div
246
+ bind:this={menuEl}
62
247
  role="menu"
63
- class="absolute z-50 mt-1 py-1 min-w-36 rounded-xl border border-line
248
+ class="absolute z-dropdown mt-1 py-1 min-w-36 rounded-xl border border-line
64
249
  bg-surface shadow-2xl
65
- {align === 'right' ? 'right-0' : 'left-0'}"
250
+ {align === 'right' ? 'end-0' : 'start-0'}"
66
251
  >
67
- {#each items as item}
252
+ {#each items as item, index}
68
253
  <button
69
254
  type="button"
70
255
  role="menuitem"
71
256
  disabled={item.disabled}
257
+ tabindex={index === active ? 0 : -1}
72
258
  onclick={() => handleItemClick(item)}
73
259
  class="w-full flex items-center gap-2.5 px-3 py-2 text-sm transition-colors duration-150
74
260
  disabled:opacity-40 disabled:cursor-not-allowed
@@ -1,3 +1,15 @@
1
+ /**
2
+ * A menu behind a trigger of the caller's own.
3
+ *
4
+ * The roles were the whole implementation: `menu` and `menuitem` were stated
5
+ * and nothing else was. A screen reader takes those roles at their word and
6
+ * tells the user to arrow through the items, so it announced a keyboard
7
+ * contract the component did not keep, and the rows were left in the tab
8
+ * sequence instead. That is worse than a plain list of buttons, which at
9
+ * least announces what it is. The contract is kept here: focus moves into the
10
+ * menu when it opens, the arrows and Home and End move it between the items,
11
+ * and Escape and Tab both close and hand focus back to the trigger.
12
+ */
1
13
  import type { Component, Snippet } from 'svelte';
2
14
  interface DropdownItem {
3
15
  label: string;
@@ -90,7 +90,7 @@
90
90
  the controls with nothing ahead of them saying what each one is.
91
91
  -->
92
92
  <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
93
- {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
93
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
94
94
  </label>
95
95
  {/if}
96
96
 
@@ -54,7 +54,7 @@
54
54
  <div class="{FIELD_WRAP} {klass}">
55
55
  {#if label}
56
56
  <label for={fieldId} class={FIELD_LABEL}>
57
- {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
57
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
58
58
  </label>
59
59
  {/if}
60
60
 
@@ -31,6 +31,6 @@
31
31
  <span class="text-danger font-bold leading-none" aria-hidden="true">*</span>
32
32
  {/if}
33
33
  {#if hint}
34
- <span class="text-faint normal-case font-normal ml-0.5">({hint})</span>
34
+ <span class="text-faint normal-case font-normal ms-0.5">({hint})</span>
35
35
  {/if}
36
36
  </label>
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The product mark, and the wordmark beside it.
4
+ *
5
+ * Two applications each carried their own copy of this lockup and the two had
6
+ * drifted: different box sizes, different wordmark weights, and one of them
7
+ * painting the mark from hex behind a prefers-color-scheme query. That query
8
+ * asks the operating system, while the apps theme off `data-theme` on the
9
+ * html element, so the two disagreed for anyone who chose a theme their
10
+ * desktop was not already set to: a light desktop with the app in dark mode
11
+ * drew a near-black column on a dark surface. There is no media query here
12
+ * for that reason, and none belongs here.
13
+ *
14
+ * The neutral faces come from currentColor and the lit faces from the brand
15
+ * ramp, so every face answers the same tokens as the rest of the page and a
16
+ * palette change reaches the mark with everything else.
17
+ */
18
+
19
+ type Size = 'sm' | 'md' | 'lg';
20
+
21
+ interface Props {
22
+ /**
23
+ * Named rather than a pixel count. The two copies this replaces shipped at
24
+ * 28px and 36px with different wordmark weights, and a free number is how
25
+ * the third one drifts too.
26
+ */
27
+ size?: Size;
28
+ /**
29
+ * False renders the mark alone, for a slot too narrow to carry the name.
30
+ * The mark is decoration and is hidden from a screen reader either way, so
31
+ * a link holding only the mark has to carry its own name.
32
+ */
33
+ wordmark?: boolean;
34
+ class?: string;
35
+ }
36
+
37
+ let { size = 'md', wordmark = true, class: klass = '' }: Props = $props();
38
+
39
+ /*
40
+ * The box is a width and a height attribute, not a utility class: the two
41
+ * dimensions are a runtime value, and a class assembled from one matches no
42
+ * candidate in Tailwind's scan and silently does nothing.
43
+ */
44
+ const marks: Record<Size, number> = { sm: 24, md: 28, lg: 36 };
45
+ const words: Record<Size, string> = { sm: 'text-sm', md: 'text-base', lg: 'text-xl' };
46
+
47
+ const px = $derived(marks[size]);
48
+ </script>
49
+
50
+ <!-- text-fg is stated here rather than inherited. The mark is usually wrapped
51
+ in a link that carries no colour of its own, so currentColor would
52
+ otherwise be whatever that ancestor happened to hold. -->
53
+ <span class="inline-flex items-center gap-2 text-fg {klass}">
54
+ <span class="inline-flex shrink-0 items-center justify-center">
55
+ <svg
56
+ width={px}
57
+ height={px}
58
+ viewBox="0 0 72 64"
59
+ xmlns="http://www.w3.org/2000/svg"
60
+ aria-hidden="true"
61
+ >
62
+ <g transform="translate(6 4)">
63
+ <polygon class="fill-current" points="0,14 14,7 14,40 0,47" />
64
+ <polygon class="fill-current opacity-50" points="0,47 14,40 14,54 0,61" />
65
+ <polygon class="fill-current opacity-75" points="14,40 28,33 28,47 14,54" />
66
+ <polygon class="fill-brand-light" points="14,54 28,47 56,33 42,40" />
67
+ <polygon class="fill-brand-deep" points="42,40 56,33 56,47 42,54" />
68
+ <polygon class="fill-brand" points="14,54 28,61 42,54 28,47" />
69
+ <polygon class="fill-brand" points="28,47 42,54 56,47 42,40" />
70
+ </g>
71
+ </svg>
72
+ </span>
73
+
74
+ {#if wordmark}
75
+ <span class="font-semibold tracking-tight {words[size]}">LyEve</span>
76
+ {/if}
77
+ </span>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The product mark, and the wordmark beside it.
3
+ *
4
+ * Two applications each carried their own copy of this lockup and the two had
5
+ * drifted: different box sizes, different wordmark weights, and one of them
6
+ * painting the mark from hex behind a prefers-color-scheme query. That query
7
+ * asks the operating system, while the apps theme off `data-theme` on the
8
+ * html element, so the two disagreed for anyone who chose a theme their
9
+ * desktop was not already set to: a light desktop with the app in dark mode
10
+ * drew a near-black column on a dark surface. There is no media query here
11
+ * for that reason, and none belongs here.
12
+ *
13
+ * The neutral faces come from currentColor and the lit faces from the brand
14
+ * ramp, so every face answers the same tokens as the rest of the page and a
15
+ * palette change reaches the mark with everything else.
16
+ */
17
+ type Size = 'sm' | 'md' | 'lg';
18
+ interface Props {
19
+ /**
20
+ * Named rather than a pixel count. The two copies this replaces shipped at
21
+ * 28px and 36px with different wordmark weights, and a free number is how
22
+ * the third one drifts too.
23
+ */
24
+ size?: Size;
25
+ /**
26
+ * False renders the mark alone, for a slot too narrow to carry the name.
27
+ * The mark is decoration and is hidden from a screen reader either way, so
28
+ * a link holding only the mark has to carry its own name.
29
+ */
30
+ wordmark?: boolean;
31
+ class?: string;
32
+ }
33
+ declare const Logo: import("svelte").Component<Props, {}, "">;
34
+ type Logo = ReturnType<typeof Logo>;
35
+ export default Logo;
@@ -51,7 +51,7 @@
51
51
  <svelte:window onkeydown={open ? onkeydown : undefined} />
52
52
 
53
53
  {#if open}
54
- <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
54
+ <div class="fixed inset-0 z-modal flex items-center justify-center p-4">
55
55
  <button
56
56
  type="button"
57
57
  tabindex="-1"
@@ -64,7 +64,7 @@
64
64
  use:overlay
65
65
  class="relative flex max-h-[calc(100dvh-2rem)] w-full {widths[size]} flex-col
66
66
  overflow-hidden rounded-xl border border-line bg-surface shadow-2xl
67
- animate-[modal-in_120ms_ease-out]"
67
+ animate-[modal-in_var(--duration-modal-in)_ease-out]"
68
68
  role="dialog"
69
69
  aria-modal="true"
70
70
  aria-labelledby={title ? headingId : undefined}
@@ -114,16 +114,3 @@
114
114
  </div>
115
115
  </div>
116
116
  {/if}
117
-
118
- <style>
119
- @keyframes modal-in {
120
- from {
121
- opacity: 0;
122
- transform: translateY(6px) scale(0.98);
123
- }
124
- to {
125
- opacity: 1;
126
- transform: translateY(0) scale(1);
127
- }
128
- }
129
- </style>
@@ -154,7 +154,7 @@
154
154
  <div class="{FIELD_WRAP} {cls}">
155
155
  {#if label}
156
156
  <label id="{fieldId}-label" for={fieldId} class={FIELD_LABEL}>
157
- {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
157
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
158
158
  </label>
159
159
  {/if}
160
160
 
@@ -181,7 +181,7 @@
181
181
  onkeydown={onTriggerKeydown}
182
182
  {...box.triggerAttrs}
183
183
  class="flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 rounded-lg
184
- border bg-surface-2 px-2.5 py-1.5 text-left text-sm transition-colors duration-150
184
+ border bg-surface-2 px-2.5 py-1.5 text-start text-sm transition-colors duration-150
185
185
  outline-none {disabled ? 'cursor-not-allowed opacity-50' : ''} {controlBorder(!!error)}"
186
186
  >
187
187
  {#if selected.length === 0}
@@ -199,11 +199,20 @@
199
199
  event.stopPropagation();
200
200
  remove(option.value);
201
201
  }}
202
- class="transition-colors duration-150 hover:text-brand-light"
202
+ class="-m-1.5 p-1.5 transition-colors duration-150 hover:text-brand-light"
203
203
  >
204
+ <!--
205
+ 12px inside 6px of padding is a 24px target, which is the floor
206
+ SC 2.5.8 puts on a control that cannot claim the spacing
207
+ exception, and this one sits inside the trigger. The padding is
208
+ taken straight back off as margin, so the chip is laid out
209
+ exactly as it was and only the reachable area grew. The glyph
210
+ is the same 12px cross Autocomplete clears its value with; it
211
+ was drawn a pixel smaller here for no reason anyone recorded.
212
+ -->
204
213
  <svg
205
- width="11"
206
- height="11"
214
+ width="12"
215
+ height="12"
207
216
  viewBox="0 0 24 24"
208
217
  fill="none"
209
218
  stroke="currentColor"
@@ -217,7 +226,7 @@
217
226
  </span>
218
227
  {/each}
219
228
  {/if}
220
- <span class="ml-auto shrink-0 text-faint" aria-hidden="true">
229
+ <span class="ms-auto shrink-0 text-faint" aria-hidden="true">
221
230
  <svg
222
231
  width="12"
223
232
  height="12"
@@ -306,7 +315,7 @@
306
315
  </svg>
307
316
  {/if}
308
317
  </span>
309
- <span class="min-w-0 truncate text-left">{option.label}</span>
318
+ <span class="min-w-0 truncate text-start">{option.label}</span>
310
319
  </button>
311
320
  {:else}
312
321
  <p class={PANEL_EMPTY}>No matches</p>
@@ -79,7 +79,7 @@
79
79
  <div class="{FIELD_WRAP} {cls}">
80
80
  {#if label}
81
81
  <label for={fieldId} class={FIELD_LABEL}>
82
- {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
82
+ {label}{#if required}<span class="text-danger ms-0.5" aria-hidden="true">*</span>{/if}
83
83
  </label>
84
84
  {/if}
85
85
 
@@ -89,7 +89,7 @@
89
89
  onclick={dec}
90
90
  disabled={!canDec}
91
91
  aria-label="Decrease"
92
- class="{step_} rounded-l-lg border border-r-0"
92
+ class="{step_} rounded-s-lg border border-e-0"
93
93
  >
94
94
  <svg
95
95
  width="14"
@@ -132,7 +132,7 @@
132
132
  onclick={inc}
133
133
  disabled={!canInc}
134
134
  aria-label="Increase"
135
- class="{step_} rounded-r-lg border border-l-0"
135
+ class="{step_} rounded-e-lg border border-s-0"
136
136
  >
137
137
  <svg
138
138
  width="14"
@@ -43,9 +43,31 @@
43
43
  <div class="min-w-0">
44
44
  <!-- The app shells render their own h1 for the current route, so a heading
45
45
  lookup by name matches two elements. This names the page's own title. -->
46
+ <!--
47
+ The page title is the one place the brand type scale had a component to
48
+ land on and did not: eighteen type tokens shipped and nothing referenced
49
+ one of them, while this rendered at 24px, a size the ramp does not
50
+ contain.
51
+
52
+ text-h2 and not text-h1. The brand ramp is a marketing ramp - it runs
53
+ 64 / 44 / 32 / 22 / 16 and holds nothing between 16 and 22, which is
54
+ where a console's own type lives. A 44px page title over a 14px table on
55
+ a 390px screen is the ramp applied rather than adopted. 32px is the step
56
+ the ramp does hold and the step this was already reaching for.
57
+
58
+ The weight stays at 700, which is the ramp's H1 weight, because this is
59
+ still the page's h1. Taking H2's 600 along with its size would move the
60
+ title twice for one decision.
61
+
62
+ Each size token names its own leading and tracking. A `text-*` token
63
+ built from `--text-*` alone sets font-size and leaves line-height to
64
+ whatever it inherits, which is not the ratio the ramp specifies.
65
+ -->
46
66
  <h1
47
67
  data-testid="page-title"
48
- class={compact ? 'text-sm font-semibold text-fg' : 'text-2xl font-bold text-fg'}
68
+ class={compact
69
+ ? 'text-sm font-semibold text-fg'
70
+ : 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}
49
71
  >
50
72
  {title}
51
73
  </h1>