@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.
@@ -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
- if (dark.length > 0) {
76
- blocks.push(`@media (prefers-color-scheme: dark) {\n${indent(rule(':root', dark))}\n}`);
77
- blocks.push(rule("html[data-theme='dark']", dark));
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.
@@ -1,6 +1,6 @@
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.
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
- /** Resolution order: explicit stored choice -> OS preference. */
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
- return storedTheme(env) ?? osTheme(env);
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 follow the OS again. */
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 = osTheme(env);
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,
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
- }