@bongos/core 1.23.14 → 1.23.15

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 (43) hide show
  1. package/.bongos-core.json +77 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +22 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +924 -878
  10. package/docs/copy-registry.json +1796 -1325
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +8 -3
  13. package/docs/page-readings.json +575 -555
  14. package/modules/platform-identity/builder-of-the-week.js +180 -0
  15. package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
  16. package/modules/platform-identity/routes/community-leaderboard.js +25 -1
  17. package/modules/public-landing/public/account.html +2 -1
  18. package/modules/public-landing/public/community-week-none.states.json +16 -0
  19. package/modules/public-landing/public/community.html +142 -13
  20. package/modules/public-landing/public/community.states.json +6 -4
  21. package/modules/public-landing/public/connections.html +2 -1
  22. package/modules/public-landing/public/contact.html +2 -1
  23. package/modules/public-landing/public/guild.html +2 -1
  24. package/modules/public-landing/public/index.html +2 -1
  25. package/modules/public-landing/public/invitations.html +2 -1
  26. package/modules/public-landing/public/privacy.html +2 -1
  27. package/modules/public-landing/public/projects.html +2 -1
  28. package/modules/public-landing/public/terms.html +2 -1
  29. package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
  30. package/modules/ui-design/kit/fixtures/community-week.json +11 -0
  31. package/modules/ui-design/kit/serve.js +8 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +6 -0
  35. package/scripts/bongos/run-unit-tests.js +4 -0
  36. package/src/module-api.js +1 -1
  37. package/src/platform-server.js +5 -5
  38. package/tests/builder_of_the_week.mjs +266 -0
  39. package/tests/builder_of_the_week_db.mjs +166 -0
  40. package/tests/hub_account_page.mjs +2 -2
  41. package/tests/hub_community_page.mjs +136 -25
  42. package/tests/landing_legal_pages.mjs +2 -2
  43. package/tests/privacy_matrix.mjs +53 -8
