@bongos/core 1.21.53 → 1.21.54

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.53",
6
- "core_contract": "1.21.53",
7
- "source_commit": "1ad7252c18fc56b532f12ded31dd3e89c51c6f94",
5
+ "core_version": "1.21.54",
6
+ "core_contract": "1.21.54",
7
+ "source_commit": "c52f0a2b10e7ae2f29cb32eb889459f19eeb71a8",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-04T23:40:31.852Z",
9
+ "built_at": "2026-10-04T23:57:08.815Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 582,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 2932,
14
+ "functional_verbatim": 2933,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3543,
20
- "tree_sha256": "a1a32ad96ab4c3586708781095ace3503dc06f0eabe6c43bf9b6c58d4d51ae03",
19
+ "file_count": 3544,
20
+ "tree_sha256": "9ab491c7b8c60e5aa3da3aa41f3c7cda613870f5eaab54cbeb5dbd4ddad408db",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2822,7 +2822,7 @@
2822
2822
  {
2823
2823
  "path": "docs/file-map.md",
2824
2824
  "mode": "0000644",
2825
- "sha256": "459d6bf111e4644831a95374e9d9c9949568a068e96d9f106fe97a882084feeb"
2825
+ "sha256": "b7de27150a5254d894b854f93b1548edabd5389592896c53e851ceca355199e4"
2826
2826
  },
