@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.
- package/.bongos-core.json +33 -28
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +88 -79
- package/modules/government/docket.js +9 -4
- package/modules/hall-ui/public/street-card.css +18 -0
- package/modules/hall-ui/public/street-card.js +285 -5
- package/modules/hall-ui/public/street-desk.js +51 -10
- package/modules/hall-ui/public/street.css +2 -0
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/street.js +180 -18
- package/modules/hall-ui/public/street.states.json +41 -1
- package/modules/hall-ui/records/governor-street.md +28 -0
- package/modules/lifecycle/goal-category.js +15 -0
- package/modules/lifecycle/routes/project-settings.js +42 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/module-api.js +1 -1
- package/tests/goal_category.mjs +16 -0
- package/tests/hall_street_desk.mjs +541 -9
- package/tests/privacy_matrix.mjs +689 -0
|
@@ -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
|
|
53
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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'
|
|
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 === '
|
|
318
|
+
const goes = a.key === 'open';
|
|
315
319
|
if (goes && !card.href) return;
|
|
316
320
|
const field = a.field || null;
|
|
317
|
-
const
|
|
318
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
217
|
+
<script src="/builders/street.js?v=2026-10-04-record1"></script>
|
|
218
218
|
</body>
|
|
219
219
|
</html>
|