@ultimat3/ui 22.15.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.
Files changed (65) hide show
  1. package/CATALOG.md +45 -14
  2. package/CLAUDE.md +16 -52
  3. package/README.md +69 -13
  4. package/package.json +6 -5
  5. package/src/a11y.ts +24 -10
  6. package/src/catalog/parse-component.ts +85 -12
  7. package/src/catalog/render-catalog.ts +21 -1
  8. package/src/components/Avatar.module.scss +1 -1
  9. package/src/components/BarChart.module.scss +12 -1
  10. package/src/components/BarChart.tsx +41 -15
  11. package/src/components/Button.tsx +3 -8
  12. package/src/components/Card.module.scss +1 -1
  13. package/src/components/Checkbox.module.scss +4 -4
  14. package/src/components/Checkbox.tsx +6 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/DataTable.module.scss +2 -0
  17. package/src/components/DataTable.tsx +135 -71
  18. package/src/components/Dialog.module.scss +2 -2
  19. package/src/components/Dropzone.module.scss +6 -4
  20. package/src/components/Dropzone.tsx +9 -8
  21. package/src/components/FileInput.module.scss +4 -2
  22. package/src/components/FileInput.tsx +6 -2
  23. package/src/components/Form.module.scss +2 -2
  24. package/src/components/Input.module.scss +1 -1
  25. package/src/components/Link.tsx +64 -12
  26. package/src/components/Meter.tsx +3 -3
  27. package/src/components/Pagination.tsx +89 -37
  28. package/src/components/Popover.module.scss +17 -20
  29. package/src/components/Popover.tsx +12 -5
  30. package/src/components/Radio.module.scss +2 -2
  31. package/src/components/Select.module.scss +3 -3
  32. package/src/components/Skeleton.module.scss +1 -1
  33. package/src/components/Spinner.module.scss +3 -3
  34. package/src/components/Switch.module.scss +2 -2
  35. package/src/components/Table.module.scss +2 -2
  36. package/src/components/Tabs.module.scss +11 -5
  37. package/src/components/Textarea.tsx +7 -4
  38. package/src/components/ThemeToggle.module.scss +1 -1
  39. package/src/components/Toast.module.scss +2 -2
  40. package/src/components/Tooltip.module.scss +8 -3
  41. package/src/components/bar-chart-view.ts +35 -5
  42. package/src/components/button-classes.ts +25 -0
  43. package/src/components/file-input-view.ts +31 -12
  44. package/src/components/link-classes.ts +38 -0
  45. package/src/components/link-target.ts +34 -1
  46. package/src/components/meter-view.ts +13 -0
  47. package/src/components/qr-matrix.ts +16 -23
  48. package/src/components/qr-reader.ts +187 -0
  49. package/src/components/relative-time-view.ts +11 -4
  50. package/src/form/form-binding.ts +9 -8
  51. package/src/icons/build-icons.ts +96 -20
  52. package/src/index.ts +15 -9
  53. package/src/sass-probe.ts +55 -0
  54. package/src/theme/brand.ts +22 -3
  55. package/src/theme/theme.ts +35 -10
  56. package/src/toast/toast-state.ts +0 -0
  57. package/src/tokens/_index.scss +3 -1
  58. package/src/tokens/_mixins.scss +56 -8
  59. package/src/tokens/_motion.scss +4 -0
  60. package/src/tokens/_stroke.scss +20 -0
  61. package/src/tokens/_units.scss +47 -0
  62. package/src/tokens/reset.scss +4 -4
  63. package/src/tokens/theme.scss +2 -0
  64. package/src/tokens/tokens.ts +6 -0
  65. package/src/theme/inline-script.ts +0 -49
@@ -27,16 +27,23 @@ const THRESHOLDS: readonly Threshold[] = [
27
27
  { unit: 'second', ms: 1000 },
28
28
  ];
29
29
 
