@ultimat3/ui 1.0.0 → 1.1.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.
@@ -0,0 +1,155 @@
1
+ // The ONE way to restyle the design system without forking it: `defineTheme()` validates a set of
2
+ // token overrides and renders the custom properties that beat `theme.scss` at every specificity
3
+ // level it emits. There is deliberately no SCSS `@use ... with ()` seam — two ways to change the
4
+ // accent colour is the ambiguity axiom 1 exists to delete.
5
+
6
+ import { invalidBrandTokenError, unknownTokenError } from '../errors';
7
+ import { parseChannels } from '../tokens/contrast';
8
+ import {
9
+ COLOR_ROLES,
10
+ type ColorRole,
11
+ type RadiusName,
12
+ radiusTokens,
13
+ type Theme,
14
+ } from '../tokens/tokens';
15
+
16
+ /** The two font slots `_typography.scss` emits. */
17
+ export const FONT_SLOTS = ['sans', 'mono'] as const;
18
+ export type FontSlot = (typeof FONT_SLOTS)[number];
19
+
20
+ export interface BrandInput {
21
+ /** Channel overrides per theme. Omit a theme to leave it at the shipped palette. */
22
+ colors?: Partial<Record<Theme, Partial<Record<ColorRole, string>>>> | undefined;
23
+ radius?: Partial<Record<RadiusName, string>> | undefined;
24
+ font?: Partial<Record<FontSlot, string>> | undefined;
25
+ }
26
+
27
+ export interface Brand {
28
+ /** The stylesheet text. Ship it in a `<style>` after `global.scss`, or write it to a file. */
29
+ readonly css: string;
30
+ }
31
+
32
+ /** `0` or a number with a CSS length unit. No `calc()`, no `var()` — a scale rung is a value. */
33
+ const LENGTH_PATTERN = /^(0|\d+(\.\d+)?(px|rem|em|ch|%))$/;
34
+
35
+ /** Family names, quotes and separators only: everything a `font-family` list legitimately needs. */
36
+ const FONT_STACK_PATTERN = /^[\w\s,'"-]{1,200}$/;
37
+
38
+ const LENGTH_EXPECTED = 'a bare CSS length such as "0.5rem", "4px" or "0"';
39
+ const STACK_EXPECTED = 'a font-family list such as "Inter, system-ui, sans-serif"';
40
+ const CHANNELS_EXPECTED = 'space-separated RGB channels such as "31 110 178"';
41
+
42
+ /**
43
+ * Validate and freeze a brand. Every value is checked here rather than at render time, so a bad
44
+ * override fails at the app's entry point with the role that broke it named — not as a silently
45
+ * dropped declaration a human has to spot in devtools.
46
+ */
47
+ export function defineTheme(input: BrandInput): Brand {
48
+ const blocks: string[] = [];
49
+ const light = colorDeclarations(input.colors?.light, 'colors.light');
50
+ const dark = colorDeclarations(input.colors?.dark, 'colors.dark');
51
+ const root: string[] = [
52
+ ...light,
53
+ ...radiusDeclarations(input.radius),
54
+ ...fontDeclarations(input.font),
55
+ ];
56
+ if (root.length > 0) blocks.push(rule(':root', root));
57
+
58
+ // `theme.scss` emits light at `:root`, dark behind the media query, and BOTH again under
59
+ // `html[data-theme]`. A brand that only wrote `:root` would lose to those attribute rules on
60
+ // specificity, so every level it emits is answered here, in the same order.
61
+ if (light.length > 0) blocks.push(rule("html[data-theme='light']", light));
62
+ if (dark.length > 0) {
63
+ blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', dark))}\n}`);
64
+ blocks.push(rule("html[data-theme='dark']", dark));
65
+ }
66
+
67
+ return Object.freeze({ css: blocks.join('\n\n') });
68
+ }
69
+
70
+ /** The exact tag to inline, after `global.scss` so the overrides land later in the cascade. */
71
+ export function brandStyleTag(brand: Brand): string {
72
+ return `<style>${brand.css}</style>`;
73
+ }
74
+
75
+ function rule(selector: string, declarations: readonly string[]): string {
76
+ return `${selector} {\n${declarations.map((line) => ` ${line}`).join('\n')}\n}`;
77
+ }
78
+
79
+ function indent(block: string): string {
80
+ return block
81
+ .split('\n')
82
+ .map((line) => ` ${line}`)
83
+ .join('\n');
84
+ }
85
+
86
+ /**
87
+ * Ordered by the canonical role list, not by the caller's object — a brand file rendered twice
88
+ * must be byte-identical, or every consumer's CSP hash and diff churns for nothing.
89
+ */
90
+ function colorDeclarations(
91
+ overrides: Partial<Record<ColorRole, string>> | undefined,
92
+ scope: string,
93
+ ): string[] {
94
+ if (overrides === undefined) return [];
95
+ for (const role of Object.keys(overrides)) {
96
+ if (!(COLOR_ROLES as readonly string[]).includes(role)) {
97
+ throw unknownTokenError('color', role, COLOR_ROLES);
98
+ }
99
+ }
100
+ const out: string[] = [];
101
+ for (const role of COLOR_ROLES) {
102
+ const value = overrides[role];
103
+ if (value === undefined) continue;
104
+ assertChannels(scope, role, value);
105
+ out.push(`--color-${role}: ${value};`);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function radiusDeclarations(overrides: Partial<Record<RadiusName, string>> | undefined): string[] {
111
+ if (overrides === undefined) return [];
112
+ const known = Object.keys(radiusTokens) as RadiusName[];
113
+ for (const name of Object.keys(overrides)) {
114
+ if (!(known as readonly string[]).includes(name)) {
115
+ throw unknownTokenError('radius', name, known, '_radius.scss');
116
+ }
117
+ }
118
+ const out: string[] = [];
119
+ for (const name of known) {
120
+ const value = overrides[name];
121
+ if (value === undefined) continue;
122
+ if (!LENGTH_PATTERN.test(value)) {
123
+ throw invalidBrandTokenError('radius', name, value, LENGTH_EXPECTED);
124
+ }
125
+ out.push(`--radius-${name}: ${value};`);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function fontDeclarations(overrides: Partial<Record<FontSlot, string>> | undefined): string[] {
131
+ if (overrides === undefined) return [];
132
+ for (const slot of Object.keys(overrides)) {
133
+ if (!(FONT_SLOTS as readonly string[]).includes(slot)) {
134
+ throw unknownTokenError('font', slot, FONT_SLOTS, '_typography.scss');
135
+ }
136
+ }
137
+ const out: string[] = [];
138
+ for (const slot of FONT_SLOTS) {
139
+ const value = overrides[slot];
140
+ if (value === undefined) continue;
141
+ if (!FONT_STACK_PATTERN.test(value)) {
142
+ throw invalidBrandTokenError('font', slot, value, STACK_EXPECTED);
143
+ }
144
+ out.push(`--font-${slot}: ${value};`);
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function assertChannels(scope: string, role: string, value: string): void {
150
+ try {
151
+ parseChannels(value);
152
+ } catch {
153
+ throw invalidBrandTokenError(scope, role, value, CHANNELS_EXPECTED);
154
+ }
155
+ }
@@ -10,21 +10,21 @@ $light: (
10
10
  fg: 38 34 31,
11
11
  fg-strong: 17 15 13,
12
12
  fg-muted: 110 102 94,
13
- line: 224 216 208,
13
+ line: 208 198 188,
14
14
  scrim: 17 15 13,
15
- accent: 34 122 197,
15
+ accent: 31 110 178,
16
16
  accent-strong: 21 92 152,
17
17
  accent-fg: 255 255 255,
18
- success: 22 128 84,
18
+ success: 21 123 80,
19
19
  success-soft: 222 244 232,
20
20
  success-fg: 255 255 255,
21
- warning: 176 106 8,
21
+ warning: 155 93 7,
22
22
  warning-soft: 253 240 213,
23
23
  warning-fg: 255 255 255,
24
24
  danger: 190 42 42,
25
25
  danger-soft: 253 227 227,
26
26
  danger-fg: 255 255 255,
27
- info: 34 122 197,
27
+ info: 31 110 178,
28
28
  info-soft: 224 239 252,
29
29
  info-fg: 255 255 255,
30
30
  );
@@ -36,8 +36,8 @@ $dark: (
36
36
  surface-raised: 44 44 50,
37
37
  fg: 228 226 222,
38
38
  fg-strong: 248 247 245,
39
- fg-muted: 150 146 140,
40
- line: 54 54 60,
39
+ fg-muted: 155 151 145,
40
+ line: 72 72 80,
41
41
  scrim: 0 0 0,
42
42
  accent: 96 170 240,
43
43
  accent-strong: 130 190 248,
@@ -56,6 +56,10 @@ $dark: (
56
56
  info-fg: 12 20 30,
57
57
  );
58
58
 
59
+ /// The tone vocabulary, in the order components expose it. Mirrors `TONES` in
60
+ /// `components/variants.ts`; `variants.test.ts` fails if the two drift.
61
+ $tones: (neutral, accent, success, warning, danger, info);
62
+
59
63
  /// Emit one theme's channels as custom properties.
60
64
  @mixin channels($map) {
61
65
  @each $role, $channels in $map {
@@ -151,6 +151,90 @@
151
151
  }
152
152
  }
153
153
 
154
+ /// The inert state, spelled once. Both selectors, because a `<button disabled>` and a
155
+ /// `[aria-disabled='true']` div are the same state to a reader and must look the same.
156
+ @mixin disabled($opacity: 0.55) {
157
+ &:disabled,
158
+ &[aria-disabled='true'] {
159
+ opacity: $opacity;
160
+ cursor: not-allowed;
161
+ }
162
+ }
163
+
164
+ /// The `::backdrop` behind a `<dialog>`. Blur is opt-in — it costs a compositor layer.
165
+ @mixin scrim-backdrop($alpha: 0.45, $blur: 0) {
166
+ &::backdrop {
167
+ background: colors.role('scrim', $alpha);
168
+
169
+ @if $blur != 0 {
170
+ backdrop-filter: blur($blur);
171
+ }
172
+ }
173
+ }
174
+
175
+ /// Inline run of items on one baseline — a toolbar, a header, an icon beside a label.
176
+ @mixin row($gap: var(--space-3), $align: center, $justify: flex-start) {
177
+ display: flex;
178
+ flex-direction: row;
179
+ align-items: $align;
180
+ justify-content: $justify;
181
+ gap: $gap;
182
+ }
183
+
184
+ /// Vertical run. Paired with `row` so no component writes `flex-direction` by hand.
185
+ @mixin column($gap: var(--space-3), $align: stretch) {
186
+ display: flex;
187
+ flex-direction: column;
188
+ align-items: $align;
189
+ gap: $gap;
190
+ }
191
+
192
+ // --- tones -------------------------------------------------------------------
193
+ // One tone class set, generated. Button and IconButton each carried a hand-written copy of the
194
+ // same six blocks; a seventh tone meant editing both, and they had already drifted.
195
+
196
+ /// The four custom properties every toned component reads.
197
+ @mixin tone-vars($base, $strong, $on, $soft) {
198
+ --tone: #{$base};
199
+ --tone-strong: #{$strong};
200
+ --tone-fg: #{$on};
201
+ --tone-soft: #{$soft};
202
+ }
203
+
204
+ /// Emits `.tone-<name>` for every rung of `colors.$tones`. Include once, at the top level of a
205
+ /// component module, then read `var(--tone)` / `var(--tone-strong)` / `--tone-fg` / `--tone-soft`.
206
+ @mixin tone-classes {
207
+ .tone-neutral {
208
+ @include tone-vars(
209
+ colors.role('fg-strong'),
210
+ colors.role('fg'),
211
+ colors.role('bg'),
212
+ colors.role('bg-soft')
213
+ );
214
+ }
215
+
216
+ .tone-accent {
217
+ // The one tone with no `-soft` token: accent tints composite off the same channels.
218
+ @include tone-vars(
219
+ colors.role('accent'),
220
+ colors.role('accent-strong'),
221
+ colors.role('accent-fg'),
222
+ colors.role('accent', 0.12)
223
+ );
224
+ }
225
+
226
+ @each $tone in (success, warning, danger, info) {
227
+ .tone-#{$tone} {
228
+ @include tone-vars(
229
+ colors.role($tone),
230
+ colors.role($tone),
231
+ colors.role('#{$tone}-fg'),
232
+ colors.role('#{$tone}-soft')
233
+ );
234
+ }
235
+ }
236
+ }
237
+
154
238
  /// Shared shape for every text-ish form control so Input/Textarea/Select match.
155
239
  @mixin control {
156
240
  @include focus-ring;
@@ -0,0 +1,63 @@
1
+ // WCAG 2.2 relative luminance and contrast ratio over the canonical channel
2
+ // tokens. Here rather than in a review checklist: a palette pairing that fails
3
+ // AA is a failing test (`contrast.test.ts`), and a brand override is measured
4
+ // with the same function before it ships.
5
+
6
+ import { invalidValueError } from '../errors';
7
+ import { type ColorRole, colorTokens, type Theme } from './tokens';
8
+
9
+ /** Minimum ratio for body text — WCAG 2.2 AA, 1.4.3. */
10
+ export const AA_TEXT = 4.5;
11
+
12
+ /** Minimum ratio for large text and non-text UI — WCAG 2.2 AA, 1.4.3 / 1.4.11. */
13
+ export const AA_LARGE = 3;
14
+
15
+ /** `R G B`, each 0–255. The only channel spelling the token layer accepts. */
16
+ export const CHANNELS_PATTERN = /^\d{1,3} \d{1,3} \d{1,3}$/;
17
+
18
+ export type Channels = readonly [number, number, number];
19
+
20
+ /** Parse `"31 110 178"`. Throws `X_UI_INVALID_VALUE` on anything else. */
21
+ export function parseChannels(value: string): Channels {
22
+ if (!CHANNELS_PATTERN.test(value)) {
23
+ throw invalidValueError('colour channels', value, 'three space-separated 0–255 integers');
24
+ }
25
+ const parts = value.split(' ').map(Number);
26
+ const [r = 0, g = 0, b = 0] = parts;
27
+ if (r > 255 || g > 255 || b > 255) {
28
+ throw invalidValueError('colour channels', value, 'three space-separated 0–255 integers');
29
+ }
30
+ return [r, g, b];
31
+ }
32
+
33
+ // sRGB → linear, then the ITU-R BT.709 luma weights WCAG specifies.
34
+ function linearize(channel: number): number {
35
+ const v = channel / 255;
36
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
37
+ }
38
+
39
+ export function relativeLuminance(channels: string): number {
40
+ const [r, g, b] = parseChannels(channels);
41
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
42
+ }
43
+
44
+ /** Symmetric — order of the two colours does not change the ratio. */
45
+ export function contrastRatio(a: string, b: string): number {
46
+ const la = relativeLuminance(a);
47
+ const lb = relativeLuminance(b);
48
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
49
+ }
50
+
51
+ /** The ratio between two semantic roles as resolved in one theme. */
52
+ export function roleContrast(theme: Theme, fg: ColorRole, bg: ColorRole): number {
53
+ return contrastRatio(colorTokens[theme][fg], colorTokens[theme][bg]);
54
+ }
55
+
56
+ export function meetsContrast(
57
+ theme: Theme,
58
+ fg: ColorRole,
59
+ bg: ColorRole,
60
+ minimum: number = AA_TEXT,
61
+ ): boolean {
62
+ return roleContrast(theme, fg, bg) >= minimum;
63
+ }
@@ -47,21 +47,21 @@ export const colorTokens: Readonly<Record<Theme, Readonly<Record<ColorRole, stri
47
47
  fg: '38 34 31',
48
48
  'fg-strong': '17 15 13',
49
49
  'fg-muted': '110 102 94',
50
- line: '224 216 208',
50
+ line: '208 198 188',
51
51
  scrim: '17 15 13',
52
- accent: '34 122 197',
52
+ accent: '31 110 178',
53
53
  'accent-strong': '21 92 152',
54
54
  'accent-fg': '255 255 255',
55
- success: '22 128 84',
55
+ success: '21 123 80',
56
56
  'success-soft': '222 244 232',
57
57
  'success-fg': '255 255 255',
58
- warning: '176 106 8',
58
+ warning: '155 93 7',
59
59
  'warning-soft': '253 240 213',
60
60
  'warning-fg': '255 255 255',
61
61
  danger: '190 42 42',
62
62
  'danger-soft': '253 227 227',
63
63
  'danger-fg': '255 255 255',
64
- info: '34 122 197',
64
+ info: '31 110 178',
65
65
  'info-soft': '224 239 252',
66
66
  'info-fg': '255 255 255',
67
67
  },
@@ -72,8 +72,8 @@ export const colorTokens: Readonly<Record<Theme, Readonly<Record<ColorRole, stri
72
72
  'surface-raised': '44 44 50',
73
73
  fg: '228 226 222',
74
74
  'fg-strong': '248 247 245',
75
- 'fg-muted': '150 146 140',
76
- line: '54 54 60',
75
+ 'fg-muted': '155 151 145',
76
+ line: '72 72 80',
77
77
  scrim: '0 0 0',
78
78
  accent: '96 170 240',
79
79
  'accent-strong': '130 190 248',
@@ -117,6 +117,8 @@ export const radiusTokens = {
117
117
  full: '50%',
118
118
  } as const;
119
119
 
120
+ export type RadiusName = keyof typeof radiusTokens;
121
+
120
122
  export const zTokens = {
121
123
  base: '0',
122
124
  raised: '10',