@bongos/core 1.21.53 → 1.21.55

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.
@@ -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';
@@ -653,6 +661,19 @@
653
661
  },
654
662
  };
655
663
  ACT_ROUTES.invite = { invite: function (id, inp) { return str(inp.option).trim() ? post('/access-requests/invite', { github_login: str(inp.option).trim() }) : null; } };
664
+ // The work categories, edited in place (task 1004580): the Project settings
665
+ // page's own two routes. A typed name renames (PATCH .../:id { label }) or
666
+ // adds (POST /work-categories { label }); retire and bring back flip whether
667
+ // new goals are offered it ({ active }); a move sets its place ({ sort_order },
668
+ // a whole number the desk works out from its neighbours). Nothing typed, or a
669
+ // place that is not a whole number, sends nothing.
670
+ ACT_ROUTES.work_category = {
671
+ rename: function (id, inp) { return str(inp.option).trim() ? call('PATCH', '/work-categories/' + id, { label: str(inp.option).trim() }) : null; },
672
+ retire: function (id) { return call('PATCH', '/work-categories/' + id, { active: false }); },
673
+ restore: function (id) { return call('PATCH', '/work-categories/' + id, { active: true }); },
674
+ move: function (id, inp) { return Number.isInteger(inp.sort_order) ? call('PATCH', '/work-categories/' + id, { sort_order: inp.sort_order }) : null; },
675
+ add: function (id, inp) { return str(inp.option).trim() ? post('/work-categories', { label: str(inp.option).trim() }) : null; },
676
+ };
656
677
  ACT_ROUTES.autonomy_fence = ACT_ROUTES.autobongos_fence;
657
678
  // The Planning desks (task 1004560). A blocker is decided by one of its chips,
658
679
  // whose words are the resolution note; a criterion proposal ratifies in one
@@ -779,8 +800,17 @@
779
800
  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
801
  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
802
  if (key === 'refuse') return { toast: 'Noted. ' + who + ' is not let in.', building: b, kind: 'settle', close: false };
803
+ if (key === 'record') {
804
+ var goal = Array.isArray(r.achieved_goals) && r.achieved_goals.length;
805
+ 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 };
806
+ }
782
807
  if (key === 'take' && cv && cv.subject && cv.subject.kind === 'core_pin') return { toast: ACT_TOASTS.take_core, building: b, kind: 'rise', close: false };
783
808
  if (key === 'invite') return { toast: ACT_TOASTS.invite.replace('{who}', r.github_login || 'They'), building: b, kind: 'light', close: false };
809
+ if (key === 'add' && cv && cv.subject && cv.subject.kind === 'work_category') {
810
+ var made = r.category && r.category.label ? str(r.category.label) : 'It';
811
+ return { toast: r.created === false ? made + ' was already a category.' : made + ' is a category now. New goals can pick it.', building: b, kind: 'light', close: false };
812
+ }
813
+ if (key === 'rename' && r.category && r.category.label) return { toast: 'Renamed. Goals read ' + str(r.category.label) + ' now.', building: b, kind: 'light', close: false };
784
814
  if (ACT_TOASTS[key]) return { toast: ACT_TOASTS[key].replace('{who}', who), building: b, kind: ACT_KINDS[key] || 'light', close: false };
785
815
  return { toast: '', building: null, kind: null, close: false };
786
816
  }
@@ -799,8 +829,11 @@
799
829
  decide: 'Decided. The work it held moves again.', ratify: 'Ratified. The promise is on its version.', ratify_goal: 'Ratified. It is a goal now.',
800
830
  budget: '{who} has a new monthly limit.', unsign: 'Undone. The promise is open again.',
801
831
  recheck: 'Checking it again. The answer lands at Release when it is done.',
832
+ // task 1004580: the work categories
833
+ rename: 'Renamed.', retire: 'Retired. New goals are not offered it; goals that carry it keep it.',
834
+ restore: 'Brought back. New goals can pick it again.', move: 'Moved. New goals are offered the categories in this order.',
802
835
  };