30
+ const unitMs = (index: number): number => (THRESHOLDS[index] as Threshold).ms;
31
+ const roundIn = (delta: number, index: number): number => Math.round(delta / unitMs(index));
32
+
30
33
  export function relativeTimeText(options: RelativeTimeOptions): string {
31
34
  const target = toDate(options.value).getTime();
32
35
  const base = options.now === undefined ? Date.now() : toDate(options.now).getTime();
33
36
  const delta = target - base;
34
37
  const magnitude = Math.abs(delta);
35
38
 
36
- const threshold =
37
- THRESHOLDS.find((candidate) => magnitude >= candidate.ms) ??
38
- (THRESHOLDS[THRESHOLDS.length - 1] as Threshold);
39
- const value = Math.round(delta / threshold.ms);
39
+ let index = THRESHOLDS.findIndex((candidate) => magnitude >= candidate.ms);
40
+ if (index < 0) index = THRESHOLDS.length - 1;
41
+ // Promote AFTER rounding: 59.6 minutes rounds to 60, and "60 minutes ago" is "1 hour ago".
42
+ while (index > 0 && Math.abs(roundIn(delta, index)) * unitMs(index) >= unitMs(index - 1)) {
43
+ index -= 1;
44
+ }
45
+ const threshold = THRESHOLDS[index] as Threshold;
46
+ const value = roundIn(delta, index);
40
47
 
41
48
  const formatter = new Intl.RelativeTimeFormat(options.locale, {
42
49
  numeric: options.numeric ?? 'auto',
@@ -148,15 +148,16 @@ export function createFormBinding<TValues, TResult>(
148
148
  // has already changed, on the one screen where the user is watching for exactly that.
149
149
  publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
150
150
 
151
- const schema = options.schema;
152
- if (schema !== undefined) {
153
- // The result's `value` is read by nothing. Deliberately: the parse below is the browser's
154
- // opinion, and the only thing this file wants from it is which paths to complain about.
155
- const local = issuesFromValidation(await schema['~standard'].validate(values));
156
- if (local.length > 0) return failed(local);
157
- }
158
-
159
151
  try {
152
+ // Inside the `try`: a validate that throws or rejects is a failed submit, never a `submit()`
153
+ // that rejects with the state left at `submitting` and the control refusing every retry.
154
+ const schema = options.schema;
155
+ if (schema !== undefined) {
156
+ // The result's `value` is read by nothing. Deliberately: the parse below is the browser's
157
+ // opinion, and the only thing this file wants from it is which paths to complain about.
158
+ const local = issuesFromValidation(await schema['~standard'].validate(values));
159
+ if (local.length > 0) return failed(local);
160
+ }
160
161
  const result = await options.submit(values);
161
162
  // The server accepted what the form held, so there is nothing left to lose. `touched` stays:
162
163
  // the user has still visited those fields, and a hint that vanishes on save is a flicker.
@@ -11,7 +11,17 @@ import { invalidIconDataError, runtimeMissingError } from '../errors';
11
11
  /** Pinned: a floating version would silently redraw icons under an app that never asked. */
12
12
  export const LUCIDE_VERSION = '1.31.0';
13
13
 
14
- export const LUCIDE_ICON_NODES_URL = `https://cdn.jsdelivr.net/npm/lucide-static@${LUCIDE_VERSION}/icon-nodes.json`;
14
+ /** Where one pinned version's node table lives. */
15
+ export const iconNodesUrl = (version: string): string =>
16
+ `https://cdn.jsdelivr.net/npm/lucide-static@${version}/icon-nodes.json`;
17
+
18
+ export const LUCIDE_ICON_NODES_URL = iconNodesUrl(LUCIDE_VERSION);
19
+
20
+ /** The registry's answer for the newest published `lucide-static` — what `--bump` pins to. */
21
+ export const LUCIDE_LATEST_URL = 'https://registry.npmjs.org/lucide-static/latest';
22
+
23
+ /** The tail of every cause about data the PIN published: why re-running alone cannot help. */
24
+ const PIN_REPEATS = `lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this; \`--bump\` raises LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts to the latest release and regenerates`;
15
25
 
16
26
  export const GLYPHS_DIR = new URL('./glyphs/', import.meta.url).pathname;
17
27
 
@@ -27,7 +37,7 @@ export function identifierFor(name: string): string {
27
37
  * the network, so its shape is an assumption until it is checked.
28
38
  */
29
39
  export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
30
- const parsed: unknown = JSON.parse(text);
40
+ const parsed = parseJson(text);
31
41
  // `typeof [] === 'object'`, and an array of icons is a different upstream format, not this one.
32
42
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
33
43
  throw invalidIconDataError(
@@ -42,8 +52,8 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
42
52
  // writes, so `../../index` is a delete plus an overwrite of a hand-written module.
43
53
  if (!SAFE_ICON_NAME.test(name)) {
44
54
  throw invalidIconDataError(
45
- `carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped. lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
46
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
55
+ `carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped. ${PIN_REPEATS}`,
56
+ 'bun run --filter @ultimat3/ui icons --bump',
47
57
  );
48
58
  }
49
59
  out.set(name, toGlyph(name, value));
@@ -51,6 +61,29 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
51
61
  return out;
52
62
  }
53
63
 
64
+ /** The body as JSON, or the same coded refusal a wrong shape gets — never a bare `SyntaxError`. */
65
+ function parseJson(text: string): unknown {
66
+ try {
67
+ return JSON.parse(text);
68
+ } catch {
69
+ throw invalidIconDataError(
70
+ `is not JSON (${renderCauseValue(text.slice(0, 80))}…); ${LUCIDE_ICON_NODES_URL} served an error page or a truncated body`,
71
+ 'bun run --filter @ultimat3/ui icons',
72
+ );
73
+ }
74
+ }
75
+
76
+ /** A node that is not `[tag, attrs?]` with an object for attrs — refused, never skipped. */
77
+ function malformedNode(name: string, node: unknown): ReturnType<typeof invalidIconDataError> {
78
+ return invalidIconDataError(
79
+ `for the icon "${name}" carries ${renderCauseValue(node)} where a [tag, attributes] node belongs; skipping it would write a partial glyph. ${PIN_REPEATS}`,
80
+ 'bun run --filter @ultimat3/ui icons --bump',
81
+ );
82
+ }
83
+
84
+ const isAttrs = (value: unknown): value is Record<string, unknown> =>
85
+ typeof value === 'object' && value !== null && !Array.isArray(value);
86
+
54
87
  /**
55
88
  * An icon name, as characters: lowercase kebab-case, no leading, trailing or doubled hyphen. The
56
89
  * same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE`, one layer up — the name is interpolated
@@ -68,10 +101,13 @@ export const SAFE_ATTR_VALUE = /^[\w\s.,+-]*$/;
68
101
 
69
102
  function toGlyph(name: string, value: unknown): IconGlyph {
70
103
  const glyph: (readonly [string, Readonly<Record<string, string>>])[] = [];
71
- for (const node of Array.isArray(value) ? (value as unknown[]) : []) {
72
- if (!Array.isArray(node) || typeof node[0] !== 'string') continue;
104
+ if (!Array.isArray(value)) throw malformedNode(name, value);
105
+ for (const node of value as unknown[]) {
106
+ if (!Array.isArray(node) || typeof node[0] !== 'string') throw malformedNode(name, node);
107
+ const rawAttrs: unknown = node[1] ?? {};
108
+ if (!isAttrs(rawAttrs)) throw malformedNode(name, node);
73
109
  const attrs: Record<string, string> = {};
74
- for (const [key, item] of Object.entries((node[1] ?? {}) as Record<string, unknown>)) {
110
+ for (const [key, item] of Object.entries(rawAttrs)) {
75
111
  // Upstream keys an element for its diff tooling; it is not an SVG attribute.
76
112
  if (key === 'key') continue;
77
113
  const text = String(item);
@@ -82,8 +118,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
82
118
  // no legitimate Lucide artwork.
83
119
  if (!SAFE_ATTR_VALUE.test(text)) {
84
120
  throw invalidIconDataError(
85
- `for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
86
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
121
+ `for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry; ${PIN_REPEATS}`,
122
+ 'bun run --filter @ultimat3/ui icons --bump',
87
123
  );
88
124
  }
89
125
  attrs[key] = text;
@@ -95,8 +131,8 @@ function toGlyph(name: string, value: unknown): IconGlyph {
95
131
  iconElements(glyph);
96
132
  if (glyph.length === 0) {
97
133
  throw invalidIconDataError(
98
- `for the icon "${name}" carries no renderable node data; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
99
- 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
134
+ `for the icon "${name}" carries no renderable node data; ${PIN_REPEATS}`,
135
+ 'bun run --filter @ultimat3/ui icons --bump',
100
136
  );
101
137
  }
102
138
  return glyph;
@@ -136,23 +172,24 @@ export function moduleSource(name: string, glyph: IconGlyph): string {
136
172
  async function format(): Promise<void> {
137
173
  const biome = new URL('../../../../node_modules/.bin/biome', import.meta.url).pathname;
138
174
  if (!(await Bun.file(biome).exists())) {
139
- throw runtimeMissingError('the biome binary', 'bun install, then re-run this generator');
175
+ throw runtimeMissingError('the biome binary', 'bun install');
140
176
  }
141
- const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR]);
177
+ // A formatter over one directory of generated files; a wedged one is the refusal below.
178
+ const result = Bun.spawnSync([biome, 'format', '--write', GLYPHS_DIR], { timeout: 60_000 });
142
179
  if (result.exitCode !== 0) {
143
180
  throw runtimeMissingError(
144
181
  'a successful `biome format` over the generated glyphs',
145
- `run: ${biome} format --write ${GLYPHS_DIR}`,
182
+ 'bunx biome format --write packages/ui/src/icons/glyphs',
146
183
  );
147
184
  }
148
185
  }
149
186
 
150
- export async function buildIcons(): Promise<number> {
151
- const response = await fetch(LUCIDE_ICON_NODES_URL);
187
+ export async function buildIcons(version: string = LUCIDE_VERSION): Promise<number> {
188
+ const response = await fetch(iconNodesUrl(version));
152
189
  if (!response.ok) {
153
190
  throw runtimeMissingError(
154
- `lucide-static@${LUCIDE_VERSION} icon data (HTTP ${response.status})`,
155
- `check network access to ${LUCIDE_ICON_NODES_URL}, then re-run: bun run --filter @ultimat3/ui icons`,
191
+ `lucide-static@${version} icon data (HTTP ${response.status} from ${iconNodesUrl(version)})`,
192
+ 'bun run --filter @ultimat3/ui icons',
156
193
  );
157
194
  }
158
195
  const glyphs = parseIconNodes(await response.text());
@@ -165,8 +202,47 @@ export async function buildIcons(): Promise<number> {
165
202
  return glyphs.size;
166
203
  }
167
204
 
205
+ /** A plain `major.minor.patch` — the only shape allowed into a TypeScript string literal here. */
206
+ const SEMVER = /^\d+\.\d+\.\d+$/;
207
+
208
+ /** `source` with its `LUCIDE_VERSION` line moved to `version`. Pure; refuses rather than guesses. */
209
+ export function withPin(source: string, version: string): string {
210
+ if (!SEMVER.test(version)) {
211
+ throw invalidIconDataError(
212
+ `answered ${renderCauseValue(version)} as the latest lucide-static version, which is not plain semver; ${LUCIDE_LATEST_URL} served something else`,
213
+ 'bun run --filter @ultimat3/ui icons --bump',
214
+ );
215
+ }
216
+ const pin = /^export const LUCIDE_VERSION = '[^']*';$/m;
217
+ if (!pin.test(source)) {
218
+ throw invalidIconDataError(
219
+ 'has no `export const LUCIDE_VERSION = …` line in packages/ui/src/icons/build-icons.ts to move',
220
+ 'git checkout -- packages/ui/src/icons/build-icons.ts',
221
+ );
222
+ }
223
+ return source.replace(pin, `export const LUCIDE_VERSION = '${version}';`);
224
+ }
225
+
226
+ /** Moves the pin to the registry's latest release, in this very file, and answers the version. */
227
+ async function bumpPin(): Promise<string> {
228
+ const response = await fetch(LUCIDE_LATEST_URL);
229
+ if (!response.ok) {
230
+ throw runtimeMissingError(
231
+ `the latest lucide-static version (HTTP ${response.status} from ${LUCIDE_LATEST_URL})`,
232
+ 'bun run --filter @ultimat3/ui icons --bump',
233
+ );
234
+ }
235
+ const body: unknown = await response.json();
236
+ const version =
237
+ typeof body === 'object' && body !== null && 'version' in body ? String(body.version) : '';
238
+ const file = new URL(import.meta.url).pathname;
239
+ await Bun.write(file, withPin(await Bun.file(file).text(), version));
240
+ return version;
241
+ }
242
+
168
243
  if (import.meta.main) {
169
- const count = await buildIcons();
170
- const json = { ok: true, icons: count, lucide: LUCIDE_VERSION, dir: GLYPHS_DIR };
244
+ const version = Bun.argv.includes('--bump') ? await bumpPin() : LUCIDE_VERSION;
245
+ const count = await buildIcons(version);
246
+ const json = { ok: true, icons: count, lucide: version, dir: GLYPHS_DIR };
171
247
  console.log(Bun.argv.includes('--json') ? JSON.stringify(json) : `${count} icon modules written`);
172
248
  }
package/src/index.ts CHANGED
@@ -89,7 +89,12 @@ export {
89
89
  settleActive,
90
90
  stepActive,
91
91
  } from './components/command-palette-view';
92
- export type { Column, DataTableProps } from './components/DataTable';
92
+ export type {
93
+ Column,
94
+ DataTableCallbackProps,
95
+ DataTableLinkProps,
96
+ DataTableProps,
97
+ } from './components/DataTable';
93
98
  export { DataTable } from './components/DataTable';
94
99
  export type { DateTimeProps } from './components/DateTime';
95
100
  export { DateTime } from './components/DateTime';
@@ -160,7 +165,7 @@ export { boxFor, reservedRatio, sourceSetsFor, srcsetFor } from './components/im
160
165
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
161
166
  export type { KbdProps } from './components/Kbd';
162
167
  export { Kbd } from './components/Kbd';
163
- export type { LinkProps } from './components/Link';
168
+ export type { ButtonLinkProps, LinkProps, TextLinkProps } from './components/Link';
164
169
  export { Link } from './components/Link';
165
170
  export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
166
171
  export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
@@ -176,7 +181,11 @@ export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/
176
181
  export { moneyText, toMoney } from './components/money-view';
177
182
  export type { PageHeaderProps } from './components/PageHeader';
178
183
  export { PageHeader } from './components/PageHeader';
179
- export type { PaginationProps } from './components/Pagination';
184
+ export type {
185
+ PaginationCallbackProps,
186
+ PaginationLinkProps,
187
+ PaginationProps,
188
+ } from './components/Pagination';
180
189
  export { Pagination } from './components/Pagination';
181
190
  export type { Placement, PopoverProps } from './components/Popover';
182
191
  export { Popover } from './components/Popover';
@@ -303,12 +312,6 @@ export {
303
312
  useUi,
304
313
  } from './theme/context';
305
314
  export { INERT_SOLID_RUNTIME } from './theme/inert-runtime';
306
- export {
307
- THEME_INLINE_SCRIPT,
308
- themeInlineScriptCspSource,
309
- themeInlineScriptHash,
310
- themeInlineScriptTag,
311
- } from './theme/inline-script';
312
315
  export type { UiProviderProps } from './theme/provider';
313
316
  export { UiProvider } from './theme/provider';
314
317
  // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
@@ -321,6 +324,7 @@ export type { ThemeEnv } from './theme/theme';
321
324
  export {
322
325
  browserThemeEnv,
323
326
  clearTheme,
327
+ defaultTheme,
324
328
  initTheme,
325
329
  isTheme,
326
330
  osTheme,
@@ -328,6 +332,7 @@ export {
328
332
  setTheme,
329
333
  storedTheme,
330
334
  THEME_ATTRIBUTE,
335
+ THEME_DEFAULT_ATTRIBUTE,
331
336
  THEME_MEDIA_QUERY,
332
337
  THEME_STORAGE_KEY,
333
338
  toggleTheme,
@@ -378,5 +383,6 @@ export {
378
383
  radiusTokens,
379
384
  shadowTokens,
380
385
  spaceTokens,
386
+ strokeTokens,
381
387
  zTokens,
382
388
  } from './tokens/tokens';
@@ -0,0 +1,55 @@
1
+ // TEST-ONLY. Compiles a stylesheet with the compiler an app's build uses, so a test can read what
2
+ // Sass EMITS — a media query, a `clamp()`, the class list a mixin generates, an `@error` — instead
3
+ // of the source it was written as. Never exported from `index.ts`, like `jsx-probe.ts`.
4
+
5
+ /** The two members of Sass's API this file calls; the package itself is not a dependency of `ui`. */
6
+ interface SassApi {
7
+ compileString(source: string, options: { readonly url: URL }): { readonly css: string };
8
+ }
9
+
10
+ let loading: Promise<SassApi> | undefined;
11
+
12
+ /**
13
+ * `sass` is `@ultimat3/render`'s dependency — the package that compiles an app's sheets — and `ui`
14
+ * may not import `render` (tier 4, both). Resolved FROM render's directory, so a test compiles with
15
+ * the one compiler at the one version the lockfile pins and `ui` declares no second copy. Only a
16
+ * specifier is read from render; no module of it loads. Lazy, so importing this file has no effect.
17
+ */
18
+ function sassCompiler(): Promise<SassApi> {
19
+ loading ??= import(
20
+ Bun.resolveSync('sass', new URL('../../render', import.meta.url).pathname)
21
+ ) as Promise<SassApi>;
22
+ return loading;
23
+ }
24
+
25
+ /** Compile `source` as though it were the file at `path`, so its relative `@use`s resolve. */
26
+ export async function compileScss(source: string, path: string): Promise<string> {
27
+ const sass = await sassCompiler();
28
+ return sass.compileString(source, { url: new URL(`file://${path}`) }).css;
29
+ }
30
+
31
+ /** Compile a stylesheet that exists on disk. */
32
+ export async function compileScssFile(path: string): Promise<string> {
33
+ return compileScss(await Bun.file(path).text(), path);
34
+ }
35
+
36
+ /**
37
+ * The `@error` text Sass refused `source` with, or `undefined` when it compiled. A refusal that is
38
+ * not Sass's own — the compiler failing to load — is rethrown: it is not an answer about `source`.
39
+ */
40
+ export async function scssRefusal(source: string, path: string): Promise<string | undefined> {
41
+ try {
42
+ await compileScss(source, path);
43
+ } catch (error) {
44
+ if (typeof error === 'object' && error !== null && 'sassMessage' in error) {
45
+ return String(error.sassMessage);
46
+ }
47
+ throw error;
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ /** Every class a compiled stylesheet declares. */
53
+ export function declaredClasses(css: string): ReadonlySet<string> {
54
+ return new Set([...css.matchAll(/\.([A-Za-z][\w-]*)/g)].map((match) => match[1] as string));
55
+ }
@@ -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,5 +1,5 @@
1
1
  // Single entry point for authors: `@use '../tokens' as t;` then `t.role('bg')`,
2
- // `t.space(4)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
2
+ // `t.space(4)`, `t.rem(24px)`, `@include t.focus-ring`. Forwards maps, functions, and mixins —
3
3
  // emits no CSS. Global custom properties come from `theme.scss`.
4
4
 
5
5
  @forward 'breakpoints';
@@ -8,6 +8,8 @@
8
8
  @forward 'radius' hide emit;
9
9
  @forward 'shadow' hide emit, levels;
10
10
  @forward 'space' hide emit;
11
+ @forward 'stroke' hide emit;
11
12
  @forward 'typography' hide emit;
13
+ @forward 'units';
12
14
  @forward 'z' hide emit;
13
15
  @forward 'mixins';
@@ -4,15 +4,22 @@
4
4
 
5
5
  @use 'sass:list';
6
6
  @use 'sass:map';
7
+ @use 'sass:string';
7
8
  @use 'breakpoints';
8
9
  @use 'colors';
9
10
  @use 'motion';
10
11
  @use 'radius';
11
12
  @use 'space';
13
+ @use 'stroke';
12
14
  @use 'typography';
15
+ @use 'units';
13
16
 
14
17
  /// Keyboard-only focus, drawn from the accent role so it themes automatically.
15
- @mixin focus-ring($role: 'accent', $width: 2px, $offset: 2px) {
18
+ @mixin focus-ring(
19
+ $role: 'accent',
20
+ $width: stroke.stroke(thick),
21
+ $offset: stroke.stroke(thick)
22
+ ) {
16
23
  &:focus-visible {
17
24
  outline: $width solid colors.role($role);
18
25
  outline-offset: $offset;
@@ -81,13 +88,54 @@
81
88
  container-name: $name;
82
89
  }
83
90
 
84
- /// Viewport breakpoint, min-width only. Named rungs, never a raw px value.
91
+ // --- Viewport breakpoints ----------------------------------------------------
92
+ // Three intents over ONE map, so a rung is defined in one place: at and above (`respond-to`),
93
+ // below (`respond-down`), and from one rung up to the next (`respond-between`). Prefer a container
94
+ // query to any of them — reach here only when the layout depends on the viewport itself.
95
+
96
+ /// What a max-width arm stops short of its rung by. `max-width: 768px` and `min-width: 768px` both
97
+ /// match at exactly 768px, which is how one rule ends up applying in two ranges at once; 0.02px is
98
+ /// below any width a browser reports and above the rounding that would collapse it.
99
+ $-under: 0.02px;
100
+
101
+ /// A rung's width. Compared as TEXT as well as by value: `2xl` in a map is the number 2 with the
102
+ /// unit `xl`, so the string `'2xl'` every stylesheet writes is a different key to `map.get`.
103
+ @function -rung($bp, $mixin) {
104
+ @each $name, $width in breakpoints.$breakpoints {
105
+ @if '#{$name}' == '#{$bp}' {
106
+ @return $width;
107
+ }
108
+ }
109
+ @error string.unquote('X_TOKEN_UNKNOWN: #{$mixin}("#{$bp}") — that breakpoint is not in $breakpoints. fix: use one of #{map.keys(breakpoints.$breakpoints)}');
110
+ }
111
+
112
+ /// At a rung and wider. Named rungs, never a raw px value.
85
113
  @mixin respond-to($bp) {
86
- @if not map.has-key(breakpoints.$breakpoints, $bp) {
87
- @error 'X_TOKEN_UNKNOWN: breakpoint "#{$bp}" is not in $breakpoints. fix: add it to src/tokens/_breakpoints.scss';
114
+ @media (min-width: -rung($bp, 'respond-to')) {
115
+ @content;
116
+ }
117
+ }
118
+
119
+ /// Narrower than a rung — the complement of `respond-to` on the same rung, with no shared pixel.
120
+ @mixin respond-down($bp) {
121
+ @media (max-width: (-rung($bp, 'respond-down') - $-under)) {
122
+ @content;
123
+ }
124
+ }
125
+
126
+ /// From `$from` up to, and not including, `$to`.
127
+ @mixin respond-between($from, $to) {
128
+ $min: -rung($from, 'respond-between');
129
+ $max: -rung($to, 'respond-between');
130
+
131
+ @if $min == $max {
132
+ @error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is an empty range — both ends are #{$min}. fix: write respond-to("#{$from}"), or name a higher rung as $to');
133
+ }
134
+ @if $min > $max {
135
+ @error string.unquote('X_UI_INVALID_VALUE: respond-between("#{$from}", "#{$to}") is reversed — #{$min} is not below #{$max}. fix: write respond-between("#{$to}", "#{$from}")');
88
136
  }
89
137
 
90
- @media (min-width: map.get(breakpoints.$breakpoints, $bp)) {
138
+ @media (min-width: $min) and (max-width: ($max - $-under)) {
91
139
  @content;
92
140
  }
93
141
  }
@@ -156,8 +204,8 @@
156
204
  scrollbar-color: colors.role($thumb) colors.role($track);
157
205
 
158
206
  &::-webkit-scrollbar {
159
- width: 10px;
160
- height: 10px;
207
+ width: units.rem(10px);
208
+ height: units.rem(10px);
161
209
  }
162
210
 
163
211
  &::-webkit-scrollbar-track {
@@ -165,7 +213,7 @@
165
213
  }
166
214
 
167
215
  &::-webkit-scrollbar-thumb {
168
- border: 2px solid transparent;
216
+ border: stroke.stroke(thick) solid transparent;
169
217
  border-radius: radius.radius(pill);
170
218
  background: colors.role($thumb);
171
219
  background-clip: content-box;
@@ -16,6 +16,10 @@ $easing: (
16
16
  spring: cubic-bezier(0.34, 1.56, 0.64, 1),
17
17
  );
18
18
 
19
+ /// What reduced motion collapses every duration to. Never `0`: an animation that takes no time
20
+ /// fires no `animationend`, and a component waiting on that event waits forever.
21
+ $still: 0.01ms;
22
+
19
23
  @mixin emit {
20
24
  :root {
21
25
  @each $name, $value in $duration {
@@ -0,0 +1,20 @@
1
+ // Line weights: a border, an outline, a focus ring, an indicator. px on purpose — a line that
2
+ // grew with the reader's text size would be a different design at 200% zoom, not a bigger one.
3
+
4
+ $stroke: (
5
+ hairline: 1px,
6
+ thick: 2px,
7
+ heavy: 3px,
8
+ );
9
+
10
+ @mixin emit {
11
+ :root {
12
+ @each $name, $value in $stroke {
13
+ --stroke-#{$name}: #{$value};
14
+ }
15
+ }
16
+ }
17
+
18
+ @function stroke($name) {
19
+ @return var(--stroke-#{$name});
20
+ }