@bongos/core 1.19.393 → 1.19.395

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.bongos-core.json +108 -58
  2. package/.claude/skills/design/SKILL.md +68 -0
  3. package/.claude/skills/design-sync/SKILL.md +2 -2
  4. package/.claude/skills/figma-design-sync/SKILL.md +3 -3
  5. package/config/design-tokens.neutral.json +1 -1
  6. package/config/design-tokens.schema.json +1 -1
  7. package/docs/adr/0197-the-ui-design-module.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/architecture.md +1 -1
  10. package/docs/copy-inventory.md +249 -236
  11. package/docs/copy-registry.json +543 -324
  12. package/docs/file-map.md +2 -0
  13. package/docs/modules-contract.md +1 -0
  14. package/docs/onboarding/slash-commands.md +1 -1
  15. package/docs/recipes/claude-design-loop.md +3 -3
  16. package/docs/recipes/figma-design-loop.md +4 -4
  17. package/modules/ui-design/CLAUDE.md +15 -0
  18. package/{src/ui → modules/ui-design}/adapters/claude-design/index.js +1 -1
  19. package/{src/ui → modules/ui-design}/adapters/figma/index.js +3 -3
  20. package/{docs → modules/ui-design/docs}/design-contract.md +12 -12
  21. package/modules/ui-design/kit/README.md +3 -0
  22. package/modules/ui-design/module.json +11 -0
  23. package/modules/ui-design/scripts/design-sync.js +65 -0
  24. package/modules/ui-design/scripts/figma-design-sync.js +64 -0
  25. package/modules/ui-design/scripts/validate-design.js +258 -0
  26. package/modules/ui-design/skills/README.md +3 -0
  27. package/modules/ui-design/styles/README.md +3 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/public-landing/assets/cosmos.css +235 -103
  31. package/public-landing/index.html +228 -75
  32. package/public-landing/projects.html +256 -140
  33. package/scripts/gds/copy-inventory.js +7 -5
  34. package/scripts/gds/design-sync.js +9 -59
  35. package/scripts/gds/doc-cli-guard.js +11 -5
  36. package/scripts/gds/figma-design-sync.js +9 -59
  37. package/scripts/gds/fitness-checks-identity.js +5 -3
  38. package/scripts/gds/publish-manifest.js +1 -1
  39. package/scripts/gds/validate-design.js +7 -249
  40. package/src/bongos/module-scope-map.js +18 -0
  41. package/src/module-api.js +5 -1
  42. package/tests/design_tokens_sync.mjs +1 -1
  43. package/tests/doc_cli_guard.mjs +19 -0
  44. package/tests/figma_adapter.mjs +1 -1
  45. package/tests/landing_page.mjs +72 -2
  46. package/tests/module_loader.mjs +4 -1
  47. package/tests/projects_hub.mjs +52 -73
  48. package/tests/projects_hub_pre_uat.mjs +10 -8
@@ -324,11 +324,6 @@ summary{min-height:24px;padding:3px 0;}
324
324
  used (860) — the two-up grids and the bongo cell fold together */
325
325
  @media (max-width:840px){.row2,.choices{grid-template-columns:1fr;}}
326
326
 
327
- /* cosmos's global reduced-motion kill switch zeroes every animation, which
328
- leaves the mark's blinking caret parked at FULL opacity — a solid bar beside
329
- the wordmark forever. Page-local on purpose: a second reduce block in
330
- cosmos.css would shadow the first one tests/landing_page.mjs extracts. */
331
- @media (prefers-reduced-motion:reduce){.shell .caret{display:none;}}
332
327
  </style>
333
328
  </head>
334
329
  <body>
@@ -350,54 +345,120 @@ summary{min-height:24px;padding:3px 0;}
350
345
  <span class="star" style="top:88%;left:42%;width:1.5px;height:1.5px;animation-delay:1.9s;"></span>
351
346
  </div>
352
347
 
353
- <!-- ══ THE PERSISTENT SHELL ═══════════════════════════════════════════════════
348
+ <!-- ══ THE BAR ═══════════════════════════════════════════════════════════════
354
349
  A header of the PAGE, not of the orb field, so it never drifts, scales or
