@ultimat3/ui 23.0.0 → 25.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.
- package/CATALOG.md +119 -15
- package/CLAUDE.md +17 -8
- package/README.md +119 -33
- package/package.json +6 -6
- package/src/a11y.ts +27 -13
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +161 -51
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +17 -8
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/Meter.tsx +3 -3
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +14 -7
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +11 -6
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +32 -13
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/money-view.ts +2 -7
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +32 -14
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +109 -24
- package/src/index.ts +47 -15
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +104 -96
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +36 -11
- package/src/toast/toast-state.ts +0 -0
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
- package/src/theme/inline-script.ts +0 -49
package/src/theme/brand.ts
CHANGED
|
@@ -3,30 +3,43 @@
|
|
|
3
3
|
// level it emits. There is deliberately no SCSS `@use ... with ()` seam — two ways to change the
|
|
4
4
|
// accent colour is the ambiguity axiom 1 exists to delete.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
invalidBrandTokenError,
|
|
9
|
-
runtimeMissingError,
|
|
10
|
-
unknownTokenError,
|
|
11
|
-
} from '../errors';
|
|
12
|
-
import { contrastRatio, parseChannels } from '../tokens/contrast';
|
|
6
|
+
import { insufficientContrastError, invalidBrandTokenError, runtimeMissingError } from '../errors';
|
|
7
|
+
import { contrastRatio } from '../tokens/contrast';
|
|
13
8
|
import { CONTRAST_PAIRS } from '../tokens/contrast-pairs';
|
|
14
9
|
import {
|
|
15
10
|
COLOR_ROLES,
|
|
16
11
|
type ColorRole,
|
|
17
12
|
colorTokens,
|
|
18
13
|
type RadiusName,
|
|
19
|
-
|
|
14
|
+
SHADOW_NAMES,
|
|
15
|
+
type ShadowName,
|
|
16
|
+
shadowTokens,
|
|
20
17
|
type Theme,
|
|
21
18
|
} from '../tokens/tokens';
|
|
19
|
+
import {
|
|
20
|
+
colorDeclarations,
|
|
21
|
+
fontDeclarations,
|
|
22
|
+
radiusDeclarations,
|
|
23
|
+
shadowDeclarations,
|
|
24
|
+
} from './brand-declarations';
|
|
25
|
+
import type { FontSlot, ThemePreset } from './preset-shape';
|
|
26
|
+
import { THEME_PRESET_NAMES, THEME_PRESETS, type ThemePresetName } from './presets';
|
|
27
|
+
|
|
28
|
+
export type { FontSlot } from './preset-shape';
|
|
29
|
+
export { FONT_SLOTS } from './preset-shape';
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
export const FONT_SLOTS = ['sans', 'mono'] as const;
|
|
25
|
-
export type FontSlot = (typeof FONT_SLOTS)[number];
|
|
31
|
+
type Themed<K extends string> = Partial<Record<Theme, Partial<Record<K, string>>>>;
|
|
26
32
|
|
|
27
33
|
export interface BrandInput {
|
|
34
|
+
/**
|
|
35
|
+
* A shipped palette to start from (`THEME_PRESETS`). Every other slot layers onto it role by
|
|
36
|
+
* role, so `{ preset: 'scifi', colors: { dark: { accent: … } } }` is scifi with your accent.
|
|
37
|
+
*/
|
|
38
|
+
preset?: ThemePresetName | undefined;
|
|
28
39
|
/** Channel overrides per theme. Omit a theme to leave it at the shipped palette. */
|
|
29
|
-
colors?:
|
|
40
|
+
colors?: Themed<ColorRole> | undefined;
|
|
41
|
+
/** Shadow overrides per theme, by rung (`SHADOW_NAMES`) — elevation is themed like colour. */
|
|
42
|
+
shadows?: Themed<ShadowName> | undefined;
|
|
30
43
|
radius?: Partial<Record<RadiusName, string>> | undefined;
|
|
31
44
|
font?: Partial<Record<FontSlot, string>> | undefined;
|
|
32
45
|
}
|
|
@@ -36,50 +49,91 @@ export interface Brand {
|
|
|
36
49
|
readonly css: string;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
/** `0` or a number with a CSS length unit. No `calc()`, no `var()` — a scale rung is a value. */
|
|
40
|
-
const LENGTH_PATTERN = /^(0|\d+(\.\d+)?(px|rem|em|ch|%))$/;
|
|
41
|
-
|
|
42
|
-
/** Family names, quotes and separators only: everything a `font-family` list legitimately needs. */
|
|
43
|
-
const FONT_STACK_PATTERN = /^[\w\s,'"-]{1,200}$/;
|
|
44
|
-
|
|
45
|
-
const LENGTH_EXPECTED = 'a bare CSS length such as "0.5rem", "4px" or "0"';
|
|
46
|
-
const STACK_EXPECTED = 'a font-family list such as "Inter, system-ui, sans-serif"';
|
|
47
|
-
const CHANNELS_EXPECTED = 'space-separated RGB channels such as "31 110 178"';
|
|
48
|
-
|
|
49
52
|
/**
|
|
50
53
|
* Validate and freeze a brand. Every value is checked here rather than at render time, so a bad
|
|
51
54
|
* override fails at the app's entry point with the role that broke it named — not as a silently
|
|
52
55
|
* dropped declaration a human has to spot in devtools.
|
|
53
56
|
*/
|
|
54
57
|
export function defineTheme(input: BrandInput): Brand {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
+
const base = presetOf(input.preset);
|
|
59
|
+
const colors = {
|
|
60
|
+
light: layer(base?.colors.light, input.colors?.light),
|
|
61
|
+
dark: layer(base?.colors.dark, input.colors?.dark),
|
|
62
|
+
};
|
|
63
|
+
const shadows = {
|
|
64
|
+
light: layer(base?.shadows?.light, input.shadows?.light),
|
|
65
|
+
dark: layer(base?.shadows?.dark, input.shadows?.dark),
|
|
66
|
+
};
|
|
67
|
+
const light = [
|
|
68
|
+
...colorDeclarations(colors.light, 'colors.light'),
|
|
69
|
+
...shadowDeclarations(shadows.light, 'shadows.light'),
|
|
70
|
+
];
|
|
71
|
+
const dark = [
|
|
72
|
+
...colorDeclarations(colors.dark, 'colors.dark'),
|
|
73
|
+
...shadowDeclarations(shadows.dark, 'shadows.dark'),
|
|
74
|
+
];
|
|
58
75
|
// Measured AFTER the channels parse and BEFORE a single declaration is rendered: a palette that
|
|
59
76
|
// fails AA is not a stylesheet with a warning attached, it is a refusal. The pairings are the
|
|
60
77
|
// ones the framework's own palette is held to (`CONTRAST_PAIRS`), so an app cannot ship a brand
|
|
61
|
-
// the design system would have failed its own tests over.
|
|
62
|
-
|
|
63
|
-
assertContrast('
|
|
78
|
+
// the design system would have failed its own tests over. A preset is measured as part of the
|
|
79
|
+
// palette it builds — the merged result is what renders.
|
|
80
|
+
assertContrast('light', colors.light);
|
|
81
|
+
assertContrast('dark', colors.dark);
|
|
64
82
|
const root: string[] = [
|
|
65
83
|
...light,
|
|
66
|
-
...radiusDeclarations(input.radius),
|
|
67
|
-
...fontDeclarations(input.font),
|
|
84
|
+
...radiusDeclarations(layer(base?.radius, input.radius)),
|
|
85
|
+
...fontDeclarations(layer(base?.font, input.font)),
|
|
68
86
|
];
|
|
87
|
+
const blocks: string[] = [];
|
|
69
88
|
if (root.length > 0) blocks.push(rule(':root', root));
|
|
70
89
|
|
|
71
90
|
// `theme.scss` emits light at `:root`, dark behind the media query, and BOTH again under
|
|
72
91
|
// `html[data-theme]`. A brand that only wrote `:root` would lose to those attribute rules on
|
|
73
92
|
// specificity, so every level it emits is answered here, in the same order.
|
|
74
93
|
if (light.length > 0) blocks.push(rule("html[data-theme='light']", light));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
const media = [
|
|
95
|
+
...darkMediaDeclarations('color', COLOR_ROLES, colors, colorTokens.dark),
|
|
96
|
+
...darkMediaDeclarations('shadow', SHADOW_NAMES, shadows, shadowTokens.dark),
|
|
97
|
+
];
|
|
98
|
+
if (media.length > 0) {
|
|
99
|
+
blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', media))}\n}`);
|
|
78
100
|
}
|
|
101
|
+
if (dark.length > 0) blocks.push(rule("html[data-theme='dark']", dark));
|
|
79
102
|
|
|
80
103
|
return Object.freeze({ css: blocks.join('\n\n') });
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The named preset, or `undefined` for none. Own-property lookup: a brand read from a JSON config
|
|
108
|
+
* reaches here untyped, and `'toString'` must be a refusal, not `Object.prototype.toString`.
|
|
109
|
+
*/
|
|
110
|
+
function presetOf(name: ThemePresetName | undefined): ThemePreset | undefined {
|
|
111
|
+
if (name === undefined) return undefined;
|
|
112
|
+
if (!Object.hasOwn(THEME_PRESETS, name)) {
|
|
113
|
+
const known = THEME_PRESET_NAMES.map((preset) => `"${preset}"`).join(', ');
|
|
114
|
+
throw invalidBrandTokenError('input', 'preset', name, `a preset @ultimat3/ui ships: ${known}`);
|
|
115
|
+
}
|
|
116
|
+
return THEME_PRESETS[name];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `overrides` on top of `base`, key by key. An `undefined` value is no override — a spread would
|
|
121
|
+
* copy it and erase the preset's value for that key, rendering the shipped default in its place.
|
|
122
|
+
*/
|
|
123
|
+
function layer<V extends string>(
|
|
124
|
+
base: Readonly<Partial<Record<string, V>>> | undefined,
|
|
125
|
+
overrides: Readonly<Partial<Record<string, V>>> | undefined,
|
|
126
|
+
): Record<string, V> | undefined {
|
|
127
|
+
if (base === undefined && overrides === undefined) return undefined;
|
|
128
|
+
const out: Record<string, V> = {};
|
|
129
|
+
for (const source of [base, overrides]) {
|
|
130
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
131
|
+
if (value !== undefined) out[key] = value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
83
137
|
/** The exact tag to inline, after `global.scss` so the overrides land later in the cascade. */
|
|
84
138
|
export function brandStyleTag(brand: Brand): string {
|
|
85
139
|
return `<style>${brand.css}</style>`;
|
|
@@ -112,64 +166,26 @@ function indent(block: string): string {
|
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
169
|
+
* The dark media `:root` must answer every token the brand's own `:root` touched. That `:root`
|
|
170
|
+
* (light) comes after `theme.scss`'s dark media `:root` at equal specificity, so a role overridden
|
|
171
|
+
* in light only would otherwise reach an OS-dark document with no `data-theme` — scripting off, or
|
|
172
|
+
* storage throwing in the boot script — on the dark palette it was never measured against. Such a
|
|
173
|
+
* token is answered with the SHIPPED dark value. Called after both scopes were validated.
|
|
117
174
|
*/
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
function darkMediaDeclarations<K extends string>(
|
|
176
|
+
prefix: string,
|
|
177
|
+
names: readonly K[],
|
|
178
|
+
scopes: {
|
|
179
|
+
readonly light: Partial<Record<string, string>> | undefined;
|
|
180
|
+
readonly dark: Partial<Record<string, string>> | undefined;
|
|
181
|
+
},
|
|
182
|
+
shippedDark: Readonly<Record<K, string>>,
|
|
121
183
|
): string[] {
|
|
122
|
-
if (overrides === undefined) return [];
|
|
123
|
-
for (const role of Object.keys(overrides)) {
|
|
124
|
-
if (!(COLOR_ROLES as readonly string[]).includes(role)) {
|
|
125
|
-
throw unknownTokenError('color', role, COLOR_ROLES);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
const out: string[] = [];
|
|
129
|
-
for (const role of COLOR_ROLES) {
|
|
130
|
-
const value = overrides[role];
|
|
131
|
-
if (value === undefined) continue;
|
|
132
|
-
assertChannels(scope, role, value);
|
|
133
|
-
out.push(`--color-${role}: ${value};`);
|
|
134
|
-
}
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function radiusDeclarations(overrides: Partial<Record<RadiusName, string>> | undefined): string[] {
|
|
139
|
-
if (overrides === undefined) return [];
|
|
140
|
-
const known = Object.keys(radiusTokens) as RadiusName[];
|
|
141
|
-
for (const name of Object.keys(overrides)) {
|
|
142
|
-
if (!(known as readonly string[]).includes(name)) {
|
|
143
|
-
throw unknownTokenError('radius', name, known, '_radius.scss');
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
const out: string[] = [];
|
|
147
|
-
for (const name of known) {
|
|
148
|
-
const value = overrides[name];
|
|
149
|
-
if (value === undefined) continue;
|
|
150
|
-
if (!LENGTH_PATTERN.test(value)) {
|
|
151
|
-
throw invalidBrandTokenError('radius', name, value, LENGTH_EXPECTED);
|
|
152
|
-
}
|
|
153
|
-
out.push(`--radius-${name}: ${value};`);
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function fontDeclarations(overrides: Partial<Record<FontSlot, string>> | undefined): string[] {
|
|
159
|
-
if (overrides === undefined) return [];
|
|
160
|
-
for (const slot of Object.keys(overrides)) {
|
|
161
|
-
if (!(FONT_SLOTS as readonly string[]).includes(slot)) {
|
|
162
|
-
throw unknownTokenError('font', slot, FONT_SLOTS, '_typography.scss');
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
184
|
const out: string[] = [];
|
|
166
|
-
for (const
|
|
167
|
-
const value =
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
throw invalidBrandTokenError('font', slot, value, STACK_EXPECTED);
|
|
171
|
-
}
|
|
172
|
-
out.push(`--font-${slot}: ${value};`);
|
|
185
|
+
for (const name of names) {
|
|
186
|
+
const value =
|
|
187
|
+
scopes.dark?.[name] ?? (scopes.light?.[name] === undefined ? undefined : shippedDark[name]);
|
|
188
|
+
if (value !== undefined) out.push(`--${prefix}-${name}: ${value};`);
|
|
173
189
|
}
|
|
174
190
|
return out;
|
|
175
191
|
}
|
|
@@ -197,11 +213,3 @@ function assertContrast(
|
|
|
197
213
|
throw insufficientContrastError(theme, pair.what, pair.fg, pair.bg, ratio, pair.minimum);
|
|
198
214
|
}
|
|
199
215
|
}
|
|
200
|
-
|
|
201
|
-
function assertChannels(scope: string, role: string, value: string): void {
|
|
202
|
-
try {
|
|
203
|
-
parseChannels(value);
|
|
204
|
-
} catch {
|
|
205
|
-
throw invalidBrandTokenError(scope, role, value, CHANNELS_EXPECTED);
|
|
206
|
-
}
|
|
207
|
-
}
|
package/src/theme/context.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const UI_DEFAULT_CURRENCY = 'USD';
|
|
|
36
36
|
/**
|
|
37
37
|
* Loud-miss translator: a forgotten catalog key renders ⟦key⟧, never blank.
|
|
38
38
|
*
|
|
39
|
-
* Written here rather than as `
|
|
39
|
+
* Written here rather than as `catalogTranslator({}, locale)`, and behaviourally the same call: an
|
|
40
40
|
* empty catalog has no key, so every lookup is a miss, `has` is false, `raw` is undefined and
|
|
41
41
|
* `keys` is empty — `context.test.ts` holds each of those against `@ultimat3/i18n`'s own. What
|
|
42
42
|
* the spelling buys is that a browser chunk with a `UiProvider` in it no longer reaches the i18n
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The `scifi` preset: deep near-black panels in the dark, cool paper in the light, a cyan accent
|
|
2
|
+
// that turns magenta under the pointer, glowing edges on every raised surface, and sharp corners.
|
|
3
|
+
// Data, not a stylesheet — `defineTheme({ preset: 'scifi' })` renders it, and `presets.test.ts`
|
|
4
|
+
// holds it to every pairing in `CONTRAST_PAIRS` plus the chart series' colour-blind floor.
|
|
5
|
+
|
|
6
|
+
import type { ThemePreset } from './preset-shape';
|
|
7
|
+
|
|
8
|
+
export const SCIFI_PRESET: ThemePreset = {
|
|
9
|
+
colors: {
|
|
10
|
+
light: {
|
|
11
|
+
bg: '240 245 250',
|
|
12
|
+
'bg-soft': '229 236 244',
|
|
13
|
+
surface: '247 250 253',
|
|
14
|
+
'surface-raised': '255 255 255',
|
|
15
|
+
fg: '20 28 42',
|
|
16
|
+
'fg-strong': '4 8 18',
|
|
17
|
+
'fg-muted': '70 84 104',
|
|
18
|
+
line: '176 192 212',
|
|
19
|
+
scrim: '2 4 10',
|
|
20
|
+
accent: '0 108 135',
|
|
21
|
+
'accent-strong': '168 0 124',
|
|
22
|
+
'accent-fg': '255 255 255',
|
|
23
|
+
success: '0 112 70',
|
|
24
|
+
'success-soft': '220 244 234',
|
|
25
|
+
'success-fg': '255 255 255',
|
|
26
|
+
warning: '140 82 0',
|
|
27
|
+
'warning-soft': '252 240 214',
|
|
28
|
+
'warning-fg': '255 255 255',
|
|
29
|
+
danger: '185 30 50',
|
|
30
|
+
'danger-soft': '253 228 232',
|
|
31
|
+
'danger-fg': '255 255 255',
|
|
32
|
+
info: '30 90 190',
|
|
33
|
+
'info-soft': '225 236 252',
|
|
34
|
+
'info-fg': '255 255 255',
|
|
35
|
+
'chart-1': '0 112 140',
|
|
36
|
+
'chart-2': '179 5 130',
|
|
37
|
+
'chart-3': '189 107 14',
|
|
38
|
+
'chart-4': '59 110 49',
|
|
39
|
+
'chart-5': '70 70 210',
|
|
40
|
+
'chart-6': '188 28 13',
|
|
41
|
+
'chart-7': '106 97 20',
|
|
42
|
+
'chart-8': '60 60 60',
|
|
43
|
+
},
|
|
44
|
+
dark: {
|
|
45
|
+
bg: '5 7 13',
|
|
46
|
+
'bg-soft': '9 12 21',
|
|
47
|
+
surface: '13 17 29',
|
|
48
|
+
'surface-raised': '20 26 42',
|
|
49
|
+
fg: '210 225 240',
|
|
50
|
+
'fg-strong': '240 250 255',
|
|
51
|
+
'fg-muted': '125 145 170',
|
|
52
|
+
line: '44 60 88',
|
|
53
|
+
scrim: '0 0 0',
|
|
54
|
+
accent: '0 229 255',
|
|
55
|
+
'accent-strong': '255 92 214',
|
|
56
|
+
'accent-fg': '2 8 16',
|
|
57
|
+
success: '40 230 150',
|
|
58
|
+
'success-soft': '6 36 28',
|
|
59
|
+
'success-fg': '2 16 10',
|
|
60
|
+
warning: '255 200 60',
|
|
61
|
+
'warning-soft': '40 30 6',
|
|
62
|
+
'warning-fg': '22 14 0',
|
|
63
|
+
danger: '255 95 120',
|
|
64
|
+
'danger-soft': '46 12 22',
|
|
65
|
+
'danger-fg': '26 4 10',
|
|
66
|
+
info: '110 170 255',
|
|
67
|
+
'info-soft': '10 26 50',
|
|
68
|
+
'info-fg': '2 10 24',
|
|
69
|
+
'chart-1': '48 229 255',
|
|
70
|
+
'chart-2': '238 88 205',
|
|
71
|
+
'chart-3': '170 255 90',
|
|
72
|
+
'chart-4': '255 175 26',
|
|
73
|
+
'chart-5': '148 130 255',
|
|
74
|
+
'chart-6': '255 110 90',
|
|
75
|
+
'chart-7': '88 198 148',
|
|
76
|
+
'chart-8': '204 214 216',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
// Dark only: a black drop shadow is invisible on a near-black page, so elevation there is an
|
|
80
|
+
// accent hairline plus a halo, with the drop kept under it for depth. Light keeps the shipped
|
|
81
|
+
// rungs, whose glows already read the (now cyan) accent.
|
|
82
|
+
shadows: {
|
|
83
|
+
dark: {
|
|
84
|
+
xs: '0 0 0 1px rgb(var(--color-accent) / 0.14)',
|
|
85
|
+
sm: '0 0 0 1px rgb(var(--color-accent) / 0.18), 0 2px 10px rgb(0 0 0 / 0.6)',
|
|
86
|
+
md: '0 0 0 1px rgb(var(--color-accent) / 0.22), 0 0 18px rgb(var(--color-accent) / 0.12), 0 8px 24px rgb(0 0 0 / 0.6)',
|
|
87
|
+
lg: '0 0 0 1px rgb(var(--color-accent) / 0.26), 0 0 28px rgb(var(--color-accent) / 0.14), 0 16px 40px rgb(0 0 0 / 0.7)',
|
|
88
|
+
xl: '0 0 0 1px rgb(var(--color-accent) / 0.3), 0 0 40px rgb(var(--color-accent) / 0.16), 0 28px 64px rgb(0 0 0 / 0.75)',
|
|
89
|
+
'glow-sm': '0 0 8px rgb(var(--color-accent) / 0.6)',
|
|
90
|
+
'glow-md': '0 0 16px rgb(var(--color-accent) / 0.5)',
|
|
91
|
+
'glow-lg': '0 0 32px rgb(var(--color-accent) / 0.45)',
|
|
92
|
+
// Coloured elevation in the second accent, so a lifted card reads magenta beside cyan edges.
|
|
93
|
+
'tinted-sm': '0 2px 10px rgb(var(--color-accent-strong) / 0.3)',
|
|
94
|
+
'tinted-md': '0 6px 20px rgb(var(--color-accent-strong) / 0.34)',
|
|
95
|
+
'tinted-lg': '0 14px 40px rgb(var(--color-accent-strong) / 0.38)',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
radius: { sm: '0.125rem', md: '0.25rem', lg: '0.375rem', xl: '0.5rem' },
|
|
99
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// What a theme preset IS: a complete palette for both themes plus optional shadow, radius and font
|
|
2
|
+
// overrides — the same slots `defineTheme()` takes, with colours made total so a preset is measured
|
|
3
|
+
// alone and never leans on the default palette to pass.
|
|
4
|
+
|
|
5
|
+
import type { ColorRole, RadiusName, ShadowName, Theme } from '../tokens/tokens';
|
|
6
|
+
|
|
7
|
+
/** The font slots `_typography.scss` emits — `defineTheme()` validates against this list. */
|
|
8
|
+
export const FONT_SLOTS = ['sans', 'mono', 'data'] as const;
|
|
9
|
+
export type FontSlot = (typeof FONT_SLOTS)[number];
|
|
10
|
+
|
|
11
|
+
export interface ThemePreset {
|
|
12
|
+
readonly colors: Readonly<Record<Theme, Readonly<Record<ColorRole, string>>>>;
|
|
13
|
+
readonly shadows?: Readonly<
|
|
14
|
+
Partial<Record<Theme, Readonly<Partial<Record<ShadowName, string>>>>>
|
|
15
|
+
>;
|
|
16
|
+
readonly radius?: Readonly<Partial<Record<RadiusName, string>>>;
|
|
17
|
+
readonly font?: Readonly<Partial<Record<FontSlot, string>>>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The presets `defineTheme({ preset })` accepts. A preset is a BASE the app's own overrides layer
|
|
2
|
+
// onto, role by role — never a mode with rules of its own — so every check the brand seam makes
|
|
3
|
+
// runs over the merged result. Adding one is a row here plus its file; `presets.test.ts` measures
|
|
4
|
+
// every row against the full contrast table.
|
|
5
|
+
|
|
6
|
+
import { SCIFI_PRESET } from './preset-scifi';
|
|
7
|
+
import type { ThemePreset } from './preset-shape';
|
|
8
|
+
|
|
9
|
+
export const THEME_PRESETS = {
|
|
10
|
+
scifi: SCIFI_PRESET,
|
|
11
|
+
} as const satisfies Readonly<Record<string, ThemePreset>>;
|
|
12
|
+
|
|
13
|
+
export type ThemePresetName = keyof typeof THEME_PRESETS;
|
|
14
|
+
|
|
15
|
+
export const THEME_PRESET_NAMES = Object.keys(THEME_PRESETS) as readonly ThemePresetName[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The grammar a `defineTheme()` shadow override must match. Validated, never escaped: the value is
|
|
2
|
+
// written into a `<style>` element, so the only safe answer to a `;`, a `}` or a `</style>` is a
|
|
3
|
+
// refusal. Strict enough to hold that line, wide enough for every rung `_shadow.scss` ships.
|
|
4
|
+
|
|
5
|
+
import { COLOR_ROLES } from '../tokens/color-tokens';
|
|
6
|
+
|
|
7
|
+
/** `0`, or a signed length in a unit a shadow is written in. No `calc()`, no `var()`. */
|
|
8
|
+
const LENGTH = String.raw`(?:0|-?\d+(?:\.\d+)?(?:px|rem|em))`;
|
|
9
|
+
const ALPHA = String.raw`(?:0|1|0?\.\d+)`;
|
|
10
|
+
const CHANNEL = String.raw`\d{1,3}`;
|
|
11
|
+
/** A role reference, so a shadow can follow the theme — the shape `t.role()` compiles to. */
|
|
12
|
+
const ROLE_COLOUR = String.raw`rgb\(var\(--color-([a-z0-9-]+)\) / ${ALPHA}\)`;
|
|
13
|
+
const CHANNEL_COLOUR = String.raw`rgb\((${CHANNEL}) (${CHANNEL}) (${CHANNEL})(?: / ${ALPHA})?\)`;
|
|
14
|
+
const LAYER = `(?:inset )?${LENGTH} ${LENGTH}(?: ${LENGTH}){0,2} (?:${ROLE_COLOUR}|${CHANNEL_COLOUR})`;
|
|
15
|
+
|
|
16
|
+
const LAYER_PATTERN = new RegExp(`^${LAYER}$`);
|
|
17
|
+
|
|
18
|
+
/** Long enough for five layers; a value past it is not a shadow anyone designed. */
|
|
19
|
+
const MAX_LENGTH = 400;
|
|
20
|
+
|
|
21
|
+
export const SHADOW_EXPECTED =
|
|
22
|
+
'a box-shadow such as "0 4px 14px rgb(17 15 13 / 0.1)" or "0 0 14px rgb(var(--color-accent) / 0.4)" — layers comma-separated, colours as rgb() over channels or a colour role, or "none"';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether `value` is a shadow this seam will write. Every layer is matched whole, every channel is
|
|
26
|
+
* 0–255, and every role a layer names is a role the palette has — a `--color-brand` that resolves
|
|
27
|
+
* to nothing would render no shadow at all and no error anywhere.
|
|
28
|
+
*/
|
|
29
|
+
export function isShadowValue(value: string): boolean {
|
|
30
|
+
if (value === 'none') return true;
|
|
31
|
+
if (value.length === 0 || value.length > MAX_LENGTH) return false;
|
|
32
|
+
return value.split(', ').every((layer) => {
|
|
33
|
+
const match = LAYER_PATTERN.exec(layer);
|
|
34
|
+
if (match === null) return false;
|
|
35
|
+
const [, role, r, g, b] = match;
|
|
36
|
+
if (role !== undefined) return (COLOR_ROLES as readonly string[]).includes(role);
|
|
37
|
+
return [r, g, b].every((channel) => Number(channel) <= 255);
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/theme/theme.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
// Theme resolution. One rule: an explicit choice in storage wins,
|
|
2
|
-
//
|
|
3
|
-
// testable without a DOM and reusable on the server.
|
|
1
|
+
// Theme resolution. One rule: an explicit choice in storage wins, then the theme the boot script
|
|
2
|
+
// already stamped on the document, then the app's `theme.defaultMode` (the OS for `'system'`). Every side effect
|
|
3
|
+
// goes through an injected `ThemeEnv`, so this is testable without a DOM and reusable on the server.
|
|
4
4
|
|
|
5
|
+
import { THEME_STORAGE_KEY } from '@ultimat3/core';
|
|
5
6
|
import { invalidThemeError, runtimeMissingError } from '../errors';
|
|
6
7
|
import type { Theme } from '../tokens/tokens';
|
|
7
8
|
|
|
8
9
|
export type { Theme };
|
|
9
10
|
|
|
10
|
-
export const THEME_STORAGE_KEY = 'ultimate.theme';
|
|
11
11
|
export const THEME_ATTRIBUTE = 'data-theme';
|
|
12
|
+
/** Where the boot script records `theme.defaultMode` itself — `'light'`, `'dark'` or `'system'`.
|
|
13
|
+
* `@ultimat3/render`'s `themeScriptBody` writes `${attribute}-default`; this is that name. */
|
|
14
|
+
export const THEME_DEFAULT_ATTRIBUTE = `${THEME_ATTRIBUTE}-default`;
|
|
12
15
|
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
|
13
16
|
|
|
14
17
|
/** The host capabilities theme control needs. Injected so tests need no DOM. */
|
|
15
18
|
export interface ThemeEnv {
|
|
16
19
|
read(): string | null;
|
|
20
|
+
/** The theme the document carries right now — what the boot script stamped, or the last apply. */
|
|
21
|
+
current(): string | null;
|
|
22
|
+
/** The app's `theme.defaultMode` as the boot stamped it, or `null` when no boot ran. */
|
|
23
|
+
appDefault(): string | null;
|
|
17
24
|
write(value: string): void;
|
|
18
25
|
remove(): void;
|
|
19
26
|
prefersDark(): boolean;
|
|
@@ -40,9 +47,25 @@ export function osTheme(env: ThemeEnv): Theme {
|
|
|
40
47
|
return env.prefersDark() ? 'dark' : 'light';
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* What "no explicit choice" means for this app: its fixed `theme.defaultMode` when it has one, the
|
|
52
|
+
* OS when it is `'system'` or unknown. Clearing a choice and following the OS both answer this, so
|
|
53
|
+
* an app that opens dark is never flipped light by an OS change or a "system" pick.
|
|
54
|
+
*/
|
|
55
|
+
export function defaultTheme(env: ThemeEnv): Theme {
|
|
56
|
+
const fixed = env.appDefault();
|
|
57
|
+
return isTheme(fixed) ? fixed : osTheme(env);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolution order: explicit stored choice -> the theme on the document -> OS preference. The
|
|
62
|
+
* middle step is the app's default: with `theme.defaultMode: 'dark'` the boot script stamps dark
|
|
63
|
+
* on a light-OS machine, and answering the OS here made the first toggle write the theme already
|
|
64
|
+
* on screen — a click that did nothing.
|
|
65
|
+
*/
|
|
44
66
|
export function resolveTheme(env: ThemeEnv): Theme {
|
|
45
|
-
|
|
67
|
+
const current = env.current();
|
|
68
|
+
return storedTheme(env) ?? (isTheme(current) ? current : defaultTheme(env));
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
/** Run once on load. Idempotent — safe after the inline head script. */
|
|
@@ -60,10 +83,10 @@ export function setTheme(theme: Theme, env: ThemeEnv = browserThemeEnv()): Theme
|
|
|
60
83
|
return theme;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
|
-
/** Forget the explicit choice and
|
|
86
|
+
/** Forget the explicit choice and return to the app's default (the OS, for `'system'`). */
|
|
64
87
|
export function clearTheme(env: ThemeEnv = browserThemeEnv()): Theme {
|
|
65
88
|
env.remove();
|
|
66
|
-
const theme =
|
|
89
|
+
const theme = defaultTheme(env);
|
|
67
90
|
env.apply(theme);
|
|
68
91
|
return theme;
|
|
69
92
|
}
|
|
@@ -74,12 +97,12 @@ export function toggleTheme(env: ThemeEnv = browserThemeEnv()): Theme {
|
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
/**
|
|
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.
|
|
100
|
+
* Follow the OS live — but only while the user has made no explicit choice AND the app's default
|
|
101
|
+
* is the OS. Returns an unsubscribe so a component can drop it on cleanup.
|
|
79
102
|
*/
|
|
80
103
|
export function watchOsTheme(env: ThemeEnv = browserThemeEnv()): () => void {
|
|
81
104
|
return env.observeOs(() => {
|
|
82
|
-
if (storedTheme(env) === null) env.apply(osTheme(env));
|
|
105
|
+
if (storedTheme(env) === null && !isTheme(env.appDefault())) env.apply(osTheme(env));
|
|
83
106
|
});
|
|
84
107
|
}
|
|
85
108
|
|
|
@@ -94,6 +117,8 @@ export function browserThemeEnv(): ThemeEnv {
|
|
|
94
117
|
const media = window.matchMedia(THEME_MEDIA_QUERY);
|
|
95
118
|
return {
|
|
96
119
|
read: () => safeStorage(() => window.localStorage.getItem(THEME_STORAGE_KEY), null),
|
|
120
|
+
current: () => document.documentElement.getAttribute(THEME_ATTRIBUTE),
|
|
121
|
+
appDefault: () => document.documentElement.getAttribute(THEME_DEFAULT_ATTRIBUTE),
|
|
97
122
|
write: (value) => safeStorage(() => window.localStorage.setItem(THEME_STORAGE_KEY, value)),
|
|
98
123
|
remove: () => safeStorage(() => window.localStorage.removeItem(THEME_STORAGE_KEY)),
|
|
99
124
|
prefersDark: () => media.matches,
|
package/src/toast/toast-state.ts
CHANGED
|
Binary file
|
package/src/toast/toast-store.ts
CHANGED
|
@@ -84,7 +84,7 @@ function defaultEnv(): ToastEnv {
|
|
|
84
84
|
return typeof document === 'undefined' ? INERT_TOAST_ENV : browserToastEnv();
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export function
|
|
87
|
+
export function toastStore(env: ToastEnv = defaultEnv()): ToastStore {
|
|
88
88
|
const holds = new Set<ToastHold>();
|
|
89
89
|
const listeners = new Set<(queue: ToastQueue) => void>();
|
|
90
90
|
let queue: ToastQueue = EMPTY_TOAST_QUEUE;
|
package/src/toast/use-toasts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The reactive shell over `
|
|
1
|
+
// The reactive shell over `toastStore`, and the whole of what it adds: the queue lands in a
|
|
2
2
|
// signal, so `<Toaster>` re-renders when a message arrives or a dwell runs out.
|
|
3
3
|
//
|
|
4
4
|
// The subscription lives in an EFFECT, which is the package's one seam for DOM-only work: a server
|
package/src/tokens/_colors.scss
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Canonical colour token source. Semantic roles only, stored as space-separated
|
|
2
2
|
// RGB channels so `rgb(var(--color-x) / 0.5)` composites without a second token.
|
|
3
3
|
// `tokens.ts` mirrors these values; `x verify` fails if the two drift.
|
|
4
|
+
//
|
|
5
|
+
// `chart-1` … `chart-8` are the categorical series: Okabe–Ito hues, darkened for the light page and
|
|
6
|
+
// lifted for the dark one until each clears 3:1 on every surface and every pair stays apart under
|
|
7
|
+
// protan/deutan/tritan simulation (`contrast.test.ts`, `colour-vision.test.ts`).
|
|
4
8
|
|
|
5
9
|
$light: (
|
|
6
10
|
bg: 253 246 240,
|
|
@@ -27,6 +31,14 @@ $light: (
|
|
|
27
31
|
info: 31 110 178,
|
|
28
32
|
info-soft: 224 239 252,
|
|
29
33
|
info-fg: 255 255 255,
|
|
34
|
+
chart-1: 0 107 170,
|
|
35
|
+
chart-2: 189 107 14,
|
|
36
|
+
chart-3: 0 125 90,
|
|
37
|
+
chart-4: 189 44 16,
|
|
38
|
+
chart-5: 170 70 140,
|
|
39
|
+
chart-6: 47 135 195,
|
|
40
|
+
chart-7: 118 97 18,
|
|
41
|
+
chart-8: 60 60 60,
|
|
30
42
|
);
|
|
31
43
|
|
|
32
44
|
$dark: (
|
|
@@ -54,6 +66,14 @@ $dark: (
|
|
|
54
66
|
info: 96 170 240,
|
|
55
67
|
info-soft: 22 38 56,
|
|
56
68
|
info-fg: 12 20 30,
|
|
69
|
+
chart-1: 105 201 255,
|
|
70
|
+
chart-2: 230 153 0,
|
|
71
|
+
chart-3: 0 167 113,
|
|
72
|
+
chart-4: 236 111 60,
|
|
73
|
+
chart-5: 203 126 189,
|
|
74
|
+
chart-6: 124 153 252,
|
|
75
|
+
chart-7: 246 234 72,
|
|
76
|
+
chart-8: 181 181 180,
|
|
57
77
|
);
|
|
58
78
|
|
|
59
79
|
/// The tone vocabulary, in the order components expose it. Mirrors `TONES` in
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Surface effects a dashboard opts into: a glowing edge and a grid/scanline ground. Paired with
|
|
2
|
+
// the `scifi` preset (`defineTheme({ preset: 'scifi' })`) and meaningful without it — every colour
|
|
3
|
+
// is a role and every glow a shadow token, so each follows whatever palette the page resolved.
|
|
4
|
+
// Nothing here animates, so there is no motion for `prefers-reduced-motion` to take away.
|
|
5
|
+
|
|
6
|
+
@use 'colors';
|
|
7
|
+
@use 'shadow';
|
|
8
|
+
@use 'space';
|
|
9
|
+
@use 'stroke';
|
|
10
|
+
|
|
11
|
+
/// A hairline ring in the accent plus a glow rung, as one `box-shadow`. For a live tile, a focused
|
|
12
|
+
/// panel, a selected series. Forced-colours mode drops box shadows itself; the `border` an
|
|
13
|
+
/// element already has is what remains there, which is why this draws no border of its own.
|
|
14
|
+
@mixin glow-edge($glow: 'glow-sm', $alpha: 0.55) {
|
|
15
|
+
box-shadow:
|
|
16
|
+
inset 0 0 0 stroke.stroke(hairline) colors.role('accent', $alpha),
|
|
17
|
+
shadow.shadow($glow);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// A faint accent grid on one spacing pitch, with optional horizontal scanlines over it: the
|
|
21
|
+
/// "instrument panel" ground. Only `background-image` and `background-size` are written, so the
|
|
22
|
+
/// element's own `background-color` shows through. Withdrawn under `prefers-contrast: more` and
|
|
23
|
+
/// `forced-colors: active`, where texture behind text is noise and not atmosphere.
|
|
24
|
+
@mixin grid-texture($pitch: space.space(6), $alpha: 0.08, $scanlines: true) {
|
|
25
|
+
$grid-x: linear-gradient(
|
|
26
|
+
90deg,
|
|
27
|
+
colors.role('accent', $alpha) stroke.stroke(hairline),
|
|
28
|
+
transparent stroke.stroke(hairline)
|
|
29
|
+
);
|
|
30
|
+
$grid-y: linear-gradient(
|
|
31
|
+
180deg,
|
|
32
|
+
colors.role('accent', $alpha) stroke.stroke(hairline),
|
|
33
|
+
transparent stroke.stroke(hairline)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
@if $scanlines {
|
|
37
|
+
background-image:
|
|
38
|
+
repeating-linear-gradient(
|
|
39
|
+
180deg,
|
|
40
|
+
colors.role('fg', $alpha * 0.5) 0 stroke.stroke(hairline),
|
|
41
|
+
transparent stroke.stroke(hairline) stroke.stroke(heavy)
|
|
42
|
+
),
|
|
43
|
+
$grid-x,
|
|
44
|
+
$grid-y;
|
|
45
|
+
background-size:
|
|
46
|
+
auto,
|
|
47
|
+
$pitch $pitch,
|
|
48
|
+
$pitch $pitch;
|
|
49
|
+
} @else {
|
|
50
|
+
background-image: $grid-x, $grid-y;
|
|
51
|
+
background-size:
|
|
52
|
+
$pitch $pitch,
|
|
53
|
+
$pitch $pitch;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@media (prefers-contrast: more), (forced-colors: active) {
|
|
57
|
+
background-image: none;
|
|
58
|
+
}
|
|
59
|
+
}
|