@bongos/core 1.21.50 → 1.21.52

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.
@@ -338,7 +338,9 @@ function countStates(matters) {
338
338
  function homeLine(counts) {
339
339
  if (!counts.needs && !counts.moving) return 'nothing waits on you.';
340
340
  const parts = [];
341
- if (counts.needs) parts.push(`${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you`);
341
+ // with nothing on you but work still moving, the design says so first
342
+ // ("nothing waits on you · 2 moving"), task 1004531
343
+ parts.push(counts.needs ? `${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you` : 'nothing waits on you');
342
344
  if (counts.moving) parts.push(`${counts.moving} moving`);
343
345
  return parts.join(' · ');
344
346
  }
@@ -272,7 +272,12 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
272
272
  .inside-draft { display: flex; flex-direction: column; gap: 12px; padding: 14px 0 4px; border-top: 1px solid var(--street-rule); }
273
273
  .inside-draft[hidden] { display: none; }
274
274
  .inside-draft__change { gap: 6px; }
275
- .inside-draft__picks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
275
+ .inside-draft__picks { display: grid; grid-template-columns: 1fr; gap: 12px; }
276
+ /* who and how are one-tap chips (task 1004531); a long choice wraps inside its
277
+ chip. Scoped under .inside-draft so they outrank the desk chip rules below. */
278
+ .inside-draft__chips { display: flex; flex-wrap: wrap; gap: 6px; }
279
+ .inside-draft .inside-draft__chip { height: auto; min-height: 30px; padding: 5px 14px; white-space: normal; text-align: left; line-height: 1.3; }
280
+ .inside-draft .inside-draft__chip--on { background: var(--ink); border-color: var(--ink); color: var(--bg); }
276
281
  .inside-draft__fixed { font-size: 14px; line-height: 1.45; color: var(--ink-soft); min-height: 36px; display: flex; align-items: center; }
277
282
  .inside-meter { display: flex; flex-direction: column; gap: 6px; }
278
283
  .inside-meter__track { height: 6px; border-radius: 999px; background: var(--street-wash); overflow: hidden; }
@@ -382,7 +387,6 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
382
387
 
383
388
  /* narrow: the drawing in its own sideways strip, the panel below it */
384
389
  @media (max-width: 899px) {
385
- .inside-draft__picks { grid-template-columns: 1fr; }
386
390
  .inside { display: block; overflow-x: hidden; overflow-y: auto; }
387
391
  .inside-sizer { width: auto !important; height: auto !important; margin: 0; overflow: visible; }
388
392
  .inside-board { position: static; width: auto; height: auto; transform: none; display: flex; flex-direction: column; gap: 16px; padding: 8px 16px 24px; }
@@ -90,6 +90,7 @@
90
90
  var desk = { done: {}, mine: {}, cleared: {}, order: {}, open: null, ask: null, busy: false, roomAsked: false, model: null };
91
91
  var TODAY_KEY = 'gov-desk-today';
92
92
  var today = { day: '', n: 0 };
93
+ var todayDrawn = null; // the count the meter's pips last drew: they are rebuilt only when it moves
93
94
 
94
95
  // ---------- light / dark ----------
95
96
  var chosen = null;
@@ -811,9 +812,13 @@
811
812
  var D = window.GovDesk, pipsEl = $('streetTodayPips');
812
813
  if (!D || !pipsEl) return;
813
814
  var t = D.todayPips(today.n);
815
+ setText('streetTodayN', t.text);
816
+ // the pips are rebuilt only when the count moves, so the newest pops once
817
+ // and a redraw neither replays nor cuts its pop short (task 1004531)
818
+ if (todayDrawn === today.n && pipsEl.children && pipsEl.children.length) return;
819
+ todayDrawn = today.n;
814
820
  clear(pipsEl);
815
821
  t.pips.forEach(function (p) { pipsEl.appendChild(node('span', 'street-today__pip' + (p.on ? ' street-today__pip--on' : '') + (p.fresh ? ' desk-pop' : ''))); });
816
- setText('streetTodayN', t.text);
817
822
  }
818
823
 
819
824
  function pickFloor(i) {
@@ -850,8 +855,7 @@
850
855
  dr.d = A ? A.draftFor(st.key, c && c.board ? c : null) : { ok: false, reason: 'unread' };
851
856
  dr.rev++;
852
857
  drawDraft(true);
853
- var who = $('insideDraftWho');
854
- if (who && who.focus) who.focus();
858
+ focusDraft('insideDraftWho');
855
859
  return !!(dr.d && dr.d.ok);
856
860
  });
