@ultimat3/render 20.1.6 → 20.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "20.1.6",
3
+ "version": "20.2.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,10 +36,10 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "20.1.6",
40
- "@ultimat3/core": "20.1.6",
41
- "@ultimat3/i18n": "20.1.6",
42
- "@ultimat3/seo": "20.1.6",
39
+ "@ultimat3/cache": "20.2.0",
40
+ "@ultimat3/core": "20.2.0",
41
+ "@ultimat3/i18n": "20.2.0",
42
+ "@ultimat3/seo": "20.2.0",
43
43
  "sass": "1.102.0"
44
44
  }
45
45
  }
package/src/head.ts CHANGED
@@ -161,10 +161,17 @@ function carriesJson(tag: HeadTag): boolean {
161
161
  return type.trim().toLowerCase().endsWith('json');
162
162
  }
163
163
 
164
+ export type ThemeFallback = 'light' | 'dark' | 'system';
165
+
164
166
  export interface ThemeScriptOptions {
165
167
  /** Attribute the tokens key off. Never a class, never a raw colour. */
166
168
  readonly attribute?: string;
167
169
  readonly storageKey?: string;
170
+ /**
171
+ * What a visitor with no stored choice gets. `'system'` (the default) asks the OS; `'dark'` or
172
+ * `'light'` is the app's own opinion — the seam `theme.defaultMode` in `app.config.ts` reaches.
173
+ */
174
+ readonly fallback?: ThemeFallback;
168
175
  /** Hard cap; a theme script that grows past this is no longer "one inlined script". */
169
176
  readonly maxBytes?: number;
170
177
  }
@@ -172,22 +179,45 @@ export interface ThemeScriptOptions {
172
179
  export const THEME_SCRIPT_MAX_BYTES = 512;
173
180
 
174
181
  /**
175
- * The injection point for the no-flash theme flip. It sets a semantic attribute and
176
- * nothing else — every colour is a token, so the whole scheme swap is one attribute.
177
- * Returns a `HeadTag` so it participates in dedupe like any other tag.
182
+ * The storage key `@ultimat3/ui`'s `ThemeToggle` reads (`THEME_STORAGE_KEY` there). One literal on
183
+ * each side, pinned equal by a test in `@ultimat3/cli`: render sits below ui in the tier table and
184
+ * cannot import it, and the two keys disagreeing was exactly the bug — the boot stamped one key,
185
+ * the toggle wrote another, and a visitor's choice never survived a reload.
178
186
  */
179
- export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
187
+ export const THEME_STORAGE_KEY = 'ultimate.theme';
188
+
189
+ /**
190
+ * The script's text alone, so the CSP hash and the tag come from one string: a policy hashed from
191
+ * a restatement admits a script that is not the one the document carries.
192
+ *
193
+ * Only `"light"` and `"dark"` are honoured from storage — anything else (a stale value, a typo,
194
+ * another app's key) falls through to `fallback`, so no page can be stamped with a scheme the
195
+ * tokens have no block for.
196
+ */
197
+ export function themeScriptBody(options: ThemeScriptOptions = {}): string {
180
198
  const attribute = options.attribute ?? 'data-theme';
181
- const storageKey = options.storageKey ?? 'x-theme';
199
+ const storageKey = options.storageKey ?? THEME_STORAGE_KEY;
200
+ const fallback = options.fallback ?? 'system';
201
+ const os = 'matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"';
202
+ const otherwise = fallback === 'system' ? os : JSON.stringify(fallback);
182
203
  // `JSON.stringify`, never a value pasted between two quotes: both options land INSIDE a JS
183
204
  // string in a `<script>` body, where one `"` ends the string and the rest is code the page runs.
184
205
  // Author-supplied today — the same status every `emitIslandAttributes` value had before it was
185
206
  // routed through `html.ts`. The element's own raw-text rule is applied by `renderTag` below.
186
- const source =
187
- `try{var t=localStorage.getItem(${JSON.stringify(storageKey)})||` +
188
- `(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");` +
189
- `document.documentElement.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`;
207
+ return (
208
+ `try{var s=localStorage.getItem(${JSON.stringify(storageKey)}),` +
209
+ `t=s==="light"||s==="dark"?s:${otherwise};` +
210
+ `document.documentElement.setAttribute(${JSON.stringify(attribute)},t)}catch(e){}`
211
+ );
212
+ }
190
213
 
214
+ /**
215
+ * The injection point for the no-flash theme flip. It sets a semantic attribute and
216
+ * nothing else — every colour is a token, so the whole scheme swap is one attribute.
217
+ * Returns a `HeadTag` so it participates in dedupe like any other tag.
218
+ */
219
+ export function themeScript(options: ThemeScriptOptions = {}): HeadTag {
220
+ const source = themeScriptBody(options);
191
221
  const bytes = new TextEncoder().encode(source).byteLength;
192
222
  // `bytes > NaN` is false for every script, so a cap that arrived non-finite does not admit a
193
223
  // bigger script — it removes the only budget a 0kb `site/` route has.
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ export type {
42
42
  HeadTagKind,
43
43
  LdRenderer,
44
44
  MetaRenderer,
45
+ ThemeFallback,
45
46
  ThemeScriptOptions,
46
47
  } from './head';
47
48
  export {
@@ -50,7 +51,9 @@ export {
50
51
  mergeHead,
51
52
  renderHead,
52
53
  THEME_SCRIPT_MAX_BYTES,
54
+ THEME_STORAGE_KEY,
53
55
  themeScript,
56
+ themeScriptBody,
54
57
  } from './head';
55
58
  export { headTagKey, seoRenderers, toHeadTag } from './head-seo';
56
59
  export type { IslandDirective } from './hydrate';