@@ -0,0 +1,180 @@
1
+ // modules/platform-identity/builder-of-the-week.js — Builder of the Week (community
2
+ // spec D5, docs/specs/bongos-v1-community-tab.md; task 1002981, goal 1000110 CT5).
3
+ // The read behind GET /community/builder-of-the-week, for the hub's /community/week
4
+ // page and the line above the leaderboard.
5
+ //
6
+ // THE RULE (owner-signed 2026-08-16; the week decided 2026-10-27): of the builders
7
+ // the leaderboard may show (rollupVisibleSql), the one who earned the most credits
8
+ // across every project in the last FULL week — Monday 00:00 to Monday 00:00 UTC —
9
+ // among those who shipped at least one work in it. Ties go to more works shipped,
10
+ // then more karma, then handle/login so the answer is stable.
11
+ //
12
+ // THE WEEK IS READ FROM HISTORY, NOT FROM THE LIVE ROWS. Per (project, builder) it is
13
+ // the last snapshot at-or-before the week's end minus the last at-or-before its start
14
+ // (activity-snapshots.js), summed across projects. With no snapshot before the week, a
15
+ // row counts from zero only if the hub first saw it during the week (first_seen_at,
16
+ // migration 032; owner 2026-10-27): the leaderboard's missing-baseline rule would let a
17
+ // builder last seen before snapshots began win one week with their whole lifetime, and
18
+ // a crown is never corrected. A returning builder's first week back is the cost. A snapshot's reported_at never runs
19
+ // backwards, so once the week has ended its numbers cannot move, and whoever asks
20
+ // first gets the same answer.
21
+ //
22
+ // CROWNED AT RENDER, NO CRON (spec D5). The first read after Monday 00:00 UTC decides
23
+ // the week and writes it to platform_identity_weekly_features (migration 032) with
24
+ // ON CONFLICT DO NOTHING; every later read finds the row. A week nobody won is written
25
+ // too (github_id NULL), so it stays empty. Nothing updates a row.
26
+ //
27
+ // WHO IS SHOWN is decided again on every read: a winner who has since hidden their
28
+ // numbers or gone private is not drawn, on the crown or in the past weeks, and their
29
+ // week is not given to anyone else (spec D8). A hidden winner and an empty week look
30
+ // the same. Viewer-independent; github_id never crosses the projection.
31
+ 'use strict';
32
+
33
+ const { pool: defaultPool } = require('../../src/module-api');
34
+ const { rollupVisibleSql } = require('./account-visibility');
35
+
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+ const WEEK_MS = 7 * DAY_MS;
38
+ // How many weeks before the current one the past-weeks list shows.
39
+ const ARCHIVE_WEEKS = 12;
40
+
41
+ // The Monday 00:00 UTC that opens the week `now` is in.
42
+ function mondayOf(now) {
43
+ const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
44
+ const sinceMonday = (new Date(midnight).getUTCDay() + 6) % 7;
45
+ return new Date(midnight - sinceMonday * DAY_MS);
46
+ }
47
+
48
+ // The week the crown covers at `now`: the last full week before `now`'s own.
49
+ function crownedWeek(now = new Date()) {
50
+ const end = mondayOf(now);
51
+ return { start: new Date(end.getTime() - WEEK_MS), end };
52
+ }
53
+
54
+ const isoDate = (d) => d.toISOString().slice(0, 10);
55
+
56
+ // The week's winner among eligible builders, or null. Arithmetic is bigint for the
57
+ // leaderboard's reason: the columns are instance-reported int4.
58
+ async function computeWinner(start, end, pool) {
59
+ const { rows } = await pool.query(
60
+ `WITH act AS (
61
+ SELECT a.client_id, a.github_id, a.first_seen_at, pa.handle, pa.github_login
62
+ FROM platform_identity_builder_project_activity a
63
+ JOIN platform_identity_accounts pa ON pa.github_id = a.github_id
64
+ WHERE ${rollupVisibleSql('pa')}
65
+ ), per_project AS (
66
+ SELECT act.github_id, act.handle, act.github_login,
67
+ COALESCE(e.credits, 0)::bigint - COALESCE(b.credits, 0) AS credits,
68
+ COALESCE(e.tasks_shipped, 0)::bigint - COALESCE(b.tasks_shipped, 0) AS tasks_shipped,
69
+ COALESCE(e.karma, 0)::bigint - COALESCE(b.karma, 0) AS karma
70
+ FROM act
71
+ LEFT JOIN LATERAL (
72
+ SELECT s.credits, s.tasks_shipped, s.karma
73
+ FROM platform_identity_activity_snapshots s
74
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
75
+ AND s.reported_at <= $2::timestamptz
76
+ ORDER BY s.reported_at DESC, s.id DESC
77
+ LIMIT 1
78
+ ) e ON true
79
+ LEFT JOIN LATERAL (
80
+ SELECT s.credits, s.tasks_shipped, s.karma
81
+ FROM platform_identity_activity_snapshots s
82
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
83
+ AND s.reported_at <= $1::timestamptz
84
+ ORDER BY s.reported_at DESC, s.id DESC
85
+ LIMIT 1
86
+ ) b ON true
87
+ WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz
88
+ )
89
+ SELECT github_id, sum(credits)::bigint AS credits, sum(tasks_shipped)::bigint AS tasks_shipped
90
+ FROM per_project
91
+ GROUP BY github_id, handle, github_login
92
+ HAVING sum(tasks_shipped) >= 1
93
+ ORDER BY sum(credits) DESC, sum(tasks_shipped) DESC, sum(karma) DESC,
94
+ handle COLLATE "C", github_login COLLATE "C"
95
+ LIMIT 1`,
96
+ [start, end],
97
+ );
98
+ return rows[0] || null;
99
+ }
100
+
101
+ // One crowning per week per process: concurrent first reads share it, and a week
102
+ // already on record is not asked about again.
103
+ const crowning = new Map();
104
+ let lastCrowned = null;
105
+
106
+ async function crownWeek(start, end, pool) {
107
+ const week = isoDate(start);
108
+ const { rows } = await pool.query(
109
+ 'SELECT 1 FROM platform_identity_weekly_features WHERE week_start = $1::date',
110
+ [week],
111
+ );
112
+ if (rows.length) return;
113
+ const winner = await computeWinner(start, end, pool);
114
+ await pool.query(
115
+ `INSERT INTO platform_identity_weekly_features (week_start, github_id, credits_delta, ships_delta)
116
+ VALUES ($1::date, $2::bigint, $3::bigint, $4::bigint)
117
+ ON CONFLICT (week_start) DO NOTHING`,
118
+ [week, winner ? winner.github_id : null, winner ? winner.credits : 0, winner ? winner.tasks_shipped : 0],
119
+ );
120
+ }
121
+
122
+ function ensureCrowned(start, end, pool) {
123
+ const week = isoDate(start);
124
+ if (lastCrowned && lastCrowned.week === week && lastCrowned.pool === pool) return Promise.resolve();
125
+ const inFlight = crowning.get(week);
126
+ if (inFlight && inFlight.pool === pool) return inFlight.promise;
127
+ const entry = { pool };
128
+ entry.promise = crownWeek(start, end, pool)
129
+ .then(() => { lastCrowned = { week, pool }; })
130
+ .finally(() => { if (crowning.get(week) === entry) crowning.delete(week); });
131
+ crowning.set(week, entry);
132
+ return entry.promise;
133
+ }
134
+
135
+ function clearCrownMemo() {
136
+ crowning.clear();
137
+ lastCrowned = null;
138
+ }
139
+
140
+ // readBuilderOfTheWeek — crowns the last full week if nobody has yet, then reads it and
141
+ // the weeks before it. Returns { week: { start, end }, winner, past }: dates as
142
+ // YYYY-MM-DD (end is the Monday the week closed on), winner null when nobody is to be
143
+ // shown, past newest first. `now` is injectable so a test can pin the week.
144
+ async function readBuilderOfTheWeek({ now = new Date(), pool = defaultPool } = {}) {
145
+ const { start, end } = crownedWeek(now);
146
+ await ensureCrowned(start, end, pool);
147
+ const { rows } = await pool.query(
148
+ `SELECT to_char(f.week_start, 'YYYY-MM-DD') AS week_start, f.credits_delta, f.ships_delta,
149
+ pa.handle, pa.github_login, pa.display_name, pa.avatar_url
150
+ FROM platform_identity_weekly_features f
151
+ JOIN platform_identity_accounts pa ON pa.github_id = f.github_id
152
+ WHERE f.week_start <= $1::date
153
+ AND ${rollupVisibleSql('pa')}
154
+ ORDER BY f.week_start DESC
155
+ LIMIT $2`,
156
+ [isoDate(start), ARCHIVE_WEEKS + 1],
157
+ );
158
+ const shown = rows.map((r) => ({
159
+ week_start: r.week_start,
160
+ handle: r.handle,
161
+ github_login: r.github_login,
162
+ display_name: r.display_name,
163
+ avatar_url: r.avatar_url,
164
+ credits: Number(r.credits_delta),
165
+ tasks_shipped: Number(r.ships_delta),
166
+ }));
167
+ const isThisWeek = shown.length > 0 && shown[0].week_start === isoDate(start);
168
+ return {
169
+ week: { start: isoDate(start), end: isoDate(end) },
170
+ winner: isThisWeek ? shown[0] : null,
171
+ past: (isThisWeek ? shown.slice(1) : shown).slice(0, ARCHIVE_WEEKS),
172
+ };
173
+ }
174
+
175
+ module.exports = {
176
+ readBuilderOfTheWeek,
177
+ crownedWeek,
178
+ clearCrownMemo,
179
+ ARCHIVE_WEEKS,
180
+ };
@@ -0,0 +1,53 @@
1
+ -- platform_identity_032_weekly_features.sql — Builder of the Week, one row per crowned
2
+ -- week (community spec D5, docs/specs/bongos-v1-community-tab.md; task 1002981).
3
+ --
4
+ -- WHY. The crown is computed at render, with no cron: the first read of
5
+ -- GET /community/builder-of-the-week after Monday 00:00 UTC decides the week that just
6
+ -- ended and writes it here. The row is what pins the crown — a builder who goes public
7
+ -- or reports late cannot take a week that was already decided — and what the page's
8
+ -- past-weeks list reads.
9
+ --
10
+ -- THE SHAPE CARRIES THE RULES (owner, 2026-10-27):
11
+ -- * THE KEY IS week_start, the Monday (UTC) the crowned week began, so a week has one
12
+ -- row. The writer is INSERT ... ON CONFLICT (week_start) DO NOTHING, and nothing
13
+ -- updates a row: the first decision stands.
14
+ -- * github_id IS NULL when nobody qualified (no eligible builder shipped a work that
15
+ -- week). The empty week is recorded too, so it stays empty.
16
+ -- * credits_delta / ships_delta are the week's numbers as decided, bigint like the
17
+ -- leaderboard's arithmetic (builder-of-the-week.js).
18
+ -- * NO FOREIGN KEY to platform_identity_accounts, like every github_id column in this
19
+ -- module. Whether a winner may be SHOWN is decided at read time by
20
+ -- rollupVisibleSql (account-visibility.js), never by this row.
21
+ --
22
+ -- AND first_seen_at ON THE ACTIVITY ROWS (owner, 2026-10-27). A week counts a
23
+ -- (project, builder) with no snapshot before it only if the hub first saw that row
24
+ -- during the week: otherwise a builder last seen before snapshots began would bring
25
+ -- their whole lifetime into one week and keep the crown for good. Added with NO
26
+ -- default first, so every existing row stays NULL ("seen before this, history
27
+ -- unknown"); only then the default, so a new row stamps its own first sighting.
28
+ --
29
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141): descriptive, public analytics.
30
+ --
31
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). A CREATE and a nullable ADD COLUMN, so the
32
+ -- previous release still runs against this schema. Idempotent.
33
+
34
+ BEGIN;
35
+
36
+ CREATE TABLE IF NOT EXISTS platform_identity_weekly_features (
37
+ week_start date PRIMARY KEY,
38
+ github_id bigint,
39
+ credits_delta bigint NOT NULL,
40
+ ships_delta bigint NOT NULL,
41
+ crowned_at timestamptz NOT NULL DEFAULT now()
42
+ );
43
+
44
+ ALTER TABLE platform_identity_builder_project_activity ADD COLUMN IF NOT EXISTS first_seen_at timestamptz;
45
+ ALTER TABLE platform_identity_builder_project_activity ALTER COLUMN first_seen_at SET DEFAULT now();
46
+
47
+ COMMENT ON COLUMN platform_identity_builder_project_activity.first_seen_at IS
48
+ 'When the hub first saw this (project, builder) row; NULL for rows older than migration 032. Builder of the Week counts a row with no snapshot before the week only if it was first seen during the week (task 1002981).';
49
+
50
+ COMMENT ON TABLE platform_identity_weekly_features IS
51
+ 'Builder of the Week, one row per week, keyed on the Monday (UTC) it began (community spec D5, task 1002981). Written once by the first read after the week ends, never updated; github_id NULL = nobody qualified. Whether a winner is shown is decided at read time by the leaderboard''s eligibility predicate.';
52
+
53
+ COMMIT;
@@ -6,6 +6,8 @@
6
6
  // ../community-leaderboard.js; this file is the HTTP shell — validation, the