355
- pans with the sky — and it renders in EVERY view including the table. The
356
- gate's corner pill was hidden by `body:not(.view-space)`, so the one view
357
- where a person scans for their own project was the view with no way out.
358
-
359
- Home is a PAGE (the landing page at /); the other three are VIEWS of this
360
- page. They sit in one control because they are all "where can I go from
361
- here", but the divider keeps the distinction honest — Home navigates and
362
- never takes the current-view pill. ── -->
363
- <!-- keyboard skip: the chrome above every view is a wordmark, a four-item nav
364
- and a right-hand cluster. Hidden until focused (.vh), it becomes a real
365
- pill on first Tab and jumps to the #hubMain landmark. -->
350
+ pans with the sky — and it renders in EVERY view including the table.
351
+ v2 "the hairline" (task 1003300; the owner's pick, docs/design/
352
+ projects-hub-direction-v2.md §7): no pill on this bar. Its ground IS the
353
+ page ground, closed by one hairline, and the only filled shape it can hold
354
+ is the sign-in door. The mark is static (the self-typing wordmark lives on
355
+ the landing only, §7) and it is the only way home. The map's search lives
356
+ here so it stays PUBLIC — a signed-out stranger must be able to search the
357
+ map — and setView hides it off the map view. Sky, Table, Your projects and
358
+ New project are the Projects band; the identity chip is the profile band's
359
+ trigger. No Home, no hall pill, no nav row, no toolbar. ── -->
360
+ <!-- keyboard skip: the chrome above every view is the bar and the two bands
361
+ that unfold under it. Hidden until focused (.vh), it becomes a real pill
362
+ on first Tab and jumps to the #hubMain landmark. -->
366
363
  <a class="skip vh" href="#hubMain">Skip to content</a>
