@ultimat3/ui 10.0.0 → 11.1.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/CLAUDE.md CHANGED
@@ -14,8 +14,16 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
14
14
  - **No raw colours.** Only `t.role('<role>')` / `var(--color-*)`. Canonical roles live in `src/tokens/_colors.scss`; `tokens.ts` mirrors them and `tokens.test.ts` fails on drift.
15
15
  - **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
16
16
  - **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
17
+ - **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid()` throws `runtimeMissingError`, so it imports `../errors`, which `package.json` declares side-effectful (`registerErrorCodes()` runs at import) and no bundler may shake — so while the slot sat beside it, an island whose `mount` did nothing but `setSolidRuntime(solidRuntime)` carried @ultimat3/core's whole error registry: **5,719 B against 72 B**. One module owns `let runtime`, `solid()` reads it through `registeredSolidRuntime()`, and `barrel-bytes.test.ts` holds the ceiling at 1 kB — small enough that the registry re-entering the graph reds it.
18
+ - **The barrel is the ONE import path, and component subpath exports are refused on measurement** (2026-08-23, issue #275). `import { Button } from '@ultimat3/ui'` and a deep path into `components/Button` emit the same chunk, to within the length of the entry's own module name; so do `useUi` (14 kB) and `moneyText` (25 kB). `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions to that: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only and deliberately absent from the barrel. `barrel-bytes.test.ts` compares the two paths, so the day the barrel stops shaking this argument reds instead of ageing.
17
19
  - **`solid()` always answers off-DOM.** No registered runtime and no DOM is a *server render*, and it gets `INERT_SOLID_RUNTIME` (`src/theme/inert-runtime.ts`) — signals hold, memos recompute on read, effects never run, `useContext` returns the default. No DOM means no reactivity to lose; a **DOM** with no runtime is still `X_UI_RUNTIME_MISSING`, because that one is the theme toggle that does nothing. Never widen this to "no runtime, never throw": that is the silent degradation the split exists to prevent.