857
861
  }
@@ -868,26 +872,42 @@
868
872
  if (!dr) return;
869
873
  dr.rev++;
870
874
  drawDraft(true);
875
+ focusDraft(focusId);
876
+ }
877
+ // Focus one control of the draft; a group of chips takes it on its picked chip.
878
+ function focusDraft(focusId) {
871
879
  var el = focusId ? $(focusId) : null;
880
+ if (el && el.getAttribute && el.getAttribute('role') === 'radiogroup') {
881
+ var kids = el.children || [], pick = kids[0] || null;
882
+ for (var i = 0; i < kids.length; i++) if (kids[i].getAttribute('aria-checked') === 'true') { pick = kids[i]; break; }
883
+ el = pick;
884
+ }
872
885
  if (el && el.focus) el.focus();
873
886
  }
874
- function draftSelect(id, list, value, onPick) {
875
- var sel = node('select', 'mcard-form__select');
876
- sel.setAttribute('id', id);
887
+ // A draft choice is one tap (the owner's line rule: choices are one-tap
888
+ // chips, task 1004531): a radio group of chips, the picked one filled.
889
+ function draftChips(id, list, value, onPick) {
890
+ var g = node('div', 'inside-draft__chips');
891
+ g.setAttribute('id', id);
892
+ g.setAttribute('role', 'radiogroup');
893
+ g.setAttribute('aria-labelledby', id + 'Label');
877
894
  list.forEach(function (o) {
878
- var op = node('option', '', o.words);
879
- op.setAttribute('value', o.value);
880
- if (o.value === value) op.setAttribute('selected', '');
881
- sel.appendChild(op);
895
+ var on = o.value === value;
896
+ var b = node('button', 'desk-chip desk-chip--choice inside-draft__chip' + (on ? ' inside-draft__chip--on' : ''), o.words);
897
+ b.setAttribute('type', 'button');
898
+ b.setAttribute('role', 'radio');
899
+ b.setAttribute('aria-checked', on ? 'true' : 'false');
900
+ b.setAttribute('data-value', o.value);
901
+ b.addEventListener('click', function () { if (!on) onPick(o.value); });
902
+ g.appendChild(b);
882
903
  });
883
- sel.value = value == null ? '' : value;
884
- sel.addEventListener('change', function () { onPick(sel.value); });
885
- return sel;
904
+ return g;
886
905
  }
887
- function draftField(labelText, forId, control) {
906
+ function draftField(labelText, forId, control, group) {
888
907
  var f = node('div', 'mcard-form');
889
- var l = node(forId ? 'label' : 'span', 'mcard-form__label', labelText);
890
- if (forId) l.setAttribute('for', forId);
908
+ var l = node(forId && !group ? 'label' : 'span', 'mcard-form__label', labelText);
909
+ if (forId && !group) l.setAttribute('for', forId);
910
+ if (group) l.setAttribute('id', forId + 'Label');
891
911
  f.appendChild(l);
892
912
  f.appendChild(control);
893
913
  return f;
@@ -925,13 +945,13 @@
925
945
  } else ch.appendChild(node('p', 'inside-note', 'This is how it works today. Pick who decides, or how, to change it.'));
926
946
  box.appendChild(ch);
927
947
  var picks = node('div', 'inside-draft__picks');
928
- picks.appendChild(draftField('who decides', 'insideDraftWho', draftSelect('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
948
+ picks.appendChild(draftField('who decides', 'insideDraftWho', draftChips('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
929
949
  dr.d = A.pick(dr.d, 'who', v); dr.error = ''; bumpDraft('insideDraftWho');
930
- })));
950
+ }), true));
931
951
  if (d.ruleOptions.length) {
932
- picks.appendChild(draftField('how they decide', 'insideDraftRule', draftSelect('insideDraftRule', d.ruleOptions, d.rule, function (v) {
952
+ picks.appendChild(draftField('how they decide', 'insideDraftRule', draftChips('insideDraftRule', d.ruleOptions, d.rule, function (v) {
933
953
  dr.d = A.pick(dr.d, 'rule', v); dr.error = ''; bumpDraft('insideDraftRule');
934
- })));
954
+ }), true));
935
955
  } else {
936
956
  picks.appendChild(draftField('how they decide', null, node('span', 'inside-draft__fixed', d.decided_by === 'nobody' ? 'nothing is decided until someone holds it' : 'decides alone, as today')));
937
957
  }
