@cosmicdrift/kumiko-headless 0.328.0 → 0.328.1

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 (108) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/apex/__tests__/lightbox.test.ts +0 -20
  65. package/src/apex/__tests__/page-head.test.ts +0 -63
  66. package/src/apex/__tests__/render.test.ts +0 -401
  67. package/src/apex/index.ts +0 -667
  68. package/src/contracts/__tests__/contracts.test.ts +0 -123
  69. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  70. package/src/contracts/asset.ts +0 -48
  71. package/src/contracts/index.ts +0 -23
  72. package/src/contracts/locale.ts +0 -33
  73. package/src/contracts/primitives.ts +0 -158
  74. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  75. package/src/dispatcher/index.ts +0 -16
  76. package/src/dispatcher/types.ts +0 -217
  77. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  78. package/src/form/__tests__/form-controller.test.ts +0 -231
  79. package/src/form/__tests__/submit.test.ts +0 -575
  80. package/src/form/__tests__/validation.test.ts +0 -274
  81. package/src/form/form-controller.ts +0 -458
  82. package/src/form/index.ts +0 -15
  83. package/src/form/types.ts +0 -297
  84. package/src/format/__tests__/escape.test.ts +0 -79
  85. package/src/format/__tests__/format.test.ts +0 -285
  86. package/src/format/__tests__/html-template.test.ts +0 -47
  87. package/src/format/html-template.ts +0 -49
  88. package/src/format/index.ts +0 -223
  89. package/src/index.ts +0 -118
  90. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  91. package/src/locale-routing/index.ts +0 -218
  92. package/src/nav/__tests__/resolve.test.ts +0 -279
  93. package/src/nav/index.ts +0 -8
  94. package/src/nav/resolve.ts +0 -60
  95. package/src/nav/types.ts +0 -70
  96. package/src/store/__tests__/create-store.test.ts +0 -139
  97. package/src/store/__tests__/equality.test.ts +0 -66
  98. package/src/store/index.ts +0 -3
  99. package/src/store/types.ts +0 -27
  100. package/src/view-model/__tests__/edit.test.ts +0 -1072
  101. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  102. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  103. package/src/view-model/__tests__/list.test.ts +0 -439
  104. package/src/view-model/edit.ts +0 -645
  105. package/src/view-model/embedded-list.ts +0 -89
  106. package/src/view-model/index.ts +0 -43
  107. package/src/view-model/list.ts +0 -280
  108. package/src/view-model/types.ts +0 -400