18
20
  - **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()`. Those two read **core's own `Ctx.locale` / `Ctx.tz`**, which `@ultimat3/http`'s `locale` stage writes once per request; `@ultimat3/i18n` and `@ultimat3/time` publish no field of their own. Never add a second ambient store here or there — `time` kept a `ctx['timeZone']` nothing wrote until 2026-08, and the whole cost was invisible: every server-rendered date formatted in UTC under a doc comment saying it did not. `theme`/`currency` deliberately have no ambient source at all.
21
+ - **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.**
22
+ `initialsOf(name, locale)` reads `useUi().locale`, exactly as `DateTime` does. A bare
23
+ `toLocaleUpperCase()` reads the RUNTIME's default — a server's `LANG`, a browser's UI language —
24
+ which is the ambient locale this package does not have; Turkish is where the absence shows,
25
+ because dotted `i` uppercases to `İ`, so a Turkish member's avatar read `I` on a server in
26
+ Ireland. **Breaking**: `initialsOf`'s second parameter is required.
19
27
  - **`UiProvider` is client-only and throws on the server** (`providerNeedsRuntimeError`, the same `X_UI_RUNTIME_MISSING`). A Provider in an inert tree reaches no descendant — the tree is built before the renderer walks it, so consumers are walked outside every owner and read the context default even with a real Solid runtime registered. Rendering the children anyway would drop its locale, zone, currency and translator silently. Making it work needs the *renderer* to scope a context around the walk; until then it refuses.
20
28
  - **A component may call `solid()` freely.** Its effects must stay DOM-only work — they simply never run on the server.
21
29
  - **No `{...rest}` prop spreading.** Splitting props reactively needs solid's `splitProps` (a value import), so components declare explicit props and read `props.x` inside JSX.
@@ -43,7 +51,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
43
51
  |---|---|
44
52
  | `src/tokens/*.scss` | canonical token maps + `_mixins.scss` authoring helpers |
45
53
  | `src/tokens/theme.scss` | the only stylesheet that emits global custom properties |
46
- | `src/theme/solid-adapter.ts` | the runtime slot and the one rule that decides which runtime a render gets |
54
+ | `src/theme/runtime-slot.ts` | the module-scope slot holding the app's Solid runtime — and nothing else, so registering one costs an island 72 B |
55
+ | `src/theme/solid-adapter.ts` | the runtime's SHAPE, and the one rule that decides which runtime a render gets |
47
56
  | `src/theme/inert-runtime.ts` | `INERT_SOLID_RUNTIME` — what a server render IS, not a stub of what it lacks |
48
57
  | `src/theme/theme.ts` | resolution: stored choice → OS; all side effects via injected `ThemeEnv` |
49
58
  | `src/theme/inline-script.ts` | anti-flash `<head>` snippet + its CSP sha256 |
@@ -56,6 +65,7 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
56
65
  | `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
57
66
  | `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
58
67
  | `src/jsx-probe.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reached from `@ultimat3/admin` at `@ultimat3/ui/jsx-probe`; the walkers stay internal |
68
+ | `src/barrel-bytes.test.ts` | the build error behind the two byte claims above: the setter's ceiling, and barrel-vs-deep-path parity |
59
69
  | `src/components/style-classes.test.ts` | the build error behind "every `styles['x']` a component names is declared in its own `.module.scss`" — under `bun test` a `.module.scss` import resolves to the file PATH, so no render can catch a dead class |
60
70
 
61
71
  ## Assumed peer contracts
package/README.md CHANGED
@@ -274,20 +274,28 @@ as text, because `t()`'s catalog does not cross the seam and neither does a call
274
274
  what is rendered. `UiProvider` sets `lang` + `dir` on `<html>` from `locale`, so
275
275
  `ar-EG` needs no second stylesheet and no second component.
276
276
 
277
- **An island pays for the barrel, not for the component it named.** Measured through
278
- `buildIslands` — minified, production Solid, `As of 2026-08-21`:
277
+ **An island pays for what it names, and `import { … } from '@ultimat3/ui'` is the one way to
278
+ name it.** Measured through `buildIslands` — minified, production Solid, `As of 2026-08-23`:
279
279
 
280
280
  | An island that imports | Chunk |
281
281
  |---|---|
282
282
  | nothing | 52 B |
283
- | `setSolidRuntime` alone | 5.7 kB |
284
- | `<UiProvider>` + one `<Button>` | 49.0 kB, of which Solid's own runtime is 12.2 kB |
285
- | `<UiProvider>` + `<Form>` + `<Input>` + `<Button>` | 54.8 kB |
286
-
287
- There are no component subpath exports, so `import { Button }` reaches the whole index —
288
- issue **#275**. `@ultimat3/ui/icons/*` is the one part of the package already shaped the
289
- other way, and is what the components want. Budget an island against these numbers, not
290
- against the component's own source.
283
+ | `setSolidRuntime` alone | 74 B |
284
+ | `<UiProvider>` + one `<Button>` | 49.4 kB, of which Solid's own runtime is 12.6 kB |
285
+ | `<UiProvider>` + `<Form>` + `<Input>` + `<Button>` | 55.3 kB |
286
+
287
+ **There are no component subpath exports, and there will not be** — measured, not asserted:
288
+ `import { Button } from '@ultimat3/ui'` and a deep path into `components/Button` produce the
289
+ same chunk to within the bytes of the entry's own name, and so do `useUi` (14 kB) and
290
+ `moneyText` (25 kB). `@ultimat3/ui/button` would be a second way to import one name for zero
291
+ bytes, which is axiom 1 for nothing. `src/barrel-bytes.test.ts` is the build error, and it will
292
+ red the day the barrel stops shaking. `@ultimat3/ui/icons/*` is a subpath for the opposite
293
+ reason: 1,767 glyph modules are data, and no bundler splits one module holding all of them.
294
+
295
+ What a component's 49 kB actually is: Solid's runtime, `@ultimat3/i18n`'s translator and plural
296
+ machinery reached through `useUi()`, and `@ultimat3/core`'s error registry reached through
297
+ `errors.ts` — none of which a different export shape removes. Budget an island against these
298
+ numbers, not against the component's own source.
291
299
 
292
300
  ## `<Text>` and `<Image>`
293
301
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "10.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  "icons": "bun run src/icons/build-icons.ts"
45
45
  },
46
46
  "dependencies": {
47
- "@ultimat3/core": "10.0.0",
48
- "@ultimat3/i18n": "10.0.0",
49
- "@ultimat3/money": "10.0.0",
50
- "@ultimat3/time": "10.0.0"
47
+ "@ultimat3/core": "11.1.0",
48
+ "@ultimat3/i18n": "11.1.0",
49
+ "@ultimat3/money": "11.1.0",
50
+ "@ultimat3/time": "11.1.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "solid-js": "^1.9.0"
@@ -5,6 +5,7 @@
5
5
  import { safeUrl } from '@ultimat3/core';
6
6
  import type { JSX } from 'solid-js';
7
7
  import { cx } from '../cx';
8
+ import { useUi } from '../theme/context';
8
9
  import styles from './Avatar.module.scss';
9
10
  import type { Size } from './variants';
10
11
 
@@ -21,16 +22,24 @@ export interface AvatarProps {
21
22
 
22
23
  const PX: Readonly<Record<string, number>> = { xs: 20, sm: 24, md: 32, lg: 40, xl: 64 };
23
24
 
24
- /** First letters of the first two words — locale-safe, no ASCII assumptions. */
25
- export function initialsOf(name: string): string {
25
+ /**
26
+ * First letters of the first two words — locale-safe, no ASCII assumptions.
27
+ *
28
+ * `locale` is REQUIRED, exactly as it is on `dateTimeView`: a bare `toLocaleUpperCase()` reads the
29
+ * RUNTIME's default locale, which is a server's `LANG` and a browser's UI language, and never the
30
+ * request's. Turkish is where the absence shows — dotted `i` uppercases to `İ` — and this package
31
+ * has no ambient locale to fall back on by rule.
32
+ */
33
+ export function initialsOf(name: string, locale: string): string {
26
34
  const words = name.trim().split(/\s+/).filter(Boolean).slice(0, 2);
27
35
  return words
28
36
  .map((word) => [...word][0] ?? '')
29
37
  .join('')
30
- .toLocaleUpperCase();
38
+ .toLocaleUpperCase(locale);
31
39
  }
32
40
 
33
41
  export function Avatar(props: AvatarProps): JSX.Element {
42
+ const ui = useUi();
34
43
  const px = (): number => PX[props.size ?? 'md'] ?? 32;
35
44
 
36
45
  return (
@@ -46,7 +55,7 @@ export function Avatar(props: AvatarProps): JSX.Element {
46
55
  >
47
56
  {props.src === undefined ? (
48
57
  <span aria-hidden="true" class={styles['initials']}>
49
- {initialsOf(props.name)}
58
+ {initialsOf(props.name, ui.locale)}
50
59
  </span>
51
60
  ) : (
52
61
  <img
@@ -116,8 +116,25 @@ export function assertNonEmptySrc(kind: string, value: unknown, src: string): st
116
116
  return trimmed;
117
117
  }
118
118
 
119
+ /**
120
+ * `srcset` is a comma-separated list whose entries are split on WHITESPACE — so the two characters
121
+ * a src may not carry are whitespace anywhere and a comma at either end. Trimming the ends is not
122
+ * enough: `'/my file.webp 800w'` parses as the URL `/my` with the descriptor `file.webp`, which is
123
+ * not a descriptor, so the candidate is dropped and the `<img>` silently falls back to `src`. An
124
+ * INTERIOR comma is fine and is deliberately allowed — the parser reads a URL up to the first
125
+ * whitespace, so `/img/a,b.webp` round-trips.
126
+ */
127
+ const SRCSET_UNSAFE = /\s|^,|,$/;
128
+
119
129
  function toCandidate(variant: ImageVariant): Candidate {
120
130
  const src = assertNonEmptySrc('Image', variant, variant.src);
131
+ if (SRCSET_UNSAFE.test(src)) {
132
+ throw invalidValueError(
133
+ 'Image',
134
+ variant,
135
+ 'a variant src with no whitespace and no leading or trailing comma — srcset splits on both, so such a src is dropped by the browser rather than reported',
136
+ );
137
+ }
121
138
 
122
139
  const width = variant.width;
123
140
  const density = variant.density;
package/src/index.ts CHANGED
@@ -220,13 +220,11 @@ export {
220
220
  } from './theme/inline-script';
221
221
  export type { UiProviderProps } from './theme/provider';
222
222
  export { UiProvider } from './theme/provider';
223
+ // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
224
+ // @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
225
+ export { clearSolidRuntime, hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
223
226
  export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
224
- export {
225
- clearSolidRuntime,
226
- hasSolidRuntime,
227
- setSolidRuntime,
228
- solid,
229
- } from './theme/solid-adapter';
227
+ export { solid } from './theme/solid-adapter';
230
228
  export type { ThemeEnv } from './theme/theme';
231
229
  // --- theme -------------------------------------------------------------------
232
230
  export {
@@ -14,7 +14,8 @@ import {
14
14
  } from '@ultimat3/i18n';
15
15
  import { currentTimeZone, type TimeZone } from '@ultimat3/time';
16
16
  import type { Theme } from '../tokens/tokens';
17
- import { hasSolidRuntime, type SolidContext, type SolidRuntime, solid } from './solid-adapter';
17
+ import { hasSolidRuntime } from './runtime-slot';
18
+ import { type SolidContext, type SolidRuntime, solid } from './solid-adapter';
18
19
 
19
20
  export type { Direction };
20
21
 
@@ -14,7 +14,8 @@ import {
14
14
  type UiContextValue,
15
15
  uiContext,
16
16
  } from './context';
17
- import { hasSolidRuntime, solid } from './solid-adapter';
17
+ import { hasSolidRuntime } from './runtime-slot';
18
+ import { solid } from './solid-adapter';
18
19
 
19
20
  export interface UiProviderProps {
20
21
  theme?: Theme | undefined;
@@ -0,0 +1,34 @@
1
+ // The module-scope slot holding the app's Solid runtime, and nothing else.
2
+ //
3
+ // Split out of `solid-adapter.ts` because of what the neighbours cost: `solid()` throws
4
+ // `runtimeMissingError`, so it imports `../errors`, which `package.json` declares side-effectful
5
+ // (`registerErrorCodes()` runs at import) and no bundler may therefore shake. An island that only
6
+ // REGISTERS the runtime paid @ultimat3/core's whole error registry for it — 5,719 B against 72 B,
7
+ // measured in `barrel-bytes.test.ts`, which is the build error behind this file existing.
8
+
9
+ import type { SolidRuntime } from './solid-adapter';
10
+
11
+ let runtime: SolidRuntime | null = null;
12
+
13
+ /** Register once, in the app entry, before the first render. */
14
+ export function setSolidRuntime(next: SolidRuntime): void {
15
+ runtime = next;
16
+ }
17
+
18
+ export function hasSolidRuntime(): boolean {
19
+ return runtime !== null;
20
+ }
21
+
22
+ /** For tests: drop the registration so cases stay independent. */
23
+ export function clearSolidRuntime(): void {
24
+ runtime = null;
25
+ }
26
+
27
+ /**
28
+ * The registered runtime or `null` — read only by `solid()`, which owns the rule about what a
29
+ * missing one MEANS. A component never reaches this: two answers to "which runtime does this
30
+ * render get" is the split this file is careful not to become.
31
+ */
32
+ export function registeredSolidRuntime(): SolidRuntime | null {
33
+ return runtime;
34
+ }
@@ -1,10 +1,15 @@
1
- // Solid runtime adapter. @ultimat3/ui imports *types* from solid-js and nothing
2
- // else, so the package installs, typechecks, and tests with no reactive runtime
3
- // present. The CLIENT entry registers the real one once; a server render gets the inert one.
1
+ // Solid runtime adapter: the shape of the runtime, and the ONE rule that decides which runtime a
2
+ // render gets. @ultimat3/ui imports *types* from solid-js and nothing else, so the package
3
+ // installs, typechecks, and tests with no reactive runtime present.
4
+ //
5
+ // The slot itself is `runtime-slot.ts`, not this file: `runtimeMissingError` below reaches
6
+ // `../errors`, which is side-effectful and unshakeable, so an island that only registers a runtime
7
+ // would pay the error registry for it (`barrel-bytes.test.ts`).
4
8
 
5
9
  import type { JSX } from 'solid-js';
6
10
  import { runtimeMissingError } from '../errors';
7
11
  import { INERT_SOLID_RUNTIME } from './inert-runtime';
12
+ import { registeredSolidRuntime } from './runtime-slot';
8
13
 
9
14
  export type Accessor<T> = () => T;
10
15
  export type Setter<T> = (next: T) => void;
@@ -34,22 +39,6 @@ export interface SolidRuntime {
34
39
  onCleanup(fn: () => void): void;
35
40
  }
36
41
 
37
- let runtime: SolidRuntime | null = null;
38
-
39
- /** Register once, in the app entry, before the first render. */
40
- export function setSolidRuntime(next: SolidRuntime): void {
41
- runtime = next;
42
- }
43
-
44
- export function hasSolidRuntime(): boolean {
45
- return runtime !== null;
46
- }
47
-
48
- /** For tests: drop the registration so cases stay independent. */
49
- export function clearSolidRuntime(): void {
50
- runtime = null;
51
- }
52
-
53
42
  /**
54
43
  * A DOM is the whole of the question. With one, a component that reaches for a runtime nobody
55
44
  * registered is a real bug — the theme toggle that does nothing — so it throws. Without one there
@@ -63,6 +52,7 @@ function hasDom(): boolean {
63
52
  }
64
53
 
65
54
  export function solid(): SolidRuntime {
55
+ const runtime = registeredSolidRuntime();
66
56
  if (runtime !== null) return runtime;
67
57
  if (hasDom()) {
68
58
  // Two lines to paste, and both are real: `x g resource` writes exactly them into the slice's