7
7
  // per-IP read cap, the response envelope. It never reads a session, so the
8
8
  // board is the same for every viewer.
9
+ // It also serves GET /community/builder-of-the-week (spec D5, task 1002981), the
10
+ // board's weekly crown, from ../builder-of-the-week.js on the same terms.
9
11
  //
10
12
  // Reaches core only through the doorway (ADR 0083).
11
13
  'use strict';
@@ -13,6 +15,7 @@
13
15
  const express = require('express');
14
16
  const api = require('../../../src/module-api');
15
17
  const leaderboard = require('../community-leaderboard');
18
+ const builderOfTheWeek = require('../builder-of-the-week');
16
19
  const { createReadRateLimit } = require('../read-rate-limit');
17
20
 
18
21
  const { pool, corsPublicGet } = api;
@@ -20,6 +23,9 @@ const { pool, corsPublicGet } = api;
20
23
  const READ_RATE_LIMIT = 60; // per IP per minute — the community-search budget
21
24
  const READ_RATE_SCOPE = 'community-leaderboard-read';
22
25
  const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
26
+ // Builder of the Week spends its own budget (ADR 0209: never a shared one).
27
+ const WEEK_READ_RATE_SCOPE = 'builder-of-the-week-read';
28
+ const weekReadRateLimit = createReadRateLimit({ scope: WEEK_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
23
29
 
24
30
  module.exports = function communityLeaderboardRoutes() {
25
31
  const router = express.Router();
@@ -63,7 +69,25 @@ module.exports = function communityLeaderboardRoutes() {
63
69
  }
64
70
  });
65
71
 
72
+ router.use('/community/builder-of-the-week', corsPublicGet('/community/builder-of-the-week'));
73
+
74
+ // rank: public — Builder of the Week (spec D5; task 1002981): the last full
75
+ // week's winner and the weeks before it. Takes no parameters. The first read
76
+ // after Monday 00:00 UTC also records the week (crown at render, no cron), at
77
+ // most one row per week. Viewer-independent; no-store so a winner who hides
78
+ // leaves the page on the next read (ADR 0171 D5).
79
+ router.get('/community/builder-of-the-week', weekReadRateLimit, async (req, res) => {
80
+ try {
81
+ const out = await builderOfTheWeek.readBuilderOfTheWeek({ pool });
82
+ res.set('Cache-Control', 'no-store');
83
+ res.json(out);
84
+ } catch (err) {
85
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/builder-of-the-week failed');
86
+ res.fail('builder_of_the_week_failed', { status: 500, message: 'internal error' });
87
+ }
88
+ });
89
+
66
90
  return router;
67
91
  };
