@ultimat3/ui 23.0.0 → 24.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 +2 -2
- package/CLAUDE.md +3 -3
- package/README.md +10 -9
- package/package.json +5 -5
- package/src/a11y.ts +24 -10
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.tsx +50 -47
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Meter.tsx +3 -3
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +6 -2
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +2 -6
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/theme/inline-script.ts +0 -49
package/src/theme/brand.ts
CHANGED
|
@@ -72,10 +72,11 @@ export function defineTheme(input: BrandInput): Brand {
|
|
|
72
72
|
// `html[data-theme]`. A brand that only wrote `:root` would lose to those attribute rules on
|
|
73
73
|
// specificity, so every level it emits is answered here, in the same order.
|
|
74
74
|
if (light.length > 0) blocks.push(rule("html[data-theme='light']", light));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
blocks.push(
|
|
75
|
+
const media = darkMediaDeclarations(input.colors);
|
|
76
|
+
if (media.length > 0) {
|
|
77
|
+
blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', media))}\n}`);
|
|
78
78
|
}
|
|
79
|
+
if (dark.length > 0) blocks.push(rule("html[data-theme='dark']", dark));
|
|
79
80
|
|
|
80
81
|
return Object.freeze({ css: blocks.join('\n\n') });
|
|
81
82
|
}
|
|
@@ -111,6 +112,24 @@ function indent(block: string): string {
|
|
|
111
112
|
.join('\n');
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The dark media `:root` must answer every role the brand's own `:root` touched. That `:root`
|
|
117
|
+
* (light) comes after `theme.scss`'s dark media `:root` at equal specificity, so a role overridden
|
|
118
|
+
* in light only would otherwise reach an OS-dark document with no `data-theme` — scripting off, or
|
|
119
|
+
* storage throwing in the boot script — on the dark palette it was never measured against. Such a
|
|
120
|
+
* role is answered with the SHIPPED dark channels. Called after both scopes were validated.
|
|
121
|
+
*/
|
|
122
|
+
function darkMediaDeclarations(colors: BrandInput['colors']): string[] {
|
|
123
|
+
const out: string[] = [];
|
|
124
|
+
for (const role of COLOR_ROLES) {
|
|
125
|
+
const value =
|
|
126
|
+
colors?.dark?.[role] ??
|
|
127
|
+
(colors?.light?.[role] === undefined ? undefined : colorTokens.dark[role]);
|
|
128
|
+
if (value !== undefined) out.push(`--color-${role}: ${value};`);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
/**
|
|
115
134
|
* Ordered by the canonical role list, not by the caller's object — a brand file rendered twice
|
|
116
135
|
* must be byte-identical, or every consumer's CSP hash and diff churns for nothing.
|
package/src/theme/theme.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
import { invalidThemeError, runtimeMissingError } from '../errors';
|
|
6
6
|
import type { Theme } from '../tokens/tokens';
|
|
@@ -9,11 +9,18 @@ export type { Theme };
|
|
|
9
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
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
// The blocking <head> snippet that kills the flash of wrong theme. Built from the
|
|
2
|
-
// same constants as theme.ts so the two can never disagree. Server/build-only:
|
|
3
|
-
// the hash is computed with Bun's hasher for the CSP header.
|
|
4
|
-
//
|
|
5
|
-
// DEPRECATED `As of 20.2.0`: the framework's boot inlines this script itself, from
|
|
6
|
-
// `@ultimat3/render`'s `themeScript({ fallback })` with `theme.defaultMode` in `app.config.ts` as
|
|
7
|
-
// the fallback, and admits it to the CSP — an app no longer writes either half. This module stays
|
|
8
|
-
// exported for one major so an app that inlined it by hand keeps building; it is removed in 21.
|
|
9
|
-
|
|
10
|
-
import { runtimeMissingError } from '../errors';
|
|
11
|
-
import { THEME_ATTRIBUTE, THEME_MEDIA_QUERY, THEME_STORAGE_KEY } from './theme';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Minified on purpose — this runs before first paint and is inlined into every
|
|
15
|
-
* document. Reads the explicit choice, falls back to the OS, and always stamps
|
|
16
|
-
* `data-theme` so screenshots and Playwright runs are deterministic.
|
|
17
|
-
*/
|
|
18
|
-
export const THEME_INLINE_SCRIPT =
|
|
19
|
-
`try{var d=document.documentElement,` +
|
|
20
|
-
`s=localStorage.getItem('${THEME_STORAGE_KEY}');` +
|
|
21
|
-
`d.setAttribute('${THEME_ATTRIBUTE}',` +
|
|
22
|
-
`s==='light'||s==='dark'?s:` +
|
|
23
|
-
`matchMedia('${THEME_MEDIA_QUERY}').matches?'dark':'light')}catch(e){}`;
|
|
24
|
-
|
|
25
|
-
/** The exact tag to inline. No attributes, so the CSP hash stays valid. */
|
|
26
|
-
export function themeInlineScriptTag(): string {
|
|
27
|
-
return `<script>${THEME_INLINE_SCRIPT}</script>`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let cachedHash: string | null = null;
|
|
31
|
-
|
|
32
|
-
/** `sha256-<base64>` for `script-src`. Stable for a given script text. */
|
|
33
|
-
export function themeInlineScriptHash(): string {
|
|
34
|
-
if (cachedHash !== null) return cachedHash;
|
|
35
|
-
if (typeof Bun === 'undefined') {
|
|
36
|
-
throw runtimeMissingError(
|
|
37
|
-
'Bun.CryptoHasher to hash the theme inline script',
|
|
38
|
-
'call themeInlineScriptHash() during build or SSR, never in browser code',
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
const digest = new Bun.CryptoHasher('sha256').update(THEME_INLINE_SCRIPT).digest('base64');
|
|
42
|
-
cachedHash = `sha256-${digest}`;
|
|
43
|
-
return cachedHash;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Quoted CSP source expression, ready to concatenate into a `script-src`. */
|
|
47
|
-
export function themeInlineScriptCspSource(): string {
|
|
48
|
-
return `'${themeInlineScriptHash()}'`;
|
|
49
|
-
}
|