@ultimat3/ui 20.2.0 → 21.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.
- package/CLAUDE.md +8 -3
- package/package.json +10 -6
- package/src/components/AsyncRegion.tsx +1 -2
- package/src/components/DataTable.tsx +2 -2
- package/src/components/async-branch.ts +7 -13
- package/src/error-registry.ts +23 -0
- package/src/errors.ts +6 -14
- package/src/index.ts +7 -2
- package/src/theme/ambient-slot.ts +20 -0
- package/src/theme/ambient.browser.ts +11 -0
- package/src/theme/ambient.ts +37 -0
- package/src/theme/context.ts +33 -38
- package/src/theme/provider.tsx +5 -2
package/CLAUDE.md
CHANGED
|
@@ -14,8 +14,9 @@ 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 `
|
|
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 bare-imports `error-registry.ts` — the `registerErrorCodes()` call `package.json` declares side-effectful 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
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 — the same MODULES, and the same bytes to the character once the bundler's own banner comments are out; 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. **Not "to within the length of the entry's own module name"**, which this line said until 2026-08-25 and no build agrees with: measured, an entry file name 29 characters longer produced a chunk of identical size, and the only difference between the two minified chunks was the minifier's identifier allocation — one byte. What DOES differ, from one build to the next and with no source change at all, is whether Bun 1.4.0's shaker dropped `@ultimat3/core`'s `schema-error-codes.ts` (1,116 B, load-correlated, issues #273 #276); the test names that module rather than allowing bytes for it.
|
|
19
|
+
- **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (2026-09-19, issue #490). `context.ts` is what every component's `useUi()` retains, and it imports NO value from `@ultimat3/i18n` or `@ultimat3/time`: the i18n barrel installs the framework catalog at import, so one `createTranslator`/`currentLocale` there was 16.1 kB of catalog, time and core's logger in every island with a `UiProvider` — for a branch a DOM never takes. The request readers live in `src/theme/ambient.ts`, which registers `ambientUiContext` into `src/theme/ambient-slot.ts` at import; `index.ts` imports it BARE, above every re-export, and `package.json`'s `browser` field maps that file to `ambient.browser.ts` for a browser bundler (measured on Bun 1.4.0 and 1.4.2, symlinked or not — and never at runtime, so `bun test` and SSR always get the server's). `directionOf` is `@ultimat3/core`'s for the same reason, and `fallbackTranslator` is written here with `context.test.ts` holding it member-for-member against `createTranslator({})`. The cost of the shape: a test that reaches components by module path and exercises the inert server path must `import './ambient'` itself, as `inert-render.test.ts` does — the barrel is what registers. `errors.ts` is pure now too: the `registerErrorCodes()` call is `src/error-registry.ts`, imported bare from `errors.ts`, so a bundler honouring `sideEffects` (Bun 1.4.2; 1.4.0 ignored the array, oven-sh/bun#40650) drops the registry from a chunk that reaches `errors.ts` only through the barrel — the setter island was 10,928 B on 1.4.2 before, 72 B over the floor after. `barrel-bytes.test.ts` pins the island's retained-module list: no `i18n`, `time` or `money` path, no `ambient.ts`, and nothing of this package outside a named list.
|
|
19
20
|
- **`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.
|
|
20
21
|
- **`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
22
|
- **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.**
|
|
@@ -44,7 +45,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
44
45
|
(`src/components/async-branch.ts`) is the ONLY place the `(pending, failed, empty, data)` decision
|
|
45
46
|
is made — `AsyncRegion` renders it and `DataTable` calls it, so a table and a card list cannot
|
|
46
47
|
disagree about what "loading with stale rows" looks like. The property is structural: an
|
|
47
|
-
`AsyncState` in `
|
|
48
|
+
`AsyncState` (declared in `@ultimat3/core` since 21.0.0 — imported here, never re-exported, so
|
|
49
|
+
it has one import path) in `pending` carries no data, so nothing can be found empty in it, and "No results"
|
|
48
50
|
for one frame before the first page arrives is unconstructible rather than discouraged.
|
|
49
51
|
`<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
|
|
50
52
|
error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
|
|
@@ -105,6 +107,9 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
105
107
|
| `src/theme/solid-adapter.ts` | the runtime's SHAPE, and the one rule that decides which runtime a render gets |
|
|
106
108
|
| `src/theme/inert-runtime.ts` | `INERT_SOLID_RUNTIME` — what a server render IS, not a stub of what it lacks |
|
|
107
109
|
| `src/theme/theme.ts` | resolution: stored choice → OS; all side effects via injected `ThemeEnv` |
|
|
110
|
+
| `src/theme/ambient-slot.ts` | the module-scope slot holding the reader `useUi()` falls back to on the server — imports nothing, so a browser chunk pays nothing for it |
|
|
111
|
+
| `src/theme/ambient.ts` | the server's reader (`ambientUiContext`: request locale, zone, direction, translator), registered at import; `index.ts` imports it bare, and the `browser` field maps it to `ambient.browser.ts`, which answers the defaults and registers nothing |
|
|
112
|
+
| `src/error-registry.ts` | the `registerErrorCodes()` call, alone, so `errors.ts` is pure and the barrel's re-export of it costs a chunk nothing |
|
|
108
113
|
| `src/theme/inline-script.ts` | anti-flash `<head>` snippet + its CSP sha256 |
|
|
109
114
|
| `src/components/` | 62 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
|
|
110
115
|
| `src/icons/glyphs/` | GENERATED: 1767 per-icon modules, `@ultimat3/ui/icons/<name>` → `icon<Name>` |
|
|
@@ -128,7 +133,7 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
128
133
|
| `src/form/field-binding.test.ts` | the WIRING, end to end: a rejection reaching `aria-invalid` and `aria-describedby` on the control it named |
|
|
129
134
|
| `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
|
|
130
135
|
| `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 |
|
|
131
|
-
| `src/barrel-bytes.test.ts` | the build error behind the
|
|
136
|
+
| `src/barrel-bytes.test.ts` | the build error behind the three claims above: the setter's byte ceiling, barrel-vs-deep-path parity, and the theme-toggle island's retained-module list — the last two read off the `minify: false` banners, never a byte allowance |
|
|
132
137
|
| `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 |
|
|
133
138
|
|
|
134
139
|
## Assumed peer contracts
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.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",
|
|
7
7
|
"sideEffects": [
|
|
8
|
-
"./src/
|
|
8
|
+
"./src/error-registry.ts",
|
|
9
|
+
"./src/theme/ambient.ts",
|
|
9
10
|
"**/*.scss"
|
|
10
11
|
],
|
|
12
|
+
"browser": {
|
|
13
|
+
"./src/theme/ambient.ts": "./src/theme/ambient.browser.ts"
|
|
14
|
+
},
|
|
11
15
|
"repository": {
|
|
12
16
|
"type": "git",
|
|
13
17
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -44,10 +48,10 @@
|
|
|
44
48
|
"icons": "bun run src/icons/build-icons.ts"
|
|
45
49
|
},
|
|
46
50
|
"dependencies": {
|
|
47
|
-
"@ultimat3/core": "
|
|
48
|
-
"@ultimat3/i18n": "
|
|
49
|
-
"@ultimat3/money": "
|
|
50
|
-
"@ultimat3/time": "
|
|
51
|
+
"@ultimat3/core": "21.0.0",
|
|
52
|
+
"@ultimat3/i18n": "21.0.0",
|
|
53
|
+
"@ultimat3/money": "21.0.0",
|
|
54
|
+
"@ultimat3/time": "21.0.0"
|
|
51
55
|
},
|
|
52
56
|
"peerDependencies": {
|
|
53
57
|
"solid-js": "^1.9.0"
|
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
// placeholder is derived from `reserve` so it cannot mismatch the loaded box, and the failure
|
|
8
8
|
// renders through `ErrorState`, which is the one thing allowed to phrase an error.
|
|
9
9
|
|
|
10
|
-
import { finiteCount } from '@ultimat3/core';
|
|
10
|
+
import { type AsyncState, finiteCount } from '@ultimat3/core';
|
|
11
11
|
import type { JSX } from 'solid-js';
|
|
12
12
|
import { ariaBool } from '../a11y';
|
|
13
13
|
import { cx } from '../cx';
|
|
14
14
|
import styles from './AsyncRegion.module.scss';
|
|
15
15
|
import {
|
|
16
16
|
type AsyncBranch,
|
|
17
|
-
type AsyncState,
|
|
18
17
|
asyncBranch,
|
|
19
18
|
isBusyBranch,
|
|
20
19
|
type ReserveBox,
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
// table and a card list cannot disagree about what "loading with stale rows" looks like. Only the
|
|
7
7
|
// PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
|
|
8
8
|
|
|
9
|
-
import { finiteCount } from '@ultimat3/core';
|
|
9
|
+
import { type AsyncState, finiteCount } from '@ultimat3/core';
|
|
10
10
|
import type { JSX } from 'solid-js';
|
|
11
11
|
import { ariaBool } from '../a11y';
|
|
12
12
|
import { cx } from '../cx';
|
|
13
13
|
import { UI_KEYS } from '../i18n-keys';
|
|
14
14
|
import { useUi } from '../theme/context';
|
|
15
|
-
import { type AsyncBranch,
|
|
15
|
+
import { type AsyncBranch, asyncBranch, isBusyBranch } from './async-branch';
|
|
16
16
|
import styles from './DataTable.module.scss';
|
|
17
17
|
import { EmptyState } from './EmptyState';
|
|
18
18
|
import { ErrorState } from './ErrorState';
|
|
@@ -4,18 +4,11 @@
|
|
|
4
4
|
// unreachable until a result has arrived, and a refetch keeps the previous data on screen.
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* What a caller hands an async region
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
|
|
12
|
-
* re-renders what is already on screen instead of tearing it down to a skeleton.
|
|
7
|
+
* What a caller hands an async region is `AsyncState` from `@ultimat3/core` — declared at tier 0
|
|
8
|
+
* because realtime's read hooks (tier 3) return it and ui (tier 4) consumes it, and neither may
|
|
9
|
+
* import the other that way. Imported, never re-exported: one home, one import path.
|
|
13
10
|
*/
|
|
14
|
-
|
|
15
|
-
| { readonly status: 'pending' }
|
|
16
|
-
| { readonly status: 'refreshing'; readonly data: T }
|
|
17
|
-
| { readonly status: 'ready'; readonly data: T }
|
|
18
|
-
| { readonly status: 'failed'; readonly error: unknown };
|
|
11
|
+
import type { AsyncState } from '@ultimat3/core';
|
|
19
12
|
|
|
20
13
|
/**
|
|
21
14
|
* The branch a region renders. `empty` and `ready` carry `busy`; `pending` and `failed` do not,
|
|
@@ -23,8 +16,9 @@ export type AsyncState<T> =
|
|
|
23
16
|
*
|
|
24
17
|
* There is no `{ kind: 'empty' }` reachable from `{ status: 'pending' }` — that is the whole
|
|
25
18
|
* point of this module. "No results" rendered for one frame before the first page arrives is the
|
|
26
|
-
* most common agent-authored UX bug in a list screen, and
|
|
27
|
-
* rather than merely discouraged: `pending` holds no data, so nothing can be found
|
|
19
|
+
* most common agent-authored UX bug in a list screen, and `AsyncState`'s union makes it
|
|
20
|
+
* unconstructible rather than merely discouraged: `pending` holds no data, so nothing can be found
|
|
21
|
+
* empty in it.
|
|
28
22
|
*/
|
|
29
23
|
export type AsyncBranch<T> =
|
|
30
24
|
| { readonly kind: 'pending' }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @ultimat3/ui's error TITLES, registered at import. Its own module, and the one `sideEffects`
|
|
2
|
+
// names, so `errors.ts` — the constructors — can be declared pure: a barrel re-export of a pure
|
|
3
|
+
// module costs a chunk nothing, while a module declared side-effectful is kept by every bundler
|
|
4
|
+
// that honours the field the moment the barrel is imported, which under Bun 1.4.2 put core's
|
|
5
|
+
// whole error registry (~10 kB) into an island that did nothing but register a runtime.
|
|
6
|
+
//
|
|
7
|
+
// `errors.ts` imports this file bare, so a chunk that constructs a `UiError` still registers its
|
|
8
|
+
// titles first — anchored by use, exactly as `scripts/lib/side-effects-scan.ts` describes.
|
|
9
|
+
|
|
10
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
// Unconditional like every other package: every code here is ui's own, and a second package
|
|
13
|
+
// claiming one has to throw X_ERROR_CODE_DUPLICATE at import. Taking the process down there is the
|
|
14
|
+
// point — the alternative is two packages shipping two meanings for one code, decided by load order.
|
|
15
|
+
registerErrorCodes({
|
|
16
|
+
X_TOKEN_UNKNOWN: { title: 'design token role does not exist' },
|
|
17
|
+
X_THEME_INVALID: { title: 'theme is not "light" or "dark"' },
|
|
18
|
+
X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
|
|
19
|
+
X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
|
|
20
|
+
X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
|
|
21
|
+
X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
|
|
22
|
+
X_UI_QR_CAPACITY: { title: 'text is too long for a QR code this component can draw' },
|
|
23
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @ultimat3/ui error codes. Every throw carries a stable code, the cause, and
|
|
2
2
|
// the exact fix — identical in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
5
|
+
// The registration is `error-registry.ts`, imported bare: this module is then side-effect-free
|
|
6
|
+
// as `package.json` declares it, so a bundler that honours `sideEffects` (Bun 1.4.2 does; 1.4.0
|
|
7
|
+
// ignored the array, oven-sh/bun#40650) drops it from a chunk that reaches it only through the
|
|
8
|
+
// barrel — and keeps it, registration included, wherever a constructor below is actually called.
|
|
9
|
+
import './error-registry';
|
|
5
10
|
|
|
6
11
|
export const UI_ERROR_CODES = {
|
|
7
12
|
tokenUnknown: 'X_TOKEN_UNKNOWN',
|
|
@@ -15,19 +20,6 @@ export const UI_ERROR_CODES = {
|
|
|
15
20
|
|
|
16
21
|
export type UiErrorCode = (typeof UI_ERROR_CODES)[keyof typeof UI_ERROR_CODES];
|
|
17
22
|
|
|
18
|
-
// Unconditional like every other package: every code here is ui's own, and a second package
|
|
19
|
-
// claiming one has to throw X_ERROR_CODE_DUPLICATE at import. Taking the process down there is the
|
|
20
|
-
// point — the alternative is two packages shipping two meanings for one code, decided by load order.
|
|
21
|
-
registerErrorCodes({
|
|
22
|
-
X_TOKEN_UNKNOWN: { title: 'design token role does not exist' },
|
|
23
|
-
X_THEME_INVALID: { title: 'theme is not "light" or "dark"' },
|
|
24
|
-
X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
|
|
25
|
-
X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
|
|
26
|
-
X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
|
|
27
|
-
X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
|
|
28
|
-
X_UI_QR_CAPACITY: { title: 'text is too long for a QR code this component can draw' },
|
|
29
|
-
});
|
|
30
|
-
|
|
31
23
|
export class UiError extends UltimateError {
|
|
32
24
|
override readonly name: string = 'UiError';
|
|
33
25
|
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
// would be TS2307 there. The reference pulls the contract along with the entry.
|
|
7
7
|
/// <reference path="./scss.d.ts" />
|
|
8
8
|
|
|
9
|
+
// Bare, and above every re-export: `theme/ambient.ts` registers the server's `useUi()` reader at
|
|
10
|
+
// import, and a bundler may drop a re-exported module whose binding nobody used. A browser build
|
|
11
|
+
// resolves this specifier to `theme/ambient.browser.ts` through `package.json`'s `browser` field,
|
|
12
|
+
// so an island never carries the i18n barrel for it (issue #490).
|
|
13
|
+
import './theme/ambient';
|
|
14
|
+
|
|
9
15
|
export type {
|
|
10
16
|
FocusTrap,
|
|
11
17
|
LiveRegionAttrs,
|
|
@@ -45,7 +51,6 @@ export { shellIds, shellLandmarks } from './components/app-shell-view';
|
|
|
45
51
|
export type {
|
|
46
52
|
AsyncBranch,
|
|
47
53
|
AsyncFlags,
|
|
48
|
-
AsyncState,
|
|
49
54
|
ReserveBox,
|
|
50
55
|
} from './components/async-branch';
|
|
51
56
|
export {
|
|
@@ -290,11 +295,11 @@ export {
|
|
|
290
295
|
TAB_SELECTOR,
|
|
291
296
|
tabStopIndex,
|
|
292
297
|
} from './roving';
|
|
298
|
+
export { ambientUiContext } from './theme/ambient';
|
|
293
299
|
export type { Brand, BrandInput, FontSlot } from './theme/brand';
|
|
294
300
|
export { brandStyleCspSource, brandStyleTag, defineTheme, FONT_SLOTS } from './theme/brand';
|
|
295
301
|
export type { Direction, UiContextValue } from './theme/context';
|
|
296
302
|
export {
|
|
297
|
-
ambientUiContext,
|
|
298
303
|
defaultUiContext,
|
|
299
304
|
fallbackTranslator,
|
|
300
305
|
UI_DEFAULT_CURRENCY,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The module-scope slot holding the reader `useUi()` falls back to when no Solid runtime is
|
|
2
|
+
// registered — a server render. Its own module for the same reason `runtime-slot.ts` is: the
|
|
3
|
+
// server's reader (`ambient.ts`) reaches `@ultimat3/i18n` and `@ultimat3/time`, and a slot that
|
|
4
|
+
// sat beside it would put the framework catalog in every browser chunk that calls `useUi()`.
|
|
5
|
+
// The slot itself imports nothing.
|
|
6
|
+
|
|
7
|
+
import type { UiContextValue } from './context';
|
|
8
|
+
|
|
9
|
+
export type AmbientUiReader = () => UiContextValue;
|
|
10
|
+
|
|
11
|
+
let reader: AmbientUiReader | null = null;
|
|
12
|
+
|
|
13
|
+
/** Called once, at import, by `ambient.ts`; a browser build never reaches that module. */
|
|
14
|
+
export function setAmbientUiReader(next: AmbientUiReader): void {
|
|
15
|
+
reader = next;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registeredAmbientUiReader(): AmbientUiReader | null {
|
|
19
|
+
return reader;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// What a browser bundler gets in place of `ambient.ts` — `package.json`'s `browser` field maps
|
|
2
|
+
// one file to the other. A browser has no request context: `useUi()` in a DOM reads the Solid
|
|
3
|
+
// context a `UiProvider` filled, and throws (`solid()`, X_UI_RUNTIME_MISSING) before it could ever
|
|
4
|
+
// fall back to an ambient reader. So nothing is registered here, and the same export answers the
|
|
5
|
+
// package defaults — which is exactly what the server's reader answers outside a request.
|
|
6
|
+
|
|
7
|
+
import { defaultUiContext, type UiContextValue } from './context';
|
|
8
|
+
|
|
9
|
+
export function ambientUiContext(): UiContextValue {
|
|
10
|
+
return defaultUiContext();
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The presentation context of a SERVER render, read from the request the framework already
|
|
2
|
+
// resolved — and the one module of the theme directory that reaches `@ultimat3/i18n` and
|
|
3
|
+
// `@ultimat3/time` for a value. `index.ts` imports it bare, so every server that imports the
|
|
4
|
+
// barrel has the reader registered before the first `useUi()`; `package.json`'s `browser` field
|
|
5
|
+
// maps this file to `ambient.browser.ts` for a browser bundler, which is what keeps the i18n
|
|
6
|
+
// barrel — and the framework catalog it installs at import — out of an island chunk (issue #490).
|
|
7
|
+
|
|
8
|
+
import { currentDirection, currentLocale, useI18n } from '@ultimat3/i18n';
|
|
9
|
+
import { currentTimeZone } from '@ultimat3/time';
|
|
10
|
+
import { setAmbientUiReader } from './ambient-slot';
|
|
11
|
+
import { UI_DEFAULT_CURRENCY, type UiContextValue } from './context';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `currentLocale()` and `currentTimeZone()` are the ambient answers `@ultimat3/i18n` and
|
|
15
|
+
* `@ultimat3/time` keep on the request context, and `useI18n()` is the translator built from the
|
|
16
|
+
* registered catalogs. No second ambient store, and no process-wide default — outside a request
|
|
17
|
+
* each of them returns its own configured fallback, which is where `defaultUiContext()`'s values
|
|
18
|
+
* come from in the first place.
|
|
19
|
+
*
|
|
20
|
+
* `theme` and `currency` have no ambient source and are not given one. The server cannot know the
|
|
21
|
+
* theme — `data-theme` is decided in the browser by the anti-flash script — and a default display
|
|
22
|
+
* currency is business convention: a `Money` carries its own, and an app that wants another for
|
|
23
|
+
* bare minor units wraps `<Money currency="EUR">` once (axiom 8).
|
|
24
|
+
*/
|
|
25
|
+
export function ambientUiContext(): UiContextValue {
|
|
26
|
+
return {
|
|
27
|
+
theme: 'light',
|
|
28
|
+
locale: currentLocale(),
|
|
29
|
+
timeZone: currentTimeZone(),
|
|
30
|
+
currency: UI_DEFAULT_CURRENCY,
|
|
31
|
+
dir: currentDirection(),
|
|
32
|
+
t: useI18n(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// At module scope, and that is the point: registration an importer can forget is not registration.
|
|
37
|
+
setAmbientUiReader(ambientUiContext);
|
package/src/theme/context.ts
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// The ambient presentation contract every component reads: theme, locale, tz, currency, direction,
|
|
2
2
|
// translator. Formatting components take nothing from a process-wide default — it arrives from
|
|
3
3
|
// `UiProvider` where a Solid runtime is registered, and from the request everywhere else.
|
|
4
|
+
//
|
|
5
|
+
// This module is what every component's `useUi()` retains, so it imports NO value from
|
|
6
|
+
// `@ultimat3/i18n` or `@ultimat3/time`: the i18n barrel installs the framework catalog at import,
|
|
7
|
+
// and the request readers live in `ambient.ts`, reached through `ambient-slot.ts` (issue #490).
|
|
4
8
|
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
currentLocale,
|
|
10
|
-
type Direction,
|
|
11
|
-
directionOf,
|
|
12
|
-
type Locale,
|
|
13
|
-
useI18n,
|
|
14
|
-
} from '@ultimat3/i18n';
|
|
15
|
-
import { currentTimeZone, type TimeZone } from '@ultimat3/time';
|
|
9
|
+
import type { Direction } from '@ultimat3/core';
|
|
10
|
+
import { directionOf } from '@ultimat3/core';
|
|
11
|
+
import type { Locale, Translator } from '@ultimat3/i18n';
|
|
12
|
+
import type { TimeZone } from '@ultimat3/time';
|
|
16
13
|
import type { Theme } from '../tokens/tokens';
|
|
14
|
+
import { registeredAmbientUiReader } from './ambient-slot';
|
|
17
15
|
import { hasSolidRuntime } from './runtime-slot';
|
|
18
16
|
import { type SolidContext, type SolidRuntime, solid } from './solid-adapter';
|
|
19
17
|
|
|
@@ -35,9 +33,23 @@ export const UI_DEFAULT_LOCALE: Locale = 'en';
|
|
|
35
33
|
export const UI_DEFAULT_TIME_ZONE = 'UTC' as TimeZone;
|
|
36
34
|
export const UI_DEFAULT_CURRENCY = 'USD';
|
|
37
35
|
|
|
38
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Loud-miss translator: a forgotten catalog key renders ⟦key⟧, never blank.
|
|
38
|
+
*
|
|
39
|
+
* Written here rather than as `createTranslator({}, locale)`, and behaviourally the same call: an
|
|
40
|
+
* empty catalog has no key, so every lookup is a miss, `has` is false, `raw` is undefined and
|
|
41
|
+
* `keys` is empty — `context.test.ts` holds each of those against `@ultimat3/i18n`'s own. What
|
|
42
|
+
* the spelling buys is that a browser chunk with a `UiProvider` in it no longer reaches the i18n
|
|
43
|
+
* barrel for a translator that cannot translate.
|
|
44
|
+
*/
|
|
39
45
|
export function fallbackTranslator(locale: Locale = UI_DEFAULT_LOCALE): Translator {
|
|
40
|
-
|
|
46
|
+
const miss = (key: string): string => `⟦${key}⟧`;
|
|
47
|
+
return Object.assign(miss, {
|
|
48
|
+
has: (): boolean => false,
|
|
49
|
+
raw: (): string | undefined => undefined,
|
|
50
|
+
keys: (): string[] => [],
|
|
51
|
+
locale,
|
|
52
|
+
});
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
export function defaultUiContext(): UiContextValue {
|
|
@@ -51,30 +63,6 @@ export function defaultUiContext(): UiContextValue {
|
|
|
51
63
|
};
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
/**
|
|
55
|
-
* The presentation context of a server render, read from the request the framework already
|
|
56
|
-
* resolved: `currentLocale()` and `currentTimeZone()` are the ambient answers `@ultimat3/i18n` and
|
|
57
|
-
* `@ultimat3/time` keep on the request context, and `useI18n()` is the translator built from the
|
|
58
|
-
* registered catalogs. No second ambient store, and no process-wide default — outside a request
|
|
59
|
-
* each of them returns its own configured fallback, which is where `defaultUiContext()`'s values
|
|
60
|
-
* come from in the first place.
|
|
61
|
-
*
|
|
62
|
-
* `theme` and `currency` have no ambient source and are not given one. The server cannot know the
|
|
63
|
-
* theme — `data-theme` is decided in the browser by the anti-flash script — and a default display
|
|
64
|
-
* currency is business convention: a `Money` carries its own, and an app that wants another for
|
|
65
|
-
* bare minor units wraps `<Money currency="EUR">` once (axiom 8).
|
|
66
|
-
*/
|
|
67
|
-
export function ambientUiContext(): UiContextValue {
|
|
68
|
-
return {
|
|
69
|
-
theme: 'light',
|
|
70
|
-
locale: currentLocale(),
|
|
71
|
-
timeZone: currentTimeZone(),
|
|
72
|
-
currency: UI_DEFAULT_CURRENCY,
|
|
73
|
-
dir: currentDirection(),
|
|
74
|
-
t: useI18n(),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
66
|
let cached: {
|
|
79
67
|
readonly runtime: SolidRuntime;
|
|
80
68
|
readonly context: SolidContext<UiContextValue>;
|
|
@@ -99,8 +87,15 @@ export function uiContext(): SolidContext<UiContextValue> {
|
|
|
99
87
|
* Solid context is provably empty — an inert tree is walked outside every owner, so `useContext`
|
|
100
88
|
* returns the context's default value even with a real runtime registered — so reading the
|
|
101
89
|
* request's own answers is strictly more true than reading a provider that provided nothing.
|
|
90
|
+
*
|
|
91
|
+
* Those answers come through the slot: `ambient.ts` registers `ambientUiContext` when the barrel
|
|
92
|
+
* is imported, and a browser build — where this branch is unreachable — never has it. The
|
|
93
|
+
* defaults are the fallback for a caller that reached this module without the barrel, which is
|
|
94
|
+
* what `ambientUiContext()` itself answers outside a request.
|
|
102
95
|
*/
|
|
103
96
|
export function useUi(): UiContextValue {
|
|
104
97
|
const runtime = solid();
|
|
105
|
-
|
|
98
|
+
if (hasSolidRuntime()) return runtime.useContext(uiContext());
|
|
99
|
+
const ambient = registeredAmbientUiReader();
|
|
100
|
+
return ambient === null ? defaultUiContext() : ambient();
|
|
106
101
|
}
|
package/src/theme/provider.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// UiProvider — the single place an app injects presentation context. Reactive
|
|
2
|
-
// access goes through the Solid adapter, so this file imports only solid types
|
|
2
|
+
// access goes through the Solid adapter, so this file imports only solid types — and only TYPES
|
|
3
|
+
// from `@ultimat3/i18n`: `directionOf` is core's, because a value import of the i18n barrel is
|
|
4
|
+
// the framework catalog in every island chunk with a provider (issue #490).
|
|
3
5
|
|
|
4
|
-
import { directionOf
|
|
6
|
+
import { directionOf } from '@ultimat3/core';
|
|
7
|
+
import type { Locale, Translator } from '@ultimat3/i18n';
|
|
5
8
|
import type { TimeZone } from '@ultimat3/time';
|
|
6
9
|
import type { JSX } from 'solid-js';
|
|
7
10
|
import { providerNeedsRuntimeError } from '../errors';
|