@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
@@ -0,0 +1,116 @@
1
+ // Theme resolution. One rule: an explicit choice in storage wins, otherwise the
2
+ // OS decides. Every side effect goes through an injected `ThemeEnv`, so this is
3
+ // testable without a DOM and reusable on the server.
4
+
5
+ import { invalidThemeError, runtimeMissingError } from '../errors';
6
+ import type { Theme } from '../tokens/tokens';
7
+
8
+ export type { Theme };
9
+
10
+ export const THEME_STORAGE_KEY = 'ultimate.theme';
11
+ export const THEME_ATTRIBUTE = 'data-theme';
12
+ export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
13
+
14
+ /** The host capabilities theme control needs. Injected so tests need no DOM. */
15
+ export interface ThemeEnv {
16
+ read(): string | null;
17
+ write(value: string): void;
18
+ remove(): void;
19
+ prefersDark(): boolean;
20
+ apply(theme: Theme): void;
21
+ /** Subscribe to OS scheme changes. Returns an unsubscribe. */
22
+ observeOs(listener: () => void): () => void;
23
+ }
24
+
25
+ export function isTheme(value: unknown): value is Theme {
26
+ return value === 'light' || value === 'dark';
27
+ }
28
+
29
+ function assertTheme(value: unknown): asserts value is Theme {
30
+ if (!isTheme(value)) throw invalidThemeError(value);
31
+ }
32
+
33
+ /** The user's explicit choice, or null when they have never chosen. */
34
+ export function storedTheme(env: ThemeEnv): Theme | null {
35
+ const raw = env.read();
36
+ return isTheme(raw) ? raw : null;
37
+ }
38
+
39
+ export function osTheme(env: ThemeEnv): Theme {
40
+ return env.prefersDark() ? 'dark' : 'light';
41
+ }
42
+
43
+ /** Resolution order: explicit stored choice -> OS preference. */
44
+ export function resolveTheme(env: ThemeEnv): Theme {
45
+ return storedTheme(env) ?? osTheme(env);
46
+ }
47
+
48
+ /** Run once on load. Idempotent — safe after the inline head script. */
49
+ export function initTheme(env: ThemeEnv = browserThemeEnv()): Theme {
50
+ const theme = resolveTheme(env);
51
+ env.apply(theme);
52
+ return theme;
53
+ }
54
+
55
+ /** The user picked a theme. Persist it so it outranks the OS from now on. */
56
+ export function setTheme(theme: Theme, env: ThemeEnv = browserThemeEnv()): Theme {
57
+ assertTheme(theme);
58
+ env.write(theme);
59
+ env.apply(theme);
60
+ return theme;
61
+ }
62
+
63
+ /** Forget the explicit choice and follow the OS again. */
64
+ export function clearTheme(env: ThemeEnv = browserThemeEnv()): Theme {
65
+ env.remove();
66
+ const theme = osTheme(env);
67
+ env.apply(theme);
68
+ return theme;
69
+ }
70
+
71
+ /** Flip whatever is currently resolved, and record it as an explicit choice. */
72
+ export function toggleTheme(env: ThemeEnv = browserThemeEnv()): Theme {
73
+ return setTheme(resolveTheme(env) === 'dark' ? 'light' : 'dark', env);
74
+ }
75
+
76
+ /**
77
+ * Follow the OS live — but only while the user has made no explicit choice.
78
+ * Returns an unsubscribe so a component can drop it on cleanup.
79
+ */
80
+ export function watchOsTheme(env: ThemeEnv = browserThemeEnv()): () => void {
81
+ return env.observeOs(() => {
82
+ if (storedTheme(env) === null) env.apply(osTheme(env));
83
+ });
84
+ }
85
+
86
+ /** The real browser implementation. Throws off-DOM instead of failing silently. */
87
+ export function browserThemeEnv(): ThemeEnv {
88
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
89
+ throw runtimeMissingError(
90
+ 'document/window for theme control',
91
+ 'pass an explicit ThemeEnv: initTheme(myServerThemeEnv)',
92
+ );
93
+ }
94
+ const media = window.matchMedia(THEME_MEDIA_QUERY);
95
+ return {
96
+ read: () => safeStorage(() => window.localStorage.getItem(THEME_STORAGE_KEY), null),
97
+ write: (value) => safeStorage(() => window.localStorage.setItem(THEME_STORAGE_KEY, value)),
98
+ remove: () => safeStorage(() => window.localStorage.removeItem(THEME_STORAGE_KEY)),
99
+ prefersDark: () => media.matches,
100
+ apply: (theme) => document.documentElement.setAttribute(THEME_ATTRIBUTE, theme),
101
+ observeOs: (listener) => {
102
+ media.addEventListener('change', listener);
103
+ return () => media.removeEventListener('change', listener);
104
+ },
105
+ };
106
+ }
107
+
108
+ // Private-mode Safari throws on localStorage access; a theme is never worth a
109
+ // crash, so storage failures degrade to "no stored choice".
110
+ function safeStorage<T>(fn: () => T, fallback?: T): T {
111
+ try {
112
+ return fn();
113
+ } catch {
114
+ return fallback as T;
115
+ }
116
+ }
@@ -0,0 +1,10 @@
1
+ // Viewport breakpoints. Prefer container queries; reach for these only when the
2
+ // layout genuinely depends on the viewport (app shell, sticky bars).
3
+
4
+ $breakpoints: (
5
+ sm: 480px,
6
+ md: 768px,
7
+ lg: 1024px,
8
+ xl: 1280px,
9
+ 2xl: 1536px,
10
+ );
@@ -0,0 +1,95 @@
1
+ // Canonical colour token source. Semantic roles only, stored as space-separated
2
+ // RGB channels so `rgb(var(--color-x) / 0.5)` composites without a second token.
3
+ // `tokens.ts` mirrors these values; `x verify` fails if the two drift.
4
+
5
+ $light: (
6
+ bg: 253 246 240,
7
+ bg-soft: 245 237 230,
8
+ surface: 250 245 241,
9
+ surface-raised: 255 255 255,
10
+ fg: 38 34 31,
11
+ fg-strong: 17 15 13,
12
+ fg-muted: 110 102 94,
13
+ line: 224 216 208,
14
+ scrim: 17 15 13,
15
+ accent: 34 122 197,
16
+ accent-strong: 21 92 152,
17
+ accent-fg: 255 255 255,
18
+ success: 22 128 84,
19
+ success-soft: 222 244 232,
20
+ success-fg: 255 255 255,
21
+ warning: 176 106 8,
22
+ warning-soft: 253 240 213,
23
+ warning-fg: 255 255 255,
24
+ danger: 190 42 42,
25
+ danger-soft: 253 227 227,
26
+ danger-fg: 255 255 255,
27
+ info: 34 122 197,
28
+ info-soft: 224 239 252,
29
+ info-fg: 255 255 255,
30
+ );
31
+
32
+ $dark: (
33
+ bg: 18 18 20,
34
+ bg-soft: 28 28 32,
35
+ surface: 34 34 39,
36
+ surface-raised: 44 44 50,
37
+ fg: 228 226 222,
38
+ fg-strong: 248 247 245,
39
+ fg-muted: 150 146 140,
40
+ line: 54 54 60,
41
+ scrim: 0 0 0,
42
+ accent: 96 170 240,
43
+ accent-strong: 130 190 248,
44
+ accent-fg: 16 20 26,
45
+ success: 74 190 130,
46
+ success-soft: 22 46 34,
47
+ success-fg: 12 26 18,
48
+ warning: 226 170 66,
49
+ warning-soft: 52 42 20,
50
+ warning-fg: 28 20 6,
51
+ danger: 240 110 110,
52
+ danger-soft: 56 26 26,
53
+ danger-fg: 30 12 12,
54
+ info: 96 170 240,
55
+ info-soft: 22 38 56,
56
+ info-fg: 12 20 30,
57
+ );
58
+
59
+ /// Emit one theme's channels as custom properties.
60
+ @mixin channels($map) {
61
+ @each $role, $channels in $map {
62
+ --color-#{$role}: #{$channels};
63
+ }
64
+ }
65
+
66
+ /// Light in `:root`, dark behind the media query, and BOTH mirrored under
67
+ /// `html[data-theme]` so an explicit user choice always beats the OS.
68
+ @mixin emit {
69
+ :root {
70
+ color-scheme: light;
71
+ @include channels($light);
72
+ }
73
+
74
+ @media (prefers-color-scheme: dark) {
75
+ :root {
76
+ color-scheme: dark;
77
+ @include channels($dark);
78
+ }
79
+ }
80
+
81
+ html[data-theme='dark'] {
82
+ color-scheme: dark;
83
+ @include channels($dark);
84
+ }
85
+
86
+ html[data-theme='light'] {
87
+ color-scheme: light;
88
+ @include channels($light);
89
+ }
90
+ }
91
+
92
+ /// `rgb()` wrapper so components never touch the raw channel list.
93
+ @function role($name, $alpha: 1) {
94
+ @return rgb(var(--color-#{$name}) / #{$alpha});
95
+ }
@@ -0,0 +1,13 @@
1
+ // Single entry point for authors: `@use '../tokens' as t;` then `t.role('bg')`,
2
+ // `t.space(4)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
3
+ // emits no CSS. Global custom properties come from `theme.scss`.
4
+
5
+ @forward 'breakpoints';
6
+ @forward 'colors' hide emit, channels;
7
+ @forward 'motion' hide emit;
8
+ @forward 'radius' hide emit;
9
+ @forward 'shadow' hide emit, levels;
10
+ @forward 'space' hide emit;
11
+ @forward 'typography' hide emit;
12
+ @forward 'z' hide emit;
13
+ @forward 'mixins';
@@ -0,0 +1,186 @@
1
+ // Shared authoring primitives. Component modules `@use '../tokens' as t;` and
2
+ // compose from here — nothing in a component stylesheet hardcodes a colour,
3
+ // a physical direction, or a breakpoint value.
4
+
5
+ @use 'sass:list';
6
+ @use 'sass:map';
7
+ @use 'breakpoints';
8
+ @use 'colors';
9
+ @use 'motion';
10
+ @use 'radius';
11
+
12
+ /// Keyboard-only focus, drawn from the accent role so it themes automatically.
13
+ @mixin focus-ring($role: 'accent', $width: 2px, $offset: 2px) {
14
+ &:focus-visible {
15
+ outline: $width solid colors.role($role);
16
+ outline-offset: $offset;
17
+ }
18
+
19
+ &:focus:not(:focus-visible) {
20
+ outline: none;
21
+ }
22
+ }
23
+
24
+ /// Removed from the visual flow, still announced by screen readers.
25
+ @mixin visually-hidden {
26
+ position: absolute;
27
+ width: 1px;
28
+ height: 1px;
29
+ margin: -1px;
30
+ padding: 0;
31
+ overflow: hidden;
32
+ clip-path: inset(50%);
33
+ white-space: nowrap;
34
+ border: 0;
35
+ }
36
+
37
+ @mixin truncate {
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ white-space: nowrap;
41
+ }
42
+
43
+ @mixin line-clamp($lines: 2) {
44
+ display: -webkit-box;
45
+ -webkit-box-orient: vertical;
46
+ -webkit-line-clamp: $lines;
47
+ overflow: hidden;
48
+ }
49
+
50
+ /// Style by the component's own width, not the viewport — the portable default.
51
+ @mixin container-query($name, $min) {
52
+ @container #{$name} (min-width: #{$min}) {
53
+ @content;
54
+ }
55
+ }
56
+
57
+ /// Declare the element a query container. Pair with `container-query`.
58
+ @mixin container($name) {
59
+ container-type: inline-size;
60
+ container-name: $name;
61
+ }
62
+
63
+ /// Viewport breakpoint, min-width only. Named rungs, never a raw px value.
64
+ @mixin respond-to($bp) {
65
+ @if not map.has-key(breakpoints.$breakpoints, $bp) {
66
+ @error 'X_TOKEN_UNKNOWN: breakpoint "#{$bp}" is not in $breakpoints. fix: add it to src/tokens/_breakpoints.scss';
67
+ }
68
+
69
+ @media (min-width: map.get(breakpoints.$breakpoints, $bp)) {
70
+ @content;
71
+ }
72
+ }
73
+
74
+ // --- Logical properties ------------------------------------------------------
75
+ // RTL comes free: authors never write `left`/`right`/`margin-left`, so there is
76
+ // no mirrored stylesheet and no `[dir='rtl']` override anywhere in the package.
77
+
78
+ @mixin margin-inline($start, $end: $start) {
79
+ margin-inline-start: $start;
80
+ margin-inline-end: $end;
81
+ }
82
+
83
+ @mixin margin-block($start, $end: $start) {
84
+ margin-block-start: $start;
85
+ margin-block-end: $end;
86
+ }
87
+
88
+ @mixin padding-inline($start, $end: $start) {
89
+ padding-inline-start: $start;
90
+ padding-inline-end: $end;
91
+ }
92
+
93
+ @mixin padding-block($start, $end: $start) {
94
+ padding-block-start: $start;
95
+ padding-block-end: $end;
96
+ }
97
+
98
+ @mixin inset-inline($start, $end: $start) {
99
+ inset-inline-start: $start;
100
+ inset-inline-end: $end;
101
+ }
102
+
103
+ @mixin inset-block($start, $end: $start) {
104
+ inset-block-start: $start;
105
+ inset-block-end: $end;
106
+ }
107
+
108
+ @mixin border-inline-start($width: 1px, $role: 'line') {
109
+ border-inline-start: $width solid colors.role($role);
110
+ }
111
+
112
+ // --- Composites --------------------------------------------------------------
113
+
114
+ /// Transition any number of properties on the shared duration + easing.
115
+ @mixin transition($props...) {
116
+ $list: ();
117
+ @each $prop in $props {
118
+ $list: list.append($list, #{$prop} motion.duration(base) motion.easing(out), comma);
119
+ }
120
+ transition: $list;
121
+ }
122
+
123
+ /// Card/sheet/popover background pair. `raised` adds the deeper elevation.
124
+ @mixin surface($level: 'surface', $elevation: 'sm') {
125
+ background: colors.role($level);
126
+ border: 1px solid colors.role('line');
127
+ border-radius: radius.radius(lg);
128
+ box-shadow: var(--shadow-#{$elevation});
129
+ }
130
+
131
+ /// Both scrollbar engines, plus no scroll chaining to the page.
132
+ @mixin custom-scrollbar($thumb: 'line', $track: 'bg-soft') {
133
+ overscroll-behavior: contain;
134
+ scrollbar-width: thin;
135
+ scrollbar-color: colors.role($thumb) colors.role($track);
136
+
137
+ &::-webkit-scrollbar {
138
+ width: 10px;
139
+ height: 10px;
140
+ }
141
+
142
+ &::-webkit-scrollbar-track {
143
+ background: colors.role($track);
144
+ }
145
+
146
+ &::-webkit-scrollbar-thumb {
147
+ border: 2px solid transparent;
148
+ border-radius: radius.radius(pill);
149
+ background: colors.role($thumb);
150
+ background-clip: content-box;
151
+ }
152
+ }
153
+
154
+ /// Shared shape for every text-ish form control so Input/Textarea/Select match.
155
+ @mixin control {
156
+ @include focus-ring;
157
+ @include transition(border-color, box-shadow, background);
158
+ inline-size: 100%;
159
+ padding-block: var(--space-2);
160
+ padding-inline: var(--space-3);
161
+ border: 1px solid colors.role('line');
162
+ border-radius: radius.radius(md);
163
+ background: colors.role('surface-raised');
164
+ color: colors.role('fg');
165
+ font: inherit;
166
+ font-size: var(--text-md);
167
+
168
+ &::placeholder {
169
+ color: colors.role('fg-muted');
170
+ }
171
+
172
+ &:hover:not(:disabled, [aria-disabled='true']) {
173
+ border-color: colors.role('accent', 0.5);
174
+ }
175
+
176
+ &:disabled,
177
+ &[aria-disabled='true'] {
178
+ background: colors.role('bg-soft');
179
+ color: colors.role('fg-muted');
180
+ cursor: not-allowed;
181
+ }
182
+
183
+ &[aria-invalid='true'] {
184
+ border-color: colors.role('danger');
185
+ }
186
+ }
@@ -0,0 +1,36 @@
1
+ // Durations + easings. Every animated rule ships behind the global
2
+ // `prefers-reduced-motion` guard in `reset.scss`.
3
+
4
+ $duration: (
5
+ instant: 0ms,
6
+ fast: 120ms,
7
+ base: 220ms,
8
+ slow: 400ms,
9
+ slower: 640ms,
10
+ );
11
+
12
+ $easing: (
13
+ out: cubic-bezier(0.16, 1, 0.3, 1),
14
+ in: cubic-bezier(0.5, 0, 0.75, 0),
15
+ in-out: cubic-bezier(0.65, 0, 0.35, 1),
16
+ spring: cubic-bezier(0.34, 1.56, 0.64, 1),
17
+ );
18
+
19
+ @mixin emit {
20
+ :root {
21
+ @each $name, $value in $duration {
22
+ --duration-#{$name}: #{$value};
23
+ }
24
+ @each $name, $value in $easing {
25
+ --easing-#{$name}: #{$value};
26
+ }
27
+ }
28
+ }
29
+
30
+ @function duration($name) {
31
+ @return var(--duration-#{$name});
32
+ }
33
+
34
+ @function easing($name) {
35
+ @return var(--easing-#{$name});
36
+ }
@@ -0,0 +1,23 @@
1
+ // Corner radii. `pill` for chips and switches, `full` for avatars.
2
+
3
+ $radius: (
4
+ none: 0,
5
+ sm: 0.25rem,
6
+ md: 0.5rem,
7
+ lg: 0.75rem,
8
+ xl: 1rem,
9
+ pill: 999px,
10
+ full: 50%,
11
+ );
12
+
13
+ @mixin emit {
14
+ :root {
15
+ @each $name, $value in $radius {
16
+ --radius-#{$name}: #{$value};
17
+ }
18
+ }
19
+ }
20
+
21
+ @function radius($name) {
22
+ @return var(--radius-#{$name});
23
+ }
@@ -0,0 +1,49 @@
1
+ // Elevation. Shadows are themed: a light-theme shadow (dark ink, low alpha)
2
+ // is invisible on a dark surface, so dark gets its own deeper, higher-alpha set.
3
+ // Single-layer values on purpose — one string per rung keeps the TS mirror exact.
4
+
5
+ $shadow-light: (
6
+ xs: 0 1px 2px rgb(17 15 13 / 0.06),
7
+ sm: 0 2px 6px rgb(17 15 13 / 0.08),
8
+ md: 0 4px 14px rgb(17 15 13 / 0.1),
9
+ lg: 0 12px 32px rgb(17 15 13 / 0.14),
10
+ xl: 0 24px 56px rgb(17 15 13 / 0.18),
11
+ );
12
+
13
+ $shadow-dark: (
14
+ xs: 0 1px 2px rgb(0 0 0 / 0.4),
15
+ sm: 0 2px 8px rgb(0 0 0 / 0.48),
16
+ md: 0 6px 20px rgb(0 0 0 / 0.56),
17
+ lg: 0 16px 40px rgb(0 0 0 / 0.64),
18
+ xl: 0 28px 64px rgb(0 0 0 / 0.72),
19
+ );
20
+
21
+ @mixin levels($map) {
22
+ @each $name, $value in $map {
23
+ --shadow-#{$name}: #{$value};
24
+ }
25
+ }
26
+
27
+ @mixin emit {
28
+ :root {
29
+ @include levels($shadow-light);
30
+ }
31
+
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ @include levels($shadow-dark);
35
+ }
36
+ }
37
+
38
+ html[data-theme='dark'] {
39
+ @include levels($shadow-dark);
40
+ }
41
+
42
+ html[data-theme='light'] {
43
+ @include levels($shadow-light);
44
+ }
45
+ }
46
+
47
+ @function shadow($name) {
48
+ @return var(--shadow-#{$name});
49
+ }
@@ -0,0 +1,28 @@
1
+ // Spacing scale on a 4px base. Applied through logical properties only
2
+ // (see `_mixins.scss`) so RTL needs no mirrored stylesheet.
3
+
4
+ $space: (
5
+ 0: 0,
6
+ 1: 0.25rem,
7
+ 2: 0.5rem,
8
+ 3: 0.75rem,
9
+ 4: 1rem,
10
+ 5: 1.25rem,
11
+ 6: 1.5rem,
12
+ 8: 2rem,
13
+ 10: 2.5rem,
14
+ 12: 3rem,
15
+ 16: 4rem,
16
+ );
17
+
18
+ @mixin emit {
19
+ :root {
20
+ @each $step, $value in $space {
21
+ --space-#{$step}: #{$value};
22
+ }
23
+ }
24
+ }
25
+
26
+ @function space($step) {
27
+ @return var(--space-#{$step});
28
+ }
@@ -0,0 +1,83 @@
1
+ // Fluid type scale. Every step is a `clamp()` so there is no breakpoint step
2
+ // change and no `font-size` media query anywhere in the design system.
3
+
4
+ $font-family: (
5
+ sans: (
6
+ system-ui,
7
+ -apple-system,
8
+ 'Segoe UI',
9
+ Roboto,
10
+ 'Helvetica Neue',
11
+ sans-serif,
12
+ ),
13
+ mono: (
14
+ ui-monospace,
15
+ 'SFMono-Regular',
16
+ 'JetBrains Mono',
17
+ Menlo,
18
+ Consolas,
19
+ monospace,
20
+ ),
21
+ );
22
+
23
+ $font-size: (
24
+ xs: clamp(0.75rem, 0.73rem + 0.1vw, 0.8125rem),
25
+ sm: clamp(0.875rem, 0.85rem + 0.15vw, 0.9375rem),
26
+ md: clamp(1rem, 0.96rem + 0.2vw, 1.0625rem),
27
+ lg: clamp(1.125rem, 1.05rem + 0.35vw, 1.25rem),
28
+ xl: clamp(1.375rem, 1.2rem + 0.7vw, 1.75rem),
29
+ 2xl: clamp(1.75rem, 1.4rem + 1.4vw, 2.5rem),
30
+ 3xl: clamp(2.25rem, 1.6rem + 2.6vw, 3.5rem),
31
+ );
32
+
33
+ $font-weight: (
34
+ normal: 400,
35
+ medium: 500,
36
+ semibold: 600,
37
+ bold: 700,
38
+ );
39
+
40
+ $line-height: (
41
+ tight: 1.2,
42
+ snug: 1.35,
43
+ normal: 1.55,
44
+ loose: 1.75,
45
+ );
46
+
47
+ $letter-spacing: (
48
+ tight: -0.015em,
49
+ normal: 0,
50
+ wide: 0.04em,
51
+ );
52
+
53
+ @mixin emit {
54
+ :root {
55
+ @each $name, $stack in $font-family {
56
+ --font-#{$name}: #{$stack};
57
+ }
58
+ @each $name, $value in $font-size {
59
+ --text-#{$name}: #{$value};
60
+ }
61
+ @each $name, $value in $font-weight {
62
+ --weight-#{$name}: #{$value};
63
+ }
64
+ @each $name, $value in $line-height {
65
+ --leading-#{$name}: #{$value};
66
+ }
67
+ @each $name, $value in $letter-spacing {
68
+ --tracking-#{$name}: #{$value};
69
+ }
70
+ }
71
+ }
72
+
73
+ @function text($name) {
74
+ @return var(--text-#{$name});
75
+ }
76
+
77
+ @function weight($name) {
78
+ @return var(--weight-#{$name});
79
+ }
80
+
81
+ @function leading($name) {
82
+ @return var(--leading-#{$name});
83
+ }
@@ -0,0 +1,27 @@
1
+ // Named z-index ladder. A magic number in a component is a review failure —
2
+ // add a rung here instead.
3
+
4
+ $z: (
5
+ base: 0,
6
+ raised: 10,
7
+ sticky: 100,
8
+ dropdown: 200,
9
+ drawer: 300,
10
+ dialog: 400,
11
+ popover: 500,
12
+ tooltip: 600,
13
+ toast: 700,
14
+ skip-nav: 800,
15
+ );
16
+
17
+ @mixin emit {
18
+ :root {
19
+ @each $name, $value in $z {
20
+ --z-#{$name}: #{$value};
21
+ }
22
+ }
23
+ }
24
+
25
+ @function z($name) {
26
+ @return var(--z-#{$name});
27
+ }