instar 1.3.804 → 1.3.805

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.
@@ -90,6 +90,63 @@ export function countdown(iso, now = Date.now(), { expiredWord = 'expired' } = {
90
90
  return `${sec}s`;
91
91
  }
92
92
 
93
+ // ── Interaction-hold (Dashboard UX Standard F9) ─────────────────────────────
94
+ // THE RULE (topic 29836 case study D1): a background refresh must NEVER replace a
95
+ // surface with an open interaction. An open interaction is (a) an element marked
96
+ // data-interaction-open (an enrollment episode the controller is driving), (b) a
97
+ // focused text-entry element, or (c) a dirty (partially-typed) field. While one is
98
+ // open, the periodic poll MERGES server state into the view (countdowns, status
99
+ // lines) instead of rebuilding the DOM out from under the operator's fingers. The
100
+ // hold releases when the flow reaches a terminal state (verified / failed /
101
+ // cancelled / expired) or the operator backs out.
102
+ export function hasOpenInteraction(doc, root) {
103
+ if (!root || typeof root.querySelector !== 'function') return false;
104
+ if (root.querySelector('[data-interaction-open]')) return true;
105
+ const active = doc && doc.activeElement;
106
+ if (active && root.contains(active) && /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName || '')) return true;
107
+ for (const field of root.querySelectorAll('input, textarea')) {
108
+ if (typeof field.value === 'string' && field.value !== '') return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** The F9 merge arm: patch every countdown element ([data-ttl-expires]) under `root`
114
+ * from the live clock WITHOUT rebuilding anything. Safe on held and unheld DOM.
115
+ * Returns the number of elements patched. */
116
+ export function updateCountdowns(doc, root, now = Date.now()) {
117
+ if (!root || typeof root.querySelectorAll !== 'function') return 0;
118
+ let patched = 0;
119
+ for (const node of root.querySelectorAll('[data-ttl-expires]')) {
120
+ const left = countdown(node.getAttribute('data-ttl-expires'), now);
121
+ node.textContent = left === 'expired'
122
+ ? 'Sign-in link expired — start again'
123
+ : (left ? `Link expires in ${left}` : '');
124
+ patched++;
125
+ }
126
+ return patched;
127
+ }
128
+
129
+ /** Plain-language explanation for a HELD (identity-refused) enrollment, naming BOTH
130
+ * accounts when the gate verdict carries them (topic 29836 D3). Fail-closed reasons
131
+ * (oracle unavailable / no expected email) get honest "couldn't confirm" copy. */
132
+ export function heldExplanation(expected, got, reason, { short = false } = {}) {
133
+ const exp = sanitizeForDisplay(expected, 'label');
134
+ const g = sanitizeForDisplay(got, 'label');
135
+ if (exp && g) {
136
+ return short
137
+ ? `That sign-in was ${g} — this slot needs ${exp}.`
138
+ : `That code signed in ${g} — this slot needs ${exp}. The account was NOT enrolled; sign in again with the right account.`;
139
+ }
140
+ if (reason === 'missing-completed-email') {
141
+ return short
142
+ ? 'Couldn’t confirm which account that was — not enrolled.'
143
+ : 'Signed in, but I couldn’t confirm which account that sign-in belongs to — so it was NOT enrolled (fail-closed). Try again, or check the attention queue.';
144
+ }
145
+ return short
146
+ ? 'The account didn’t verify — not enrolled.'
147
+ : 'Signed in, but the account couldn’t be verified against what you approved — so it was NOT enrolled.';
148
+ }
149
+
93
150
  /** A coarse "N ago" for a PAST ISO timestamp (token-refresh recency). '' if invalid. */
94
151
  export function relativeAge(iso, now = Date.now()) {
95
152
  const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
@@ -262,11 +319,38 @@ function trustedLoginUrl(raw) {
262
319
  } catch { return null; }
263
320
  }
264
321
 
265
- export function renderPendingLogins(doc, target, logins, now = Date.now()) {
322
+ /** Explicit terminal-outcome card for the pending panel (topic 29836 D4 — completion
323
+ * must never be a vanishing line). `o` is a client-recorded outcome:
324
+ * { kind:'validated'|'held'|'expired', accountId, machineId, machineNickname?, email?,
325
+ * expected?, got?, reason? }. Static glyphs are literals (never dynamic data). */
326
+ export function renderOutcomeCard(doc, o) {
327
+ const isDone = o && o.kind === 'validated';
328
+ const row = el(doc, 'div', isDone ? 'sub-pending sub-pending-done' : 'sub-pending sub-pending-failed');
329
+ const who = sanitizeForDisplay((o && (o.email || o.accountId)) || 'The account', 'label');
330
+ const machine = sanitizeForDisplay((o && (o.machineNickname || o.machineId)) || 'the machine', 'label');
331
+ if (isDone) {
332
+ row.appendChild(el(doc, 'div', 'sub-pending-done-head', '✓ Done'));
333
+ row.appendChild(el(doc, 'div', 'sub-pending-outcome-body', `${who} is now set up on ${machine}.`));
334
+ } else if (o && o.kind === 'held') {
335
+ row.appendChild(el(doc, 'div', 'sub-pending-failed-head', '✗ Not enrolled — the account didn’t match'));
336
+ row.appendChild(el(doc, 'div', 'sub-pending-outcome-body', heldExplanation(o.expected, o.got, o.reason)));
337
+ } else {
338
+ row.appendChild(el(doc, 'div', 'sub-pending-failed-head', '✗ Sign-in link expired'));
339
+ row.appendChild(el(doc, 'div', 'sub-pending-outcome-body',
340
+ `The sign-in for ${who} on ${machine} expired before it finished — start it again from the grid above.`));
341
+ }
342
+ return row;
343
+ }
344
+
345
+ export function renderPendingLogins(doc, target, logins, now = Date.now(), outcomes = []) {
266
346
  if (!target) return;
267
347
  target.replaceChildren();
348
+ // Client-observed terminal outcomes lead the panel (explicit completed/failed cards —
349
+ // never a vanishing line; topic 29836 D4). Newest first, capped by the controller.
350
+ const cards = Array.isArray(outcomes) ? outcomes : [];
351
+ for (const o of cards) target.appendChild(renderOutcomeCard(doc, o));
268
352
  if (!Array.isArray(logins) || logins.length === 0) {
269
- target.appendChild(el(doc, 'div', 'sub-empty', 'No logins waiting for approval.'));
353
+ if (cards.length === 0) target.appendChild(el(doc, 'div', 'sub-empty', 'No logins waiting for approval.'));
270
354
  return;
271
355
  }
272
356
  for (const l of logins) {
@@ -275,14 +359,38 @@ export function renderPendingLogins(doc, target, logins, now = Date.now()) {
275
359
  row.setAttribute('data-login-id', sanitizeForDisplay(l && l.id, 'label'));
276
360
  if (l && (l.machineId || l.machineNickname)) row.setAttribute('data-machine-id', sanitizeForDisplay(l.machineId, 'label'));
277
361
 
278
- // Lead with a plain-language headline naming what to do + where (machine).
279
- const machine = sanitizeForDisplay(l && (l.machineNickname || l.machineId), 'label');
280
- const who = sanitizeForDisplay(l && l.label, 'label');
362
+ // Lead with a plain-language headline naming what to do + where. Wording floors (D5):
363
+ // the ACCOUNT is shown by its email when known (the label can be an internal nickname
364
+ // like "Justin"); the MACHINE by nickname, NEVER a raw m_<hex> machine id.
365
+ const machine = friendlyMachine(l && l.machineNickname, l && l.machineId);
366
+ const who = sanitizeForDisplay(l && (l.expectedEmail || l.email || l.label), 'label');
367
+
368
+ // D5 record ⟂ pane liveness: a login whose sign-in window is GONE (paneAlive === false)
369
+ // must not present as submittable — no link, no code input; an explicit needs-restart
370
+ // card pointing at the working restart affordance (the grid's Retry) instead.
371
+ if (l && l.paneAlive === false) {
372
+ row.setAttribute('class', 'sub-pending sub-pending-failed');
373
+ row.appendChild(el(doc, 'div', 'sub-pending-failed-head',
374
+ machine ? `✗ This sign-in can’t finish — its window on ${machine} closed` : '✗ This sign-in can’t finish — its window closed'));
375
+ row.appendChild(el(doc, 'div', 'sub-pending-outcome-body',
376
+ `Start ${who || 'it'} again from the “Accounts on each machine” grid above — tapping Retry starts a fresh sign-in.`));
377
+ target.appendChild(row);
378
+ continue;
379
+ }
380
+
281
381
  const headline = machine
282
382
  ? `Sign in to finish setting up ${who} on ${machine}`
283
383
  : `Sign in to finish setting up ${who}`;
284
384
  row.appendChild(el(doc, 'div', 'sub-pending-headline', headline));
285
385
 
386
+ // Wrong-account hazard (topic 29836 D3): state prominently, BEFORE the link, which
387
+ // account the provider's OAuth page MUST show — the page opens in whatever login
388
+ // state the browser already has, and nothing else warns.
389
+ if (l && l.expectedEmail) {
390
+ row.appendChild(el(doc, 'div', 'sub-pending-expected',
391
+ `The sign-in page must show ${sanitizeForDisplay(l.expectedEmail, 'label')} — if it shows a different account, tap “Switch account” first.`));
392
+ }
393
+
286
394
  // The PRIMARY action: one tappable "Sign in" link to the provider's own OAuth URL.
287
395
  // Falls back to copy-text only if the URL isn't a trusted provider sign-in host.
288
396
  const href = trustedLoginUrl(l && l.verificationUrl);
@@ -319,9 +427,14 @@ export function renderPendingLogins(doc, target, logins, now = Date.now()) {
319
427
  row.appendChild(submit);
320
428
  }
321
429
 
322
- // One short secondary line: the TTL, and the flow notice only if present (trimmed).
430
+ // One short secondary line: the TTL (patched live by updateCountdowns between
431
+ // rebuilds — the F9 merge arm), and the flow notice only if present (trimmed).
323
432
  const ttl = l && l.ttlExpiresAt ? countdown(l.ttlExpiresAt, now) : '';
324
- if (ttl) row.appendChild(el(doc, 'div', 'sub-pending-ttl', `Link expires in ${ttl}`));
433
+ if (ttl) {
434
+ const ttlEl = el(doc, 'div', 'sub-pending-ttl', ttl === 'expired' ? 'Sign-in link expired — start again' : `Link expires in ${ttl}`);
435
+ ttlEl.setAttribute('data-ttl-expires', sanitizeForDisplay(l.ttlExpiresAt, 'label'));
436
+ row.appendChild(ttlEl);
437
+ }
325
438
  if (l && l.notice) {
326
439
  row.appendChild(el(doc, 'div', 'sub-pending-notice', sanitizeForDisplay(l.notice, 'summary')));
327
440
  }
@@ -392,9 +505,13 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
392
505
  cellStatus.set(`${a.id}::${mid}`, a.status === 'needs-reauth' ? 'needs-reauth' : (a.status === 'active' ? 'active' : 'other'));
393
506
  }
394
507
  // (accountId, machineId) in-progress, correlated on (login.id === accountId, machineId) (FD6 r3 #2).
395
- const inProgress = new Set();
508
+ // The MAP carries the pending-login RECORD so the in-progress cell can render the COMPLETE
509
+ // sign-in flow (link, expected email, code input, TTL, notice) from SERVER state — the flow
510
+ // must never exist only in the bottom panel (topic 29836 D2), and a rebuild mid-flow renders
511
+ // the same step back instead of losing it (D1 defense-in-depth).
512
+ const inProgress = new Map();
396
513
  for (const l of pendingRows) {
397
- if (l && l.id && l.machineId) inProgress.add(`${l.id}::${l.machineId}`);
514
+ if (l && l.id && l.machineId) inProgress.set(`${l.id}::${l.machineId}`, l);
398
515
  }
399
516
 
400
517
  const machineList = Array.from(machines.values());
@@ -404,15 +521,32 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
404
521
  const rowCells = [];
405
522
  for (const m of machineList) {
406
523
  const key = `${acct.accountId}::${m.machineId}`;
524
+ const t = transient[key] || null;
525
+ const pendingLogin = inProgress.get(key) || null;
407
526
  let state;
408
527
  if (m.offline) state = 'offline'; // whole column offline (FD6)
409
- else if (transient[key] && transient[key].state === 'held') state = 'held';
410
- else if (transient[key] && transient[key].state === 'cant-resolve') state = 'cant-resolve';
411
- else if (inProgress.has(key)) state = 'in-progress';
528
+ else if (t && t.state === 'held') state = 'held';
529
+ else if (t && t.state === 'cant-resolve') state = 'cant-resolve';
412
530
  else if (cellStatus.get(key) === 'active') state = 'active';
531
+ // just-verified BRIDGES the gap between a client-observed successful enrollment and the
532
+ // next pool read that shows the account active — the cell must flip to an unmistakable
533
+ // verified presentation the moment the enrollment verifies (topic 29836 D4), never blink
534
+ // back to a "Set up" button while the server catches up.
535
+ else if (t && t.state === 'just-verified') state = 'just-verified';
536
+ // broken (D5): the server says this attempt's sign-in pane is DEAD (record ⟂ pane
537
+ // reconciliation) — or the client just watched a code-submit refuse with pane-dead.
538
+ // Presenting it as submittable would be a lie; it gets an explicit needs-restart
539
+ // presentation with a working Retry (start-cell supersedes the zombie atomically).
540
+ else if ((pendingLogin && pendingLogin.paneAlive === false) || (t && t.state === 'broken')) state = 'broken';
541
+ else if (pendingLogin) state = 'in-progress';
542
+ else if (t && t.state === 'expired') state = 'expired';
413
543
  else if (cellStatus.get(key) === 'needs-reauth') state = 'needs-reauth';
414
544
  else state = 'empty'; // → "Set up" button
415
- rowCells.push({ accountId: acct.accountId, machineId: m.machineId, state });
545
+ rowCells.push({
546
+ accountId: acct.accountId, machineId: m.machineId, state,
547
+ login: state === 'in-progress' ? pendingLogin : null,
548
+ detail: t,
549
+ });
416
550
  }
417
551
  cells.push({ account: acct, cells: rowCells });
418
552
  }
@@ -421,12 +555,85 @@ export function buildMatrixModel(poolScope, pendingScope, transient = {}) {
421
555
 
422
556
  const MATRIX_CELL_GLYPH = {
423
557
  active: '✓', 'needs-reauth': '⟳', 'in-progress': '◷', offline: '—', held: '⚠', 'cant-resolve': '✗',
558
+ expired: '✗', 'just-verified': '✓', broken: '✗',
424
559
  };
425
560
  const MATRIX_CELL_WORD = {
426
561
  active: 'Active', 'needs-reauth': 'Needs sign-in', 'in-progress': 'Signing in…',
427
562
  offline: 'Machine offline', held: 'Didn’t match — re-try', 'cant-resolve': 'Can’t set up', other: 'Set up',
563
+ expired: 'Sign-in link expired', 'just-verified': 'Set up complete', broken: 'Sign-in needs a restart',
428
564
  };
429
565
 
566
+ /** D5 wording floor: never show a raw internal machine id (m_<hex>) to the operator —
567
+ * a nickname, or nothing. Exported for the floor test. */
568
+ export function friendlyMachine(nickname, machineId) {
569
+ const nick = sanitizeForDisplay(nickname, 'label');
570
+ if (nick) return nick;
571
+ const id = typeof machineId === 'string' ? machineId : '';
572
+ if (/^m_[0-9a-f]{8,}$/i.test(id)) return '';
573
+ return sanitizeForDisplay(id, 'label');
574
+ }
575
+
576
+ /**
577
+ * Append the COMPLETE in-cell sign-in flow (topic 29836 D2): the sign-in link, the
578
+ * expected-account warning (D3 UI layer), the paste-back code input, the live TTL
579
+ * countdown, the flow notice (two-codes heads-up), and a Cancel. Shared by the
580
+ * in-progress matrix cell (rendered from SERVER pending-login state, so the step
581
+ * survives reloads and rebuilds) and the controller's immediate post-start render.
582
+ * `flow` = { accountId, machineId, loginId, verificationUrl, expectedEmail,
583
+ * ttlExpiresAt, notice, kind, userCode }.
584
+ */
585
+ export function appendCellSignInFlow(doc, cell, flow, now = Date.now()) {
586
+ cell.appendChild(el(doc, 'div', 'sub-matrix-flow-step', 'Open the sign-in link, then paste the code it gives you below.'));
587
+ if (flow && flow.expectedEmail) {
588
+ cell.appendChild(el(doc, 'div', 'sub-matrix-expected',
589
+ `The sign-in page must show ${sanitizeForDisplay(flow.expectedEmail, 'label')} — if it shows a different account, tap “Switch account” first.`));
590
+ }
591
+ const href = trustedLoginUrl(flow && flow.verificationUrl);
592
+ if (href) {
593
+ const a = doc.createElement('a');
594
+ a.setAttribute('href', href);
595
+ a.setAttribute('target', '_blank');
596
+ a.setAttribute('rel', 'noopener noreferrer');
597
+ a.setAttribute('class', 'sub-matrix-signin');
598
+ a.textContent = 'Sign in';
599
+ cell.appendChild(a);
600
+ } else if (flow && flow.verificationUrl) {
601
+ cell.appendChild(el(doc, 'div', 'sub-matrix-url', sanitizeForDisplay(flow.verificationUrl, 'url')));
602
+ }
603
+ if (flow && flow.userCode) {
604
+ cell.appendChild(el(doc, 'div', 'sub-pending-code', `Code: ${sanitizeForDisplay(flow.userCode, 'code')}`));
605
+ }
606
+ if (!flow || flow.kind !== 'device-code') {
607
+ const code = doc.createElement('input');
608
+ code.setAttribute('type', 'text');
609
+ code.setAttribute('class', 'sub-matrix-code-input');
610
+ code.setAttribute('placeholder', 'Paste your sign-in code');
611
+ code.setAttribute('autocomplete', 'off');
612
+ cell.appendChild(code);
613
+ const submit = el(doc, 'button', 'sub-matrix-code-submit', 'Submit');
614
+ submit.setAttribute('data-matrix-code-submit', '1');
615
+ submit.setAttribute('data-account-id', sanitizeForDisplay(flow && flow.accountId, 'label'));
616
+ submit.setAttribute('data-machine-id', sanitizeForDisplay(flow && flow.machineId, 'label'));
617
+ submit.setAttribute('data-login-id', sanitizeForDisplay((flow && (flow.loginId || flow.accountId)) || '', 'label'));
618
+ cell.appendChild(submit);
619
+ }
620
+ if (flow && flow.notice) {
621
+ cell.appendChild(el(doc, 'div', 'sub-matrix-notice', sanitizeForDisplay(flow.notice, 'summary')));
622
+ }
623
+ if (flow && flow.ttlExpiresAt) {
624
+ const left = countdown(flow.ttlExpiresAt, now);
625
+ const ttl = el(doc, 'div', 'sub-matrix-ttl',
626
+ left === 'expired' ? 'Sign-in link expired — start again' : (left ? `Link expires in ${left}` : ''));
627
+ ttl.setAttribute('data-ttl-expires', sanitizeForDisplay(flow.ttlExpiresAt, 'label'));
628
+ cell.appendChild(ttl);
629
+ }
630
+ const cancelBtn = el(doc, 'button', 'sub-matrix-cancel', 'Cancel');
631
+ cancelBtn.setAttribute('data-matrix-cancel', '1');
632
+ cancelBtn.setAttribute('data-account-id', sanitizeForDisplay(flow && flow.accountId, 'label'));
633
+ cancelBtn.setAttribute('data-machine-id', sanitizeForDisplay(flow && flow.machineId, 'label'));
634
+ cell.appendChild(cancelBtn);
635
+ }
636
+
430
637
  /** Render the account × machine grid. `target` is replaced. Each empty (reachable) cell
431
638
  * gets a "Set up" button carrying its (accountId, machineId) as data-* attributes for the
432
639
  * controller's delegated tap handler. Offline columns are disabled; no state is fabricated. */
@@ -452,16 +659,22 @@ export function renderAccountMatrix(doc, target, poolScope, pendingScope, transi
452
659
  table.appendChild(thead);
453
660
 
454
661
  const tbody = doc.createElement('tbody');
662
+ const now = Date.now();
455
663
  for (const row of model.rows) {
456
664
  const tr = doc.createElement('tr');
457
665
  tr.appendChild(el(doc, 'th', 'sub-matrix-acct', sanitizeForDisplay(row.account.email, 'label')));
458
666
  for (const c of row.cells) {
459
- const td = el(doc, 'td', `sub-matrix-cell sub-matrix-${c.state}`);
460
- if (c.state === 'empty' || c.state === 'needs-reauth' || c.state === 'held' || c.state === 'cant-resolve') {
667
+ // just-verified rides a transient highlight on an otherwise-active cell (D4 ceremony).
668
+ const justVerified = c.state === 'just-verified' || (c.state === 'active' && c.detail && c.detail.state === 'just-verified');
669
+ const td = el(doc, 'td', `sub-matrix-cell sub-matrix-${c.state}${justVerified && c.state !== 'just-verified' ? ' sub-matrix-just-verified' : ''}`);
670
+ // Stable cell identity for the interaction-hold rule + targeted merge updates (F9).
671
+ td.setAttribute('data-cell-key', sanitizeForDisplay(`${c.accountId}::${c.machineId}`, 'url'));
672
+ if (c.state === 'empty' || c.state === 'needs-reauth' || c.state === 'held' || c.state === 'cant-resolve' || c.state === 'expired' || c.state === 'broken') {
461
673
  // An actionable cell → a button that runs the SAME in-dashboard sign-in flow (PIN → link →
462
674
  // paste code). empty → "Set up"; needs-reauth (an existing account whose login expired) →
463
- // "Sign in"; held/cant-resolve → "Retry". A needs-reauth account already resolves to its
464
- // email, so the start-cell orchestrator drives a real re-auth — never a cosmetic button.
675
+ // "Sign in"; held/cant-resolve/expired/broken → "Retry". A needs-reauth account already
676
+ // resolves to its email, so the start-cell orchestrator drives a real re-auth — never a
677
+ // cosmetic button, and a broken (dead-pane) attempt is superseded server-side on Retry.
465
678
  const label = c.state === 'empty' ? 'Set up' : (c.state === 'needs-reauth' ? 'Sign in' : 'Retry');
466
679
  const btn = el(doc, 'button', 'sub-matrix-setup', label);
467
680
  btn.setAttribute('data-matrix-setup', '1');
@@ -471,9 +684,32 @@ export function renderAccountMatrix(doc, target, poolScope, pendingScope, transi
471
684
  // Show the status word ("⟳ Needs sign-in" / "⚠ Didn't match…") ABOVE the button.
472
685
  td.appendChild(el(doc, 'div', 'sub-matrix-glyph', `${MATRIX_CELL_GLYPH[c.state]} ${MATRIX_CELL_WORD[c.state]}`));
473
686
  }
687
+ // A held cell with the gate verdict's both-account detail names BOTH accounts in
688
+ // plain language (topic 29836 D3) — never just a bare "didn't match".
689
+ if (c.state === 'held' && c.detail) {
690
+ td.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
691
+ heldExplanation(c.detail.expected, c.detail.got, c.detail.reason, { short: true })));
692
+ }
693
+ // A broken cell says WHY in plain words (D5): the attempt's sign-in window is gone.
694
+ if (c.state === 'broken') {
695
+ td.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
696
+ 'Its sign-in window closed before finishing — tap Retry to start a fresh sign-in.'));
697
+ }
474
698
  td.appendChild(btn);
699
+ } else if (c.state === 'in-progress' && c.login && c.login.verificationUrl) {
700
+ // The cell carries the COMPLETE flow, rebuilt from SERVER pending-login state
701
+ // (topic 29836 D2): a poll rebuild mid-flow renders the same step back — the
702
+ // "flips to ◷ before the code can be pasted" defect is structurally impossible.
703
+ td.appendChild(el(doc, 'div', 'sub-matrix-glyph', '◷ Signing in'));
704
+ appendCellSignInFlow(doc, td, {
705
+ accountId: c.accountId, machineId: c.machineId, loginId: c.login.id,
706
+ verificationUrl: c.login.verificationUrl, expectedEmail: c.login.expectedEmail,
707
+ ttlExpiresAt: c.login.ttlExpiresAt, notice: c.login.notice, kind: c.login.kind,
708
+ userCode: c.login.userCode,
709
+ }, now);
475
710
  } else {
476
- const word = c.state === 'offline' ? 'unknown' : MATRIX_CELL_WORD[c.state];
711
+ const word = c.state === 'offline' ? 'unknown'
712
+ : (justVerified && c.state === 'active' ? 'Active — just set up' : MATRIX_CELL_WORD[c.state]);
477
713
  const glyph = MATRIX_CELL_GLYPH[c.state] || '';
478
714
  td.appendChild(el(doc, 'span', 'sub-matrix-glyph', `${glyph} ${word}`.trim()));
479
715
  // An in-progress (◷) cell gets a tappable Cancel so a mis-tapped setup can be
@@ -534,9 +770,20 @@ export function createController(opts) {
534
770
  } = opts;
535
771
 
536
772
  // matrixTransient: client-side last-attempt state per `${accountId}::${machineId}` cell (FD6 —
537
- // held / cant-resolve are known only to the client from the response it just got).
773
+ // held / cant-resolve / expired / just-verified are known only to the client from the
774
+ // response it just got or from an episode it drove).
775
+ // matrixEpisodes: enrollment episodes THIS client started (start-cell succeeded) — used to
776
+ // detect a server-side expiry (pending login vanished without a terminal outcome) and to
777
+ // add the D4 ceremony when the episode lands. recentOutcomes: client-observed terminal
778
+ // outcomes rendered as explicit cards in the pending panel (D4 — never a vanishing line).
538
779
  const state = { timerId: null, active: false, inFlight: null, offers: [], approveWired: false,
539
- matrixWired: false, matrixTransient: {}, lastPoolBody: null, lastPendingBody: null };
780
+ matrixWired: false, matrixTransient: {}, lastPoolBody: null, lastPendingBody: null,
781
+ matrixEpisodes: {}, recentOutcomes: [] };
782
+
783
+ const JUST_VERIFIED_TTL_MS = 5 * 60_000; // highlight ceremony window
784
+ const EXPIRED_TTL_MS = 60 * 60_000; // explicit expired presentation window
785
+ const OUTCOME_TTL_MS = 15 * 60_000; // pending-panel outcome cards window
786
+ const MAX_OUTCOME_CARDS = 5;
540
787
 
541
788
  async function fetchJson(url, controller) {
542
789
  const resp = await fetchImpl(url, { signal: controller.signal });
@@ -594,26 +841,147 @@ export function createController(opts) {
594
841
  reschedule();
595
842
  }
596
843
 
844
+ // ── Episode + outcome bookkeeping (D1/D4/D5) ───────────────────────────────
845
+ function purgeTransients() {
846
+ const t = now();
847
+ for (const key of Object.keys(state.matrixTransient)) {
848
+ const entry = state.matrixTransient[key];
849
+ if (!entry || typeof entry.at !== 'number') continue;
850
+ if (entry.state === 'just-verified' && t - entry.at > JUST_VERIFIED_TTL_MS) delete state.matrixTransient[key];
851
+ if ((entry.state === 'expired' || entry.state === 'broken') && t - entry.at > EXPIRED_TTL_MS) delete state.matrixTransient[key];
852
+ }
853
+ state.recentOutcomes = state.recentOutcomes
854
+ .filter((o) => o && typeof o.at === 'number' && now() - o.at <= OUTCOME_TTL_MS)
855
+ .slice(0, MAX_OUTCOME_CARDS);
856
+ }
857
+
858
+ /** Resolve a machine's operator-facing name from the cached bodies ('' if unknown —
859
+ * never a raw m_<hex> id; D5 wording floor). */
860
+ function machineNick(machineId) {
861
+ const accounts = state.lastPoolBody && Array.isArray(state.lastPoolBody.accounts) ? state.lastPoolBody.accounts : [];
862
+ const hit = accounts.find((a) => a && a.machineId === machineId && a.machineNickname);
863
+ if (hit) return hit.machineNickname;
864
+ const logins = state.lastPendingBody && Array.isArray(state.lastPendingBody.logins) ? state.lastPendingBody.logins : [];
865
+ const l = logins.find((x) => x && x.machineId === machineId && x.machineNickname);
866
+ return l ? l.machineNickname : '';
867
+ }
868
+
869
+ /** One chokepoint for a client-observed terminal submit outcome — records the panel
870
+ * card (D4), the matrix transient (so BOTH surfaces flip together), and ends the
871
+ * episode. Used by the in-cell submit AND the pending-panel submit. */
872
+ function recordSubmitOutcome(kind, ids, body) {
873
+ const { accountId, machineId } = ids;
874
+ const key = accountId && machineId ? `${accountId}::${machineId}` : null;
875
+ if (key) delete state.matrixEpisodes[key];
876
+ const base = { accountId, machineId, machineNickname: machineNick(machineId), at: now() };
877
+ if (kind === 'validated') {
878
+ if (key) state.matrixTransient[key] = { state: 'just-verified', at: now() };
879
+ state.recentOutcomes.unshift({ ...base, kind, email: (body && body.email) || null });
880
+ } else if (kind === 'held') {
881
+ if (key) {
882
+ state.matrixTransient[key] = {
883
+ state: 'held', at: now(),
884
+ expected: (body && body.expected) || null, got: (body && body.got) || null, reason: (body && body.reason) || null,
885
+ };
886
+ }
887
+ state.recentOutcomes.unshift({ ...base, kind, expected: (body && body.expected) || null, got: (body && body.got) || null, reason: (body && body.reason) || null });
888
+ } else if (kind === 'broken') {
889
+ if (key) state.matrixTransient[key] = { state: 'broken', at: now() };
890
+ }
891
+ state.recentOutcomes = state.recentOutcomes.slice(0, MAX_OUTCOME_CARDS);
892
+ }
893
+
894
+ /** Episode reconciliation (D5/D4): an episode this client started whose pending login
895
+ * VANISHED server-side resolves to a terminal state — active pool row → just-verified
896
+ * ceremony; gone without any outcome → explicit expired (never a silent revert to a
897
+ * bare "Set up" button). Runs on every tick with the fresh bodies. */
898
+ function reconcileEpisodes(poolBody, pendingBody) {
899
+ const accounts = poolBody && Array.isArray(poolBody.accounts) ? poolBody.accounts : [];
900
+ const logins = pendingBody && Array.isArray(pendingBody.logins) ? pendingBody.logins : [];
901
+ for (const key of Object.keys(state.matrixEpisodes)) {
902
+ const sep = key.indexOf('::');
903
+ const accountId = key.slice(0, sep);
904
+ const machineId = key.slice(sep + 2);
905
+ const active = accounts.some((a) => a && a.id === accountId && a.machineId === machineId && a.status === 'active');
906
+ const stillPending = logins.some((l) => l && l.id === accountId && l.machineId === machineId);
907
+ if (active) {
908
+ delete state.matrixEpisodes[key];
909
+ const t = state.matrixTransient[key];
910
+ if (!t || (t.state !== 'held' && t.state !== 'just-verified')) {
911
+ state.matrixTransient[key] = { state: 'just-verified', at: now() };
912
+ }
913
+ releaseCellHold(key);
914
+ } else if (!stillPending) {
915
+ delete state.matrixEpisodes[key];
916
+ const t = state.matrixTransient[key];
917
+ if (!t || (t.state !== 'held' && t.state !== 'cant-resolve' && t.state !== 'just-verified' && t.state !== 'broken')) {
918
+ state.matrixTransient[key] = { state: 'expired', at: now() };
919
+ state.recentOutcomes.unshift({ kind: 'expired', accountId, machineId, machineNickname: machineNick(machineId), at: now() });
920
+ }
921
+ releaseCellHold(key);
922
+ }
923
+ }
924
+ }
925
+
926
+ /** The episode is over server-side — release the cell's open-interaction marker so
927
+ * the guarded rebuild may replace it with the terminal presentation. */
928
+ function releaseCellHold(key) {
929
+ if (!els.matrix || typeof els.matrix.querySelectorAll !== 'function') return;
930
+ for (const cell of els.matrix.querySelectorAll('[data-interaction-open]')) {
931
+ if (cell.getAttribute && cell.getAttribute('data-cell-key') === key) cell.removeAttribute('data-interaction-open');
932
+ }
933
+ }
934
+
935
+ /** Guarded matrix rebuild from the cached bodies — used by handlers that just flipped a
936
+ * cell terminal so the explicit presentation shows immediately (not 30s later). Respects
937
+ * the F9 hold: skipped while any OTHER interaction is open (the next tick catches up). */
938
+ function rerenderMatrixFromCache() {
939
+ if (!els.matrix || !state.lastPoolBody) return;
940
+ if (hasOpenInteraction(doc, els.matrix)) return;
941
+ renderAccountMatrix(doc, els.matrix, state.lastPoolBody, state.lastPendingBody, state.matrixTransient);
942
+ }
943
+
597
944
  function render(accountsBody, pendingBody, inUseBody, poolBody) {
598
945
  const accounts = accountsBody && Array.isArray(accountsBody.accounts) ? accountsBody.accounts : [];
599
946
  const logins = pendingBody && Array.isArray(pendingBody.logins) ? pendingBody.logins : [];
600
947
  const inUseAccountId = inUseBody && inUseBody.activeAccountId ? inUseBody.activeAccountId : null;
601
948
  renderAccounts(doc, els.accounts, accounts, now(), inUseAccountId);
602
- renderPendingLogins(doc, els.pending, logins, now());
603
- wireCodeSubmit();
949
+ purgeTransients();
950
+ // Episode reconciliation runs FIRST so a terminal transition it derives (expired /
951
+ // landed) is visible to BOTH surfaces on this same tick (the panel card + the cell).
952
+ state.lastPoolBody = poolBody || null;
953
+ state.lastPendingBody = pendingBody || null;
954
+ if (els.matrix) reconcileEpisodes(poolBody, pendingBody);
955
+ // F9 (Dashboard UX Standard): every surface that can hold an operator interaction is
956
+ // rebuilt ONLY while no interaction is open; a held surface gets targeted merge updates
957
+ // (live countdowns) instead. This is the structural fix for the topic 29836 D1 defect
958
+ // (the poll reverting a PIN input / swapping the code step out mid-paste).
959
+ if (els.pending) {
960
+ if (!hasOpenInteraction(doc, els.pending)) {
961
+ renderPendingLogins(doc, els.pending, logins, now(), state.recentOutcomes);
962
+ } else {
963
+ updateCountdowns(doc, els.pending, now());
964
+ }
965
+ wireCodeSubmit();
966
+ }
604
967
  // The account × machine matrix (account-machine-matrix) — built from the pool-scope read +
605
968
  // the (already pool-scope) pending logins. Hidden when the pool-scope read is unavailable.
606
969
  if (els.matrix) {
607
- state.lastPoolBody = poolBody || null;
608
- state.lastPendingBody = pendingBody || null;
609
- renderAccountMatrix(doc, els.matrix, poolBody, pendingBody, state.matrixTransient);
970
+ if (!hasOpenInteraction(doc, els.matrix)) {
971
+ renderAccountMatrix(doc, els.matrix, poolBody, pendingBody, state.matrixTransient);
972
+ } else {
973
+ updateCountdowns(doc, els.matrix, now());
974
+ }
610
975
  wireMatrixSetup();
611
976
  }
612
977
  // The one-tap follow-me Approve card(s) — rendered into els.followMe from the scan offers
613
978
  // (ws52-operator-tap-not-text Part A). Silent when there are none. The Approve click is wired
614
- // once (delegated) so re-renders never stack listeners.
979
+ // once (delegated) so re-renders never stack listeners. Same F9 hold: a half-typed PIN on an
980
+ // Approve card is an open interaction the poll must not clobber.
615
981
  if (els.followMe) {
616
- renderFollowMeOffers(doc, els.followMe, state.offers);
982
+ if (!hasOpenInteraction(doc, els.followMe)) {
983
+ renderFollowMeOffers(doc, els.followMe, state.offers);
984
+ }
617
985
  wireApprove();
618
986
  }
619
987
  }
@@ -681,25 +1049,50 @@ export function createController(opts) {
681
1049
  if (!id) { setRowStatus(row, 'Couldn’t identify this login — please refresh.'); return; }
682
1050
  setRowStatus(row, 'Sending your code…');
683
1051
  btn.setAttribute('disabled', '1');
1052
+ // F9: the row is an open interaction while the submit is in flight — a poll rebuild
1053
+ // would detach the very node the outcome is about to be written into.
1054
+ row.setAttribute('data-interaction-open', 'submitting');
684
1055
  void (async () => {
685
1056
  try {
686
1057
  const r = await postJson(URLS.submitCode, { machineId, id, code });
687
1058
  if (r.ok && r.json && r.json.outcome === 'validated') {
688
- setRowStatus(row, 'Done — this machine is set up with the account.');
689
1059
  if (input) input.value = '';
1060
+ row.removeAttribute('data-interaction-open');
1061
+ recordSubmitOutcome('validated', { accountId: id, machineId }, r.json);
1062
+ // Explicit terminal presentation in place (D4) — the next rebuild shows the
1063
+ // durable ✓ Done card from recentOutcomes.
1064
+ row.setAttribute('class', 'sub-pending sub-pending-done');
1065
+ setRowStatus(row, `✓ Done — ${r.json.email ? r.json.email + ' is' : 'this account is'} set up on this machine.`);
1066
+ rerenderMatrixFromCache();
690
1067
  } else if (r.ok && r.json && r.json.outcome === 'submitted') {
691
1068
  setRowStatus(row, 'Code sent — finishing sign-in…');
692
1069
  if (input) input.value = '';
1070
+ row.removeAttribute('data-interaction-open');
693
1071
  } else if (r.ok && r.json && r.json.outcome === 'held') {
694
- setRowStatus(row, 'Signed in, but the account didn’t match what was approved — check with the operator.');
1072
+ if (input) input.value = '';
1073
+ row.removeAttribute('data-interaction-open');
1074
+ recordSubmitOutcome('held', { accountId: id, machineId }, r.json);
1075
+ row.setAttribute('class', 'sub-pending sub-pending-failed');
1076
+ // Plain language naming BOTH accounts (D3) — never a bare "didn't match".
1077
+ setRowStatus(row, `✗ ${heldExplanation(r.json.expected, r.json.got, r.json.reason)}`);
1078
+ rerenderMatrixFromCache();
695
1079
  btn.removeAttribute('disabled');
1080
+ } else if (r.json && r.json.code === 'pane-dead') {
1081
+ // D5: the attempt's window is gone — flip to the explicit needs-restart state.
1082
+ row.removeAttribute('data-interaction-open');
1083
+ recordSubmitOutcome('broken', { accountId: id, machineId }, r.json);
1084
+ row.setAttribute('class', 'sub-pending sub-pending-failed');
1085
+ setRowStatus(row, `✗ ${r.json.error || 'This sign-in’s window is gone — start it again from the grid above.'}`);
1086
+ rerenderMatrixFromCache();
696
1087
  } else {
697
1088
  const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
698
1089
  setRowStatus(row, `Couldn’t submit the code: ${msg}`);
1090
+ row.removeAttribute('data-interaction-open');
699
1091
  btn.removeAttribute('disabled');
700
1092
  }
701
1093
  } catch (e) {
702
1094
  setRowStatus(row, 'Couldn’t reach the server — try again.');
1095
+ row.removeAttribute('data-interaction-open');
703
1096
  btn.removeAttribute('disabled');
704
1097
  }
705
1098
  })();
@@ -730,6 +1123,8 @@ export function createController(opts) {
730
1123
  if (codeBtn && els.matrix.contains(codeBtn)) { onCodeTap(codeBtn); return; }
731
1124
  const cancelBtn = t.closest('[data-matrix-cancel]');
732
1125
  if (cancelBtn && els.matrix.contains(cancelBtn)) { onCancelTap(cancelBtn); return; }
1126
+ const collapseBtn = t.closest('[data-matrix-collapse]');
1127
+ if (collapseBtn && els.matrix.contains(collapseBtn)) { onCollapseTap(collapseBtn); return; }
733
1128
  });
734
1129
  }
735
1130
 
@@ -743,12 +1138,17 @@ export function createController(opts) {
743
1138
  }
744
1139
 
745
1140
  // Expand the cell into a PIN input + Confirm (replacing the "Set up"/"Retry" button).
1141
+ // The cell becomes an OPEN INTERACTION the moment it expands (F9): the marker holds it
1142
+ // through the poll until the flow reaches a terminal state or the operator backs out.
746
1143
  function onSetupTap(btn) {
747
1144
  const cell = matrixCellOf(btn);
748
1145
  if (!cell) return;
749
1146
  const accountId = btn.getAttribute('data-account-id');
750
1147
  const machineId = btn.getAttribute('data-machine-id');
751
1148
  if (!accountId || !machineId) return;
1149
+ // A retry clears the previous attempt's terminal presentation for this cell.
1150
+ delete state.matrixTransient[`${accountId}::${machineId}`];
1151
+ cell.setAttribute('data-interaction-open', 'pin-entry');
752
1152
  btn.remove();
753
1153
  const pin = doc.createElement('input');
754
1154
  pin.setAttribute('type', 'password');
@@ -761,9 +1161,25 @@ export function createController(opts) {
761
1161
  confirm.setAttribute('data-account-id', accountId);
762
1162
  confirm.setAttribute('data-machine-id', machineId);
763
1163
  cell.appendChild(confirm);
1164
+ // An explicit way OUT of the interaction (the hold would otherwise pin the cell
1165
+ // forever if the operator changes their mind) — client-side only, nothing started yet.
1166
+ const back = el(doc, 'button', 'sub-matrix-collapse', 'Back');
1167
+ back.setAttribute('data-matrix-collapse', '1');
1168
+ cell.appendChild(back);
1169
+ }
1170
+
1171
+ // Back out of an un-started PIN entry: release the hold and restore the cell from cache.
1172
+ function onCollapseTap(btn) {
1173
+ const cell = matrixCellOf(btn);
1174
+ if (!cell) return;
1175
+ cell.removeAttribute('data-interaction-open');
1176
+ const pin = cell.querySelector('.sub-matrix-pin');
1177
+ if (pin) pin.value = '';
1178
+ rerenderMatrixFromCache();
764
1179
  }
765
1180
 
766
- // Confirm → POST the PIN-gated start-cell; render the auth link + code input on success.
1181
+ // Confirm → POST the PIN-gated start-cell; render the COMPLETE in-cell sign-in flow on
1182
+ // success (link + expected-account warning + code input + TTL + notice + Cancel — D2).
767
1183
  function onConfirmTap(btn) {
768
1184
  const cell = matrixCellOf(btn);
769
1185
  if (!cell) return;
@@ -780,9 +1196,16 @@ export function createController(opts) {
780
1196
  const r = await postJson(URLS.startCell, { accountId, machineId, pin });
781
1197
  if (pinInput) pinInput.value = ''; // PIN is memory-only — clear it immediately
782
1198
  if (r.ok && r.json && r.json.verificationUrl) {
783
- renderCellSignIn(cell, accountId, machineId, r.json.verificationUrl, r.json.loginId || accountId);
1199
+ // Episode opens: the enrollment is live server-side; the reconciler now owns
1200
+ // detecting its expiry / landing when the client misses the terminal response.
1201
+ state.matrixEpisodes[`${accountId}::${machineId}`] = { loginId: r.json.loginId || accountId, at: now() };
1202
+ renderCellSignIn(cell, {
1203
+ accountId, machineId, loginId: r.json.loginId || accountId,
1204
+ verificationUrl: r.json.verificationUrl, expectedEmail: r.json.expectedEmail,
1205
+ ttlExpiresAt: r.json.ttlExpiresAt, notice: r.json.notice, kind: r.json.kind,
1206
+ });
784
1207
  } else if (r.status === 409) {
785
- state.matrixTransient[`${accountId}::${machineId}`] = { state: 'cant-resolve' };
1208
+ state.matrixTransient[`${accountId}::${machineId}`] = { state: 'cant-resolve', at: now() };
786
1209
  setCellStatus(cell, 'Can’t set this account up here — its details couldn’t be resolved.');
787
1210
  btn.removeAttribute('disabled');
788
1211
  } else {
@@ -797,36 +1220,16 @@ export function createController(opts) {
797
1220
  })();
798
1221
  }
799
1222
 
800
- // After start-cell: show the sign-in link (operator opens it) + a code input + Submit.
801
- function renderCellSignIn(cell, accountId, machineId, verificationUrl, loginId) {
802
- // Clear the PIN/Confirm UI, keep the status line.
1223
+ // After start-cell: the immediate in-cell render of the complete flow (the SAME structure
1224
+ // the poll rebuilds from server state, via the shared appendCellSignInFlow — D2 coherence:
1225
+ // one live attempt, one URL, on every surface).
1226
+ function renderCellSignIn(cell, flow) {
803
1227
  const pin = cell.querySelector('.sub-matrix-pin'); if (pin) pin.remove();
804
1228
  const confirm = cell.querySelector('[data-matrix-confirm]'); if (confirm) confirm.remove();
805
- setCellStatus(cell, 'Open the sign-in link, then paste the code below.');
806
- const href = trustedLoginUrl(verificationUrl);
807
- if (href) {
808
- const a = doc.createElement('a');
809
- a.setAttribute('href', href);
810
- a.setAttribute('target', '_blank');
811
- a.setAttribute('rel', 'noopener noreferrer');
812
- a.setAttribute('class', 'sub-matrix-signin');
813
- a.textContent = 'Sign in';
814
- cell.appendChild(a);
815
- } else {
816
- cell.appendChild(el(doc, 'div', 'sub-matrix-url', sanitizeForDisplay(verificationUrl, 'url')));
817
- }
818
- const code = doc.createElement('input');
819
- code.setAttribute('type', 'text');
820
- code.setAttribute('class', 'sub-matrix-code-input');
821
- code.setAttribute('placeholder', 'Paste your sign-in code');
822
- code.setAttribute('autocomplete', 'off');
823
- cell.appendChild(code);
824
- const submit = el(doc, 'button', 'sub-matrix-code-submit', 'Submit');
825
- submit.setAttribute('data-matrix-code-submit', '1');
826
- submit.setAttribute('data-account-id', accountId);
827
- submit.setAttribute('data-machine-id', machineId);
828
- submit.setAttribute('data-login-id', loginId);
829
- cell.appendChild(submit);
1229
+ const back = cell.querySelector('[data-matrix-collapse]'); if (back) back.remove();
1230
+ const status = cell.querySelector('.sub-matrix-status'); if (status) status.remove();
1231
+ cell.setAttribute('data-interaction-open', 'signing-in');
1232
+ appendCellSignInFlow(doc, cell, flow, now());
830
1233
  }
831
1234
 
832
1235
  // Submit the pasted code via the SHIPPED submit-code relay (unchanged contract).
@@ -841,32 +1244,68 @@ export function createController(opts) {
841
1244
  if (!code) { setCellStatus(cell, 'Paste the code the sign-in page gave you, then tap Submit.'); return; }
842
1245
  setCellStatus(cell, 'Sending your code…');
843
1246
  btn.setAttribute('disabled', '1');
1247
+ cell.setAttribute('data-interaction-open', 'submitting');
844
1248
  void (async () => {
845
1249
  try {
846
1250
  const r = await postJson(URLS.submitCode, { machineId, id: loginId, code });
847
1251
  if (input) input.value = ''; // code is memory-only — cleared after use
848
- const key = `${accountId}::${machineId}`;
849
1252
  if (r.ok && r.json && r.json.outcome === 'validated') {
850
- delete state.matrixTransient[key];
851
- setCellStatus(cell, 'Done — this machine is set up with the account.');
1253
+ // TERMINAL SUCCESS (D4): unmistakable in-cell presentation the moment the
1254
+ // enrollment verifies; the transient keeps the ceremony on the next rebuilds.
1255
+ cell.removeAttribute('data-interaction-open');
1256
+ recordSubmitOutcome('validated', { accountId, machineId }, r.json);
1257
+ renderCellDone(cell, r.json.email || null);
852
1258
  } else if (r.ok && r.json && r.json.outcome === 'submitted') {
853
1259
  setCellStatus(cell, 'Code sent — finishing sign-in…');
1260
+ cell.removeAttribute('data-interaction-open');
854
1261
  } else if (r.ok && r.json && r.json.outcome === 'held') {
855
- state.matrixTransient[key] = { state: 'held' };
856
- setCellStatus(cell, 'Signed in, but the account didn’t match what was approved — re-try with the right account.');
857
- btn.removeAttribute('disabled');
1262
+ // TERMINAL REFUSAL (D3): the identity gate held the enrollment — name BOTH
1263
+ // accounts in plain language; the wrong account was NOT enrolled.
1264
+ cell.removeAttribute('data-interaction-open');
1265
+ recordSubmitOutcome('held', { accountId, machineId }, r.json);
1266
+ renderCellHeld(cell, accountId, machineId, r.json);
1267
+ } else if (r.json && r.json.code === 'pane-dead') {
1268
+ // TERMINAL (D5): the sign-in window is gone — explicit needs-restart state.
1269
+ cell.removeAttribute('data-interaction-open');
1270
+ recordSubmitOutcome('broken', { accountId, machineId }, r.json);
1271
+ rerenderMatrixFromCache();
858
1272
  } else {
859
1273
  const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
860
1274
  setCellStatus(cell, `Couldn’t submit the code: ${msg}`);
1275
+ cell.setAttribute('data-interaction-open', 'signing-in');
861
1276
  btn.removeAttribute('disabled');
862
1277
  }
863
1278
  } catch (e) {
864
1279
  setCellStatus(cell, 'Couldn’t reach the server — try again.');
1280
+ cell.setAttribute('data-interaction-open', 'signing-in');
865
1281
  btn.removeAttribute('disabled');
866
1282
  }
867
1283
  })();
868
1284
  }
869
1285
 
1286
+ // The in-cell terminal SUCCESS presentation (D4): unmistakable, named, highlighted.
1287
+ function renderCellDone(cell, email) {
1288
+ cell.replaceChildren();
1289
+ cell.setAttribute('class', 'sub-matrix-cell sub-matrix-just-verified sub-matrix-done');
1290
+ cell.appendChild(el(doc, 'div', 'sub-matrix-glyph', '✓ All set'));
1291
+ cell.appendChild(el(doc, 'div', 'sub-matrix-done-detail',
1292
+ email ? `${sanitizeForDisplay(email, 'label')} is signed in on this machine.` : 'The account is signed in on this machine.'));
1293
+ }
1294
+
1295
+ // The in-cell terminal HELD presentation (D3): both accounts named + a working Retry.
1296
+ function renderCellHeld(cell, accountId, machineId, body) {
1297
+ cell.replaceChildren();
1298
+ cell.setAttribute('class', 'sub-matrix-cell sub-matrix-held');
1299
+ cell.appendChild(el(doc, 'div', 'sub-matrix-glyph', `${MATRIX_CELL_GLYPH.held} ${MATRIX_CELL_WORD.held}`));
1300
+ cell.appendChild(el(doc, 'div', 'sub-matrix-held-detail',
1301
+ heldExplanation(body && body.expected, body && body.got, body && body.reason, { short: true })));
1302
+ const btn = el(doc, 'button', 'sub-matrix-setup', 'Retry');
1303
+ btn.setAttribute('data-matrix-setup', '1');
1304
+ btn.setAttribute('data-account-id', sanitizeForDisplay(accountId, 'label'));
1305
+ btn.setAttribute('data-machine-id', sanitizeForDisplay(machineId, 'label'));
1306
+ cell.appendChild(btn);
1307
+ }
1308
+
870
1309
  // Cancel an in-flight cell: POST the Bearer-only cancel relay (self/peer), abandoning
871
1310
  // the login + tearing down its pane. No PIN (mirrors the code-submit step — a PIN can't
872
1311
  // cross the mesh). Reversible: the cell frees up to re-tap "Set up". Guarded by a native
@@ -889,7 +1328,12 @@ export function createController(opts) {
889
1328
  try {
890
1329
  const r = await postJson(URLS.cancel, { machineId, id: accountId });
891
1330
  if (r.ok && r.json && (r.json.cancelled || r.json.alreadyTerminal)) {
892
- delete state.matrixTransient[`${accountId}::${machineId}`];
1331
+ // TERMINAL (cancelled): the episode is over — release the hold so the next
1332
+ // rebuild replaces the flow with a fresh "Set up" from server state.
1333
+ const key = `${accountId}::${machineId}`;
1334
+ delete state.matrixTransient[key];
1335
+ delete state.matrixEpisodes[key];
1336
+ cell.removeAttribute('data-interaction-open');
893
1337
  setCellStatus(cell, 'Cancelled — you can set this up again.');
894
1338
  } else {
895
1339
  const msg = (r.json && (r.json.error || r.json.reason)) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
@@ -926,5 +1370,7 @@ if (typeof window !== 'undefined') {
926
1370
  createController, sanitizeForDisplay, renderAccounts, renderPendingLogins,
927
1371
  renderFollowMeOffers, renderFollowMeApproveCard, buildFollowMeIssuePayload,
928
1372
  renderAccountMatrix, buildMatrixModel,
1373
+ hasOpenInteraction, updateCountdowns, heldExplanation, appendCellSignInFlow,
1374
+ renderOutcomeCard, friendlyMachine,
929
1375
  };
930
1376
  }