803
- var ACT_KINDS = { recheck: 'light', budget: 'light', unsign: 'settle', decide: 'rise', ratify: 'rise', ratify_goal: 'rise', check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
836
+ var ACT_KINDS = { rename: 'light', retire: 'settle', restore: 'rise', move: 'light', recheck: 'light', budget: 'light', unsign: 'settle', decide: 'rise', ratify: 'rise', ratify_goal: 'rise', check: 'light', rot: 'light', module_on: 'rise', module_off: 'settle', backup: 'light', confirm: 'settle', dispute: 'settle', fix: 'rise', remove: 'settle', keep: 'settle', signoff: 'rise', force: 'rise', switch_on: 'rise', switch_off: 'settle', allow: 'rise', disallow: 'settle', first: 'light', pause: 'settle', resume: 'rise', tier: 'light', deactivate: 'settle', give: 'light', take: 'settle' };
804
837
 
805
838
  // A refused act, in words. The server's own message wins when it has one.
806
839
  var REFUSALS = {
@@ -822,10 +855,14 @@
822
855
  bad_github_login: 'That does not look like a GitHub name.',
823
856
  backup_already_running: 'A backup is already running.',
824
857
  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.',
858
+ criterion_needs_recording: 'This one needs a recording: record it here.',
826
859
  not_proposer: 'Only the person who put it up may withdraw it.',
827
860
  votes_cast: 'Someone has voted on it, so it is the sitting\'s to decide now.',
828
861
  item_changed: 'Someone voted just now, so it stays with the sitting.',
862
+ // task 1004580: the work categories
863
+ category_not_found: 'That category is gone. The floor reads it again.',
864
+ label_required: 'Type a name first.',
865
+ bad_slug: 'Use letters or digits in the name.',
829
866
  };
830
867
  function refusal(status, body) {
831
868
  var e = body && body.error ? body.error : {};
@@ -835,6 +872,185 @@
835
872
  return e.message && e.message !== code ? String(e.message) : 'It could not be done just now.';
836
873
  }
837
874
 
875
+ // ---------- the recorded sign-off (task 1004579) ----------
876
+ // The goal page's own limits (criterion-uat-ui.js; the server screens again).
877
+ var RECORD_TYPES = ['video/mp4', 'video/webm'];
878
+ var RECORD_MAX = 100 * 1024 * 1024;
879
+ // Why an upload or a sign-off was refused, in one short sentence a person can
880
+ // act on. The server's own sentence stands in for a code not named here.
881
+ var RECORD_WHY = {
882
+ bad_type: 'Only an mp4 or webm video can go.',
883
+ empty: 'That file is empty.',
884
+ too_big: 'Over the 100 MB cap. Record a shorter pass.',
885
+ sniff_mismatch: 'That file is not the video it says it is.',
886
+ recording_not_found: 'The upload was lost on the way. Send it again.',
887
+ signer_shipped_this_work: 'You built this, so someone else signs it off.',
888
+ work_not_live: 'It is not on the live site yet. Sign off once it is.',
889
+ criterion_already_satisfied: 'Someone has already signed this off.',
890
+ criterion_not_awaiting_uat: 'Some of its work is still open.',
891
+ criterion_is_backend_only: 'This one is signed off without a recording.',
892
+ live_attestation_required: 'Say that you tried it on the live site.',
893
+ preview_readonly: 'This preview records nothing.',
894
+ };
895
+ // A refusal no new file can fix is not offered again ("try again" would only
896
+ // be refused the same way); a file the server refused asks for another file.
897
+ 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 };
898
+ var RECORD_FILE = { bad_type: 1, empty: 1, too_big: 1, sniff_mismatch: 1 };
899
+ function recordCheck(file) {
900
+ if (!file) return 'Pick the recording first.';
901
+ if (RECORD_TYPES.indexOf(str(file.type)) < 0) return RECORD_WHY.bad_type;
902
+ if (!(Number(file.size) > 0)) return RECORD_WHY.empty;
903
+ if (Number(file.size) > RECORD_MAX) return RECORD_WHY.too_big;
904
+ return null;
905
+ }
906
+ function firstSentence(s) {
907
+ var t = norm(s);
908
+ var hit = /^.*?[.!?](\s|$)/.exec(t);
909
+ return clip(hit ? hit[0] : t, 72);
910
+ }
911
+ function recordWhy(status, body) {
912
+ var e = body && body.error ? body.error : {};
913
+ var code = str(e.code || (body && body.code));
914
+ if (RECORD_WHY[code]) return RECORD_WHY[code];
915
+ if (status === 413) return RECORD_WHY.too_big;
916
+ if (!status) return 'The upload broke off. Try again.';
917
+ if (e.message && e.message !== code) return firstSentence(e.message);
918
+ if (status === 403) return 'Only a reviewer who did not build it may sign off.';
919
+ return 'It did not go through. Try again.';
920
+ }
921
+ // The two routes, the goal page's own: the raw video, then the sign-off with
922
+ // the stored name. live_attested is what the "tested it live" chip says.
923
+ function recordRoutes(id) {
924
+ var e = encodeURIComponent(str(id));
925
+ return {
926
+ upload: API + '/done-when/' + e + '/uat/recording',
927
+ signoff: function (recording) { return post('/done-when/' + e + '/uat', { kind: 'uat', recording: str(recording), live_attested: true }); },
928
+ };
929
+ }
930
+ function sizeWords(n) {
931
+ var b = Number(n) || 0;
932
+ if (b >= 1024 * 1024) return (Math.round(b / 1024 / 102.4) / 10) + ' MB';
933
+ return Math.max(1, Math.round(b / 1024)) + ' KB';
934
+ }
935
+ function fileWords(f) { return f ? clip(str(f.name) || 'the recording', 28) : ''; }
936
+ // The line in each phase: { text, pct (while sending), busy, error, chips }.
937
+ // st { file, phase: 'empty'|'picked'|'uploading'|'signing'|'failed', pct, why, retry }
938
+ function recordLine(state) {
939
+ var st = state || {};
940
+ var name = fileWords(st.file);
941
+ var pick = { key: 'pick', label: st.file ? 'pick another' : 'pick or drop it' };
942
+ 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: [] };
943
+ if (st.phase === 'signing') return { text: name + ' · sent, signing off…', pct: 100, busy: true, error: false, chips: [] };
944
+ 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]) };
945
+ if (st.file) return { text: name + ' · ' + sizeWords(st.file.size), pct: null, busy: false, error: false, chips: [pick, { key: 'send', label: 'tested it live' }] };
946
+ return { text: 'mp4 or webm, up to 100 MB', pct: null, busy: false, error: false, chips: [pick] };
947
+ }
948
+ // A file picked or dropped: checked at once, so a wrong one says why before
949
+ // anything is sent.
950
+ function recordPick(state, file) {
951
+ var st = state || {};
952
+ st.file = file || null;
953
+ st.recording = null; st.pct = 0;
954
+ var bad = recordCheck(file);
955
+ if (bad) { st.phase = 'failed'; st.why = bad; st.retry = false; st.final = false; } else { st.phase = 'picked'; st.why = ''; }
956
+ return st;
957
+ }
958
+ // Send it, then sign off. deps { upload(url, file, onPct) → {ok,status,body},
959
+ // send(req) → {ok,status,body}, change(st), progress(st) }. An upload that
960
+ // went through is kept, so trying again after a refused sign-off does not send
961
+ // the video twice. Resolves to the sign-off's answer, or the refusal.
962
+ function recordRun(id, state, deps) {
963
+ var st = state || {};
964
+ var d = deps || {};
965
+ var tell = function () { if (d.change) d.change(st); };
966
+ var fail = function (out) {
967
+ var code = str(out && out.body && out.body.error && out.body.error.code);
968
+ // a file the server refused (or one too big for the body parser) needs another file
969
+ var badFile = !!RECORD_FILE[code] || (out && out.status) === 413;
970
+ st.phase = 'failed';
971
+ st.why = recordWhy(out && out.status, out && out.body);
972
+ st.final = !!RECORD_FINAL[code];
973
+ st.retry = !st.final && !badFile;
974
+ if (code === 'recording_not_found' || badFile) st.recording = null;
975
+ tell();
976
+ return { ok: false, status: out && out.status || 0, body: out && out.body || {} };
977
+ };
978
+ var bad = recordCheck(st.file);
979
+ if (bad) { st.phase = 'failed'; st.why = bad; st.retry = false; st.final = false; tell(); return Promise.resolve({ ok: false, status: 0, body: {} }); }
980
+ var routes = recordRoutes(id);
981
+ var sent;
982
+ if (st.recording) sent = Promise.resolve({ ok: true, status: 201, body: { recording: st.recording } });
983
+ else {
984
+ st.phase = 'uploading'; st.pct = 0; st.why = '';
985
+ tell();
986
+ sent = Promise.resolve(d.upload(routes.upload, st.file, function (p) { st.pct = p; if (d.progress) d.progress(st); }));
987
+ }
988
+ return sent.then(function (up) {
989
+ if (!up || !up.ok) return fail(up);
990
+ var name = up.body && up.body.recording;
991
+ if (!name) return fail({ status: up.status, body: {} });
992
+ st.recording = str(name);
993
+ st.phase = 'signing'; st.pct = 100;
994
+ tell();
995
+ return Promise.resolve(d.send(routes.signoff(st.recording))).then(function (out) {
996
+ if (!out || !out.ok) return fail(out);
997
+ st.phase = 'done';
998
+ return out;
999
+ });
1000
+ });
1001
+ }
1002
+ // The raw upload with its progress (fetch cannot report an upload's progress).
1003
+ function uploadXhr(X, url, file, onPct) {
1004
+ return new Promise(function (res) {
1005
+ var x;
1006
+ try { x = new X(); } catch (e) { res({ ok: false, status: 0, body: {} }); return; }
1007
+ x.open('POST', url, true);
1008
+ x.withCredentials = true;
1009
+ x.setRequestHeader('Content-Type', str(file && file.type));
1010
+ x.setRequestHeader('Accept', 'application/json');
1011
+ 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))); };
1012
+ x.onload = function () {
1013
+ var b = {};
1014
+ try { b = JSON.parse(x.responseText || '{}') || {}; } catch (e) { b = {}; }
1015
+ res({ ok: x.status >= 200 && x.status < 300, status: x.status, body: b });
1016
+ };
1017
+ x.onerror = function () { res({ ok: false, status: 0, body: {} }); };
1018
+ x.onabort = x.onerror;
1019
+ x.send(file);
1020
+ });
1021
+ }
1022
+ // What the goal page showed above its form, from GET /done-when/:id/uat:
1023
+ // the three checks, short. { facts: up to three, for the desk row; lines: for
1024
+ // the card's "more" }.
1025
+ function uatChecks(view) {
1026
+ var v = view || {};
1027
+ var c = v.checks || {};
1028
+ var facts = [], lines = [];
1029
+ facts.push(c.code ? 'code done' : 'code still open');
1030
+ lines.push(c.code ? 'code · every linked task is done' : 'code · some linked work is still open');
1031
+ var live = c.live;
1032
+ if (live) {
1033
+ if (!live.available) { facts.push('live: your word'); lines.push('live · this site cannot tell, so the sign-off says you tried it live'); }
1034
+ else if (live.live) { facts.push('running live'); lines.push('live · running on the live site'); }
1035
+ else { facts.push('not live yet'); lines.push('live · shipped but not on the live site yet'); }
1036
+ }
1037
+ var s = c.uat || null;
1038
+ var who = function (x) { return str(x.signed_off_by_login || 'someone') + (dayOf(x.created_at) ? ' on ' + dayOf(x.created_at) : ''); };
1039
+ if (s) { facts.push('signed off'); lines.push('sign-off · by ' + who(s)); }
1040
+ 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'); }
1041
+ else { facts.push('not signed off yet'); lines.push('sign-off · not yet'); }
1042
+ return { facts: facts.slice(0, 3), lines: lines };
1043
+ }
1044
+ // The checks under the card's "more", once read (or "reading…" until then).
1045
+ function withChecks(cv, view, failed) {
1046
+ if (!cv) return cv;
1047
+ var mo = cv.more || { text: [], who: '', when: '', history: [] };
1048
+ var words = view ? uatChecks(view).lines : [failed ? 'the checks could not be read just now' : 'reading the checks…'];
1049
+ 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 }; }));
1050
+ cv.more = mo;
1051
+ return cv;
1052
+ }
1053
+
838
1054
  // ---------- the DOM ----------
