@bongos/core 1.19.339 → 1.19.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.
@@ -40,6 +40,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
40
40
 
41
41
  Same trust boundary as /create — every button only ASKS (enqueues an
42
42
  intent); the control-plane runner does the real infra work (ADR 0111 §2). */
43
+ /* the skip link un-hides ITSELF on focus (0,2,0 beats cosmos's .vh at 0,1,0):
44
+ every property .vh sets is reversed here, so it is a real pill on first Tab */
45
+ .skip:focus{position:fixed;top:8px;left:8px;z-index:100;width:auto;height:auto;margin:0;overflow:visible;clip-path:none;white-space:normal;
46
+ font-size:14px;font-weight:800;text-decoration:none;color:var(--ink);background:var(--bg-card);
47
+ border:2px solid var(--ctl-line);border-radius:12px;padding:10px 16px;}
43
48
  .viewWrap{padding-bottom:40px;}
44
49
  .card{margin-bottom:26px;}
45
50
  /* page titles align with the cards below them — cosmos's .hero is 880px-centred
@@ -255,9 +260,13 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
255
260
  .again{
256
261
  display:inline-block;margin-top:14px;font-size:13px;font-weight:700;color:var(--ink-faint);
257
262
  background:none;border:none;cursor:pointer;font-family:var(--font-body);text-decoration:underline;
258
- text-underline-offset:3px;padding:0;
263
+ text-underline-offset:3px;padding:6px 2px;min-height:24px;
259
264
  }
260
265
  .again:hover{color:var(--accent-deep);}
266
+ /* every fold on this page is a real target: these summaries are 12.5–13px text
267
+ and measured under the 24px minimum (WCAG 2.2 target size, AA). Not
268
+ display:flex — that would drop the disclosure marker every fold shows today. */
269
+ summary{min-height:24px;padding:3px 0;}
261
270
 
262
271
  /* the collapsed address step — a confirmation, not a form (task 1002704) */
263
272
  .addrConfirm{border:2px solid color-mix(in srgb,var(--ok) 30%,var(--bg-card));border-radius:18px;padding:18px 20px;background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);margin-bottom:16px;}
@@ -285,7 +294,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
285
294
  .ghrow{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:none;border:0;border-bottom:1px solid color-mix(in srgb,var(--ink) 8%,transparent);padding:9px 12px;font:inherit;font-size:13.5px;color:var(--ink);cursor:pointer;}
286
295
  .ghrow:last-child{border-bottom:0;}
287
296
  .ghrow:hover{background:color-mix(in srgb,var(--accent) 6%,var(--bg-deep));}
288
- .ghrow[aria-selected=true]{background:color-mix(in srgb,var(--accent) 12%,var(--bg-deep));box-shadow:inset 3px 0 0 var(--accent);font-weight:600;}
297
+ .ghrow[aria-current=true]{background:color-mix(in srgb,var(--accent) 12%,var(--bg-deep));box-shadow:inset 3px 0 0 var(--accent);font-weight:600;}
289
298
  /* --ink-soft, not --ink-faint: the badge tint is the darkest ground on the page,
290
299
  and faint ink only reaches 3.96:1 on it (task 1002749). */
291
300
  .ghrow .priv{margin-left:auto;font-size:11px;font-weight:600;color:var(--ink-soft);background:color-mix(in srgb,var(--ink) 8%,transparent);border-radius:5px;padding:1px 6px;}
@@ -309,6 +318,12 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
309
318
  /* 840px matches cosmos's createSplit collapse, the breakpoint the old /create
310
319
  used (860) — the two-up grids and the bongo cell fold together */
311
320
  @media (max-width:840px){.row2,.choices{grid-template-columns:1fr;}}
321
+
322
+ /* cosmos's global reduced-motion kill switch zeroes every animation, which
323
+ leaves the mark's blinking caret parked at FULL opacity — a solid bar beside
324
+ the wordmark forever. Page-local on purpose: a second reduce block in
325
+ cosmos.css would shadow the first one tests/landing_page.mjs extracts. */
326
+ @media (prefers-reduced-motion:reduce){.shell .caret{display:none;}}
312
327
  </style>
313
328
  </head>
314
329
  <body>
@@ -340,6 +355,10 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
340
355
  page. They sit in one control because they are all "where can I go from
341
356
  here", but the divider keeps the distinction honest — Home navigates and
342
357
  never takes the current-view pill. ── -->