68
92
 
69
- module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE };
93
+ module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE };
@@ -118,6 +118,7 @@
118
118
  <div class="bandIn">
119
119
  <ul class="bandRow">
120
120
  <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
121
+ <li class="bandCell"><a class="bandT" href="/community/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</span></li>
121
122
  <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>
122
123
  <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>
123
124
  <li class="bandCell knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
@@ -131,7 +132,7 @@
131
132
  <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>
132
133
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
133
134
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
134
- <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><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
135
+ <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/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</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><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
135
136
  </ul>
136
137
  </div>
137
138
  </div>
@@ -0,0 +1,16 @@
1
+ {
2
+ "_": "The builder of the week when the last week has no one to show (task 1002981): nobody qualified, or the winner hides their numbers — the two look the same (spec D8). A hub's first week, so no past weeks either (fixtures/community-week-none.json). The page says so in one line; the board draws no crown line.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-week": "community-week-none" } },
8
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/me/guild-", "/scouting$"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
10
+ "ignoreControls": [".vh"],
11
+ "states": {
12
+ "out-week-none": { "auth": false, "url": "/community/week", "actions": [["wait", 400]], "expect": { "visible": ["#week", "#weekNone", "#weekRuleH"], "hidden": ["#weekCrown", "#weekLoading", "#weekFailed", "#pastH"], "count": [["#weekPast .commRow", 0]], "text": [["#weekNone", "No builder of the week for 19–25 Oct 2026."]] } },
13
+ "out-board-no-crown": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList"], "hidden": ["#boardCrown"] } }
14
+ },
15
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
16
+ }
@@ -5,13 +5,15 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <!-- THE COMMUNITY PAGES (task 1003363; ADR 0217 D1, ADR 0337 D1/D3/D4.1, the community
7
7
  spec docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8). Who is building, across every
