@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 +11 -1
- package/README.md +18 -10
- package/package.json +5 -5
- package/src/components/Avatar.tsx +13 -4
- package/src/components/image-source.ts +17 -0
- package/src/index.ts +4 -6
- package/src/theme/context.ts +2 -1
- package/src/theme/provider.tsx +2 -1
- package/src/theme/runtime-slot.ts +34 -0
- package/src/theme/solid-adapter.ts +9 -19
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/
|
|
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
|
|
278
|
-
`buildIslands` — minified, production Solid, `As of 2026-08-
|
|
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 |
|
|
284
|
-
| `<UiProvider>` + one `<Button>` | 49.
|
|
285
|
-
| `<UiProvider>` + `<Form>` + `<Input>` + `<Button>` |
|
|
286
|
-
|
|
287
|
-
There are no component subpath exports,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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": "
|
|
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": "
|
|
48
|
-
"@ultimat3/i18n": "
|
|
49
|
-
"@ultimat3/money": "
|
|
50
|
-
"@ultimat3/time": "
|
|
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
|
-
/**
|
|
25
|
-
|
|
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 {
|
package/src/theme/context.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/theme/provider.tsx
CHANGED
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type UiContextValue,
|
|
15
15
|
uiContext,
|
|
16
16
|
} from './context';
|
|
17
|
-
import { hasSolidRuntime
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|