@dxos/react-ui-attention 0.9.1-staging.ee54ba693a → 0.11.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 (115) hide show
  1. package/dist/lib/chunk-ViewStateProvider.mjs +308 -0
  2. package/dist/lib/chunk-ViewStateProvider.mjs.map +1 -0
  3. package/dist/lib/chunk-types.mjs +351 -0
  4. package/dist/lib/chunk-types.mjs.map +1 -0
  5. package/dist/lib/index.mjs +149 -0
  6. package/dist/lib/index.mjs.map +1 -0
  7. package/dist/lib/testing.mjs +20 -0
  8. package/dist/lib/testing.mjs.map +1 -0
  9. package/dist/lib/types.mjs +2 -0
  10. package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts +2 -15
  11. package/dist/types/src/components/AttentionProvider/AttentionProvider.d.ts.map +1 -1
  12. package/dist/types/src/components/AttentionProvider/attention-context.d.ts +20 -0
  13. package/dist/types/src/components/AttentionProvider/attention-context.d.ts.map +1 -0
  14. package/dist/types/src/components/AttentionProvider/index.d.ts +1 -0
  15. package/dist/types/src/components/AttentionProvider/index.d.ts.map +1 -1
  16. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts +2 -26
  17. package/dist/types/src/components/ViewStateProvider/ViewStateProvider.d.ts.map +1 -1
  18. package/dist/types/src/components/ViewStateProvider/index.d.ts +1 -0
  19. package/dist/types/src/components/ViewStateProvider/index.d.ts.map +1 -1
  20. package/dist/types/src/components/ViewStateProvider/view-state-hooks.d.ts +32 -0
  21. package/dist/types/src/components/ViewStateProvider/view-state-hooks.d.ts.map +1 -0
  22. package/dist/types/src/core/backends.d.ts +24 -0
  23. package/dist/types/src/core/backends.d.ts.map +1 -0
  24. package/dist/types/src/core/backends.test.d.ts.map +1 -0
  25. package/dist/types/src/core/index.d.ts +2 -0
  26. package/dist/types/src/core/index.d.ts.map +1 -0
  27. package/dist/types/src/hooks/index.d.ts +2 -0
  28. package/dist/types/src/hooks/index.d.ts.map +1 -0
  29. package/dist/types/src/hooks/useArticleKeyboardNavigation.d.ts.map +1 -0
  30. package/dist/types/src/hooks/useArticleKeyboardNavigation.test.d.ts.map +1 -0
  31. package/dist/types/src/index.d.ts +3 -4
  32. package/dist/types/src/index.d.ts.map +1 -1
  33. package/dist/types/src/{attention.d.ts → types/Attention.d.ts} +13 -2
  34. package/dist/types/src/types/Attention.d.ts.map +1 -0
  35. package/dist/types/src/types/Attention.test.d.ts +2 -0
  36. package/dist/types/src/types/Attention.test.d.ts.map +1 -0
  37. package/dist/types/src/{selection.d.ts → types/Selection.d.ts} +12 -10
  38. package/dist/types/src/types/Selection.d.ts.map +1 -0
  39. package/dist/types/src/types/Selection.test.d.ts +2 -0
  40. package/dist/types/src/types/Selection.test.d.ts.map +1 -0
  41. package/dist/types/src/types/ViewState.d.ts +66 -0
  42. package/dist/types/src/types/ViewState.d.ts.map +1 -0
  43. package/dist/types/src/types/ViewState.test.d.ts +2 -0
  44. package/dist/types/src/types/ViewState.test.d.ts.map +1 -0
  45. package/dist/types/src/types/index.d.ts +3 -3
  46. package/dist/types/src/types/index.d.ts.map +1 -1
  47. package/dist/types/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +18 -20
  49. package/src/components/AttentionProvider/AttentionProvider.stories.tsx +1 -1
  50. package/src/components/AttentionProvider/AttentionProvider.tsx +4 -86
  51. package/src/components/AttentionProvider/attention-context.ts +81 -0
  52. package/src/components/AttentionProvider/index.ts +2 -0
  53. package/src/components/ViewStateProvider/ViewStateProvider.test.tsx +7 -5
  54. package/src/components/ViewStateProvider/ViewStateProvider.tsx +6 -110
  55. package/src/components/ViewStateProvider/index.ts +9 -0
  56. package/src/components/ViewStateProvider/view-state-hooks.ts +115 -0
  57. package/src/{view-state → core}/backends.test.ts +5 -5
  58. package/src/{view-state → core}/backends.ts +24 -17
  59. package/src/{view-state → core}/index.ts +0 -1
  60. package/src/hooks/index.ts +5 -0
  61. package/src/{useArticleKeyboardNavigation.ts → hooks/useArticleKeyboardNavigation.ts} +1 -1
  62. package/src/index.ts +3 -4
  63. package/src/testing/decorators/withAttention.ts +2 -2
  64. package/src/{attention.test.ts → types/Attention.test.ts} +2 -2
  65. package/src/{attention.ts → types/Attention.ts} +17 -2
  66. package/src/types/Selection.test.ts +100 -0
  67. package/src/{selection.ts → types/Selection.ts} +27 -17
  68. package/src/{view-state/view-state.test.ts → types/ViewState.test.ts} +6 -6
  69. package/src/types/ViewState.ts +101 -0
  70. package/src/types/index.ts +8 -7
  71. package/dist/lib/browser/chunk-4HYAJ4IO.mjs +0 -434
  72. package/dist/lib/browser/chunk-4HYAJ4IO.mjs.map +0 -7
  73. package/dist/lib/browser/chunk-I4FDY4AZ.mjs +0 -236
  74. package/dist/lib/browser/chunk-I4FDY4AZ.mjs.map +0 -7
  75. package/dist/lib/browser/index.mjs +0 -218
  76. package/dist/lib/browser/index.mjs.map +0 -7
  77. package/dist/lib/browser/meta.json +0 -1
  78. package/dist/lib/browser/testing/index.mjs +0 -30
  79. package/dist/lib/browser/testing/index.mjs.map +0 -7
  80. package/dist/lib/browser/types/index.mjs +0 -43
  81. package/dist/lib/browser/types/index.mjs.map +0 -7
  82. package/dist/lib/node-esm/chunk-DLBZMHST.mjs +0 -237
  83. package/dist/lib/node-esm/chunk-DLBZMHST.mjs.map +0 -7
  84. package/dist/lib/node-esm/chunk-HWN7KJDX.mjs +0 -436
  85. package/dist/lib/node-esm/chunk-HWN7KJDX.mjs.map +0 -7
  86. package/dist/lib/node-esm/index.mjs +0 -219
  87. package/dist/lib/node-esm/index.mjs.map +0 -7
  88. package/dist/lib/node-esm/meta.json +0 -1
  89. package/dist/lib/node-esm/testing/index.mjs +0 -31
  90. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  91. package/dist/lib/node-esm/types/index.mjs +0 -44
  92. package/dist/lib/node-esm/types/index.mjs.map +0 -7
  93. package/dist/types/src/attention.d.ts.map +0 -1
  94. package/dist/types/src/attention.test.d.ts +0 -2
  95. package/dist/types/src/attention.test.d.ts.map +0 -1
  96. package/dist/types/src/selection.d.ts.map +0 -1
  97. package/dist/types/src/selection.test.d.ts +0 -2
  98. package/dist/types/src/selection.test.d.ts.map +0 -1
  99. package/dist/types/src/useArticleKeyboardNavigation.d.ts.map +0 -1
  100. package/dist/types/src/useArticleKeyboardNavigation.test.d.ts.map +0 -1
  101. package/dist/types/src/view-state/backends.d.ts +0 -24
  102. package/dist/types/src/view-state/backends.d.ts.map +0 -1
  103. package/dist/types/src/view-state/backends.test.d.ts.map +0 -1
  104. package/dist/types/src/view-state/index.d.ts +0 -3
  105. package/dist/types/src/view-state/index.d.ts.map +0 -1
  106. package/dist/types/src/view-state/view-state.d.ts +0 -54
  107. package/dist/types/src/view-state/view-state.d.ts.map +0 -1
  108. package/dist/types/src/view-state/view-state.test.d.ts +0 -2
  109. package/dist/types/src/view-state/view-state.test.d.ts.map +0 -1
  110. package/src/selection.test.ts +0 -67
  111. package/src/view-state/view-state.ts +0 -91
  112. /package/dist/types/src/{view-state → core}/backends.test.d.ts +0 -0
  113. /package/dist/types/src/{useArticleKeyboardNavigation.d.ts → hooks/useArticleKeyboardNavigation.d.ts} +0 -0
  114. /package/dist/types/src/{useArticleKeyboardNavigation.test.d.ts → hooks/useArticleKeyboardNavigation.test.d.ts} +0 -0
  115. /package/src/{useArticleKeyboardNavigation.test.ts → hooks/useArticleKeyboardNavigation.test.ts} +0 -0
