@ultimat3/ui 6.0.0 → 8.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 +3 -3
- package/README.md +64 -9
- package/package.json +10 -5
- package/src/errors.ts +11 -7
- package/src/jsx-probe.ts +8 -0
- package/src/theme/solid-adapter.ts +18 -2
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @ultimat3/ui — agent notes
|
|
2
2
|
|
|
3
|
-
Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http`, `action`, `render`, `admin
|
|
3
|
+
Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was deleted). Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which is what keeps the static bundle graph out of the design system.
|
|
4
4
|
|
|
5
5
|
## Boundary
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http
|
|
|
29
29
|
- **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render`'s `index.ts` installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import, and `bun test` is one process — so any file in the run that imports render first makes every ui component after it compile to render's `h` instead of the file's own inert copy. The walker recognises both (`Symbol.for('ultimate.render.jsx')`, off the global registry, never an import), and the first test in the describe asserts a component returned a node it recognises. Without both halves the file silently rendered `"[object Object]"` and 26 assertions were decided by shard packing.
|
|
30
30
|
- **Components are not unit-tested through a renderer.** `.tsx` compiles to `@ultimat3/render`'s `h`, which this package may not import, so every rule lives in a pure module beside the component (`icon-glyph.ts`, `accordion-view.ts`, `combobox-filter.ts`, `infinite-scroll-view.ts`) and *that* is what the tests assert.
|
|
31
31
|
- Formatting logic lives in a pure `*-view.ts` next to the component (`money-view.ts`, `date-time-view.ts`) so it is testable with no renderer. Every other renderer-free core follows the same rule under its own name (`sort-state.ts`, `image-source.ts`) — the `.tsx` holds markup, never a rule.
|
|
32
|
-
- **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
|
|
32
|
+
- **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `jsx-probe`'s `probe`/`unprobe` are the one exception, reachable at the subpath `@ultimat3/ui/jsx-probe` and still absent from the barrel: `globalThis.React` is ONE property, so its install/restore counter has to be ONE counter — `@ultimat3/admin` (tier 5) kept a second pair, and interleaved installs restored the two harnesses in the wrong order, leaving the global holding a harness the run had already torn down. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
|
|
33
33
|
- **A roving group excludes disabled items from both answers** — the set arrows walk and the one item holding the tab stop (`src/roving.ts`). `focus()` on a disabled control is a no-op, so a disabled item left in the list pins the reducer on its index forever. And a control that answers arrows itself (`handlesOwnArrowKeys`) keeps them: a `Toolbar` exists to hold a search field.
|
|
34
34
|
- **A single-winner state attribute is decided by POSITION, never by a missing prop.** `Breadcrumb` gives `aria-current="page"` to the last item and to nothing else; an href-less ancestor renders as plain text with no `aria-current` at all. Reading "no href" as "is the current page" put two of them in one `<nav>`. `Tabs.tsx` is the same rule for `tabindex`, and `Breadcrumb.test.ts` proves it through `jsx-probe` rather than through the pure helper.
|
|
35
35
|
- **Live semantics belong to the container that outlives the message.** `ToastRegion`'s `<ol>` carries `aria-live`; a `Toast` is a plain `<li>`. A region created with its content already inside it is not announced, and a `role="status"` on the `<li>` also strips its `listitem` semantics.
|
|
@@ -55,7 +55,7 @@ Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http
|
|
|
55
55
|
| `src/catalog/` | parses `components/*.tsx` into `CATALOG.md`; `bun run catalog` writes it, `catalog.test.ts` fails on drift |
|
|
56
56
|
| `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
|
|
57
57
|
| `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
|
|
58
|
-
| `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 |
|
|
58
|
+
| `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 |
|
|
59
59
|
| `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
60
|
|
|
61
61
|
## Assumed peer contracts
|
package/README.md
CHANGED
|
@@ -186,7 +186,7 @@ to render on the server.**
|
|
|
186
186
|
| | Server render | Client render |
|
|
187
187
|
|---|---|---|
|
|
188
188
|
| The renderer | `@ultimat3/render`'s inert JSX factory — a component is a plain function, called once | Solid, with a reactive graph |
|
|
189
|
-
| The runtime | `INERT_SOLID_RUNTIME`, handed out automatically: signals hold, memos recompute on read, effects never run | the real one, registered once: `setSolidRuntime(
|
|
189
|
+
| The runtime | `INERT_SOLID_RUNTIME`, handed out automatically: signals hold, memos recompute on read, effects never run | the real one, registered once: `setSolidRuntime(solidRuntime)`, from `import * as solidRuntime from 'solid-js'` |
|
|
190
190
|
| Where `useUi()` reads | the request — `currentLocale()`, `currentTimeZone()`, `useI18n()` | `<UiProvider>`, through Solid's context |
|
|
191
191
|
| `<UiProvider>` | **throws** `X_UI_RUNTIME_MISSING` | the one injection point |
|
|
192
192
|
|
|
@@ -220,19 +220,74 @@ import '../../shared/global'; // `shared/global.scss` is the app's one `@use '@u
|
|
|
220
220
|
```
|
|
221
221
|
|
|
222
222
|
```tsx
|
|
223
|
-
// A client entry — an island
|
|
223
|
+
// A client entry — an island's `mount()`, or a hydrated app shell. In this order, once.
|
|
224
|
+
import { createTranslator } from '@ultimat3/i18n';
|
|
224
225
|
import { setSolidRuntime, UiProvider } from '@ultimat3/ui';
|
|
226
|
+
import type { JSX } from 'solid-js';
|
|
227
|
+
import * as solidRuntime from 'solid-js';
|
|
228
|
+
import { render } from 'solid-js/web';
|
|
229
|
+
|
|
230
|
+
interface Props {
|
|
231
|
+
readonly locale: string;
|
|
232
|
+
readonly timeZone: string;
|
|
233
|
+
readonly currency: string;
|
|
234
|
+
/** The `ui.*` keys this tree renders, resolved on the server. A catalog crosses the seam as
|
|
235
|
+
* JSON; a `Translator` is a function and cannot. */
|
|
236
|
+
readonly strings: Readonly<Record<string, string>>;
|
|
237
|
+
readonly tree: JSX.Element;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function mount(el: HTMLElement, props: Props): void {
|
|
241
|
+
// NOT `await import('solid-js')`: the chunk already carries Solid statically, so the await buys
|
|
242
|
+
// no bytes and makes `mount` async — and the hydration runtime calls it synchronously.
|
|
243
|
+
setSolidRuntime(solidRuntime);
|
|
244
|
+
el.textContent = ''; // Solid's `render` APPENDS; the server's shell would stay above this one.
|
|
245
|
+
render(
|
|
246
|
+
() => (
|
|
247
|
+
<UiProvider
|
|
248
|
+
locale={props.locale}
|
|
249
|
+
timeZone={props.timeZone}
|
|
250
|
+
currency={props.currency}
|
|
251
|
+
t={createTranslator(props.strings, props.locale)}
|
|
252
|
+
>
|
|
253
|
+
{props.tree}
|
|
254
|
+
</UiProvider>
|
|
255
|
+
),
|
|
256
|
+
el,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
```
|
|
225
260
|
|
|
226
|
-
|
|
261
|
+
**The prop is `t`, it takes a `Translator`, and omitting it is not neutral.** `<UiProvider>` with
|
|
262
|
+
no `t` falls back to `fallbackTranslator(locale)` — `createTranslator({}, locale)`, an **empty**
|
|
263
|
+
catalog — so every built-in string in the tree renders its key: `<Dialog>`'s close button reads
|
|
264
|
+
`⟦ui.close⟧`, `<Field>`'s marker `⟦ui.required⟧`. The keys are `UI_KEYS`, and they live in the
|
|
265
|
+
framework catalog the SERVER has registered; a browser chunk has none, which is why
|
|
266
|
+
`translatorFor(locale)` on the client is the same empty answer wearing a better name. Send the
|
|
267
|
+
subset the island renders and build the translator from it. `t` itself — `@ultimat3/i18n`'s bare
|
|
268
|
+
exported function — is not a `Translator` and is `TS2739` in this position.
|
|
227
269
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
</UiProvider>
|
|
231
|
-
```
|
|
270
|
+
An island's own copy is a different thing and stays a plain prop: it arrives already translated,
|
|
271
|
+
as text, because `t()`'s catalog does not cross the seam and neither does a callback.
|
|
232
272
|
|
|
233
273
|
`Field` owns the ids, so `aria-describedby` / `aria-invalid` can never drift from
|
|
234
|
-
what is rendered. `UiProvider` sets `lang` + `dir` on `<html
|
|
235
|
-
|
|
274
|
+
what is rendered. `UiProvider` sets `lang` + `dir` on `<html>` from `locale`, so
|
|
275
|
+
`ar-EG` needs no second stylesheet and no second component.
|
|
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`:
|
|
279
|
+
|
|
280
|
+
| An island that imports | Chunk |
|
|
281
|
+
|---|---|
|
|
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.
|
|
236
291
|
|
|
237
292
|
## `<Text>` and `<Image>`
|
|
238
293
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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
|
+
"sideEffects": [
|
|
8
|
+
"./src/errors.ts",
|
|
9
|
+
"**/*.scss"
|
|
10
|
+
],
|
|
7
11
|
"repository": {
|
|
8
12
|
"type": "git",
|
|
9
13
|
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
@@ -16,6 +20,7 @@
|
|
|
16
20
|
"exports": {
|
|
17
21
|
".": "./src/index.ts",
|
|
18
22
|
"./icons/*": "./src/icons/glyphs/*.ts",
|
|
23
|
+
"./jsx-probe": "./src/jsx-probe.ts",
|
|
19
24
|
"./tokens": "./src/tokens/_index.scss",
|
|
20
25
|
"./theme.scss": "./src/tokens/theme.scss",
|
|
21
26
|
"./reset.scss": "./src/tokens/reset.scss",
|
|
@@ -39,10 +44,10 @@
|
|
|
39
44
|
"icons": "bun run src/icons/build-icons.ts"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
42
|
-
"@ultimat3/core": "
|
|
43
|
-
"@ultimat3/i18n": "
|
|
44
|
-
"@ultimat3/money": "
|
|
45
|
-
"@ultimat3/time": "
|
|
47
|
+
"@ultimat3/core": "8.0.0",
|
|
48
|
+
"@ultimat3/i18n": "8.0.0",
|
|
49
|
+
"@ultimat3/money": "8.0.0",
|
|
50
|
+
"@ultimat3/time": "8.0.0"
|
|
46
51
|
},
|
|
47
52
|
"peerDependencies": {
|
|
48
53
|
"solid-js": "^1.9.0"
|
package/src/errors.ts
CHANGED
|
@@ -91,18 +91,22 @@ export function runtimeMissingError(api: string, fix: string): UiError {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* `<UiProvider>`
|
|
95
|
-
* what X_UI_RUNTIME_MISSING already names — and a code is stable forever once shipped. The
|
|
96
|
-
* is the point. A Provider in an inert tree reaches no descendant (they are walked outside
|
|
97
|
-
* owner), so rendering the children anyway would drop the locale, zone, currency and
|
|
98
|
-
* was handed while looking like it worked.
|
|
94
|
+
* `<UiProvider>` with no runtime registered. No new code: a reactive runtime is absent, which is
|
|
95
|
+
* exactly what X_UI_RUNTIME_MISSING already names — and a code is stable forever once shipped. The
|
|
96
|
+
* throw is the point. A Provider in an inert tree reaches no descendant (they are walked outside
|
|
97
|
+
* every owner), so rendering the children anyway would drop the locale, zone, currency and
|
|
98
|
+
* translator it was handed while looking like it worked.
|
|
99
|
+
*
|
|
100
|
+
* TWO callers reach here, not one, and the cause used to name only the first: a server render,
|
|
101
|
+
* which has no runtime by design, and an island whose `mount` never called `setSolidRuntime` —
|
|
102
|
+
* which is a real bug in a real browser, and was being told it was a server render.
|
|
99
103
|
*/
|
|
100
104
|
export function providerNeedsRuntimeError(): UiError {
|
|
101
105
|
return new UiError({
|
|
102
106
|
code: UI_ERROR_CODES.runtimeMissing,
|
|
103
107
|
cause:
|
|
104
|
-
'<UiProvider>
|
|
105
|
-
fix:
|
|
108
|
+
'<UiProvider> was rendered with no Solid runtime registered, so its locale, time zone, currency and translator would reach no component',
|
|
109
|
+
fix: "in an island, paste `import * as solidRuntime from 'solid-js';` at the top of the *.island.tsx and `setSolidRuntime(solidRuntime);` as the first line of its mount(), above the render() that builds <UiProvider>; on the server, delete <UiProvider> — useUi() already reads the request locale and time zone",
|
|
106
110
|
});
|
|
107
111
|
}
|
|
108
112
|
|
package/src/jsx-probe.ts
CHANGED
|
@@ -35,6 +35,14 @@ function h(
|
|
|
35
35
|
* DELETED the property on the way out destroyed a binding it did not create, and a nested or
|
|
36
36
|
* repeated probe tore the factory out from under the suite still using it — the counter is what
|
|
37
37
|
* makes the last unprobe the only one that restores.
|
|
38
|
+
*
|
|
39
|
+
* There may be exactly ONE such counter in the framework, and it is this one — `globalThis.React`
|
|
40
|
+
* is a single property, so a second module counting its own depth over it restores in the wrong
|
|
41
|
+
* order. `@ultimat3/admin`'s `inert-jsx.ts` kept a second pair and the two interleaved: admin
|
|
42
|
+
* installs (saving the real binding), ui installs (saving ADMIN's factory), admin restores (both
|
|
43
|
+
* counters at 1 → the real binding is back), ui restores (its counter hits 0 → admin's factory is
|
|
44
|
+
* reinstalled over it). The global ended up holding a harness the run had already torn down.
|
|
45
|
+
* `admin` is tier 5 and `ui` is tier 4, so admin imports these rather than declaring them.
|
|
38
46
|
*/
|
|
39
47
|
let depth = 0;
|
|
40
48
|
let saved: PropertyDescriptor | undefined;
|
|
@@ -9,10 +9,19 @@ import { INERT_SOLID_RUNTIME } from './inert-runtime';
|
|
|
9
9
|
export type Accessor<T> = () => T;
|
|
10
10
|
export type Setter<T> = (next: T) => void;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* `children` is REQUIRED, and that one keyword is what makes `setSolidRuntime(solidRuntime)`
|
|
14
|
+
* compile at all. Solid's own `ContextProviderComponent` takes `FlowProps`, whose `children` is
|
|
15
|
+
* required, and a function taking `children?` is not assignable to one taking `children` — so
|
|
16
|
+
* `typeof import('solid-js')` did not satisfy `SolidRuntime` and the registration this package
|
|
17
|
+
* documents was a type error nobody had ever compiled: it had zero non-test callers (issue #246),
|
|
18
|
+
* and every test passed a hand-written fake that matched the declaration instead of the runtime.
|
|
19
|
+
* `JSX.Element` already includes `undefined`, so a provider with no children still type-checks.
|
|
20
|
+
*/
|
|
12
21
|
export interface SolidContext<T> {
|
|
13
22
|
readonly id: symbol;
|
|
14
23
|
readonly defaultValue: T;
|
|
15
|
-
readonly Provider: (props: { value: T; children
|
|
24
|
+
readonly Provider: (props: { value: T; children: JSX.Element }) => JSX.Element;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
/** The exact slice of solid-js the design system touches. */
|
|
@@ -56,9 +65,16 @@ function hasDom(): boolean {
|
|
|
56
65
|
export function solid(): SolidRuntime {
|
|
57
66
|
if (runtime !== null) return runtime;
|
|
58
67
|
if (hasDom()) {
|
|
68
|
+
// Two lines to paste, and both are real: `x g resource` writes exactly them into the slice's
|
|
69
|
+
// `*-form.island.tsx`. The line this used to hand out was `setSolidRuntime(await
|
|
70
|
+
// import('solid-js'))`, which makes `mount` ASYNC for nothing — an island chunk already
|
|
71
|
+
// carries Solid statically, because the same file imports `render` from `solid-js/web`.
|
|
59
72
|
throw runtimeMissingError(
|
|
60
73
|
'a registered Solid runtime',
|
|
61
|
-
|
|
74
|
+
// ONE literal, never a concatenation: `fix-scan.ts` reads a single literal in this position
|
|
75
|
+
// and counts anything else `unreadable`, so a fix split across `+` is a fix the gate stops
|
|
76
|
+
// checking.
|
|
77
|
+
"paste `import * as solidRuntime from 'solid-js';` at the top of your *.island.tsx and `setSolidRuntime(solidRuntime);` as the first line of its mount(), above render() — a server render needs none",
|
|
62
78
|
);
|
|
63
79
|
}
|
|
64
80
|
return INERT_SOLID_RUNTIME;
|