@ultimat3/ui 20.2.1 → 22.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
@@ -1,6 +1,6 @@
1
1
  # @ultimat3/ui — agent notes
2
2
 
3
- Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was deleted). Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**, which this line claimed until 2026-08-24 and `package.json` never declared. That absence is why `src/form/` re-declares Standard Schema's one member structurally (`FormSchema`) and copies `formatPath` as `formatFieldPath`: the tier table permits the edge, the manifest and the lockfile do not. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which is what keeps the static bundle graph out of the design system.
3
+ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**, which `package.json` does not declare: that is why `src/form/` re-declares Standard Schema's one member structurally (`FormSchema`) and copies `formatPath` as `formatFieldPath`. Never `http`, `action`, `render`, `admin` — `render` is tier 4 too, which keeps the static bundle graph out of the design system.
4
4
 
5
5
  ## Boundary
6
6
 
@@ -14,17 +14,12 @@ 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 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
- - **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.
17
+ - **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.
18
+ - **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, issues #273 #276) rather than allowing bytes for it.
19
+ - **`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` (the i18n barrel installs the framework catalog at import: 16.1 kB per island). `src/theme/ambient.ts` registers `ambientUiContext` into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export, and `package.json`'s `browser` field maps it to `ambient.browser.ts` (bundlers only — `bun test` and SSR get the server's). A test reaching components by module path must `import './ambient'` itself, as `inert-render.test.ts` does. `fallbackTranslator` is held member-for-member against `createTranslator({})` by `context.test.ts`. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare. `barrel-bytes.test.ts` pins the theme island's retained-module list.
20
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.
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.
22
- - **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.**
23
- `initialsOf(name, locale)` reads `useUi().locale`, exactly as `DateTime` does. A bare
24
- `toLocaleUpperCase()` reads the RUNTIME's default — a server's `LANG`, a browser's UI language —
25
- which is the ambient locale this package does not have; Turkish is where the absence shows,
26
- because dotted `i` uppercases to `İ`, so a Turkish member's avatar read `I` on a server in
27
- Ireland. **Breaking**: `initialsOf`'s second parameter is required.
21
+ - **`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.
22
+ - **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.** `initialsOf(name, locale)` reads `useUi().locale`; a bare `toLocaleUpperCase()` reads the runtime's default (Turkish dotted `i` → `İ`).
28
23
  - **`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.
29
24
  - **A component may call `solid()` freely.** Its effects must stay DOM-only work — they simply never run on the server.
30
25
  - **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.
@@ -35,7 +30,7 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
35
30
  - **Icons are generated, never authored.** `src/icons/glyphs/*.ts` is one module per Lucide icon, written by `bun run icons` from `lucide-static@LUCIDE_VERSION`. Never hand-edit a glyph, never add a hand-drawn one, never introduce a second icon source. An upstream fix is a version bump plus a re-run. `lucide` itself is NOT a dependency — the data is committed, so the package still installs, typechecks and renders offline with zero runtime deps.
36
31
  - **One icon, one module.** The `Icon` component takes a `glyph`, not a name: a `name → glyph` map would be one module holding 1767 icons, and no bundler can split that. Per-icon imports are the whole point (1 icon = 104 B minified, 50 = 8.9 kB).
37
32
  - **No client runtime exists yet.** A new interactive component must be correct server-rendered and usable with scripting off — `<details>` for disclosure, `<input list>`+`<datalist>` for suggestions, a real `rel="next"` link for paging — with listeners and observers as additive extras. A component that renders nothing until JS runs does not ship.
38
- - **`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.
33
+ - **`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.
39
34
  - **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.
40
35
  - 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.
41
36
  - **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.
@@ -45,7 +40,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
45
40
  (`src/components/async-branch.ts`) is the ONLY place the `(pending, failed, empty, data)` decision
46
41
  is made — `AsyncRegion` renders it and `DataTable` calls it, so a table and a card list cannot
47
42
  disagree about what "loading with stale rows" looks like. The property is structural: an
48
- `AsyncState` in `pending` carries no data, so nothing can be found empty in it, and "No results"
43
+ `AsyncState` (declared in `@ultimat3/core` since 21.0.0 — imported here, never re-exported, so
44
+ it has one import path) in `pending` carries no data, so nothing can be found empty in it, and "No results"
49
45
  for one frame before the first page arrives is unconstructible rather than discouraged.