@@ -1210,6 +1230,8 @@
1210
1230
  card.pick = null;
1211
1231
  card.fresh = key === 'for' || key === 'object' ? 'you' : null;
1212
1232
  if (a.building && a.kind) card.answer = { building: a.building, matter: cv.key, kind: a.kind, at: Date.now() };
1233
+ // a decision from a card is a decision today, as one from a desk row is (task 1004531)
1234
+ if (recorded) { bumpToday(); drawToday(); }
1213
1235
  if (a.close) closeCard(true);
1214
1236
  else if (view.open) { fillCard(); var c = $('streetCardClose'); if (c && c.focus) c.focus(); }
1215
1237
  if (!recorded) return true;
@@ -47,3 +47,24 @@ Every building and every floor desk was walked in light, dark and at phone width
47
47
  - **The desk's toast is the board's**: an ink pill with the orange dot, low over the desk. The street's own toast is centred again (its enter animation had undone the centring).
48
48
 
49
49
  Left as found, on purpose: the six readings stay under a building (task 1004526 marks the building's own reading), the old month drawers stay under the desk, and the recording sign-off still goes to its page (an open owner choice).
50
+
51
+ ## The whole city, walked against the prototype (task 1004531)
52
+
53
+ The UAT walkthrough of goal 1000125: open the home city, start governing, find what needs you, enter every building and district, decide a vote from its card and watch the city change, and draft an amendment from a building, in light and dark at 1440 and at phone width (390 px), side by side with `Street.dc.html` (the prototype), `Desks.dc.html` and `Ground.dc.html`, judged by the owner's line rule. The live site is behind its password gate, so the walk ran on the real pages in the hall preview harness (`--fixture-me`). **The real-vote step was done in the harness only**: the vote went to the harness's canned answer for `POST /government/board/items/:id/votes`, and the docket read after it was stubbed in the page to show what the server would now say; no vote was cast on the live site. The amendment was filed the same way (the page's `POST /government/board/decision-rules` answered in the page, the harness refuses writes). The district interior was walked over twelve stand-in tasks answered in the page, since the harness serves one task list for every goal. Screenshots stay out of the repo: they are in the session scratchpad, `walk-1004531/` (53 shots).
54
+
55
+ What it found and fixed:
56
+
57
+ - **The preview fixtures disagreed with themselves.** The docket fixture's counts, home line, per-building counts and fairness reading had been written for the first seven matters and never re-derived as later tasks added more, so the city said "3 matters wait on you", the street header 4, its pins 13, and Disputes read "0 open" under two matters that need you. `buildDocket` derives all four from the matters, so live they cannot disagree; the fixture now holds the same derivation, and `tests/hall_preview_governor_fixtures.mjs` holds it to it.
58
+ - **Change how this works could not be walked.** The constitution fixture had no `decision_rules` block, so the drafter said it could not read how a building decides. It now carries the block the route serves (every building as today), and the same test drafts every building from it. The fixture scrubber had read a building's decided_by (gate, board, nobody) as a login; it keeps those words now.
59
+ - **The drafter's choices were dropdowns.** Who decides and how they decide are one-tap chips now (the line rule: choices are one-tap chips): a labelled radio group each, the picked chip filled in ink, a long choice wrapping inside its chip, stacked one under the other so the chips have the panel's width. Focus lands on the picked chip after each pick.
60
+ - **A vote from a street card did not count today.** The today meter only took a pip for an act on a desk row; a decision from a card is a decision today too, so a recorded card act now fills a pip (an act that records nothing, or is refused, does not).
61
+ - **The meter's newest pip popped on every redraw** (any pick, the card closing, the docket read after an act), and the read after an act cut the pop short. The pips are drawn again only when the count moves, so the newest pops once, as on the board.
62
+ - **The home line with nothing on you.** With nothing waiting on you but work still moving, the server said "2 moving"; the prototype says "nothing waits on you · 2 moving", and now so does the server (`homeLine`).
63
+
64
+ Walked and matching, left as found: the city (pins, the one button, its line), the street (the order, the group brackets, one pin per building in its tier, the crates and the tester count pin, the six readings), every building's section, lift and panel, the card (its pager, the change, the seats and tally, counsel, the yes/no acts under the desk's words from task 1004566), the toast, and a district's road of work.
65
+
66
+ Left on purpose, not counted as differences:
67
+
68
+ - **The city sits inside the hall's shell** (sidebar and top bar). The prototype's home page carries only the wordmark, the switch, one button and one line; standalone hall pages were retired by task 1002868, and the city page adopted the shell after its record was written.
69
+ - **The home line never says "pieces of work stuck at Review".** The prototype adds it while Review is a vacant lot; the city draws Review as held in this project (the header of `city-draw.js`), so the clause has nothing to stand on.
70
+ - **Open owner choices met on the walk, untouched:** the "1" on a single-item pin, the light-mode legend hint wording, the recording sign-off going to its page, failed passed-vote actions on the record, a Discord update on withdraw, and the Work categories link.
@@ -84,7 +84,8 @@
84
84
  <!-- THE BAR and its two bands are the hub's own (projects.html, task 1004455):
85
85
  same markup, same cosmos.css rules, so /projects → /account keeps the chrome.
86
86
  No map search (it only filters the map), and the band links name /projects
87
- in full because this page has no views of its own. -->
87
+ in full because this page has no views of its own. assets/hub-bar.js runs it,
88
+ shared with the three footer doors (task 1004489). -->
88
89
  <a class="skip vh" href="#acct">Skip to content</a>
89
90
  <header class="shell bar">
90
91
  <div class="barLeft">
@@ -101,7 +102,7 @@
101
102
  </div>
102
103
  <div class="barRight">
103
104
  <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>
104
- <!-- the identity chip IS the profile trigger; paintBar fills it from GET /me.
105
+ <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
105
106
  Signed out it stays hidden and the sign-in door takes its slot. -->
106
107
  <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden><img id="whoImg" alt="" hidden><span class="whoName" id="whoLogin"></span><svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
107
108
  <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/account&amp;repos=1">Sign in</a>
@@ -249,11 +250,17 @@
249
250
 
250
251
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">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><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
251
252
 
253
+ <script src="/assets/hub-bar.js"></script>
252
254
  <script>
253
255
  (function () {
254
256
  'use strict';
255
257
  var API = '/api/bongos';
256
258
  function $(id) { return document.getElementById(id); }
259
+ // The bar's chip, bands and sign out are assets/hub-bar.js (task 1004489), loaded
260
+ // just above. This page only repaints the chip: signed out when its own read
261
+ // 401s, and named from the account when GET /me failed. Without the asset the
262
+ // page still works; it just has no chip to repaint.
263
+ var bar = window.HubBar || { paint: function () {}, ready: Promise.resolve(null) };
257
264
 
258
265
  // The server's own view of the account — the last GET /me/cross-project
259
266
  // answer. Every save re-renders from it, never from what was typed.
@@ -298,7 +305,7 @@
298
305
  function showDoor() {
299
306
  $('loading').hidden = true;
300
307
  $('acctBody').hidden = true;
301
- paintBar(null);
308
+ bar.paint(null);
302
309
  $('door').hidden = false;
303
310
  }
304
311
  function showAlert(text) {
@@ -523,106 +530,10 @@
523
530
  : 'Cross-project record shown on your public profile.');
524
531
  });
525
532
 
526
- // ---- the bar: who is signed in, from the hub's own identity read. Signed
527
- // out there is no chip and no Your-projects item; the sign-in door takes the slot ----
528
- function paintBar(me) {
529
- var login = me ? (me.github_login || 'signed in') : '';
530
- var avatar = me && me.avatar_url;
531
- $('who').hidden = !me;
532
- $('signinTop').hidden = !!me;
533
- $('mineCell').hidden = !me;
534
- [['whoImg', 'whoLogin'], ['bandWhoImg', 'bandWhoLogin']].forEach(function (ids) {
535
- $(ids[0]).hidden = !avatar;
536
- if (avatar) $(ids[0]).src = avatar;
537
- $(ids[1]).textContent = login;
538
- });
539
- if (me) $('who').setAttribute('aria-label', 'Account, ' + login);
540
- else closeMenus();
541
- }
542
-
543
- // ---- the bands: the hub's disclosure (projects.html "THE BANDS") without
544
- // the map search. Escape closes and returns focus to the trigger, a click
545
- // outside closes, the arrows walk the items, opening one closes the other ----
546
- function openBand() { return document.querySelector('.menuBand:not([hidden])'); }
547
- function closeMenus() {
548
- var open = openBand();
549
- if (!open) return;
550
- var trig = document.querySelector('.trig[aria-expanded="true"]');
551
- var inside = open.contains(document.activeElement);
552
- open.hidden = true;
553
- if (trig) trig.setAttribute('aria-expanded', 'false');
554
- if (inside && trig) trig.focus();
555
- }
556
- function openMenu(trig, byKeyboard) {
557
- closeMenus();
558
- trig.setAttribute('aria-expanded', 'true');
559
- var band = $(trig.getAttribute('aria-controls'));
560
- band.hidden = false;
561
- // Enter/Space on the trigger lands on the first item; a pointer stays put
562
- if (byKeyboard) { var first = band.querySelector('.bandCell:not([hidden]) a'); if (first) first.focus(); }
563
- }
564
- document.querySelectorAll('.trig').forEach(function (t) {
565
- t.addEventListener('click', function (ev) {
566
- if (t.getAttribute('aria-expanded') === 'true') closeMenus();
567
- else openMenu(t, ev.detail === 0);
568
- });
569
- });
570
- document.addEventListener('click', function (ev) {
571
- if (!openBand()) return;
572
- if (ev.target.closest && (ev.target.closest('.menuBand') || ev.target.closest('.trig'))) return;
573
- closeMenus();
574
- });
575
- document.addEventListener('keydown', function (ev) {
576
- var open = openBand();
577
- if (!open) return;
578
- if (ev.key === 'Escape') { closeMenus(); return; }
579
- if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
580
- var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]'); });
581
- if (!items.length) return;
582
- var at = items.indexOf(document.activeElement);
583
- ev.preventDefault();
584
- items[(at + (ev.key === 'ArrowDown' ? 1 : items.length - 1) + items.length) % items.length].focus();
585
- });
586
- document.querySelectorAll('.menuBand').forEach(function (b) {
587
- b.addEventListener('click', function (ev) {
588
- if (ev.target.closest && ev.target.closest('a')) closeMenus();
589
- });
590
- // Tab out of the band closes it; a null relatedTarget is the window losing
591
- // focus or a click on nothing, which the document click covers
592
- b.addEventListener('focusout', function (ev) {
593
- var to = ev.relatedTarget;
594
- if (!to || b.contains(to) || (to.closest && to.closest('.trig'))) return;
595
- closeMenus();
596
- });
597
- });
598
-
599
- // ---- sign out: the hub's own logout (projects.html doLogout), delegated on
600
- // the profile band's plain anchor ----
601
- $('menuProfile').addEventListener('click', function (ev) {
602
- if (!ev.target.closest) return;
603
- // Account is this page: a reload would only throw away what was typed
604
- if (ev.target.closest('[aria-current="page"]')) { ev.preventDefault(); return; }
605
- if (!ev.target.closest('a.signout')) return;
606
- ev.preventDefault();
607
- var finish = function () {
608
- var apex = location.hostname.split('.').slice(-2).join('.');
609
- ['bongos_session', 'gds_session', 'pms_session'].forEach(function (name) {
610
- document.cookie = name + '=; Max-Age=0; path=/';
611
- document.cookie = name + '=; Max-Age=0; path=/; Domain=.' + apex;
612
- });
613
- window.location.assign(location.pathname);
614
- };
615
- fetch(API + '/auth/logout', { method: 'POST', credentials: 'same-origin' }).then(finish, finish);
616
- });
617
-
618
- var whoRead = request('GET', '/me').then(function (res) {
619
- var d = res.ok && res.data;
620
- paintBar(d ? (d.builder || d.me || d) : null);
621
- }, function () { paintBar(null); });
622
533
  // The identity read failing is not a sign-out. If the account itself was
