@bongos/core 1.21.82 → 1.21.84

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 (41) hide show
  1. package/.bongos-core.json +63 -43
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -0
  4. package/docs/api/openapi.json +8 -1
  5. package/docs/api-reference.md +2 -2
  6. package/docs/copy-inventory.md +32 -28
  7. package/docs/copy-registry.json +70 -34
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-inventory.json +9 -1
  10. package/docs/page-readings.json +657 -648
  11. package/modules/platform-identity/invite-notices.js +78 -10
  12. package/modules/platform-identity/migrations/platform_identity_031_parked_invite_notices.sql +45 -0
  13. package/modules/platform-identity/routes/my-projects.js +7 -3
  14. package/modules/provisioning/birth.js +28 -1
  15. package/modules/provisioning/routes/provisioning.js +3 -1
  16. package/modules/public-landing/public/account.html +1 -0
  17. package/modules/public-landing/public/assets/cosmos.css +8 -0
  18. package/modules/public-landing/public/assets/invite-badge.js +91 -0
  19. package/modules/public-landing/public/contact.html +1 -0
  20. package/modules/public-landing/public/guild.html +1 -0
  21. package/modules/public-landing/public/invitations.html +7 -2
  22. package/modules/public-landing/public/privacy.html +1 -0
  23. package/modules/public-landing/public/projects.html +49 -5
  24. package/modules/public-landing/public/projects.states.json +1 -0
  25. package/modules/public-landing/public/terms.html +1 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/src/bongos/project-settings.js +16 -2
  30. package/src/branding.js +3 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/hub_invite_badge.mjs +204 -0
  33. package/tests/hub_invite_notices.mjs +61 -6
  34. package/tests/landing_legal_pages.mjs +1 -1
  35. package/tests/projects_hub.mjs +1 -1
  36. package/tests/projects_hub_app_step.mjs +1 -1
  37. package/tests/provision_settings_apply.mjs +4 -1
  38. package/tests/provisioning_settings_env.mjs +3 -1
  39. package/tests/wizard_physics_more.mjs +5 -5
  40. package/tests/wizard_ship_mode.mjs +187 -0
  41. package/tests/wizard_six_screens.mjs +3 -2
@@ -18,31 +18,96 @@
18
18
  //
19
19
  // Both writers touch ONLY a `pending` row, so a notice can never create, downgrade or
20
20
  // remove a real membership (a member is left as-is; the row is not theirs to change).
21
- // A login with no hub account is a no-op: there is nobody on the hub to notify, and
22
- // the project's own row still admits them when they sign in there.
21
+ //
22
+ // A login with no hub account is PARKED by login (task 1004646, migration 031): a
23
+ // platform account is only born when someone signs in to a project THROUGH the hub,
24
+ // so the usual invitee — new to the platform — has none, and dropping the notice
25
+ // meant they never saw it, even after signing in to the hub. The invitations read
26
+ // lists a parked notice for whoever signs in with that login (listParkedInvites).
23
27
  //
24
28
  // Its own file because platform-identity.js is at the size ratchet; it reuses that
25
29
  // file's account lookup and pending-row writers rather than restating them.
26
30
  'use strict';
27
31
 
32
+ const api = require('../../src/module-api');
28
33
  const pi = require('./platform-identity');
29
34
 
30
- // The project noticed an invite for `githubLogin` → a `pending` hub row, if the login
31
- // has a hub account and no membership in this project yet. Returns { noted } for the
35
+ const loginKey = (githubLogin) => String(githubLogin || '').trim().replace(/^@/, '').toLowerCase();
36
+
37
+ // The project noticed an invite for `githubLogin` → a `pending` hub row if the login
38
+ // has a hub account, else a parked notice under the login. Returns { noted } for the
32
39
  // route's own logging; the route never tells the caller which case happened (see it).