839
1055
  function el(doc, tag, cls, text) {
840
1056
  var n = doc.createElement(tag);
@@ -995,7 +1211,11 @@
995
1211
  var box = el(doc, 'div', 'mcard-chips');
996
1212
  box.setAttribute('data-chips', open);
997
1213
  box.appendChild(el(doc, 'span', 'mcard-chips__ask', ch.ask));
998
- if (ch.field) {
1214
+ if (ch.file && on.record) {
1215
+ var strip = recordStrip(doc, on.record.state(), on.record, 'card', u.busy);
1216
+ if (on.record.painted) on.record.painted(strip);
1217
+ box.appendChild(strip.node);
1218
+ } else if (ch.field) {
999
1219
  var input = el(doc, 'input', 'mcard-chips__field');
1000
1220
  input.setAttribute('id', 'mcardField');
1001
1221
  input.setAttribute('type', 'text');
@@ -1011,6 +1231,63 @@
1011
1231
  host.appendChild(box);
1012
1232
  }
1013
1233
 
1234
+ // The recording's one line (task 1004579), for the desk row ('desk') and the
1235
+ // card ('card'): what the line says, a thin bar while it is sent, and its
1236
+ // chips. 'pick' is a label wrapping the file input, so a tap opens the file
1237
+ // chooser and the keyboard reaches it; a drop on the line takes the file too.
1238
+ // on { pick(file), send() }. Returns { node, paint(st) }: paint() moves the
1239
+ // line and the bar in place, so progress never redraws the desk.
1240
+ function recordStrip(doc, state, on, kind, busy) {
1241
+ var desk = kind === 'desk';
1242
+ var chipCls = desk ? 'desk-chip desk-chip--choice' : 'mcard-chip';
1243
+ var ln = recordLine(state);
1244
+ var box = el(doc, 'span', 'rec' + (ln.busy ? ' rec--busy' : '') + (ln.error ? ' rec--error' : ''));
1245
+ box.setAttribute('data-record', ln.error ? 'failed' : (state && state.phase) || 'empty');
1246
+ var line = el(doc, 'span', 'rec__line', ln.text);
1247
+ line.setAttribute('role', ln.error ? 'alert' : 'status');
1248
+ box.appendChild(line);
1249
+ var bar = el(doc, 'span', 'rec__bar');
1250
+ var fill = el(doc, 'span', 'rec__fill');
1251
+ bar.setAttribute('aria-hidden', 'true');
1252
+ bar.appendChild(fill);
1253
+ if (ln.pct == null) bar.hidden = true;
1254
+ else fill.style.setProperty('width', ln.pct + '%');
1255
+ box.appendChild(bar);
1256
+ ln.chips.forEach(function (c) {
1257
+ if (c.key === 'pick') {
1258
+ var lab = el(doc, 'label', chipCls + ' rec__pick');
1259
+ lab.setAttribute('data-act', 'record-pick');
1260
+ lab.appendChild(el(doc, 'span', '', c.label));
1261
+ var input = el(doc, 'input', 'rec__file');
1262
+ input.setAttribute('type', 'file');
1263
+ input.setAttribute('accept', RECORD_TYPES.join(','));
1264
+ input.setAttribute('aria-label', c.label + ': the recording, mp4 or webm');
1265
+ if (busy) input.setAttribute('disabled', '');
1266
+ input.addEventListener('change', function () { var f = input.files && input.files[0]; if (f) on.pick(f); });
1267
+ lab.appendChild(input);
1268
+ box.appendChild(lab);
1269
+ } else {
1270
+ box.appendChild(button(doc, chipCls + (c.key === 'send' && !ln.error ? ' rec__go' : ''), c.label, 'record-' + c.key, function () { on.send(); }, busy));
1271
+ }
1272
+ });
1273
+ var stop = function (e) { if (e && e.preventDefault) e.preventDefault(); if (e && e.stopPropagation) e.stopPropagation(); };
1274
+ box.addEventListener('dragover', function (e) { stop(e); box.className += /\brec--over\b/.test(box.className) ? '' : ' rec--over'; });
1275
+ box.addEventListener('dragleave', function () { box.className = box.className.replace(/\s*rec--over\b/g, ''); });
1276
+ box.addEventListener('drop', function (e) {
1277
+ stop(e);
1278
+ var f = e && e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files[0] : null;
1279
+ if (f && !busy) on.pick(f);
1280
+ });
1281
+ return {
1282
+ node: box,
1283
+ paint: function (st) {
1284
+ var now = recordLine(st);
1285
+ line.textContent = now.text;
1286
+ if (now.pct != null) { bar.hidden = false; fill.style.setProperty('width', now.pct + '%'); }
1287
+ },
1288
+ };
1289
+ }
1290
+
1014
1291
  // "more": the card grows in place with the detail (task 1004566). The toggle
1015
1292
  // says whether it is open (aria-expanded); the queue's other pieces of work
1016
1293
  // are listed there, each opening in this same card.
@@ -1078,6 +1355,9 @@
1078
1355
  API: API, SECTIONS: SECTIONS, needsRoom: needsRoom, itemIdOf: itemIdOf, subjectKind: subjectKind,
1079
1356
  model: model, actsCard: actsCard, moreOf: moreOf, request: request, orderRequest: orderRequest, check: check, answer: answer, refusal: refusal, render: render, pager: pager,
1080
1357
  memberWords: memberWords, windowWords: windowWords, LINE: LINE, compact: compact,
1358
+ // the recorded sign-off from the card (task 1004579)
1359
+ RECORD_TYPES: RECORD_TYPES, RECORD_MAX: RECORD_MAX, recordCheck: recordCheck, recordWhy: recordWhy, recordRoutes: recordRoutes,
1360
+ recordLine: recordLine, recordPick: recordPick, recordRun: recordRun, recordStrip: recordStrip, uploadXhr: uploadXhr, uatChecks: uatChecks, withChecks: withChecks,
1081
1361
  // the wording change, for a drafted amendment's struck and inserted text (task 1004530)
1082
1362
  change: changeOf,
1083
1363
  };
@@ -171,9 +171,11 @@
171
171
  withdraw: 'withdraw',
172
172
  // task 1004566: a module version flagged by its security check, checked again
173
173
  recheck: 'check again',
174
+ // task 1004580: a work category renamed, retired, brought back, or a new one added
175
+ rename: 'rename', retire: 'retire', restore: 'bring back', add: 'add',
174
176
  });