623
534
  // read, the chip names it from that answer, so the way out is never lost.
624
- load().then(function () { return whoRead; }).then(function () {
625
- if (acct && $('who').hidden) paintBar({ github_login: acct.handle || acct.display_name });
535
+ load().then(function () { return bar.ready; }).then(function () {
536
+ if (acct && $('who').hidden) bar.paint({ github_login: acct.handle || acct.display_name });
626
537
  });
627
538
  })();
628
539
  </script>
@@ -0,0 +1,127 @@
1
+ /* assets/hub-bar.js — THE HUB'S BAR on the pages that have no views of their own
2
+ (task 1004489, after task 1004455).
3
+
4
+ /projects draws the bar and its two bands — the Projects band and the profile
5
+ band behind the identity chip. The apex pages a person reaches from it wear the
6
+ same bar so the chrome does not swap under them: the account page (/account) and
7
+ the three footer doors (/privacy, /terms, /contact). Each page keeps the bar's
8
+ MARKUP (real markup, pinned byte-equal to the hub's); this file is the one copy
9
+ of what makes it work there:
10
+ - the chip, filled from GET /me — signed out there is no chip and no
11
+ Your-projects item, and the sign-in door takes the chip's slot;
12
+ - the bands' disclosure: a trigger toggles its band, opening one closes the
13
+ other, Escape closes and returns focus to the trigger, a click outside or
14
+ Tab out closes, the arrows walk the items, an item activated closes it;
15
+ - sign out: the profile band's plain anchor upgraded to the hub's logout POST.
16
+
17
+ projects.html keeps its own copy (its bands also fold the map search and switch
18
+ views), so a change to the disclosure is made there AND here.
19
+ tests/hub_account_page.mjs and tests/landing_legal_pages.mjs execute this file.
20
+
21
+ A page that knows more about who is signed in than GET /me says paints the chip
22
+ itself through window.HubBar.paint(me); window.HubBar.ready settles with what
23
+ GET /me answered (null when nobody is signed in or the read failed). */
24
+ (function () {
25
+ 'use strict';
26
+ var API = '/api/bongos';
27
+ function $(id) { return document.getElementById(id); }
28
+
29
+ // ---- the chip: who is signed in (null = nobody) ----
30
+ function paint(me) {
31
+ var login = me ? (me.github_login || 'signed in') : '';
32
+ var avatar = me && me.avatar_url;
33
+ $('who').hidden = !me;
34
+ $('signinTop').hidden = !!me;
35
+ $('mineCell').hidden = !me;
36
+ [['whoImg', 'whoLogin'], ['bandWhoImg', 'bandWhoLogin']].forEach(function (ids) {
37
+ $(ids[0]).hidden = !avatar;
38
+ if (avatar) $(ids[0]).src = avatar;
39
+ $(ids[1]).textContent = login;
40
+ });
41
+ if (me) $('who').setAttribute('aria-label', 'Account, ' + login);
42
+ else closeMenus();
43
+ }
44
+
45
+ // ---- the bands: the hub's disclosure (projects.html "THE BANDS") without the map search ----
46
+ function openBand() { return document.querySelector('.menuBand:not([hidden])'); }
47
+ function closeMenus() {
48
+ var open = openBand();
49
+ if (!open) return;
50
+ var trig = document.querySelector('.trig[aria-expanded="true"]');
51
+ var inside = open.contains(document.activeElement);
52
+ open.hidden = true;
53
+ if (trig) trig.setAttribute('aria-expanded', 'false');
54
+ if (inside && trig) trig.focus();
55
+ }
56
+ function openMenu(trig, byKeyboard) {
57
+ closeMenus();
58
+ trig.setAttribute('aria-expanded', 'true');
59
+ var band = $(trig.getAttribute('aria-controls'));
60
+ band.hidden = false;
61
+ // Enter/Space on the trigger lands on the first item; a pointer stays put
62
+ if (byKeyboard) { var first = band.querySelector('.bandCell:not([hidden]) a'); if (first) first.focus(); }
63
+ }
64
+ document.querySelectorAll('.trig').forEach(function (t) {
65
+ t.addEventListener('click', function (ev) {
66
+ if (t.getAttribute('aria-expanded') === 'true') closeMenus();
67
+ else openMenu(t, ev.detail === 0);
68
+ });
69
+ });
70
+ document.addEventListener('click', function (ev) {
71
+ if (!openBand()) return;
72
+ if (ev.target.closest && (ev.target.closest('.menuBand') || ev.target.closest('.trig'))) return;
73
+ closeMenus();
74
+ });
75
+ document.addEventListener('keydown', function (ev) {
76
+ var open = openBand();
77
+ if (!open) return;
78
+ if (ev.key === 'Escape') { closeMenus(); return; }
79
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
80
+ var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]'); });
81
+ if (!items.length) return;
82
+ var at = items.indexOf(document.activeElement);
83
+ ev.preventDefault();
84
+ items[(at + (ev.key === 'ArrowDown' ? 1 : items.length - 1) + items.length) % items.length].focus();
85
+ });
86
+ document.querySelectorAll('.menuBand').forEach(function (b) {
87
+ b.addEventListener('click', function (ev) {
88
+ if (ev.target.closest && ev.target.closest('a')) closeMenus();
89
+ });
90
+ // Tab out of the band closes it; a null relatedTarget is the window losing
91
+ // focus or a click on nothing, which the document click covers
92
+ b.addEventListener('focusout', function (ev) {
93
+ var to = ev.relatedTarget;
94
+ if (!to || b.contains(to) || (to.closest && to.closest('.trig'))) return;
95
+ closeMenus();
96
+ });
97
+ });
98
+
99
+ // ---- the profile band: the current place, and sign out (projects.html doLogout) ----
100
+ $('menuProfile').addEventListener('click', function (ev) {
101
+ if (!ev.target.closest) return;
102
+ // an item marked as this page: a reload would only throw away what was typed
103
+ if (ev.target.closest('[aria-current="page"]')) { ev.preventDefault(); return; }
104
+ if (!ev.target.closest('a.signout')) return;
105
+ ev.preventDefault();
106
+ var finish = function () {
107
+ var apex = location.hostname.split('.').slice(-2).join('.');
108
+ ['bongos_session', 'gds_session', 'pms_session'].forEach(function (name) {
109
+ document.cookie = name + '=; Max-Age=0; path=/';
110
+ document.cookie = name + '=; Max-Age=0; path=/; Domain=.' + apex;
111
+ });
112
+ window.location.assign(location.pathname);
113
+ };
114
+ fetch(API + '/auth/logout', { method: 'POST', credentials: 'same-origin' }).then(finish, finish);
115
+ });
116
+
117
+ // ---- the identity read: the hub's own GET /me ----
118
+ var ready = fetch(API + '/me', { method: 'GET', credentials: 'same-origin', headers: { Accept: 'application/json' } })
119
+ .then(function (res) { return res.ok ? res.json().catch(function () { return null; }) : null; })
120
+ .then(function (d) {
121
+ var me = d ? (d.builder || d.me || d) : null;
122
+ paint(me);
123
+ return me;
124
+ }, function () { paint(null); return null; });
125
+
126
+ window.HubBar = { paint: paint, ready: ready };
127
+ })();
@@ -20,9 +20,14 @@
20
20
  <!-- Dark only, like every apex page (task 1003336, ADR 0204): data-mode="dark" is fixed on <html>. -->