8
- project, as three sub-pages of the bar's Community menu (owner, 2026-10-27), all served
9
- from this one file: /community is the leaderboard, /community/find the builder search,
10
- /community/recruiters the builders who hide their numbers but are open to recruiters.
8
+ project, as four sub-pages of the bar's Community menu (owner, 2026-10-27), all served
9
+ from this one file: /community is the leaderboard, /community/week the builder of the
10
+ week (task 1002981, spec D5), /community/find the builder search, /community/recruiters
11
+ the builders who hide their numbers but are open to recruiters.
11
12
  The script shows the section the path names and marks it current in the menu.
12
- VIEWING IS UNGATED (ADR 0217 D1): the leaderboard and the search are the same for every
13
- viewer, signed in or not. Both reads are public, viewer-independent routes
14
- (modules/platform-identity/routes/community-leaderboard.js, community-search.js),
13
+ VIEWING IS UNGATED (ADR 0217 D1): the leaderboard, the builder of the week and the search
14
+ are the same for every viewer, signed in or not. All three reads are public,
15
+ viewer-independent routes (modules/platform-identity/routes/community-leaderboard.js,
16
+ which also serves the builder of the week, and community-search.js),
15
17
  which decide WHO appears through account-visibility.js; these pages add no rule of
16
18
  their own. The one viewer-relative page is the recruiter list: drawn only from a 200 of
17
19
  GET /scouting, whose door (a project owner, or project.curate) is the server's, never
@@ -90,7 +92,28 @@
90
92
  .commDoor p{margin:0 0 18px;font-size:15px;line-height:1.55;color:var(--ink-soft);}
91
93
  .commDoor .gh{width:18px;height:18px;fill:currentColor;}
92
94
  .commDoor .btn{text-transform:none;}