175
177
  // 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' });
178
+ 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', rename: 'new name', add: 'its name' });
177
179
  // How a floor counts rows nobody is waiting on (desk rows), by its desk.
178
180
  const DESK_NOUNS = Object.freeze({
179
181
  'people.builders': 'builders', 'people.ranks': 'ranks', 'disputes.confirmed': 'to fix',
@@ -287,7 +289,9 @@
287
289
  // 1004559). An act with options asks first, with its options as one-tap chips;
288
290
  // `pick: 'open_goals'` offers the open goals the street already reads; an act
289
291
  // 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).
292
+ // (a sign-off that needs a recording) asks for the recording in place: its
293
+ // choice is the file line (`file: true`), drawn by GovMatterCard.recordStrip
294
+ // (task 1004579), never a link to the goal's page.
291
295
  function actedRow(acts, card, ctx) {
292
296
  const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || 'goal ' + g.id)]));
293
297
  // an act at a building a sitting decides is filed as its matter (task 1004561)
@@ -297,7 +301,7 @@
297
301
  // 'open' leaves for an older page: a row that can act here keeps its detail
298
302
  // in place (tap the name) and drops the link; only a row with no act of its
299
303
  // own may link out (owner standard, task 1004562)
300
- const inPlace = acts.some((a) => a && a.key !== 'open' && a.key !== 'record');
304
+ const inPlace = acts.some((a) => a && a.key !== 'open');
301
305
  acts.forEach((a) => {
302
306
  if (a.key === 'open' && inPlace) return;
303
307
  // the core's 'take' is its own word ('take it'), not the rank's 'take back'
@@ -311,11 +315,13 @@
311
315
  if (!options.length) return;
312
316
  }
313
317
  options = options.slice(0, LIMITS.chips);
314
- const goes = a.key === 'record' || a.key === 'open';
318
+ const goes = a.key === 'open';
315
319
  if (goes && !card.href) return;
316
320
  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 };