21
21
  <style>
22
22
  /* The reading column only. No :root block — the pack's injected theme owns the
23
- tokens (landing direction C1); everything below reads them. */
23
+ tokens (landing direction C1); everything below reads them. The bar and the
24
+ bands are cosmos.css's own rules, not restyled here. */
25
+ /* the skip link un-hides itself on focus, reversing everything .vh sets (the hub's rule) */
26
+ .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;
27
+ font-size:14px;font-weight:800;text-decoration:none;color:var(--ink);background:var(--bg-card);
28
+ border:2px solid var(--ctl-line);border-radius:12px;padding:10px 16px;}
24
29
  .legal{position:relative;z-index:1;max-width:720px;margin:0 auto;padding:clamp(6px,1.5vw,18px) clamp(16px,4vw,40px) 48px;}
25
- .legal h1{font-family:var(--font-display);font-weight:700;font-size:clamp(26px,3.8vw,36px);letter-spacing:-0.5px;color:var(--chrome-ink);text-shadow:var(--chrome-glow);margin-bottom:6px;}
30
+ .legal h1{font-family:var(--font-display);font-weight:700;font-size:clamp(26px,3.8vw,36px);letter-spacing:-0.5px;color:var(--chrome-ink);text-shadow:var(--chrome-glow);margin:18px 0 6px;}
26
31
  .legal .lede{color:var(--hero-lede);text-shadow:var(--chrome-glow);margin-bottom:18px;}