358
+ <!-- keyboard skip: the chrome above every view is a wordmark, a four-item nav
359
+ and a right-hand cluster. Hidden until focused (.vh), it becomes a real
360
+ pill on first Tab and jumps to the #hubMain landmark. -->
361
+ <a class="skip vh" href="#hubMain">Skip to content</a>
343
362
  <header class="shell">
344
363
  <!-- The mark types the PREFIX and holds `bongos` still, because `bongos` is
345
364
  the word you type and the assistant is what varies — which is also what
@@ -383,7 +402,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
383
402
  What you land on, signed in or out (owner, 2026-08-13). The orbs are
384
403
  placed statically on purpose: where things sit is this pass; how the sky
385
404
  moves is not. -->
405
+ <main id="hubMain">
386
406
  <section id="view-map">
407
+ <h1 class="vh">The project map</h1>
387
408
  <div class="toolbar">
388
409
  <div class="seg" role="group" aria-label="Map display">
389
410
  <button type="button" id="sub-sky" aria-pressed="true">Sky</button>
@@ -441,9 +462,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
441
462
  <table class="tbl">
442
463
  <thead>
443
464
  <tr>
444
- <th class="colName" data-sort="name" aria-sort="ascending">Project</th>
465
+ <th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project</button></th>
445
466
  <th class="colType">Type</th>
446
- <th class="right" data-sort="builders">Builders</th>
467
+ <th class="right" data-sort="builders"><button type="button" class="thSort">Builders</button></th>
447
468
  <th class="colTags">Runs</th>
448
469
  <th class="right colStatus">Status</th>
449
470
  <th class="colGo"></th>
@@ -468,7 +489,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
468
489
  <!--apex-only:end-->
469
490
  </tbody>
470
491
  </table>
471
- <p class="finePrint" style="text-align:center">Click a column to sort.</p>
492
+ <p class="finePrint" style="text-align:center">Sort by Project or Builders.</p>
472
493
  </div>
473
494
 
474
495
  <!-- The project card. An OVERLAY inside the map, not a fourth view: opening
@@ -519,7 +540,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
519
540
  Each card says what the platform <b>last verified</b>, and when.</p>
520
541
  </div>
521
542
 
522
- <main class="card">
543
+ <div class="card">
523
544
  <!-- signed-out gate: identity-only sign-in (ADR 0143 least-privilege — unlike
524
545
  /create, listing your own instances needs no repo scope) -->
525
546
  <section id="gate" hidden>
@@ -546,11 +567,11 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
546
567
  <span class="count" id="mineCount"></span>
547
568
  </div>
548
569
  <div class="filters" role="group" aria-label="Filter by state">
549
- <button type="button" data-f="all" aria-pressed="true">All <span class="n">0</span></button>
550
- <button type="button" data-f="live" aria-pressed="false">Live <span class="n">0</span></button>
551
- <button type="button" data-f="attention" aria-pressed="false">Needs you <span class="n">0</span></button>
552
- <button type="button" data-f="working" aria-pressed="false">Working <span class="n">0</span></button>
553
- <button type="button" data-f="gone" aria-pressed="false">Torn down <span class="n">0</span></button>
570
+ <button type="button" data-f="all" aria-controls="projectsList" aria-pressed="true">All <span class="n">0</span></button>
571
+ <button type="button" data-f="live" aria-controls="projectsList" aria-pressed="false">Live <span class="n">0</span></button>
572
+ <button type="button" data-f="attention" aria-controls="projectsList" aria-pressed="false">Needs you <span class="n">0</span></button>
573
+ <button type="button" data-f="working" aria-controls="projectsList" aria-pressed="false">Working <span class="n">0</span></button>
574
+ <button type="button" data-f="gone" aria-controls="projectsList" aria-pressed="false">Torn down <span class="n">0</span></button>
554
575
  </div>
555
576
  </div>
556
577
  <p class="mineEmpty" id="mineEmpty" hidden>Nothing matches that. <button type="button" class="linklike" id="mineClear">Clear the filters</button></p>
@@ -562,7 +583,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
562
583
  <ul class="projects" id="projectsList"></ul>
563
584
  <p class="finePrint">Buttons here ask the platform to act; each card reports only what was verified, and when. A standup keeps going even with this tab closed.</p>
564
585
  </section>
565
- </main>
586
+ </div>
566
587
  </div>
567
588
  </section>
568
589
 
@@ -802,7 +823,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
802
823
  <div id="ghAdopt" class="wizField" hidden>