@@ -1,279 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { resolveNavigation } from "../resolve";
4
- import type { NavRegistrySlice } from "../types";
5
-
6
- // Builds the minimal NavRegistrySlice resolveNavigation consumes from a
7
- // flat NavDefinition list. Shape matches what the framework registry
8
- // exposes: `topLevel` collects entries without a parent, `byParent`
9
- // looks up children by qualified parent-id. Ids in the list MUST already
10
- // be qualified ("feature:nav:short") — that's what the registry hands
11
- // back, so tests here mirror it 1:1.
12
- function buildSource(navs: readonly NavDefinition[]): NavRegistrySlice {
13
- const byParent = new Map<string, NavDefinition[]>();
14
- const topLevel: NavDefinition[] = [];
15
- for (const nav of navs) {
16
- if (nav.parent) {
17
- const bucket = byParent.get(nav.parent);
18
- if (bucket) bucket.push(nav);
19
- else byParent.set(nav.parent, [nav]);
20
- } else {
21
- topLevel.push(nav);
22
- }
23
- }
24
- return {
25
- topLevel,
26
- byParent: (qn) => byParent.get(qn) ?? [],
27
- };
28
- }
29
-
30
- const userAdmin = { id: "u-admin", roles: ["Admin"] };
31
- const userStandard = { id: "u-std", roles: ["User"] };
32
-
33
- describe("resolveNavigation", () => {
34
- test("flat list: all top-level entries become root nodes", () => {
35
- const source = buildSource([
36
- { id: "orders:nav:list", label: "Orders", screen: "orders:screen:order-list" },
37
- { id: "orders:nav:dashboard", label: "Home" },
38
- ]);
39
-
40
- const tree = resolveNavigation({ source });
41
-
42
- expect(tree).toHaveLength(2);
43
- expect(tree.map((n) => n.qualifiedName).sort()).toEqual([
44
- "orders:nav:dashboard",
45
- "orders:nav:list",
46
- ]);
47
- expect(tree.every((n) => n.children.length === 0)).toBe(true);
48
- });
49
-
50
- test("parent refs assemble the tree — one level deep", () => {
51
- const source = buildSource([
52
- { id: "app:nav:ops", label: "Operations" },
53
- {
54
- id: "app:nav:ops-orders",
55
- label: "Orders",
56
- parent: "app:nav:ops",
57
- screen: "orders:screen:order-list",
58
- },
59
- {
60
- id: "app:nav:ops-shipments",
61
- label: "Shipments",
62
- parent: "app:nav:ops",
63
- screen: "ship:screen:ship-list",
64
- },
65
- ]);
66
-
67
- const tree = resolveNavigation({ source });
68
-
69
- expect(tree).toHaveLength(1);
70
- const ops = tree[0];
71
- expect(ops?.qualifiedName).toBe("app:nav:ops");
72
- expect(ops?.children).toHaveLength(2);
73
- expect(ops?.children.map((c) => c.qualifiedName).sort()).toEqual([
74
- "app:nav:ops-orders",
75
- "app:nav:ops-shipments",
76
- ]);
77
- });
78
-
79
- test("sort order: `order` ASC, ties broken by qualifiedName alphabetic", () => {
80
- const source = buildSource([
81
- { id: "app:nav:alpha", label: "A", order: 10 },
82
- { id: "app:nav:beta", label: "B", order: 5 },
83
- { id: "app:nav:charlie", label: "C", order: 5 }, // same order as beta
84
- ]);
85
-
86
- const tree = resolveNavigation({ source });
87
-
88
- // beta (order=5) and charlie (order=5) first, both alphabetical, then alpha
89
- expect(tree.map((n) => n.qualifiedName)).toEqual([
90
- "app:nav:beta",
91
- "app:nav:charlie",
92
- "app:nav:alpha",
93
- ]);
94
- });
95
-
96
- test("access: role-gated entries drop out for non-matching users", () => {
97
- const source = buildSource([
98
- { id: "app:nav:public", label: "Public" },
99
- { id: "app:nav:admin-only", label: "Admin Area", access: { roles: ["Admin"] } },
100
- ]);
101
-
102
- const adminTree = resolveNavigation({ source, user: userAdmin });
103
- expect(adminTree.map((n) => n.qualifiedName).sort()).toEqual([
104
- "app:nav:admin-only",
105
- "app:nav:public",
106
- ]);
107
-
108
- const stdTree = resolveNavigation({ source, user: userStandard });
109
- expect(stdTree.map((n) => n.qualifiedName)).toEqual(["app:nav:public"]);
110
- });
111
-
112
- test("access: openToAll bypasses user-role check (matches handler-access semantic)", () => {
113
- const source = buildSource([
114
- {
115
- id: "app:nav:help",
116
- label: "Help",
117
- access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
118
- },
119
- ]);
120
-
121
- // Anonymous sees it.
122
- expect(resolveNavigation({ source })).toHaveLength(1);
123
- // Standard user sees it.
124
- expect(resolveNavigation({ source, user: userStandard })).toHaveLength(1);
125
- });
126
-
127
- test("access: no user + role-gated entry → dropped (anonymous can't satisfy roles)", () => {
128
- const source = buildSource([
129
- { id: "app:nav:gated", label: "Gated", access: { roles: ["Admin"] } },
130
- ]);
131
-
132
- expect(resolveNavigation({ source })).toHaveLength(0);
133
- });
134
-
135
- test("hidden parent hides descendants (no orphaned children)", () => {
136
- // "Reports" is Admin-only; the child "Sales Report" has no access rule
137
- // (would be publicly visible by itself). With the parent gone, the
138
- // child drops too — the resolver never recurses into a hidden node,
139
- // so a lone link without its containing group can't appear.
140
- const source = buildSource([
141
- { id: "app:nav:reports", label: "Reports", access: { roles: ["Admin"] } },
142
- {
143
- id: "app:nav:sales-report",
144
- label: "Sales",
145
- parent: "app:nav:reports",
146
- screen: "reports:screen:sales",
147
- },
148
- ]);
149
-
150
- const tree = resolveNavigation({ source, user: userStandard });
151
- expect(tree).toHaveLength(0);
152
- });
153
-
154
- test("entries pass through label/icon/screen verbatim — renderer translates label", () => {
155
- const source = buildSource([
156
- {
157
- id: "app:nav:orders",
158
- label: "orders:i18n:nav.orders",
159
- icon: "package",
160
- screen: "orders:screen:list",
161
- order: 1,
162
- },
163
- ]);
164
-
165
- const [node] = resolveNavigation({ source });
166
- expect(node?.label).toBe("orders:i18n:nav.orders");
167
- expect(node?.icon).toBe("package");
168
- expect(node?.screen).toBe("orders:screen:list");
169
- expect(node?.order).toBe(1);
170
- });
171
-
172
- test("order default is 0 on the NavNode when not provided", () => {
173
- const source = buildSource([{ id: "app:nav:x", label: "X" }]);
174
- expect(resolveNavigation({ source })[0]?.order).toBe(0);
175
- });
176
-
177
- test("dangling parent ref → child never reached (boot-validator should catch upstream)", () => {
178
- // "orphan" has parent="missing"; "missing" is not registered.
179
- // Because the walk is top-down, orphan sits only in byParent("missing")
180
- // and byParent is never called for "missing" (it's not in topLevel,
181
- // it's not reachable). So the child naturally drops.
182
- const source = buildSource([
183
- { id: "app:nav:orphan", label: "Orphan", parent: "app:nav:missing" },
184
- ]);
185
-
186
- expect(resolveNavigation({ source })).toHaveLength(0);
187
- });
188
-
189
- test("empty registry → empty tree", () => {
190
- const source: NavRegistrySlice = { topLevel: [], byParent: () => [] };
191
- expect(resolveNavigation({ source })).toEqual([]);
192
- });
193
-
194
- test("deeper nesting: three levels compose correctly", () => {
195
- const source = buildSource([
196
- { id: "a:nav:root", label: "Root" },
197
- { id: "a:nav:mid", label: "Mid", parent: "a:nav:root" },
198
- { id: "a:nav:leaf", label: "Leaf", parent: "a:nav:mid", screen: "a:screen:x" },
199
- ]);
200
-
201
- const tree = resolveNavigation({ source });
202
- expect(tree[0]?.qualifiedName).toBe("a:nav:root");
203
- expect(tree[0]?.children[0]?.qualifiedName).toBe("a:nav:mid");
204
- expect(tree[0]?.children[0]?.children[0]?.qualifiedName).toBe("a:nav:leaf");
205
- });
206
-
207
- // Visual-Tree-Merge: die polymorphen Felder (target/actions/createAction/
208
- // provider) müssen durch resolveNavigation durchgereicht werden, sonst
209
- // kann der eine Renderer dynamische/dispatch-Knoten nicht bauen.
210
- test("polymorphic fields pass through: target, actions, createAction, provider", () => {
211
- const editTarget = { featureId: "text-content", action: "edit", args: { slug: "hero" } };
212
- const createTarget = { featureId: "text-content", action: "create", args: { folder: "page" } };
213
- const source = buildSource([
214
- {
215
- id: "tc:nav:content",
216
- label: "Content",
217
- provider: true,
218
- createAction: { icon: "plus", label: "New page", target: createTarget },
219
- },
220
- {
221
- id: "tc:nav:hero",
222
- label: "Hero",
223
- parent: "tc:nav:content",
224
- target: editTarget,
225
- actions: [{ icon: "trash", label: "Delete", target: editTarget }],
226
- },
227
- ]);
228
-
229
- const tree = resolveNavigation({ source });
230
- const content = tree[0];
231
- expect(content?.provider).toBe(true);
232
- expect(content?.createAction?.target).toEqual(createTarget);
233
- const hero = content?.children[0];
234
- expect(hero?.target).toEqual(editTarget);
235
- expect(hero?.screen).toBeUndefined();
236
- expect(hero?.actions).toHaveLength(1);
237
- });
238
-
239
- // fw#2750: a TreeAction can also carry `screen` (route link) instead of
240
- // `target` (dispatch) — same polymorphism as the node itself.
241
- test("action `screen` (route-link variant) passes through alongside target-based actions", () => {
242
- const editTarget = { featureId: "text-content", action: "edit", args: { slug: "hero" } };
243
- const source = buildSource([
244
- {
245
- id: "tc:nav:content",
246
- label: "Content",
247
- provider: true,
248
- createAction: { icon: "plus", label: "New page", screen: "tc:screen:new-page" },
249
- },
250
- {
251
- id: "tc:nav:hero",
252
- label: "Hero",
253
- parent: "tc:nav:content",
254
- target: editTarget,
255
- actions: [{ icon: "edit", label: "Edit in editor", screen: "tc:screen:hero" }],
256
- },
257
- ]);
258
-
259
- const tree = resolveNavigation({ source });
260
- const content = tree[0];
261
- expect(content?.createAction?.screen).toBe("tc:screen:new-page");
262
- expect(content?.createAction?.target).toBeUndefined();
263
- const hero = content?.children[0];
264
- expect(hero?.actions?.[0]?.screen).toBe("tc:screen:hero");
265
- expect(hero?.actions?.[0]?.target).toBeUndefined();
266
- });
267
-
268
- // A node without the polymorphic fields must NOT carry them as
269
- // undefined-valued keys (conditional-spread): keeps the resolved node
270
- // clean and snapshot-stable.
271
- test("absent polymorphic fields are omitted, not set to undefined", () => {
272
- const source = buildSource([{ id: "a:nav:plain", label: "Plain", screen: "a:screen:x" }]);
273
- const node = resolveNavigation({ source })[0];
274
- expect(node).not.toBeUndefined();
275
- expect("target" in (node ?? {})).toBe(false);
276
- expect("provider" in (node ?? {})).toBe(false);
277
- expect("actions" in (node ?? {})).toBe(false);
278
- });
279
- });
package/src/nav/index.ts DELETED
@@ -1,8 +0,0 @@
1
- export { resolveNavigation } from "./resolve";
2
- export type {
3
- NavDefinition,
4
- NavNode,
5
- NavRegistrySlice,
6
- NavTree,
7
- ResolveNavigationOptions,
8
- } from "./types";
@@ -1,60 +0,0 @@
1
- import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import type { NavNode, NavTree, ResolveNavigationOptions } from "./types";
4
-
5
- // Assembles the renderable nav tree from the registry's pre-grouped
6
- // indexes (topLevel + byParent). Walks top-down: each node is
7
- // access-checked; a hidden parent drops its entire subtree implicitly
8
- // because we never recurse into it. Siblings sort by `order` (ascending,
9
- // default 0), tie-broken by qualified name so renders stay deterministic
10
- // across registry iteration orders.
11
- //
12
- // Pure — same inputs produce the same tree. The renderer memoizes the
13
- // result and only recomputes when the registry changes (rare — boot
14
- // only) or the user's roles change (logout/login, tenant-switch).
15
- export function resolveNavigation(options: ResolveNavigationOptions): NavTree {
16
- const { source, user } = options;
17
-
18
- function build(entry: NavDefinition): NavNode | null {
19
- // Shared default-visible UI predicate, not hasAccess from the engine:
20
- // that module pulls server-side deps and would break bundle purity.
21
- if (!isUiAccessGranted(entry.access, user?.roles)) return null;
22
- // `entry.id` is already the qualified name — the registry stores
23
- // it that way. No reverse-index lookup needed.
24
- const children: NavNode[] = [];
25
- for (const child of source.byParent(entry.id)) {
26
- const node = build(child);
27
- if (node !== null) children.push(node);
28
- }
29
- children.sort(bySortKey);
30
- return {
31
- qualifiedName: entry.id,
32
- label: entry.label,
33
- order: entry.order ?? 0,
34
- children,
35
- ...(entry.icon !== undefined && { icon: entry.icon }),
36
- ...(entry.screen !== undefined && { screen: entry.screen }),
37
- ...(entry.target !== undefined && { target: entry.target }),
38
- ...(entry.actions !== undefined && { actions: entry.actions }),
39
- ...(entry.createAction !== undefined && { createAction: entry.createAction }),
40
- ...(entry.provider !== undefined && { provider: entry.provider }),
41
- };
42
- }
43
-
44
- const roots: NavNode[] = [];
45
- for (const entry of source.topLevel) {
46
- const node = build(entry);
47
- if (node !== null) roots.push(node);
48
- }
49
- roots.sort(bySortKey);
50
- return roots;
51
- }
52
-
53
- function bySortKey(a: NavNode, b: NavNode): number {
54
- // Primary key: `order` ascending. Secondary: qualifiedName alphabetic,
55
- // which is a stable fallback — the registry-iteration order isn't
56
- // guaranteed across boots, so tied-order entries would otherwise
57
- // shuffle between renders.
58
- if (a.order !== b.order) return a.order - b.order;
59
- return a.qualifiedName.localeCompare(b.qualifiedName);
60
- }
package/src/nav/types.ts DELETED
@@ -1,70 +0,0 @@
1
- import type { NavDefinition, TargetRef, TreeAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
-
3
- // A single resolved nav entry as the renderer consumes it. Labels are NOT
4
- // translated yet — nav can be used in SSR contexts where the locale isn't
5
- // known at resolve time; the renderer runs `label` through
6
- // LocaleResolver.translate() when it draws the sidebar/topbar item.
7
- //
8
- // Identity: `qualifiedName` is the same QN the registry uses
9
- // ("{feature}:nav:{id}"). Renderers key off this for active-route tracking
10
- // and for `parent` matching in screen extensions (M4).
11
- export type NavNode = {
12
- readonly qualifiedName: string;
13
- readonly label: string;
14
- readonly icon?: string;
15
- readonly screen?: string;
16
- // Polymorphes Klick-Ziel (EditorPanel via dispatch) — Alternative zu
17
- // `screen`. Knoten trägt screen XOR target.
18
- readonly target?: TargetRef;
19
- // Hover-Actions + „+"-Affordance, durchgereicht aus der NavDefinition.
20
- readonly actions?: readonly TreeAction[];
21
- readonly createAction?: TreeAction;
22
- // true → Children kommen zur Laufzeit aus einem nav-provider (lazy +
23
- // SSE-live), nicht aus `children`. Renderer macht den Knoten expandable.
24
- readonly provider?: boolean;
25
- readonly order: number;
26
- readonly children: readonly NavNode[];
27
- };
28
-
29
- // The tree returned by resolveNavigation. Empty when the user can see no
30
- // entries — renderer draws a fallback ("no navigation available" or just
31
- // a blank sidebar, app decision).
32
- export type NavTree = readonly NavNode[];
33
-
34
- // Minimal registry surface resolveNavigation reads from. Deliberately
35
- // narrower than the full Registry — keeps the resolver test-friendly
36
- // (callers can stub with a two-field plain object) and pins the
37
- // coupling to just the two index-accessors the resolver actually uses:
38
- //
39
- // - `topLevel` : roots list (entries with no parent). Pre-grouped in
40
- // the registry — resolver starts its walk here.
41
- // - `byParent` : O(1) children lookup by parent qualified name. The
42
- // walk descends into this for each node; access-gating
43
- // happens top-down, so a hidden parent implicitly
44
- // hides the whole subtree.
45
- //
46
- // Note: the registry stores each NavDefinition with its `id` already
47
- // qualified ("feature:nav:short"); resolveNavigation reads that directly.
48
- //
49
- // Production callers pass:
50
- // { topLevel: registry.getTopLevelNavs(),
51
- // byParent: (qn) => registry.getNavsByParent(qn) }
52
- export type NavRegistrySlice = {
53
- readonly topLevel: readonly NavDefinition[];
54
- readonly byParent: (parentQualifiedName: string) => readonly NavDefinition[];
55
- };
56
-
57
- // Options passed to resolveNavigation.
58
- export type ResolveNavigationOptions = {
59
- readonly source: NavRegistrySlice;
60
- // Current session user. Access-rule enforcement compares
61
- // rule.roles against user.roles. When user is undefined, ONLY
62
- // entries with access.openToAll === true (or no access declared)
63
- // survive — matches the "anonymous visit" semantic.
64
- readonly user?: {
65
- readonly id: string;
66
- readonly roles: readonly string[];
67
- };
68
- };
69
-
70
- export type { NavDefinition };
@@ -1,139 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import { createStore } from "../create-store";
3
-
4
- describe("createStore — snapshot & setState", () => {
5
- test("getSnapshot returns the initial value", () => {
6
- const store = createStore({ count: 0 });
7
- expect(store.getSnapshot()).toEqual({ count: 0 });
8
- });
9
-
10
- test("setState with a value updates the snapshot", () => {
11
- const store = createStore({ count: 0 });
12
- store.setState({ count: 5 });
13
- expect(store.getSnapshot()).toEqual({ count: 5 });
14
- });
15
-
16
- test("setState with a reducer receives the current snapshot", () => {
17
- const store = createStore({ count: 3 });
18
- store.setState((prev) => ({ count: prev.count + 1 }));
19
- expect(store.getSnapshot()).toEqual({ count: 4 });
20
- });
21
-
22
- test("getSnapshot returns stable reference when value unchanged (Object.is gate)", () => {
23
- const initial = { count: 0 };
24
- const store = createStore(initial);
25
- const before = store.getSnapshot();
26
- store.setState(initial); // same reference
27
- expect(store.getSnapshot()).toBe(before);
28
- });
29
- });
30
-
31
- describe("createStore — subscribe & notify", () => {
32
- test("subscribed listener fires on setState", () => {
33
- const store = createStore({ count: 0 });
34
- const listener = mock();
35
- store.subscribe(listener);
36
-
37
- store.setState({ count: 1 });
38
-
39
- expect(listener).toHaveBeenCalledTimes(1);
40
- });
41
-
42
- test("multiple listeners all fire on setState", () => {
43
- const store = createStore({ count: 0 });
44
- const a = mock();
45
- const b = mock();
46
- store.subscribe(a);
47
- store.subscribe(b);
48
-
49
- store.setState({ count: 1 });
50
-
51
- expect(a).toHaveBeenCalledTimes(1);
52
- expect(b).toHaveBeenCalledTimes(1);
53
- });
54
-
55
- test("unsubscribe stops further notifications", () => {
56
- const store = createStore({ count: 0 });
57
- const listener = mock();
58
- const unsub = store.subscribe(listener);
59
-
60
- store.setState({ count: 1 });
61
- unsub();
62
- store.setState({ count: 2 });
63
-
64
- expect(listener).toHaveBeenCalledTimes(1);
65
- });
66
-
67
- test("Object.is-equal setState does NOT notify listeners", () => {
68
- const store = createStore({ count: 0 });
69
- const same = store.getSnapshot();
70
- const listener = mock();
71
- store.subscribe(listener);
72
-
73
- // Same reference — gate blocks notification.
74
- store.setState(same);
75
- // Primitive-equal setState on primitive-valued store also no-ops.
76
- const primStore = createStore(42);
77
- const primListener = mock();
78
- primStore.subscribe(primListener);
79
- primStore.setState(42);
80
-
81
- expect(listener).not.toHaveBeenCalled();
82
- expect(primListener).not.toHaveBeenCalled();
83
- });
84
-
85
- test("setState inside reducer that returns same ref does NOT notify", () => {
86
- const store = createStore({ count: 0 });
87
- const listener = mock();
88
- store.subscribe(listener);
89
-
90
- store.setState((prev) => prev); // reducer returns same ref
91
-
92
- expect(listener).not.toHaveBeenCalled();
93
- });
94
- });
95
-
96
- describe("createStore — reentrancy safety", () => {
97
- test("listener unsubscribing itself during callback does not break iteration", () => {
98
- const store = createStore({ count: 0 });
99
- const order: string[] = [];
100
-
101
- const unsubA = store.subscribe(() => {
102
- order.push("a");
103
- unsubA(); // self-unsubscribe mid-loop
104
- });
105
- store.subscribe(() => {
106
- order.push("b");
107
- });
108
-
109
- store.setState({ count: 1 });
110
-
111
- // Both fire on this cycle; A must not prevent B from running.
112
- expect(order).toEqual(["a", "b"]);
113
-
114
- // A is gone; only B fires on the next cycle.
115
- order.length = 0;
116
- store.setState({ count: 2 });
117
- expect(order).toEqual(["b"]);
118
- });
119
-
120
- test("listener unsubscribing ANOTHER listener mid-loop does not re-invoke it", () => {
121
- const store = createStore({ count: 0 });
122
- const order: string[] = [];
123
-
124
- let unsubB: (() => void) | null = null;
125
-
126
- store.subscribe(() => {
127
- order.push("a");
128
- unsubB?.(); // kill B before its turn
129
- });
130
- unsubB = store.subscribe(() => {
131
- order.push("b");
132
- });
133
-
134
- store.setState({ count: 1 });
135
-
136
- // B was unsubscribed by A before iteration reached it.
137
- expect(order).toEqual(["a"]);
138
- });
139
- });
@@ -1,66 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { shallowEqual } from "../equality";
3
-
4
- describe("shallowEqual — primitive cases", () => {
5
- test("Object.is-equal primitives are equal", () => {
6
- expect(shallowEqual(1, 1)).toBe(true);
7
- expect(shallowEqual("a", "a")).toBe(true);
8
- expect(shallowEqual(true, true)).toBe(true);
9
- expect(shallowEqual(null, null)).toBe(true);
10
- expect(shallowEqual(undefined, undefined)).toBe(true);
11
- });
12
-
13
- test("NaN equals NaN (Object.is semantics)", () => {
14
- expect(shallowEqual(Number.NaN, Number.NaN)).toBe(true);
15
- });
16
-
17
- test("differing primitives are not equal", () => {
18
- expect(shallowEqual(1, 2)).toBe(false);
19
- expect(shallowEqual("a", "b")).toBe(false);
20
- expect(shallowEqual(0, -0)).toBe(false); // Object.is distinguishes
21
- });
22
-
23
- test("primitive vs object is not equal", () => {
24
- expect(shallowEqual(1, { 0: 1 })).toBe(false);
25
- expect(shallowEqual(null, {})).toBe(false);
26
- expect(shallowEqual({}, null)).toBe(false);
27
- });
28
- });
29
-
30
- describe("shallowEqual — object cases", () => {
31
- test("same reference is equal", () => {
32
- const o = { a: 1 };
33
- expect(shallowEqual(o, o)).toBe(true);
34
- });
35
-
36
- test("same keys + same values → equal", () => {
37
- expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
38
- });
39
-
40
- test("same keys, different value → not equal", () => {
41
- expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false);
42
- });
43
-
44
- test("different number of keys → not equal", () => {
45
- expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
46
- expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false);
47
- });
48
-
49
- test("same count, different key names → not equal", () => {
50
- expect(shallowEqual({ a: 1 }, { b: 1 })).toBe(false);
51
- });
52
-
53
- test("nested object: only top-level refs compared (shallow)", () => {
54
- const inner = { x: 1 };
55
- // Same nested ref → equal at top level.
56
- expect(shallowEqual({ inner }, { inner })).toBe(true);
57
- // Equivalent but distinct nested ref → NOT equal (shallow doesn't recurse).
58
- expect(shallowEqual({ inner: { x: 1 } }, { inner: { x: 1 } })).toBe(false);
59
- });
60
-
61
- test("arrays compared as objects: same length + same values → equal", () => {
62
- expect(shallowEqual([1, 2, 3], [1, 2, 3])).toBe(true);
63
- expect(shallowEqual([1, 2, 3], [1, 2, 4])).toBe(false);
64
- expect(shallowEqual([1, 2], [1, 2, 3])).toBe(false);
65
- });
66
- });
@@ -1,3 +0,0 @@
1
- export { createStore } from "./create-store";
2
- export { shallowEqual } from "./equality";
3
- export type { Store, WritableStore } from "./types";
@@ -1,27 +0,0 @@
1
- // Store contract — Pull-Style Subscribe/Emit matching React's
2
- // useSyncExternalStore signature. The single canonical shape for
3
- // every stateful controller in ui-core/renderer land (form,
4
- // dispatcher-status, locale, token, nav, lifecycle).
5
- //
6
- // Rationale in docs/plans/ui-store.md. Not a new state-management
7
- // decision — ui-decisions.md already picked Subscribe/Emit; this
8
- // module just materializes the pattern instead of rolling a new
9
- // Set<() => void> per controller.
10
-
11
- export type Store<T> = {
12
- // Current snapshot. Referentially stable across no-op setState calls
13
- // (see createStore's Object.is gate) so useSyncExternalStore doesn't
14
- // re-render consumers when nothing actually changed.
15
- getSnapshot(): T;
16
- // Subscribe listener, returns unsubscribe. Listener receives no
17
- // payload — it reads via getSnapshot(). Direct match for
18
- // useSyncExternalStore's contract.
19
- subscribe(listener: () => void): () => void;
20
- };
21
-
22
- export type WritableStore<T> = Store<T> & {
23
- // Update with a new value or a reducer function. If the resulting
24
- // snapshot is Object.is-equal to the current one, listeners are NOT
25
- // notified — re-render prevention at the source.
26
- setState(next: T | ((prev: T) => T)): void;
27
- };