@ultimat3/ui 21.0.0 → 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.
@@ -94,8 +89,8 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
94
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.
95
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.
96
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.
97
- - **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`.
98
- - **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.
99
94
 
100
95
  ## Files
101
96
 
@@ -155,3 +150,5 @@ bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; d
155
150
  `tsconfig` that maps `@ultimat3/*` to `packages/*/src` needs the more specific entry
156
151
  `"@ultimat3/ui/icons/*": ["./packages/ui/src/icons/glyphs/*"]` for TypeScript to follow it —
157
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": "21.0.0",
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": "21.0.0",
52
- "@ultimat3/i18n": "21.0.0",
53
- "@ultimat3/money": "21.0.0",
54
- "@ultimat3/time": "21.0.0"
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"
@@ -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
  }
@@ -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();
@@ -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,9 +44,8 @@ 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,
@@ -69,7 +67,7 @@ export { Breadcrumb } from './components/Breadcrumb';
69
67
  export type { ButtonProps } from './components/Button';
70
68
  export { Button } from './components/Button';
71
69
  export type { BarRect, ChartPoint } from './components/bar-chart-view';
72
- export { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './components/bar-chart-view';
70
+ export { barRects } from './components/bar-chart-view';
73
71
  export type { CardProps, Elevation } from './components/Card';
74
72
  export { Card } from './components/Card';
75
73
  export type { CheckboxProps } from './components/Checkbox';
@@ -83,7 +81,7 @@ export { Container } from './components/Container';
83
81
  export type { CopyButtonProps } from './components/CopyButton';
84
82
  export { COPIED_MS, CopyButton } from './components/CopyButton';
85
83
  export type { ComboboxOption } from './components/combobox-filter';
86
- export { COMBOBOX_LIMIT, filterOptions, normalizeQuery } from './components/combobox-filter';
84
+ export { filterOptions, normalizeQuery } from './components/combobox-filter';
87
85
  export type { CommandPaletteItem, PaletteKeyAction } from './components/command-palette-view';
88
86
  export {
89
87
  filterItems,
@@ -130,10 +128,7 @@ export type {
130
128
  FileTarget,
131
129
  } from './components/file-input-view';
132
130
  export {
133
- acceptMatches,
134
- adoptDroppedFiles,
135
131
  formatBytes,
136
- progressPercent,
137
132
  selectFiles,
138
133
  } from './components/file-input-view';
139
134
  export type { GridProps } from './components/Grid';
@@ -152,11 +147,10 @@ export { InfiniteScroll } from './components/InfiniteScroll';
152
147
  export type { InputProps, InputType } from './components/Input';
153
148
  export { Input } from './components/Input';
154
149
  export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
155
- export { ICON_TAGS, iconElements, isIconTag } from './components/icon-glyph';
150
+ export { ICON_TAGS, iconElements } from './components/icon-glyph';
156
151
  export type { ImageBox, ImageLoadingHints, ImageVariant } from './components/image-source';
157
- export { boxFor, loadingHints, ratioFor, srcsetFor } from './components/image-source';
152
+ export { boxFor, srcsetFor } from './components/image-source';
158
153
  export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
159
- export { loadMoreState } from './components/infinite-scroll-view';
160
154
  export type { KbdProps } from './components/Kbd';
161
155
  export { Kbd } from './components/Kbd';
162
156
  export type { LinkProps } from './components/Link';
@@ -164,14 +158,12 @@ export { Link } from './components/Link';
164
158
  export type { LocaleSwitcherProps } from './components/LocaleSwitcher';
165
159
  export { LocaleSwitcher, localeLabel } from './components/LocaleSwitcher';
166
160
  export type { LinkTarget } from './components/link-target';
167
- export { linkTarget } from './components/link-target';
168
161
  export type { MenuItem, MenuProps } from './components/Menu';
169
162
  export { Menu } from './components/Menu';
170
163
  export type { MeterProps } from './components/Meter';
171
164
  export { Meter } from './components/Meter';
172
165
  export type { MoneyProps } from './components/Money';
173
166
  export { Money } from './components/Money';
174
- export { meterShare, meterWidth } from './components/meter-view';
175
167
  export type { MoneyFormatter, MoneyInput, MoneyViewOptions } from './components/money-view';
176
168
  // --- formatting cores (pure, renderer-free) ----------------------------------
177
169
  export { moneyText, toMoney } from './components/money-view';
@@ -190,7 +182,6 @@ export { Radio } from './components/Radio';
190
182
  export type { RelativeTimeProps } from './components/RelativeTime';
191
183
  export { RelativeTime } from './components/RelativeTime';
192
184
  export type { RelativeTimeOptions } from './components/relative-time-view';
193
- export { relativeTimeText } from './components/relative-time-view';
194
185
  export type { SectionProps } from './components/Section';
195
186
  export { Section } from './components/Section';
196
187
  export type { SelectOption, SelectProps } from './components/Select';
@@ -208,11 +199,10 @@ export { StatTile } from './components/StatTile';
208
199
  export type { SwitchProps } from './components/Switch';
209
200
  export { Switch } from './components/Switch';
210
201
  export type { SortDirection, SortState } from './components/sort-state';
211
- export { ariaSortFor, nextSortState } from './components/sort-state';
212
202
  export type { SparkPoint } from './components/sparkline-view';
213
- export { SPARKLINE, sparklinePath, sparkPoints } from './components/sparkline-view';
203
+ export { sparklinePath } from './components/sparkline-view';
214
204
  export type { StatDelta, StatTrend } from './components/stat-delta';
215
- export { DELTA_ARROW_PATH, deltaOf } from './components/stat-delta';
205
+ export { deltaOf } from './components/stat-delta';
216
206
  export type { TableProps } from './components/Table';
217
207
  export { Table } from './components/Table';
218
208
  export type { TabItem, TabsProps } from './components/Tabs';
@@ -269,11 +259,9 @@ export type {
269
259
  export { issuesFromRejection, issuesFromValidation } from './form/form-issue';
270
260
  export type { FormErrors, FormState, FormStatus } from './form/form-state';
271
261
  export {
272
- distributeIssues,
273
262
  errorOf,
274
263
  firstInvalidField,
275
264
  IDLE_FORM_STATE,
276
- messagesOf,
277
265
  NO_FORM_ERRORS,
278
266
  } from './form/form-state';
279
267
  export type { FormTouch } from './form/form-touch';
@@ -282,7 +270,6 @@ export {
282
270
  markDirty,
283
271
  markTouched,
284
272
  NO_FORM_TOUCH,
285
- sameFieldValue,
286
273
  } from './form/form-touch';
287
274
  export { valuesOfForm } from './form/form-values';
288
275
  export { useForm } from './form/use-form';
@@ -319,7 +306,7 @@ export type { UiProviderProps } from './theme/provider';
319
306
  export { UiProvider } from './theme/provider';
320
307
  // The slot is its own module so that registering a runtime does not drag `errors.ts` — and with it
321
308
  // @ultimat3/core's error registry — into an island chunk. `barrel-bytes.test.ts` holds the ceiling.
322
- export { clearSolidRuntime, hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
309
+ export { hasSolidRuntime, setSolidRuntime } from './theme/runtime-slot';
323
310
  export type { Accessor, Setter, SolidContext, SolidRuntime } from './theme/solid-adapter';
324
311
  export { solid } from './theme/solid-adapter';
325
312
  export type { ThemeEnv } from './theme/theme';
@@ -349,11 +336,8 @@ export type {
349
336
  } from './toast/toast-state';
350
337
  // --- toasts: the queue behind ToastRegion ------------------------------------
351
338
  export {
352
- collapsedToasts,
353
- EMPTY_TOAST_QUEUE,
354
339
  TOAST_DWELL_MS,
355
340
  TOAST_MAX_VISIBLE,
356
- visibleToasts,
357
341
  } from './toast/toast-state';
358
342
  export type { ToastEnv, ToastStore } from './toast/toast-store';
359
343
  export { browserToastEnv, createToastStore, INERT_TOAST_ENV } from './toast/toast-store';
@@ -362,15 +346,13 @@ export type { Channels } from './tokens/contrast';
362
346
  export {
363
347
  AA_LARGE,
364
348
  AA_TEXT,
365
- CHANNELS_PATTERN,
366
349
  contrastRatio,
367
350
  meetsContrast,
368
- parseChannels,
369
351
  relativeLuminance,
370
352
  roleContrast,
371
353
  } from './tokens/contrast';
372
354
  export type { ContrastPair } from './tokens/contrast-pairs';
373
- export { CONTRAST_PAIRS, VISIBLE_EDGE } from './tokens/contrast-pairs';
355
+ export { CONTRAST_PAIRS } from './tokens/contrast-pairs';
374
356
  export type { ColorRole, RadiusName, Theme } from './tokens/tokens';
375
357
  // --- tokens ------------------------------------------------------------------
376
358
  export {