50
46
  `<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
51
47
  error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
@@ -93,8 +89,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
93
89
  - **An issue that matches no declared field is SURFACED, never swallowed.** It goes to `formErrors`, which `<Form error>` announces and focuses; a near miss (`items` against a form holding `items[0].price`) goes there too, because an error rendered against the wrong input is a lie the user acts on. Two issues on one field both survive in the state — `Field`'s one slot renders the first, `messagesFor` has the rest.
94
90
  - **The framework ships the mapping and no copy table.** `FormIssue.message` is a schema's diagnostic text and is never user-facing; `messageFor` is required and is the app's. There is deliberately no `ui.form.*` key: a built-in wording would be one app's convention shipped to every other. A `messageFor` that answers `''` or throws falls back to the diagnostic text — the loud-but-safe answer `t()` already gives with `⟦key⟧`, because the alternatives are a control marked `aria-invalid` with nothing to read, or a submit abandoned mid-flight holding the user's input.
95
91
  - **The wire carries no structured issue list, `As of 2026-08-24`.** `InputInvalidError` renders the issues into `cause` and puts nothing in `meta`, and `toProblem` has no `issues` member, so `issuesFromRejection` reads `meta.issues` where there is one and parses the cause line where there is not. It binds a fragment to a field only when the fragment's head is a DECLARED path, so a mis-split degrades to a form-level error rather than to a wrong control. Delete the line parser the day the wire carries the list.
96
- - **Generated source is a CODE sink.** `build-icons.ts` writes modules every app EXECUTES at import, from data fetched over the network, so an attribute value goes through `JSON.stringify` and never `'${value}'` — and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry one layer earlier. `iconElements` guards tags and attribute NAMES; it has never guarded a value. Malformed upstream data is `X_UI_INVALID_VALUE`; only the generator's real environment faults (no network, no biome binary) are `X_UI_RUNTIME_MISSING`.
97
- - **The icon NAME is the third sink, and it has no escape.** The upstream map key becomes a filesystem path under `GLYPHS_DIR`, an exported identifier and a `//` banner, and `buildIcons` clears `GLYPHS_DIR` before it writes — so `../../index` was a delete of the glyph tree followed by an overwrite of a hand-written module, and a key carrying `;` produced a module that typechecked and ran. `SAFE_ICON_NAME` (`/^[a-z0-9]+(-[a-z0-9]+)*$/`) is checked in `parseIconNodes` BEFORE `out.set`, the same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE` one layer down; all 1767 committed names pass it and `build-icons.test.ts` asserts that.
92
+ - **Generated source is a CODE sink.** `build-icons.ts` writes modules every app executes, from network data: attribute values go through `JSON.stringify`, and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry. Malformed upstream data is `X_UI_INVALID_VALUE`; only real environment faults (no network, no biome) are `X_UI_RUNTIME_MISSING`.
93
+ - **The icon NAME is the third sink, and it has no escape.** It becomes a path under `GLYPHS_DIR` (which `buildIcons` clears), an identifier and a banner, so `SAFE_ICON_NAME` (`/^[a-z0-9]+(-[a-z0-9]+)*$/`) is checked in `parseIconNodes` before `out.set`; `build-icons.test.ts` asserts all 1767 committed names pass.
98
94
 
99
95
  ## Files
100
96
 
@@ -154,3 +150,5 @@ bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; d
154
150
  `tsconfig` that maps `@ultimat3/*` to `packages/*/src` needs the more specific entry
155
151
  `"@ultimat3/ui/icons/*": ["./packages/ui/src/icons/glyphs/*"]` for TypeScript to follow it —
156
152
  runtime resolution is the `exports` map either way.
153
+
154
+ Why each rule above is shaped the way it is: [`docs/history/ui.md`](../../docs/history/ui.md).
package/README.md CHANGED
@@ -545,6 +545,15 @@ of truth for the one thing the server decides.
545
545
  | `X_UI_QR_CAPACITY` | a `<QrCode>` value over version 3's 42-byte ceiling (byte mode, error-correction level M). The encoder draws versions 1–3 only; the cause names the byte count and the ceiling |
546
546
  | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
547
547
 