27
32
  .legal h2{font-family:var(--font-display);font-weight:700;font-size:var(--fs-h3);letter-spacing:-0.3px;line-height:1.25;margin:22px 0 8px;}
28
33
  .legal .card h2:first-child{margin-top:0;}
@@ -34,15 +39,60 @@
34
39
  </style>
35
40
  </head>
36
41
  <body>
37
- <header class="top">
38
- <a class="mark" href="/">{{productName}}</a>
39
- <div class="top-right">
40
- <a class="btn-space" href="/projects">Projects</a>
41
- <a class="btn-space" data-link-hall href="{{buildersOrigin}}">Hall</a>
42
+ <!-- THE BAR and its two bands are the hub's own (projects.html; task 1004489,
43
+ after the account page's task 1004455): same markup, same cosmos.css rules,
44
+ so following a footer link from /projects or /account keeps the chrome. No
45
+ map search (it only filters the map), the band links name /projects in full
46
+ because this page has no views of its own, and no item is the current place.
47
+ assets/hub-bar.js fills the chip from GET /me and runs the bands. -->
48
+ <a class="skip vh" href="#legal">Skip to content</a>
49
+ <header class="shell bar">
50
+ <div class="barLeft">
51
+ <a class="barMark" id="barMark" href="/">
52
+ <svg viewBox="0 0 26 20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4"
53
+ stroke-linecap="round" stroke-linejoin="round">
54
+ <ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>
55
+ <path d="M1.8 5.6v6.6a6.6 2.9 0 0 0 13.2 0V5.6"/>
56
+ <ellipse cx="19.6" cy="8.4" rx="4.7" ry="2.1"/>
57
+ <path d="M14.9 8.4v4.6a4.7 2.1 0 0 0 9.4 0V8.4"/>
58
+ </svg>
59
+ <span>{{productName}}</span>
60
+ </a>
61
+ </div>
62
+ <div class="barRight">
63
+ <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>
64
+ <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from
65
+ GET /me. Signed out it stays hidden and the sign-in door takes its slot. -->
66
+ <button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden><img id="whoImg" alt="" hidden><span class="whoName" id="whoLogin"></span><svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
67
+ <a class="signinDoor" id="signinTop" hidden href="/api/bongos/auth/web/start?return=/contact&amp;repos=1">Sign in</a>
42
68
  </div>
