@texuli/core 0.0.0-stage → 0.1.0-next.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -2
  3. package/dist/announcer/announcer.d.ts +24 -0
  4. package/dist/announcer/announcer.js +253 -0
  5. package/dist/announcer/index.d.ts +3 -0
  6. package/dist/announcer/index.js +3 -0
  7. package/dist/announcer/regions.d.ts +10 -0
  8. package/dist/announcer/regions.js +46 -0
  9. package/dist/announcer/types.d.ts +68 -0
  10. package/dist/announcer/types.js +2 -0
  11. package/dist/controllable/controllable-state.d.ts +50 -0
  12. package/dist/controllable/controllable-state.js +40 -0
  13. package/dist/dir-locale/dir-locale.d.ts +72 -0
  14. package/dist/dir-locale/dir-locale.js +98 -0
  15. package/dist/dismiss/bind.d.ts +16 -0
  16. package/dist/dismiss/bind.js +139 -0
  17. package/dist/dismiss/index.d.ts +4 -0
  18. package/dist/dismiss/index.js +3 -0
  19. package/dist/dismiss/open-gesture.d.ts +16 -0
  20. package/dist/dismiss/open-gesture.js +85 -0
  21. package/dist/dismiss/outside.d.ts +36 -0
  22. package/dist/dismiss/outside.js +115 -0
  23. package/dist/dismiss/types.d.ts +71 -0
  24. package/dist/dismiss/types.js +1 -0
  25. package/dist/env/index.d.ts +26 -0
  26. package/dist/env/index.js +54 -0
  27. package/dist/focus-scope/focus-scope.d.ts +40 -0
  28. package/dist/focus-scope/focus-scope.js +124 -0
  29. package/dist/focus-scope/index.d.ts +1 -0
  30. package/dist/focus-scope/index.js +1 -0
  31. package/dist/form-bridge/form-bridge.d.ts +38 -0
  32. package/dist/form-bridge/form-bridge.js +65 -0
  33. package/dist/ids/ids.d.ts +91 -0
  34. package/dist/ids/ids.js +146 -0
  35. package/dist/index.d.ts +25 -0
  36. package/dist/index.js +24 -0
  37. package/dist/inert/index.d.ts +1 -0
  38. package/dist/inert/index.js +1 -0
  39. package/dist/inert/inert.d.ts +105 -0
  40. package/dist/inert/inert.js +232 -0
  41. package/dist/keyboard/index.d.ts +2 -0
  42. package/dist/keyboard/index.js +2 -0
  43. package/dist/keyboard/keys.d.ts +69 -0
  44. package/dist/keyboard/keys.js +138 -0
  45. package/dist/keyboard/shortcuts.d.ts +106 -0
  46. package/dist/keyboard/shortcuts.js +233 -0
  47. package/dist/layer-stack/index.d.ts +1 -0
  48. package/dist/layer-stack/index.js +1 -0
  49. package/dist/layer-stack/stack.d.ts +143 -0
  50. package/dist/layer-stack/stack.js +215 -0
  51. package/dist/lazy/index.d.ts +1 -0
  52. package/dist/lazy/index.js +1 -0
  53. package/dist/lazy/lazy.d.ts +18 -0
  54. package/dist/lazy/lazy.js +35 -0
  55. package/dist/overlay/core-entry.d.ts +2 -0
  56. package/dist/overlay/core-entry.js +2 -0
  57. package/dist/overlay/core.d.ts +20 -0
  58. package/dist/overlay/core.js +417 -0
  59. package/dist/overlay/index.d.ts +3 -0
  60. package/dist/overlay/index.js +2 -0
  61. package/dist/overlay/overlay.d.ts +12 -0
  62. package/dist/overlay/overlay.js +26 -0
  63. package/dist/overlay/types.d.ts +131 -0
  64. package/dist/overlay/types.js +1 -0
  65. package/dist/portal/index.d.ts +29 -0
  66. package/dist/portal/index.js +87 -0
  67. package/dist/position-dom/auto-update.d.ts +42 -0
  68. package/dist/position-dom/auto-update.js +162 -0
  69. package/dist/position-dom/css-anchor.d.ts +25 -0
  70. package/dist/position-dom/css-anchor.js +67 -0
  71. package/dist/position-dom/index.d.ts +5 -0
  72. package/dist/position-dom/index.js +5 -0
  73. package/dist/position-dom/measure.d.ts +32 -0
  74. package/dist/position-dom/measure.js +72 -0
  75. package/dist/position-dom/position-floating.d.ts +28 -0
  76. package/dist/position-dom/position-floating.js +88 -0
  77. package/dist/position-dom/position.d.ts +85 -0
  78. package/dist/position-dom/position.js +231 -0
  79. package/dist/position-math/index.d.ts +6 -0
  80. package/dist/position-math/index.js +5 -0
  81. package/dist/position-math/math.d.ts +94 -0
  82. package/dist/position-math/math.js +356 -0
  83. package/dist/position-math/types.d.ts +88 -0
  84. package/dist/position-math/types.js +1 -0
  85. package/dist/presence/presence.d.ts +106 -0
  86. package/dist/presence/presence.js +256 -0
  87. package/dist/roving/index.d.ts +2 -0
  88. package/dist/roving/index.js +2 -0
  89. package/dist/roving/roving.d.ts +91 -0
  90. package/dist/roving/roving.js +158 -0
  91. package/dist/roving/typeahead.d.ts +33 -0
  92. package/dist/roving/typeahead.js +50 -0
  93. package/dist/scroll-lock/scroll-lock.d.ts +9 -0
  94. package/dist/scroll-lock/scroll-lock.js +136 -0
  95. package/dist/tabbable/index.d.ts +2 -0
  96. package/dist/tabbable/index.js +1 -0
  97. package/dist/tabbable/options.d.ts +18 -0
  98. package/dist/tabbable/options.js +1 -0
  99. package/dist/tabbable/query.d.ts +18 -0
  100. package/dist/tabbable/query.js +383 -0
  101. package/dist/url/safe-href.d.ts +28 -0
  102. package/dist/url/safe-href.js +58 -0
  103. package/dist/url-policy/index.d.ts +5 -0
  104. package/dist/url-policy/index.js +5 -0
  105. package/dist/url-policy/safe-image-src.d.ts +7 -0
  106. package/dist/url-policy/safe-image-src.js +47 -0
  107. package/dist/url-policy/safe-image-srcset.d.ts +7 -0
  108. package/dist/url-policy/safe-image-srcset.js +45 -0
  109. package/dist/url-policy/safe-media-src.d.ts +12 -0
  110. package/dist/url-policy/safe-media-src.js +32 -0
  111. package/dist/url-policy/shared.d.ts +11 -0
  112. package/dist/url-policy/shared.js +85 -0
  113. package/package.json +40 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dailybot, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,131 @@
