@bongos/core 1.21.52 → 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.
Files changed (70) hide show
  1. package/.bongos-core.json +88 -73
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +135 -3
  8. package/docs/api-reference.md +4 -2
  9. package/docs/copy-inventory.md +23 -11
  10. package/docs/copy-registry.json +142 -13
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +202 -202
  14. package/modules/hall-ui/public/atlas.html +3 -3
  15. package/modules/hall-ui/public/blockers.html +3 -3
  16. package/modules/hall-ui/public/board-room.html +3 -3
  17. package/modules/hall-ui/public/city.html +3 -3
  18. package/modules/hall-ui/public/collab.html +3 -3
  19. package/modules/hall-ui/public/commands.html +3 -3
  20. package/modules/hall-ui/public/copy-desk.html +3 -3
  21. package/modules/hall-ui/public/craft-mode.js +7 -1
  22. package/modules/hall-ui/public/deploy.html +3 -3
  23. package/modules/hall-ui/public/diagrams.html +3 -3
  24. package/modules/hall-ui/public/drachmae.html +3 -3
  25. package/modules/hall-ui/public/fleet.html +3 -3
  26. package/modules/hall-ui/public/gate.html +3 -3
  27. package/modules/hall-ui/public/goal-map.html +3 -3
  28. package/modules/hall-ui/public/goals.html +3 -3
  29. package/modules/hall-ui/public/government.html +3 -3
  30. package/modules/hall-ui/public/idea.html +3 -3
  31. package/modules/hall-ui/public/ideas.html +3 -3
  32. package/modules/hall-ui/public/index.html +3 -3
  33. package/modules/hall-ui/public/lightbulb.js +213 -0
  34. package/modules/hall-ui/public/modules.html +3 -3
  35. package/modules/hall-ui/public/primer.html +3 -3
  36. package/modules/hall-ui/public/profile.html +3 -3
  37. package/modules/hall-ui/public/project-settings.html +3 -3
  38. package/modules/hall-ui/public/ranks.html +3 -3
  39. package/modules/hall-ui/public/roadmap.html +3 -3
  40. package/modules/hall-ui/public/roster.html +3 -3
  41. package/modules/hall-ui/public/sessions.html +3 -3
  42. package/modules/hall-ui/public/settings.html +3 -3
  43. package/modules/hall-ui/public/street-card.css +18 -0
  44. package/modules/hall-ui/public/street-card.js +259 -4
  45. package/modules/hall-ui/public/street-desk.js +11 -7
  46. package/modules/hall-ui/public/street.html +7 -7
  47. package/modules/hall-ui/public/street.js +121 -14
  48. package/modules/hall-ui/public/studio.html +3 -3
  49. package/modules/hall-ui/public/style.css +1 -0
  50. package/modules/hall-ui/public/task.html +3 -3
  51. package/modules/hall-ui/public/thinking.html +3 -3
  52. package/modules/hall-ui/public/tweak-editor.html +3 -3
  53. package/modules/hall-ui/public/watch.html +3 -3
  54. package/modules/hall-ui/public/work.html +3 -3
  55. package/modules/hall-ui/records/craft-mode.md +8 -0
  56. package/modules/hall-ui/records/governor-street.md +15 -0
  57. package/modules/page-notes/db.js +34 -0
  58. package/modules/page-notes/notes.js +54 -8
  59. package/modules/page-notes/routes/page-notes.js +49 -1
  60. package/modules/page-notes/split.js +39 -8
  61. package/package-lock.json +2 -2
  62. package/package.json +1 -1
  63. package/release-notes.json +16 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/hall_street_desk.mjs +370 -5
  66. package/tests/hall_tweak_editor.mjs +1 -1
  67. package/tests/lightbulb.mjs +385 -0
  68. package/tests/page_notes_panel.mjs +3 -1
  69. package/tests/paintbrush.mjs +2 -1
  70. package/tests/privacy_matrix.mjs +689 -0
@@ -111,9 +111,9 @@
111
111
 
112
112
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
113
113
  chrome exists before the page script runs. -->
114
- <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
115
- <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
116
- <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
114
+ <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
115
+ <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
116
+ <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
117
117
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
118
118
  <script src="/builders/palette.js" defer></script>
119
119
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
@@ -435,9 +435,9 @@
435
435
 
436
436
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
437
437
  chrome exists before the page scripts run. -->
438
- <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
439
- <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
440
- <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
438
+ <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
439
+ <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
440
+ <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
441
441
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
442
442
  <script src="/builders/palette.js" defer></script>
443
443
  <!-- shared frontend toolkit (window.OTB) — loaded before consuming IIFEs. -->
@@ -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,
@@ -204,16 +204,16 @@
204
204
  </div>
205
205
  </div>
206
206
 
207
- <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
208
- <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
209
- <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
207
+ <script src="/builders/shell-trail.js?v=2026-10-04-lightbulb"></script>
208
+ <script src="/builders/craft-mode.js?v=2026-10-04-lightbulb"></script>
209
+ <script src="/builders/shell.js?v=2026-10-04-lightbulb"></script>
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>