43
69
  </header>
44
70
 
45
- <main class="legal">
71
+ <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
72
+ <div class="bandIn">
73
+ <ul class="bandRow">
74
+ <li class="bandCell"><a class="bandT" href="/projects?view=map">Sky</a><span class="bandC">the sky of every project</span></li>
75
+ <li class="bandCell"><a class="bandT" href="/projects?view=map&amp;sub=table">Table</a><span class="bandC">the same projects as rows</span></li>
76
+ <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own or build on</span></li>
77
+ <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
78
+ </ul>
79
+ </div>
80
+ </div>
81
+ <div class="menuBand" id="menuProfile" data-menu="profile" hidden>
82
+ <div class="bandIn bandProfile">
83
+ <p class="bandWho" id="bandWho"><img id="bandWhoImg" alt="" hidden><span class="whoName" id="bandWhoLogin"></span></p>
84
+ <ul class="bandRow">
85
+ <li class="bandCell"><a class="bandT" href="/projects?view=mine">My projects</a></li>
86
+ <!-- apex-only because /account is: it is served only where platform-identity
87
+ runs, so a child instance loses the item whole (as on projects.html) -->
88
+ <!--apex-only:start--><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->
89
+ <!-- the honest hover target; assets/hub-bar.js upgrades the click to the POST -->
90
+ <li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>
91
+ </ul>
92
+ </div>
93
+ </div>
94
+
95
+ <main class="legal" id="legal">
46
96
  <h1>Contact</h1>
47
97
  <p class="lede">Two doors. Both are public, and both are how {{productName}} itself works day to day.</p>
48
98
 
@@ -68,6 +118,7 @@
68
118
  </section>
69
119
  </main>
70
120
 
71
- <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a></nav></footer>
121
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
122
+ <script src="/assets/hub-bar.js"></script>
72
123
  </body>
73
124
  </html>