hcifootprint 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +524 -0
- package/dist/atom/types.d.ts +618 -0
- package/dist/atom/types.d.ts.map +1 -0
- package/dist/atom/types.js +2 -0
- package/dist/atom/types.js.map +1 -0
- package/dist/graph/builder.d.ts +31 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +165 -0
- package/dist/graph/builder.js.map +1 -0
- package/dist/graph/guards.d.ts +31 -0
- package/dist/graph/guards.d.ts.map +1 -0
- package/dist/graph/guards.js +94 -0
- package/dist/graph/guards.js.map +1 -0
- package/dist/graph/skill-deps.d.ts +20 -0
- package/dist/graph/skill-deps.d.ts.map +1 -0
- package/dist/graph/skill-deps.js +15 -0
- package/dist/graph/skill-deps.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +15 -0
- package/dist/mcp.js.map +1 -0
- package/dist/presence/presence.d.ts +50 -0
- package/dist/presence/presence.d.ts.map +1 -0
- package/dist/presence/presence.js +126 -0
- package/dist/presence/presence.js.map +1 -0
- package/dist/registry/registry.d.ts +53 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/registry.js +76 -0
- package/dist/registry/registry.js.map +1 -0
- package/dist/serve/mcp-server.d.ts +40 -0
- package/dist/serve/mcp-server.d.ts.map +1 -0
- package/dist/serve/mcp-server.js +72 -0
- package/dist/serve/mcp-server.js.map +1 -0
- package/dist/serve/mcp.d.ts +12 -0
- package/dist/serve/mcp.d.ts.map +1 -0
- package/dist/serve/mcp.js +62 -0
- package/dist/serve/mcp.js.map +1 -0
- package/dist/serve/modes.d.ts +66 -0
- package/dist/serve/modes.d.ts.map +1 -0
- package/dist/serve/modes.js +347 -0
- package/dist/serve/modes.js.map +1 -0
- package/dist/testing/harness.d.ts +156 -0
- package/dist/testing/harness.d.ts.map +1 -0
- package/dist/testing/harness.js +416 -0
- package/dist/testing/harness.js.map +1 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/lint.d.ts +20 -0
- package/dist/testing/lint.d.ts.map +1 -0
- package/dist/testing/lint.js +18 -0
- package/dist/testing/lint.js.map +1 -0
- package/dist/testing/model/check.d.ts +30 -0
- package/dist/testing/model/check.d.ts.map +1 -0
- package/dist/testing/model/check.js +89 -0
- package/dist/testing/model/check.js.map +1 -0
- package/dist/testing/model/lint.d.ts +54 -0
- package/dist/testing/model/lint.d.ts.map +1 -0
- package/dist/testing/model/lint.js +235 -0
- package/dist/testing/model/lint.js.map +1 -0
- package/dist/testing/model/satisfiable.d.ts +25 -0
- package/dist/testing/model/satisfiable.d.ts.map +1 -0
- package/dist/testing/model/satisfiable.js +101 -0
- package/dist/testing/model/satisfiable.js.map +1 -0
- package/dist/traverse/nav-session.d.ts +108 -0
- package/dist/traverse/nav-session.d.ts.map +1 -0
- package/dist/traverse/nav-session.js +637 -0
- package/dist/traverse/nav-session.js.map +1 -0
- package/dist/traverse/session.d.ts +239 -0
- package/dist/traverse/session.d.ts.map +1 -0
- package/dist/traverse/session.js +1305 -0
- package/dist/traverse/session.js.map +1 -0
- package/dist/tree/appmap.d.ts +8 -0
- package/dist/tree/appmap.d.ts.map +1 -0
- package/dist/tree/appmap.js +259 -0
- package/dist/tree/appmap.js.map +1 -0
- package/dist/tree/types.d.ts +151 -0
- package/dist/tree/types.d.ts.map +1 -0
- package/dist/tree/types.js +2 -0
- package/dist/tree/types.js.map +1 -0
- package/package.json +90 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { detectSchema } from 'footprintjs';
|
|
2
|
+
import { SkillGraphValidationError, composeGuards, validateGuardShape } from '../graph/guards.js';
|
|
3
|
+
import { PresenceIndex } from '../presence/presence.js';
|
|
4
|
+
import { Session } from './session.js';
|
|
5
|
+
export class InteractionSession extends Session {
|
|
6
|
+
#map;
|
|
7
|
+
#presence = new PresenceIndex();
|
|
8
|
+
/** Deepest node evidenced by sync()/fire(). Registration NEVER writes this. */
|
|
9
|
+
#focusPath;
|
|
10
|
+
/**
|
|
11
|
+
* Mount-declared tool overlay: qualified id → a STACK of declarations
|
|
12
|
+
* (newest last = the served one). A stack, not a slot: two components may
|
|
13
|
+
* declare the same tool (StrictMode, list twins); releasing one must never
|
|
14
|
+
* delete the tool out from under the other — the newest survivor serves.
|
|
15
|
+
*/
|
|
16
|
+
#dynamic = new Map();
|
|
17
|
+
#mergedSpec = null;
|
|
18
|
+
/** Foreign (off-router-page) registrations: node path → first seen (ms). */
|
|
19
|
+
#foreignSeen = new Map();
|
|
20
|
+
/** Overlay modals shown by presence alone (no signal): path → first seen (ms). */
|
|
21
|
+
#overlaySeen = new Map();
|
|
22
|
+
#warnedOnce = new Set();
|
|
23
|
+
#graceMs;
|
|
24
|
+
/** Clock for the grace timers (injectable for deterministic staleness tests). */
|
|
25
|
+
#now;
|
|
26
|
+
constructor(map, opts) {
|
|
27
|
+
const node = opts?.node ?? Object.keys(map.spec.pages)[0];
|
|
28
|
+
super(map.spec, { ...(opts ?? {}), node });
|
|
29
|
+
this.#map = map;
|
|
30
|
+
this.#focusPath = node;
|
|
31
|
+
this.#graceMs = opts?.dormantGraceMs ?? 3000;
|
|
32
|
+
this.#now = opts?.now ?? Date.now;
|
|
33
|
+
// Our fingerprint override reads the fields above — re-baseline now so the
|
|
34
|
+
// first real mutation (not construction itself) is what flushes a row.
|
|
35
|
+
this.resetStructureBaseline();
|
|
36
|
+
}
|
|
37
|
+
get spec() {
|
|
38
|
+
try {
|
|
39
|
+
if (this.#dynamic.size === 0)
|
|
40
|
+
return super.spec;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// super() is still constructing this instance — no overlay can exist yet.
|
|
44
|
+
return super.spec;
|
|
45
|
+
}
|
|
46
|
+
return (this.#mergedSpec ??= this.#buildMergedSpec());
|
|
47
|
+
}
|
|
48
|
+
#buildMergedSpec() {
|
|
49
|
+
const base = super.spec;
|
|
50
|
+
const affordances = { ...base.affordances };
|
|
51
|
+
for (const [qualifiedId, stack] of this.#dynamic) {
|
|
52
|
+
if (Object.hasOwn(base.affordances, qualifiedId))
|
|
53
|
+
continue; // declared-wins (warned at mount)
|
|
54
|
+
if (stack.length > 0)
|
|
55
|
+
affordances[qualifiedId] = stack[stack.length - 1].affordance;
|
|
56
|
+
}
|
|
57
|
+
return { ...base, affordances };
|
|
58
|
+
}
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// mount — one handle per rendered thing (presence + handlers + declarations)
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* Register a component's handlers/tools ON a node when it renders. You never
|
|
64
|
+
* name a group — this RETURNS a ToolGroupHandle that is the identity (with a
|
|
65
|
+
* generated `id`). Hold it in a ref; call `handle.unregister()` on unmount.
|
|
66
|
+
* `handle.setEnabled(toolId, false)` greys one tool out (a disabled button).
|
|
67
|
+
*/
|
|
68
|
+
registerToolGroup(path, opts) {
|
|
69
|
+
const node = this.#map.nodes[path];
|
|
70
|
+
if (!node) {
|
|
71
|
+
throw new Error(`hcifootprint: unknown node '${path}'. Known nodes: ${Object.keys(this.#map.nodes).join(', ')}.`);
|
|
72
|
+
}
|
|
73
|
+
if (opts?.instance !== undefined && !node.repeats) {
|
|
74
|
+
throw new Error(`hcifootprint: node '${path}' is not repeats: true — instance keys only apply to repeats containers.`);
|
|
75
|
+
}
|
|
76
|
+
const owner = Symbol(path);
|
|
77
|
+
const group = this.nextGroupId(`mount:${path}`);
|
|
78
|
+
const declaredHere = [];
|
|
79
|
+
// 1. Mount-declared leaf tools go into the overlay FIRST so handler
|
|
80
|
+
// resolution below (and every later lookup) sees them.
|
|
81
|
+
for (const [name, toolDef] of Object.entries(opts?.tools ?? {})) {
|
|
82
|
+
const qualifiedId = this.#declareMountTool(path, node, name, toolDef, owner, group, opts?.instance);
|
|
83
|
+
if (qualifiedId)
|
|
84
|
+
declaredHere.push(qualifiedId);
|
|
85
|
+
}
|
|
86
|
+
// 2. Bind existing app handlers (by reference) to declared tools.
|
|
87
|
+
for (const [name, handler] of Object.entries(opts?.handlers ?? {})) {
|
|
88
|
+
const qualifiedId = this.#resolveToolOnNode(path, name);
|
|
89
|
+
const enabled = opts?.enabled?.[name] ?? opts?.enabled?.[qualifiedId] ?? true;
|
|
90
|
+
this.registry.register(group, this.#registryKey(qualifiedId, opts?.instance), handler, enabled);
|
|
91
|
+
}
|
|
92
|
+
// 3. Presence + optional initial visibility signal.
|
|
93
|
+
const presenceHandle = this.#presence.open(path, opts?.instance);
|
|
94
|
+
if (opts?.visible !== undefined)
|
|
95
|
+
this.#presence.setVisible(path, opts.visible);
|
|
96
|
+
// 4. Dormancy bookkeeping: a mount outside the router-confirmed page is
|
|
97
|
+
// held, not offered — its clock starts now.
|
|
98
|
+
if (node.page !== this.node && !this.#foreignSeen.has(path)) {
|
|
99
|
+
this.#foreignSeen.set(path, this.#now());
|
|
100
|
+
}
|
|
101
|
+
this.noteStructureChange();
|
|
102
|
+
let released = false;
|
|
103
|
+
const unregister = () => {
|
|
104
|
+
if (released)
|
|
105
|
+
return;
|
|
106
|
+
released = true;
|
|
107
|
+
presenceHandle.release();
|
|
108
|
+
this.unregisterGroup(group);
|
|
109
|
+
let removedAny = false;
|
|
110
|
+
for (const qualifiedId of declaredHere) {
|
|
111
|
+
const stack = this.#dynamic.get(qualifiedId);
|
|
112
|
+
if (!stack)
|
|
113
|
+
continue;
|
|
114
|
+
const remaining = stack.filter((entry) => entry.owner !== owner);
|
|
115
|
+
if (remaining.length !== stack.length) {
|
|
116
|
+
if (remaining.length === 0)
|
|
117
|
+
this.#dynamic.delete(qualifiedId);
|
|
118
|
+
else
|
|
119
|
+
this.#dynamic.set(qualifiedId, remaining);
|
|
120
|
+
removedAny = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (removedAny)
|
|
124
|
+
this.#mergedSpec = null;
|
|
125
|
+
this.noteStructureChange();
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
id: group,
|
|
129
|
+
node: path,
|
|
130
|
+
...(opts?.instance !== undefined ? { instance: opts.instance } : {}),
|
|
131
|
+
// Map a leaf/qualified toolId to its registry key (instance-aware).
|
|
132
|
+
setEnabled: (toolId, enabled) => {
|
|
133
|
+
const qualifiedId = this.#resolveToolOnNode(path, toolId);
|
|
134
|
+
this.setToolEnabled(this.#registryKey(qualifiedId, opts?.instance), enabled);
|
|
135
|
+
},
|
|
136
|
+
unregister,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Register ONE tool on a node (convenience over registerToolGroup). `def`
|
|
141
|
+
* either binds an existing declared tool (`{ handler }`) or declares a new
|
|
142
|
+
* leaf here (`{ does, handler }`). Returns a single-tool handle.
|
|
143
|
+
*/
|
|
144
|
+
registerTool(path, toolId, def) {
|
|
145
|
+
// 'declared' covers BOTH node-scoped tools ('path.toolId') and root/
|
|
146
|
+
// multi-attach tools (bare 'toolId' offered on this page) — bind the handler
|
|
147
|
+
// to the existing tool; only declare a NEW leaf when neither exists.
|
|
148
|
+
const declared = this.spec.affordances[`${path}.${toolId}`] !== undefined ||
|
|
149
|
+
this.spec.affordances[toolId] !== undefined;
|
|
150
|
+
const group = declared
|
|
151
|
+
? this.registerToolGroup(path, { handlers: { [toolId]: def.handler } })
|
|
152
|
+
: this.registerToolGroup(path, { tools: { [toolId]: def } });
|
|
153
|
+
return {
|
|
154
|
+
id: group.id,
|
|
155
|
+
...(group.node !== undefined ? { node: group.node } : {}),
|
|
156
|
+
toolId,
|
|
157
|
+
setEnabled: (enabled) => group.setEnabled(toolId, enabled),
|
|
158
|
+
unregister: () => group.unregister(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
#declareMountTool(path, node, name, toolDef, owner, group, instance) {
|
|
162
|
+
if (/[.[\]#/|]/.test(name)) {
|
|
163
|
+
throw new SkillGraphValidationError(`mount('${path}') tool '${name}' contains a reserved character (. [ ] # / |).`);
|
|
164
|
+
}
|
|
165
|
+
const qualifiedId = `${path}.${name}`;
|
|
166
|
+
if (name === 'leave-skill') {
|
|
167
|
+
throw new SkillGraphValidationError(`tool name 'leave-skill' is reserved.`);
|
|
168
|
+
}
|
|
169
|
+
if (super.spec.affordances[qualifiedId]) {
|
|
170
|
+
// Declared-wins precedence: the central declaration is the audited one.
|
|
171
|
+
this.warn(`hcifootprint: mount('${path}') re-declares '${qualifiedId}' — the declared tool wins; ` +
|
|
172
|
+
`only the handler was bound.`);
|
|
173
|
+
if (toolDef.handler)
|
|
174
|
+
this.registry.register(group, this.#registryKey(qualifiedId, instance), toolDef.handler);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (!toolDef.does || !toolDef.does.trim()) {
|
|
178
|
+
throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' needs a 'does'.`);
|
|
179
|
+
}
|
|
180
|
+
if (toolDef.when) {
|
|
181
|
+
if (Object.keys(toolDef.when).length === 0) {
|
|
182
|
+
throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' has an empty when {} — omit it.`);
|
|
183
|
+
}
|
|
184
|
+
validateGuardShape(`mount-declared tool '${qualifiedId}' when`, toolDef.when);
|
|
185
|
+
}
|
|
186
|
+
if (toolDef.goTo && !super.spec.pages[toolDef.goTo]) {
|
|
187
|
+
throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' goTo unknown page '${toolDef.goTo}'.`);
|
|
188
|
+
}
|
|
189
|
+
if (toolDef.input !== undefined && detectSchema(toolDef.input) === 'none') {
|
|
190
|
+
throw new SkillGraphValidationError(`mount-declared tool '${qualifiedId}' has an unrecognized input schema.`);
|
|
191
|
+
}
|
|
192
|
+
const existing = this.#dynamic.get(qualifiedId);
|
|
193
|
+
if (existing && existing.length > 0) {
|
|
194
|
+
this.warn(`hcifootprint: '${qualifiedId}' mount-declared twice — the newest declaration serves (common ` +
|
|
195
|
+
`cause: a component mounted twice without releasing its handle).`);
|
|
196
|
+
}
|
|
197
|
+
const guard = composeGuards(qualifiedId, [
|
|
198
|
+
...this.#guardChain(path),
|
|
199
|
+
...(toolDef.when ? [toolDef.when] : []),
|
|
200
|
+
]);
|
|
201
|
+
const affordance = Object.freeze({
|
|
202
|
+
id: qualifiedId,
|
|
203
|
+
on: [node.page],
|
|
204
|
+
description: toolDef.does,
|
|
205
|
+
binding: toolDef.binding ? structuredClone(toolDef.binding) : undefined,
|
|
206
|
+
guard,
|
|
207
|
+
effect: toolDef.writes || toolDef.goTo
|
|
208
|
+
? {
|
|
209
|
+
...(toolDef.writes ? { writes: [...toolDef.writes] } : {}),
|
|
210
|
+
...(toolDef.goTo ? { navigatesTo: toolDef.goTo } : {}),
|
|
211
|
+
}
|
|
212
|
+
: undefined,
|
|
213
|
+
schema: toolDef.input,
|
|
214
|
+
highEffect: toolDef.confirm ?? false,
|
|
215
|
+
role: toolDef.role ?? (toolDef.goTo ? 'next' : 'action'),
|
|
216
|
+
descriptionSource: 'registration',
|
|
217
|
+
});
|
|
218
|
+
this.#dynamic.set(qualifiedId, [...(existing ?? []), { owner, affordance }]);
|
|
219
|
+
this.#mergedSpec = null;
|
|
220
|
+
if (toolDef.handler)
|
|
221
|
+
this.registry.register(group, this.#registryKey(qualifiedId, instance), toolDef.handler);
|
|
222
|
+
return qualifiedId;
|
|
223
|
+
}
|
|
224
|
+
#resolveToolOnNode(path, name) {
|
|
225
|
+
const qualified = `${path}.${name}`;
|
|
226
|
+
if (this.spec.affordances[qualified])
|
|
227
|
+
return qualified;
|
|
228
|
+
if (this.spec.affordances[name])
|
|
229
|
+
return name; // already-qualified id or root tool
|
|
230
|
+
throw new Error(`hcifootprint: mount('${path}') binds unknown tool '${name}' — declare it in appMap (or pass it in ` +
|
|
231
|
+
`mount({tools})). Known here: ${Object.keys(this.spec.affordances)
|
|
232
|
+
.filter((id) => id.startsWith(`${path}.`) || !id.includes('.'))
|
|
233
|
+
.join(', ')}.`);
|
|
234
|
+
}
|
|
235
|
+
#registryKey(qualifiedId, instance) {
|
|
236
|
+
return instance === undefined ? qualifiedId : `${qualifiedId}[${instance}]`;
|
|
237
|
+
}
|
|
238
|
+
#guardChain(path) {
|
|
239
|
+
const chain = [];
|
|
240
|
+
for (let cursor = this.#map.nodes[path]; cursor;) {
|
|
241
|
+
if (cursor.guard)
|
|
242
|
+
chain.unshift(cursor.guard);
|
|
243
|
+
cursor = cursor.parent ? this.#map.nodes[cursor.parent] : undefined;
|
|
244
|
+
}
|
|
245
|
+
return chain;
|
|
246
|
+
}
|
|
247
|
+
// -------------------------------------------------------------------------
|
|
248
|
+
// visibility wire — the one signal registration cannot infer
|
|
249
|
+
// -------------------------------------------------------------------------
|
|
250
|
+
setVisible(path, visible) {
|
|
251
|
+
this.#requireNode(path);
|
|
252
|
+
this.#presence.setVisible(path, visible);
|
|
253
|
+
this.noteStructureChange();
|
|
254
|
+
}
|
|
255
|
+
/** Show a node; for a tab this also hides its tab siblings (at most one shown). */
|
|
256
|
+
show(path) {
|
|
257
|
+
const node = this.#requireNode(path);
|
|
258
|
+
if (node.kind === 'tab' && node.parent) {
|
|
259
|
+
for (const siblingPath of this.#map.nodes[node.parent].children) {
|
|
260
|
+
const sibling = this.#map.nodes[siblingPath];
|
|
261
|
+
if (sibling.kind === 'tab')
|
|
262
|
+
this.#presence.setVisible(siblingPath, siblingPath === path);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
this.#presence.setVisible(path, true);
|
|
267
|
+
}
|
|
268
|
+
this.noteStructureChange();
|
|
269
|
+
}
|
|
270
|
+
#requireNode(path) {
|
|
271
|
+
const node = this.#map.nodes[path];
|
|
272
|
+
if (!node) {
|
|
273
|
+
throw new Error(`hcifootprint: unknown node '${path}'. Known nodes: ${Object.keys(this.#map.nodes).join(', ')}.`);
|
|
274
|
+
}
|
|
275
|
+
return node;
|
|
276
|
+
}
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// focus — evidence-based "you are here", with ancestor fallback
|
|
279
|
+
// -------------------------------------------------------------------------
|
|
280
|
+
get focus() {
|
|
281
|
+
let cursor = this.#focusPath;
|
|
282
|
+
while (true) {
|
|
283
|
+
const node = this.#map.nodes[cursor];
|
|
284
|
+
if (!node || node.page !== this.node)
|
|
285
|
+
return this.node; // stale/off-graph focus → page floor
|
|
286
|
+
if (node.kind === 'page')
|
|
287
|
+
return cursor;
|
|
288
|
+
const gate = this.#gateNode(cursor);
|
|
289
|
+
if (gate.served)
|
|
290
|
+
return cursor;
|
|
291
|
+
cursor = node.parent ?? this.node; // nearest active ancestor = auto-resume
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
// the fused activation model
|
|
296
|
+
// -------------------------------------------------------------------------
|
|
297
|
+
/**
|
|
298
|
+
* Shown blocking modals on the current page: signal true, or present without
|
|
299
|
+
* a contrary signal — AND whose ancestor chain allows visibility. A modal
|
|
300
|
+
* kept mounted inside a hidden tab is NOT shown and must not mask anything.
|
|
301
|
+
*/
|
|
302
|
+
#shownOverlays() {
|
|
303
|
+
const shown = [];
|
|
304
|
+
for (const node of Object.values(this.#map.nodes)) {
|
|
305
|
+
if (!node.overlay || node.page !== this.node)
|
|
306
|
+
continue;
|
|
307
|
+
const visibility = this.#presence.visibility(node.path);
|
|
308
|
+
const present = this.#presence.isPresent(node.path);
|
|
309
|
+
const selfShown = visibility === true || (visibility === undefined && present);
|
|
310
|
+
const ancestorsAllow = node.parent === null || this.#gateChain(node.parent).served;
|
|
311
|
+
if (selfShown && ancestorsAllow) {
|
|
312
|
+
shown.push(node.path);
|
|
313
|
+
if (visibility === undefined)
|
|
314
|
+
this.#overlayGraceCheck(node.path);
|
|
315
|
+
else
|
|
316
|
+
this.#overlaySeen.delete(node.path);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
this.#overlaySeen.delete(node.path);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return shown;
|
|
323
|
+
}
|
|
324
|
+
/** Overlay self-defense: a modal masking its page on presence alone, past grace, names the missing wire. */
|
|
325
|
+
#overlayGraceCheck(path) {
|
|
326
|
+
const firstSeen = this.#overlaySeen.get(path);
|
|
327
|
+
if (firstSeen === undefined) {
|
|
328
|
+
this.#overlaySeen.set(path, this.#now());
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const warnKey = `overlay:${path}`;
|
|
332
|
+
if (this.#now() - firstSeen >= this.#graceMs && !this.#warnedOnce.has(warnKey)) {
|
|
333
|
+
this.#warnedOnce.add(warnKey);
|
|
334
|
+
this.warn(`hcifootprint: modal '${path}' has been masking its page on mount-presence alone for over ` +
|
|
335
|
+
`${this.#graceMs}ms with no visibility signal. If it is actually closed (kept mounted for ` +
|
|
336
|
+
`animation), wire session.setVisible('${path}', false) — mounts cannot see CSS.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
#gateNode(path) {
|
|
340
|
+
// Overlay masking first. With shown blocking modals, a node is served only
|
|
341
|
+
// if it lives inside AT LEAST ONE of them — "outside ANY shown modal"
|
|
342
|
+
// would make two simultaneously-shown modals mask each other (total page
|
|
343
|
+
// deadlock, both modals' own close buttons included).
|
|
344
|
+
const overlays = this.#shownOverlays();
|
|
345
|
+
if (overlays.length > 0) {
|
|
346
|
+
const inside = overlays.some((overlay) => path === overlay || path.startsWith(`${overlay}.`));
|
|
347
|
+
if (!inside)
|
|
348
|
+
return { served: false, reason: 'BLOCKED_BY_OVERLAY', overlay: overlays[0] };
|
|
349
|
+
}
|
|
350
|
+
return this.#gateChain(path);
|
|
351
|
+
}
|
|
352
|
+
/** The chain walk (visibility, modal-shown, tab prior) WITHOUT overlay masking. */
|
|
353
|
+
#gateChain(path) {
|
|
354
|
+
// Walk root → node; containers can only reject or degrade certainty.
|
|
355
|
+
const chain = [];
|
|
356
|
+
for (let cursor = this.#map.nodes[path]; cursor;) {
|
|
357
|
+
chain.unshift(cursor);
|
|
358
|
+
cursor = cursor.parent ? this.#map.nodes[cursor.parent] : undefined;
|
|
359
|
+
}
|
|
360
|
+
let presenceUnknown = false;
|
|
361
|
+
let activation = 'assumed';
|
|
362
|
+
for (const node of chain) {
|
|
363
|
+
const visibility = this.#presence.visibility(node.path);
|
|
364
|
+
if (visibility === false)
|
|
365
|
+
return { served: false, reason: 'NODE_NOT_VISIBLE', node: node.path };
|
|
366
|
+
const present = this.#presence.isPresent(node.path);
|
|
367
|
+
if (node.kind === 'page') {
|
|
368
|
+
activation = 'synced'; // router-confirmed
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (node.kind === 'modal') {
|
|
372
|
+
// NEVER assumed: closed until registered or shown.
|
|
373
|
+
if (visibility !== true && !present) {
|
|
374
|
+
return { served: false, reason: 'NODE_NOT_VISIBLE', node: node.path };
|
|
375
|
+
}
|
|
376
|
+
activation = visibility === true ? 'shown' : 'registered';
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (node.kind === 'tab') {
|
|
380
|
+
const gate = this.#gateTab(node, visibility, present);
|
|
381
|
+
if (!gate.served)
|
|
382
|
+
return gate;
|
|
383
|
+
activation = gate.activation;
|
|
384
|
+
presenceUnknown ||= gate.presenceUnknown ?? false;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
// areas / plain containers: mounted sharpens, signals decide, absence stays assumed
|
|
388
|
+
activation = visibility === true ? 'shown' : present ? 'registered' : 'assumed';
|
|
389
|
+
}
|
|
390
|
+
return { served: true, activation, ...(presenceUnknown ? { presenceUnknown: true } : {}) };
|
|
391
|
+
}
|
|
392
|
+
#gateTab(tab, visibility, present) {
|
|
393
|
+
if (visibility === true)
|
|
394
|
+
return { served: true, activation: 'shown' };
|
|
395
|
+
const siblings = tab.parent
|
|
396
|
+
? this.#map.nodes[tab.parent].children
|
|
397
|
+
.map((childPath) => this.#map.nodes[childPath])
|
|
398
|
+
.filter((child) => child.kind === 'tab')
|
|
399
|
+
: [tab];
|
|
400
|
+
// The exclusivity PRIOR: a sibling explicitly shown means this one is not.
|
|
401
|
+
const siblingShown = siblings.some((sibling) => sibling.path !== tab.path && this.#presence.visibility(sibling.path) === true);
|
|
402
|
+
if (siblingShown)
|
|
403
|
+
return { served: false, reason: 'NODE_NOT_VISIBLE', node: tab.path };
|
|
404
|
+
// Explicitly-hidden siblings are not candidates: with tab A mounted-but-
|
|
405
|
+
// signaled-hidden and tab B unmounted, B is the plausibly-shown one — a
|
|
406
|
+
// hidden mount must not make its siblings unreachable.
|
|
407
|
+
const mountedSiblings = siblings.filter((sibling) => this.#presence.isPresent(sibling.path) && this.#presence.visibility(sibling.path) !== false);
|
|
408
|
+
if (mountedSiblings.length === 0) {
|
|
409
|
+
// L0: nothing registers → the flagged union (assumed), never a guessed winner.
|
|
410
|
+
return { served: true, activation: 'assumed', presenceUnknown: siblings.length > 1 };
|
|
411
|
+
}
|
|
412
|
+
if (!present) {
|
|
413
|
+
// Mounts exist and none of them is this tab: it is really not there (yet).
|
|
414
|
+
return { served: false, reason: 'NODE_NOT_VISIBLE', node: tab.path };
|
|
415
|
+
}
|
|
416
|
+
if (mountedSiblings.length > 1) {
|
|
417
|
+
// Keep-mounted panels with no wire: serve the union, flagged, and say the one-line upgrade once.
|
|
418
|
+
const warnKey = `tabs:${tab.parent ?? tab.path}`;
|
|
419
|
+
if (!this.#warnedOnce.has(warnKey)) {
|
|
420
|
+
this.#warnedOnce.add(warnKey);
|
|
421
|
+
this.warn(`hcifootprint: ${mountedSiblings.length} tab siblings under '${tab.parent}' are mounted at once ` +
|
|
422
|
+
`and no visibility signal exists — serving all of them flagged presence:'unknown'. One line fixes ` +
|
|
423
|
+
`it: session.show('<the visible tab>') on tab change (or pass visible: at mount).`);
|
|
424
|
+
}
|
|
425
|
+
return { served: true, activation: 'registered', presenceUnknown: true };
|
|
426
|
+
}
|
|
427
|
+
return { served: true, activation: 'registered' };
|
|
428
|
+
}
|
|
429
|
+
// -------------------------------------------------------------------------
|
|
430
|
+
// available / fire / sync / brief — the Session surface, tree-gated
|
|
431
|
+
// -------------------------------------------------------------------------
|
|
432
|
+
available() {
|
|
433
|
+
this.#driftCheck();
|
|
434
|
+
const base = super.available();
|
|
435
|
+
const edges = [];
|
|
436
|
+
for (const edge of base.edges) {
|
|
437
|
+
const path = this.#pathOnCurrentPage(edge.affordanceId);
|
|
438
|
+
if (!path) {
|
|
439
|
+
edges.push(edge); // defensive: a projection tool with no tree node keeps v1 semantics
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const gate = this.#gateNode(path);
|
|
443
|
+
if (!gate.served)
|
|
444
|
+
continue; // fire() on it returns the typed reason — available() just omits
|
|
445
|
+
const node = this.#map.nodes[path];
|
|
446
|
+
const stamped = {
|
|
447
|
+
...edge,
|
|
448
|
+
node: path,
|
|
449
|
+
activation: gate.activation,
|
|
450
|
+
...(gate.presenceUnknown ? { presence: 'unknown' } : {}),
|
|
451
|
+
};
|
|
452
|
+
if (node.repeats) {
|
|
453
|
+
const existence = this.#instanceExistence(node);
|
|
454
|
+
stamped.instances = existence.keys.slice(0, 50); // render cap only — fireability is uncapped
|
|
455
|
+
stamped.enumeration = existence.enumeration;
|
|
456
|
+
}
|
|
457
|
+
edges.push(stamped);
|
|
458
|
+
}
|
|
459
|
+
return { ...base, edges };
|
|
460
|
+
}
|
|
461
|
+
fire(affordanceId, opts) {
|
|
462
|
+
const affordance = this.spec.affordances[affordanceId];
|
|
463
|
+
if (affordance) {
|
|
464
|
+
const path = this.#pathOnCurrentPage(affordanceId);
|
|
465
|
+
if (path) {
|
|
466
|
+
const gate = this.#gateNode(path);
|
|
467
|
+
if (!gate.served) {
|
|
468
|
+
this.recordRejection(affordanceId, gate.reason, opts.source);
|
|
469
|
+
return gate.reason === 'BLOCKED_BY_OVERLAY'
|
|
470
|
+
? { ok: false, reason: 'BLOCKED_BY_OVERLAY', overlay: gate.overlay }
|
|
471
|
+
: { ok: false, reason: 'NODE_NOT_VISIBLE', node: gate.node };
|
|
472
|
+
}
|
|
473
|
+
const node = this.#map.nodes[path];
|
|
474
|
+
if (node.repeats) {
|
|
475
|
+
const existence = this.#instanceExistence(node);
|
|
476
|
+
if (opts.instance === undefined) {
|
|
477
|
+
this.recordRejection(affordanceId, 'INSTANCE_REQUIRED', opts.source);
|
|
478
|
+
return { ok: false, reason: 'INSTANCE_REQUIRED', instances: existence.keys.slice(0, 50) };
|
|
479
|
+
}
|
|
480
|
+
// Membership against the FULL set — the render cap must never make
|
|
481
|
+
// instance #51 unfireable.
|
|
482
|
+
if (!existence.keys.includes(opts.instance)) {
|
|
483
|
+
this.recordRejection(affordanceId, 'INSTANCE_UNKNOWN', opts.source);
|
|
484
|
+
return { ok: false, reason: 'INSTANCE_UNKNOWN', instances: existence.keys.slice(0, 50) };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (gate.activation === 'assumed' &&
|
|
488
|
+
opts.invoke !== false &&
|
|
489
|
+
this.#presence.hasAny() &&
|
|
490
|
+
this.handlerFor(affordanceId, opts) === undefined) {
|
|
491
|
+
// The session runs on mounts, this node's have not arrived, and the
|
|
492
|
+
// caller wanted execution: retriable, and never a fake GUARD_FAILED.
|
|
493
|
+
this.recordRejection(affordanceId, 'STILL_MOUNTING', opts.source);
|
|
494
|
+
return { ok: false, reason: 'STILL_MOUNTING', node: path };
|
|
495
|
+
}
|
|
496
|
+
const result = super.fire(affordanceId, opts);
|
|
497
|
+
if (result.ok)
|
|
498
|
+
this.#focusPath = path; // fire evidence moves focus
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return super.fire(affordanceId, opts);
|
|
503
|
+
}
|
|
504
|
+
handlerFor(affordanceId, opts) {
|
|
505
|
+
if (opts.instance !== undefined) {
|
|
506
|
+
return (this.registry.handlerFor(this.#registryKey(affordanceId, opts.instance)) ??
|
|
507
|
+
this.registry.handlerFor(affordanceId));
|
|
508
|
+
}
|
|
509
|
+
return super.handlerFor(affordanceId, opts);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Instance-aware disabled gate: a per-row button registered disabled under
|
|
513
|
+
* 'id[instance]' must block, matching how handlerFor resolves it. Falls back
|
|
514
|
+
* to the base (non-instance) registration.
|
|
515
|
+
*/
|
|
516
|
+
isToolDisabled(affordanceId, opts) {
|
|
517
|
+
if (opts.instance !== undefined) {
|
|
518
|
+
const keyed = this.registry.isEnabled(this.#registryKey(affordanceId, opts.instance));
|
|
519
|
+
if (keyed !== undefined)
|
|
520
|
+
return keyed === false;
|
|
521
|
+
}
|
|
522
|
+
return this.registry.isEnabled(affordanceId) === false;
|
|
523
|
+
}
|
|
524
|
+
sync(observedNode, opts) {
|
|
525
|
+
const result = super.sync(observedNode, opts);
|
|
526
|
+
if (result.changed) {
|
|
527
|
+
this.#focusPath = this.node; // router evidence resets focus to the page
|
|
528
|
+
// Dormant registrations under the now-confirmed page become native…
|
|
529
|
+
for (const path of [...this.#foreignSeen.keys()]) {
|
|
530
|
+
if (this.#map.nodes[path]?.page === this.node)
|
|
531
|
+
this.#foreignSeen.delete(path);
|
|
532
|
+
}
|
|
533
|
+
// …and still-mounted nodes elsewhere start (or keep) their drift clocks.
|
|
534
|
+
for (const path of this.#presence.presentNodes()) {
|
|
535
|
+
const node = this.#map.nodes[path];
|
|
536
|
+
if (node && node.page !== this.node && !this.#foreignSeen.has(path)) {
|
|
537
|
+
this.#foreignSeen.set(path, this.#now());
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
contextBrief(opts) {
|
|
544
|
+
this.#driftCheck();
|
|
545
|
+
const brief = super.contextBrief(opts);
|
|
546
|
+
const lines = [];
|
|
547
|
+
if (this.focus !== this.node)
|
|
548
|
+
lines.push(`Focus: ${this.focus}.`);
|
|
549
|
+
const frontier = this.#presence
|
|
550
|
+
.presentNodes()
|
|
551
|
+
.filter((path) => this.#map.nodes[path]?.page === this.node)
|
|
552
|
+
.sort();
|
|
553
|
+
if (frontier.length > 0)
|
|
554
|
+
lines.push(`Mounted here: ${frontier.join(', ')}.`);
|
|
555
|
+
return lines.length > 0 ? { ...brief, text: `${brief.text}\n${lines.join('\n')}` } : brief;
|
|
556
|
+
}
|
|
557
|
+
// -------------------------------------------------------------------------
|
|
558
|
+
// internals
|
|
559
|
+
// -------------------------------------------------------------------------
|
|
560
|
+
/** The tool's node on the CURRENT page, or null (root tools resolve to the page node). */
|
|
561
|
+
#pathOnCurrentPage(affordanceId) {
|
|
562
|
+
const declared = this.#map.toolNodes[affordanceId];
|
|
563
|
+
if (declared) {
|
|
564
|
+
return declared.find((path) => this.#map.nodes[path]?.page === this.node) ?? null;
|
|
565
|
+
}
|
|
566
|
+
if (this.#dynamic.has(affordanceId)) {
|
|
567
|
+
const path = affordanceId.split('.').slice(0, -1).join('.');
|
|
568
|
+
return this.#map.nodes[path]?.page === this.node ? path : null;
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The FULL existence set (sanitized, unbounded). Fire-time membership checks
|
|
574
|
+
* run against ALL of it — instance #51 is real. Serving layers cap what they
|
|
575
|
+
* RENDER (50 keys on an edge), never what is fireable.
|
|
576
|
+
*/
|
|
577
|
+
#instanceExistence(node) {
|
|
578
|
+
if (node.instances) {
|
|
579
|
+
try {
|
|
580
|
+
const keys = node.instances(this.state());
|
|
581
|
+
if (Array.isArray(keys)) {
|
|
582
|
+
return { keys: keys.map(sanitizeKey), enumeration: 'selector' };
|
|
583
|
+
}
|
|
584
|
+
this.warn(`hcifootprint: instances source for '${node.path}' returned a non-array — using the mounted window.`);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
this.warn(`hcifootprint: instances source for '${node.path}' threw: ${String(error)} — using the mounted window.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
keys: this.#presence.instancesOf(node.path).map(sanitizeKey),
|
|
592
|
+
enumeration: 'mounted-window',
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/** Foreign registrations past grace = sensor drift: one dev warning + one gap row, per node. */
|
|
596
|
+
#driftCheck() {
|
|
597
|
+
const now = this.#now();
|
|
598
|
+
for (const [path, firstSeen] of [...this.#foreignSeen]) {
|
|
599
|
+
if (!this.#presence.isPresent(path) || this.#map.nodes[path]?.page === this.node) {
|
|
600
|
+
this.#foreignSeen.delete(path);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const warnKey = `foreign:${path}`;
|
|
604
|
+
if (now - firstSeen >= this.#graceMs && !this.#warnedOnce.has(warnKey)) {
|
|
605
|
+
this.#warnedOnce.add(warnKey);
|
|
606
|
+
this.warn(`hcifootprint: '${path}' has been mounted for over ${this.#graceMs}ms while the router says ` +
|
|
607
|
+
`'${this.node}' — its tools are dormant. Either sync() is missing a page change or a component ` +
|
|
608
|
+
`outlived its page (exit animation / portal). Recorded as a sensor-drift gap row.`);
|
|
609
|
+
this.reportGap({
|
|
610
|
+
request: `dormant registration: node '${path}' mounted while router-confirmed page is '${this.node}'`,
|
|
611
|
+
reason: 'sensor-drift',
|
|
612
|
+
principal: 'system',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
structureFingerprint() {
|
|
618
|
+
// Instance-keyed handler registrations ('id[key]') are EXCLUDED: a
|
|
619
|
+
// scrolling virtualized list must never bump the cursor or write rows.
|
|
620
|
+
// Enabled state IS included — a setEnabled flip changes the served surface.
|
|
621
|
+
const handlers = this.registry
|
|
622
|
+
.registrations()
|
|
623
|
+
// Enabled instance-keyed registrations ('id[key]') are excluded (scroll
|
|
624
|
+
// churn is not world motion) — but a DISABLED instance tool IS kept, so a
|
|
625
|
+
// per-row setEnabled(false) flip changes the fingerprint and flushes.
|
|
626
|
+
.filter((registration) => !registration.affordanceId.includes('[') || !registration.enabled)
|
|
627
|
+
.map((registration) => registration.affordanceId + (registration.enabled ? '' : ':off'))
|
|
628
|
+
.sort()
|
|
629
|
+
.join('|');
|
|
630
|
+
return `${handlers}::${this.#presence.fingerprint()}::dyn=${[...this.#dynamic.keys()].sort().join('|')}`;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function sanitizeKey(key) {
|
|
634
|
+
// eslint-disable-next-line no-control-regex
|
|
635
|
+
return String(key).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 120);
|
|
636
|
+
}
|
|
637
|
+
//# sourceMappingURL=nav-session.js.map
|