95
+ /* Builder of the week (task 1002981): one line on the board, then its own page — the
96
+ crown, the rule, and the weeks before it as board rows with the week where the place is */
97
+ .commCrownLine{margin:-6px 0 10px;max-width:62ch;font-size:14.5px;line-height:1.5;color:var(--ink-soft);}
98
+ .commCrownLine a{display:inline-block;min-height:24px;padding:3px 0;line-height:18px;color:var(--ink);
99
+ text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:4px;}
100
+ .commCrownLine a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;}
101
+ .commCrownLine strong{font-weight:500;color:var(--ink);}
102
+ .commCrown{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:14px 40px;
103
+ padding:0 0 26px;margin:0 0 6px;border-bottom:1px solid var(--rule-soft);}
104
+ .crownWhen{grid-column:1/-1;margin:0;font-family:var(--label-face);font-size:12.5px;color:var(--ink-faint);}
105
+ .crownWho{display:flex;align-items:center;gap:16px;min-width:0;}
106
+ .crownWho .connAv{width:64px;height:64px;}
107
+ .crownName{font-family:var(--font-display);font-weight:300;font-size:clamp(26px,3.4vw,34px);letter-spacing:-.02em;
108
+ line-height:1.1;color:var(--ink);overflow-wrap:anywhere;}
109
+ .crownNums{display:flex;gap:0 36px;text-align:right;}
110
+ .commSub{margin:24px 0 4px;font-family:var(--font-body);font-size:14.5px;font-weight:500;color:var(--ink);}
111
+ .weekRows .commRow{grid-template-columns:6.5rem 36px minmax(0,1fr) auto;}
112
+ .weekRows .commPlace{font-family:var(--label-face);}
93
113
  @media (max-width:480px){
114
+ .commCrown{grid-template-columns:minmax(0,1fr);}
115
+ .crownNums{text-align:left;}
116
+ .weekRows .commRow{grid-template-columns:7ch 32px minmax(0,1fr) auto;}
94
117
  .commRow,.commRows.canRecruit .commRow{grid-template-columns:3ch 32px minmax(0,1fr) auto;gap:0 10px;}
95
118
  .commRow .connAv{width:32px;height:32px;}
96
119
  .commNum{font-size:19px;}
@@ -132,6 +155,7 @@
132
155
  <div class="bandIn">
133
156
  <ul class="bandRow">
134
157
  <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
158
+ <li class="bandCell"><a class="bandT" href="/community/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</span></li>
135
159
  <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>
136
160
  <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>
137
161
  <li class="bandCell knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
@@ -145,7 +169,7 @@
145
169
  <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>
146
170
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
147
171
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
148
- <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><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
172
+ <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/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</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><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
149
173
  </ul>
150
174
  </div>
151
175
  </div>
@@ -164,14 +188,15 @@
164
188
  <main class="acct" id="comm">
165
189
  <h1 class="vh">Community</h1>
166
190
 
167
- <!-- THREE SUB-PAGES, ONE FILE: every section is real markup; the script un-hides the one
168
- the path names (/community, /community/find, /community/recruiters). -->
191
+ <!-- FOUR SUB-PAGES, ONE FILE: every section is real markup; the script un-hides the one
192
+ the path names (/community, /community/week, /community/find, /community/recruiters). -->
169
193
  <div class="mineExtra">
170
194
  <!-- THE LEADERBOARD (spec D3): GET /community/leaderboard, one window and one craft
171
195
  at a time. The server ranks; this page draws its rows in the order and with the
172
196
  `place` it was given, and every count it prints is the length of a list. -->
173
197
  <section id="board" data-view="leaderboard" aria-labelledby="boardH">
174
198
  <h2 id="boardH">Leaderboard</h2>
199
+ <p class="commCrownLine" id="boardCrown" hidden></p>
175
200
  <div class="commFilters">
176
201
  <div class="commTabs" id="craftTabs" role="group" aria-label="Role">
177
202
  <button class="commTab" type="button" data-craft="" aria-pressed="true">Everyone</button>
@@ -199,6 +224,23 @@
199
224
  <p class="commNote" id="boardFine">A project reports a builder’s numbers when they sign in or ship there, so a window counts work when it was reported, not when it was done.</p>
200
225
  </section>
201
226
 
227
+ <!-- BUILDER OF THE WEEK (spec D5, task 1002981): GET /community/builder-of-the-week, the
228
+ last full week's winner and the weeks before it. The server decides the week and
229
+ records it (builder-of-the-week.js); this page draws what it was sent, the same for
230
+ every viewer, and states the rule in full (the spec: the formula is public). -->
231
+ <section id="week" data-view="week" aria-labelledby="weekH" hidden>
232
+ <h2 id="weekH">Builder of the week</h2>
233
+ <p class="commMsg" id="weekLoading" role="status">Reading the builder of the week…</p>
234
+ <p class="projMsg err" id="weekFailed" role="alert" hidden></p>
235
+ <div class="commCrown" id="weekCrown" hidden></div>
236
+ <p class="commMsg" id="weekNone" hidden></p>
237
+ <h3 class="commSub" id="weekRuleH">How it is chosen</h3>
238
+ <p class="commNote">The builder who earned the most {{currency}} across every project in one week, Monday to Sunday (UTC), among the builders who shipped at least one work that week. A tie goes to whoever shipped more works, then to whoever earned more karma. Builders who hide their numbers or keep their account private are not counted.</p>
239
+ <p class="commNote">Each week is decided once, the first time anyone opens this page after it ends, and it does not change after that. A project reports a builder’s numbers when they sign in or ship there, so a week counts work when it was reported, not when it was done.</p>
240
+ <h3 class="commSub" id="pastH" hidden>Past weeks</h3>
241
+ <ol class="commRows weekRows" id="weekPast" aria-labelledby="pastH"></ol>
242
+ </section>
243
+
202
244
  <!-- THE SEARCH (spec D4): GET /community/search, a name and an optional craft. A
203
245
  builder who hides their numbers is still found by name, and drops out once a
204
246
  craft is chosen — the server's rule (builder-search.js), never this page's. -->
@@ -262,10 +304,11 @@
262
304
  var QUERY_MIN = 2;
263
305
  var QUERY_MAX = 100;
264
306
  var CRAFT_WORD = { engineer: 'engineer', artist: 'artist', ideator: 'ideator', ui: 'designer' };
265
- // The three sub-pages this file is served as (platform-server.js's /community routes);
307
+ // The four sub-pages this file is served as (platform-server.js's /community routes);
266
308
  // the path picks one, and its menu items are marked current.
267
309
  var VIEWS = {
268
310
  leaderboard: { path: '/community', section: 'board', title: 'Leaderboard' },
311
+ week: { path: '/community/week', section: 'week', title: 'Builder of the week' },
269
312
  find: { path: '/community/find', section: 'find', title: 'Find a builder' },
270
313
  recruiters: { path: '/community/recruiters', section: 'recruit', title: 'Open to recruiters' },
271
314
  };
@@ -311,7 +354,7 @@
311
354
  return what + ' could not be read. Reload the page to try again.';
312
355
  }
313
356
  function viewOf(pathname) {
314
- var m = String(pathname || '').replace(/\/+$/, '').match(/^\/community(?:\/(find|recruiters))?$/);
357
+ var m = String(pathname || '').replace(/\/+$/, '').match(/^\/community(?:\/(week|find|recruiters))?$/);
315
358
  return m ? (m[1] || 'leaderboard') : 'leaderboard';
316
359
  }