803
824
  <span>Pick one of your repositories</span>
804
825
  <input type="text" id="ghSearch" placeholder="Search your repos…" autocomplete="off" aria-label="Search your repositories">
805
- <div id="ghList" class="ghlist" role="listbox" aria-label="Your repositories"></div>
826
+ <div id="ghList" class="ghlist" role="group" aria-label="Your repositories"></div>
806
827
  <div class="hint" id="ghAdoptHint">&nbsp;</div>
807
828
  <!-- visibility stated on EVERY repo path (task 1002721) — the picker
808
829
  marks private repos, and this says what adoption does about it -->
@@ -896,7 +917,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
896
917
  <h1 id="done-h">The drums are rolling</h1>
897
918
  </div>
898
919
  <p class="lede" id="doneLede"></p>
899
- <div id="liveProgress"></div>
920
+ <!-- the STABLE live region: the strip inside is rebuilt every 5s tick,
921
+ and a live region that is itself replaced never announces -->
922
+ <div id="liveProgress" role="status" aria-live="polite"></div>
900
923
  <h2 style="font-family:var(--font-display);font-size:19px;margin-top:22px;">Your steps — the parts only you can play</h2>
901
924
  <ol class="humanSteps" id="humanSteps"></ol>
902
925
  <p class="samePath">These are the same steps however a project is created — nothing here is special to this page.</p>
@@ -977,7 +1000,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
977
1000
  </div>
978
1001
  </section>
979
1002
 
980
- <div class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.</div>
1003
+ </main>
1004
+
1005
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.</footer>
981
1006
 
982
1007
  <script>
983
1008
  /* Brand-pack fill (R57): the server injects window.__BRANDING__ ahead of this
@@ -1008,7 +1033,15 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1008
1033
  if (m === 'light' || m === 'dark') return m;
1009
1034
  return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
1010
1035
  }
1011
- function paintModeToggle() { $('modeToggle').textContent = resolvedMode() === 'dark' ? '☀' : '☾'; }
1036
+ /* the glyph shows the mode you would GET, so the label must say the same:
1037
+ a bare ☀/☾ announces as "sun"/"moon" and nothing about what the press does.
1038
+ No aria-pressed — this swaps a mode, it is not a two-state toggle button. */
1039
+ function paintModeToggle() {
1040
+ var dark = resolvedMode() === 'dark';
1041
+ var el = $('modeToggle');
1042
+ el.textContent = dark ? '☀' : '☾';
1043
+ el.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
1044
+ }
1012
1045
  $('modeToggle').addEventListener('click', function () {
1013
1046
  var next = resolvedMode() === 'dark' ? 'light' : 'dark';
1014
1047
  document.documentElement.setAttribute('data-mode', next);
@@ -1022,7 +1055,20 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1022
1055
  });
1023
1056
  }
1024
1057
 
1025
- var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1058
+ /* HELD, not sampled once: a visitor who flips "reduce motion" mid-session must
1059
+ stop the sky then and there (and get it back if they flip it off), instead
1060
+ of being stuck with whatever the preference was at load. addListener is the
1061
+ Safari <14 fallback — MediaQueryList only grew addEventListener later. */
1062
+ var REDUCED_MQ = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
1063
+ var REDUCED = !!(REDUCED_MQ && REDUCED_MQ.matches);
1064
+ if (REDUCED_MQ) {
1065
+ var onReducedChange = function (ev) {
1066
+ REDUCED = !!(ev && typeof ev.matches === 'boolean' ? ev.matches : REDUCED_MQ.matches);
1067
+ if (REDUCED) stopSky(); else startSky();
1068
+ };
1069
+ if (REDUCED_MQ.addEventListener) REDUCED_MQ.addEventListener('change', onReducedChange);
1070
+ else if (REDUCED_MQ.addListener) REDUCED_MQ.addListener(onReducedChange);
1071
+ }
1026
1072
 
1027
1073
  /* ══════════════════════════════════════════════════════════════════════════
1028
1074
  THE HUB SHELL — one surface, three views, one real URL.
@@ -1111,7 +1157,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1111
1157
  });
1112
1158
  /* Back / Forward land on the view the URL names — viewFromUrl() reads the
1113
1159
  manage id too, so a manage entry restores its project */
1114
- window.addEventListener('popstate', function () { setView(viewFromUrl()); });
1160
+ window.addEventListener('popstate', function () { closeCard(); setView(viewFromUrl()); });
1115
1161
 
