@ultimat3/ui 1.0.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/package.json +45 -0
  4. package/src/a11y.ts +187 -0
  5. package/src/components/Alert.module.scss +70 -0
  6. package/src/components/Alert.tsx +49 -0
  7. package/src/components/Avatar.module.scss +66 -0
  8. package/src/components/Avatar.tsx +56 -0
  9. package/src/components/Badge.module.scss +94 -0
  10. package/src/components/Badge.tsx +34 -0
  11. package/src/components/Breadcrumb.module.scss +45 -0
  12. package/src/components/Breadcrumb.tsx +48 -0
  13. package/src/components/Button.module.scss +146 -0
  14. package/src/components/Button.tsx +66 -0
  15. package/src/components/Card.module.scss +69 -0
  16. package/src/components/Card.tsx +40 -0
  17. package/src/components/Checkbox.module.scss +90 -0
  18. package/src/components/Checkbox.tsx +57 -0
  19. package/src/components/Container.module.scss +31 -0
  20. package/src/components/Container.tsx +29 -0
  21. package/src/components/DataTable.module.scss +33 -0
  22. package/src/components/DataTable.tsx +149 -0
  23. package/src/components/DateTime.module.scss +9 -0
  24. package/src/components/DateTime.tsx +45 -0
  25. package/src/components/Dialog.module.scss +83 -0
  26. package/src/components/Dialog.tsx +75 -0
  27. package/src/components/Divider.module.scss +39 -0
  28. package/src/components/Divider.tsx +38 -0
  29. package/src/components/Drawer.module.scss +89 -0
  30. package/src/components/Drawer.tsx +76 -0
  31. package/src/components/EmptyState.module.scss +34 -0
  32. package/src/components/EmptyState.tsx +35 -0
  33. package/src/components/ErrorState.module.scss +77 -0
  34. package/src/components/ErrorState.tsx +84 -0
  35. package/src/components/Field.module.scss +41 -0
  36. package/src/components/Field.tsx +86 -0
  37. package/src/components/Form.module.scss +21 -0
  38. package/src/components/Form.tsx +51 -0
  39. package/src/components/Grid.module.scss +9 -0
  40. package/src/components/Grid.tsx +40 -0
  41. package/src/components/IconButton.module.scss +99 -0
  42. package/src/components/IconButton.tsx +53 -0
  43. package/src/components/Image.module.scss +14 -0
  44. package/src/components/Image.tsx +59 -0
  45. package/src/components/Input.module.scss +55 -0
  46. package/src/components/Input.tsx +84 -0
  47. package/src/components/Link.module.scss +42 -0
  48. package/src/components/Link.tsx +46 -0
  49. package/src/components/LocaleSwitcher.module.scss +28 -0
  50. package/src/components/LocaleSwitcher.tsx +61 -0
  51. package/src/components/Menu.module.scss +63 -0
  52. package/src/components/Menu.tsx +115 -0
  53. package/src/components/Money.module.scss +11 -0
  54. package/src/components/Money.tsx +49 -0
  55. package/src/components/Pagination.module.scss +15 -0
  56. package/src/components/Pagination.tsx +77 -0
  57. package/src/components/Popover.module.scss +59 -0
  58. package/src/components/Popover.tsx +76 -0
  59. package/src/components/Radio.module.scss +87 -0
  60. package/src/components/Radio.tsx +64 -0
  61. package/src/components/RelativeTime.module.scss +7 -0
  62. package/src/components/RelativeTime.tsx +49 -0
  63. package/src/components/Select.module.scss +51 -0
  64. package/src/components/Select.tsx +63 -0
  65. package/src/components/Skeleton.module.scss +41 -0
  66. package/src/components/Skeleton.tsx +34 -0
  67. package/src/components/Spinner.module.scss +33 -0
  68. package/src/components/Spinner.tsx +35 -0
  69. package/src/components/Stack.module.scss +21 -0
  70. package/src/components/Stack.tsx +49 -0
  71. package/src/components/Switch.module.scss +66 -0
  72. package/src/components/Switch.tsx +44 -0
  73. package/src/components/Table.module.scss +77 -0
  74. package/src/components/Table.tsx +42 -0
  75. package/src/components/Tabs.module.scss +71 -0
  76. package/src/components/Tabs.tsx +88 -0
  77. package/src/components/Text.module.scss +37 -0
  78. package/src/components/Text.tsx +57 -0
  79. package/src/components/Textarea.module.scss +15 -0
  80. package/src/components/Textarea.tsx +48 -0
  81. package/src/components/ThemeToggle.module.scss +23 -0
  82. package/src/components/ThemeToggle.tsx +93 -0
  83. package/src/components/Toast.module.scss +92 -0
  84. package/src/components/Toast.tsx +74 -0
  85. package/src/components/Tooltip.module.scss +41 -0
  86. package/src/components/Tooltip.tsx +32 -0
  87. package/src/components/date-time-view.ts +68 -0
  88. package/src/components/image-source.ts +150 -0
  89. package/src/components/money-view.ts +50 -0
  90. package/src/components/relative-time-view.ts +45 -0
  91. package/src/components/sort-state.ts +28 -0
  92. package/src/components/variants.ts +17 -0
  93. package/src/cx.ts +42 -0
  94. package/src/errors.ts +72 -0
  95. package/src/global.scss +5 -0
  96. package/src/i18n-keys.ts +34 -0
  97. package/src/index.ts +199 -0
  98. package/src/scss.d.ts +7 -0
  99. package/src/theme/context.ts +65 -0
  100. package/src/theme/inline-script.ts +44 -0
  101. package/src/theme/provider.tsx +56 -0
  102. package/src/theme/solid-adapter.ts +51 -0
  103. package/src/theme/theme.ts +116 -0
  104. package/src/tokens/_breakpoints.scss +10 -0
  105. package/src/tokens/_colors.scss +95 -0
  106. package/src/tokens/_index.scss +13 -0
  107. package/src/tokens/_mixins.scss +186 -0
  108. package/src/tokens/_motion.scss +36 -0
  109. package/src/tokens/_radius.scss +23 -0
  110. package/src/tokens/_shadow.scss +49 -0
  111. package/src/tokens/_space.scss +28 -0
  112. package/src/tokens/_typography.scss +83 -0
  113. package/src/tokens/_z.scss +27 -0
  114. package/src/tokens/reset.scss +144 -0
  115. package/src/tokens/theme.scss +19 -0
  116. package/src/tokens/tokens.ts +222 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @ultimat3/ui 🎨