317
360
 
@@ -442,6 +485,91 @@
442
485
  loadBoard();
443
486
  });
444
487
 
488
+ // ── the builder of the week (task 1002981, spec D5) ──────────────────────────
489
+ // The server decides each week and records it; this draws the answer as sent. On the
490
+ // board it is one line, and a failed or empty read draws nothing there.
491
+ var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
492
+ // "19–25 Oct" for the Monday a week began on (UTC), the year added when asked
493
+ function weekSpan(start, withYear) {
494
+ var a = new Date(String(start) + 'T00:00:00Z');
495
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(start)) || !isFinite(a.getTime())) return '';
496
+ var b = new Date(a.getTime() + 6 * 24 * 60 * 60 * 1000);
497
+ var year = function (d) { return withYear ? ' ' + d.getUTCFullYear() : ''; };
498
+ if (a.getUTCMonth() === b.getUTCMonth()) return a.getUTCDate() + '–' + b.getUTCDate() + ' ' + MONTHS[b.getUTCMonth()] + year(b);
499
+ var first = a.getUTCDate() + ' ' + MONTHS[a.getUTCMonth()] + (a.getUTCFullYear() === b.getUTCFullYear() ? '' : year(a));
500
+ return first + ' – ' + b.getUTCDate() + ' ' + MONTHS[b.getUTCMonth()] + year(b);
501
+ }
502
+ function crownLine(r) {
503
+ var line = $('boardCrown');
504
+ var w = r.ok ? r.data.winner : null;
505
+ line.textContent = '';
506
+ line.hidden = !w;
507
+ if (!w) return;
508
+ var a = el('a', null, 'Builder of the week');
509
+ a.setAttribute('href', '/community/week');
510
+ line.appendChild(a);
511
+ line.appendChild(el('span', null, ': '));
512
+ line.appendChild(el('strong', null, w.display_name || w.handle || w.github_login || 'Builder'));
513
+ var span = weekSpan(r.data.week && r.data.week.start, false);
514
+ line.appendChild(el('span', null, ', ' + num(w.credits).toLocaleString() + ' ' + UNIT + (span ? ', ' + span : '')));
515
+ }
516
+ function stat(value, unit) {
517
+ var d = el('div');
518
+ d.appendChild(el('span', 'commNum', value));
519
+ d.appendChild(el('span', 'commUnit', unit));
520
+ return d;
521
+ }
522
+ function paintCrown(w, span) {
523
+ var box = $('weekCrown');
524
+ box.textContent = '';
525
+ if (span) box.appendChild(el('p', 'crownWhen', span));
526
+ var who = el('div', 'crownWho');
527
+ who.appendChild(face(w));
528
+ var main = el('div', 'mineRow__main');
529
+ main.appendChild(el('div', 'crownName', w.display_name || w.handle || w.github_login || 'Builder'));
530
+ if (w.handle) main.appendChild(el('span', 'mineRow__host', '@' + w.handle));
531
+ who.appendChild(main);
532
+ box.appendChild(who);
533
+ var nums = el('div', 'crownNums');
534
+ var ships = num(w.tasks_shipped);
535
+ nums.appendChild(stat(num(w.credits).toLocaleString(), UNIT));
536
+ nums.appendChild(stat(String(ships), ships === 1 ? 'work shipped' : 'works shipped'));
537
+ box.appendChild(nums);
538
+ box.hidden = false;
539
+ }
540
+ function pastRow(p) {
541
+ var li = el('li', 'commRow');
542
+ li.appendChild(el('span', 'commPlace', weekSpan(p.week_start, false)));
543
+ li.appendChild(face(p));
544
+ var main = nameBlock(p, p.handle ? '@' + p.handle : '');
545
+ main.appendChild(el('p', 'mineRow__hint', plural(num(p.tasks_shipped), 'work') + ' shipped'));
546
+ li.appendChild(main);
547
+ var credits = el('div', 'commCredits');
548
+ credits.appendChild(el('span', 'commNum', num(p.credits).toLocaleString()));
549
+ credits.appendChild(el('span', 'commUnit', UNIT));
550
+ li.appendChild(credits);
551
+ return li;
552
+ }
553
+ function loadWeek() {
554
+ return read('/community/builder-of-the-week').then(function (r) {
555
+ $('weekLoading').hidden = true;
556
+ if (!r.ok) {
557
+ $('weekFailed').textContent = failText(r.status, 'The builder of the week');
558
+ $('weekFailed').hidden = false;
559
+ return;
560
+ }
561
+ var span = weekSpan(r.data.week && r.data.week.start, true);
562
+ if (r.data.winner) paintCrown(r.data.winner, span);
563
+ else {
564
+ $('weekNone').textContent = span ? 'No builder of the week for ' + span + '.' : 'No builder of the week yet.';
565
+ $('weekNone').hidden = false;
566
+ }
567
+ var list = $('weekPast');
568
+ arr(r.data.past).forEach(function (p) { if (p) list.appendChild(pastRow(p)); });
569
+ $('pastH').hidden = !list.children.length;
570
+ });
571
+ }
572
+
445
573
  // ── the search ──────────────────────────────────────────────────────────────