33
- async function noteProjectInvite({ clientId, githubLogin }, { pool } = {}) {
40
+ async function noteProjectInvite({ clientId, githubLogin }, { pool = api.pool } = {}) {
34
41
  const account = await pi.getAccountByLogin(githubLogin, { pool });
35
- if (!account) return { noted: false };
42
+ if (!account) {
43
+ const login = loginKey(githubLogin);
44
+ if (!login) return { noted: false };
45
+ await pool.query(
46
+ `INSERT INTO platform_identity_parked_invite_notices (client_id, login_lower)
47
+ VALUES ($1, $2)
48
+ ON CONFLICT (client_id, login_lower) DO UPDATE SET noted_at = now()`,
49
+ [clientId, login],
50
+ );
51
+ return { noted: true, parked: true };
52
+ }
36
53
  const out = await pi.inviteToProject({ clientId, githubId: account.github_id }, { pool });
37
54
  return { noted: !!out.invited };
38
55
  }
39
56
 
40
- // The project rescinded the invite for `githubLogin` → drop the `pending` hub row.
57
+ // The project rescinded the invite for `githubLogin` → drop the `pending` hub row and
58
+ // any parked notice under the login (an account made since the notice keeps both
59
+ // reachable, so both go).
41
60
  async function clearProjectInvite({ clientId, githubLogin }, { pool } = {}) {
61
+ const parked = await removeParkedInvite({ clientId, githubLogin }, { pool });
42
62
  const account = await pi.getAccountByLogin(githubLogin, { pool });
43
- if (!account) return { cleared: false };
63
+ if (!account) return { cleared: parked.removed };
44
64
  const out = await pi.removePendingInvite({ clientId, githubId: account.github_id }, { pool });
45
- return { cleared: out.removed };
65
+ return { cleared: out.removed || parked.removed };
66
+ }
67
+
68
+ // The parked notices for one signed-in login, in the shape listPendingInvitesForAccount
69
+ // returns, minus any past the invite's 30 days (ADR 0356) and any project the reader
70
+ // already holds a real membership in — signing in there accepted it.
71
+ async function listParkedInvites({ githubLogin, githubId }, { pool = api.pool } = {}) {
72
+ const login = loginKey(githubLogin);
73
+ if (!login) return [];
74
+ const { rows } = await pool.query(
75
+ `SELECT n.client_id, n.noted_at AS invited_at,
76
+ c.name AS project_name, c.origin AS project_origin
77
+ FROM platform_identity_parked_invite_notices n
78
+ LEFT JOIN platform_identity_sso_clients c ON c.client_id = n.client_id
79
+ WHERE n.login_lower = $1
80
+ AND n.noted_at > now() - make_interval(days => $2)
81
+ AND NOT EXISTS (
82
+ SELECT 1 FROM platform_identity_project_memberships m
83
+ WHERE m.client_id = n.client_id AND m.github_id = $3 AND m.membership_kind <> 'pending')
84
+ ORDER BY n.noted_at DESC`,
85
+ [login, api.inviteExpiry.INVITE_EXPIRY_DAYS, String(githubId || '')],
86
+ );
87
+ return rows;
88
+ }
89
+
90
+ // The invitee declined, or the project rescinded: drop the parked notice for the login.
91
+ async function removeParkedInvite({ clientId, githubLogin }, { pool = api.pool } = {}) {
92
+ const login = loginKey(githubLogin);
93
+ if (!login) return { removed: false };
94
+ const { rowCount } = await pool.query(
95
+ `DELETE FROM platform_identity_parked_invite_notices WHERE client_id = $1 AND login_lower = $2`,
96
+ [clientId, login],
97
+ );
98
+ return { removed: rowCount > 0 };
99
+ }
100
+
101
+ // Everything waiting on one signed-in builder: their pending rows plus the notices
102
+ // parked under their login, one per project (a pending row wins — it is the same
103
+ // invite, written once the account existed).
104
+ async function listInvitesForBuilder({ githubId, githubLogin }, { pool = api.pool } = {}) {
105
+ const [pending, parked] = await Promise.all([
106
+ pi.listPendingInvitesForAccount(githubId, { pool }),
107
+ listParkedInvites({ githubLogin, githubId }, { pool }),
108
+ ]);
109
+ const seen = new Set(pending.map((r) => r.client_id));
110
+ return pending.concat(parked.filter((r) => !seen.has(r.client_id)));
46
111
  }
47
112
 
48
113
  // The project's own invite box for `githubLogin`: its hall's watch page, with the login
@@ -58,4 +123,7 @@ function hallInviteUrl(origin, githubLogin) {
58
123
  return `${u.origin}/builders/watch${login ? `?invite=${encodeURIComponent(login)}` : ''}`;
59
124
  }
60
125
 
61
- module.exports = { noteProjectInvite, clearProjectInvite, hallInviteUrl };
126
+ module.exports = {
127
+ noteProjectInvite, clearProjectInvite, hallInviteUrl,
128
+ removeParkedInvite, listInvitesForBuilder,
129
+ };
@@ -0,0 +1,45 @@
1
+ -- platform_identity_031_parked_invite_notices.sql — a project's invite notice for a
2
+ -- GitHub login the hub has no account for yet, kept until that person signs in
3
+ -- (task 1004646; ADR 0353 amended).
4
+ --
5
+ -- WHY. POST /sso/invites/notify resolves the invited login to a platform account and,
6
+ -- before this table, dropped the notice when there was none. A platform account is
7
+ -- only created when someone signs in to a PROJECT through the hub — signing in to the
8
+ -- hub itself creates none — so the usual invitee (new to the platform) never saw the
9
+ -- invite on /invitations, even after signing in. Now the notice is PARKED here by
10
+ -- login, and the invitations read lists it for whoever signs in with that login.
11
+ --
12
+ -- THE SHAPE CARRIES THE RULES:
13
+ -- * THE KEY IS (client_id, login_lower), so a re-invite cannot pile up rows: the
14
+ -- writer's upsert restarts noted_at in place.
15
+ -- * NO STATUS COLUMN (ADR 0201 D1.3, ADR 0353). A notice admits nobody: the
16
+ -- project's own access_requests row does, and signing in there accepts it. A
17
+ -- parked notice is hidden at read time once the reader holds a non-pending
18
+ -- membership in that project.
19
+ -- * THE 30-DAY WINDOW IS READ-TIME (ADR 0356, the doorway's INVITE_EXPIRY_DAYS),
20
+ -- exactly as for a pending membership row. Nothing sweeps the rows.
21
+ -- * KEYED BY LOGIN, like the project's own invited access_requests row, which is
22
+ -- what admits the same person — so the notice can claim no more than the invite.
23
+ --
24
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141).
25
+ --
26
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). CREATE only, so the
27
+ -- previous release still runs against this schema. Idempotent.
28
+
29
+ BEGIN;
30
+
31
+ CREATE TABLE IF NOT EXISTS platform_identity_parked_invite_notices (
32
+ client_id text NOT NULL,
33
+ login_lower text NOT NULL,
34
+ noted_at timestamptz NOT NULL DEFAULT now(),
35
+ PRIMARY KEY (client_id, login_lower)
36
+ );
37
+
38
+ -- The reader's side: every notice for one login.
39
+ CREATE INDEX IF NOT EXISTS platform_identity_parked_invite_notices_login
40
+ ON platform_identity_parked_invite_notices (login_lower);
41
+
42
+ COMMENT ON TABLE platform_identity_parked_invite_notices IS
43
+ 'A project''s invite notice for a GitHub login with no hub account yet (task 1004646). Listed on /invitations for whoever signs in with that login; a notice only, never a way in (ADR 0353). The 30-day window is applied when read (ADR 0356).';
44
+
45
+ COMMIT;
@@ -29,6 +29,7 @@ const projectStats = require('../project-stats');
29
29
  const applicationEcho = require('../application-echo');