367
- <header class="shell">
368
- <!-- The mark types the PREFIX and holds `bongos` still, because `bongos` is
369
- the word you type and the assistant is what varies — which is also what
370
- the vanity doors do (claudebongos.com, geminibongos.com swap the first
371
- word, not the second). The typing only runs on the default two-word name;
372
- a re-branded instance gets its own wordmark, static.
373
- "Still" is literal (task 1002793): the prefix + caret live in #markLeft,
374
- whose width is pinned to the coming word BEFORE typing, so letters come
375
- and go without bongos moving; #markMeasure is the hidden ruler. The
376
- caret is a drawn 2px bar, not a glyph — a block glyph plus the literal
377
- space read as two characters of air. Only bongos wears the accent, the
378
- same split the landing's static mark already has. -->
379
- <a class="mark" href="/"><span class="markLeft" id="markLeft"><span id="markPrefix">cloud</span><span class="caret" aria-hidden="true"></span></span> <span id="markWord">bongos</span><span class="markMeasure" id="markMeasure" aria-hidden="true"></span></a>
380
-
381
- <nav class="navrow" aria-label="Project hub">
382
- <a href="/" class="navHome" data-nav="home">Home</a>
383
- <span class="navSep" aria-hidden="true"></span>
384
- <a href="?view=map" data-nav="map" aria-current="page">Map</a>
385
- <a href="?view=mine" data-nav="mine">Your projects</a>
386
- <a href="?view=new" data-nav="new">New project</a>
387
- </nav>
388
-
389
- <div class="shell-right">
390
- <!-- the hall lives on a different HOST, so its href must be stamped
391
- server-side: clientBranding() deliberately withholds domains, so a
392
- client fill can never re-point it. apex-only — a child instance has no
393
- hall of its own. -->
394
- <!--apex-only:start--><a class="btn-space" data-link-hall href="{{buildersOrigin}}">🏛 Builders' hall</a><!--apex-only:end-->
395
- <button type="button" class="mode-toggle" id="modeToggle" title="Switch light / dark" aria-label="Switch light or dark mode">☾</button>
396
- <div class="who" id="who"></div>
397
- <a class="btn-space" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects">Sign in</a>
364
+ <header class="shell bar">
365
+ <div class="barLeft">
366
+ <a class="barMark" id="barMark" href="/">
367
+ <svg viewBox="0 0 26 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4"
368
+ stroke-linecap="round" stroke-linejoin="round">
369
+ <ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>
370
+ <path d="M1.8 5.6v6.6a6.6 2.9 0 0 0 13.2 0V5.6"/>
371
+ <ellipse cx="19.6" cy="8.4" rx="4.7" ry="2.1"/>
372
+ <path d="M14.9 8.4v4.6a4.7 2.1 0 0 0 9.4 0V8.4"/>
373
+ </svg>
374
+ <span data-brand-product-inline>Cloud Bongos</span>
375
+ </a>
376
+ <!-- under 480 the search folds into this magnifier, which drops one
377
+ hairline row of the page ground under the bar; above 480 it is never
378
+ shown and the field sits in the bar. -->
379
+ <button class="qToggle" type="button" id="qToggle" aria-expanded="false" aria-controls="qWrap" aria-label="Search projects">
380
+ <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
381
+ </button>
382
+ <!-- the map's search: a bare field with one bottom hairline. Every id is
383
+ kept (#mapQ, #mapCount, #onlyMine) so the filter path is untouched. -->
384
+ <div class="qWrap" id="qWrap">
385
+ <div class="qField">
386
+ <svg class="qIcon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
387
+ <input class="qInput" type="text" id="mapQ" placeholder="Find a project" aria-label="Search projects">
388
+ </div>
389
+ <span class="qCount" id="mapCount"></span>
390
+ <!-- the only-mine filter rides with the search (§7): it filters the same
391
+ map, folds with the field under 480 and hides with it off the map.
392
+ Member-gated by paintShellIdentity, as before. -->
393
+ <label class="switch" title="Show only projects you own or build on">
394
+ <input type="checkbox" id="onlyMine"><span class="track"></span> Only mine
395
+ </label>
396
+ </div>
397
+ </div>
398
+ <div class="barRight">
399
+ <button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
400
+ <!-- the glyph shows the mode you would GET: the moon in light, the sun in
401
+ dark, swapped by cosmos.css on data-mode; the script labels it -->
402
+ <button type="button" class="modeBtn" id="modeToggle" aria-label="Switch light or dark mode">
403
+ <svg class="moon" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
404
+ stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
405
+ <path d="M16.2 12.4A6.8 6.8 0 0 1 7.6 3.8a6.8 6.8 0 1 0 8.6 8.6z"/>
406
+ </svg>
407
+ <svg class="sun" viewBox="0 0 20 20" aria-hidden="true" fill="none" stroke="currentColor"
408
+ stroke-width="1.4" stroke-linecap="round">
409
+ <circle cx="10" cy="10" r="3.9"/>
410
+ <path d="M10 1.6v2.1M10 16.3v2.1M1.6 10h2.1M16.3 10h2.1M4.1 4.1l1.5 1.5M14.4 14.4l1.5 1.5M15.9 4.1l-1.5 1.5M5.6 14.4l-1.5 1.5"/>
411
+ </svg>
412
+ </button>
413
+ <!-- the identity chip IS the profile trigger; renderWho fills it. Signed
414
+ out it does not exist and the sign-in door takes its slot. -->
415
+ <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden></button>
416
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/projects">Sign in</a>
398
417
  </div>
399
418
  </header>
400
419
 
420
+ <!-- ══ THE BANDS ══ The menus are not floating cards. Each is a full-width band
421
+ that unfolds under the bar's edge and PUSHES the page down, so a
422
+ destination is a place the page opens into rather than a list that hovers
423
+ over it. One component, two instances (see the bands block in the script):
424
+ Escape closes and returns focus to the trigger, the arrows walk the items,
425
+ a click outside closes, opening one closes the other. Plain links in a
426
+ disclosure (aria-expanded + aria-controls, no menu role), so what is
427
+ announced is what ships. ── -->
428
+ <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
429
+ <div class="bandIn">
430
+ <ul class="bandRow">
431
+ <!-- Sky and Table are two VIEWS of the map (§7): both deep-link
432
+ ?view=map and carry the sub-view; setSub keeps the current one lit -->
433
+ <li class="bandCell"><a class="bandT" href="?view=map" data-nav="map" data-sub="sky" aria-current="page">Sky</a><span class="bandC">the sky of every project</span></li>
434
+ <li class="bandCell"><a class="bandT" href="?view=map&amp;sub=table" data-nav="map" data-sub="table">Table</a><span class="bandC">the same projects as rows</span></li>
435
+ <li class="bandCell"><a class="bandT" href="?view=mine" data-nav="mine">Your projects</a><span class="bandC">the ones you own or build on</span></li>
436
+ <li class="bandCell"><a class="bandT" href="?view=new" data-nav="new">New project</a><span class="bandC">five questions and a home</span></li>
437
+ </ul>
438
+ <!-- under a hairline (§5's About cell): what this is, and the docs. About is the landing's
439
+ own explanation until goal 1000063's About page takes the anchor over. -->
440
+ <ul class="bandRow bandMore">
441
+ <li class="bandCell"><a class="bandT" href="/#about">About</a></li>
442
+ <li class="bandCell"><a class="bandT" href="/docs">Docs</a></li>
443
+ </ul>
444
+ </div>
445
+ </div>
446
+ <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
447
+ <div class="bandIn bandProfile">
448
+ <p class="bandWho" id="bandWho"></p>
449
+ <ul class="bandRow">
450
+ <li class="bandCell"><a class="bandT" href="?view=mine" data-nav-to="mine">My projects</a></li>
451
+ <!-- the deferred Settings slot: a place it would go, rendering nothing at
452
+ all — not even the hairline that declares a list item. Never a
453
+ disabled item: the apex has no settings page, and on a child with no
454
+ hall the buildersOrigin token falls back to the page you are on. -->
455
+ <li class="bandSlot" aria-hidden="true"></li>
456
+ <!-- the honest hover target; the delegated click upgrades it to the POST -->
457
+ <li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>
458
+ </ul>
459
+ </div>
460
+ </div>
461
+
401
462
  <noscript>
402
463
  <div class="viewWrap wrap"><div class="fieldNote">The project hub needs JavaScript — the map, your
403
464
  projects, and starting a new one all live here. You can read what this is on the <a href="/">home page</a>.</div></div>
@@ -410,23 +471,6 @@ summary{min-height:24px;padding:3px 0;}
410
471
  <main id="hubMain">
411
472
  <section id="view-map">
412
473
  <h1 class="vh">The project map</h1>
413
- <div class="toolbar">
414
- <div class="seg" role="group" aria-label="Map display">
415
- <button type="button" id="sub-sky" aria-pressed="true">Sky</button>
416
- <button type="button" id="sub-table" aria-pressed="false">Table</button>
417
- </div>
418
-
419
- <label class="switch" title="Show only projects you own or build on">
420
- <input type="checkbox" id="onlyMine"><span class="track"></span> Only mine
421
- </label>
422
-
423
- <div class="search">
424
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></svg>
425
- <input type="text" id="mapQ" placeholder="Search projects by name or description…" aria-label="Search projects">
426
- <span class="count" id="mapCount"></span>
427
- </div>
428
- </div>
429
-
430
474
  <div class="fieldWrap" id="sub-sky-body">
431
475
  <div class="field" id="orbField">
432
476
  <div class="ground"></div>
@@ -1026,7 +1070,7 @@ summary{min-height:24px;padding:3px 0;}
1026
1070
 
1027
1071
  </main>
1028
1072
 
1029
- <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>.<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a></nav></footer>
1073
+ <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>.<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
1030
1074
 
1031
1075
  <script>
1032
1076
  /* Brand-pack fill (R57): the server injects window.__BRANDING__ ahead of this
@@ -1057,13 +1101,12 @@ summary{min-height:24px;padding:3px 0;}
1057
1101
  if (m === 'light' || m === 'dark') return m;
1058
1102
  return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
1059
1103
  }
1060
- /* the glyph shows the mode you would GET, so the label must say the same:
1061
- a bare ☀/☾ announces as "sun"/"moon" and nothing about what the press does.
1104
+ /* the glyph shows the mode you would GET (the moon in light, the sun in
1105
+ dark — cosmos.css swaps them on data-mode), so the label must say the same.
1062
1106
  No aria-pressed — this swaps a mode, it is not a two-state toggle button. */
1063
1107
  function paintModeToggle() {
1064
1108
  var dark = resolvedMode() === 'dark';
1065
1109
  var el = $('modeToggle');
1066
- el.textContent = dark ? '☀' : '☾';
1067
1110
  el.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
1068
1111
  }
1069
1112
  $('modeToggle').addEventListener('click', function () {
@@ -1105,6 +1148,7 @@ summary{min-height:24px;padding:3px 0;}
1105
1148
 
1106
1149
  var VIEWS = { map: 1, mine: 1, new: 1, manage: 1 };
1107
1150
  var currentView = 'map';
1151
+ var currentSub = 'sky'; /* the map's sub-view on stage — sky or table (task 1003300) */
1108
1152
  /* the manage view's target project (task 1003041) — set by goToManage() and
1109
1153
  the ?view=manage&id=N deep link, read by loadManage() */
1110
1154
  var manageId = null;
@@ -1129,12 +1173,19 @@ summary{min-height:24px;padding:3px 0;}
1129
1173
  currentView = v;
1130
1174
  Object.keys(VIEWS).forEach(function (k) { $('view-' + k).hidden = (k !== v); });
1131
1175
  document.querySelectorAll('[data-nav]').forEach(function (a) {
1132
- /* Home is a PAGE, not a view — it never takes the current-view pill */
1133
- if (a.dataset.nav !== 'home' && a.dataset.nav === v) a.setAttribute('aria-current', 'page');
1134
- /* the manage view is a page OF your projects — that pill stays lit there */
1176
+ /* Sky and Table are both the map view: only the one whose sub-view is on
1177
+ stage is current (setSub keeps the pair in step) */
1178
+ if (a.dataset.nav === v && (!a.dataset.sub || a.dataset.sub === currentSub)) a.setAttribute('aria-current', 'page');
1179
+ /* the manage view is a page OF your projects — that item stays lit there */
1135
1180
  else if (v === 'manage' && a.dataset.nav === 'mine') a.setAttribute('aria-current', 'page');
1136
1181
  else a.removeAttribute('aria-current');
1137
1182
  });
1183
+ /* the map's search (and the only-mine filter riding with it) is meaningless
1184
+ off the map: HIDE, never disable, and never clear the value — returning to
1185
+ the map restores the filter. It is PUBLIC, so this is not in
1186
+ paintShellIdentity. The folded row (under 480) closes with it. */
1187
+ $('qWrap').hidden = $('qToggle').hidden = (v !== 'map');
1188
+ if (v !== 'map') foldSearch(false);
1138
1189
  /* the manage view's URL names its project — the deep-link contract grew
1139
1190
  an id in task 1003041, so a manage tab is shareable and reload-safe */
1140
1191
  var idPart = (v === 'manage' && manageId) ? '&id=' + encodeURIComponent(manageId) : '';
@@ -1173,8 +1224,12 @@ summary{min-height:24px;padding:3px 0;}
1173
1224
  }
1174
1225
 
1175
1226
  document.querySelectorAll('[data-nav]').forEach(function (a) {
1176
- if (a.dataset.nav === 'home') return; /* a real navigation — leave it alone */
1177
- a.addEventListener('click', function (ev) { ev.preventDefault(); setView(a.dataset.nav, true); });
1227
+ a.addEventListener('click', function (ev) {
1228
+ if (!isPlainClick(ev)) return; /* ctrl/cmd/shift/middle mean "new tab" everywhere else */
1229
+ ev.preventDefault();
1230
+ setView(a.dataset.nav, true);
1231
+ if (a.dataset.sub) setSub(a.dataset.sub);
1232
+ });
1178
1233
  });
1179
1234
  /* delegated, not swept: a data-nav-to link injected into a card or the manage
1180
1235
  page after boot (addressRow, renderDelete) would never be upgraded by a
@@ -1190,54 +1245,11 @@ summary{min-height:24px;padding:3px 0;}
1190
1245
  manage id too, so a manage entry restores its project */
1191
1246
  window.addEventListener('popstate', function () { closeCard(); setView(viewFromUrl()); });
1192
1247
 
1193
- /* ── the self-typing mark. `bongos` is the CONSTANT — it is the word you
1194
- type — and the prefix is what varies, which is exactly what the vanity
1195
- doors do (claudebongos.com swaps the first word, not the second). Only
1196
- runs on the default two-word name so a re-branded instance keeps its own
1197
- wordmark, and never when the visitor asked for reduced motion. ── */
1198
- (function typeMark() {
1199
- var parts = productName.toLowerCase().split(/\s+/);
1200
- var word = parts.pop();
1201
- var prefix = parts.join(' ');
1202
- $('markWord').textContent = word;
1203
- $('markPrefix').textContent = prefix;
1204
- /* the link's name is its two spans, so mid-type it announces "gem bongos".
1205
- Label the LINK instead — before the early return, so a re-branded or
1206
- reduced-motion visitor gets it too. #markLeft stays visible to AT:
1207
- tests/projects_hub.mjs pins that markup literal. */
1208
- $('markLeft').parentNode.setAttribute('aria-label', productName + ' — home');
1209
- if (REDUCED || prefix !== 'cloud' || word !== 'bongos') return;
1210
- var PREFIXES = ['cloud', 'claude', 'chatgpt', 'gemini'];
1211
-
1212
- /* Type, HOLD, erase — the landing hero's cadence (index.html), not a
1213
- symmetric ping-pong. The single reversing interval this replaced turned
1214
- around on the very tick it completed a word, so `claude bongos` finished
1215
- and was already being deleted: the reader never got to see the word the
1216
- whole animation exists to show. A hold is the only part of the cycle that
1217
- carries meaning; the typing is just how it arrives.
1218
-
1219
- `cloud` rests longest because it is the real wordmark and the state the
1220
- mark should sit in most — the others are the joke, and the joke lands
1221
- once. Same two numbers the hero uses, so the two surfaces read as one
1222
- system rather than two animations that happen to share a font. */
1223
- var TYPE = 72, ERASE = 38, HOLD = 900, HOLD_HOME = 2100;
1224
- var el = $('markPrefix'), box = $('markLeft'), meas = $('markMeasure'), wi = 0;
1225
- (function cycle() {
1226
- var w = PREFIXES[wi++ % PREFIXES.length], i = 0;
1227
- /* pin the box to the coming word's rendered width BEFORE typing: bongos
1228
- must not move while letters come and go (the owner's call, 2026-08-14).
1229
- Between words the CSS width transition glides instead of jumping. */
1230
- meas.textContent = w;
1231
- box.style.width = (meas.offsetWidth + 4) + 'px';
1232
- (function type() {
1233
- if (i <= w.length) { el.textContent = w.slice(0, i++); setTimeout(type, TYPE); }
1234
- else setTimeout(function erase() {
1235
- if (el.textContent.length) { el.textContent = el.textContent.slice(0, -1); setTimeout(erase, ERASE); }
1236
- else cycle();
1237
- }, w === 'cloud' ? HOLD_HOME : HOLD);
1238
- })();
1239
- })();
1240
- })();
1248
+ /* the static mark (task 1003300, direction record §7): the self-typing
1249
+ wordmark lives on the landing only. Here the glyph and the product name are
1250
+ plain type, and the mark is the only way home — so it is labelled from the
1251
+ product name a re-branded instance stamps, not from its visible text. */
1252
+ $('barMark').setAttribute('aria-label', productName + ' — home');
1241
1253
 
1242
1254
  /* ══ THE MAP ═══════════════════════════════════════════════════════════════
1243
1255
  The public catalog: GET /projects/featured is the PUBLIC projection
@@ -1643,6 +1655,7 @@ summary{min-height:24px;padding:3px 0;}
1643
1655
  (p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
1644
1656
  (p.isSelf ? 'Home →' : 'Open site →') + '</a>'
1645
1657
  : '';
1658
+ closeMenus();
1646
1659
  cardReturn = returnTo || null;
1647
1660
  $('cardScrim').hidden = false;
1648
1661
  pageInert(true);
@@ -1731,7 +1744,95 @@ summary{min-height:24px;padding:3px 0;}
1731
1744
  if (ev.target === $('cardScrim')) closeCard(); /* backdrop only, never the card */
1732
1745
  });
1733
1746
  document.addEventListener('keydown', function (ev) {
1734
- if (ev.key === 'Escape') closeCard();
1747
+ /* a band claims Escape while it is open — its own listener, registered
1748
+ below this one, closes it; the card's Escape is the page default */
1749
+ if (ev.key === 'Escape' && !openBand()) closeCard();
1750
+ });
1751
+
1752
+ /* ══ THE BANDS (task 1003300) — one disclosure component, two instances: the
1753
+ Projects band and the profile band. Not palette.js's single-control focus
1754
+ trap (a multi-item menu is keyboard-unusable under it); the card's physics
1755
+ instead. Escape closes and RETURNS FOCUS to the trigger, a click outside
1756
+ closes, Tab walks the items in natural order and leaving them closes, the
1757
+ arrows move between items, opening one closes the other, and an item
1758
+ activated closes its band. Plain links in a disclosure — aria-expanded +
1759
+ aria-controls, no menu role — so what is announced is what ships. The open
1760
+ move is CSS (bandIn), so the kill switch owns it. Every read of the profile
1761
+ trigger is by class, and signed out it is simply hidden: nothing here can
1762
+ throw with no session, which is the public map's default state. ══ */
1763
+ function openBand() { return document.querySelector('.menuBand:not([hidden])'); }
1764
+ function closeMenus() {
1765
+ var open = openBand();
1766
+ if (!open) return;
1767
+ var trig = document.querySelector('.trig[aria-expanded="true"]');
1768
+ var inside = open.contains(document.activeElement);
1769
+ open.hidden = true;
1770
+ if (trig) trig.setAttribute('aria-expanded', 'false');
1771
+ /* focus restoration is a property of the close, not of one caller */
1772
+ if (inside && trig) trig.focus();
1773
+ }
1774
+ function openMenu(trig, byKeyboard) {
1775
+ closeMenus();
1776
+ foldSearch(false);
1777
+ trig.setAttribute('aria-expanded', 'true');
1778
+ var band = $(trig.getAttribute('aria-controls'));
1779
+ band.hidden = false;
1780
+ /* Enter/Space on the trigger lands on the first item; a pointer stays put */
1781
+ if (byKeyboard) { var first = band.querySelector('.bandCell:not([hidden]) a'); if (first) first.focus(); }
1782
+ }
1783
+ document.querySelectorAll('.trig').forEach(function (t) {
1784
+ t.addEventListener('click', function (ev) {
1785
+ if (t.getAttribute('aria-expanded') === 'true') closeMenus();
1786
+ else openMenu(t, ev.detail === 0);
1787
+ });
1788
+ });
1789
+ document.addEventListener('click', function (ev) {
1790
+ if (!openBand()) return;
1791
+ if (ev.target.closest && (ev.target.closest('.menuBand') || ev.target.closest('.trig'))) return;
1792
+ closeMenus();
1793
+ });
1794
+ document.addEventListener('keydown', function (ev) {
1795
+ var open = openBand();
1796
+ if (!open) {
1797
+ /* Escape with the folded search open (under 480) folds it and hands focus back */
1798
+ if (ev.key === 'Escape' && $('qWrap').classList.contains('is-open')) { foldSearch(false); $('qToggle').focus(); }
1799
+ return;
1800
+ }
1801
+ if (ev.key === 'Escape') { closeMenus(); return; }
1802
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
1803
+ var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]'); });
1804
+ if (!items.length) return;
1805
+ var at = items.indexOf(document.activeElement);
1806
+ ev.preventDefault();
1807
+ items[(at + (ev.key === 'ArrowDown' ? 1 : items.length - 1) + items.length) % items.length].focus();
1808
+ });
1809
+ document.querySelectorAll('.menuBand').forEach(function (b) {
1810
+ /* an item is a real link or a view switch: either way its band closes */
1811
+ b.addEventListener('click', function (ev) {
1812
+ if (ev.target.closest && ev.target.closest('a')) closeMenus();
1813
+ });
1814
+ /* Tab out of the band closes it; a null relatedTarget is left alone (the
1815
+ window losing focus, a click on nothing) — the document click covers it */
1816
+ b.addEventListener('focusout', function (ev) {
1817
+ var to = ev.relatedTarget;
1818
+ if (!to || b.contains(to) || (to.closest && to.closest('.trig'))) return;
1819
+ closeMenus();
1820
+ });
1821
+ });
1822
+
1823
+ /* the folded search (under 480): the magnifier opens one hairline row under
1824
+ the bar and hands focus to the field; Escape folds it and returns focus; an
1825
+ opening menu folds it. Above 480 the class is inert — the field is in the bar. */
1826
+ function qFolded() { return getComputedStyle($('qToggle')).display !== 'none'; }
1827
+ function foldSearch(open) {
1828
+ $('qWrap').classList.toggle('is-open', !!open);
1829
+ $('qToggle').setAttribute('aria-expanded', open ? 'true' : 'false');
1830
+ }
1831
+ $('qToggle').addEventListener('click', function () {
1832
+ var open = $('qToggle').getAttribute('aria-expanded') !== 'true';
1833
+ closeMenus();
1834
+ foldSearch(open);
1835
+ if (open) $('mapQ').focus();
1735
1836
  });