446
574
  function say(text, kind) {
447
575
  var s = $('findStatus');
@@ -570,8 +698,9 @@
570
698
  });
571
699
  document.title = document.title + ' · ' + view.title;
572
700
  $('signinTop').setAttribute('href', '/api/bongos/auth/web/start?return=' + view.path + '&repos=1');
573
- if (VIEW === 'leaderboard') loadBoard();
574
- if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
701
+ if (VIEW === 'leaderboard') { loadBoard(); read('/community/builder-of-the-week').then(crownLine); }
702
+ if (VIEW === 'week') loadWeek();
703
+ else if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
575
704
  else bar.ready.then(function (me) { if (me) loadOwned(me, $(VIEW === 'find' ? 'findFor' : 'boardFor')); });
576
705
  })();
577
706
  </script>
@@ -1,5 +1,5 @@
1
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).",
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 four sub-pages of the bar's Community menu, each state naming its `url`. The leaderboard, the builder of the week 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-week.json: a winner and four past weeks, task 1002981; community-search.json); community-week-none.states.json is a week nobody won. 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
3
  "page": "/community",
4
4
  "surface": "public-landing",
5
5
  "modes": ["dark"],
@@ -8,13 +8,15 @@
8
8
  "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
9
9
  "ignoreControls": [".vh"],
10
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."]] } },
11
+ "out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList", "#boardWindow", "#signinTop", "#projTrig", "#boardCrown"], "hidden": ["#week", "#find", "#recruit", "#who", "#boardLoading", "#boardFailed", "#boardEmpty"], "href": [["#boardCrown a", "/community/week"]], "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."], ["#boardCrown", "Builder of the week: Ada Okafor, 1,240 credits, 19–25 Oct"]] } },
12
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
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": { "hidden": ["#menuCommunity .knowCell"], "count": [["#menuCommunity .bandCell:not([hidden])", 3]], "attr": [["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]] } },
15
- "in-community-band": { "auth": true, "actions": [["wait", 400], ["clickIfVisible", "#commTrig"], ["wait", 300]], "expect": { "count": [["#menuCommunity .bandCell:not([hidden])", 4]], "href": [["#menuCommunity .knowCell a", "/connections#connSuggest"]] } },
14
+ "out-community-band": { "auth": false, "actions": [["wait", 400], ["clickIfVisible", "#commTrig"], ["wait", 300]], "expect": { "hidden": ["#menuCommunity .knowCell"], "count": [["#menuCommunity .bandCell:not([hidden])", 4]], "attr": [["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]] } },
15
+ "in-community-band": { "auth": true, "actions": [["wait", 400], ["clickIfVisible", "#commTrig"], ["wait", 300]], "expect": { "count": [["#menuCommunity .bandCell:not([hidden])", 5]], "href": [["#menuCommunity .knowCell a", "/connections#connSuggest"]] } },
16
16
  "in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects"], "count": [["#menuProjects .knowCell:not([hidden])", 1]] } },
17
17
  "out-projects-band": { "auth": false, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects"], "attr": [["#menuProjects a[href=\"/community\"]", "aria-current", "page"]] } },
18
+ "out-week": { "auth": false, "url": "/community/week", "actions": [["wait", 400]], "expect": { "visible": ["#week", "#weekCrown", "#pastH", "#weekPast"], "hidden": ["#board", "#find", "#recruit", "#weekLoading", "#weekFailed", "#weekNone"], "count": [["#weekPast .commRow", 4]], "attr": [["#menuCommunity a[href=\"/community/week\"]", "aria-current", "page"]], "text": [["#weekCrown .crownWhen", "19–25 Oct 2026"], ["#weekCrown .crownName", "Ada Okafor"], ["#weekCrown .crownNums div:first-child .commNum", "1,240"], ["#weekCrown .crownNums div:last-child .commUnit", "works shipped"], ["#weekPast .commRow:first-child .commPlace", "12–18 Oct"], ["#weekPast .commRow:nth-child(3) .commPlace", "28 Sep – 4 Oct"]] } },
19
+ "in-week": { "auth": true, "url": "/community/week", "actions": [["wait", 600]], "expect": { "visible": ["#week", "#weekCrown", "#who"], "hidden": ["#board", "#signinTop", "#weekFailed"], "count": [["#weekPast .commRow", 4]] } },
18
20
  "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"]] } },
19
21
  "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"]] } },
20
22
  "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"]] } },