@@ -2,30 +2,33 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
5
+ import { Atom, type Registry } from '@effect-atom/atom';
6
6
  import * as Schema from 'effect/Schema';
7
7
 
8
- import { type AspectDef, type BackendName, type ViewStateBackend } from './view-state';
8
+ import { ViewState } from '../types';
9
9
 
10
- // Only the stable `key` string is needed to form the map key; avoids variance issues with AspectDef<T>.
10
+ // Only the stable `key` string is needed to form the map key; avoids variance issues with Aspect<T>.
11
11
  const cacheKey = (aspect: { key: string }, contextId: string) => `${aspect.key}:${contextId}`;
12
12
 
13
13
  /** In-memory backend: state is ephemeral and scoped to the session (never persisted). */
14
- export class MemoryBackend implements ViewStateBackend {
14
+ export class MemoryBackend implements ViewState.Backend {
15
15
  readonly #atoms = new Map<string, Atom.Writable<unknown>>();
16
16
 
17
- atom<T>(aspect: AspectDef<T>, contextId: string): Atom.Writable<T> {
17
+ atom<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
18
18
  const key = cacheKey(aspect, contextId);
19
19
  let atom = this.#atoms.get(key);
20
20
  if (!atom) {
21
- atom = Atom.make<unknown>(aspect.defaultValue());
21
+ // `keepAlive` pins the node so a written value survives for the session; without it the registry
22
+ // sweeps the unsubscribed atom back to its default, dropping state that no UI happens to observe
23
+ // (e.g. a selection read on-demand by an agent tool rather than a live subscriber).
24
+ atom = Atom.make<unknown>(aspect.defaultValue()).pipe(Atom.keepAlive);
22
25
  this.#atoms.set(key, atom);
23
26
  }
24
27
  // Cast bridges the per-aspect value type erased by the shared atom map; safe by construction.
25
28
  return atom as Atom.Writable<T>;
26
29
  }
27
30
 
28
- contexts<T>(aspect: AspectDef<T>): string[] {
31
+ contexts<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>): string[] {
29
32
  const prefix = `${aspect.key}:`;
30
33
  return [...this.#atoms.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
31
34
  }
@@ -52,14 +55,14 @@ export interface LocalBackendOptions {
52
55
  }
53
56
 
54
57
  /** localStorage-backed backend: seeds atoms from storage, persists on set, syncs across tabs. */
55
- export class LocalBackend implements ViewStateBackend {
58
+ export class LocalBackend implements ViewState.Backend {
56
59
  readonly #registry: Registry.Registry;
57
60
  // Absent in non-browser contexts (SSR/tests without injection); the backend then degrades to
58
61
  // ephemeral, in-memory behaviour rather than crashing on a missing `localStorage`.
59
62
  readonly #storage: Storage | undefined;
60
63
  readonly #atoms = new Map<string, Atom.Writable<unknown>>();
61
64
  // Reverse map: storage key -> (aspect, contextId) so `storage` events can target the right atom.
62
- readonly #byStorageKey = new Map<string, { aspect: AspectDef<unknown>; contextId: string }>();
65
+ readonly #byStorageKey = new Map<string, { aspect: ViewState.Aspect<unknown, unknown>; contextId: string }>();
63
66
  #storageListener?: (event: StorageEvent) => void;
64
67
 
65
68
  constructor({ registry, storage }: LocalBackendOptions) {
@@ -80,26 +83,28 @@ export class LocalBackend implements ViewStateBackend {
80
83
  }
81
84
  }
82
85
 
83
- atom<T>(aspect: AspectDef<T>, contextId: string): Atom.Writable<T> {
86
+ atom<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
84
87
  const key = cacheKey(aspect, contextId);
85
88
  let atom = this.#atoms.get(key);
86
89
  if (!atom) {
87
90
  const storageKey = storageKeyFor(aspect, contextId);
88
- atom = Atom.make<unknown>(this.#read(aspect, storageKey));
91
+ // `keepAlive` pins the node so the in-memory value stays consistent with storage without a live
92
+ // subscriber; otherwise the registry sweeps the unsubscribed atom back to its creation-time seed.
93
+ atom = Atom.make<unknown>(this.#read(aspect, storageKey)).pipe(Atom.keepAlive);
89
94
  this.#atoms.set(key, atom);
90
95
  // Cast erases the per-aspect value type so the reverse map can hold aspects of any `T`; the
91
96
  // stored aspect is only used to re-read/decode its own value, so the erasure is safe.
92
- this.#byStorageKey.set(storageKey, { aspect: aspect as AspectDef<unknown>, contextId });
97
+ this.#byStorageKey.set(storageKey, { aspect: aspect as ViewState.Aspect<unknown, unknown>, contextId });
93
98
  }
94
99
  // Cast bridges the per-aspect value type erased by the shared atom map; safe by construction.
95
100
  return atom as Atom.Writable<T>;
96
101
  }
97
102
 
98
- persist<T>(aspect: AspectDef<T>, contextId: string, value: T): void {
103
+ persist<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, contextId: string, value: T): void {
99
104
  this.#storage?.setItem(storageKeyFor(aspect, contextId), JSON.stringify(Schema.encodeSync(aspect.schema)(value)));
100
105
  }
101
106
 
102
- contexts<T>(aspect: AspectDef<T>): string[] {
107
+ contexts<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>): string[] {
103
108
  if (!this.#storage) {
104
109
  // No persistent storage: fall back to the in-memory atoms (mirrors `atom()`'s ephemeral path).
105
110
  const prefix = `${aspect.key}:`;
@@ -125,7 +130,7 @@ export class LocalBackend implements ViewStateBackend {
125
130
  this.#byStorageKey.clear();
126
131
  }
127
132
 
128
- #read<T>(aspect: AspectDef<T>, storageKey: string): T {
133
+ #read<T, Encoded>(aspect: ViewState.Aspect<T, Encoded>, storageKey: string): T {
129
134
  const raw = this.#storage?.getItem(storageKey);
130
135
  if (raw == null) {
131
136
  return aspect.defaultValue();
@@ -133,13 +138,15 @@ export class LocalBackend implements ViewStateBackend {
133
138
  try {
134
139
  return Schema.decodeUnknownSync(aspect.schema)(JSON.parse(raw));
135
140
  } catch {
136
- // Tolerate stale/corrupt entries (e.g. a prior schema shape) by falling back to the default.
141
+ // Tolerate stale/corrupt entries (e.g., a prior schema shape) by falling back to the default.
137
142
  return aspect.defaultValue();
138
143
  }
139
144
  }
140
145
  }
141
146
 
142
- export const createDefaultBackends = (registry: Registry.Registry): Record<BackendName, ViewStateBackend> => ({
147
+ export const createDefaultBackends = (
148
+ registry: Registry.Registry,
149
+ ): Record<ViewState.BackendName, ViewState.Backend> => ({
143
150
  memory: new MemoryBackend(),
144
151
  local: new LocalBackend({ registry }),
145
152
  });
@@ -2,5 +2,4 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- export * from './view-state';
6
5
  export * from './backends';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './useArticleKeyboardNavigation';
@@ -6,7 +6,7 @@ import { useEffect, useMemo } from 'react';
6
6
 
7
7
  import { Keyboard, nestKeyboardContext } from '@dxos/keyboard';
8
8
 
9
- import { useAttention } from './components';
9
+ import { useAttention } from '../components';
10
10
 
11
11
  /**
12
12
  * Compute the id to select after pressing 'j' (delta = 1) or 'k' (delta = -1).
package/src/index.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './attention';
6
5
  export * from './components';
7
- export * from './selection';
8
- export * from './useArticleKeyboardNavigation';
9
- export * from './view-state';
6
+ export * from './core';
7
+ export * from './hooks';
8
+ export * from './types';
@@ -6,8 +6,8 @@ import { Registry, RegistryContext } from '@effect-atom/atom-react';
6
6
  import { type Decorator } from '@storybook/react';
7
7
  import { createElement, useMemo } from 'react';
8
8
 
9
- import { AttentionManager } from '../../attention';
10
9
  import { RootAttentionProvider, ViewStateProvider } from '../../components';
10
+ import { Attention } from '../../types';
11
11
 
12
12
  /**
13
13
  * Storybook decorator that provides attention context.
@@ -17,7 +17,7 @@ export const withAttention = (initialAttendedId?: string): Decorator => {
17
17
  return (Story) => {
18
18
  const registry = useMemo(() => Registry.make(), []);
19
19
  const attention = useMemo(
20
- () => (initialAttendedId ? new AttentionManager(registry, [initialAttendedId]) : undefined),
20
+ () => (initialAttendedId ? new Attention.AttentionManager(registry, [initialAttendedId]) : undefined),
21
21
  [registry],
22
22
  );
23
23
 
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Registry } from '@effect-atom/atom-react';
5
+ import { Registry } from '@effect-atom/atom';
6
6
  import { describe, test } from 'vitest';
7
7
 
8
8
  import {
@@ -13,7 +13,7 @@ import {
13
13
  getSegmentId,
14
14
  isLinkedSegment,
15
15
  linkedSegment,
16
- } from './attention';
16
+ } from './Attention';
17
17
 
18
18
  describe('AttentionManager', () => {
19
19
  test('takes an initial attended id', ({ expect }) => {
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Atom, type Registry } from '@effect-atom/atom-react';
5
+ import { Atom, type Registry } from '@effect-atom/atom';
6
6
 
7
7
  export type Attention = {
8
8
  hasAttention: boolean;
@@ -159,6 +159,12 @@ export class AttentionManager {
159
159
  }
160
160
  }
161
161
 
162
+ /** The attribute marking an element as attendable, carrying its qualified id. */
163
+ export const ATTENDABLE_ATTRIBUTE = 'data-attendable-id';
164
+
165
+ /** Selector matching any attendable element. */
166
+ export const ATTENDABLE_SELECTOR = `[${ATTENDABLE_ATTRIBUTE}]`;
167
+
162
168
  /**
163
169
  * Accumulates all attendable IDs between the element provided and the root, inclusive.
164
170
  * Each `data-attendable-id` value is treated as a single qualified ID (no splitting).
@@ -167,7 +173,7 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
167
173
  // Find the closest element with `data-attendable-id`, if any; start from cursor and move up the DOM tree.
168
174
  const closestAttendable = cursor.closest(selector);
169
175
  if (closestAttendable) {
170
- const attendableId = closestAttendable.getAttribute('data-attendable-id');
176
+ const attendableId = closestAttendable.getAttribute(ATTENDABLE_ATTRIBUTE);
171
177
  if (!attendableId) {
172
178
  // This has an id of an aria-controls elsewhere on the page, move cursor to that trigger.
173
179
  const trigger = document.querySelector(`[aria-controls="${closestAttendable.getAttribute('id')}"]`);
@@ -185,6 +191,15 @@ export const getAttendables = (selector: string, cursor: Element, acc: string[]
185
191
  return [...new Set(acc)];
186
192
  };
187
193
 
194
+ /**
195
+ * The outermost attendable ancestor of `element` — the root of any nested attendables (e.g. a section
196
+ * within a stack). Structural (real DOM ancestry), so it is independent of what currently has attention.
197
+ * Resolve it from an in-DOM element: a portaled subtree (a menu, a popover) is not under its own
198
+ * attendable, so walk from the trigger or anchor instead.
199
+ */
200
+ export const getRootAttendableId = (element: Element): string | undefined =>
201
+ getAttendables(ATTENDABLE_SELECTOR, element).at(-1);
202
+
188
203
  export type AttendableId = { attendableId?: string };
189
204
 
190
205
  export type Related = { related?: boolean };
@@ -0,0 +1,100 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry } from '@effect-atom/atom';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { createDefaultBackends } from '../core';
9
+ import * as Selection from './Selection';
10
+ import { Manager } from './ViewState';
11
+
12
+ describe('selection helpers', () => {
13
+ test('aspect declares a memory-backed aspect', ({ expect }) => {
14
+ expect(Selection.aspect.key).toEqual('selection');
15
+ expect(Selection.aspect.backend).toEqual('memory');
16
+ expect(Selection.aspect.defaultValue()).toEqual({ mode: 'multi', ids: [] });
17
+ });
18
+
19
+ test('resolveSelection extracts the value for the requested mode', ({ expect }) => {
20
+ expect(Selection.resolve({ mode: 'single', id: 'x' }, 'single')).toEqual('x');
21
+ expect(Selection.resolve({ mode: 'multi', ids: ['a', 'b'] }, 'multi')).toEqual(['a', 'b']);
22
+ expect(Selection.resolve({ mode: 'range', from: 'a', to: 'b' }, 'range')).toEqual({ from: 'a', to: 'b' });
23
+ expect(Selection.resolve({ mode: 'multi-range', ranges: [{ from: 'a', to: 'b' }] }, 'multi-range')).toEqual([
24
+ { from: 'a', to: 'b' },
25
+ ]);
26
+ });
27
+
28
+ test('resolveSelection returns the requested-mode default on mismatch or undefined', ({ expect }) => {
29
+ expect(Selection.resolve(undefined, 'single')).toBeUndefined();
30
+ expect(Selection.resolve(undefined, 'multi')).toEqual([]);
31
+ // Stored value is the default multi but a single reader asks — yields the single default.
32
+ expect(Selection.resolve({ mode: 'multi', ids: [] }, 'single')).toBeUndefined();
33
+ expect(Selection.resolve({ mode: 'range' }, 'range')).toBeUndefined();
34
+ });
35
+
36
+ test('toggleSelection adds/removes within a multi selection', ({ expect }) => {
37
+ expect(Selection.toggle({ mode: 'multi', ids: ['a'] }, 'b')).toEqual({ mode: 'multi', ids: ['a', 'b'] });
38
+ expect(Selection.toggle({ mode: 'multi', ids: ['a', 'b'] }, 'b')).toEqual({ mode: 'multi', ids: ['a'] });
39
+ // Tolerate a non-multi current value by starting fresh.
40
+ expect(Selection.toggle({ mode: 'single', id: 'x' }, 'b')).toEqual({ mode: 'multi', ids: ['b'] });
41
+ });
42
+ });
43
+
44
+ describe('toAnchors', () => {
45
+ test('undefined selection yields no anchors', ({ expect }) => {
46
+ expect(Selection.toAnchors(undefined)).toEqual([]);
47
+ });
48
+
49
+ test('single mode yields the id when set', ({ expect }) => {
50
+ expect(Selection.toAnchors({ mode: 'single', id: 'a' })).toEqual(['a']);
51
+ expect(Selection.toAnchors({ mode: 'single' })).toEqual([]);
52
+ });
53
+
54
+ test('multi mode yields all ids', ({ expect }) => {
55
+ expect(Selection.toAnchors({ mode: 'multi', ids: ['a', 'b'] })).toEqual(['a', 'b']);
56
+ });
57
+
58
+ test('range mode yields a cursor-pair anchor when complete', ({ expect }) => {
59
+ expect(Selection.toAnchors({ mode: 'range', from: 'x', to: 'y' })).toEqual(['x:y']);
60
+ expect(Selection.toAnchors({ mode: 'range', from: 'x' })).toEqual([]);
61
+ });
62
+
63
+ test('multi-range mode yields one anchor per range', ({ expect }) => {
64
+ expect(
65
+ Selection.toAnchors({
66
+ mode: 'multi-range',
67
+ ranges: [
68
+ { from: 'a', to: 'b' },
69
+ { from: 'c', to: 'd' },
70
+ ],
71
+ }),
72
+ ).toEqual(['a:b', 'c:d']);
73
+ });
74
+ });
75
+
76
+ describe('getValue', () => {
77
+ const makeManager = () => {
78
+ const registry = Registry.make();
79
+ return new Manager({ registry, backends: createDefaultBackends(registry) });
80
+ };
81
+
82
+ test('unions multi-selected ids across every context', ({ expect }) => {
83
+ const manager = makeManager();
84
+ manager.set(Selection.aspect, 'ctx-a', { mode: 'multi', ids: ['a1', 'a2'] });
85
+ manager.set(Selection.aspect, 'ctx-b', { mode: 'multi', ids: ['a2', 'b1'] });
86
+ // Single-mode contexts contribute nothing to the set.
87
+ manager.set(Selection.aspect, 'ctx-c', { mode: 'single', id: 'c1' });
88
+ expect(Selection.getValue(manager)).toEqual(new Set(['a1', 'a2', 'b1']));
89
+ });
90
+
91
+ test('seeds the set with the optional explicit contextId', ({ expect }) => {
92
+ const manager = makeManager();
93
+ manager.set(Selection.aspect, 'ctx-a', { mode: 'multi', ids: ['a1'] });
94
+ expect(Selection.getValue(manager, 'explicit')).toEqual(new Set(['explicit', 'a1']));
95
+ });
96
+
97
+ test('returns an empty set when nothing is selected', ({ expect }) => {
98
+ expect(Selection.getValue(makeManager())).toEqual(new Set());
99
+ });
100
+ });
@@ -5,11 +5,11 @@
5
5
  import * as Match from 'effect/Match';
6
6
  import * as Schema from 'effect/Schema';
7
7
 
8
- import { type AspectDef, type ViewStateManager, defineViewState } from './view-state';
8
+ import { type Aspect, type Manager, define } from './ViewState';
9
9
 
10
10
  export type SelectionMode = 'single' | 'multi' | 'range' | 'multi-range';
11
11
 
12
- export const SelectionSchema = Schema.Union(
12
+ export const Selection = Schema.Union(
13
13
  Schema.Struct({
14
14
  mode: Schema.Literal('single'),
15
15
  id: Schema.optional(Schema.String),
@@ -29,9 +29,9 @@ export const SelectionSchema = Schema.Union(
29
29
  }).pipe(Schema.mutable),
30
30
  ).pipe(Schema.mutable);
31
31
 
32
- export type Selection = Schema.Schema.Type<typeof SelectionSchema>;
32
+ export type Selection = Schema.Schema.Type<typeof Selection>;
33
33
 
34
- export const defaultSelection = Match.type<SelectionMode>().pipe(
34
+ export const defaultValue = Match.type<SelectionMode>().pipe(
35
35
  Match.withReturnType<Selection>(),
36
36
  Match.when('single', () => ({ mode: 'single' })),
37
37
  Match.when('multi', () => ({ mode: 'multi', ids: [] })),
@@ -40,7 +40,7 @@ export const defaultSelection = Match.type<SelectionMode>().pipe(
40
40
  Match.exhaustive,
41
41
  );
42
42
 
43
- export type SelectionResult<T extends SelectionMode> = T extends 'single'
43
+ export type Result<T extends SelectionMode> = T extends 'single'
44
44
  ? string | undefined
45
45
  : T extends 'multi'
46
46
  ? string[]
@@ -51,10 +51,10 @@ export type SelectionResult<T extends SelectionMode> = T extends 'single'
51
51
  : never;
52
52
 
53
53
  /** Selection state for a context, stored in memory (ephemeral, per-device session). */
54
- export const selectionAspect: AspectDef<Selection> = defineViewState<Selection>({
54
+ export const aspect: Aspect<Selection> = define<Selection>({
55
55
  key: 'selection',
56
56
  backend: 'memory',
57
- schema: SelectionSchema,
57
+ schema: Selection,
58
58
  defaultValue: () => ({ mode: 'multi', ids: [] }),
59
59
  });
60
60
 
@@ -62,11 +62,8 @@ export const selectionAspect: AspectDef<Selection> = defineViewState<Selection>(
62
62
  * Extract the typed result for `mode` from a stored selection value, returning the requested
63
63
  * mode's default when the stored value is absent or holds a different mode.
64
64
  */
65
- export const resolveSelection = <T extends SelectionMode>(
66
- selection: Selection | undefined,
67
- mode: T,
68
- ): SelectionResult<T> => {
69
- const value = selection?.mode === mode ? selection : defaultSelection(mode);
65
+ export const resolve = <T extends SelectionMode>(selection: Selection | undefined, mode: T): Result<T> => {
66
+ const value = selection?.mode === mode ? selection : defaultValue(mode);
70
67
  // Cast required because TypeScript cannot relate the `Match` result to the generic `T`.
71
68
  return Match.type<Selection>().pipe(
72
69
  Match.when({ mode: 'single' }, (s) => s.id),
@@ -74,25 +71,38 @@ export const resolveSelection = <T extends SelectionMode>(
74
71
  Match.when({ mode: 'range' }, (s) => (s.from && s.to ? { from: s.from, to: s.to } : undefined)),
75
72
  Match.when({ mode: 'multi-range' }, (s) => s.ranges),
76
73
  Match.exhaustive,
77
- )(value) as SelectionResult<T>;
74
+ )(value) as Result<T>;
78
75
  };
79
76
 
80
77
  /** Toggle `id` within a multi selection; resets to a multi selection if the current value differs. */
81
- export const toggleSelection = (selection: Selection | undefined, id: string): Selection => {
78
+ export const toggle = (selection: Selection | undefined, id: string): Selection => {
82
79
  const ids = selection?.mode === 'multi' ? selection.ids : [];
83
80
  return { mode: 'multi', ids: ids.includes(id) ? ids.filter((existing) => existing !== id) : [...ids, id] };
84
81
  };
85
82
 
83
+ /** Anchor strings for a selection: ids for single/multi modes, `"${from}:${to}"` cursor pairs for range modes. */
84
+ export const toAnchors = (selection: Selection | undefined): string[] =>
85
+ selection == null
86
+ ? []
87
+ : Match.type<Selection>().pipe(
88
+ Match.when({ mode: 'single' }, (value) => (value.id ? [value.id] : [])),
89
+ Match.when({ mode: 'multi' }, (value) => [...value.ids]),
90
+ Match.when({ mode: 'range' }, (value) => (value.from && value.to ? [`${value.from}:${value.to}`] : [])),
91
+ Match.when({ mode: 'multi-range' }, (value) => value.ranges.map((range) => `${range.from}:${range.to}`)),
92
+ Match.exhaustive,
93
+ )(selection);
94
+
86
95
  /** Union of all multi-selected ids across every selection context, plus an optional explicit id. */
87
- export const getSelectionSet = (manager: ViewStateManager, contextId?: string): Set<string> => {
96
+ export const getValue = (manager: Manager, contextId?: string): Set<string> => {
88
97
  const ids = new Set<string>(contextId ? [contextId] : []);
89
- for (const context of manager.contexts(selectionAspect)) {
90
- const selection = manager.get(selectionAspect, context);
98
+ for (const context of manager.contexts(aspect)) {
99
+ const selection = manager.get(aspect, context);
91
100
  if (selection.mode === 'multi') {
92
101
  for (const id of selection.ids) {
93
102
  ids.add(id);
94
103
  }
95
104
  }
96
105
  }
106
+
97
107
  return ids;
98
108
  };
@@ -2,24 +2,24 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { Registry } from '@effect-atom/atom-react';
5
+ import { Registry } from '@effect-atom/atom';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import { describe, test } from 'vitest';
8
8
 
9
- import { createDefaultBackends } from './backends';
10
- import { ViewStateManager, defineViewState } from './view-state';
9
+ import { createDefaultBackends } from '../core';
10
+ import { Manager, define } from './ViewState';
11
11
 
12
- const Counter = defineViewState({
12
+ const Counter = define({
13
13
  key: 'counter',
14
14
  backend: 'memory',
15
15
  schema: Schema.Struct({ value: Schema.Number }).pipe(Schema.mutable),
16
16
  defaultValue: () => ({ value: 0 }),
17
17
  });
18
18
 
19
- describe('ViewStateManager', () => {
19
+ describe('Manager', () => {
20
20
  const make = () => {
21
21
  const registry = Registry.make();
22
- return new ViewStateManager({ registry, backends: createDefaultBackends(registry) });
22
+ return new Manager({ registry, backends: createDefaultBackends(registry) });
23
23
  };
24
24
 
25
25
  test('returns the aspect default for an unwritten context', ({ expect }) => {
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Atom, type Registry } from '@effect-atom/atom';
6
+ import type * as Schema from 'effect/Schema';
7
+
8
+ /**
9
+ * Persistence backend identifier. `personal` (ECHO/personal-space) is reserved for a future backend.
10
+ */
11
+ export type BackendName = 'memory' | 'local';
12
+
13
+ /**
14
+ * Declares a kind of per-context UI state. The value type `T` and its persisted wire type `Encoded`
15
+ * are inferred from the schema; `Encoded` defaults to `T` for the common no-transform case.
16
+ */
17
+ export interface Aspect<T, Encoded = T> {
18
+ readonly key: string;
19
+ readonly backend: BackendName;
20
+ readonly schema: Schema.Schema<T, Encoded>;
21
+ readonly defaultValue: () => T;
22
+ }
23
+
24
+ /**
25
+ * Identity helper that pins the value type from the schema while keeping the literal `key`/`backend`.
26
+ * Declares an aspect of per-context UI state (selection, scroll, view mode, split) read/written via
27
+ * {@link Manager} — through `useViewState`/`useViewStateActions` in React or the
28
+ * `AttentionCapabilities.ViewState` capability in operations. Durability is the `backend`'s: `local`
29
+ * survives reloads (best-effort — degrades to in-memory when storage is blocked), `memory` is
30
+ * session-only.
31
+ *
32
+ * @idiom org.dxos.react-ui-attention.viewState
33
+ * applies: Holding per-context UI state (selection, view mode, scroll, split) that survives navigation (and reloads with the `local` backend)
34
+ * instead-of: ad-hoc `useState` that resets on remount, or stuffing per-context state into the plugin Settings store
35
+ * uses: {@link define}, {@link Manager}
36
+ * related: org.dxos.effect.kvsStore
37
+ */
38
+ export const define = <T, Encoded = T>(def: Aspect<T, Encoded>): Aspect<T, Encoded> => def;
39
+
40
+ /**
41
+ * A backend produces a reactive, writable atom for each `(aspect, contextId)` pair. Backends may
42
+ * hydrate asynchronously (an ECHO backend would), yielding `aspect.defaultValue()` until loaded;
43
+ * the memory and local backends resolve synchronously.
44
+ */
45
+ export interface Backend {
46
+ /** Stable atom for the pair; created (and seeded) on first access, cached thereafter. */
47
+ atom: <T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string) => Atom.Writable<T>;
48
+ /** Persist a value after the atom is updated. No-op for in-memory backends. */
49
+ persist?: <T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, value: T) => void;
50
+ /** Context ids that currently hold a value for the aspect. */
51
+ contexts: <T, Encoded>(aspect: Aspect<T, Encoded>) => string[];
52
+ /** Release listeners/timers (used by tests; app-lifetime managers do not call this). */
53
+ dispose?: () => void;
54
+ }
55
+
56
+ export interface ManagerOptions {
57
+ readonly registry: Registry.Registry;
58
+ readonly backends: Record<BackendName, Backend>;
59
+ }
60
+
61
+ /**
62
+ * Routes per-context UI state to the backend declared by each aspect. Reads/writes go through the
63
+ * effect-atom registry so React hooks and graph atoms observe changes uniformly.
64
+ */
65
+ export class Manager {
66
+ readonly #registry: Registry.Registry;
67
+ readonly #backends: Record<BackendName, Backend>;
68
+
69
+ constructor({ registry, backends }: ManagerOptions) {
70
+ this.#registry = registry;
71
+ this.#backends = backends;
72
+ }
73
+
74
+ /** Reactive atom for `(aspect, contextId)`; pass to `registry.get` inside derived atoms/hooks. */
75
+ atom<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string): Atom.Writable<T> {
76
+ return this.#backends[aspect.backend].atom(aspect, contextId);
77
+ }
78
+
79
+ get<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string): T {
80
+ return this.#registry.get(this.atom(aspect, contextId));
81
+ }
82
+
83
+ set<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, value: T): void {
84
+ const backend = this.#backends[aspect.backend];
85
+ this.#registry.set(backend.atom(aspect, contextId), value);
86
+ backend.persist?.(aspect, contextId, value);
87
+ }
88
+
89
+ update<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, fn: (prev: T) => T): void {
90
+ this.set(aspect, contextId, fn(this.get(aspect, contextId)));
91
+ }
92
+
93
+ subscribe<T, Encoded>(aspect: Aspect<T, Encoded>, contextId: string, cb: (value: T) => void): () => void {
94
+ const atom = this.atom(aspect, contextId);
95
+ return this.#registry.subscribe(atom, () => cb(this.#registry.get(atom)));
96
+ }
97
+
98
+ contexts<T, Encoded>(aspect: Aspect<T, Encoded>): string[] {
99
+ return this.#backends[aspect.backend].contexts(aspect);
100
+ }
101
+ }
@@ -1,10 +1,11 @@
1
1
  //
2
- // Copyright 2024 DXOS.org
2
+ // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- // UI-free re-exports of @dxos/react-ui-attention. Importing from here does not
6
- // pull in React components, so it is safe to use from operations, schemas, and
7
- // other non-DOM code that only needs the data model and pure helpers.
8
- export * from '../attention';
9
- export * from '../selection';
10
- export * from '../view-state';
5
+ // A UI-free entrypoint: the attention/selection/view state definitions with no React attached, so
6
+ // operation handlers and app-graph builders running under node or bun can use them without pulling
7
+ // the components.
8
+
9
+ export * as Attention from './Attention';
10
+ export * as Selection from './Selection';
11
+ export * as ViewState from './ViewState';