@bongos/core 1.23.8 → 1.23.10

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 (42) hide show
  1. package/.bongos-core.json +92 -37
  2. package/docs/copy-inventory.md +858 -724
  3. package/docs/copy-registry.json +2538 -1155
  4. package/docs/file-map.md +1 -0
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-inventory.json +41 -4
  7. package/docs/page-readings.json +1240 -1156
  8. package/modules/public-landing/public/account.html +11 -0
  9. package/modules/public-landing/public/assets/cosmos.css +9 -1
  10. package/modules/public-landing/public/assets/hub-bar.js +1 -1
  11. package/modules/public-landing/public/community-empty.states.json +15 -0
  12. package/modules/public-landing/public/community-recruiter.states.json +15 -0
  13. package/modules/public-landing/public/community.html +469 -0
  14. package/modules/public-landing/public/community.probes.json +92 -0
  15. package/modules/public-landing/public/community.states.json +23 -0
  16. package/modules/public-landing/public/connections.html +11 -0
  17. package/modules/public-landing/public/contact.html +11 -0
  18. package/modules/public-landing/public/guild.html +11 -0
  19. package/modules/public-landing/public/index.html +18 -1
  20. package/modules/public-landing/public/index.probes.json +1 -1
  21. package/modules/public-landing/public/invitations.html +11 -0
  22. package/modules/public-landing/public/privacy.html +11 -0
  23. package/modules/public-landing/public/projects.html +12 -1
  24. package/modules/public-landing/public/terms.html +11 -0
  25. package/modules/ui-design/kit/fixtures/community-leaderboard-empty.json +4 -0
  26. package/modules/ui-design/kit/fixtures/community-leaderboard.json +28 -0
  27. package/modules/ui-design/kit/fixtures/community-search.json +10 -0
  28. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +11 -0
  29. package/modules/ui-design/kit/fixtures/scouting.json +4 -0
  30. package/modules/ui-design/kit/serve.js +55 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +12 -0
  34. package/src/bongos/serve-internal.js +4 -2
  35. package/src/module-api.js +1 -1
  36. package/src/platform-server.js +11 -0
  37. package/tests/hub_account_page.mjs +2 -0
  38. package/tests/hub_community_page.mjs +475 -0
  39. package/tests/landing_legal_pages.mjs +3 -0
  40. package/tests/landing_page.mjs +1 -1
  41. package/tests/render_check.mjs +2 -2
  42. package/tests/ui_design_kit.mjs +2 -2