1736
1837
 
1737
1838
  /* The platform's own row is not in the catalog (it is synthesized — task
@@ -2244,18 +2345,24 @@ summary{min-height:24px;padding:3px 0;}
2244
2345
  document.addEventListener('keyup', function (e) {
2245
2346
  if (e.key !== 'Shift' || !solo) return;
2246
2347
  solo = false;
2247
- /* never steal focus out of the open project card (it is aria-modal) */
2248
- if (currentView !== 'map' || !$('cardScrim').hidden) return;
2348
+ /* one source of truth — the search's own visibility (hidden off the
2349
+ map) — and never steal focus out of the open card or an open band */
2350
+ if ($('qWrap').hidden || !$('cardScrim').hidden || openBand()) return;
2249
2351
  var t = document.activeElement;
2250
2352
  if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
2251
- var q = $('mapQ');
2252
- if (q) q.focus();
2353
+ if (qFolded()) foldSearch(true); /* under 480 the field is behind the magnifier */
2354
+ $('mapQ').focus();
2253
2355
  });
2254
2356
  })();
2255
2357
 
2256
2358
  function setSub(which) {
2257
- $('sub-sky').setAttribute('aria-pressed', String(which === 'sky'));
2258
- $('sub-table').setAttribute('aria-pressed', String(which === 'table'));
2359
+ currentSub = which;
2360
+ /* the Sky and Table items of the Projects band are the control (task
2361
+ 1003300): the one on stage is current, the other a plain link */
2362
+ document.querySelectorAll('[data-nav="map"][data-sub]').forEach(function (a) {
2363
+ if (a.dataset.sub === which && currentView === 'map') a.setAttribute('aria-current', 'page');
2364
+ else a.removeAttribute('aria-current');
2365
+ });
2259
2366
  $('sub-sky-body').hidden = which !== 'sky';
2260
2367
  $('sub-table-body').hidden = which !== 'table';
2261
2368
  /* the sim runs only while the sky is on stage (C8); anchors that were
@@ -2265,8 +2372,6 @@ summary{min-height:24px;padding:3px 0;}
2265
2372
  startSky();
2266
2373
  } else { stopSky(); hideHover(); }
2267
2374
  }
2268
- $('sub-sky').addEventListener('click', function () { setSub('sky'); });
2269
- $('sub-table').addEventListener('click', function () { setSub('table'); });
2270
2375
 
2271
2376
  /* the starfield only paints where --star-display is block (dark mode) */
2272
2377
  (function stars() {
@@ -5240,16 +5345,21 @@ summary{min-height:24px;padding:3px 0;}
5240
5345
  /* ── boot: who am I → my instances ────────────────────────────────────── */
5241
5346
 
5242
5347
  function renderWho() {
5243
- var w = $('who');
5348
+ var w = $('who'), b = $('bandWho');
5349
+ var login = me ? (me.github_login || 'signed in') : '';
5244
5350
  if (me) {
5245
- /* the chip carries the one session control this page owes its visitor:
5246
- Sign out (task 1002952). The anchor's href is the honest hover target;
5247
- the delegated click below upgrades it to the real POST. */
5248
- w.innerHTML = (me.avatar_url ? '<img src="' + esc(me.avatar_url) + '" alt="">' : '') +
5249
- '<span>' + esc(me.github_login || 'signed in') + '</span>' +
5250
- '<a class="signout" href="' + API + '/auth/logout">Sign out</a>';
5351
+ /* the chip is the profile band's trigger (task 1003300): avatar + login +
5352
+ a chevron. The one session control this page owes its visitor, Sign
5353
+ out (task 1002952), is a static anchor in that band; the delegated
5354
+ click below upgrades it to the real POST. The band names the person. */
5355
+ var who = (me.avatar_url ? '<img src="' + esc(me.avatar_url) + '" alt="">' : '') +
5356
+ '<span class="whoName">' + esc(login) + '</span>';
5357
+ w.innerHTML = who + '<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg>';
5358
+ w.setAttribute('aria-label', 'Account, ' + login);
5359
+ b.innerHTML = who;
5251
5360
  } else {
5252
5361
  w.innerHTML = '';
5362
+ b.innerHTML = '';
5253
5363
  }
5254
5364
  }
5255
5365
 
@@ -5273,7 +5383,7 @@ summary{min-height:24px;padding:3px 0;}
5273
5383
  };
5274
5384
  fetch(API + '/auth/logout', { method: 'POST', credentials: 'same-origin' }).then(finish, finish);
5275
5385
  }
5276
- $('who').addEventListener('click', function (ev) {
5386
+ $('menuProfile').addEventListener('click', function (ev) {
5277
5387
  var a = ev.target.closest && ev.target.closest('a.signout');
5278
5388
  if (!a) return;
5279
5389
  ev.preventDefault();
@@ -5304,8 +5414,11 @@ summary{min-height:24px;padding:3px 0;}
5304
5414
  var signedIn = !!me;
5305
5415
  $('signinTop').hidden = signedIn;
5306
5416
  $('who').hidden = !signedIn;
5307
- document.querySelector('[data-nav="mine"]').hidden = !signedIn;
5417
+ /* every Your-projects item a band carries (§6 asked for querySelectorAll so
5418
+ a second data-nav="mine" item could never become a signed-out leak) */
5419
+ document.querySelectorAll('[data-nav="mine"]').forEach(function (a) { (a.closest('.bandCell') || a).hidden = !signedIn; });
5308
5420
  $('onlyMine').closest('.switch').hidden = !signedIn;
5421
+ if (!signedIn) closeMenus();
5309
5422
  if (!signedIn && $('onlyMine').checked) { $('onlyMine').checked = false; applyMapFilter(); }
5310
5423
  }
5311
5424
 
@@ -5313,6 +5426,9 @@ summary{min-height:24px;padding:3px 0;}
5313
5426
  any request resolves — /projects?view=new must land on the create view even
5314
5427
  if the identity call is slow or fails. */
5315
5428
  setView(viewFromUrl());
5429
+ /* the Table item's deep link (and the landing's) names the sub-view; a view change
5430
+ never writes it back, so a reload from the band returns to the sky as before */
5431
+ if (new URLSearchParams(location.search).get('sub') === 'table') setSub('table');
5316
5432
 
5317
5433
  fetch(API + '/me', { credentials: 'same-origin' })
5318
5434
  .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
@@ -16,10 +16,12 @@
16
16
  // machine-readable registry and one human report. Same posture as gen-repo-map /
17
17
  // gen-api-docs: a command you run and commit.
18
18
  //
19
- // THE SURFACE MISMATCH, RECORDED RATHER THAN ASSUMED AWAY. docs/design-contract.md
20
- // says UI components live under `src/ui/<surface>/`, and that a surface directory is
21
- // the unit a design adapter targets. On this tree `src/ui/` contains only
22
- // `adapters/` — the real UI is modules/hall-ui/public and modules/status-ui/public.
19
+ // THE SURFACE MISMATCH, RECORDED RATHER THAN ASSUMED AWAY. The design contract
20
+ // (modules/ui-design/docs/design-contract.md) says UI components live under
21
+ // `src/ui/<surface>/`, and that a surface directory is the unit a design adapter
22
+ // targets. This tree has no `src/ui/` at all (the adapters moved to
23
+ // modules/ui-design/adapters/ in task 1003324) — the real UI is modules/hall-ui/public
24
+ // and modules/status-ui/public.
23
25
  // So this tool maps REAL paths onto contract-shaped surface ids and reports the
24
26
  // mismatch in its own output. Do not "fix" that by pretending the contract layout
25
27
  // exists.
@@ -76,7 +78,7 @@ const BLIND_SPOTS = [
76
78
  'Template literals containing a nested backtick: the literal scanner stops at the first closing backtick, so a nested template inside `${...}` truncates that one literal. Rare in this tree and cheap to spot (a row that ends mid-sentence); a real JS parser is the fix if it ever matters.',
77
79
  'Runtime-composed strings: anything assembled from fragments or interpolated at render time is captured only as its literal parts. A sentence that exists only once the browser runs cannot be inventoried by reading the tree.',
78
80
  'Generated pages: `docs/api-reference.md` and the OpenAPI surface are generated from route prose; that copy belongs to its generator, not to a designer.',
79
- 'The design-contract surface layout: `docs/design-contract.md` expects `src/ui/<surface>/`, which this tree does not use (`src/ui` holds only `adapters/`). Surface ids here are a MAPPING onto real paths, not evidence the contract layout exists.',
81
+ 'The design-contract surface layout: `modules/ui-design/docs/design-contract.md` expects `src/ui/<surface>/`, which this tree does not use (there is no `src/ui/`; the adapters live in `modules/ui-design/adapters/`). Surface ids here are a MAPPING onto real paths, not evidence the contract layout exists.',
80
82
  ];
81
83
 
82
84
  // A stable id survives the string MOVING: it hashes surface + normalized text,