2827
2827
  {
2828
2828
  "path": "docs/handoff-template.md",
@@ -2832,7 +2832,7 @@
2832
2832
  {
2833
2833
  "path": "docs/module-api-changelog.md",
2834
2834
  "mode": "0000644",
2835
- "sha256": "0811801e6606e17166a198042daf177ce79905d814281948a219794b13075952"
2835
+ "sha256": "f2adf16278e58aca451f80de5e8b116325519472cebc00742a9e63e057c13918"
2836
2836
  },
2837
2837
  {
2838
2838
  "path": "docs/modules-contract.md",
@@ -2922,7 +2922,7 @@
2922
2922
  {
2923
2923
  "path": "docs/page-readings.json",
2924
2924
  "mode": "0000644",
2925
- "sha256": "8ef17139f7872a73e971bb1feddf3009db0dd5450c2e2e7ae9edf67d18a77154"
2925
+ "sha256": "1fe6cf551a7fe944c86bbbb3b3906a05be4e67d9aceafd17d0ff9a069dca9b40"
2926
2926
  },
2927
2927
  {
2928
2928
  "path": "docs/project-context.template.md",
@@ -6207,12 +6207,12 @@
6207
6207
  {
6208
6208
  "path": "modules/hall-ui/public/street-card.css",
6209
6209
  "mode": "0000644",
6210
- "sha256": "ebca29688f443dda588fe1f351131199ba325c959f9e5c3c0a4818c0d74cbe0c"
6210
+ "sha256": "7c635571a875b07630ffa2c8ef0b08e4c6f5797f28da45d6e7d7b4dfe086d515"
6211
6211
  },
6212
6212
  {
6213
6213
  "path": "modules/hall-ui/public/street-card.js",
6214
6214
  "mode": "0000644",
6215
- "sha256": "0e0645f6c1cfcf793e1850b4f81eaa169e8a9fb3214a8e47dd5a25171c8b6d8c"
6215
+ "sha256": "0e7aff2f6d68ea12c9ba18850cd25fffa2d1180305d10de1ed54c9f4d5abd079"
6216
6216
  },
6217
6217
  {
6218
6218
  "path": "modules/hall-ui/public/street-card.states.json",
@@ -6222,7 +6222,7 @@
6222
6222
  {
6223
6223
  "path": "modules/hall-ui/public/street-desk.js",
6224
6224
  "mode": "0000644",
6225
- "sha256": "9d4fc788d6866b5daf6ee8b43f20aac3b552851f431743e74f617fd9335540e5"
6225
+ "sha256": "62493777a5d35806429f6d1cff05fc5aa4d2b6cf8b4a6d13d4249b63fc108f82"
6226
6226
  },
6227
6227
  {
6228
6228
  "path": "modules/hall-ui/public/street-draw.js",
@@ -6242,12 +6242,12 @@
6242
6242
  {
6243
6243
  "path": "modules/hall-ui/public/street.html",
6244
6244
  "mode": "0000644",
6245
- "sha256": "1c3ffcf83915d7ae03054370f63969b2f79875743a984dff23d41406c1fda442"
6245
+ "sha256": "3e08c2f7029a7f93cccc373daf0991cd0bf49287e33d3776d1a14eef066f69e2"
6246
6246
  },
6247
6247
  {
6248
6248
  "path": "modules/hall-ui/public/street.js",
6249
6249
  "mode": "0000644",
6250
- "sha256": "037c550922daab94b112c2f5756f3102780f2270491d9c6ce44981742585132a"
6250
+ "sha256": "dbe8d0d35e5009bc08c3b93414f7268cb8fa8606872a41708f6ee64dc40e39d4"
6251
6251
  },
6252
6252
  {
6253
6253
  "path": "modules/hall-ui/public/street.states.json",
@@ -6467,7 +6467,7 @@
6467
6467
  {
6468
6468
  "path": "modules/hall-ui/records/governor-street.md",
6469
6469
  "mode": "0000644",
6470
- "sha256": "0a180c91f0f921fff166bafa1783e37422f1b0ebd61f98dcca6dca75659f1f63"
6470
+ "sha256": "c19e1506210e097dcb6799646ff6199e47bc7cce00e89213bb8771cbbf5d29c1"
6471
6471
  },
6472
6472
  {
6473
6473
  "path": "modules/hall-ui/records/hall-navigation.md",
@@ -9902,12 +9902,12 @@
9902
9902
  {
9903
9903
  "path": "package-lock.json",
9904
9904
  "mode": "0000644",
9905
- "sha256": "8b15e4d781eadc340d97c3560c82041a4aa57cbb58d4eab10f5f59c416322833"
9905
+ "sha256": "ab9eb6c2597791f8f2bd504400664bb8d7644c7efaebb4f1150c23541bbb6fbe"
9906
9906
  },
9907
9907
  {
9908
9908
  "path": "package.json",
9909
9909
  "mode": "0000644",
9910
- "sha256": "bb7c8427e09006b6a5b748c21cd7161a968d38ef203fa3dd26b4271373a9d553"
9910
+ "sha256": "3d42b7cd6a62c8664f803818e83429dbaca537189f2c7bfb8c155def16eb25e4"
9911
9911
  },
9912
9912
  {
9913
9913
  "path": "public-docs/index.html",
@@ -9927,7 +9927,7 @@
9927
9927
  {
9928
9928
  "path": "release-notes.json",
9929
9929
  "mode": "0000644",
9930
- "sha256": "dd93db028c64dbaabd38284309255318dcef2d76ad5544c4a73963173715173c"
9930
+ "sha256": "e125c78d3a8ed71b1b2324630e8f837835a6d744953da21e1fdb23b4f20e398e"
9931
9931
  },
9932
9932
  {
9933
9933
  "path": "scripts/cron/style_guide_audit.sh",
@@ -12147,7 +12147,7 @@
12147
12147
  {
12148
12148
  "path": "src/module-api.js",
12149
12149
  "mode": "0000644",
12150
- "sha256": "4d56f69a34c4621f423052b91da4d7310a9c33b3b8473a57053cdc2a6c905ffb"
12150
+ "sha256": "032eda2254b90462ff3327b6751c1e8946a6e03f1eb3e8a476fa0cb240552b6e"
12151
12151
  },
12152
12152
  {
12153
12153
  "path": "src/module-loader/catalog.js",
@@ -14557,7 +14557,7 @@
14557
14557
  {
14558
14558
  "path": "tests/hall_street_desk.mjs",
14559
14559
  "mode": "0000644",
14560
- "sha256": "aa280239073113623d7eb26f09772daefbb2d1dec72dc4ef760454f3e9ef20b0"
14560
+ "sha256": "11aa6b69b820c18d887d26868af580ee35e136a4803111e28f65510f45e41042"
14561
14561
  },
14562
14562
  {
14563
14563
  "path": "tests/hall_street_inside.mjs",
@@ -15834,6 +15834,11 @@
15834
15834
  "mode": "0000644",
15835
15835
  "sha256": "3c85d28ecc55e375988f3e55aee993c8c84220889678262d8207146c2772f3f5"
15836
15836
  },
15837
+ {
15838
+ "path": "tests/privacy_matrix.mjs",
15839
+ "mode": "0000644",
15840
+ "sha256": "c409bf3f72c33086ccd75e65dc019297e38029c71b5bb6bec9ecc08e4c34d929"
15841
+ },
15837
15842
  {
15838
15843
  "path": "tests/probe_cache.mjs",
15839
15844
  "mode": "0000644",
package/docs/file-map.md CHANGED
@@ -358,6 +358,7 @@ tests/
358
358
  ├── terms_acceptance.mjs ← (task 1002913, goal 1000045, ADR 0213): the platform T&C acceptance record. Proves the four properties that make it evidence rather than a claim — the /terms page carries the cross-project consent ADR 0141 §4 deferred to it and it is apex-only; acceptance is written ONLY by the hub's own /sso/authorize (driven over the real router with an attacker-controlled query string, so a forged timestamp/version/activate flag reaches nothing); it is written ONCE (the fake pool interprets the upsert's own column assignments, so COALESCE is executed, not string-matched, and an assignment shape it cannot read throws instead of reading as "unchanged"); and consent is two columns in the shared predicate, so an account the hub witnessed before the terms existed publishes nothing. Plus the projection rule — every read whose row a JS twin judges must select both consent columns, or the twin denies everyone silently. DB-free `unit` lane, asserts its own lane membership.
359
359
  ├── applicant_profile_boundary.mjs ← (task 1002972, goal 1000045 C4, privacy spec D7, ADR 0241): THE D7 BOUNDARY proof for the applicant view a reviewer sees. Whitelists read from the MODULES’ own exports — the private branch against `recruiter-sliver`’s `SLIVER_KEYS`/`SLIVER_KEYS_HIDDEN`, the public branch against the public-page payload’s key list — so a third shape (the “new disclosure class” D7 forbids) reds this file. Pins THE CONSENT SWAP in both directions on the same account: a private builder who never opted into recruiting gets an applicant view and gets `null` from `getRecruiterSliver`, so a refactor routing the port through the recruiter sliver cannot pass quietly. `hide_stats` beats the application in both branches; provisional and terms-never-accepted disclose nothing; planted sentinels asserted absent from the whole body with the avatar-embedded `github_id` pinned as the one honest exception. Plus the federated gate (ADR 0205): no echo → 404 and NO account row read (asserted on the statement log), the witness matched against the AUTHENTICATED client, an out-of-window echo stops working, and `Cache-Control: no-store`. Real router + real HTTP over in-memory tables filtered by each statement’s own WHERE clause. DB-free `unit` lane, asserted in the file itself.
360
360
  ├── recruiter_sliver_boundary.mjs ← (task 1002968, goal 1000045 C2/C5, privacy spec D3, ADR 0215): THE PRE-ACCEPT BOUNDARY proof for the D3 recruiter sliver. A WHITELIST over the module's own exported key list (a sixth key fails whether or not this file was told about it), planted improbable sentinels asserted absent from the WHOLE response body with two controls against vacuity, the ceiling assertion (the handle a recruiter is handed 404s at `GET /profiles/:handle`, with a 200 control) and the gate (both recruiting routes admit the same two audiences and refuse byte-identically). Real router + real HTTP over an in-memory table filtered by each statement's own WHERE clause. DB-free `unit` lane, asserted in the file itself.
361
+ ├── privacy_matrix.mjs ← (task 1002973, goal 1000110, privacy spec D2/D3/D4): THE PRIVACY MATRIX as one table — a fixture account per D2 row (provisional, public shown, public hidden, private, private+discoverable, private+discoverable+hidden, opted out of recruiting) × every viewer (Anon, Builder, Recruiter, Self) × every cross-project surface that exists (`/profiles/:handle`, `/me/cross-project`, `/community/search`, `/community/leaderboard`, `/scouting`, `/scouting/:handle`), asserted against a literal transcription of the spec's cells; then the rules swept across EVERY recorded response (hidden numbers reach nobody but Self, private/provisional are absent, private+discoverable is named only by the sliver), the D3 key lists pinned, and an expired invite refused by the real `hasInvitedAccessRequest`. Real routers + real HTTP over in-memory tables filtered by each statement's own WHERE (boolean structure read, calibrated against the JS twins). The declined-invite row is task 1004444's. DB-free `unit` lane, asserted in the file itself.
361
362
  └── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
362
363
  ```
363
364
 
@@ -2933,5 +2933,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2933
2933
  landed since 1.21.51 with no explicit bump. run 37243572142. (task 1002620)
2934
2934
  1.21.53 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2935
2935
  landed since 1.21.52 with no explicit bump. run 37244559329. (task 1002620)
2936
+ 1.21.54 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2937
+ landed since 1.21.53 with no explicit bump. run 37245549793. (task 1002620)
2936
2938
  ---------------------------------------------------------------------------
2937
2939
  ```
@@ -3078,7 +3078,7 @@
3078
3078
  "surface": "builders",
3079
3079
  "title": "The street",
3080
3080
  "states_read": ["street","fullscreen","card","switched","focus","here","inside-rules","inside-disputes","inside-people","inside-planning","inside-automation","inside-review","inside-release","inside-treasury","floor","amend","district","desk","desk-facts","desk-quiet","desk-people-builders","desk-people-tier","desk-disputes","desk-automation-goals","desk-release-core","desk-rules","desk-planning-priorities","desk-planning-ideas","desk-planning-blockers","pins","pins-pager","sitting","object","voted","admission","settled","blocker","blocker-more","report","report-more","core","core-more","recheck","recheck-more","idea","idea-more","queue","queue-more","automation","automation-more"],
3081
- "files_hash": "809fbe1ef71633aa",
3081
+ "files_hash": "0c488b1bac292b13",
3082
3082
  "reading_hash": "f78d4b381fddc311",
3083
3083
  "counts": {"lines":429,"placed":79,"shared":8,"unplaced":342},
3084
3084
  "omitted": {"data":448},
@@ -82,6 +82,24 @@ body[data-page="street"] {
82
82
  .mcard-chip[disabled] { opacity: .55; cursor: progress; }
83
83
  .mcard-chips__field { flex: 1 1 140px; min-width: 0; height: 32px; box-sizing: border-box; padding: 0 10px; font-family: var(--font-body); font-size: 14px; color: var(--ink); background: var(--bg); border: 1px solid var(--street-rule-strong); border-radius: 8px; }
84
84
 
85
+ /* the recorded sign-off's one line (task 1004579), on the card and the desk row
86
+ alike: the file or the why, a hairline bar that fills while it is sent, and
87
+ the chips. The file input sits inside its chip, out of sight but focusable. */
88
+ .rec { position: relative; flex: 1 1 auto; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-bottom: 8px; }
89
+ .rec__line { flex: 1 1 160px; min-width: 0; font-size: 13.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
90
+ .rec--busy .rec__line { color: var(--ink); }
91
+ .rec--error .rec__line { color: var(--street-accent-text); white-space: normal; }
92
+ .rec__bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; border-radius: 2px; background: var(--street-rule); overflow: hidden; }
93
+ .rec__bar[hidden] { display: none; }
94
+ .rec__fill { display: block; width: 0; height: 100%; border-radius: 2px; background: var(--accent); transition: width 200ms linear; }
95
+ .rec__pick { position: relative; display: inline-flex; align-items: center; box-sizing: border-box; }
96
+ .rec__file { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; font-size: 0; }
97
+ .rec__file[disabled] { cursor: progress; }
98
+ .rec__pick:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; }
99
+ .rec--over { outline: 1px dashed var(--accent); outline-offset: 4px; border-radius: 8px; }
100
+ .mcard-chip.rec__go { background: var(--accent); color: var(--accent-on); border-color: transparent; }
101
+ .desk-chip.rec__go { background: var(--ink); color: var(--bg); border-color: var(--ink); }
102
+
85
103
  /* more: the detail, in place */
86
104
  .mcard-more__toggle { margin-top: -2px; }
87
105
  /* the pager: "1 of N" with previous and next, when a pin holds more than one matter (task 1004569) */
@@ -49,8 +49,16 @@
49
49
  // the model as `row`, and render() draws row.actions and row.choices, sending
50
50
  // each act through request() with the row's own card. "more" opens the detail
51
51
  // in place (moreOf): the full text, who and when, and the record. Nothing on
52
- // the card links to an older page to decide; the one link left is a sign-off
53
- // that needs a recording ('record it'), which only the goal's page can take.
52
+ // the card links to an older page to decide.
53
+ //
54
+ // THE RECORDED SIGN-OFF (task 1004579). A promise that needs a recording is
55
+ // signed off here too: 'record it' opens one line where the recording is picked
56
+ // or dropped, sent with its progress on that line, then signed off, through the
57
+ // SAME two routes the goal page uses (POST /done-when/:id/uat/recording, then
58
+ // POST /done-when/:id/uat). recordLine() says what the line shows in each
59
+ // phase, recordRun() runs the two steps, recordStrip() draws the line for the
60
+ // desk row and the card alike. A refused or failed upload says why on that
61
+ // line, in one sentence, and offers to try again.
54
62
 
55
63
  (function (root) {
56
64
  'use strict';
@@ -779,6 +787,10 @@
779
787
  if (key === 'release') return { toast: cv && cv.source === 'gate_approvals' ? 'Released from the gate. It merges and goes live.' : 'Released. It goes live.', building: b, kind: 'rise', close: true };
780
788
  if (key === 'fine') return { toast: cv && cv.source === 'goal_join_requests' ? 'Noted. ' + who + ' joins the team.' : 'Noted. ' + who + ' is invited in.', building: b, kind: 'rise', close: false };
781
789
  if (key === 'refuse') return { toast: 'Noted. ' + who + ' is not let in.', building: b, kind: 'settle', close: false };
790
+ if (key === 'record') {
791
+ var goal = Array.isArray(r.achieved_goals) && r.achieved_goals.length;
792
+ return { toast: goal ? 'Signed off with the recording. That was the last promise: the goal is achieved.' : 'Signed off with the recording. The promise counts as kept.', building: b, kind: 'rise', close: true };
793
+ }
782
794
  if (key === 'take' && cv && cv.subject && cv.subject.kind === 'core_pin') return { toast: ACT_TOASTS.take_core, building: b, kind: 'rise', close: false };
783
795
  if (key === 'invite') return { toast: ACT_TOASTS.invite.replace('{who}', r.github_login || 'They'), building: b, kind: 'light', close: false };
784
796
  if (ACT_TOASTS[key]) return { toast: ACT_TOASTS[key].replace('{who}', who), building: b, kind: ACT_KINDS[key] || 'light', close: false };
@@ -822,7 +834,7 @@
822
834
  bad_github_login: 'That does not look like a GitHub name.',
823
835
  backup_already_running: 'A backup is already running.',
824
836
  intent_conflict: 'Another core run is going. Try again when it settles.',
825
- criterion_needs_recording: 'This one needs a recording. Sign it off on its goal page.',
837
+ criterion_needs_recording: 'This one needs a recording: record it here.',
826
838
  not_proposer: 'Only the person who put it up may withdraw it.',
827
839
  votes_cast: 'Someone has voted on it, so it is the sitting\'s to decide now.',
828
840
  item_changed: 'Someone voted just now, so it stays with the sitting.',
@@ -835,6 +847,185 @@
835
847
  return e.message && e.message !== code ? String(e.message) : 'It could not be done just now.';
836
848
  }
837
849
 
850
+ // ---------- the recorded sign-off (task 1004579) ----------
851
+ // The goal page's own limits (criterion-uat-ui.js; the server screens again).
852
+ var RECORD_TYPES = ['video/mp4', 'video/webm'];
853
+ var RECORD_MAX = 100 * 1024 * 1024;
854
+ // Why an upload or a sign-off was refused, in one short sentence a person can
855
+ // act on. The server's own sentence stands in for a code not named here.
856
+ var RECORD_WHY = {
857
+ bad_type: 'Only an mp4 or webm video can go.',
858
+ empty: 'That file is empty.',
859
+ too_big: 'Over the 100 MB cap. Record a shorter pass.',
860
+ sniff_mismatch: 'That file is not the video it says it is.',
861
+ recording_not_found: 'The upload was lost on the way. Send it again.',
862
+ signer_shipped_this_work: 'You built this, so someone else signs it off.',
863
+ work_not_live: 'It is not on the live site yet. Sign off once it is.',
864
+ criterion_already_satisfied: 'Someone has already signed this off.',
865
+ criterion_not_awaiting_uat: 'Some of its work is still open.',
866
+ criterion_is_backend_only: 'This one is signed off without a recording.',
867
+ live_attestation_required: 'Say that you tried it on the live site.',
868
+ preview_readonly: 'This preview records nothing.',
869
+ };
870
+ // A refusal no new file can fix is not offered again ("try again" would only
871
+ // be refused the same way); a file the server refused asks for another file.
872
+ var RECORD_FINAL = { signer_shipped_this_work: 1, criterion_already_satisfied: 1, criterion_not_awaiting_uat: 1, criterion_is_backend_only: 1, preview_readonly: 1 };
873
+ var RECORD_FILE = { bad_type: 1, empty: 1, too_big: 1, sniff_mismatch: 1 };
874
+ function recordCheck(file) {
875
+ if (!file) return 'Pick the recording first.';
876
+ if (RECORD_TYPES.indexOf(str(file.type)) < 0) return RECORD_WHY.bad_type;
877
+ if (!(Number(file.size) > 0)) return RECORD_WHY.empty;
878
+ if (Number(file.size) > RECORD_MAX) return RECORD_WHY.too_big;
879
+ return null;
880
+ }
881
+ function firstSentence(s) {
882
+ var t = norm(s);
883
+ var hit = /^.*?[.!?](\s|$)/.exec(t);
884
+ return clip(hit ? hit[0] : t, 72);
885
+ }
886
+ function recordWhy(status, body) {
887
+ var e = body && body.error ? body.error : {};
888
+ var code = str(e.code || (body && body.code));
889
+ if (RECORD_WHY[code]) return RECORD_WHY[code];
890
+ if (status === 413) return RECORD_WHY.too_big;
891
+ if (!status) return 'The upload broke off. Try again.';
892
+ if (e.message && e.message !== code) return firstSentence(e.message);
893
+ if (status === 403) return 'Only a reviewer who did not build it may sign off.';
894
+ return 'It did not go through. Try again.';
895
+ }
896
+ // The two routes, the goal page's own: the raw video, then the sign-off with
897
+ // the stored name. live_attested is what the "tested it live" chip says.
898
+ function recordRoutes(id) {
899
+ var e = encodeURIComponent(str(id));
900
+ return {
901
+ upload: API + '/done-when/' + e + '/uat/recording',
902
+ signoff: function (recording) { return post('/done-when/' + e + '/uat', { kind: 'uat', recording: str(recording), live_attested: true }); },
903
+ };
904
+ }
905
+ function sizeWords(n) {
906
+ var b = Number(n) || 0;
907
+ if (b >= 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + ' MB';
908
+ return Math.max(1, Math.round(b / 1024)) + ' KB';
909
+ }
910
+ function fileWords(f) { return f ? clip(str(f.name) || 'the recording', 28) : ''; }
911
+ // The line in each phase: { text, pct (while sending), busy, error, chips }.
912
+ // st { file, phase: 'empty'|'picked'|'uploading'|'signing'|'failed', pct, why, retry }
913
+ function recordLine(state) {
914
+ var st = state || {};
915
+ var name = fileWords(st.file);
916
+ var pick = { key: 'pick', label: st.file ? 'pick another' : 'pick or drop it' };
917
+ if (st.phase === 'uploading') return { text: name + ' · ' + Math.max(0, Math.min(100, Math.floor(Number(st.pct) || 0))) + '% sent', pct: Math.floor(Number(st.pct) || 0), busy: true, error: false, chips: [] };
918
+ if (st.phase === 'signing') return { text: name + ' · sent, signing off…', pct: 100, busy: true, error: false, chips: [] };
919
+ if (st.phase === 'failed') return { text: str(st.why) || 'It did not go through.', pct: null, busy: false, error: true, chips: (st.retry && st.file ? [{ key: 'send', label: 'try again' }] : []).concat(st.final ? [] : [pick]) };
920
+ if (st.file) return { text: name + ' · ' + sizeWords(st.file.size), pct: null, busy: false, error: false, chips: [pick, { key: 'send', label: 'tested it live' }] };
921
+ return { text: 'mp4 or webm, up to 100 MB', pct: null, busy: false, error: false, chips: [pick] };
922
+ }
923
+ // A file picked or dropped: checked at once, so a wrong one says why before
924
+ // anything is sent.
925
+ function recordPick(state, file) {
926
+ var st = state || {};
927
+ st.file = file || null;
928
+ st.recording = null; st.pct = 0;
929
+ var bad = recordCheck(file);
930
+ if (bad) { st.phase = 'failed'; st.why = bad; st.retry = false; st.final = false; } else { st.phase = 'picked'; st.why = ''; }
931
+ return st;
932
+ }
933
+ // Send it, then sign off. deps { upload(url, file, onPct) → {ok,status,body},
934
+ // send(req) → {ok,status,body}, change(st), progress(st) }. An upload that
935
+ // went through is kept, so trying again after a refused sign-off does not send
936
+ // the video twice. Resolves to the sign-off's answer, or the refusal.
937
+ function recordRun(id, state, deps) {
938
+ var st = state || {};
939
+ var d = deps || {};
940
+ var tell = function () { if (d.change) d.change(st); };
941
+ var fail = function (out) {
942
+ var code = str(out && out.body && out.body.error && out.body.error.code);
943
+ // a file the server refused (or one too big for the body parser) needs another file
944
+ var badFile = !!RECORD_FILE[code] || (out && out.status) === 413;
945
+ st.phase = 'failed';
946
+ st.why = recordWhy(out && out.status, out && out.body);
947
+ st.final = !!RECORD_FINAL[code];
948
+ st.retry = !st.final && !badFile;
949
+ if (code === 'recording_not_found' || badFile) st.recording = null;
950
+ tell();
951
+ return { ok: false, status: out && out.status || 0, body: out && out.body || {} };
952
+ };
953
+ var bad = recordCheck(st.file);
954
+ if (bad) { st.phase = 'failed'; st.why = bad; st.retry = false; st.final = false; tell(); return Promise.resolve({ ok: false, status: 0, body: {} }); }
955
+ var routes = recordRoutes(id);
956
+ var sent;
957
+ if (st.recording) sent = Promise.resolve({ ok: true, status: 201, body: { recording: st.recording } });
958
+ else {
959
+ st.phase = 'uploading'; st.pct = 0; st.why = '';
960
+ tell();
961
+ sent = Promise.resolve(d.upload(routes.upload, st.file, function (p) { st.pct = p; if (d.progress) d.progress(st); }));
962
+ }
963
+ return sent.then(function (up) {
964
+ if (!up || !up.ok) return fail(up);
965
+ var name = up.body && up.body.recording;
966
+ if (!name) return fail({ status: up.status, body: {} });
967
+ st.recording = str(name);
968
+ st.phase = 'signing'; st.pct = 100;
969
+ tell();
970
+ return Promise.resolve(d.send(routes.signoff(st.recording))).then(function (out) {
971
+ if (!out || !out.ok) return fail(out);
972
+ st.phase = 'done';
973
+ return out;
974
+ });
975
+ });
976
+ }
977
+ // The raw upload with its progress (fetch cannot report an upload's progress).
978
+ function uploadXhr(X, url, file, onPct) {
979
+ return new Promise(function (res) {
980
+ var x;
981
+ try { x = new X(); } catch (e) { res({ ok: false, status: 0, body: {} }); return; }
982
+ x.open('POST', url, true);
983
+ x.withCredentials = true;
984
+ x.setRequestHeader('Content-Type', str(file && file.type));
985
+ x.setRequestHeader('Accept', 'application/json');
986
+ if (x.upload) x.upload.onprogress = function (e) { if (e && e.lengthComputable && e.total) onPct(Math.min(99, Math.floor(e.loaded * 100 / e.total))); };
987
+ x.onload = function () {
988
+ var b = {};
989
+ try { b = JSON.parse(x.responseText || '{}') || {}; } catch (e) { b = {}; }
990
+ res({ ok: x.status >= 200 && x.status < 300, status: x.status, body: b });
991
+ };
992
+ x.onerror = function () { res({ ok: false, status: 0, body: {} }); };
993
+ x.onabort = x.onerror;
994
+ x.send(file);
995
+ });
996
+ }
997
+ // What the goal page showed above its form, from GET /done-when/:id/uat:
998
+ // the three checks, short. { facts: up to three, for the desk row; lines: for
999
+ // the card's "more" }.
1000
+ function uatChecks(view) {
1001
+ var v = view || {};
1002
+ var c = v.checks || {};
1003
+ var facts = [], lines = [];
1004
+ facts.push(c.code ? 'code done' : 'code still open');
1005
+ lines.push(c.code ? 'code · every linked task is done' : 'code · some linked work is still open');
1006
+ var live = c.live;
1007
+ if (live) {
1008
+ if (!live.available) { facts.push('live: your word'); lines.push('live · this site cannot tell, so the sign-off says you tried it live'); }
1009
+ else if (live.live) { facts.push('running live'); lines.push('live · running on the live site'); }
1010
+ else { facts.push('not live yet'); lines.push('live · shipped but not on the live site yet'); }
1011
+ }
1012
+ var s = c.uat || null;
1013
+ var who = function (x) { return str(x.signed_off_by_login || 'someone') + (dayOf(x.created_at) ? ' on ' + dayOf(x.created_at) : ''); };
1014
+ if (s) { facts.push('signed off'); lines.push('sign-off · by ' + who(s)); }
1015
+ else if (c.stale_uat) { facts.push('old sign-off lapsed'); lines.push('sign-off · an earlier one by ' + who(c.stale_uat) + ' no longer counts: more work shipped after it'); }
1016
+ else { facts.push('not signed off yet'); lines.push('sign-off · not yet'); }
1017
+ return { facts: facts.slice(0, 3), lines: lines };
1018
+ }
1019
+ // The checks under the card's "more", once read (or "reading…" until then).
1020
+ function withChecks(cv, view, failed) {
1021
+ if (!cv) return cv;
1022
+ var mo = cv.more || { text: [], who: '', when: '', history: [] };
1023
+ var words = view ? uatChecks(view).lines : [failed ? 'the checks could not be read just now' : 'reading the checks…'];
1024
+ mo.text = mo.text.filter(function (x) { return !x.checks; }).concat(words.map(function (w, i) { return { label: i ? '' : 'the checks', words: w, checks: true }; }));
1025
+ cv.more = mo;
1026
+ return cv;
1027
+ }
1028
+
838
1029
  // ---------- the DOM ----------
839
1030
  function el(doc, tag, cls, text) {
840
1031
  var n = doc.createElement(tag);
@@ -995,7 +1186,11 @@
995
1186
  var box = el(doc, 'div', 'mcard-chips');
996
1187
  box.setAttribute('data-chips', open);
997
1188
  box.appendChild(el(doc, 'span', 'mcard-chips__ask', ch.ask));
998
- if (ch.field) {
1189
+ if (ch.file && on.record) {
1190
+ var strip = recordStrip(doc, on.record.state(), on.record, 'card', u.busy);
1191
+ if (on.record.painted) on.record.painted(strip);
1192
+ box.appendChild(strip.node);
1193
+ } else if (ch.field) {
999
1194
  var input = el(doc, 'input', 'mcard-chips__field');
1000
1195
  input.setAttribute('id', 'mcardField');
1001
1196
  input.setAttribute('type', 'text');
@@ -1011,6 +1206,63 @@
1011
1206
  host.appendChild(box);
1012
1207
  }
1013
1208
 
1209
+ // The recording's one line (task 1004579), for the desk row ('desk') and the
1210
+ // card ('card'): what the line says, a thin bar while it is sent, and its
1211
+ // chips. 'pick' is a label wrapping the file input, so a tap opens the file
1212
+ // chooser and the keyboard reaches it; a drop on the line takes the file too.
1213
+ // on { pick(file), send() }. Returns { node, paint(st) }: paint() moves the
1214
+ // line and the bar in place, so progress never redraws the desk.
1215
+ function recordStrip(doc, state, on, kind, busy) {
1216
+ var desk = kind === 'desk';
1217
+ var chipCls = desk ? 'desk-chip desk-chip--choice' : 'mcard-chip';
1218
+ var ln = recordLine(state);
1219
+ var box = el(doc, 'span', 'rec' + (ln.busy ? ' rec--busy' : '') + (ln.error ? ' rec--error' : ''));
1220
+ box.setAttribute('data-record', ln.error ? 'failed' : (state && state.phase) || 'empty');
1221
+ var line = el(doc, 'span', 'rec__line', ln.text);
1222
+ line.setAttribute('role', ln.error ? 'alert' : 'status');
1223
+ box.appendChild(line);
1224
+ var bar = el(doc, 'span', 'rec__bar');
1225
+ var fill = el(doc, 'span', 'rec__fill');
1226
+ bar.setAttribute('aria-hidden', 'true');
1227
+ bar.appendChild(fill);
1228
+ if (ln.pct == null) bar.hidden = true;
1229
+ else fill.style.setProperty('width', ln.pct + '%');
1230
+ box.appendChild(bar);
1231
+ ln.chips.forEach(function (c) {
1232
+ if (c.key === 'pick') {
1233
+ var lab = el(doc, 'label', chipCls + ' rec__pick');
1234
+ lab.setAttribute('data-act', 'record-pick');
1235
+ lab.appendChild(el(doc, 'span', '', c.label));
1236
+ var input = el(doc, 'input', 'rec__file');
1237
+ input.setAttribute('type', 'file');
1238
+ input.setAttribute('accept', RECORD_TYPES.join(','));
1239
+ input.setAttribute('aria-label', c.label + ': the recording, mp4 or webm');
1240
+ if (busy) input.setAttribute('disabled', '');
1241
+ input.addEventListener('change', function () { var f = input.files && input.files[0]; if (f) on.pick(f); });
1242
+ lab.appendChild(input);
1243
+ box.appendChild(lab);
1244
+ } else {
1245
+ box.appendChild(button(doc, chipCls + (c.key === 'send' && !ln.error ? ' rec__go' : ''), c.label, 'record-' + c.key, function () { on.send(); }, busy));
1246
+ }
1247
+ });
1248
+ var stop = function (e) { if (e && e.preventDefault) e.preventDefault(); if (e && e.stopPropagation) e.stopPropagation(); };
1249
+ box.addEventListener('dragover', function (e) { stop(e); box.className += /\brec--over\b/.test(box.className) ? '' : ' rec--over'; });
1250
+ box.addEventListener('dragleave', function () { box.className = box.className.replace(/\s*rec--over\b/g, ''); });
1251
+ box.addEventListener('drop', function (e) {
1252
+ stop(e);
1253
+ var f = e && e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null;
1254
+ if (f && !busy) on.pick(f);
1255
+ });
1256
+ return {
1257
+ node: box,
1258
+ paint: function (st) {
1259
+ var now = recordLine(st);
1260
+ line.textContent = now.text;
1261
+ if (now.pct != null) { bar.hidden = false; fill.style.setProperty('width', now.pct + '%'); }
1262
+ },
1263
+ };
1264
+ }
1265
+
1014
1266
  // "more": the card grows in place with the detail (task 1004566). The toggle
1015
1267
  // says whether it is open (aria-expanded); the queue's other pieces of work
1016
1268
  // are listed there, each opening in this same card.
@@ -1078,6 +1330,9 @@
1078
1330
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
1079
1331
  model: model, actsCard: actsCard, moreOf: moreOf, request: request, orderRequest: orderRequest, check: check, answer: answer, refusal: refusal, render: render, pager: pager,
1080
1332
  memberWords: memberWords, windowWords: windowWords, LINE: LINE, compact: compact,
1333
+ // the recorded sign-off from the card (task 1004579)
1334
+ RECORD_TYPES: RECORD_TYPES, RECORD_MAX: RECORD_MAX, recordCheck: recordCheck, recordWhy: recordWhy, recordRoutes: recordRoutes,
1335
+ recordLine: recordLine, recordPick: recordPick, recordRun: recordRun, recordStrip: recordStrip, uploadXhr: uploadXhr, uatChecks: uatChecks, withChecks: withChecks,
1081
1336
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
1082
1337
  change: changeOf,
1083
1338
  };
@@ -173,7 +173,7 @@
173
173
  recheck: 'check again',
174
174
  });
175
175
  // What a row's chips ask, per act (a reason or an option is one tap, never text).
176
- const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
176
+ const ASKS = Object.freeze({ recheck: 'why', budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', record: 'the recording', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
177
177
  // How a floor counts rows nobody is waiting on (desk rows), by its desk.
178
178
  const DESK_NOUNS = Object.freeze({
179
179
  'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
@@ -287,7 +287,9 @@
287
287
  // 1004559). An act with options asks first, with its options as one-tap chips;
288
288
  // `pick: 'open_goals'` offers the open goals the street already reads; an act
289
289
  // whose chips come out empty is left off rather than drawn dead. 'record'
290
- // goes to the matter's own page (a sign-off that needs a recording).
290
+ // (a sign-off that needs a recording) asks for the recording in place: its
291
+ // choice is the file line (`file: true`), drawn by GovMatterCard.recordStrip
292
+ // (task 1004579), never a link to the goal's page.
291
293
  function actedRow(acts, card, ctx) {
292
294
  const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || 'goal ' + g.id)]));
293
295
  // an act at a building a sitting decides is filed as its matter (task 1004561)
@@ -297,7 +299,7 @@
297
299
  // 'open' leaves for an older page: a row that can act here keeps its detail
298
300
  // in place (tap the name) and drops the link; only a row with no act of its
299
301
  // own may link out (owner standard, task 1004562)
300
- const inPlace = acts.some((a) => a && a.key !== 'open' && a.key !== 'record');
302
+ const inPlace = acts.some((a) => a && a.key !== 'open');
301
303
  acts.forEach((a) => {
302
304
  if (a.key === 'open' && inPlace) return;
303
305
  // the core's 'take' is its own word ('take it'), not the rank's 'take back'
@@ -311,11 +313,13 @@
311
313
  if (!options.length) return;
312
314
  }
313
315
  options = options.slice(0, LIMITS.chips);
314
- const goes = a.key === 'record' || a.key === 'open';
316
+ const goes = a.key === 'open';
315
317
  if (goes && !card.href) return;
316
318
  const field = a.field || null;
317
- const ask = options.length > 0 || !!field;
318
- if (ask) choices[a.key] = field ? { for: a.key, ask: ASKS[word] || 'which', field: field, options: [] } : { for: a.key, ask: ASKS[word] || 'which', options: options };
319
+ const file = a.key === 'record';
320
+ const ask = options.length > 0 || !!field || file;
321
+ if (file) choices[a.key] = { for: a.key, ask: ASKS[word], file: true, options: [] };
322
+ else if (ask) choices[a.key] = field ? { for: a.key, ask: ASKS[word] || 'which', field: field, options: [] } : { for: a.key, ask: ASKS[word] || 'which', options: options };
319
323
  actions.push({ key: a.key, label: ACT_WORDS[word], tone: actions.length ? 'quiet' : 'primary', go: goes ? card.href : null, ask: ask });
320
324
  });
321
325
  const first = actions.find((a) => a.ask);
@@ -568,7 +572,7 @@
568
572
  if (r.actions.some((a) => a.ask) && !r.chips) bad('a reason without chips', r.name);
569
573
  Object.keys(r.choices || {}).forEach((k) => {
570
574
  const c = r.choices[k];
571
- if ((!c.field && !c.options.length) || c.options.length > LIMITS.chips || c.options.some((x) => !x.label || x.label.length > LIMITS.chip)) bad('chips', c.options.map((x) => x.label));
575
+ if ((!c.field && !c.file && !c.options.length) || c.options.length > LIMITS.chips || c.options.some((x) => !x.label || x.label.length > LIMITS.chip)) bad('chips', c.options.map((x) => x.label));
572
576
  });
573
577
  if (r.actions.some((a) => a.ask && !(r.choices || {})[a.key])) bad('an ask without its own chips', r.name);
574
578
  if (r.pips && r.pips.length > LIMITS.pips) bad('more than twelve pips', r.pips.length);
@@ -12,7 +12,7 @@
12
12
  (tests/hall_tokens.mjs: no hall page hardcodes a web-font stylesheet). -->
13
13
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
14
14
  <link rel="stylesheet" href="/builders/street.css?v=2026-10-04-pins1">
15
- <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-04-pins1">
15
+ <link rel="stylesheet" href="/builders/street-card.css?v=2026-10-04-record1">
16
16
  </head>
17
17
  <!-- THE STREET (task 1004525, goal 1000125, R03). The street view of
18
18
  docs/design/mocks/governor-city/Street.dc.html (renderVals' street branch,
@@ -210,10 +210,10 @@
210
210
  <script src="/builders/palette.js" defer></script>
211
211
  <script src="/builders/city-draw.js?v=2026-10-04-pins1"></script>
212
212
  <script src="/builders/street-draw.js?v=2026-10-04-pins1"></script>
213
- <script src="/builders/street-card.js?v=2026-10-04-pins1"></script>
213
+ <script src="/builders/street-card.js?v=2026-10-04-record1"></script>
214
214
  <script src="/builders/street-inside.js?v=2026-10-03-desk1"></script>
215
- <script src="/builders/street-desk.js?v=2026-10-03-desk1"></script>
215
+ <script src="/builders/street-desk.js?v=2026-10-04-record1"></script>
216
216
  <script src="/builders/street-amend.js?v=2026-10-02-amend1"></script>
217
- <script src="/builders/street.js?v=2026-10-04-pins1"></script>
217
+ <script src="/builders/street.js?v=2026-10-04-record1"></script>
218
218
  </body>
219
219
  </html>