30
30
  const guilds = require('../guilds');
31
31
  const guildEngagements = require('../guild-engagements');
32
+ const inviteNotices = require('../invite-notices');
32
33
  const hubKeys = require('../hub-keys');
33
34
  const { isAccountActive } = require('../account-visibility');
34
35
  const { projectPublishability } = require('../publish-gate');
@@ -302,10 +303,12 @@ module.exports = function myProjectsRoutes() {
302
303
  }
303
304
  });
304
305
 
305
- // rank: any-builder — the caller's OWN pending invitations (ADR 0141 §5).
306
+ // rank: any-builder — the caller's OWN pending invitations (ADR 0141 §5), plus the
307
+ // notices parked under their login before they had a hub account (task 1004646).
306
308
  router.get('/my-projects/invites', requireBuilder, async (req, res) => {
307
309
  try {
308
- const invites = await pi.listPendingInvitesForAccount(req.builder.github_id, { pool });
310
+ const invites = await inviteNotices.listInvitesForBuilder(
311
+ { githubId: req.builder.github_id, githubLogin: req.builder.github_login }, { pool });
309
312
  res.json({ invites: invites.map((i) => ({ client_id: i.client_id, name: i.project_name || i.project_origin || i.client_id, origin: i.project_origin || null, invited_at: i.invited_at })) });
310
313
  } catch (err) {
311
314
  console.error('[platform-identity] GET /my-projects/invites', err);
@@ -324,7 +327,8 @@ module.exports = function myProjectsRoutes() {
324
327
  if (!clientId) return res.fail('bad_client_id', 400);
325
328
  try {
326
329
  const out = await pi.removePendingInvite({ clientId, githubId: req.builder.github_id }, { pool });
327
- if (!out.removed) return res.fail('no_pending_invite', 404);
330
+ const parked = await inviteNotices.removeParkedInvite({ clientId, githubLogin: req.builder.github_login }, { pool });
331
+ if (!out.removed && !parked.removed) return res.fail('no_pending_invite', 404);
328
332
  res.json({ ok: true, accepted: false });
329
333
  } catch (err) {
330
334
  console.error('[platform-identity] decline invite', err);
@@ -21,6 +21,11 @@
21
21
  // that came and went would leave a stale line behind.
22
22
 
23
23
  const BIRTH_KEY = 'founding_birth';
24
+ // The wizard's ship-mode answer (task 1004629): auto | manual, stamped beside the birth on
25
+ // the row the create route just made. Reserved like the birth (out of SETTINGS_VOCAB): the
26
+ // instance's own project_settings['ship.mode'] is where the setting LIVES and where the
27
+ // owner changes it later; this is only what the project was born with.
28
+ const SHIP_MODE_KEY = 'ship_mode';
24
29
  const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
25
30
 
26
31
  const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
@@ -43,11 +48,18 @@ function demoBox(row) {
43
48
  return { people, hours_each: hours };
44
49
  }
45
50
 
51
+ // The closed ship-mode list is the core's own (src/bongos/project-settings.js), never retyped here.
52
+ const parseShipMode = (raw) => require('../../src/module-api').projectSettings.parseShipMode(raw);
53
+
54
+ // bornShipMode(row) — PURE. The ship mode the wizard asked for, or '' for none (or junk).
55
+ const bornShipMode = (row) => parseShipMode(storedOf(row)[SHIP_MODE_KEY]).mode || '';
56
+
46
57
  // The companion env (SETTINGS_COMPANION_ENV). src/branding.js maps them onto founding.*.
47
58
  const BIRTH_COMPANION_ENV = Object.freeze({
48
59
  FOUNDING_BIRTH: bornAt,
49
60
  DEMO_PEOPLE: (row) => { const d = demoBox(row); return d ? String(d.people) : ''; },
50
61
  DEMO_HOURS: (row) => { const d = demoBox(row); return d ? String(d.hours_each) : ''; },
62
+ SHIP_MODE: bornShipMode,
51
63
  });
52
64
 
53
65
  // stampBirth(db, instanceId, now) — the create route's one write, on the row it just
@@ -61,4 +73,19 @@ async function stampBirth(db, instanceId, now = new Date()) {
61
73
  );
62
74
  }
63
75
 
64
- module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, stampBirth };
76
+ // stampShipMode(db, instanceId, raw) — the wizard's ship-mode answer, on the row the create
77
+ // route just made. An absent or unusable answer writes nothing, so the instance reads "never
78
+ // chosen" and asks at its first ship (task 1004630). Write-once, like the birth.
79
+ async function stampShipMode(db, instanceId, raw) {
80
+ const { mode } = parseShipMode(raw);
81
+ if (!mode) return false;
82
+ await db.query(
83
+ `UPDATE provisioning_instances
84
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text)
85
+ WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
86
+ [instanceId, SHIP_MODE_KEY, mode],
87
+ );
88
+ return true;
89
+ }
90
+
91
+ module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, SHIP_MODE_KEY, stampBirth, stampShipMode };
@@ -359,7 +359,7 @@ module.exports = function provisioningRoutes() {
359
359
  // exactly the set the panel preselected. An explicit [] is the different
360
360
  // answer "none of them". maxItems is a sanity ceiling well above a catalog
361
361
  // that is three modules wide today.
362
- modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
362
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
363
363
  })) return;
364
364
  const body = req.body || {};
365
365
  const detailFault = badProjectDetail(body.detail);
@@ -475,6 +475,8 @@ module.exports = function provisioningRoutes() {
475
475
  // provision intent exists, so the runner's web.env already carries the marker and the demo's box.
476
476
  // Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
477
477
  if (created) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
478
+ // The wizard's ship mode (task 1004629) rides beside it; a miss means the project asks at its first ship.
479
+ if (created) await birth.stampShipMode(pool, instance.id, body.ship_mode).catch((err) => log.error('[provisioning] ship mode not stamped', err));
478
480
  const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
479
481
  // Enqueue the provision intent (idempotent: an already-open intent is returned)
480
482
  // — but ONLY when the instance actually needs standing up. Blindly enqueueing on
@@ -624,6 +624,7 @@
624
624
  });
625
625
  })();
626
626
  </script>
627
+ <script src="/assets/invite-badge.js"></script>
627
628
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
628
629
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
629
630
  </body>
@@ -628,6 +628,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
628
628
  .signinDoor:hover{background:var(--ink);color:var(--bg);}
629
629
  /* a long GitHub login truncates instead of pushing Sign out off the screen */
630
630
  .who>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:14ch;}
631
+ /* what is waiting on you (task 1004646, assets/invite-badge.js): a count on the chip and on
632
+ the band's Invitations item — the accent fill with --accent-on ink, the .btn pairing */
633
+ .invBadge{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;
634
+ border-radius:var(--radius-pill);background:var(--accent);color:var(--accent-on);
635
+ font-family:var(--font-body);font-size:12px;font-weight:700;line-height:1;letter-spacing:0;vertical-align:middle;}
636
+ .who>.invBadge{max-width:none;overflow:visible;}
637
+ .bandT .invBadge{margin-left:10px;}
638
+ .invWaiting{margin:6px 0 4px;font-size:15.5px;font-weight:600;}
631
639
  /* keyboard focus on the two search pills is VISIBLE — the input's own outline
632
640
  is off by design, so the pill carries the ring */
633
641
  .search:focus-within{box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 40%,transparent);}
@@ -0,0 +1,91 @@
1
+ /* assets/invite-badge.js — WHAT IS WAITING ON YOU, said where you already look
2
+ (task 1004646; the owner's report of 2026-10-08 that invitations "aren't obvious").
3
+
4
+ /invitations lists what asks you in, but the only way there was the profile band,
5
+ with nothing saying anything was waiting. This counts what is waiting — a project's
6
+ invite (GET /my-projects/invites) and a guild's invite or a join request to a guild
7
+ you own (GET /me/guild-requests) — and, when it is more than none, paints:
8
+ - a count on the identity chip (#who) and on the profile band's Invitations item;
9
+ - on /projects?view=mine, a line "You have N invitations waiting →" under the
10
+ view's heading.
11
+ A guild's engagement with a project is left out: it asks nothing of you.
12
+
13
+ Hub only, with no flag of its own: the Invitations item sits inside the apex-only
14
+ region, which a child instance strips, so there this file finds no item and reads
15
+ nothing. Signed out, both reads 401 and nothing is painted. A failed read counts
16
+ as none — a badge is a convenience, never the record.
17
+
18
+ Built from nodes only. window.InviteBadge.refresh() re-reads, for a page
19
+ that has just answered one (the invitations page's Decline and Accept). */
20
+ (function () {
21
+ 'use strict';
22
+ var API = '/api/bongos';
23
+ function $(id) { return document.getElementById(id); }
24
+ function item() { var m = $('menuProfile'); return m && m.querySelector('a[href="/invitations"]'); }
25
+
26
+ function readCount(path, pick) {
27
+ return fetch(API + path, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
28
+ .then(function (res) { return res.ok ? res.json() : null; })
29
+ .then(function (d) { return d ? pick(d) : 0; })
30
+ .catch(function () { return 0; });
31
+ }
32
+ function len(x) { return Array.isArray(x) ? x.length : 0; }
33
+
34
+ function badge(host, n, label) {
35
+ if (!host) return;
36
+ var b = host.querySelector('.invBadge');
37
+ if (!n) { if (b) b.remove(); return; }
38
+ if (!b) {
39
+ b = document.createElement('b');
40
+ b.className = 'invBadge';
41
+ var shown = document.createElement('span');
42
+ shown.setAttribute('aria-hidden', 'true');
43
+ var said = document.createElement('span');
44
+ said.className = 'vh';
45
+ b.appendChild(shown);
46
+ b.appendChild(said);
47
+ // before the chip's chevron; at the end of a band item
48
+ host.insertBefore(b, host.querySelector('svg.cv'));
49
+ }
50
+ b.firstChild.textContent = n > 99 ? '99+' : String(n);
51
+ b.lastChild.textContent = ', ' + label;
52
+ }
53
+
54
+ // at the top of the mine view, under its heading and lede — the list of projects
55
+ // below it can be long, and the Invitations pointer sits at its foot
56
+ function mineLine(n, label) {
57
+ var view = $('view-mine');
58
+ var hero = view && view.querySelector('.hero');
59
+ if (!hero) return;
60
+ var p = $('mineInvitesWaiting');
61
+ if (!n) { if (p) p.remove(); return; }
62
+ if (!p) {
63
+ p = document.createElement('p');
64
+ p.id = 'mineInvitesWaiting';
65
+ p.className = 'invWaiting';
66
+ var a = document.createElement('a');
67
+ a.href = '/invitations';
68
+ p.appendChild(a);
69
+ hero.appendChild(p);
70
+ }
71
+ p.firstChild.textContent = 'You have ' + label + ' waiting →';
72
+ }
73
+
74
+ function paint(n) {
75
+ var label = n + (n === 1 ? ' invitation' : ' invitations');
76
+ badge($('who'), n, label + ' waiting');
77
+ badge(item(), n, label + ' waiting');
78
+ mineLine(n, label);
79
+ }
80
+
81
+ function refresh() {
82
+ if (!item()) return Promise.resolve(0);
83
+ return Promise.all([
84
+ readCount('/my-projects/invites', function (d) { return len(d.invites); }),
85
+ readCount('/me/guild-requests', function (d) { return len(d.incoming) + len(d.owner_incoming); }),
86
+ ]).then(function (c) { var n = c[0] + c[1]; paint(n); return n; });
87
+ }
88
+
89
+ window.InviteBadge = { refresh: refresh, paint: paint };
90
+ refresh();
91
+ })();
@@ -120,6 +120,7 @@
120
120
 
121
121
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
122
122
  <script src="/assets/hub-bar.js"></script>
123
+ <script src="/assets/invite-badge.js"></script>
123
124
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
124
125
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
125
126
  </body>
@@ -629,6 +629,7 @@
629
629
  load();
630
630
  })();
631
631
  </script>
632
+ <script src="/assets/invite-badge.js"></script>
632
633
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
633
634
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
634
635
  </body>
@@ -232,11 +232,13 @@
232
232
  button on failure. `onRemoved` carries whatever else the caller needs after
233
233
  the row is gone (the empty-line flip). The busy/idle label swap the /projects
234
234
  copy carries is for its Leave button, which never moved here. */
235
+ /* the bar's waiting count (assets/invite-badge.js, task 1004646) follows an answer here */
236
+ function recount() { if (typeof window !== 'undefined' && window.InviteBadge) window.InviteBadge.refresh(); }
235
237
  function postThenRemoveRow(url, row, btn, onRemoved) {
236
238
  btn.disabled = true;
237
239
  fetch(url, { method: 'POST', credentials: 'same-origin' })
238
240
  .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
239
- .then(function () { row.remove(); if (onRemoved) onRemoved(); })
241
+ .then(function () { row.remove(); if (onRemoved) onRemoved(); recount(); })
240
242
  .catch(function () { btn.disabled = false; });
241
243
  }
242
244
 
@@ -481,7 +483,7 @@
481
483
  msg.className = 'projMsg';
482
484
  msg.textContent = '';
483
485
  guildPost('/guild-requests/' + encodeURIComponent(id) + '/decline').then(function (res) {
484
- if (res.ok) { row.remove(); paintInvitesSection(); return; }
486
+ if (res.ok) { row.remove(); paintInvitesSection(); recount(); return; }
485
487
  btns.forEach(function (b) { b.disabled = false; });
486
488
  if (res.status === 401) { sessionExpired(msg); return; }
487
489
  msg.className = 'projMsg err';
@@ -582,6 +584,7 @@
582
584
  radios.forEach(function (r) { r.disabled = true; });
583
585
  page.hidden = false;
584
586
  msg.className = 'projMsg ok';
587
+ recount();
585
588
  msg.textContent = 'You’ve joined ' + name + (on ? ', and your numbers count toward its totals.' : ', and your numbers are left out of its totals.');
586
589
  return;
587
590
  }
@@ -660,6 +663,7 @@
660
663
  hint.hidden = true;
661
664
  msg.className = 'projMsg ok';
662
665
  msg.textContent = who + ' is in ' + (r.guild.name || r.guild.slug) + ' now.';
666
+ recount();
663
667
  return;
664
668
  }
665
669
  accept.textContent = 'Accept';
@@ -961,6 +965,7 @@
961
965
  });
962
966
  })();
963
967
  </script>
968
+ <script src="/assets/invite-badge.js"></script>
964
969
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
965
970
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
966
971
  </body>
@@ -129,6 +129,7 @@
129
129
 
130
130
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
131
131
  <script src="/assets/hub-bar.js"></script>
132
+ <script src="/assets/invite-badge.js"></script>
132
133
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
133
134
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
134
135
  </body>
@@ -1950,6 +1950,31 @@ summary{min-height:24px;padding:3px 0;}
1950
1950
  </div>
1951
1951
  <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1952
1952
  </div>
1953
+
1954
+ <!-- how finished work ships (task 1004629, ADR 0369): Auto unless the owner
1955
+ says otherwise. It rides the create as ship_mode and becomes the project's
1956
+ own ship mode, which its settings page can change any time. -->
1957
+ <div class="setRow" data-row="ship">
1958
+ <span class="k">Shipping</span>
1959
+ <div class="v"><span class="rowV" data-row-v></span><small class="rowS" data-row-s></small>
1960
+ <div class="opts" hidden>
1961
+ <fieldset class="appOpts" id="shipOpts">
1962
+ <legend>When the AI finishes a task</legend>
1963
+ <label class="appOpt">
1964
+ <input type="radio" name="shipMode" value="auto" checked>
1965
+ <span><b>Auto (recommended)</b>
1966
+ <span>The AI ships finished work on its own. You don’t run any commands.</span></span>
1967
+ </label>
1968
+ <label class="appOpt">
1969
+ <input type="radio" name="shipMode" value="manual">
1970
+ <span><b>Manual</b>
1971
+ <span>The AI stops before each ship and waits for you to approve it.</span></span>
1972
+ </label>
1973
+ </fieldset>
1974
+ </div>
1975
+ </div>
1976
+ <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1977
+ </div>
1953
1978
  </div>
1954
1979
  <div class="hint" id="h-review">&nbsp;</div>
1955
1980
  <div class="nav">
@@ -4476,7 +4501,7 @@ summary{min-height:24px;padding:3px 0;}
4476
4501
  request omits the field), [] = the owner switched them all off, [...] =
4477
4502
  what they picked. Never seed it with the preselect — that would store an
4478
4503
  answer nobody gave. */
4479
- var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4504
+ var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4480
4505
  try { Object.assign(state, JSON.parse(sessionStorage.getItem(DRAFT_KEY) || '{}')); } catch (e) {}
4481
4506
  var authKnown = false; /* flips true when the boot's /me settles, either way */
4482
4507
 
@@ -5956,7 +5981,6 @@ summary{min-height:24px;padding:3px 0;}
5956
5981
  if (appOptsEl) appOptsEl.addEventListener('change', function (ev) {
5957
5982
  if (ev.target && ev.target.name === 'appPlatform' && !ev.target.disabled) { state.appPlatform = ev.target.value; saveDraft(); syncRenderSetup(); paintSetup(); }
5958
5983
  });
5959
-
5960
5984
  /* ── Render (task 1004352, ADR 0348) ──
5961
5985
  The flow itself is renderConnect(), shared with an existing project's page (task
5962
5986
  1004184). Here it reads and writes the draft (`state`), so every answer but the key
@@ -6073,7 +6097,14 @@ summary{min-height:24px;padding:3px 0;}
6073
6097
  return 'Workspace ' + ws + ' — a new app (' + runtimeLabel(state.renderRuntime || 'node') + ', ' + plan + ')' + db + '. Render bills you directly.';
6074
6098
  }
6075
6099
 
6076
- var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'];
6100
+ /* the ship row (task 1004629): anything but 'manual' — an older draft has none — is Auto */
6101
+ function shipModeChoice() { return state.shipMode === 'manual' ? 'manual' : 'auto'; }
6102
+ function syncShipStep() {
6103
+ var pick = shipModeChoice();
6104
+ root.querySelectorAll('#shipOpts input[name=shipMode]').forEach(function (i) { i.checked = i.value === pick; });
6105
+ }
6106
+
6107
+ var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'];
6077
6108
  function rowEl(key) { return root.querySelector('#reviewRows .setRow[data-row="' + key + '"]'); }
6078
6109
  function confirmedRows() {
6079
6110
  if (!state.confirmed || typeof state.confirmed !== 'object') state.confirmed = {};
@@ -6118,6 +6149,7 @@ summary{min-height:24px;padding:3px 0;}
6118
6149
  if (key === 'extras') renderModules();
6119
6150
  if (key === 'code') syncHome();
6120
6151
  if (key === 'app') { syncAppStep(); syncRenderSetup(); }
6152
+ if (key === 'ship') syncShipStep();
6121
6153
  }
6122
6154
  function closeRow(key) {
6123
6155
  var row = rowEl(key);
@@ -6182,6 +6214,8 @@ summary{min-height:24px;padding:3px 0;}
6182
6214
  controls' own handlers run first (they are on the targets), then the row repaints */
6183
6215
  ['input', 'change'].forEach(function (type) {
6184
6216
  $('reviewRows').addEventListener(type, function (ev) {
6217
+ /* the ship row's radios have no handler of their own (task 1004629) */
6218
+ if (type === 'change' && ev.target.name === 'shipMode') { state.shipMode = ev.target.value; saveDraft(); }
6185
6219
  if (ev.target.closest && ev.target.closest('.setRow.open .opts')) {
6186
6220
  var row = ev.target.closest('.setRow'), t = rowText(row.getAttribute('data-row'));
6187
6221
  row.querySelector('[data-row-v]').textContent = t.v;
@@ -6247,6 +6281,11 @@ summary{min-height:24px;padding:3px 0;}
6247
6281
  if (a.framing === 'open-source') return { v: 'Free core only', s: 'The free core, on your own machines.' };
6248
6282
  return { v: 'Everything, for you', s: 'Hosting, payouts and the starter kit, managed.' };
6249
6283
  }
6284
+ if (key === 'ship') {
6285
+ return shipModeChoice() === 'manual'
6286
+ ? { v: 'Manual', s: 'The AI waits for you to approve each ship. You can change this later in your project’s settings.' }
6287
+ : { v: 'Auto (recommended)', s: 'The AI ships finished work on its own. You can change this later in your project’s settings.' };
6288
+ }
6250
6289
  return { v: '', s: '' };
6251
6290
  }
6252
6291
  /* paint every row from the draft, and the locked Continue under them */
@@ -6407,7 +6446,9 @@ summary{min-height:24px;padding:3px 0;}
6407
6446
  existing history instead of pushing a fresh tree, which a repo that already
6408
6447
  has commits rejects as unrelated history. state.mode already tracked
6409
6448
  greenfield|adopt for the UI — it has to reach the server too. */
6410
- onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield'
6449
+ onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
6450
+ /* the ship row (task 1004629): becomes the new project's own ship mode */
6451
+ ship_mode: shipModeChoice()
6411
6452
  })
6412
6453
  });
6413
6454
  }).then(function (res) {
@@ -7175,7 +7216,7 @@ summary{min-height:24px;padding:3px 0;}
7175
7216
  is three-valued and null is its "nothing said" sentinel; leaving the key out
7176
7217
  left it UNDEFINED, which is not null — so pickedModules() reached .slice()
7177
7218
  on it and the second project's Modules step threw. */
7178
- state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7219
+ state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7179
7220
  $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
7180
7221
  $('f-noaddr').checked = false; $('f-desc').value = '';
7181
7222
  $('termsAck').checked = false; paintDoors(); demoShow('intro'); checksShow('main');
@@ -11142,6 +11183,8 @@ summary{min-height:24px;padding:3px 0;}
11142
11183
  .then(function (data) {
11143
11184
  me = data.builder || data.me || data;
11144
11185
  renderWho();
11186
+ /* renderWho rebuilds the chip, so the waiting count is painted again on it (task 1004646) */
11187
+ if (window.InviteBadge) window.InviteBadge.refresh();
11145
11188
  paintShellIdentity();
11146
11189
  wizard.onAuth();
11147
11190
  $('gate').hidden = true;
@@ -11174,6 +11217,7 @@ summary{min-height:24px;padding:3px 0;}
11174
11217
  .then(loadSelfShipped, loadSelfShipped);
11175
11218
  })();
11176
11219
  </script>
11220
+ <script src="/assets/invite-badge.js"></script>
11177
11221
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
11178
11222
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
11179
11223
  </body>
@@ -55,6 +55,7 @@
55
55
  "in-new-review-extras": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"extras\"] [data-row-chg]"],["wait",900]],"expect":{"visible":["#modQ","#modCards .tpill","#modCoreNote"]}},
56
56
  "in-new-review-team": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",300],["click","[data-phys-solo=\"many\"]"],["wait",600]],"expect":{"visible":["#physTeam","#physGuessLine","#teamCards","#reviewRows .setRow[data-row=\"rewards\"]"]}},
57
57
  "in-new-review-run": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"run\"] [data-row-chg]"],["wait",300],["click","[data-phys=\"framing\"][data-val=\"hybrid\"]"],["wait",600]],"expect":{"visible":["#physSuite"]}},
58
+ "in-new-review-ship": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"ship\"] [data-row-chg]"],["wait",300],["click","#shipOpts input[value=\"manual\"]"],["wait",300]],"expect":{"visible":["#shipOpts"]}},
58
59
  "in-new-review-confirmed": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200]],"expect":{"visible":["#reviewGo","#reviewRows .setRow.done"],"text":[["#reviewGo","Looks right, continue"]]}},
59
60
  "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],"expect":{"visible":["#panel-look","#wizLook","#wizRecap","#doCreate"],"hidden":["#panel-review"],"attr":[["#wizRoom","data-screen","look"]]}},
60
61
  "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite","#liveProgress","#humanSteps","#wizInviteLater","#stepPager"]}},
@@ -134,6 +134,7 @@
134
134
 
135
135
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms" aria-current="page">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
136
136
  <script src="/assets/hub-bar.js"></script>
137
+ <script src="/assets/invite-badge.js"></script>
137
138
  <!-- the Artist review button: signed-in builders only (task 1004499) -->
138
139
  <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
139
140
  </body>