1116
1162
  /* ── the self-typing mark. `bongos` is the CONSTANT — it is the word you
1117
1163
  type — and the prefix is what varies, which is exactly what the vanity
@@ -1124,6 +1170,11 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1124
1170
  var prefix = parts.join(' ');
1125
1171
  $('markWord').textContent = word;
1126
1172
  $('markPrefix').textContent = prefix;
1173
+ /* the link's name is its two spans, so mid-type it announces "gem bongos".
1174
+ Label the LINK instead — before the early return, so a re-branded or
1175
+ reduced-motion visitor gets it too. #markLeft stays visible to AT:
1176
+ tests/projects_hub.mjs pins that markup literal. */
1177
+ $('markLeft').parentNode.setAttribute('aria-label', productName + ' — home');
1127
1178
  if (REDUCED || prefix !== 'cloud' || word !== 'bongos') return;
1128
1179
  var PREFIXES = ['cloud', 'claude', 'chatgpt', 'gemini'];
1129
1180
 
@@ -1367,7 +1418,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1367
1418
  'data-origin="' + esc(String(p.origin || '')) + '">' +
1368
1419
  '<td class="colName"><span class="pname"><span class="pdot" style="--acc:' +
1369
1420
  accentOf(p) + '"></span>' +
1370
- '<span><span class="ptitle">' + esc(p.name) + '</span>' + pill + '<br>' +
1421
+ '<span>' + (p._inst
1422
+ ? '<a class="ptitle" data-manage="' + esc(String(p._inst.id)) + '" href="?view=manage&id=' + esc(String(p._inst.id)) + '">' + esc(p.name) + '</a>'
1423
+ : '<span class="ptitle">' + esc(p.name) + '</span>') + pill + '<br>' +
1371
1424
  '<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
1372
1425
  /* Builders and Runs come from the project's OWN public API, enriched
1373
1426
  server-side (task 1002771). null is unknown — the project was
@@ -1561,12 +1614,24 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1561
1614
  : '';
1562
1615
  cardReturn = returnTo || null;
1563
1616
  $('cardScrim').hidden = false;
1617
+ pageInert(true);
1564
1618
  $('cardClose').focus();
1565
1619
  }
1566
1620
 
1621
+ /* While the dialog is open the page behind it is inert (task 1003246): no tab
1622
+ stop, no screen-reader route out into the map. aria-modal alone only tells
1623
+ AT; it does not stop Tab. The card is the MAP's own child, so the map is
1624
+ inerted a child at a time — inert on an ancestor can never be lifted on a
1625
+ descendant, and inerting #view-map would inert the card with it. */
1626
+ function pageInert(on) {
1627
+ document.querySelectorAll('a.skip, header.shell, footer.foot, #view-mine, #view-new, #view-manage, #view-map > :not(#cardScrim)')
1628
+ .forEach(function (el) { if (on) el.setAttribute('inert', ''); else el.removeAttribute('inert'); });
1629
+ }
1630
+
1567
1631
  function closeCard() {
1568
1632
  if ($('cardScrim').hidden) return;
1569
1633
  $('cardScrim').hidden = true;
1634
+ pageInert(false); /* before the handback — an inert element cannot take focus */
1570
1635
  if (cardReturn && cardReturn.focus) cardReturn.focus();
1571
1636
  cardReturn = null;
1572
1637
  }
@@ -1611,6 +1676,14 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1611
1676
  }
1612
1677
 