2
+
3
+ SolidJS design system. Semantic tokens, SCSS modules, dark + RTL by construction.
4
+
5
+ ## Token roles
6
+
7
+ Every colour in every component is one of these, stored as **space-separated RGB
8
+ channels** so `rgb(var(--color-accent) / 0.12)` gives a tint with no extra token.
9
+
10
+ | Role | Use |
11
+ |---|---|
12
+ | `bg` / `bg-soft` | page background; subtle zones, hovers, table headers |
13
+ | `surface` / `surface-raised` | cards and sheets; popovers, dialogs, inputs above them |
14
+ | `fg` / `fg-strong` / `fg-muted` | body text; headings; captions and placeholders |
15
+ | `line` | borders, dividers |
16
+ | `scrim` | modal backdrops |
17
+ | `accent` / `accent-strong` / `accent-fg` | primary action, its hover, text on it |
18
+ | `success` / `warning` / `danger` / `info` | status solid, each with a `-soft` tint and a `-fg` text-on-solid |
19
+
20
+ Scales: `--space-*` (4px base), `--text-*` (fluid `clamp()`), `--radius-*`,
21
+ `--shadow-*` (themed — dark gets deeper, higher-alpha shadows), `--duration-*`,
22
+ `--easing-*`, `--z-*` (named ladder, no magic numbers).
23
+
24
+ ## The law
25
+
26
+ | Rule | Enforcement |
27
+ |---|---|
28
+ | No raw colours | a hex or `rgb()` literal in a component stylesheet fails review; `tokens.test.ts` asserts the shared SCSS is hex-free |
29
+ | No Tailwind | not a dependency, not a config, not an escape hatch |
30
+ | No CSS-in-JS | styles are `Foo.module.scss` next to `Foo.tsx`, compiled at build |
31
+ | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet |
32
+ | No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
33
+ | One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
34
+
35
+ ## Example
36
+
37
+ ```tsx
38
+ import { Button, Field, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
39
+ import '@ultimat3/ui/global.scss';
40
+
41
+ setSolidRuntime(await import('solid-js')); // once, in the app entry
42
+
43
+ <UiProvider locale="ar-EG" timeZone="Africa/Cairo" currency="EGP" t={t}>
44
+ <Field label={t('signup.email')} hint={t('signup.email.hint')} error={errors.email}>
45
+ {(control) => <Input {...control} type="email" autocomplete="email" />}
46
+ </Field>
47
+ <Button tone="accent" loading={pending()}>{t('signup.submit')}</Button>
48
+ </UiProvider>
49
+ ```
50
+
51
+ `Field` owns the ids, so `aria-describedby` / `aria-invalid` can never drift from
52
+ what is rendered. `UiProvider` sets `lang` + `dir` on `<html>`; nothing else is
53
+ needed to make that form correct in Arabic.
54
+
55
+ ## `<Text>` and `<Image>`
56
+
57
+ `<Text>` is the typography primitive. Unset `size` and `weight` inherit, so it is
58
+ transparent inside a heading or a caption.
59
+
60
+ | Prop | Values |
61
+ |---|---|
62
+ | `tone` | `default` `muted` `accent` `success` `warning` `danger` `info` — the `fg` / `fg-muted` / status roles |
63
+ | `size` | keys of `fontSizeTokens`: `xs` … `3xl` |
64
+ | `weight` | keys of `fontWeightTokens`: `normal` `medium` `semibold` `bold` |
65
+ | `as` | `span` (default) `p` `div` `strong` `em` |
66
+
67
+ `<Image>` is one `<img>` — no JS, no fetch, no client state. `alt` is a required
68
+ prop, so a missing description is a type error rather than a review comment.
69
+
70
+ | Prop | Emitted |
71
+ |---|---|
72
+ | `variants` | `srcset`, descriptors derived and ordered ascending (`srcsetFor`) |
73
+ | `sizes` | `sizes`, verbatim |
74
+ | `priority` | `loading="eager"` + `fetchpriority="high"`; otherwise `lazy` + `auto`, always `decoding="async"` |
75
+ | `width` + `height` | inlined attributes — both or neither, so the ratio is always reservable |
76
+
77
+ Shipped here: the element. Measuring intrinsic dimensions, encoding AVIF/WebP
78
+ renditions and the data-URI blur placeholder are build-pipeline steps
79
+ ([`docs/idea/07-rendering-seo.md`](../../docs/idea/07-rendering-seo.md)), not part
80
+ of this package. The component emits what it is handed and fabricates nothing —
81
+ no variants it was not given, no dimensions it did not measure.
82
+
83
+ ## Theme resolution
84
+
85
+ `explicit choice in localStorage` → `OS preference`. `setTheme()` persists,
86
+ `clearTheme()` forgets and follows the OS again, and the OS listener only applies
87
+ while nothing is stored. `themeInlineScriptTag()` goes in `<head>` and applies the
88
+ result before first paint, so there is no flash and screenshots are deterministic:
89
+
90
+ ```
91
+ Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCspSource()
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ | Code | When |
97
+ |---|---|
98
+ | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define |
99
+ | `X_THEME_INVALID` | a theme other than `light` / `dark` |
100
+ | `X_UI_RUNTIME_MISSING` | reactive context or DOM APIs used where they do not exist |
101
+ | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other |
102
+
103
+ ## Commands
104
+
105
+ ```
106
+ bun test # token parity, theme resolution, cx, a11y, formatting cores
107
+ bun run typecheck
108
+ ```
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@ultimat3/ui",
3
+ "version": "1.0.0",
4
+ "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/developerz-ai/ultimate.git",
10
+ "directory": "packages/ui"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts",
18
+ "./tokens": "./src/tokens/_index.scss",
19
+ "./theme.scss": "./src/tokens/theme.scss",
20
+ "./reset.scss": "./src/tokens/reset.scss",
21
+ "./global.scss": "./src/global.scss"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "engines": {
30
+ "bun": ">=1.3.0"
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "test": "bun test"
35
+ },
36
+ "dependencies": {
37
+ "@ultimat3/core": "1.0.0",
38
+ "@ultimat3/i18n": "1.0.0",
39
+ "@ultimat3/money": "1.0.0",
40
+ "@ultimat3/time": "1.0.0"
41
+ },
42
+ "peerDependencies": {
43
+ "solid-js": "^2.0.0"
44
+ }
45
+ }
package/src/a11y.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Keyboard and screen-reader plumbing shared by the interactive components.
2
+ // The decision logic is pure (and tested); only the thin shells touch the DOM.
3
+
4
+ import type { Direction } from '@ultimat3/i18n';
5
+
6
+ export const FOCUSABLE_SELECTOR = [
7
+ 'a[href]',
8
+ 'area[href]',
9
+ 'button:not([disabled])',
10
+ 'input:not([disabled]):not([type="hidden"])',
11
+ 'select:not([disabled])',
12
+ 'textarea:not([disabled])',
13
+ 'summary',
14
+ 'iframe',
15
+ 'audio[controls]',
16
+ 'video[controls]',
17
+ '[contenteditable]:not([contenteditable="false"])',
18
+ '[tabindex]:not([tabindex="-1"])',
19
+ ].join(',');
20
+
21
+ let idCounter = 0;
22
+
23
+ /**
24
+ * Stable-per-render unique id for label/description wiring. Prefixed so a
25
+ * hydration mismatch is obvious in the DOM rather than silent.
26
+ */
27
+ export function useId(prefix = 'u'): string {
28
+ idCounter += 1;
29
+ return `${prefix}-${idCounter.toString(36)}`;
30
+ }
31
+
32
+ /** Test-only: make id assertions deterministic. */
33
+ export function resetIdCounter(): void {
34
+ idCounter = 0;
35
+ }
36
+
37
+ /**
38
+ * `aria-busy`, `aria-checked`, `aria-disabled`, `aria-expanded`, `aria-invalid`,
39
+ * `aria-pressed` and `aria-selected` are ENUMERATED attributes, not boolean ones: their
40
+ * values are the literal strings "true" and "false", and an absent attribute is a third,
41
+ * different state. Converting at the DOM boundary keeps `undefined` meaning "omit" instead
42
+ * of collapsing it into "false".
43
+ */
44
+ export function ariaBool(value: boolean | undefined): 'true' | 'false' | undefined {
45
+ if (value === undefined) return undefined;
46
+ return value ? 'true' : 'false';
47
+ }
48
+
49
+ export function focusableWithin(root: ParentNode): HTMLElement[] {
50
+ return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
51
+ (el) => el.offsetParent !== null || el.getClientRects().length > 0,
52
+ );
53
+ }
54
+
55
+ export interface FocusTrap {
56
+ activate(): void;
57
+ release(): void;
58
+ }
59
+
60
+ /**
61
+ * Cycles Tab within `root` and restores focus to the previously active element
62
+ * on release — the behaviour Dialog and Drawer both need.
63
+ */
64
+ export function createFocusTrap(root: HTMLElement): FocusTrap {
65
+ let previous: HTMLElement | null = null;
66
+
67
+ function onKeyDown(event: KeyboardEvent): void {
68
+ if (event.key !== 'Tab') return;
69
+ const items = focusableWithin(root);
70
+ if (items.length === 0) {
71
+ event.preventDefault();
72
+ return;
73
+ }
74
+ const first = items[0] as HTMLElement;
75
+ const last = items[items.length - 1] as HTMLElement;
76
+ const active = document.activeElement;
77
+ if (event.shiftKey && (active === first || !root.contains(active))) {
78
+ event.preventDefault();
79
+ last.focus();
80
+ } else if (!event.shiftKey && active === last) {
81
+ event.preventDefault();
82
+ first.focus();
83
+ }
84
+ }
85
+
86
+ return {
87
+ activate() {
88
+ previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
89
+ root.addEventListener('keydown', onKeyDown);
90
+ (focusableWithin(root)[0] ?? root).focus();
91
+ },
92
+ release() {
93
+ root.removeEventListener('keydown', onKeyDown);
94
+ previous?.focus();
95
+ },
96
+ };
97
+ }
98
+
99
+ export type RovingOrientation = 'horizontal' | 'vertical' | 'both';
100
+
101
+ export interface RovingOptions {
102
+ orientation?: RovingOrientation;
103
+ dir?: Direction;
104
+ loop?: boolean;
105
+ }
106
+
107
+ /**
108
+ * Pure roving-tabindex reducer. Returns the next index, or the current one when
109
+ * the key is not a navigation key. Inline arrows invert under `dir: 'rtl'`.
110
+ */
111
+ export function nextRovingIndex(
112
+ current: number,
113
+ key: string,
114
+ count: number,
115
+ options: RovingOptions = {},
116
+ ): number {
117
+ if (count <= 0) return -1;
118
+ const { orientation = 'horizontal', dir = 'ltr', loop = true } = options;
119
+ const inline = orientation !== 'vertical';
120
+ const block = orientation !== 'horizontal';
121
+ const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
122
+ const backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
123
+
124
+ let step = 0;
125
+ if (inline && key === forward) step = 1;
126
+ else if (inline && key === backward) step = -1;
127
+ else if (block && key === 'ArrowDown') step = 1;
128
+ else if (block && key === 'ArrowUp') step = -1;
129
+ else if (key === 'Home') return 0;
130
+ else if (key === 'End') return count - 1;
131
+ else return current;
132
+
133
+ const raw = current + step;
134
+ if (loop) return (raw + count) % count;
135
+ return Math.min(count - 1, Math.max(0, raw));
136
+ }
137
+
138
+ /** Wire a roving group: one tabbable item, arrows move focus and selection. */
139
+ export function createRovingTabindex(
140
+ getItems: () => readonly HTMLElement[],
141
+ options: RovingOptions = {},
142
+ ): (event: KeyboardEvent) => void {
143
+ return (event) => {
144
+ const items = getItems();
145
+ const active = document.activeElement;
146
+ // Focus may sit on nothing, on `<body>`, or on a non-HTML element (an SVG child): none of
147
+ // those are in `items`, and all of them mean "start from the first item".
148
+ const current = active instanceof HTMLElement ? items.indexOf(active) : -1;
149
+ const next = nextRovingIndex(Math.max(current, 0), event.key, items.length, options);
150
+ if (next === current || next < 0) return;
151
+ event.preventDefault();
152
+ for (const [index, item] of items.entries()) {
153
+ item.tabIndex = index === next ? 0 : -1;
154
+ }
155
+ items[next]?.focus();
156
+ };
157
+ }
158
+
159
+ export type Politeness = 'polite' | 'assertive';
160
+
161
+ const LIVE_REGION_ID = 'ultimate-live-region';
162
+
163
+ /**
164
+ * Announce a message to assistive tech. Uses one persistent region per
165
+ * politeness level, because creating a region and writing to it in the same
166
+ * frame is not announced by most screen readers.
167
+ */
168
+ export function announce(message: string, politeness: Politeness = 'polite'): void {
169
+ if (typeof document === 'undefined') return;
170
+ const id = `${LIVE_REGION_ID}-${politeness}`;
171
+ let region = document.getElementById(id);
172
+ if (region === null) {
173
+ region = document.createElement('div');
174
+ region.id = id;
175
+ region.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status');
176
+ region.setAttribute('aria-live', politeness);
177
+ region.setAttribute('aria-atomic', 'true');
178
+ region.style.cssText =
179
+ 'position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip-path:inset(50%)';
180
+ document.body.appendChild(region);
181
+ }
182
+ region.textContent = '';
183
+ // Empty-then-write in a later task so repeated identical messages re-announce.
184
+ queueMicrotask(() => {
185
+ if (region !== null) region.textContent = message;
186
+ });
187
+ }
@@ -0,0 +1,70 @@
1
+ @use '../tokens' as t;
2
+
3
+ .alert {
4
+ display: flex;
5
+ align-items: flex-start;
6
+ gap: t.space(3);
7
+ padding: t.space(4);
8
+ border: 1px solid var(--alert-line);
9
+ border-radius: t.radius(md);
10
+ background: var(--alert-bg);
11
+ color: t.role('fg');
12
+ }
13
+
14
+ .icon {
15
+ display: inline-flex;
16
+ color: var(--alert-accent);
17
+ font-size: t.text(lg);
18
+ line-height: t.leading(tight);
19
+ }
20
+
21
+ .content {
22
+ flex: 1;
23
+ min-inline-size: 0;
24
+ }
25
+
26
+ .title {
27
+ color: t.role('fg-strong');
28
+ font-weight: t.weight(semibold);
29
+ }
30
+
31
+ .body {
32
+ color: t.role('fg-muted');
33
+ font-size: t.text(sm);
34
+ }
35
+
36
+ .tone-neutral {
37
+ --alert-bg: #{t.role('bg-soft')};
38
+ --alert-line: #{t.role('line')};
39
+ --alert-accent: #{t.role('fg-muted')};
40
+ }
41
+
42
+ .tone-accent {
43
+ --alert-bg: #{t.role('accent', 0.1)};
44
+ --alert-line: #{t.role('accent', 0.35)};
45
+ --alert-accent: #{t.role('accent')};
46
+ }
47
+
48
+ .tone-success {
49
+ --alert-bg: #{t.role('success-soft')};
50
+ --alert-line: #{t.role('success', 0.4)};
51
+ --alert-accent: #{t.role('success')};
52
+ }
53
+
54
+ .tone-warning {
55
+ --alert-bg: #{t.role('warning-soft')};
56
+ --alert-line: #{t.role('warning', 0.4)};
57
+ --alert-accent: #{t.role('warning')};
58
+ }
59
+
60
+ .tone-danger {
61
+ --alert-bg: #{t.role('danger-soft')};
62
+ --alert-line: #{t.role('danger', 0.4)};
63
+ --alert-accent: #{t.role('danger')};
64
+ }
65
+
66
+ .tone-info {
67
+ --alert-bg: #{t.role('info-soft')};
68
+ --alert-line: #{t.role('info', 0.4)};
69
+ --alert-accent: #{t.role('info')};
70
+ }
@@ -0,0 +1,49 @@
1
+ // Inline message bound to the page, not the viewport. `danger`/`warning` use
2
+ // `role="alert"` (assertive); the rest use `role="status"`, so an informational
3
+ // banner never interrupts what a screen-reader user is doing.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import styles from './Alert.module.scss';
8
+ import { IconButton } from './IconButton';
9
+ import type { Tone } from './variants';
10
+
11
+ export interface AlertProps {
12
+ /** Already-translated heading. */
13
+ title?: string | undefined;
14
+ children: JSX.Element;
15
+ tone?: Tone | undefined;
16
+ icon?: JSX.Element | undefined;
17
+ /** Provide both to make the alert dismissible; the label is the a11y name. */
18
+ onDismiss?: (() => void) | undefined;
19
+ dismissLabel?: string | undefined;
20
+ class?: string | undefined;
21
+ }
22
+
23
+ export function Alert(props: AlertProps): JSX.Element {
24
+ const tone = (): Tone => props.tone ?? 'info';
25
+ const assertive = (): boolean => tone() === 'danger' || tone() === 'warning';
26
+
27
+ return (
28
+ <div
29
+ role={assertive() ? 'alert' : 'status'}
30
+ aria-live={assertive() ? 'assertive' : 'polite'}
31
+ class={cx(styles['alert'], styles[`tone-${tone()}`], props.class)}
32
+ >
33
+ {props.icon === undefined ? null : (
34
+ <span aria-hidden="true" class={styles['icon']}>
35
+ {props.icon}
36
+ </span>
37
+ )}
38
+ <div class={styles['content']}>
39
+ {props.title === undefined ? null : <p class={styles['title']}>{props.title}</p>}
40
+ <div class={styles['body']}>{props.children}</div>
41
+ </div>
42
+ {props.onDismiss === undefined || props.dismissLabel === undefined ? null : (
43
+ <IconButton label={props.dismissLabel} size="sm" onClick={() => props.onDismiss?.()}>
44
+ <span aria-hidden="true">×</span>
45
+ </IconButton>
46
+ )}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,66 @@
1
+ @use '../tokens' as t;
2
+
3
+ .avatar {
4
+ position: relative;
5
+ display: inline-grid;
6
+ place-items: center;
7
+ inline-size: var(--avatar-size, 2rem);
8
+ block-size: var(--avatar-size, 2rem);
9
+ flex: none;
10
+ background: t.role('bg-soft');
11
+ color: t.role('fg-muted');
12
+ font-size: calc(var(--avatar-size, 2rem) * 0.4);
13
+ font-weight: t.weight(semibold);
14
+ line-height: 1;
15
+ overflow: clip;
16
+
17
+ > img {
18
+ inline-size: 100%;
19
+ block-size: 100%;
20
+ object-fit: cover;
21
+ }
22
+ }
23
+
24
+ .shape-circle {
25
+ border-radius: t.radius(full);
26
+ }
27
+
28
+ .shape-rounded {
29
+ border-radius: t.radius(md);
30
+ }
31
+
32
+ .initials {
33
+ user-select: none;
34
+ }
35
+
36
+ // The name is available to assistive tech without being painted twice.
37
+ .name {
38
+ @include t.visually-hidden;
39
+ }
40
+
41
+ .status-online,
42
+ .status-busy,
43
+ .status-offline {
44
+ &::after {
45
+ position: absolute;
46
+ inset-block-end: 0;
47
+ inset-inline-end: 0;
48
+ inline-size: 28%;
49
+ block-size: 28%;
50
+ border: 2px solid t.role('surface');
51
+ border-radius: t.radius(full);
52
+ content: '';
53
+ }
54
+ }
55
+
56
+ .status-online::after {
57
+ background: t.role('success');
58
+ }
59
+
60
+ .status-busy::after {
61
+ background: t.role('warning');
62
+ }
63
+
64
+ .status-offline::after {
65
+ background: t.role('fg-muted');
66
+ }
@@ -0,0 +1,56 @@
1
+ // Identity chip. An avatar image always carries intrinsic dimensions and an
2
+ // empty alt (the name is rendered as text or the accessible label), so it can
3
+ // never shift layout or duplicate the name to a screen reader.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import { cx } from '../cx';
7
+ import styles from './Avatar.module.scss';
8
+ import type { Size } from './variants';
9
+
10
+ export interface AvatarProps {
11
+ /** Already-localised display name. Drives initials and the accessible name. */
12
+ name: string;
13
+ src?: string | undefined;
14
+ size?: Size | 'xs' | 'xl' | undefined;
15
+ shape?: 'circle' | 'rounded' | undefined;
16
+ /** Presence ring; the meaning must also be conveyed in text nearby. */
17
+ status?: 'online' | 'busy' | 'offline' | undefined;
18
+ class?: string | undefined;
19
+ }
20
+
21
+ const PX: Readonly<Record<string, number>> = { xs: 20, sm: 24, md: 32, lg: 40, xl: 64 };
22
+
23
+ /** First letters of the first two words — locale-safe, no ASCII assumptions. */
24
+ export function initialsOf(name: string): string {
25
+ const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
26
+ return words
27
+ .map((word) => [...word][0] ?? '')
28
+ .join('')
29
+ .toLocaleUpperCase();
30
+ }
31
+
32
+ export function Avatar(props: AvatarProps): JSX.Element {
33
+ const px = (): number => PX[props.size ?? 'md'] ?? 32;
34
+
35
+ return (
36
+ <span
37
+ class={cx(
38
+ styles['avatar'],
39
+ styles[`shape-${props.shape ?? 'circle'}`],
40
+ props.status === undefined ? undefined : styles[`status-${props.status}`],
41
+ props.class,
42
+ )}
43
+ style={{ '--avatar-size': `${px()}px` }}
44
+ title={props.name}
45
+ >
46
+ {props.src === undefined ? (
47
+ <span aria-hidden="true" class={styles['initials']}>
48
+ {initialsOf(props.name)}
49
+ </span>
50
+ ) : (
51
+ <img src={props.src} alt="" width={px()} height={px()} loading="lazy" decoding="async" />
52
+ )}
53
+ <span class={styles['name']}>{props.name}</span>
54
+ </span>
55
+ );
56
+ }