548
+ ### Error classes
549
+
550
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
551
+ a job boundary the class is gone and the `code` is what survives — match on that.
552
+
553
+ | Class | Code | Declared in |
554
+ |---|---|---|
555
+ | `UiError` | any `UiErrorCode` — `UI_ERROR_CODES` | `src/errors.ts` |
556
+
548
557
  ## Commands
549
558
 
550
559
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "20.2.1",
3
+ "version": "22.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",
@@ -48,10 +48,10 @@
48
48
  "icons": "bun run src/icons/build-icons.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@ultimat3/core": "20.2.1",
52
- "@ultimat3/i18n": "20.2.1",
53
- "@ultimat3/money": "20.2.1",
54
- "@ultimat3/time": "20.2.1"
51
+ "@ultimat3/core": "22.0.0",
52
+ "@ultimat3/i18n": "22.0.0",
53
+ "@ultimat3/money": "22.0.0",
54
+ "@ultimat3/time": "22.0.0"
55
55
  },
56
56
  "peerDependencies": {
57
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,
@@ -12,6 +12,7 @@ import { iconCopy } from '../icons/glyphs/copy';
12
12
  import { useUi } from '../theme/context';
13
13
  import { solid } from '../theme/solid-adapter';
14
14
  import styles from './CopyButton.module.scss';
15
+ import { writeToClipboard } from './copy-write';
15
16
  import { Icon } from './Icon';
16
17
  import type { Size } from './variants';
17
18
 
@@ -36,12 +37,20 @@ export function CopyButton(props: CopyButtonProps): JSX.Element {
36
37
  const label = (): string => props.label ?? ui.t(UI_KEYS.copy);
37
38
  const copiedLabel = (): string => props.copiedLabel ?? ui.t(UI_KEYS.copied);
38
39
 
40
+ let reset: ReturnType<typeof setTimeout> | undefined;
41
+ // The "copied" timer does not outlive the button: firing a setter on an unmounted component is
42
+ // a write into a disposed owner.
43
+ rt.onCleanup(() => clearTimeout(reset));
44
+
39
45
  const onClick = async (): Promise<void> => {
40
- // Optional chaining on both: a harness has a `navigator` with no `clipboard`, and `announce`
41
- // needs a document that can find its live region.
42
- await navigator.clipboard?.writeText(props.value);
46
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
47
+ // "Copied" only on a write that RESOLVED: no clipboard used to show the check mark anyway, and
48
+ // a refused write escaped as an unhandled rejection through the `void` below.
49
+ if (!(await writeToClipboard(props.value, clipboard))) return;
43
50
  setCopied(true);
44
- setTimeout(() => setCopied(false), COPIED_MS);
51
+ clearTimeout(reset);
52
+ reset = setTimeout(() => setCopied(false), COPIED_MS);
53
+ // `announce` needs a document that can find its live region.
45
54
  if (typeof document !== 'undefined' && typeof document.getElementById === 'function') {
46
55
  announce(copiedLabel());
47
56
  }
@@ -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, type AsyncState, asyncBranch, isBusyBranch } from './async-branch';
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';
@@ -30,6 +30,9 @@ export function Dialog(props: DialogProps): JSX.Element {
30
30
  const rt = solid();
31
31
  const titleId = useId('dialog-title');
32
32
  let element: HTMLDialogElement | undefined;
33
+ // Where the press STARTED. A drag-select begun in the panel and released over the backdrop
34
+ // fires `click` on the <dialog> itself, and closed the dialog mid-selection.
35
+ let pressedBackdrop = false;
33
36
 
34
37
  rt.createEffect(() => {
35
38
  const dialog = element;
@@ -52,9 +55,15 @@ export function Dialog(props: DialogProps): JSX.Element {
52
55
  event.preventDefault();
53
56
  props.onClose();
54
57
  }}
58
+ onPointerDown={(event) => {
59
+ pressedBackdrop = event.target === element;
60
+ }}
55
61
  onClick={(event) => {
56
- // A click on the backdrop lands on the <dialog> itself, never a child.
57
- if (props.dismissOnBackdrop !== false && event.target === element) props.onClose();
62
+ // A click on the backdrop lands on the <dialog> itself, never a child — and so must the
63
+ // press that began it, or this is the end of a drag that started in the panel.
64
+ const dismiss = pressedBackdrop && event.target === element;
65
+ pressedBackdrop = false;
66
+ if (props.dismissOnBackdrop !== false && dismiss) props.onClose();
58
67
  }}
59
68
  >
60
69
  <div class={styles['panel']}>
@@ -30,6 +30,9 @@ export function Drawer(props: DrawerProps): JSX.Element {
30
30
  const rt = solid();
31
31
  const titleId = useId('drawer-title');
32
32
  let element: HTMLDialogElement | undefined;
33
+ // Where the press STARTED — the `Dialog` rule: a drag-select begun in the panel and released
34
+ // over the backdrop fires `click` on the <dialog> itself, and closed the drawer mid-selection.
35
+ let pressedBackdrop = false;
33
36
 
34
37
  rt.createEffect(() => {
35
38
  const dialog = element;
@@ -53,8 +56,13 @@ export function Drawer(props: DrawerProps): JSX.Element {
53
56
  event.preventDefault();
54
57
  props.onClose();
55
58
  }}
59
+ onPointerDown={(event) => {
60
+ pressedBackdrop = event.target === element;
61
+ }}
56
62
  onClick={(event) => {
57
- if (event.target === element) props.onClose();
63
+ const dismiss = pressedBackdrop && event.target === element;
64
+ pressedBackdrop = false;
65
+ if (dismiss) props.onClose();
58
66
  }}
59
67
  >
60
68
  <div class={styles['panel']}>
@@ -10,6 +10,7 @@ import { useUi } from '../theme/context';
10
10
  import { solid } from '../theme/solid-adapter';
11
11
  import styles from './InfiniteScroll.module.scss';
12
12
  import { loadMoreState } from './infinite-scroll-view';
13
+ import { createLoadMoreTrigger } from './load-more-trigger';
13
14
  import { Spinner } from './Spinner';
14
15
 
15
16
  /** One viewport-ish of runway, so the next page is usually there before the current one ends. */
@@ -36,6 +37,9 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
36
37
  const rt = solid();
37
38
  let sentinel: HTMLSpanElement | undefined;
38
39
 
40
+ // Built once per mount; the observer reports into it and so does the `loading` edge below.
41
+ const trigger = createLoadMoreTrigger(() => props.onLoadMore?.());
42
+
39
43
  rt.createEffect(() => {
40
44
  const onLoadMore = props.onLoadMore;
41
45
  if (onLoadMore === undefined || sentinel === undefined) return;
@@ -43,7 +47,10 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
43
47
  if (!props.hasMore || typeof IntersectionObserver === 'undefined') return;
44
48
  const observer = new IntersectionObserver(
45
49
  (entries) => {
46
- if (props.loading !== true && entries.some((entry) => entry.isIntersecting)) onLoadMore();
50
+ trigger.seen(
51
+ entries.some((entry) => entry.isIntersecting),
52
+ props.loading === true,
53
+ );
47
54
  },
48
55
  { rootMargin: props.rootMargin ?? ROOT_MARGIN },
49
56
  );
@@ -51,6 +58,13 @@ export function InfiniteScroll(props: InfiniteScrollProps): JSX.Element {
51
58
  rt.onCleanup(() => observer.disconnect());
52
59
  });
53
60
 
61
+ // A page that loaded without pushing the sentinel out of view gets no new intersection report,
62
+ // so the loading edge re-asks — the list stalled here with its runway still showing.
63
+ rt.createEffect(() => {
64
+ const loading = props.loading === true;
65
+ if (props.hasMore) trigger.settled(loading);
66
+ });
67
+
54
68
  const onClick = (event: MouseEvent): void => {
55
69
  if (props.onLoadMore === undefined) return;
56
70
  event.preventDefault();
@@ -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. A STATE, never a query: `@ultimat3/ui` is tier 4 and may
8
- * not import `query`, `action` or `realtime`, so a live-query accessor, a `createResource` and a
9
- * plain signal all arrive here as the same four shapes.
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
- export type AsyncState<T> =
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 the union above makes it unconstructible
27
- * rather than merely discouraged: `pending` holds no data, so nothing can be found empty in it.
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,22 @@
1
+ // Single responsibility: one clipboard write, answered as a boolean. `CopyButton` claims "Copied"
2
+ // only on `true` — a missing clipboard (an insecure origin, an old browser, a harness) and a
3
+ // refused write (permission) are both a copy that did not happen, and neither may reject.
4
+
5
+ /** The one method used, structurally — `navigator.clipboard`, or a test's stand-in. */
6
+ export interface ClipboardWriter {
7
+ writeText(text: string): Promise<void>;
8
+ }
9
+
10
+ export async function writeToClipboard(
11
+ value: string,
12
+ clipboard: ClipboardWriter | undefined,
13
+ ): Promise<boolean> {
14
+ if (clipboard === undefined) return false;
15
+ try {
16
+ await clipboard.writeText(value);
17
+ return true;
18
+ } catch {
19
+ // Refused: a permission prompt denied, or a document without focus. The button stays as it was.
20
+ return false;
21
+ }
22
+ }
@@ -2,6 +2,7 @@
2
2
  // needs: the machine-readable ISO instant for `<time datetime>` and the human
3
3
  // string formatted in the injected zone + locale.
4
4
 
5
+ import { isIsoDateTime } from '@ultimat3/core';
5
6
  import {
6
7
  type DateTimeStyle,
7
8
  type FormatDateTimeOptions,
@@ -38,17 +39,21 @@ export interface DateTimeView {
38
39
  readonly text: string;
39
40
  }
40
41
 
41
- /** A date-TIME string with no `Z` and no `±HH:MM`. A date-only string is not one: the spec parses
42
- * `2026-08-14` as UTC, so it is already zone-independent. */
43
- const OFFSETLESS_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?![\s\S]*(?:Z|[+-]\d{2}:?\d{2})$)/;
44
-
45
42
  export function toDate(value: TimeInput): Date {
46
43
  // `new Date('2026-08-14T09:00')` resolves in the HOST's zone — the one ambient default this
47
44
  // package forbids, inside the one function that had it. It rendered `09:00` on a `TZ=UTC` runner
48
45
  // and `00:00` on `TZ=Asia/Tokyo` with the same `timeZone="UTC"` prop and no error. Every
49
46
  // FORMATTING path here was already zoned; only the parse was not.
50
- if (typeof value === 'string' && OFFSETLESS_DATETIME.test(value)) {
51
- throw invalidValueError('DateTime', value, 'an ISO string carrying Z or a ±HH:MM offset');
47
+ //
48
+ // The predicate is `@ultimat3/schema`'s — the one `t.date` and `timestamp()` use — so a string is
49
+ // ISO-8601 in SHAPE as well as zoned: `'August 14, 2026 09:00'` and `'8/14/2026'` were not
50
+ // "ISO-shaped offset-less" and walked past a narrower local guard into `new Date`, in the host zone.
51
+ if (typeof value === 'string' && !isIsoDateTime(value)) {
52
+ throw invalidValueError(
53
+ 'DateTime',
54
+ value,
55
+ 'an ISO-8601 string: a date (2026-08-14) or a date-time carrying Z or a ±HH:MM offset',
56
+ );
52
57
  }
53
58
  const date = value instanceof Date ? value : new Date(value);
54
59
  if (Number.isNaN(date.getTime())) {
@@ -0,0 +1,25 @@
1
+ // Single responsibility: when an endless list's sentinel asks for the next page. Kept apart from
2
+ // the observer because IntersectionObserver reports only CHANGES — a sentinel that never left the
3
+ // viewport produces no second callback, and the loading edge is the only other signal there is.
4
+
5
+ export interface LoadMoreTrigger {
6
+ /** The observer's report: is the sentinel in view, and is a page in flight right now? */
7
+ seen(visible: boolean, loading: boolean): void;
8
+ /** `loading` changed. Going false with the sentinel still in view is a request of its own. */
9
+ settled(loading: boolean): void;
10
+ }
11
+
12
+ export function createLoadMoreTrigger(onLoadMore: () => void): LoadMoreTrigger {
13
+ let visible = false;
14
+ return {
15
+ seen(nowVisible, loading) {
16
+ visible = nowVisible;
17
+ if (visible && !loading) onLoadMore();
18
+ },
19
+ settled(loading) {
20
+ // The stall this exists for: a page too short to push the sentinel out left it in view, and
21
+ // no intersection CHANGE ever came to ask again.
22
+ if (!loading && visible) onLoadMore();
23
+ },
24
+ };
25
+ }
package/src/index.ts CHANGED
@@ -28,7 +28,6 @@ export {
28
28
  focusableWithin,
29
29
  LIVE_REGION_LEVELS,
30
30
  liveRegionAttrs,
31
- nextRovingIndex,
32
31
  resetIdCounter,
33
32
  useId,
34
33
  } from './a11y';
@@ -45,13 +44,11 @@ export { AsyncRegion } from './components/AsyncRegion';
45
44
  export type { AvatarProps } from './components/Avatar';
46
45
  export { Avatar, initialsOf } from './components/Avatar';
47
46
  export type { AccordionSection } from './components/accordion-view';
48
- export { accordionOpenIds } from './components/accordion-view';
49
47
  export type { ShellIds, ShellLandmark, ShellSlots } from './components/app-shell-view';
50
- export { shellIds, shellLandmarks } from './components/app-shell-view';
48
+ export { shellIds } from './components/app-shell-view';
51
49
  export type {
52
50
  AsyncBranch,
53
51
  AsyncFlags,
54
- AsyncState,
55
52
  ReserveBox,
56
53
  } from './components/async-branch';
57
54
  export {
@@ -70,7 +67,7 @@ export { Breadcrumb } from './components/Breadcrumb';
70
67
  export type { ButtonProps } from './components/Button';
71
68
  export { Button } from './components/Button';
72
69
  export type { BarRect, ChartPoint } from './components/bar-chart-view';
73
- export { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './components/bar-chart-view';
70
+ export { barRects } from './components/bar-chart-view';
74
71
  export type { CardProps, Elevation } from './components/Card';
75
72
  export { Card } from './components/Card';
76
73
  export type { CheckboxProps } from './components/Checkbox';
@@ -84,7 +81,7 @@ export { Container } from './components/Container';
84
81
  export type { CopyButtonProps } from './components/CopyButton';
85
82
  export { COPIED_MS, CopyButton } from './components/CopyButton';
86
83
  export type { ComboboxOption } from './components/combobox-filter';
87
- export { COMBOBOX_LIMIT, filterOptions, normalizeQuery } from './components/combobox-filter';
84
+ export { filterOptions, normalizeQuery } from './components/combobox-filter';
88
85
  export type { CommandPaletteItem, PaletteKeyAction } from './components/command-palette-view';
89
86
  export {
90
87
  filterItems,
@@ -131,10 +128,7 @@ export type {
131
128
  FileTarget,
132
129
  } from './components/file-input-view';
133
130
  export {
134
- acceptMatches,
135
- adoptDroppedFiles,
136
131
  formatBytes,
137
- progressPercent,
138
132
  selectFiles,
139
133
  } from './components/file-input-view';
140
134
  export type { GridProps } from './components/Grid';
@@ -153,11 +147,10 @@ export { InfiniteScroll } from './components/InfiniteScroll';
153
147
  export type { InputProps, InputType } from './components/Input';
154
148
  export { Input } from './components/Input';
155
149
  export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
156
- export { ICON_TAGS, iconElements, isIconTag } from './components/icon-glyph';
150
+ export { ICON_TAGS, iconElements } from './components/icon-glyph';
157
151
  export type { ImageBox, ImageLoadingHints, ImageVariant } from './components/image-source';
158
- export { boxFor, loadingHints, ratioFor, srcsetFor } from './components/image-source';
152
+ export { boxFor, srcsetFor } from './components/image-source';
159
153
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
160
- export { loadMoreState } from './components/infinite-scroll-view';
161
154
  export type { KbdProps } from './components/Kbd';
162
155
  export { Kbd } from './components/Kbd';
163
156
  export type { LinkProps } from './components/Link';
@@ -165,14 +158,12 @@ export { Link } from './components/Link';
165
158
  export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
166
159
  export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
167
160
  export type { LinkTarget } from './components/link-target';
168
- export { linkTarget } from './components/link-target';
169
161
  export type { MenuItem, MenuProps } from './components/Menu';
170
162
  export { Menu } from './components/Menu';
171
163
  export type { MeterProps } from './components/Meter';
172
164
  export { Meter } from './components/Meter';
173
165
  export type { MoneyProps } from './components/Money';
174
166
  export { Money } from './components/Money';
175
- export { meterShare, meterWidth } from './components/meter-view';
176
167
  export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/money-view';
177
168
  // --- formatting cores (pure, renderer-free) ----------------------------------
178
169
  export { moneyText, toMoney } from './components/money-view';
@@ -191,7 +182,6 @@ export { Radio } from './components/Radio';
191
182
  export type { RelativeTimeProps } from './components/RelativeTime';
192
183
  export { RelativeTime } from './components/RelativeTime';
193
184
  export type { RelativeTimeOptions } from './components/relative-time-view';
194
- export { relativeTimeText } from './components/relative-time-view';
195
185
  export type { SectionProps } from './components/Section';
196
186
  export { Section } from './components/Section';
197
187
  export type { SelectOption, SelectProps } from './components/Select';
@@ -209,11 +199,10 @@ export { StatTile } from './components/StatTile';
209
199
  export type { SwitchProps } from './components/Switch';
210
200
  export { Switch } from './components/Switch';
211
201
  export type { SortDirection, SortState } from './components/sort-state';
212
- export { ariaSortFor, nextSortState } from './components/sort-state';
213
202
  export type { SparkPoint } from './components/sparkline-view';
214
- export { SPARKLINE, sparklinePath, sparkPoints } from './components/sparkline-view';
203
+ export { sparklinePath } from './components/sparkline-view';
215
204
  export type { StatDelta, StatTrend } from './components/stat-delta';
216
- export { DELTA_ARROW_PATH, deltaOf } from './components/stat-delta';
205
+ export { deltaOf } from './components/stat-delta';
217
206
  export type { TableProps } from './components/Table';
218
207
  export { Table } from './components/Table';
219
208
  export type { TabItem, TabsProps } from './components/Tabs';
@@ -270,11 +259,9 @@ export type {
270
259
  export { issuesFromRejection, issuesFromValidation } from './form/form-issue';
271
260
  export type { FormErrors, FormState, FormStatus } from './form/form-state';
272
261
  export {
273
- distributeIssues,
274
262
  errorOf,
275
263
  firstInvalidField,
276
264
  IDLE_FORM_STATE,
277
- messagesOf,
278
265
  NO_FORM_ERRORS,
279
266
  } from './form/form-state';
280
267
  export type { FormTouch } from './form/form-touch';
@@ -283,7 +270,6 @@ export {
283
270
  markDirty,
284
271
  markTouched,
285
272
  NO_FORM_TOUCH,
286
- sameFieldValue,
287
273
  } from './form/form-touch';
288
274
  export { valuesOfForm } from './form/form-values';
289
275
  export { useForm } from './form/use-form';
@@ -320,7 +306,7 @@ export type { UiProviderProps } from './theme/provider';
320
306
  export { UiProvider } from './theme/provider';
321
307
  // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
322
308
  // @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
323
- export { clearSolidRuntime, hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
309
+ export { hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
324
310
  export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
325
311
  export { solid } from './theme/solid-adapter';
326
312
  export type { ThemeEnv } from './theme/theme';
@@ -350,11 +336,8 @@ export type {
350
336
  } from './toast/toast-state';
351
337
  // --- toasts: the queue behind ToastRegion ------------------------------------
352
338
  export {
353
- collapsedToasts,
354
- EMPTY_TOAST_QUEUE,
355
339
  TOAST_DWELL_MS,
356
340
  TOAST_MAX_VISIBLE,
357
- visibleToasts,
358
341
  } from './toast/toast-state';
359
342
  export type { ToastEnv, ToastStore } from './toast/toast-store';
360
343
  export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
@@ -363,15 +346,13 @@ export type { Channels } from './tokens/contrast';
363
346
  export {
364
347
  AA_LARGE,
365
348
  AA_TEXT,
366
- CHANNELS_PATTERN,
367
349
  contrastRatio,
368
350
  meetsContrast,
369
- parseChannels,
370
351
  relativeLuminance,
371
352
  roleContrast,
372
353
  } from './tokens/contrast';
373
354
  export type { ContrastPair } from './tokens/contrast-pairs';
374
- export { CONTRAST_PAIRS, VISIBLE_EDGE } from './tokens/contrast-pairs';
355
+ export { CONTRAST_PAIRS } from './tokens/contrast-pairs';
375
356
  export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
376
357
  // --- tokens ------------------------------------------------------------------
377
358
  export {