1
- # Temporary Holding Version
1
+ # @texuli/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Layer L0: texels.** Framework-free TypeScript behavior and policy that every
4
+ Texuli component is built from. No Svelte, no runtime dependencies. Private
5
+ workspace package (`"private": true`), not published.
6
+
7
+ ## What exists today
8
+
9
+ Every texel in the table below is implemented, tested and documented in this
10
+ package. The public API is exactly what the root barrel `src/index.ts`
11
+ exports; each texel is also a per-area subpath export in `package.json`
12
+ (`@texuli/core/<area>`), so consumers can import narrowly and tree-shake the
13
+ rest.
14
+
15
+ | Texel | Headline exports | Doc |
16
+ | --- | --- | --- |
17
+ | Announcer | `createAnnouncer`, `createLiveRegion`, `getAnnouncer`, `appendRegion` | [core-announcer](docs/core-announcer.md) |
18
+ | Controllable state | `createControllableState` | [core-controllable](docs/core-controllable.md) |
19
+ | Direction and locale | `directionOf`, `inlineSideToPhysical`, `createCollator`, `compareLocale`, `resolveMessages` | [core-dir-locale](docs/core-dir-locale.md) |
20
+ | Dismiss | `bindDismissLayer`, `isPointerOutside`, `isFocusOutside`, `createDismissOutsideEvent`, `createOpenGestureGuard` | [core-dismiss](docs/core-dismiss.md) |
21
+ | Environment | `isBrowser`, `getOwnerDocument`, `getOwnerWindow`, `listen`, `matchesMedia`, `requestFrame` | [core-env](docs/core-env.md) |
22
+ | Focus scope | `createFocusScope` | [core-focus-scope](docs/core-focus-scope.md) |
23
+ | Form bridge | `createFormBridge` | [core-form-bridge](docs/core-form-bridge.md) |
24
+ | Ids | `createIdScope`, `createIdFamily`, `joinIds`, `ariaLabelledBy`, `ariaDescribedBy`, `ariaControls`, `ensureElementId`, `sanitizeIdPart` | [core-ids](docs/core-ids.md) |
25
+ | Inert | `createInertRegistry`, `getInertRegistry`, `computeInertTargets` | [core-inert](docs/core-inert.md) |
26
+ | Keyboard | `parseShortcut`, `matchesShortcut`, `formatShortcut`, `createShortcutMap`, `getNavigationIntent`, `normalizeKey`, `detectPlatform` | [core-keyboard](docs/core-keyboard.md) |
27
+ | Layer stack | `createLayerStack`, `getLayerStack`, `decideEscape`, `bindLayerStackDocument`, `layerReceivesEscape` | [core-layer-stack](docs/core-layer-stack.md) |
28
+ | Lazy chunks | `lazyChunk`, `hasLazyChunk`, `clearLazyChunk(s)` (subpath `@texuli/core/lazy`; not in the root barrel) | — |
29
+ | Overlay engine | `createOverlay` (`OVERLAY_MODE_ATTR`, `OVERLAY_Z_BASE`); the reduced surface is the `@texuli/core/overlay-core` subpath | [core-overlay-core](docs/core-overlay-core.md), [overlay-engine](docs/overlay-engine.md) |
30
+ | Portal | `createPortal` | [core-portal](docs/core-portal.md) |
31
+ | Positioning (DOM) | `positionFloating`, `applyPosition`, `autoUpdate`, `observeMove`, `cssAnchorDeclarations`, `supportsCssAnchorPositioning` | [core-position-dom](docs/core-position-dom.md) |
32
+ | Positioning (math) | `computePosition`, `place`, `shiftIntoBoundary`, `sizeToAvailable`, `listPlacements`, `resolvePlacementForDirection`, `rect` | [core-position-math](docs/core-position-math.md) |
33
+ | Presence | `createPresence`, `prefersReducedMotion`, `hasPendingMotion`, `parseCssTimeList` | [core-presence](docs/core-presence.md) |
34
+ | Roving focus + typeahead | `createRovingFocus`, `createTypeahead`, `stepRovingIndex`, `setRovingTabIndex`, `indexOfSelected` | [core-roving](docs/core-roving.md) |
35
+ | Scroll lock | `lockScroll` | [core-scroll-lock](docs/core-scroll-lock.md) |
36
+ | Tabbable | `isFocusable`, `isTabbable`, `getFocusableElements`, `getTabbableElements` | [core-tabbable](docs/core-tabbable.md) |
37
+ | URL policy (links) | `safeHref`, `type HrefTrust` | [core-url-policy](docs/core-url-policy.md) |
38
+ | URL policy (media) | `safeImageSrc`, `safeImageSrcset`, `safeMediaSrc`, `MAX_DATA_IMAGE_BYTES` | [core-url-policy](docs/core-url-policy.md) |
39
+
40
+ The tables above name the headline exports per texel, not the full surface:
41
+ read `src/index.ts` (and the per-area `index.ts`) for every exported symbol
42
+ and type. The threat model behind the URL policy lives in
43
+ [docs/SECURITY.md](../../docs/SECURITY.md).
44
+
45
+ ## Examples
46
+
47
+ ```ts
48
+ import {
49
+ safeHref,
50
+ createIdScope,
51
+ ariaLabelledBy,
52
+ getLayerStack,
53
+ decideEscape,
54
+ getTabbableElements,
55
+ isTabbable,
56
+ } from '@texuli/core';
57
+
58
+ safeHref('https://example.com/a'); // 'https://example.com/a'
59
+ safeHref('http://example.com'); // undefined (untrusted is the default)
60
+ safeHref('/docs/button', 'developer'); // '/docs/button'
61
+ safeHref('javascript:alert(1)', 'developer'); // undefined in every tier
62
+
63
+ const ids = createIdScope({ prefix: 'dialog' });
64
+ const titleId = ids.next('title'); // scoped, SSR-stable
65
+ ariaLabelledBy(titleId); // the id list for aria-labelledby
66
+
67
+ const stack = getLayerStack();
68
+ const layer = stack.register({ element: panelEl, label: 'dialog' });
69
+ decideEscape(stack, { key: 'Escape' }); // reaches only the topmost layer
70
+ layer.unregister();
71
+
72
+ const stops = getTabbableElements(dialogElement);
73
+ isTabbable(stops[0]!); // true
74
+ ```
75
+
76
+ ### URL policy tiers
77
+
78
+ - **`untrusted`** (default, for agent or user content): only `https:`,
79
+ `mailto:` and `#fragment`.
80
+ - **`developer`** (for app-authored props): anything except `javascript:`,
81
+ `vbscript:`, `data:` and protocol-relative `//host` URLs.
82
+ - Whitespace and ASCII control characters are stripped before the scheme is
83
+ read, so `java\tscript:` is caught.
84
+
85
+ Images and media use the element-specific policies: `safeImageSrc` allows
86
+ HTTPS and relative assets from either tier, plus (developer tier only) HTTP,
87
+ valid blob URLs, and PNG, JPEG, GIF, WebP or AVIF base64 data images up to
88
+ 1 MiB decoded; SVG and non-image data URLs are always refused.
89
+ `safeImageSrcset` accepts a comma-separated `srcset` only when every candidate
90
+ is safe. `safeMediaSrc` restricts untrusted audio/video to blob URLs or exact
91
+ origins from an HTTPS `allowedOrigins` list. Full contract:
92
+ [core-url-policy](docs/core-url-policy.md).
93
+
94
+ ## Layout
95
+
96
+ ```text
97
+ packages/core/
98
+ ├── package.json exports "." and one subpath per texel; script: typecheck
99
+ ├── tsconfig.json extends ../../tsconfig.base.json
100
+ ├── docs/ one contract doc per texel (core-*.md) + overlay-engine.md
101
+ ├── src/index.ts the public barrel (only what is exported here is public)
102
+ ├── src/<area>/ one directory per texel (announcer, roving, layer-stack, …)
103
+ └── tests/ 22 test files, one or more per texel, including hostile
104
+ inputs for every policy that guards a sink
105
+ ```
106
+
107
+ ## Rules
108
+
109
+ - Framework-free: no Svelte or other UI framework imports; standard DOM types
110
+ are fine.
111
+ - No runtime dependencies; no imports from any other workspace package (L0 is
112
+ the bottom layer).
113
+ - Policy functions fail closed and document their contract in JSDoc.
114
+ - Every export ships with tests in `tests/<name>.test.ts`, including hostile
115
+ inputs for anything that guards a sink.
116
+ - Ids never use `Math.random` at module scope; scopes are per-root.
117
+
118
+ ## Testing
119
+
120
+ ```bash
121
+ pnpm vitest run packages/core # every core texel test
122
+ pnpm vitest run packages/core/tests/roving.test.ts # one texel
123
+ pnpm --filter @texuli/core typecheck # tsc -p tsconfig.json
124
+ npx biome check packages/core # lint + format
125
+ ```
126
+
127
+ Core is shared by every layer above it, so a change here also runs
128
+ `pnpm check`, the primitives behavior suite and `pnpm build`
129
+ ([TESTING_GUIDE](../../docs/TESTING_GUIDE.md)).
130
+ To add a texel, follow
131
+ [`.agents/skills/add-texel/SKILL.md`](../../.agents/skills/add-texel/SKILL.md).
@@ -0,0 +1,24 @@
1
+ import type { Announcer, CreateAnnouncerOptions } from './types.js';
2
+ import { ANNOUNCER_GLOBAL_KEY } from './types.js';
3
+ /**
4
+ * Create a live-region announcer bound to one document.
5
+ *
6
+ * Owns one polite (`role="status"`) and one assertive (`role="alert"`) region.
7
+ * Announcements are debounced per region, deduplicated, written with
8
+ * `textContent` only, and the region nodes are registered as inert exemptions
9
+ * so they keep working under modals.
10
+ */
11
+ export declare function createAnnouncer(options: CreateAnnouncerOptions): Announcer;
12
+ export type AnnouncerGlobal = {
13
+ [ANNOUNCER_GLOBAL_KEY]?: WeakMap<Document, Announcer>;
14
+ };
15
+ /**
16
+ * Page-wide announcer for `documentRef`, stored on `globalObject` (default
17
+ * `globalThis`). Separate documents get separate instances.
18
+ */
19
+ export declare function getAnnouncer(documentRef: Document, options?: Omit<CreateAnnouncerOptions, 'document'>, globalObject?: AnnouncerGlobal): Announcer;
20
+ /**
21
+ * Drop the shared announcer for `documentRef` (tests). Destroys the instance
22
+ * when one was stored.
23
+ */
24
+ export declare function resetAnnouncer(documentRef?: Document, globalObject?: AnnouncerGlobal): void;
@@ -0,0 +1,253 @@
1
+ import { createInertRegistry, getInertRegistry, } from "../inert/index.js";
2
+ import { appendRegion, createLiveRegion, resolveAppendTarget } from "./regions.js";
3
+ import { ANNOUNCER_GLOBAL_KEY } from "./types.js";
4
+ const DEFAULT_POLITE_DEBOUNCE_MS = 150;
5
+ const DEFAULT_ASSERTIVE_DEBOUNCE_MS = 50;
6
+ const DEFAULT_DEDUPE_MS = 1000;
7
+ /** Gap after clearing a region before writing new text (SR re-announce). */
8
+ const CLEAR_GAP_MS = 16;
9
+ function defaultClock(documentRef) {
10
+ const view = documentRef.defaultView;
11
+ return {
12
+ now: () => (view?.performance?.now ? view.performance.now() : Date.now()),
13
+ setTimeout: (callback, ms) => {
14
+ if (view?.setTimeout)
15
+ return view.setTimeout(callback, ms);
16
+ return setTimeout(callback, ms);
17
+ },
18
+ clearTimeout: (id) => {
19
+ if (view?.clearTimeout)
20
+ view.clearTimeout(id);
21
+ else
22
+ clearTimeout(id);
23
+ },
24
+ };
25
+ }
26
+ function normalizeMessage(message) {
27
+ if (typeof message !== 'string')
28
+ return null;
29
+ const trimmed = message.replace(/\s+/g, ' ').trim();
30
+ return trimmed === '' ? null : trimmed;
31
+ }
32
+ function resolvePoliteness(options) {
33
+ if (options === 'polite' || options === 'assertive') {
34
+ return { politeness: options, immediate: false };
35
+ }
36
+ if (options && typeof options === 'object') {
37
+ return {
38
+ politeness: options.politeness === 'assertive' ? 'assertive' : 'polite',
39
+ immediate: options.immediate === true,
40
+ };
41
+ }
42
+ return { politeness: 'polite', immediate: false };
43
+ }
44
+ function resolveInertRegistry(option) {
45
+ if (option === null)
46
+ return null;
47
+ if (option)
48
+ return option;
49
+ try {
50
+ return getInertRegistry();
51
+ }
52
+ catch {
53
+ return createInertRegistry();
54
+ }
55
+ }
56
+ /**
57
+ * Create a live-region announcer bound to one document.
58
+ *
59
+ * Owns one polite (`role="status"`) and one assertive (`role="alert"`) region.
60
+ * Announcements are debounced per region, deduplicated, written with
61
+ * `textContent` only, and the region nodes are registered as inert exemptions
62
+ * so they keep working under modals.
63
+ */
64
+ export function createAnnouncer(options) {
65
+ const documentRef = options.document;
66
+ if (!documentRef || typeof documentRef.createElement !== 'function') {
67
+ throw new TypeError('createAnnouncer: document must be a Document');
68
+ }
69
+ const clock = options.clock ?? defaultClock(documentRef);
70
+ const politeDebounceMs = options.politeDebounceMs ?? DEFAULT_POLITE_DEBOUNCE_MS;
71
+ const assertiveDebounceMs = options.assertiveDebounceMs ?? DEFAULT_ASSERTIVE_DEBOUNCE_MS;
72
+ const dedupeMs = options.dedupeMs ?? DEFAULT_DEDUPE_MS;
73
+ const inertRegistry = resolveInertRegistry(options.inertRegistry);
74
+ const inertLabel = options.inertLabel ?? 'announcer';
75
+ const polite = {
76
+ node: null,
77
+ pending: null,
78
+ timer: null,
79
+ clearTimer: null,
80
+ lastMessage: '',
81
+ lastAt: 0,
82
+ exemption: null,
83
+ };
84
+ const assertive = {
85
+ node: null,
86
+ pending: null,
87
+ timer: null,
88
+ clearTimer: null,
89
+ lastMessage: '',
90
+ lastAt: 0,
91
+ exemption: null,
92
+ };
93
+ let destroyed = false;
94
+ const stateOf = (politeness) => politeness === 'assertive' ? assertive : polite;
95
+ const ensureRegion = (politeness) => {
96
+ const state = stateOf(politeness);
97
+ if (state.node)
98
+ return state.node;
99
+ const node = createLiveRegion(documentRef, politeness);
100
+ const parent = resolveAppendTarget(documentRef, options.appendTo);
101
+ appendRegion(node, parent);
102
+ state.node = node;
103
+ if (inertRegistry) {
104
+ state.exemption = inertRegistry.exempt({
105
+ element: node,
106
+ label: `${inertLabel}:${politeness}`,
107
+ });
108
+ }
109
+ return node;
110
+ };
111
+ const cancelTimers = (state) => {
112
+ if (state.timer !== null) {
113
+ clock.clearTimeout(state.timer);
114
+ state.timer = null;
115
+ }
116
+ if (state.clearTimer !== null) {
117
+ clock.clearTimeout(state.clearTimer);
118
+ state.clearTimer = null;
119
+ }
120
+ };
121
+ const deliver = (politeness, message) => {
122
+ if (destroyed)
123
+ return;
124
+ const state = stateOf(politeness);
125
+ const now = clock.now();
126
+ if (dedupeMs > 0 && message === state.lastMessage && now - state.lastAt < dedupeMs) {
127
+ state.pending = null;
128
+ return;
129
+ }
130
+ const node = ensureRegion(politeness);
131
+ // Clear first so repeated identical text (after dedupe window) is re-read.
132
+ node.textContent = '';
133
+ cancelTimers(state);
134
+ state.clearTimer = clock.setTimeout(() => {
135
+ state.clearTimer = null;
136
+ if (destroyed || state.node !== node)
137
+ return;
138
+ node.textContent = message;
139
+ state.lastMessage = message;
140
+ state.lastAt = clock.now();
141
+ state.pending = null;
142
+ }, CLEAR_GAP_MS);
143
+ };
144
+ const flush = (politeness) => {
145
+ const state = stateOf(politeness);
146
+ state.timer = null;
147
+ const message = state.pending;
148
+ if (message === null)
149
+ return;
150
+ deliver(politeness, message);
151
+ };
152
+ const announcer = {
153
+ get regions() {
154
+ return {
155
+ get polite() {
156
+ return ensureRegion('polite');
157
+ },
158
+ get assertive() {
159
+ return ensureRegion('assertive');
160
+ },
161
+ };
162
+ },
163
+ ensure() {
164
+ if (destroyed)
165
+ return;
166
+ ensureRegion('polite');
167
+ ensureRegion('assertive');
168
+ },
169
+ announce(message, optionsOrPoliteness) {
170
+ if (destroyed)
171
+ return;
172
+ const text = normalizeMessage(message);
173
+ if (text === null)
174
+ return;
175
+ const { politeness, immediate } = resolvePoliteness(optionsOrPoliteness);
176
+ const state = stateOf(politeness);
177
+ state.pending = text;
178
+ if (immediate) {
179
+ cancelTimers(state);
180
+ deliver(politeness, text);
181
+ return;
182
+ }
183
+ if (state.timer !== null)
184
+ clock.clearTimeout(state.timer);
185
+ const delay = politeness === 'assertive' ? assertiveDebounceMs : politeDebounceMs;
186
+ if (delay <= 0) {
187
+ state.timer = null;
188
+ deliver(politeness, text);
189
+ return;
190
+ }
191
+ state.timer = clock.setTimeout(() => flush(politeness), delay);
192
+ },
193
+ clear() {
194
+ if (destroyed)
195
+ return;
196
+ for (const state of [polite, assertive]) {
197
+ cancelTimers(state);
198
+ state.pending = null;
199
+ state.lastMessage = '';
200
+ state.lastAt = 0;
201
+ if (state.node)
202
+ state.node.textContent = '';
203
+ }
204
+ },
205
+ destroy() {
206
+ if (destroyed)
207
+ return;
208
+ destroyed = true;
209
+ for (const state of [polite, assertive]) {
210
+ cancelTimers(state);
211
+ state.pending = null;
212
+ state.exemption?.release();
213
+ state.exemption = null;
214
+ state.node?.remove();
215
+ state.node = null;
216
+ }
217
+ },
218
+ };
219
+ return announcer;
220
+ }
221
+ /**
222
+ * Page-wide announcer for `documentRef`, stored on `globalObject` (default
223
+ * `globalThis`). Separate documents get separate instances.
224
+ */
225
+ export function getAnnouncer(documentRef, options, globalObject = globalThis) {
226
+ let map = globalObject[ANNOUNCER_GLOBAL_KEY];
227
+ if (!map) {
228
+ map = new WeakMap();
229
+ globalObject[ANNOUNCER_GLOBAL_KEY] = map;
230
+ }
231
+ const existing = map.get(documentRef);
232
+ if (existing)
233
+ return existing;
234
+ const created = createAnnouncer({ ...options, document: documentRef });
235
+ map.set(documentRef, created);
236
+ return created;
237
+ }
238
+ /**
239
+ * Drop the shared announcer for `documentRef` (tests). Destroys the instance
240
+ * when one was stored.
241
+ */
242
+ export function resetAnnouncer(documentRef, globalObject = globalThis) {
243
+ const map = globalObject[ANNOUNCER_GLOBAL_KEY];
244
+ if (!map)
245
+ return;
246
+ if (documentRef) {
247
+ const existing = map.get(documentRef);
248
+ existing?.destroy();
249
+ map.delete(documentRef);
250
+ return;
251
+ }
252
+ delete globalObject[ANNOUNCER_GLOBAL_KEY];
253
+ }
@@ -0,0 +1,3 @@
1
+ export { type AnnouncerGlobal, createAnnouncer, getAnnouncer, resetAnnouncer, } from './announcer.js';
2
+ export { ANNOUNCER_REGION_ATTR, appendRegion, createLiveRegion, type RegionKind, resolveAppendTarget, } from './regions.js';
3
+ export { ANNOUNCER_GLOBAL_KEY, type AnnounceOptions, type AnnouncePoliteness, type Announcer, type AnnouncerClock, type CreateAnnouncerOptions, } from './types.js';
@@ -0,0 +1,3 @@
1
+ export { createAnnouncer, getAnnouncer, resetAnnouncer, } from "./announcer.js";
2
+ export { ANNOUNCER_REGION_ATTR, appendRegion, createLiveRegion, resolveAppendTarget, } from "./regions.js";
3
+ export { ANNOUNCER_GLOBAL_KEY, } from "./types.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Visually hidden live-region nodes. Styles use logical properties and keep
3
+ * the node in the accessibility tree (no `display: none` / `visibility: hidden`
4
+ * / `aria-hidden`).
5
+ */
6
+ export declare const ANNOUNCER_REGION_ATTR: "data-texuli-announcer";
7
+ export type RegionKind = 'polite' | 'assertive';
8
+ export declare function createLiveRegion(documentRef: Document, kind: RegionKind): HTMLElement;
9
+ export declare function appendRegion(node: HTMLElement, parent: Element): void;
10
+ export declare function resolveAppendTarget(documentRef: Document, appendTo?: Element): Element;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Visually hidden live-region nodes. Styles use logical properties and keep
3
+ * the node in the accessibility tree (no `display: none` / `visibility: hidden`
4
+ * / `aria-hidden`).
5
+ */
6
+ export const ANNOUNCER_REGION_ATTR = 'data-texuli-announcer';
7
+ const HIDDEN_STYLE = [
8
+ 'position: absolute',
9
+ 'inline-size: 1px',
10
+ 'block-size: 1px',
11
+ 'margin: -1px',
12
+ 'padding: 0',
13
+ 'border: 0',
14
+ 'overflow: hidden',
15
+ 'clip: rect(0, 0, 0, 0)',
16
+ 'white-space: nowrap',
17
+ ].join(';');
18
+ export function createLiveRegion(documentRef, kind) {
19
+ const node = documentRef.createElement('div');
20
+ node.setAttribute(ANNOUNCER_REGION_ATTR, kind);
21
+ node.setAttribute('aria-live', kind);
22
+ node.setAttribute('aria-atomic', 'true');
23
+ if (kind === 'assertive') {
24
+ node.setAttribute('role', 'alert');
25
+ }
26
+ else {
27
+ node.setAttribute('role', 'status');
28
+ }
29
+ // Presentational hide; keep in a11y tree. CSSOM, not a `style` attribute,
30
+ // which a strict CSP (`style-src` without 'unsafe-inline') blocks.
31
+ node.style.cssText = HIDDEN_STYLE;
32
+ node.textContent = '';
33
+ return node;
34
+ }
35
+ export function appendRegion(node, parent) {
36
+ if (node.parentNode === parent)
37
+ return;
38
+ parent.appendChild(node);
39
+ }
40
+ export function resolveAppendTarget(documentRef, appendTo) {
41
+ if (appendTo)
42
+ return appendTo;
43
+ if (documentRef.body)
44
+ return documentRef.body;
45
+ return documentRef.documentElement;
46
+ }
@@ -0,0 +1,68 @@
1
+ import type { InertRegistry } from '../inert/index.js';
2
+ /** Live-region politeness. One DOM region exists per value per announcer. */
3
+ export type AnnouncePoliteness = 'polite' | 'assertive';
4
+ /**
5
+ * Clock used for debounce and dedupe. Defaults to the document's window
6
+ * timers / `Date.now`. Tests inject a fake.
7
+ */
8
+ export interface AnnouncerClock {
9
+ now: () => number;
10
+ setTimeout: (callback: () => void, ms: number) => unknown;
11
+ clearTimeout: (id: unknown) => void;
12
+ }
13
+ export interface CreateAnnouncerOptions {
14
+ /** Document that owns the live regions (required; no import-time DOM). */
15
+ document: Document;
16
+ /**
17
+ * Parent for the region nodes. Defaults to `document.body` when present,
18
+ * otherwise `document.documentElement`.
19
+ */
20
+ appendTo?: Element;
21
+ /** Debounce for polite announcements in ms (default 150). */
22
+ politeDebounceMs?: number;
23
+ /** Debounce for assertive announcements in ms (default 50). */
24
+ assertiveDebounceMs?: number;
25
+ /**
26
+ * Skip a message identical to the last one delivered on the same region
27
+ * within this many ms (default 1000). `0` disables dedupe.
28
+ */
29
+ dedupeMs?: number;
30
+ /**
31
+ * Inert registry used to exempt the region nodes under modals. Defaults to
32
+ * the page-wide registry when the option is omitted; pass `null` to skip
33
+ * registration (tests).
34
+ */
35
+ inertRegistry?: InertRegistry | null;
36
+ /** Optional debug label prefix for inert exemptions. */
37
+ inertLabel?: string;
38
+ /** Timer / now source (tests). */
39
+ clock?: AnnouncerClock;
40
+ }
41
+ export interface AnnounceOptions {
42
+ politeness?: AnnouncePoliteness;
43
+ /**
44
+ * When true, bypass debounce and deliver on the next task (still clears the
45
+ * region first so repeated text is re-read).
46
+ */
47
+ immediate?: boolean;
48
+ }
49
+ export interface Announcer {
50
+ /**
51
+ * Queue `message` for the polite or assertive region. Empty / whitespace
52
+ * messages are ignored. Text is set via `textContent` only (no HTML).
53
+ */
54
+ announce: (message: string, options?: AnnounceOptions | AnnouncePoliteness) => void;
55
+ /** Clear both regions and cancel pending debounced work. */
56
+ clear: () => void;
57
+ /** Remove nodes, release inert exemptions, cancel timers. Idempotent. */
58
+ destroy: () => void;
59
+ /** The live-region elements (created lazily on first announce or ensure). */
60
+ readonly regions: {
61
+ readonly polite: HTMLElement;
62
+ readonly assertive: HTMLElement;
63
+ };
64
+ /** Force-create both regions and register exemptions without announcing. */
65
+ ensure: () => void;
66
+ }
67
+ /** Versioned key for a page-wide announcer map on `globalThis`. */
68
+ export declare const ANNOUNCER_GLOBAL_KEY: "__TEXULI_ANNOUNCER_V1__";
@@ -0,0 +1,2 @@
1
+ /** Versioned key for a page-wide announcer map on `globalThis`. */
2
+ export const ANNOUNCER_GLOBAL_KEY = '__TEXULI_ANNOUNCER_V1__';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Options for a state value that may be controlled by its owner.
3
+ *
4
+ * Supplying `value` makes the state controlled for this helper's lifetime.
5
+ * The getter is read each time so rune-backed props remain reactive. Without
6
+ * it, `defaultValue` initializes local state once.
7
+ */
8
+ export interface ControllableStateOptions<T> {
9
+ /** Current owner-provided value. Omit this getter for uncontrolled state. */
10
+ value?: () => T;
11
+ /** Initial value used only when `value` is omitted. */
12
+ defaultValue: T;
13
+ /** Called when a requested value differs from the current value. */
14
+ onChange?: (value: T) => void;
15
+ /** Called only when the owner explicitly marks a change as complete. */
16
+ onChangeComplete?: (value: T) => void;
17
+ }
18
+ /**
19
+ * A rune-friendly view of controlled or local state.
20
+ *
21
+ * `value` reads the owner's getter when controlled, or the local value
22
+ * otherwise. `setValue` and `update` request changes; controlled state is
23
+ * never mutated locally. Call `complete` at the interaction boundary (for
24
+ * example pointer-up or blur) to invoke `onChangeComplete`. This helper does
25
+ * not attach listeners, schedule callbacks, or access browser globals.
26
+ */
27
+ export interface ControllableState<T> {
28
+ /** The current controlled or uncontrolled value. */
29
+ readonly value: T;
30
+ /** Request an explicit value. Equal values are ignored using `Object.is`. */
31
+ setValue(value: T): void;
32
+ /** Request a value derived from the latest current value. */
33
+ update(updater: (current: T) => T): void;
34
+ /** Signal that the caller's interaction is complete. */
35
+ complete(): void;
36
+ }
37
+ /**
38
+ * Creates framework-free state for props that support controlled (`bind:`)
39
+ * and uncontrolled (`defaultValue`) use.
40
+ *
41
+ * The `value` getter is read at access time, so a Svelte rune-backed prop can
42
+ * be passed without taking a stale snapshot. Controlledness is fixed when the
43
+ * helper is created: provide or omit `value` consistently for its lifetime.
44
+ *
45
+ * `onChange` runs synchronously only when a new value is requested. In
46
+ * controlled mode the owner remains the source of truth and must update its
47
+ * value in response. `onChangeComplete` runs only when the caller invokes
48
+ * `complete`, and receives the then-current value.
49
+ */
50
+ export declare function createControllableState<T>(options: ControllableStateOptions<T>): ControllableState<T>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Creates framework-free state for props that support controlled (`bind:`)
3
+ * and uncontrolled (`defaultValue`) use.
4
+ *
5
+ * The `value` getter is read at access time, so a Svelte rune-backed prop can
6
+ * be passed without taking a stale snapshot. Controlledness is fixed when the
7
+ * helper is created: provide or omit `value` consistently for its lifetime.
8
+ *
9
+ * `onChange` runs synchronously only when a new value is requested. In
10
+ * controlled mode the owner remains the source of truth and must update its
11
+ * value in response. `onChangeComplete` runs only when the caller invokes
12
+ * `complete`, and receives the then-current value.
13
+ */
14
+ export function createControllableState(options) {
15
+ let localValue = options.defaultValue;
16
+ const controlledValue = options.value;
17
+ const isControlled = controlledValue !== undefined;
18
+ const read = () => (controlledValue ? controlledValue() : localValue);
19
+ const request = (next) => {
20
+ if (Object.is(read(), next))
21
+ return;
22
+ if (!isControlled)
23
+ localValue = next;
24
+ options.onChange?.(next);
25
+ };
26
+ return {
27
+ get value() {
28
+ return read();
29
+ },
30
+ setValue(value) {
31
+ request(value);
32
+ },
33
+ update(updater) {
34
+ request(updater(read()));
35
+ },
36
+ complete() {
37
+ options.onChangeComplete?.(read());
38
+ },
39
+ };
40
+ }