@@ -0,0 +1,92 @@
1
+ {
2
+ "_": "The community pages' interaction contract for modules/ui-design/kit/probe.js (task 1003363): the Community menu as the current section with each sub-page marked, the window dropdown and a craft each re-reading the board, the search on Enter with its too-short refusal and a hidden builder found by name, and the recruiter list as a door signed out and a refusal for a builder the recruiting door does not admit.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "probes": [
6
+ {
7
+ "name": "community signed out: the leaderboard for everyone; Community the current section, Leaderboard the current item",
8
+ "auth": false,
9
+ "width": 1440,
10
+ "steps": [["wait", 400], ["click", "#commTrig"], ["wait", 150]],
11
+ "expect": {
12
+ "open": "#menuCommunity",
13
+ "visible": ["#boardList", "#signinTop"],
14
+ "hidden": ["#find", "#recruit"],
15
+ "count": [["#boardList .commRow", 6], ["#menuCommunity a[aria-current=\"page\"]", 1]],
16
+ "attr": [["#commTrig", "aria-current", "true"]],
17
+ "href": [["#menuCommunity a[aria-current=\"page\"]", "/community"], ["#signinTop", "/api/bongos/auth/web/start?return=/community&repos=1"]]
18
+ }
19
+ },
20
+ {
21
+ "name": "community: the 7-day window from the dropdown re-reads the board",
22
+ "auth": false,
23
+ "width": 1440,
24
+ "steps": [["wait", 400], ["select", "#boardWindow", "7d"], ["wait", 300]],
25
+ "expect": {
26
+ "count": [["#boardList .commRow", 2]],
27
+ "value": [["#boardWindow", "7d"]],
28
+ "text": [["#boardList .commRow:last-child .commNum", "-15"], ["#boardNote", "Credits earned in the last 7 days, across every project."]]
29
+ }
30
+ },
31
+ {
32
+ "name": "community: a craft filters the board and renumbers nothing the server did not",
33
+ "auth": false,
34
+ "width": 390,
35
+ "steps": [["wait", 400], ["click", "#craftTabs [data-craft=\"engineer\"]"], ["wait", 300]],
36
+ "expect": {
37
+ "count": [["#boardList .commRow", 3]],
38
+ "attr": [["#craftTabs [data-craft=\"engineer\"]", "aria-pressed", "true"]],
39
+ "text": [["#boardList .commRow:first-child .commPlace", "1"], ["#boardList .commRow:first-child .mineRow__name", "Priya Nair"]]
40
+ }
41
+ },
42
+ {
43
+ "name": "community find: the sub-page is marked, the search refuses one letter",
44
+ "auth": false,
45
+ "width": 1440,
46
+ "url": "/community/find",
47
+ "steps": [["wait", 400], ["fill", "#findQ", "s"], ["press", "Enter"], ["wait", 200]],
48
+ "expect": {
49
+ "hidden": ["#board", "#recruit"],
50
+ "count": [["#findList .mineRow", 0], ["#menuCommunity a[aria-current=\"page\"]", 1]],
51
+ "href": [["#menuCommunity a[aria-current=\"page\"]", "/community/find"], ["#signinTop", "/api/bongos/auth/web/start?return=/community/find&repos=1"]],
52
+ "text": [["#findStatus", "Type at least 2 letters of a name."]]
53
+ }
54
+ },
55
+ {
56
+ "name": "community find: a hidden builder is found by name, with no craft said",
57
+ "auth": false,
58
+ "width": 1440,
59
+ "url": "/community/find",
60
+ "steps": [["wait", 400], ["fill", "#findQ", "sam"], ["press", "Enter"], ["wait", 300]],
61
+ "expect": {
62
+ "count": [["#findList .mineRow", 1], ["#findList .mineRow__hint", 0]],
63
+ "text": [["#findStatus", "1 builder found."], ["#findList .mineRow:first-child .mineRow__name", "Sam Rivera"]]
64
+ }
65
+ },
66
+ {
67
+ "name": "community recruiters signed out: the sign-in door, and no list",
68
+ "auth": false,
69
+ "width": 1440,
70
+ "url": "/community/recruiters",
71
+ "steps": [["wait", 400]],
72
+ "expect": {
73
+ "visible": ["#recruitDoor"],
74
+ "hidden": ["#recruitClosed"],
75
+ "count": [["#recruitList .mineRow", 0]],
76
+ "href": [["#recruitSignin", "/api/bongos/auth/web/start?return=/community/recruiters&repos=1"]]
77
+ }
78
+ },
79
+ {
80
+ "name": "community recruiters signed in, not a project owner: told it is for project owners, and shown no one",
81
+ "auth": true,
82
+ "width": 1440,
83
+ "url": "/community/recruiters",
84
+ "steps": [["wait", 600]],
85
+ "expect": {
86
+ "visible": ["#recruitClosed", "#who"],
87
+ "hidden": ["#recruitDoor", "#signinTop"],
88
+ "count": [["#recruitList .mineRow", 0]]
89
+ }
90
+ }
91
+ ]
92
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "_": "The community pages' states for modules/ui-design/kit/render.js (task 1003363; ADR 0217 D1, ADR 0337 D4.1): one file served as three sub-pages of the bar's Community menu, each state naming its `url`. The leaderboard and the search are the same for every viewer, from the stub's fixtures (community-leaderboard.json: six builders all time, fewer in each window; community-search.json). The recruiter list signed out is a sign-in door, and signed in it is the default fixture's 403 (most builders own no project); community-recruiter.states.json is a project owner's list, community-empty.states.json a board with nobody on it. Dark only, like every apex page (ADR 0204).",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/me/guild-", "/scouting$"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
9
+ "ignoreControls": [".vh"],
10
+ "states": {
11
+ "out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList", "#boardWindow", "#signinTop", "#projTrig"], "hidden": ["#find", "#recruit", "#who", "#boardLoading", "#boardFailed", "#boardEmpty"], "count": [["#boardList .commRow", 6]], "value": [["#boardWindow", "all"]], "attr": [["#craftTabs [data-craft=\"\"]", "aria-pressed", "true"], ["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Priya Nair"], ["#boardList .commRow:first-child .commNum", "18,420"], ["#boardList .commRow:first-child .mineRow__hint", "212 works shipped · 64 karma"], ["#boardNote", "Credits earned across every project, since each builder started."]] } },
12
+ "out-30d": { "auth": false, "actions": [["wait", 400], ["select", "#boardWindow", "30d"], ["wait", 300]], "expect": { "count": [["#boardList .commRow", 3]], "value": [["#boardWindow", "30d"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Ada Okafor"], ["#boardNote", "Credits earned in the last 30 days, across every project."]] } },
13
+ "out-artists": { "auth": false, "actions": [["wait", 400], ["click", "#craftTabs [data-craft=\"artist\"]"], ["wait", 300]], "expect": { "count": [["#boardList .commRow", 1]], "text": [["#boardList .commRow:first-child .mineRow__name", "Ada Okafor"], ["#boardList .commRow:first-child .commPlace", "1"]] } },
14
+ "out-community-band": { "auth": false, "actions": [["wait", 400], ["clickIfVisible", "#commTrig"], ["wait", 300]], "expect": { "count": [["#menuCommunity .bandCell", 3]], "attr": [["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]] } },
15
+ "out-projects-band": { "auth": false, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects"], "attr": [["#menuProjects a[href=\"/community\"]", "aria-current", "page"]] } },
16
+ "out-find": { "auth": false, "url": "/community/find", "actions": [["wait", 400]], "expect": { "visible": ["#find", "#findQ", "#findCraft", "#findBtn"], "hidden": ["#board", "#recruit"], "attr": [["#menuCommunity a[href=\"/community/find\"]", "aria-current", "page"]] } },
17
+ "out-find-search": { "auth": false, "url": "/community/find", "actions": [["wait", 400], ["fill", "#findQ", "pri"], ["click", "#findBtn"], ["wait", 300]], "expect": { "count": [["#findList .mineRow", 2]], "text": [["#findStatus", "2 builders found."], ["#findList .mineRow:first-child .mineRow__hint", "engineer · designer"]] } },
18
+ "out-recruiters": { "auth": false, "url": "/community/recruiters", "actions": [["wait", 400]], "expect": { "visible": ["#recruit", "#recruitDoor", "#recruitSignin"], "hidden": ["#board", "#find", "#recruitLoading", "#recruitClosed"], "count": [["#recruitList .mineRow", 0]], "attr": [["#menuCommunity a[href=\"/community/recruiters\"]", "aria-current", "page"]] } },
19
+ "in-recruiters": { "auth": true, "url": "/community/recruiters", "actions": [["wait", 600]], "expect": { "visible": ["#recruit", "#recruitClosed", "#who"], "hidden": ["#recruitDoor", "#recruitLoading", "#recruitFailed"], "count": [["#recruitList .mineRow", 0]] } },
20
+ "in": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#board", "#who"], "hidden": ["#find", "#recruit", "#signinTop", "#boardFailed"], "count": [["#boardList .commRow", 6]] } }
21
+ },
22
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
23
+ }
@@ -76,6 +76,7 @@
76
76
  </a>
77
77
  </div>
78
78
  <div class="barRight">
79
+ <button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
79
80
  <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>
80
81
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
81
82
  Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -84,6 +85,15 @@
84
85
  </div>
85
86
  </header>
86
87
 
88
+ <div class="menuBand" id="menuCommunity" data-menu="community" hidden>
89
+ <div class="bandIn">
90
+ <ul class="bandRow">
91
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
92
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
93
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
94
+ </ul>
95
+ </div>
96
+ </div>
87
97
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
88
98
  <div class="bandIn">
89
99
  <ul class="bandRow">
@@ -91,6 +101,7 @@
91
101
  <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>
92
102
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
93
103
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
104
+ <li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
94
105
  </ul>
95
106
  </div>
96
107
  </div>
@@ -58,6 +58,7 @@
58
58
  </a>
59
59
  </div>
60
60
  <div class="barRight">
61
+ <!--apex-only:start--><button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button><!--apex-only:end-->
61
62
  <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>
62
63
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from
63
64
  GET /me. Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -66,6 +67,15 @@
66
67
  </div>
67
68
  </header>
68
69
 
70
+ <!--apex-only:start--><div class="menuBand" id="menuCommunity" data-menu="community" hidden>
71
+ <div class="bandIn">
72
+ <ul class="bandRow">
73
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
74
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
75
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
76
+ </ul>
77
+ </div>
78
+ </div><!--apex-only:end-->
69
79
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
70
80
  <div class="bandIn">
71
81
  <ul class="bandRow">
@@ -73,6 +83,7 @@
73
83
  <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>
74
84
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
75
85
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
86
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
76
87
  </ul>
77
88
  </div>
78
89
  </div>
@@ -133,6 +133,7 @@
133
133
  </a>
134
134
  </div>
135
135
  <div class="barRight">
136
+ <button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
136
137
  <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>
137
138
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
138
139
  Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -141,6 +142,15 @@
141
142
  </div>
142
143
  </header>
143
144
 
145
+ <div class="menuBand" id="menuCommunity" data-menu="community" hidden>
146
+ <div class="bandIn">
147
+ <ul class="bandRow">
148
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
149
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
150
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
151
+ </ul>
152
+ </div>
153
+ </div>
144
154
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
145
155
  <div class="bandIn">
146
156
  <ul class="bandRow">
@@ -148,6 +158,7 @@
148
158
  <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>
149
159
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
150
160
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
161
+ <li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
151
162
  </ul>
152
163
  </div>
153
164
  </div>
@@ -166,6 +166,10 @@ main:focus{outline:none;box-shadow:none}
166
166
  .trig .cv{width:13px;height:13px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;
167
167
  stroke-linecap:round;stroke-linejoin:round;transition:transform .2s cubic-bezier(.2,.7,.3,1)}
168
168
  .trig[aria-expanded="true"] .cv{transform:rotate(180deg)}
169
+ /* the Community menu (task 1003363), a trigger like Projects. Under 900 this bar has no
170
+ room for it (the New account pill and the chip), so its three items fold into the
171
+ Projects band as its last cells; above 900 those cells are not drawn */
172
+ .bandFold{display:none}
169
173
  .whoSlot{display:inline-flex;align-items:center}
170
174
  .who{gap:9px}
171
175
  .who img{width:26px;height:26px;border-radius:var(--radius-pill);object-fit:cover;flex:none;border:1px solid var(--rule)}
@@ -501,6 +505,8 @@ body{
501
505
  .barMark{min-width:44px;justify-content:center}
502
506
  .mark-word{display:none}
503
507
  .trig{font-size:13.5px}
508
+ .commTrig{display:none}
509
+ .bandFold{display:block}
504
510
  .who-name{display:none}
505
511
  .pill{min-height:42px;font-size:13.5px}
506
512
  .signin,.newacct{padding:0 14px}
@@ -690,6 +696,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
690
696
  </a>
691
697
  </div>
692
698
  <div class="barRight">
699
+ <!--apex-only:start--><button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button><!--apex-only:end-->
693
700
  <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>
694
701
  <!-- the identity slot: the sign-in door signed out; the chip (the profile trigger) once
695
702
  the session resolves — swapped client-side, never a wrong-state flash -->
@@ -707,6 +714,15 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
707
714
  component, two instances (the script at the foot of the page): Escape closes and returns focus
708
715
  to the trigger, the arrows walk the items, Tab out closes, a click outside closes, opening one
709
716
  closes the other. -->
717
+ <!--apex-only:start--><div class="menuBand" id="menuCommunity" data-menu="community" hidden>
718
+ <div class="bandIn">
719
+ <ul class="bandRow">
720
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
721
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
722
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
723
+ </ul>
724
+ </div>
725
+ </div><!--apex-only:end-->
710
726
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
711
727
  <div class="bandIn">
712
728
  <ul class="bandRow">
@@ -714,6 +730,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
714
730
  <li class="bandCell"><a class="bandT" href="/projects?view=map&amp;sub=table">Table</a><span class="bandC">sky project directory</span></li>
715
731
  <li class="bandCell" id="mineCell" hidden><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
716
732
  <li class="bandCell"><a class="bandT" href="/projects?view=new">+ Create New Project</a><span class="bandC">fresh start/fresh idea</span></li>
733
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
717
734
  </ul>
718
735
  <!-- About and Docs are site pages, not project views: they live in the footer row with
719
736
  Privacy / Terms / Contact (owner, 2026-08-28; task 1003332). -->
@@ -1085,7 +1102,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1085
1102
  if (!open) return;
1086
1103
  if (ev.key === 'Escape') { closeMenus(); return; }
1087
1104
  if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
1088
- var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]'); });
1105
+ var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]') && a.offsetParent !== null; });
1089
1106
  if (!items.length) return;
1090
1107
  var at = items.indexOf(document.activeElement);
1091
1108
  ev.preventDefault();
@@ -26,7 +26,7 @@
26
26
  "expect": { "closed": "#menuProfile", "expanded": false } },
27
27
  { "name": "landing: the band deep-links the hub views; About and Docs are footer links (task 1003332)", "continue": true,
28
28
  "steps": [["click", "#projTrig"], ["wait", 120]],
29
- "expect": { "count": [["#menuProjects a", 4], ["#menuProjects a[href=\"/docs\"]", 0]], "href": [["#menuProjects .bandCell:nth-child(1) a", "/projects?view=map"], ["#menuProjects .bandCell:nth-child(2) a", "/projects?view=map&sub=table"], ["footer a[href=\"#about\"]", "#about"], ["footer a[href=\"/docs\"]", "/docs"]] } },
29
+ "expect": { "count": [["#menuProjects a", 7], ["#menuProjects a[href=\"/community\"]", 1], ["#menuCommunity a", 3], ["#menuProjects a[href=\"/docs\"]", 0]], "href": [["#menuProjects .bandCell:nth-child(1) a", "/projects?view=map"], ["#menuProjects .bandCell:nth-child(2) a", "/projects?view=map&sub=table"], ["footer a[href=\"#about\"]", "#about"], ["footer a[href=\"/docs\"]", "/docs"]] } },
30
30
  { "name": "landing: #about exists", "continue": true,
31
31
  "steps": [["press", "Escape"]],
32
32
  "expect": { "count": [["#about", 1]] } },
@@ -88,6 +88,7 @@
88
88
  </a>
89
89
  </div>
90
90
  <div class="barRight">
91
+ <button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>
91
92
  <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>
92
93
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from GET /me.
93
94
  Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -96,6 +97,15 @@
96
97
  </div>
97
98
  </header>
98
99
 
100
+ <div class="menuBand" id="menuCommunity" data-menu="community" hidden>
101
+ <div class="bandIn">
102
+ <ul class="bandRow">
103
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
104
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
105
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
106
+ </ul>
107
+ </div>
108
+ </div>
99
109
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
100
110
  <div class="bandIn">
101
111
  <ul class="bandRow">
@@ -103,6 +113,7 @@
103
113
  <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>
104
114
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
105
115
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
116
+ <li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
106
117
  </ul>
107
118
  </div>
108
119
  </div>
@@ -57,6 +57,7 @@
57
57
  </a>
58
58
  </div>
59
59
  <div class="barRight">
60
+ <!--apex-only:start--><button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button><!--apex-only:end-->
60
61
  <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>
61
62
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from
62
63
  GET /me. Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -65,6 +66,15 @@
65
66
  </div>
66
67
  </header>
67
68
 
69
+ <!--apex-only:start--><div class="menuBand" id="menuCommunity" data-menu="community" hidden>
70
+ <div class="bandIn">
71
+ <ul class="bandRow">
72
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
73
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
74
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
75
+ </ul>
76
+ </div>
77
+ </div><!--apex-only:end-->
68
78
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
69
79
  <div class="bandIn">
70
80
  <ul class="bandRow">
@@ -72,6 +82,7 @@
72
82
  <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>
73
83
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
74
84
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
85
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
75
86
  </ul>
76
87
  </div>
77
88
  </div>
@@ -837,6 +837,7 @@ summary{min-height:24px;padding:3px 0;}
837
837
  </div>
838
838
  </div>
839
839
  <div class="barRight">
840
+ <!--apex-only:start--><button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button><!--apex-only:end-->
840
841
  <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>
841
842
  <!-- the identity chip IS the profile trigger; renderWho fills it. Signed
842
843
  out it does not exist and the sign-in door takes its slot. -->
@@ -854,6 +855,15 @@ summary{min-height:24px;padding:3px 0;}
854
855
  a click outside closes, opening one closes the other. Plain links in a
855
856
  disclosure (aria-expanded + aria-controls, no menu role), so what is
856
857
  announced is what ships. ── -->
858
+ <!--apex-only:start--><div class="menuBand" id="menuCommunity" data-menu="community" hidden>
859
+ <div class="bandIn">
860
+ <ul class="bandRow">
861
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
862
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
863
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
864
+ </ul>
865
+ </div>
866
+ </div><!--apex-only:end-->
857
867
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
858
868
  <div class="bandIn">
859
869
  <ul class="bandRow">
@@ -863,6 +873,7 @@ summary{min-height:24px;padding:3px 0;}
863
873
  <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>
864
874
  <li class="bandCell"><a class="bandT" href="?view=mine" data-nav="mine">Your projects</a><span class="bandC">the ones you own</span></li>
865
875
  <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>
876
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
866
877
  </ul>
867
878
  <!-- About and Docs are site pages, not project views: they live in the
868
879
  footer row with Privacy / Terms / Contact (owner, 2026-08-28; task 1003332). -->
@@ -3610,7 +3621,7 @@ summary{min-height:24px;padding:3px 0;}
3610
3621
  }
3611
3622
  if (ev.key === 'Escape') { closeMenus(); return; }
3612
3623
  if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
3613
- var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]'); });
3624
+ var items = Array.prototype.filter.call(open.querySelectorAll('a'), function (a) { return !a.closest('[hidden]') && a.offsetParent !== null; });
3614
3625
  if (!items.length) return;
3615
3626
  var at = items.indexOf(document.activeElement);
3616
3627
  ev.preventDefault();
@@ -57,6 +57,7 @@
57
57
  </a>
58
58
  </div>
59
59
  <div class="barRight">
60
+ <!--apex-only:start--><button class="trig commTrig" type="button" id="commTrig" data-menu="community" aria-expanded="false" aria-controls="menuCommunity">Community<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button><!--apex-only:end-->
60
61
  <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>
61
62
  <!-- the identity chip IS the profile trigger; assets/hub-bar.js fills it from
62
63
  GET /me. Signed out it stays hidden and the sign-in door takes its slot. -->
@@ -65,6 +66,15 @@
65
66
  </div>
66
67
  </header>
67
68
 
69
+ <!--apex-only:start--><div class="menuBand" id="menuCommunity" data-menu="community" hidden>
70
+ <div class="bandIn">
71
+ <ul class="bandRow">
72
+ <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
73
+ <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
74
+ <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
75
+ </ul>
76
+ </div>
77
+ </div><!--apex-only:end-->
68
78
  <div class="menuBand" id="menuProjects" data-menu="projects" hidden>
69
79
  <div class="bandIn">
70
80
  <ul class="bandRow">
@@ -72,6 +82,7 @@
72
82
  <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>
73
83
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
74
84
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
85
+ <!--apex-only:start--><li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><!--apex-only:end-->
75
86
  </ul>
76
87
  </div>
77
88
  </div>
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "GET /community/leaderboard with nobody on any board (task 1003363): a hub on its first day.",
3
+ "boards": {}
4
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "_": "GET /community/leaderboard for the kit stub (task 1003363): one ranked list per window, already in the route's order (credits, ships, karma, handle). The stub filters by discipline and numbers `place`. Names are invented; the long one tests the row's wrap.",
3
+ "boards": {
4
+ "all": [
5
+ { "handle": "priya-nair", "github_login": "priyanair", "display_name": "Priya Nair", "avatar_url": "/__kit/avatar.svg", "credits": 18420, "tasks_shipped": 212, "karma": 64, "disciplines": ["engineer", "ui"] },
6
+ { "handle": "tomas-ek", "github_login": "tomasek", "display_name": "Tomas Ek", "avatar_url": "/__kit/avatar.svg", "credits": 9310, "tasks_shipped": 131, "karma": 40, "disciplines": ["engineer"] },
7
+ { "handle": "ada-okafor", "github_login": "adaokafor", "display_name": "Ada Okafor", "avatar_url": "/__kit/avatar.svg", "credits": 6125, "tasks_shipped": 77, "karma": 52, "disciplines": ["artist"] },
8
+ { "handle": "lin-wei", "github_login": "linwei", "display_name": "Lin Wei", "avatar_url": null, "credits": 2480, "tasks_shipped": 31, "karma": 18, "disciplines": ["ideator", "engineer"] },
9
+ { "handle": "maximiliana-van-der-berghe-lindqvist", "github_login": "mvdbl", "display_name": "Maximiliana van der Berghe-Lindqvist", "avatar_url": "/__kit/avatar.svg", "credits": 940, "tasks_shipped": 12, "karma": 7, "disciplines": ["ui"] },
10
+ { "handle": "jo-park", "github_login": "jopark", "display_name": null, "avatar_url": "/__kit/avatar.svg", "credits": 310, "tasks_shipped": 1, "karma": 0, "disciplines": ["ideator"] }
11
+ ],
12
+ "365d": [
13
+ { "handle": "priya-nair", "github_login": "priyanair", "display_name": "Priya Nair", "avatar_url": "/__kit/avatar.svg", "credits": 12200, "tasks_shipped": 150, "karma": 41, "disciplines": ["engineer", "ui"] },
14
+ { "handle": "ada-okafor", "github_login": "adaokafor", "display_name": "Ada Okafor", "avatar_url": "/__kit/avatar.svg", "credits": 6125, "tasks_shipped": 77, "karma": 52, "disciplines": ["artist"] },
15
+ { "handle": "tomas-ek", "github_login": "tomasek", "display_name": "Tomas Ek", "avatar_url": "/__kit/avatar.svg", "credits": 4020, "tasks_shipped": 58, "karma": 22, "disciplines": ["engineer"] },
16
+ { "handle": "lin-wei", "github_login": "linwei", "display_name": "Lin Wei", "avatar_url": null, "credits": 2480, "tasks_shipped": 31, "karma": 18, "disciplines": ["ideator", "engineer"] }
17
+ ],
18
+ "30d": [
19
+ { "handle": "ada-okafor", "github_login": "adaokafor", "display_name": "Ada Okafor", "avatar_url": "/__kit/avatar.svg", "credits": 1340, "tasks_shipped": 19, "karma": 9, "disciplines": ["artist"] },
20
+ { "handle": "priya-nair", "github_login": "priyanair", "display_name": "Priya Nair", "avatar_url": "/__kit/avatar.svg", "credits": 880, "tasks_shipped": 11, "karma": 4, "disciplines": ["engineer", "ui"] },
21
+ { "handle": "jo-park", "github_login": "jopark", "display_name": null, "avatar_url": "/__kit/avatar.svg", "credits": 310, "tasks_shipped": 1, "karma": 0, "disciplines": ["ideator"] }
22
+ ],
23
+ "7d": [
24
+ { "handle": "ada-okafor", "github_login": "adaokafor", "display_name": "Ada Okafor", "avatar_url": "/__kit/avatar.svg", "credits": 420, "tasks_shipped": 6, "karma": 3, "disciplines": ["artist"] },
25
+ { "handle": "tomas-ek", "github_login": "tomasek", "display_name": "Tomas Ek", "avatar_url": "/__kit/avatar.svg", "credits": -15, "tasks_shipped": 0, "karma": 1, "disciplines": ["engineer"] }
26
+ ]
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "_": "GET /community/search for the kit stub (task 1003363): the public, existence-visible builders the stub matches by name or handle. Sam Rivera hides their numbers, so `disciplines` is null: found by name, dropped by any craft filter (builder-search.js).",
3
+ "directory": [
4
+ { "handle": "priya-nair", "github_login": "priyanair", "display_name": "Priya Nair", "avatar_url": "/__kit/avatar.svg", "disciplines": ["engineer", "ui"] },
5
+ { "handle": "priyanka-shah", "github_login": "pshah", "display_name": "Priyanka Shah", "avatar_url": "/__kit/avatar.svg", "disciplines": ["artist"] },
6
+ { "handle": "tomas-ek", "github_login": "tomasek", "display_name": "Tomas Ek", "avatar_url": "/__kit/avatar.svg", "disciplines": ["engineer"] },
7
+ { "handle": "ada-okafor", "github_login": "adaokafor", "display_name": "Ada Okafor", "avatar_url": "/__kit/avatar.svg", "disciplines": ["artist"] },
8
+ { "handle": "sam-rivera", "github_login": "samrivera", "display_name": "Sam Rivera", "avatar_url": "/__kit/avatar.svg", "disciplines": null }
9
+ ]
10
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "_": "GET /scouting for a viewer the recruiting door admits — a project owner (task 1003363). `builders` is the ranked tier, which the community page never draws (the leaderboard already shows those numbers to everyone); `name_only` is ADR 0334's hidden-but-open-to-recruiters tier, login, name and avatar only, alphabetical by login as listScoutingNameOnly sends it.",
3
+ "builders": [
4
+ { "github_id": "9001", "github_login": "priyanair", "display_name": "Priya Nair", "avatar_url": "/__kit/avatar.svg", "project_count": 3, "total_credits": 18420, "total_tasks_shipped": 212, "total_karma": 64, "last_active": "2026-10-20T10:00:00Z", "disciplines": ["engineer", "ui"] }
5
+ ],
6
+ "name_only": [
7
+ { "github_login": "kemi-a", "display_name": "Kemi Adeyemi", "avatar_url": "/__kit/avatar.svg" },
8
+ { "github_login": "samrivera", "display_name": "Sam Rivera", "avatar_url": "/__kit/avatar.svg" },
9
+ { "github_login": "yuki-t", "display_name": null, "avatar_url": null }
10
+ ]
11
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "GET /scouting for a signed-in viewer the recruiting door does not admit (task 1003363): most builders own no project, so the default answer is the unchanged permission refusal (403). scouting-recruiter.json is an admitted reader's answer.",
3
+ "refused": true
4
+ }
@@ -684,6 +684,58 @@ function connRemove(res, id) {
684
684
  return refuse(res, 404, 'connection_not_found', 'connection_not_found');
685
685
  }
686
686
 
687
+ // The community page (task 1003363): the two public reads and the recruiter door, the
688
+ // routes' answers mirrored (modules/platform-identity/routes/community-leaderboard.js,
689
+ // community-search.js, scouting.js). The board fixture holds one ranked list per window;
690
+ // the stub filters it by discipline and numbers `place` the way listLeaderboard does. The
691
+ // search matches a name or handle, case-folded, and a craft filter drops every row whose
692
+ // disciplines are null (a hidden account: findable by name, never by craft). GET /scouting
693
+ // answers 401 signed out, and signed in whatever its fixture says: `scouting.json` is the
694
+ // unchanged permission refusal (most viewers own no project), `scouting-recruiter.json` an
695
+ // admitted reader's { builders, name_only }. A fixture's "stub_status" answers that status.
696
+ // The caps are copied (a kit module may not import one).
697
+ const STUB_COMMUNITY_WINDOWS = ['7d', '30d', '365d', 'all'];
698
+ const STUB_COMMUNITY_DISCIPLINE_MAX = 32;
699
+ const STUB_SEARCH_MIN = 2;
700
+ const STUB_SEARCH_MAX = 100;
701
+ function stubStatus(res, f) {
702
+ if (!f || !f.stub_status) return false;
703
+ json(res, f.stub_status, { error: { code: 'stub_failure', message: `stub_status ${f.stub_status}` } });
704
+ return true;
705
+ }
706
+ function communityBoard(res, u, origin) {
707
+ const f = heldFixture('community-leaderboard');
708
+ if (stubStatus(res, f)) return;
709
+ const window = u.searchParams.get('window') || 'all';
710
+ if (!STUB_COMMUNITY_WINDOWS.includes(window)) return refuse(res, 400, 'bad_window', `window must be one of: ${STUB_COMMUNITY_WINDOWS.join(', ')}`);
711
+ const disc = (u.searchParams.get('discipline') || '').trim();
712
+ if (disc.length > STUB_COMMUNITY_DISCIPLINE_MAX) return refuse(res, 400, 'discipline_too_long', 'discipline must be at most 32 characters');
713
+ const rows = ((f.boards || {})[window] || []).filter((r) => !disc || (r.disciplines || []).includes(disc));
714
+ const results = rows.map((r, i) => ({ place: i + 1, ...withAvatar(r, origin) }));
715
+ return json(res, 200, { window, since: window === 'all' ? null : stamp('$now-1d'), discipline: disc || null, count: results.length, results, page: { limit: 25, offset: 0 } });
716
+ }
717
+ function communitySearch(res, u, origin) {
718
+ const f = heldFixture('community-search');
719
+ if (stubStatus(res, f)) return;
720
+ const q = (u.searchParams.get('q') || '').trim();
721
+ if (!q) return refuse(res, 400, 'bad_request', 'q (a non-empty search string) is required');
722
+ if (q.length < STUB_SEARCH_MIN) return refuse(res, 400, 'query_too_short', 'q must be at least 2 characters');
723
+ if (q.length > STUB_SEARCH_MAX) return refuse(res, 400, 'query_too_long', 'q must be at most 100 characters');
724
+ const disc = (u.searchParams.get('discipline') || '').trim();
725
+ const needle = q.toLowerCase();
726
+ const results = (f.directory || [])
727
+ .filter((r) => [r.handle, r.display_name].some((s) => String(s || '').toLowerCase().includes(needle)))
728
+ .filter((r) => !disc || (Array.isArray(r.disciplines) && r.disciplines.includes(disc)))
729
+ .map((r) => withAvatar(r, origin));
730
+ return json(res, 200, { query: q, discipline: disc || null, count: results.length, results, page: { limit: 20, offset: 0 } });
731
+ }
732
+ function scoutingRead(res, origin) {
733
+ const f = heldFixture('scouting');
734
+ if (stubStatus(res, f)) return;
735
+ if (f.refused) return refuse(res, 403, 'forbidden', 'requires permission project.curate');
736
+ return json(res, 200, { builders: (f.builders || []).map((r) => withAvatar(r, origin)), name_only: (f.name_only || []).map((r) => withAvatar(r, origin)) });
737
+ }
738
+
687
739
  const server = http.createServer((req, res) => {
688
740
  const u = new URL(req.url, 'http://x');
689
741
  const p = u.pathname;
@@ -712,6 +764,9 @@ const server = http.createServer((req, res) => {
712
764
  if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
713
765
  if (rest === '/me/guilds' && req.method === 'GET') return AUTH ? myGuilds(res) : json(res, 401, UNAUTH);
714
766
  if (rest === '/me/guild-requests' && req.method === 'GET') return AUTH ? myGuildRequests(res) : json(res, 401, UNAUTH);
767
+ if (rest === '/community/leaderboard' && req.method === 'GET') return communityBoard(res, u, origin);
768
+ if (rest === '/community/search' && req.method === 'GET') return communitySearch(res, u, origin);
769
+ if (rest === '/scouting' && req.method === 'GET') return AUTH ? scoutingRead(res, origin) : json(res, 401, UNAUTH);
715
770
  if (rest === '/connections' && req.method === 'GET') return AUTH ? connRead(res, 'list', origin) : json(res, 401, UNAUTH);
716
771
  if (rest === '/connections/requests' && req.method === 'GET') return AUTH ? connRead(res, 'requests', origin) : json(res, 401, UNAUTH);
717
772
  if (rest === '/connections/suggestions' && req.method === 'GET') return AUTH ? connRead(res, 'suggestions', origin) : json(res, 401, UNAUTH);