1613
1678
  $('mapRows').addEventListener('click', function (ev) {
1679
+ /* an owned row's title is a real manage anchor — a modified click keeps its
1680
+ browser meaning, a plain one stays in-page (the orb-click contract) */
1681
+ var m = ev.target.closest && ev.target.closest('a[data-manage]');
1682
+ if (m) {
1683
+ if (!isPlainClick(ev)) return;
1684
+ ev.preventDefault();
1685
+ return goToManage(m.getAttribute('data-manage'));
1686
+ }
1614
1687
  /* the row's own "Open →" keeps its job — the card is for the rest of the row */
1615
1688
  if (ev.target.closest && ev.target.closest('a')) return;
1616
1689
  var tr = ev.target.closest && ev.target.closest('tr');
@@ -1711,9 +1784,13 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
1711
1784
 
1712
1785
  $('onlyMine').addEventListener('change', applyMapFilter);
1713
1786
  $('mapQ').addEventListener('input', applyMapFilter);
1787
+ /* the sort control is the BUTTON, not the th: a th takes no focus and no
1788
+ Enter/Space, so the column sort was mouse-only. aria-sort stays on the th,
1789
+ which is where a screen reader reads it from. */
1714
1790
  document.querySelectorAll('.tbl th[data-sort]').forEach(function (th) {
1715
- th.style.cursor = 'pointer';
1716
- th.addEventListener('click', function () {
1791
+ var btn = th.querySelector('button');
1792
+ if (!btn) return;
1793
+ btn.addEventListener('click', function () {
1717
1794
  var k = th.getAttribute('data-sort');
1718
1795
  mapSort.dir = (mapSort.key === k) ? -mapSort.dir : 1;
1719
1796
  mapSort.key = k;
@@ -2413,6 +2490,19 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2413
2490
  li.classList.toggle('now', now);
2414
2491
  li.classList.toggle('done', landed || n < cur);
2415
2492
  if (now) li.setAttribute('aria-current', 'step'); else li.removeAttribute('aria-current');
2493
+ /* a step already answered is a way BACK (task 1003246): only those take
2494
+ the button role, the tab stop and the data-goto the delegated handler
2495
+ below reads. The done panel offers none — the project is filed, and
2496
+ there is nothing behind it to change. */
2497
+ if (step < 6 && n < cur) {
2498
+ li.setAttribute('role', 'button');
2499
+ li.setAttribute('tabindex', '0');
2500
+ li.setAttribute('data-goto', String(n));
2501
+ } else {
2502
+ li.removeAttribute('role');
2503
+ li.removeAttribute('tabindex');
2504
+ li.removeAttribute('data-goto');
2505
+ }
2416
2506
  });
2417
2507
  }
2418
2508
  function show(step, moveFocus) {
@@ -2444,6 +2534,24 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2444
2534
  if (step === 5) renderReview();
2445
2535
  }
2446
2536
 
2537
+ /* One delegated pair on the rail, so paintRail can add and drop data-goto
2538
+ freely (task 1003246). Enter and Space are the contract role="button"
2539
+ makes; Space must not scroll the card while it keeps it. */
2540
+ var railEl = root.querySelector('.steps');
2541
+ if (railEl) {
2542
+ railEl.addEventListener('click', function (ev) {
2543
+ var li = ev.target.closest && ev.target.closest('li[data-goto]');
2544
+ if (li) show(Number(li.getAttribute('data-goto')), true);
2545
+ });
2546
+ railEl.addEventListener('keydown', function (ev) {
2547
+ if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
2548
+ var li = ev.target.closest && ev.target.closest('li[data-goto]');
2549
+ if (!li) return;
2550
+ ev.preventDefault();
2551
+ show(Number(li.getAttribute('data-goto')), true);
2552
+ });
2553
+ }
2554
+
2447
2555
  /* ── field wiring ── */
2448
2556
  $('newName').value = state.name;
2449
2557
  $('f-owner').value = state.owner;
@@ -2601,7 +2709,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2601
2709
  var rows = repoList.filter(function (r) { return !effectiveQ || String(r.full_name).toLowerCase().indexOf(effectiveQ) >= 0; }).slice(0, 100);
2602
2710
  $('ghList').innerHTML = rows.length
2603
2711
  ? rows.map(function (r) {
2604
- return '<button type="button" class="ghrow" role="option" data-full="' + esc(r.full_name) + '" aria-selected="' + (r.full_name === sel) + '">' +
2712
+ return '<button type="button" class="ghrow" data-full="' + esc(r.full_name) + '" aria-current="' + (r.full_name === sel) + '">' +
2605
2713
  '<span>' + esc(r.full_name) + '</span>' + (r.private ? '<span class="priv">Private</span>' : '') + '</button>';
2606
2714
  }).join('')
2607
2715
  : '<div class="hint" style="padding:10px 12px">No repositories match “' + esc(effectiveQ) + '”.</div>';
@@ -2628,6 +2736,20 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2628
2736
  }
2629
2737
  $('ghSearch').addEventListener('focus', function () { this.select(); openRepoList(); });
2630
2738
  $('ghSearch').addEventListener('input', openRepoList);
2739
+ /* A dropdown must be dismissable and must not outlive the field it belongs
2740
+ to (task 1003246). Escape hands focus back to the box FIRST, because the
2741
+ box's own focus handler reopens the list — the collapse has to be the
2742
+ last word. A null relatedTarget is left alone: some browsers report it
2743
+ for a click on a row, and closing there would eat the pick. */
2744
+ $('ghAdopt').addEventListener('keydown', function (ev) {
2745
+ if (ev.key !== 'Escape' || $('ghList').hidden) return;
2746
+ ev.stopPropagation();
2747
+ $('ghSearch').focus();
2748
+ closeRepoList();
2749
+ });
2750
+ $('ghAdopt').addEventListener('focusout', function (ev) {
2751
+ if (ev.relatedTarget && !$('ghAdopt').contains(ev.relatedTarget)) closeRepoList();
2752
+ });
2631
2753
  $('ghList').addEventListener('click', function (ev) {
2632
2754
  var row = ev.target.closest('.ghrow'); if (!row) return;
2633
2755
  var full = row.getAttribute('data-full'); var slash = full.indexOf('/');
@@ -2944,6 +3066,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2944
3066
  var live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
2945
3067
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
2946
3068
  var lastStepsHtml = '';
3069
+ var lastProgHtml = ''; /* same change-only contract, for the progress strip */
2947
3070
  var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'device-flow', 'app-install', 'first-sign-in', 'enter-hall'];
2948
3071
 
2949
3072
  function fetchPlan() {
@@ -3279,22 +3402,39 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3279
3402
  revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
3280
3403
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
3281
3404
  };
3405
+ /* No live-region attributes here: this strip is destroyed and rebuilt on
3406
+ every 5s tick, and a live region that is itself replaced never announces.
3407
+ #liveProgress is the stable region; this is only its contents. (The
3408
+ mine-card twin stripMarkup() still carries its own — left for a later pass.) */
3282
3409
  function progressMarkup(stageNow, stagesDone, note, cls, flow) {
3283
3410
  var f = PROG_FLOWS[flow || 'standup'];
3284
- return '<div class="prog ' + (f.cls ? f.cls + ' ' : '') + (cls || '') + '" role="status" aria-live="polite">' +
3411
+ return '<div class="prog ' + (f.cls ? f.cls + ' ' : '') + (cls || '') + '">' +
3285
3412
  '<ul class="stages">' + f.names.map(function (n, i) {
3286
3413
  var c = stagesDone > i ? 'done' : (stageNow === i ? 'now' : '');
3287
- return '<li class="' + c + '"><span class="sdot"></span><span class="slbl">' + n + '</span></li>';
3414
+ /* the dot carries the state for the eye only — a screen reader needs
3415
+ the word, and the live stage marked, or it hears three bare names */
3416
+ var word = c === 'done' ? 'done' : (c === 'now' ? 'current' : 'upcoming');
3417
+ return '<li class="' + c + '"' + (c === 'now' ? ' aria-current="step"' : '') + '>' +
3418
+ '<span class="sdot"></span><span class="slbl">' + n + '</span>' +
3419
+ '<span class="vh">, ' + word + '</span></li>';
3288
3420
  }).join('') + '</ul>' +
3289
3421
  '<p class="pnote">' + note + '</p></div>';
3290
3422
  }
3423
+ /* Change-only write into the stable live region: an identical tick performs
3424
+ no DOM write at all, so the region announces real movement only — the same
3425
+ contract renderSteps() uses with lastStepsHtml. */
3426
+ function setProgress(html) {
3427
+ var el = $('liveProgress');
3428
+ if (!el || html === lastProgHtml) return;
3429
+ lastProgHtml = html;
3430
+ el.innerHTML = html;
3431
+ }
3291
3432
  /* Renders the current instance state; returns true when the state is terminal
3292
3433
  (no further polling wanted). The strip is chosen by the OPEN INTENT first,
3293
3434
  then status (F22): a torn-down instance with a queued revive must never be
3294
3435
  told to "start another project". */
3295
3436
  function renderProgress(inst) {
3296
- var el = $('liveProgress');
3297
- if (!el) return true;
3437
+ if (!$('liveProgress')) return true;
3298
3438
  var s = inst && inst.status;
3299
3439
  var revive = !!(inst && inst.torn_down_at);
3300
3440
  var oi = inst && inst.open_intent;
@@ -3303,23 +3443,23 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3303
3443
  would make the status branch dead and diverge from the mine card) */
3304
3444
  if (oi && s !== 'provisioning' && s !== 'tearing_down' && s !== 'requested') {
3305
3445
  if (oi.action === 'provision') {
3306
- el.innerHTML = progressMarkup(0, 0,
3446
+ setProgress(progressMarkup(0, 0,
3307
3447
  (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this panel updates when it starts.',
3308
- '', s === 'torn_down' ? 'revive' : 'standup');
3448
+ '', s === 'torn_down' ? 'revive' : 'standup'));
3309
3449
  return false;
3310
3450
  }
3311
3451
  if (oi.action === 'teardown') {
3312
- el.innerHTML = progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown');
3452
+ setProgress(progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown'));
3313
3453
  return false;
3314
3454
  }
3315
3455
  }
3316
3456
  if (s === 'requested') {
3317
- el.innerHTML = progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup');
3457
+ setProgress(progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup'));
3318
3458
  } else if (s === 'provisioning') {
3319
- el.innerHTML = progressMarkup(1, 1,
3459
+ setProgress(progressMarkup(1, 1,
3320
3460
  revive ? 'Bringing it back on the newest platform version — nothing needed from you.'
3321
3461
  : 'Raising the server, database, and address — nothing needed from you.',
3322
- '', revive ? 'revive' : 'standup');
3462
+ '', revive ? 'revive' : 'standup'));
3323
3463
  } else if (s === 'active') {
3324
3464
  /* Live lights on a VERIFIED answer, not on script completion (direction
3325
3465
  §7): the strip keys on the same last_verified_* overlay the manage
@@ -3333,39 +3473,39 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3333
3473
  /* domainless instances are never probed — there is no address to
3334
3474
  answer at, so a verified answer can never arrive; say what IS true
3335
3475
  instead of promising the hall (sign-in stays locked without one) */
3336
- el.innerHTML = progressMarkup(-1, 2,
3337
- 'The build finished — your project is standing, but it has no address yet, so sign-in can’t be wired up. Attach a domain any time from <a href="?view=mine">your projects page</a>; the steps below that need one unlock then.', '', flow);
3476
+ setProgress(progressMarkup(-1, 2,
3477
+ 'The build finished — your project is standing, but it has no address yet, so sign-in can’t be wired up. Attach a domain any time from <a href="?view=mine">your projects page</a>; the steps below that need one unlock then.', '', flow));
3338
3478
  return true;
3339
3479
  }
3340
3480
  if (inst.last_verified_up === false) {
3341
- el.innerHTML = progressMarkup(2, 2,
3342
- 'The build finished, but ' + link + ' isn’t answering right now — this panel keeps checking.', '', flow);
3481
+ setProgress(progressMarkup(2, 2,
3482
+ 'The build finished, but ' + link + ' isn’t answering right now — this panel keeps checking.', '', flow));
3343
3483
  return false;
3344
3484
  }
3345
3485
  if (inst.last_verified_up) {
3346
- el.innerHTML = progressMarkup(-1, 3,
3347
- 'Your project is answering at ' + link + '. Finish the steps below and the hall is yours.', 'isLive', flow);
3486
+ setProgress(progressMarkup(-1, 3,
3487
+ 'Your project is answering at ' + link + '. Finish the steps below and the hall is yours.', 'isLive', flow));
3348
3488
  return true;
3349
3489
  }
3350
3490
  /* null: standing, not checked yet — keep watching until the first probe */
3351
- el.innerHTML = progressMarkup(2, 2,
3352
- 'The build finished — checking that ' + link + ' answers…', '', flow);
3491
+ setProgress(progressMarkup(2, 2,
3492
+ 'The build finished — checking that ' + link + ' answers…', '', flow));
3353
3493
  return false;
3354
3494
  } else if (s === 'error') {
3355
3495
  /* the honest snag (direction §5 / F19): no raw stderr as the headline, no
3356
3496
  false "keeps retrying" — retries stop, and the retry door is the mine view */
3357
- el.innerHTML = progressMarkup(1, 1,
3358
- 'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr');
3497
+ setProgress(progressMarkup(1, 1,
3498
+ 'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr'));
3359
3499
  } else if (s === 'tearing_down') {
3360
3500
  /* a deliberate end is calm, never styled as a failure (direction §5) —
3361
3501
  and it speaks the teardown vocabulary, not the standup's (F22) */
3362
- el.innerHTML = progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
3363
- (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown');
3502
+ setProgress(progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
3503
+ (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown'));
3364
3504
  } else if (s === 'torn_down') {
3365
- el.innerHTML = progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.', '', 'teardown');
3505
+ setProgress(progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.', '', 'teardown'));
3366
3506
  return true;
3367
3507
  } else {
3368
- el.innerHTML = progressMarkup(0, 0, 'Filed. Watching for the standup to start…');
3508
+ setProgress(progressMarkup(0, 0, 'Filed. Watching for the standup to start…'));
3369
3509
  }
3370
3510
  return false;
3371
3511
  }
@@ -3377,8 +3517,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3377
3517
  var myGen = watchGen;
3378
3518
  var ticks = 0;
3379
3519
  var watchFlow = 'standup'; /* the vocabulary the strip is speaking (F22) */
3380
- var el = $('liveProgress');
3381
- el.innerHTML = progressMarkup(0, 0, 'Filed. Watching for the standup to start…');
3520
+ setProgress(progressMarkup(0, 0, 'Filed. Watching for the standup to start…'));
3382
3521
  function tick() {
3383
3522
  if (myGen !== watchGen) return;
3384
3523
  if (!wizPollActive()) { watchPending = true; stopPolling(); return; }
@@ -3391,8 +3530,8 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3391
3530
  /* a SNAGGED standup keeps its honest error — "still building" would be
3392
3531
  a lie exactly when the founder most needs the truth */
3393
3532
  if (live.instStatus !== 'error') {
3394
- $('liveProgress').innerHTML = progressMarkup(1, 1,
3395
- 'Still working — this tab will stop watching now. Follow it any time on <a href="?view=mine">your projects page</a>.', '', watchFlow);
3533
+ setProgress(progressMarkup(1, 1,
3534
+ 'Still working — this tab will stop watching now. Follow it any time on <a href="?view=mine">your projects page</a>.', '', watchFlow));
3396
3535
  }
3397
3536
  stopPolling();
3398
3537
  return;
@@ -3424,7 +3563,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3424
3563
  if (myGen !== watchGen) return;
3425
3564
  var m = String(err && err.message || '');
3426
3565
  if (m.indexOf('gone:') === 0) {
3427
- $('liveProgress').innerHTML = progressMarkup(1, 1, m.slice(5), 'isErr', watchFlow);
3566
+ setProgress(progressMarkup(1, 1, m.slice(5), 'isErr', watchFlow));
3428
3567
  stopPolling();
3429
3568
  return;
3430
3569
  }
@@ -3449,6 +3588,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3449
3588
  manifestStartedAt = 0; manifestStale = false;
3450
3589
  live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
3451
3590
  lastStepsHtml = '';
3591
+ lastProgHtml = '';
3452
3592
  try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
3453
3593
  state = { step: 1, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
3454
3594
  $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
@@ -69,10 +69,17 @@ const CONTROLS = [
69
69
  wiredIn: { file: '.github/workflows/unit.yml', needle: '--knip' },
70
70
  },
71
71
  {
72
- label: 'patrol: weekly red-team routine wired to its scanner',
72
+ // Task 1003209 re-pointed this row. It used to prove the routine mentioned
73
+ // its own scanner — a wiring substring satisfied by the SKILL.md talking
74
+ // about itself, which is why the manifest read GREEN for months while the
75
+ // patrol never ran once (follow-up audit §7). "Wired" for a scheduled
76
+ // control has to mean SOMETHING FIRES IT, so the needle now lives in the
77
+ // scheduler. The period itself is checked relationally, where a substring
78
+ // can't reach: scripts/gds/routine-schedule.js (fitness Check 25).
79
+ label: 'patrol: weekly red-team routine is actually scheduled',
73
80
  file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md',
74
- wiredIn: { file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md', needle: 'redteam-patrol.js' },
75
- alsoExists: ['scripts/gds/redteam-patrol.js'],
81
+ wiredIn: { file: '.github/workflows/redteam-patrol.yml', needle: 'run-routine.js redteam-weekly' },
82
+ alsoExists: ['scripts/gds/redteam-patrol.js', 'scripts/gds/routine-schedule.js', 'tests/routine_schedule.mjs'],
76
83
  },
77
84
 
78
85
  // ── goal 1000077's four guards (task 1003169 / G05) ──────────────────────
@@ -1305,6 +1305,7 @@ const CHECKS = [
1305
1305
  // and the check gets its own line back.
1306
1306
  require('./version-literal-guard.js').checkNoVersionLiterals,
1307
1307
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1308
+ require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1308
1309
  ];
1309
1310
 
1310
1311
  function runAll() {