@ultimat3/ui 27.16.0 → 28.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 CHANGED
@@ -15,8 +15,8 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
15
15
  - **No raw length, breakpoint, z-index, shadow or duration** in any sheet here: `t.space()` / `t.stroke()` / `t.rem()`, `respond-*`, `t.z()`, `t.shadow()`, `t.duration()`. Held by `packages/cli/src/templates/scaffold-guards-style.test.ts`, which runs the guards `x new` ships over this package.
16
16
  - **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
17
17
  - **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
18
- - **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid-adapter.ts` reaches `../errors` and so core's error registry; the slot imports nothing, so an island that only calls `setSolidRuntime` pays 72 B, not 5,719. `barrel-bytes.test.ts` holds the ceiling at 1 kB.
19
- - **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only. `barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issue #273) rather than allowing bytes for it. Its sibling #276 — a pure re-export entry shaken to its export clause — was fixed in Bun 1.4.1 (oven-sh/bun#40578), and barrels have been built as their own entry since.
18
+ - **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid-adapter.ts` reaches `../errors` and so core's error registry; the slot imports nothing, so an island that only calls `setSolidRuntime` pays 72 B, not 5,719. `scripts/ui-barrel-bytes.test.ts` holds the ceiling at 1 kB.
19
+ - **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The one JavaScript subpath besides the barrel is not an exception: `./icons/*` is 1,767 data modules no bundler can split (`./tokens`, `./theme.scss`, `./reset.scss` and `./global.scss` are stylesheets). `scripts/ui-barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issue #273) rather than allowing bytes for it. Its sibling #276 — a pure re-export entry shaken to its export clause — was fixed in Bun 1.4.1 (oven-sh/bun#40578), and barrels have been built as their own entry since.
20
20
  - **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (issue #490). `context.ts` imports no value from `@ultimat3/i18n` or `@ultimat3/time`. `src/theme/ambient.ts` registers into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export; `package.json`'s `browser` field maps it to `ambient.browser.ts`. A test reaching components by module path must `import './ambient'` itself. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare.
21
21
  - **`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.
22
22
  - **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()`, which read **core's own `Ctx.locale` / `Ctx.tz`** (written once per request by `@ultimat3/http`'s `locale` stage). Never add a second ambient store here or in `i18n`/`time`. `theme`/`currency` have no ambient source at all.
@@ -35,7 +35,7 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
35
35
  - **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render` installs a process-global `.tsx` `onLoad` plugin at import, so in one `bun test` process a component may compile to render's `h`. The walker recognises both (`Symbol.for('ultimate.render.jsx')`), and the first test asserts a recognised node — otherwise the file renders `"[object Object]"` and passes by shard packing.
36
36
  - **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.
37
37
  - 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.
38
- - **The rule being pure is not enough — the WIRING is tested too.** `src/jsx-probe.ts` reads the props an element carries and `src/fake-dom.ts` is a DOM where a disabled control refuses focus; both TEST-ONLY, neither in `index.ts`. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reachable at `@ultimat3/ui/jsx-probe` (admin imports them). A keyboard or form-participation claim is proven in `components/interaction.test.ts`.
38
+ - **The rule being pure is not enough — the WIRING is tested too.** `src/jsx-probe-fixture.ts` reads the props an element carries and `src/fake-dom.ts` is a DOM where a disabled control refuses focus; both TEST-ONLY, neither in `index.ts`. A keyboard or form-participation claim is proven in `components/interaction.test.ts`.
39
39
  - **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.
40
40
  - **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.
41
41
  - **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch` (`src/components/async-branch.ts`) is the ONLY place the decision is made — `AsyncRegion` renders it, `DataTable` calls it. `AsyncState` is core's (imported, never re-exported). `empty` and `ready` are REQUIRED props; `refreshing` carries the previous data and dims it (`aria-busy`), never tears it down; `reserve` feeds the placeholder AND every branch's `min-block-size`.
@@ -100,8 +100,8 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
100
100
  | `src/sass-probe.ts` | TEST-ONLY: what Sass EMITS. `sass` is resolved from `packages/render`'s directory — never an import of render, never a second copy |
101
101
  | `src/components/button-classes.ts` | the keys of a button's look, asked by `Button` AND `<Link appearance="button">` (`link-classes.ts`). `Button` gets no `href` |
102
102
  | `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
103
- | `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 |
104
- | `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 |
103
+ | `src/jsx-probe-fixture.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries. A `-fixture` file, so no tarball ships it; its subpath export went in 28.0.0 with `probe`/`unprobe` and their `globalThis.React` (issue #883) |
104
+ | `scripts/ui-barrel-bytes.test.ts` (repo root) | 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 |
105
105
  | `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 |
106
106
 
107
107
  ## Assumed peer contracts
package/README.md CHANGED
@@ -521,7 +521,7 @@ name it.** Measured through `buildIslands` — minified, production Solid, `As o
521
521
  `import { Button } from '@ultimat3/ui'` and a deep path into `components/Button` produce the
522
522
  same chunk to within the bytes of the entry's own name, and so do `useUi` (14 kB) and
523
523
  `moneyText` (25 kB). `@ultimat3/ui/button` would be a second way to import one name for zero
524
- bytes, which is axiom 1 for nothing. `src/barrel-bytes.test.ts` is the build error, and it will
524
+ bytes, which is axiom 1 for nothing. `scripts/ui-barrel-bytes.test.ts` (repo root) is the build error, and it will
525
525
  red the day the barrel stops shaking. `@ultimat3/ui/icons/*` is a subpath for the opposite
526
526
  reason: 1,767 glyph modules are data, and no bundler splits one module holding all of them.
527
527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "27.16.0",
3
+ "version": "28.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",
@@ -24,7 +24,6 @@
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
26
  "./icons/*": "./src/icons/glyphs/*.ts",
27
- "./jsx-probe": "./src/jsx-probe.ts",
28
27
  "./tokens": "./src/tokens/_index.scss",
29
28
  "./theme.scss": "./src/tokens/theme.scss",
30
29
  "./reset.scss": "./src/tokens/reset.scss",
@@ -40,7 +39,7 @@
40
39
  "LICENSE"
41
40
  ],
42
41
  "engines": {
43
- "bun": ">=1.4.2"
42
+ "bun": ">=1.4.3"
44
43
  },
45
44
  "scripts": {
46
45
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -49,10 +48,10 @@
49
48
  "icons": "bun run src/icons/build-icons.ts"
50
49
  },
51
50
  "dependencies": {
52
- "@ultimat3/core": "27.16.0",
53
- "@ultimat3/i18n": "27.16.0",
54
- "@ultimat3/money": "27.16.0",
55
- "@ultimat3/time": "27.16.0"
51
+ "@ultimat3/core": "28.0.0",
52
+ "@ultimat3/i18n": "28.0.0",
53
+ "@ultimat3/money": "28.0.0",
54
+ "@ultimat3/time": "28.0.0"
56
55
  },
57
56
  "peerDependencies": {
58
57
  "solid-js": "^1.9.0"
package/src/index.ts CHANGED
@@ -341,7 +341,7 @@ export { THEME_PRESET_NAMES, THEME_PRESETS } from './theme/presets';
341
341
  export type { UiProviderProps } from './theme/provider';
342
342
  export { UiProvider } from './theme/provider';
343
343
  // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
344
- // @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
344
+ // @ultimat3/core's error registry — into an island chunk. `scripts/ui-barrel-bytes.test.ts` holds the ceiling.
345
345
  export { hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
346
346
  export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
347
347
  export { solid } from './theme/solid-adapter';
@@ -4,7 +4,7 @@
4
4
  // `runtimeMissingError`, so it imports `../errors`, which `package.json` declares side-effectful
5
5
  // (`registerErrorCodes()` runs at import) and no bundler may therefore shake. An island that only
6
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.
7
+ // measured in `scripts/ui-barrel-bytes.test.ts`, which is the build error behind this file existing.
8
8
 
9
9
  import type { SolidRuntime } from './solid-adapter';
10
10
 
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // The slot itself is `runtime-slot.ts`, not this file: `runtimeMissingError` below reaches
6
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`).
7
+ // would pay the error registry for it (`scripts/ui-barrel-bytes.test.ts`).
8
8
 
9
9
  import type { JSX } from 'solid-js';
10
10
  import { runtimeMissingError } from '../errors';
package/src/jsx-probe.ts DELETED
@@ -1,134 +0,0 @@
1
- // TEST-ONLY. Calls a component and walks what it returned, so a test can assert on the props an
2
- // element actually carries — the `tabindex`, the `aria-live`, the `onKeyDown`, the `ref` — rather
3
- // than on a string it was serialised into. Complements `theme/inert-render.test.ts`, which asks
4
- // the other question (does the markup come out at all).
5
- //
6
- // Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
7
- // installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import, and `bun test` is one
8
- // process. Both factories build the same shape — a `type`, a `props`, children under
9
- // `props.children` — so this walker recognises both and the run's file order stops deciding.
10
-
11
- const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
12
-
13
- export interface ProbeNode {
14
- readonly type: string | ((props: Record<string, unknown>) => unknown);
15
- readonly props: Record<string, unknown>;
16
- }
17
-
18
- export function isProbeNode(value: unknown): value is ProbeNode {
19
- if (typeof value !== 'object' || value === null) return false;
20
- return 'inert' in value || RENDER_NODE in value;
21
- }
22
-
23
- function h(
24
- type: ProbeNode['type'],
25
- props: Record<string, unknown> | null,
26
- ...children: readonly unknown[]
27
- ): ProbeNode {
28
- const base = { ...(props ?? {}) };
29
- if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
30
- return { inert: true, type, props: base } as unknown as ProbeNode;
31
- }
32
-
33
- /**
34
- * How many probes are live, and what `globalThis.React` was before the first one. A harness that
35
- * DELETED the property on the way out destroyed a binding it did not create, and a nested or
36
- * repeated probe tore the factory out from under the suite still using it — the counter is what
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-fixture.ts` kept a second pair and the two interleaved:
42
- * admin installs (saving the real binding), ui installs (saving ADMIN's factory), admin restores
43
- * (both counters at 1 → the real binding is back), ui restores (its counter hits 0 → admin's
44
- * factory is reinstalled over it). The global ended up holding a harness the run had already torn
45
- * down.
46
- * `admin` is tier 5 and `ui` is tier 4, so admin imports these rather than declaring them.
47
- */
48
- let depth = 0;
49
- let saved: PropertyDescriptor | undefined;
50
-
51
- /** Install the classic-factory global these `.tsx` files fall back to. Paired with `unprobe()`. */
52
- export function probe(): void {
53
- if (depth === 0) {
54
- // The descriptor, not the value: the property may be a getter or non-writable, and `assign`
55
- // onto either throws or silently loses. `defineProperty` installs over both.
56
- saved = Object.getOwnPropertyDescriptor(globalThis, 'React');
57
- Object.defineProperty(globalThis, 'React', {
58
- value: { createElement: h },
59
- configurable: true,
60
- writable: true,
61
- enumerable: true,
62
- });
63
- }
64
- depth += 1;
65
- }
66
-
67
- /** Undo the matching `probe()`. Unbalanced calls are inert: nothing this did not install is torn
68
- * down, because the binding at depth 0 belongs to somebody else. */
69
- export function unprobe(): void {
70
- if (depth === 0) return;
71
- depth -= 1;
72
- if (depth > 0) return;
73
- if (saved === undefined) Reflect.deleteProperty(globalThis, 'React');
74
- else Object.defineProperty(globalThis, 'React', saved);
75
- saved = undefined;
76
- }
77
-
78
- /**
79
- * Every host node in the tree, depth first, with nested components CALLED. Thunks are called too:
80
- * a component that reads a prop inside one renders nothing until something asks.
81
- */
82
- export function nodesOf(value: unknown): ProbeNode[] {
83
- if (value === null || value === undefined || typeof value === 'boolean') return [];
84
- if (typeof value === 'string' || typeof value === 'number') return [];
85
- if (Array.isArray(value)) return value.flatMap(nodesOf);
86
- if (isProbeNode(value)) {
87
- if (typeof value.type === 'function') return nodesOf(value.type(value.props));
88
- return [value, ...nodesOf(value.props['children'])];
89
- }
90
- if (typeof value === 'function') return nodesOf((value as () => unknown)());
91
- return [];
92
- }
93
-
94
- /** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
95
- export function renderNodes(component: unknown, props: Record<string, unknown> = {}): ProbeNode[] {
96
- return nodesOf((component as (p: Record<string, unknown>) => unknown)(props));
97
- }
98
-
99
- const attr = (node: ProbeNode, name: string): unknown => node.props[name];
100
-
101
- /** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
102
- export function withAttr(nodes: readonly ProbeNode[], name: string, value?: unknown): ProbeNode[] {
103
- return nodes.filter((node) =>
104
- value === undefined ? attr(node, name) !== undefined : attr(node, name) === value,
105
- );
106
- }
107
-
108
- export function byTag(nodes: readonly ProbeNode[], tag: string): ProbeNode[] {
109
- return nodes.filter((node) => node.type === tag);
110
- }
111
-
112
- /** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
113
- export function one(nodes: readonly ProbeNode[], what: string): ProbeNode {
114
- const node = nodes[0];
115
- if (node === undefined || nodes.length !== 1) {
116
- throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
117
- }
118
- return node;
119
- }
120
-
121
- /** Call an element's `ref` prop with the element a test built for it. */
122
- export function attachRef(node: ProbeNode, element: unknown): void {
123
- const ref = node.props['ref'];
124
- if (typeof ref !== 'function') throw new Error(`node <${String(node.type)}> carries no ref`);
125
- (ref as (el: unknown) => void)(element);
126
- }
127
-
128
- /** Call an element's event handler prop, e.g. `fire(menu, 'onKeyDown', keydown('ArrowDown'))`. */
129
- export function fire(node: ProbeNode, handler: string, event: unknown): void {
130
- const fn = node.props[handler];
131
- if (typeof fn !== 'function')
132
- throw new Error(`node <${String(node.type)}> carries no ${handler}`);
133
- (fn as (e: unknown) => void)(event);
134
- }