321
+ const file = a.key === 'record';
322
+ const ask = options.length > 0 || !!field || file;
323
+ if (file) choices[a.key] = { for: a.key, ask: ASKS[word], file: true, options: [] };
324
+ 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
325
  actions.push({ key: a.key, label: ACT_WORDS[word], tone: actions.length ? 'quiet' : 'primary', go: goes ? card.href : null, ask: ask });
320
326
  });
321
327
  const first = actions.find((a) => a.ask);
@@ -326,6 +332,9 @@
326
332
 
327
333
  // A desk row (task 1004559): something on this floor the viewer may act on that
328
334
  // no one is waiting on, read by the module that owns its route.
335
+ // Task 1004580: a row that `stay`s is edited in place, not decided, so an act
336
+ // leaves it on the desk; a rename's field starts from its whole name; a row
337
+ // with a `seq` (a place in an order) is moved from its open detail.
329
338
  function deskRowOf(d, ctx) {
330
339
  const titles = new Map((ctx.goals || []).map((g) => [str(g.id), str(g.title || '')]));
331
340
  const hit = /^goal (\d+)$/.exec(str(d.name));
@@ -339,13 +348,45 @@
339
348
  if (age) add('since ' + age);
340
349
  const ref = d.ref || {};
341
350
  const card = C && C.actsCard ? C.actsCard({ key: d.id, kind: ref.type, id: ref.id, who: full, title: full, building: d.building, href: d.href }) : null;
342
- return Object.assign(actedRow(d.acts || [], card || { href: null }, ctx), {
351
+ const acted = actedRow(d.acts || [], card || { href: null }, ctx);
352
+ if (acted.choices.rename && acted.choices.rename.field) acted.choices.rename.value = full;
353
+ return Object.assign(acted, {
343
354
  key: str(d.id), matter: str(d.id), ini: initials(name), name: name, fact: fit(d.fact, LIMITS.fact), facts: facts,
344
355
  needs: d.needs === true, source: d.desk, opened: d.opened_at || null, desk: true, tool: d.tool === true, over: d.over || null,
345
356
  order: d.set || null, goalId: d.set && ref.id != null ? str(ref.id) : null, full: full,
346
357
  pips: d.pips ? Array.from({ length: Math.min(d.pips.of, LIMITS.pips) }, (_, i) => ({ key: 'p' + i, on: i < d.pips.on })) : null,
358
+ stay: d.stay === true, seq: Number.isInteger(d.seq) ? d.seq : null,
347
359
  });
348
360
  }
361
+ // The rows of one floor that hold a place (`seq`), in their order: each learns
362
+ // whether it can move up or down (task 1004580).
363
+ function placed(rows) {
364
+ const set = rows.filter((r) => r.seq != null);
365
+ set.forEach((r, i) => { r.moves = { up: i > 0, down: i < set.length - 1 }; });
366
+ return rows;
367
+ }
368
+ // The writes that move one placed row a place up (-1) or down (+1): one, when a
369
+ // whole number sits between its new neighbours' places; else every place
370
+ // numbered again in tens, writing only those that change. [{ id, sort_order }],
371
+ // [] at an end. Pure (task 1004580).
372
+ function placeMoves(rows, key, by) {
373
+ const set = (rows || []).filter((r) => r && r.seq != null && r.card && r.card.subject);
374
+ const i = set.findIndex((r) => r.key === key);
375
+ const j = i + by;
376
+ if (i < 0 || (by !== -1 && by !== 1) || j < 0 || j >= set.length) return [];
377
+ const order = set.slice();
378
+ order.splice(i, 1);
379
+ order.splice(j, 0, set[i]);
380
+ const lo = j > 0 ? order[j - 1].seq : null;
381
+ const hi = j < order.length - 1 ? order[j + 1].seq : null;
382
+ let v = null;
383
+ if (lo == null) v = hi - 10;
384
+ else if (hi == null) v = lo + 10;
385
+ else if (hi - lo >= 2) v = Math.floor((lo + hi) / 2);
386
+ if (v != null) return [{ id: str(set[i].card.subject.id), sort_order: v }];
387
+ return order.map((r, k) => ({ id: str(r.card.subject.id), sort_order: (k + 1) * 10, was: r.seq }))
388
+ .filter((p) => p.sort_order !== p.was).map((p) => ({ id: p.id, sort_order: p.sort_order }));
389
+ }
349
390
  // The desk rows of one floor: those of the desk its PLAN entry names.
350
391
  function deskRows(key, floor, docket, ctx) {
351
392
  const plan = I && I.PLAN[key];
@@ -360,7 +401,7 @@
360
401
  function rowsFor(key, floor, docket, ctx) {
361
402
  const c = Object.assign({ docket: docket }, ctx || {});
362
403
  const done = c.done || {};
363
- const desked = deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length);
404
+ const desked = placed(deskRows(key, floor, docket).filter((d) => !done[str(d.id)]).map((d) => deskRowOf(d, c)).filter((r) => r.actions.length));
364
405
  // a desk row that stands in for a source (the core update's check-then-take)
365
406
  // replaces that source's matter rows on this floor
366
407
  const over = desked.map((r) => r.over).filter(Boolean);
@@ -568,7 +609,7 @@
568
609
  if (r.actions.some((a) => a.ask) && !r.chips) bad('a reason without chips', r.name);
569
610
  Object.keys(r.choices || {}).forEach((k) => {
570
611
  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));
612
+ 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
613
  });
573
614
  if (r.actions.some((a) => a.ask && !(r.choices || {})[a.key])) bad('an ask without its own chips', r.name);
574
615
  if (r.pips && r.pips.length > LIMITS.pips) bad('more than twelve pips', r.pips.length);
@@ -588,7 +629,7 @@
588
629
 
589
630
  const api = {
590
631
  LIMITS: LIMITS, ACT_WORDS: ACT_WORDS, ASKS: ASKS, DESK_NOUNS: DESK_NOUNS, CHIPS: CHIPS, NOUNS: NOUNS, CLEAR: CLEAR,
591
- fit: fit, ageWord: ageWord, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
632
+ fit: fit, ageWord: ageWord, placeMoves: placeMoves, floorMatters: floorMatters, firstNeeds: firstNeeds, rowsFor: rowsFor, cardFor: cardFor, deskModel: deskModel, breaks: breaks, todayPips: todayPips, moved: moved,
592
633
  };
593
634
  root.GovDesk = api;
594
635
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -343,6 +343,8 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
343
343
  .desk-save__line { font-size: 13.5px; color: var(--ink-soft); margin-right: auto; }
344
344
  .desk-facts, .desk-choices { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 10px 0 2px 52px; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
345
345
  .desk-fact { font-size: 13px; color: var(--ink-soft); background: var(--street-wash); border-radius: 6px; padding: 6px 10px; white-space: nowrap; }
346
+ /* a place in an order moves from the open detail: its two chips wrap together (task 1004580) */
347
+ .desk-moves { display: inline-flex; gap: 8px; flex-wrap: wrap; }
346
348
  .desk-choices__ask { font-size: 12.5px; color: var(--ink-faint); margin-right: 4px; }
347
349
  .desk-quiet { min-height: 168px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border-top: 1px solid var(--street-rule); text-align: center; animation: street-enter 300ms cubic-bezier(.16,1,.3,1) both; }
348
350
  .desk-quiet__tick { display: block; fill: none; stroke: var(--ink); stroke-width: 1.4; stroke-linecap: round; animation: desk-pop 420ms cubic-bezier(.2,.7,.3,1) both; }
@@ -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>