fraim-hub 2.0.340 → 2.0.341

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.
@@ -0,0 +1,315 @@
1
+ /*
2
+ * FRAIM Hub - main-pane surface host (issue #1911 R16).
3
+ *
4
+ * The Hub's main pane has grown a series of hand-wired, mutually exclusive
5
+ * siblings inside <main class="conversation">: the idle empty state, the hire
6
+ * strip, the active conversation, the A/B compare panel. Each one was added by
7
+ * hand and each one is toggled by hand. This module turns that into one slot
8
+ * with a registration API, so a future surface - a form, a report, a picker, a
9
+ * chart, any markup at all - mounts into the pane without editing the pane.
10
+ *
11
+ * It is deliberately feature-agnostic. Every application-specific selector is
12
+ * injected through configure(), and every piece of content comes from the
13
+ * surface's own render(). A test can drive this host with a surface that has
14
+ * nothing to do with any particular Hub feature.
15
+ *
16
+ * Surface definition:
17
+ * {
18
+ * id: 'my-surface', // required, unique
19
+ * render: (ctx) => Node | DocumentFragment | string, // required
20
+ * focusSelector: '#my-first-control', // optional, else first focusable
21
+ * onOpen: (ctx) => void, // optional
22
+ * onClose: (ctx) => void, // optional
23
+ * onEscape:(ctx) => boolean, // optional; true = handled, stay open
24
+ * composer: { // optional; claims the composer
25
+ * chipLabel: (ctx) => string|null,
26
+ * chipClearLabel: (ctx) => string,
27
+ * placeholder: (ctx) => string,
28
+ * validate: ({ text, context }) => { ok, message?, focus? },
29
+ * submit: ({ text, context }) => { ok, error?, keepOpen? } | Promise<...>,
30
+ * },
31
+ * }
32
+ */
33
+ (function (global) {
34
+ 'use strict';
35
+
36
+ var surfaces = new Map();
37
+ var config = null;
38
+ var active = null; // { id, def, context, trigger }
39
+ var submitting = false;
40
+
41
+ function query(selector) {
42
+ if (!selector) return null;
43
+ try {
44
+ return document.querySelector(selector);
45
+ } catch (err) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function hostElement() { return query(config && config.hostSelector); }
51
+ function composerTextElement() { return query(config && config.composerTextSelector); }
52
+ function chipElement() { return query(config && config.chipSelector); }
53
+ function chipLabelElement() { return query(config && config.chipLabelSelector); }
54
+ function chipClearElement() { return query(config && config.chipClearSelector); }
55
+ function composerErrorElement() { return query(config && config.composerErrorSelector); }
56
+
57
+ function notifyChange() {
58
+ if (config && typeof config.onChange === 'function') config.onChange();
59
+ }
60
+
61
+ // Accepts whatever render() returned and puts it in the host, replacing any
62
+ // previous content. A string is treated as markup so a surface can be a
63
+ // template literal and nothing more.
64
+ function mount(host, content) {
65
+ host.innerHTML = '';
66
+ if (content == null) return;
67
+ if (typeof content === 'string') {
68
+ host.innerHTML = content;
69
+ return;
70
+ }
71
+ if (typeof content.nodeType === 'number') {
72
+ host.appendChild(content);
73
+ return;
74
+ }
75
+ if (typeof content.length === 'number') {
76
+ for (var i = 0; i < content.length; i += 1) host.appendChild(content[i]);
77
+ }
78
+ }
79
+
80
+ var FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
81
+
82
+ function focusInto(host, def) {
83
+ var target = def.focusSelector ? host.querySelector(def.focusSelector) : null;
84
+ if (!target) {
85
+ var candidates = host.querySelectorAll(FOCUSABLE);
86
+ for (var i = 0; i < candidates.length; i += 1) {
87
+ var el = candidates[i];
88
+ if (!el.disabled && !el.hidden && el.getClientRects().length) { target = el; break; }
89
+ }
90
+ }
91
+ if (target && typeof target.focus === 'function') target.focus();
92
+ }
93
+
94
+ function composerOf(def) {
95
+ return def && def.composer ? def.composer : null;
96
+ }
97
+
98
+ function callContract(fn, fallback) {
99
+ if (typeof fn !== 'function') return fallback;
100
+ try {
101
+ return fn(active ? active.context : null);
102
+ } catch (err) {
103
+ if (global.console && console.warn) console.warn('[pane-surfaces] contract call failed:', err);
104
+ return fallback;
105
+ }
106
+ }
107
+
108
+ function setComposerError(message) {
109
+ var el = composerErrorElement();
110
+ if (el) el.textContent = message || '';
111
+ }
112
+
113
+ // Paints the chip and the placeholder the active surface asked for. Called
114
+ // on open, on refresh, and by the host application whenever it re-syncs the
115
+ // composer, so the two never disagree about who owns the chip.
116
+ function renderComposerChrome() {
117
+ var chip = chipElement();
118
+ var label = chipLabelElement();
119
+ var clear = chipClearElement();
120
+ var text = composerTextElement();
121
+ if (!active || !composerOf(active.def)) return false;
122
+ var chipLabel = callContract(composerOf(active.def).chipLabel, null);
123
+ if (chip && label && chipLabel) {
124
+ label.textContent = chipLabel;
125
+ chip.hidden = false;
126
+ if (clear) clear.setAttribute('aria-label', callContract(composerOf(active.def).chipClearLabel, 'Cancel'));
127
+ }
128
+ if (text) {
129
+ var placeholder = callContract(composerOf(active.def).placeholder, null);
130
+ if (placeholder) text.placeholder = placeholder;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ var api = {
136
+ /**
137
+ * Binds the host to its application. Every selector here is the
138
+ * application's, not this module's, which is what keeps this file free of
139
+ * feature knowledge.
140
+ */
141
+ configure: function (options) {
142
+ config = options || {};
143
+ return api;
144
+ },
145
+
146
+ register: function (def) {
147
+ if (!def || !def.id) throw new Error('pane-surfaces: a surface needs an id');
148
+ if (typeof def.render !== 'function') throw new Error('pane-surfaces: ' + def.id + ' needs a render()');
149
+ surfaces.set(def.id, def);
150
+ return api;
151
+ },
152
+
153
+ isRegistered: function (id) { return surfaces.has(id); },
154
+
155
+ activeId: function () { return active ? active.id : null; },
156
+
157
+ activeContext: function () { return active ? active.context : null; },
158
+
159
+ isOpen: function (id) { return !!active && (id === undefined || active.id === id); },
160
+
161
+ isSubmitting: function () { return submitting; },
162
+
163
+ /**
164
+ * Mounts a registered surface into the pane. Opening while another surface
165
+ * is open replaces it (closing the first properly) rather than stacking.
166
+ * Re-opening the surface that is already active re-renders it in place, so
167
+ * a double click on an entry point cannot duplicate anything.
168
+ */
169
+ open: function (id, context) {
170
+ var def = surfaces.get(id);
171
+ if (!def) throw new Error('pane-surfaces: no surface registered as "' + id + '"');
172
+ var host = hostElement();
173
+ if (!host) return null;
174
+ var trigger = document.activeElement;
175
+ if (active && active.id !== id) closeActive({ restoreFocus: false });
176
+ var reopening = !!active && active.id === id;
177
+ active = {
178
+ id: id,
179
+ def: def,
180
+ context: context === undefined ? (reopening ? active.context : null) : context,
181
+ trigger: reopening ? active.trigger : trigger,
182
+ };
183
+ setComposerError('');
184
+ // onOpen before render: a surface prepares its state there, and render()
185
+ // must draw the prepared state, not the state a previous open left behind.
186
+ if (!reopening && typeof def.onOpen === 'function') def.onOpen(active.context);
187
+ mount(host, def.render(active.context));
188
+ host.hidden = false;
189
+ host.dataset.surfaceId = id;
190
+ renderComposerChrome();
191
+ notifyChange();
192
+ focusInto(host, def);
193
+ return active.context;
194
+ },
195
+
196
+ /** Re-runs render() for the active surface without re-firing onOpen. */
197
+ refresh: function () {
198
+ if (!active) return false;
199
+ var host = hostElement();
200
+ if (!host) return false;
201
+ var focusedId = document.activeElement && document.activeElement.id;
202
+ var selectionStart = null;
203
+ var selectionEnd = null;
204
+ var focused = document.activeElement;
205
+ if (focused && typeof focused.selectionStart === 'number') {
206
+ selectionStart = focused.selectionStart;
207
+ selectionEnd = focused.selectionEnd;
208
+ }
209
+ mount(host, active.def.render(active.context));
210
+ renderComposerChrome();
211
+ if (focusedId) {
212
+ var again = host.querySelector('#' + (global.CSS && CSS.escape ? CSS.escape(focusedId) : focusedId));
213
+ if (again && typeof again.focus === 'function') {
214
+ again.focus();
215
+ if (selectionStart !== null && typeof again.setSelectionRange === 'function') {
216
+ try { again.setSelectionRange(selectionStart, selectionEnd); } catch (err) { /* not a text field */ }
217
+ }
218
+ }
219
+ }
220
+ return true;
221
+ },
222
+
223
+ close: function (options) { return closeActive(options); },
224
+
225
+ closeIfActive: function (id, options) {
226
+ if (!active || active.id !== id) return false;
227
+ return closeActive(options);
228
+ },
229
+
230
+ /** The composer contract of the active surface, or null when none claims it. */
231
+ composerContract: function () {
232
+ if (!active) return null;
233
+ return composerOf(active.def);
234
+ },
235
+
236
+ /** Paints chip + placeholder for the active surface. Returns false when no surface owns the composer. */
237
+ renderComposerChrome: renderComposerChrome,
238
+
239
+ setComposerError: setComposerError,
240
+
241
+ /**
242
+ * Routed from the application's Escape handling so there is exactly one
243
+ * keydown listener in the app. Returns true when this module consumed the key.
244
+ */
245
+ handleEscape: function () {
246
+ if (!active) return false;
247
+ if (typeof active.def.onEscape === 'function' && active.def.onEscape(active.context) === true) return true;
248
+ closeActive();
249
+ return true;
250
+ },
251
+
252
+ /**
253
+ * Routed from the application's send action. Returns false when no surface
254
+ * claims the composer, so the caller falls through to its own behavior.
255
+ */
256
+ submitFromComposer: async function (text) {
257
+ var contract = api.composerContract();
258
+ if (!contract || typeof contract.submit !== 'function') return false;
259
+ if (submitting) return true;
260
+ var input = { text: text == null ? '' : String(text), context: active.context };
261
+ setComposerError('');
262
+ if (typeof contract.validate === 'function') {
263
+ var verdict = contract.validate(input) || { ok: true };
264
+ if (!verdict.ok) {
265
+ setComposerError(verdict.message || '');
266
+ var focusTarget = typeof verdict.focus === 'string' ? query(verdict.focus) : verdict.focus;
267
+ if (focusTarget && typeof focusTarget.focus === 'function') focusTarget.focus();
268
+ return true;
269
+ }
270
+ }
271
+ submitting = true;
272
+ var result;
273
+ try {
274
+ result = await contract.submit(input);
275
+ } catch (err) {
276
+ result = { ok: false, error: (err && err.message) || 'Could not complete this action.' };
277
+ } finally {
278
+ submitting = false;
279
+ }
280
+ result = result || { ok: true };
281
+ if (result.ok === false) {
282
+ setComposerError(result.error || 'Could not complete this action.');
283
+ return true;
284
+ }
285
+ var composerText = composerTextElement();
286
+ if (composerText) composerText.value = '';
287
+ if (!result.keepOpen) closeActive({ restoreFocus: false });
288
+ else notifyChange();
289
+ return true;
290
+ },
291
+ };
292
+
293
+ function closeActive(options) {
294
+ if (!active) return false;
295
+ var opts = options || {};
296
+ var closing = active;
297
+ active = null;
298
+ var host = hostElement();
299
+ if (host) {
300
+ host.hidden = true;
301
+ host.innerHTML = '';
302
+ delete host.dataset.surfaceId;
303
+ }
304
+ setComposerError('');
305
+ if (typeof closing.def.onClose === 'function') closing.def.onClose(closing.context);
306
+ notifyChange();
307
+ if (opts.restoreFocus !== false && closing.trigger && closing.trigger.isConnected
308
+ && typeof closing.trigger.focus === 'function') {
309
+ closing.trigger.focus();
310
+ }
311
+ return true;
312
+ }
313
+
314
+ global.PaneSurfaces = api;
315
+ }(typeof window !== 'undefined' ? window : globalThis));
@@ -232,6 +232,38 @@
232
232
  border-left: 3px solid var(--line);
233
233
  padding-left: 10px;
234
234
  }
235
+ /* Issue #1857: a reopened claim. Built from the same tokens as .lp-example
236
+ directly above, because it is the same kind of thing, a quoted aside that
237
+ qualifies the lesson, and it should not read as a new visual language. The
238
+ accent separates it from the example rather than competing with it. */
239
+ .learn-prop .lp-reopen {
240
+ font-size: 13px;
241
+ line-height: 1.45;
242
+ color: var(--text);
243
+ border-left: 3px solid var(--warn, #b8860b);
244
+ background: color-mix(in srgb, var(--warn, #b8860b) 8%, transparent);
245
+ padding: 6px 10px;
246
+ border-radius: 3px;
247
+ }
248
+
249
+ /* Issue #1857 R7: claims suppressed because the manager already decided them.
250
+ Deliberately quieter than a card: this is a receipt, not a request. It sits
251
+ below the batch so the eye reaches the decisions first. */
252
+ .learn-suppressed {
253
+ margin-top: 10px;
254
+ padding-top: 8px;
255
+ border-top: 1px solid var(--line);
256
+ font-size: 12px;
257
+ line-height: 1.6;
258
+ color: var(--muted);
259
+ }
260
+ .learn-suppressed .ls-lead {
261
+ font-weight: 600;
262
+ }
263
+ .learn-suppressed .ls-row {
264
+ padding-left: 2px;
265
+ }
266
+
235
267
  .learn-prop .lp-actions {
236
268
  display: flex;
237
269
  align-items: center;
@@ -887,6 +919,16 @@
887
919
  background: var(--bg);
888
920
  }
889
921
 
922
+ /* Issue #1857 R19: the rejected section reuses the Archived section's shape
923
+ entirely, so a manager reads one collapsed list the same way in both places.
924
+ Only the card is dimmed, because a refusal is settled rather than pending. */
925
+ .learn-rejected-section > .learn-prop {
926
+ opacity: 0.78;
927
+ }
928
+ .learn-rejected-section .lp-title {
929
+ text-decoration: none;
930
+ }
931
+
890
932
 
891
933
  /* Issue #1755: entries are authored as short labelled lines (Takeaway / Why this
892
934
  matters / Example / What to do next). Collapsing them into one paragraph is