@bongos/core 1.24.19 → 1.24.21
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 +46 -41
- package/docs/copy-inventory.md +28 -22
- package/docs/copy-registry.json +87 -23
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +698 -698
- package/modules/ai-studio/studio.js +1 -1
- package/modules/autonomy/governor-docket.js +3 -0
- package/modules/government/docket.js +51 -0
- package/modules/government/treasury.js +2 -0
- package/modules/hall-ui/public/ai-studio-lib.js +74 -9
- package/modules/hall-ui/public/ai-studio.css +12 -0
- package/modules/hall-ui/public/ai-studio.js +37 -1
- package/modules/hall-ui/public/city-decide.js +5 -2
- package/modules/hall-ui/public/city.css +1 -0
- package/modules/hall-ui/public/gov-living.js +7 -3
- package/modules/hall-ui/public/roster.css +43 -0
- package/modules/hall-ui/public/street-deck.css +3 -0
- package/modules/hall-ui/public/street-deck.js +5 -1
- package/modules/hall-ui/records/builders-roster.md +9 -0
- package/modules/ideas/governor-docket.js +3 -0
- package/modules/lifecycle/governor-docket.js +26 -0
- package/modules/onboarding/routes/access-requests.js +2 -0
- package/modules/security/routes/reports.js +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/module-rechecks.js +3 -0
- package/src/bongos/routes/core-update.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/city_card_context.mjs +139 -0
- package/tests/government_treasury.mjs +1 -1
- package/tests/governor_docket.mjs +3 -1
- package/tests/hall_ai_studio.mjs +44 -1
- package/tests/hall_gov_real_site.mjs +1 -1
- package/tests/hall_roster_world.mjs +10 -0
- package/tests/hall_street_depth.mjs +7 -1
|
@@ -100,7 +100,7 @@ function buildContext({ branding, projectDoc, designDoc }) {
|
|
|
100
100
|
const palette = branding && branding.theme && branding.theme.colors ? JSON.stringify(branding.theme.colors).slice(0, 800) : '';
|
|
101
101
|
const parts = [
|
|
102
102
|
`You are the art director's assistant for ${id.productName || 'this project'}${id.worldName && id.worldName !== id.productName ? `, set in the world of ${id.worldName}` : ''}.`,
|
|
103
|
-
'An artist on the project has sent a sketch or reference images with a request. Answer as a thoughtful collaborator: say what you see, what works, and how to take it further, in plain words.',
|
|
103
|
+
'An artist on the project has sent a sketch or reference images with a request. Answer as a thoughtful collaborator: say what you see, what works, and how to take it further, in plain words. Keep it short and friendly for a visual artist: a few short paragraphs or a brief list, no code, no markup symbols, and no technical file formats unless they asked for one.',
|
|
104
104
|
'Keep everything you suggest consistent with the project as described below.',
|
|
105
105
|
];
|
|
106
106
|
if (palette) parts.push(`## Brand colours\n${palette}`);
|
|
@@ -58,6 +58,9 @@ function fenceDocketMatters(project, mayManage) {
|
|
|
58
58
|
tally: null,
|
|
59
59
|
href: '/watch',
|
|
60
60
|
ref: { type: 'autonomy_fence', id: 'project' },
|
|
61
|
+
context: project.enabled === false
|
|
62
|
+
? 'Unattended building lets AI runners claim and build tasks on their own, with nobody at the keyboard. It is switched off, so no runner builds anything until someone turns it on.'
|
|
63
|
+
: 'Unattended building lets AI runners claim and build tasks on their own. It is on, but no goal is on its allowed list, so the runners have nothing they may work on yet.',
|
|
61
64
|
...(detail ? { detail } : {}),
|
|
62
65
|
// The floor desk's acts (task 1004559), only for the switch's own atom.
|
|
63
66
|
...(mayManage ? { acts: project.enabled === false ? [{ key: 'switch_on' }] : [{ key: 'allow', pick: 'open_goals', except: [] }, { key: 'switch_off', options: OFF_REASONS }] } : {}),
|
|
@@ -165,12 +165,44 @@ function tally(t) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// WHAT A CARD IS ABOUT (task 1005013). A matter may carry `context`: a few plain
|
|
169
|
+
// sentences from the module that owns it saying what kind of thing this is, what
|
|
170
|
+
// it belongs to (a goal, a task, a module, by name), why it waits and what
|
|
171
|
+
// answering does — so a card is never only a bare title. A matter that brings
|
|
172
|
+
// none gets its source's sentence below; every source the design names has one.
|
|
173
|
+
// It may also carry `question`: the plain question a card asks when no agent
|
|
174
|
+
// stored an ask, in place of the raw title.
|
|
175
|
+
const SOURCE_CONTEXT = Object.freeze({
|
|
176
|
+
board: 'A sitting of the board. Its members vote, and the result is decided by the rule in the charter.',
|
|
177
|
+
blockers: 'A blocker: work that cannot go on until a person decides something. Deciding it releases the work that waits on it.',
|
|
178
|
+
access_requests: 'Someone asked to join this project as a builder. Admitting them lets them sign in and claim work; nobody joins until someone with the right says yes.',
|
|
179
|
+
goal_join_requests: 'Someone asked to join a goal team. Members of a goal may claim its tasks.',
|
|
180
|
+
override_requests: 'A request to let finished work through although it failed its automatic check or grade. Approving it treats the work as passed.',
|
|
181
|
+
removal_proposals: 'A later change deleted work that had already shipped. A person confirms whether that work is really gone, or keeps it counted.',
|
|
182
|
+
redteam_reports: 'A security report: someone says they found a flaw in the project itself. Confirming it means the flaw is real and needs a fix; disputing it means it is not.',
|
|
183
|
+
criteria_uat: 'A done-when promise of a goal. The work for it has shipped; a person checks it on the live site and signs it off, which closes the promise.',
|
|
184
|
+
core_update: 'A newer version of Bongos, the software this project runs on, is available. Taking it updates the project to that version.',
|
|
185
|
+
autobongos_fence: 'The switch for unattended building: whether AI runners may build on their own, and which goals they may work on.',
|
|
186
|
+
module_rechecks: 'A module (an add-on to the project) is being checked again for safety. One that failed waits for someone to run the check again.',
|
|
187
|
+
treasury_spending: 'A goal has used up its money for the month and asks to borrow from the reserve so its work can go on.',
|
|
188
|
+
});
|
|
189
|
+
const MAX_CONTEXT = 600;
|
|
190
|
+
const MAX_QUESTION = 160;
|
|
191
|
+
function clip(v, max) {
|
|
192
|
+
if (typeof v !== 'string') return null;
|
|
193
|
+
const t = v.replace(/\s+/g, ' ').trim();
|
|
194
|
+
if (!t) return null;
|
|
195
|
+
return t.length <= max ? t : t.slice(0, max - 1).replace(/\s+\S*$/, '') + '…';
|
|
196
|
+
}
|
|
197
|
+
|
|
168
198
|
// One matter as a contributor handed it → the docket's one shape. Pure. Unknown
|
|
169
199
|
// rule → null (the card says so), unknown state → 'moving' (never a false
|
|
170
200
|
// "needs you"), unknown building → the source's own building.
|
|
171
201
|
function normalizeMatter(raw, { source, building }) {
|
|
172
202
|
if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
|
|
173
203
|
const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
|
|
204
|
+
const context = clip(raw.context, MAX_CONTEXT) || SOURCE_CONTEXT[source] || null;
|
|
205
|
+
const question = clip(raw.question, MAX_QUESTION);
|
|
174
206
|
return {
|
|
175
207
|
id: `${source}:${raw.id}`,
|
|
176
208
|
source,
|
|
@@ -186,6 +218,8 @@ function normalizeMatter(raw, { source, building }) {
|
|
|
186
218
|
tally: tally(raw.tally),
|
|
187
219
|
href: raw.href ? String(raw.href) : null,
|
|
188
220
|
ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
|
|
221
|
+
...(context ? { context } : {}),
|
|
222
|
+
...(question ? { question } : {}),
|
|
189
223
|
...actsField(raw.acts),
|
|
190
224
|
...detailField(raw.detail),
|
|
191
225
|
};
|
|
@@ -364,6 +398,13 @@ const PASS_RULE_NEEDS = Object.freeze({
|
|
|
364
398
|
majority: 'needs more than half',
|
|
365
399
|
unanimous: 'needs everyone who votes',
|
|
366
400
|
});
|
|
401
|
+
// the same rule as the last sentence of a sitting's context (task 1005013)
|
|
402
|
+
const PASS_RULE_SENTENCE = Object.freeze({
|
|
403
|
+
first_ratifier: 'One yes decides it.',
|
|
404
|
+
consent: 'It passes unless someone objects.',
|
|
405
|
+
majority: 'It needs a yes from more than half the members.',
|
|
406
|
+
unanimous: 'It needs a yes from everyone who votes.',
|
|
407
|
+
});
|
|
367
408
|
|
|
368
409
|
// One open or recently closed sitting, for one viewer. Pure.
|
|
369
410
|
//
|
|
@@ -402,6 +443,14 @@ function boardMatter(item, { viewerId, members }) {
|
|
|
402
443
|
: item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
|
|
403
444
|
: item.subject_type === 'genesis_stage' ? 'charter · founding'
|
|
404
445
|
: 'planning · idea to ratify';
|
|
446
|
+
const passWords = PASS_RULE_SENTENCE[constitution.pass_rule] ? ' ' + PASS_RULE_SENTENCE[constitution.pass_rule] : '';
|
|
447
|
+
const context = (matterArea
|
|
448
|
+
? `A decision put to a vote at ${building}: the charter says matters here are decided by a sitting, not by one person acting alone. A yes carries out what it proposes.`
|
|
449
|
+
: item.subject_type === 'constitutional_amendment'
|
|
450
|
+
? 'A proposed change to the charter, the rules for who decides what in this project. If it passes, the new rule takes effect at once.'
|
|
451
|
+
: item.subject_type === 'genesis_stage'
|
|
452
|
+
? 'A founding stage of this project has finished its work. Closing it moves the project on to the next stage.'
|
|
453
|
+
: 'A full idea someone wrote up, put to the board. If it passes, it becomes part of the plan: a new goal or a done-when promise of one.') + passWords;
|
|
405
454
|
const forN = votes.filter((v) => v.direction === 'yes').length;
|
|
406
455
|
const againstN = votes.filter((v) => v.direction === 'no').length;
|
|
407
456
|
const castIds = new Set(votes.map((v) => String(v.voter_id)));
|
|
@@ -433,6 +482,7 @@ function boardMatter(item, { viewerId, members }) {
|
|
|
433
482
|
tally: { for: forN, against: againstN, open: isOpen ? open : 0, needs: PASS_RULE_NEEDS[constitution.pass_rule] || null },
|
|
434
483
|
href: `/board-room?item=${encodeURIComponent(item.id)}`,
|
|
435
484
|
ref: { type: 'board_item', id: item.id },
|
|
485
|
+
context,
|
|
436
486
|
};
|
|
437
487
|
}
|
|
438
488
|
|
|
@@ -962,6 +1012,7 @@ module.exports = {
|
|
|
962
1012
|
normalizeDeskRow,
|
|
963
1013
|
loadGovernment,
|
|
964
1014
|
normalizeMatter,
|
|
1015
|
+
SOURCE_CONTEXT,
|
|
965
1016
|
gateAllows,
|
|
966
1017
|
homeLine,
|
|
967
1018
|
boardMatter,
|
|
@@ -227,6 +227,8 @@ function borrowMatter(borrow, { title, canAnswer = false } = {}) {
|
|
|
227
227
|
deadline: null,
|
|
228
228
|
href: `/goal/${b.goal_id}`,
|
|
229
229
|
ref: { type: 'pot_borrow', id: b.id },
|
|
230
|
+
question: `Lend ${name} ${usd(b.amount_usd)} from the reserve?`,
|
|
231
|
+
context: `Each goal gets a monthly pot of money for its AI work. ${name} has spent ${usd(b.spent_usd)} of its ${usd(b.budget_usd)} this month, so its work stops until it gets more. Lending from the reserve lets it go on; saying no pauses it until next month.`,
|
|
230
232
|
detail: `${name} has spent ${usd(b.spent_usd)} of its ${usd(b.budget_usd)} pot this month and asks the reserve for ${usd(b.amount_usd)}.`,
|
|
231
233
|
...(canAnswer ? { acts: [{ key: ACT_KEY, options: [
|
|
232
234
|
{ key: 'lend', label: `Lend ${usd(b.amount_usd)}` }, { key: 'half', label: `Lend ${usd(half)}` }, { key: 'no', label: 'Say no' },
|
|
@@ -98,6 +98,48 @@
|
|
|
98
98
|
}));
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// What a request will probably cost, from this builder's own finished ones
|
|
102
|
+
// paid the same way: the median of what we billed (ours) or of the cost the
|
|
103
|
+
// provider reported (their own key). max is the most one can ever cost on
|
|
104
|
+
// our key (max_cost_micro).
|
|
105
|
+
// typical is null when there is no history yet: the page then says so
|
|
106
|
+
// instead of inventing a number.
|
|
107
|
+
function estimate(requests, maxMicro, ours) {
|
|
108
|
+
const costs = (requests || [])
|
|
109
|
+
.filter((r) => r && r.status === 'done' && (r.key_source === 'ours') === !!ours)
|
|
110
|
+
.map((r) => (ours ? r.price_micro : r.provider_cost_micro))
|
|
111
|
+
.filter((n) => isMicro(n) && n > 0)
|
|
112
|
+
.sort((a, b) => a - b);
|
|
113
|
+
const max = isMicro(maxMicro) ? maxMicro : null;
|
|
114
|
+
if (!costs.length) return { typical: null, max, basis: 0 };
|
|
115
|
+
const mid = Math.floor(costs.length / 2);
|
|
116
|
+
const median = costs.length % 2 ? costs[mid] : Math.round((costs[mid - 1] + costs[mid]) / 2);
|
|
117
|
+
return { typical: max === null ? median : Math.min(median, max), max, basis: costs.length };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Google reports no price on a builder's own Gemini key, so the page says
|
|
121
|
+
// "about" and works it out from Google's published rates: a written reply
|
|
122
|
+
// (gemini-2.5-pro, a few thousand tokens in and about a thousand out) and
|
|
123
|
+
// each picture (gemini-2.5-flash-image, about $0.039 each). The two numbers
|
|
124
|
+
// are named here and spelled nowhere else; update them if Google's change.
|
|
125
|
+
const GEMINI_REPLY_MICRO = 25000;
|
|
126
|
+
const GEMINI_IMAGE_MICRO = 39000;
|
|
127
|
+
function geminiEstimate(imageCount) {
|
|
128
|
+
const n = Math.max(1, Math.floor(Number(imageCount) || 0));
|
|
129
|
+
return GEMINI_REPLY_MICRO + n * GEMINI_IMAGE_MICRO;
|
|
130
|
+
}
|
|
131
|
+
const outputCount = (r) => ((r && r.files) || []).filter((f) => f.role === 'output').length;
|
|
132
|
+
|
|
133
|
+
// The headline cost on the result: one sentence an artist can read at a glance.
|
|
134
|
+
function costHeadline(req) {
|
|
135
|
+
const r = req || {};
|
|
136
|
+
if (r.status !== 'done') return '';
|
|
137
|
+
if (r.key_source === 'ours' && isMicro(r.price_micro)) return `This cost ${usd(r.price_micro)}, taken from your balance.`;
|
|
138
|
+
if (r.key_source === 'own' && isMicro(r.provider_cost_micro)) return `This cost ${usd(r.provider_cost_micro)}, billed to your own key.`;
|
|
139
|
+
if (r.key_source === 'own') return `This cost about ${usd(geminiEstimate(outputCount(r)))} on your Gemini key.`;
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
|
|
101
143
|
// The prompt as one quoted line for step 3's summary row.
|
|
102
144
|
function quoteLine(prompt, max) {
|
|
103
145
|
const t = String(prompt || '').replace(/\s+/g, ' ').trim();
|
|
@@ -189,8 +231,8 @@
|
|
|
189
231
|
}
|
|
190
232
|
if (r.key_source === 'own') {
|
|
191
233
|
return {
|
|
192
|
-
rows: [['Paid with', 'your own Gemini key'], ['Cost',
|
|
193
|
-
note: "Google doesn't tell us the price on your own key, so
|
|
234
|
+
rows: [['Paid with', 'your own Gemini key'], ['Cost', `about ${usd(geminiEstimate(outputCount(r)))}`]],
|
|
235
|
+
note: "Google doesn't tell us the exact price on your own key, so this is an estimate. Your Google bill has the real figure. We charged nothing.",
|
|
194
236
|
};
|
|
195
237
|
}
|
|
196
238
|
return { rows: [], note: '' };
|
|
@@ -238,15 +280,38 @@
|
|
|
238
280
|
}
|
|
239
281
|
}
|
|
240
282
|
|
|
241
|
-
// The model's reply
|
|
242
|
-
//
|
|
283
|
+
// The model's reply read the way an artist wants to see it: headings as
|
|
284
|
+
// headings, bullets as a list, **bold** and `names` kept, divider dashes
|
|
285
|
+
// dropped, and any code listing folded away behind one line instead of
|
|
286
|
+
// pouring down the page. Every character of text goes through the hall's ONE
|
|
287
|
+
// escaper, handed in (window.OTB.escapeHtml in the page), before any tag is
|
|
288
|
+
// added.
|
|
243
289
|
function replyHtml(md, escape) {
|
|
244
290
|
const text = String(md || '').replace(/\r\n/g, '\n').trim();
|
|
245
291
|
if (!text || typeof escape !== 'function') return '';
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
292
|
+
const inline = (s) => escape(s).replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>').replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
293
|
+
const out = [];
|
|
294
|
+
let para = [];
|
|
295
|
+
let list = [];
|
|
296
|
+
let code = null;
|
|
297
|
+
const flushPara = () => { if (para.length) out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; };
|
|
298
|
+
const flushList = () => { if (list.length) out.push(`<ul>${list.map((t) => `<li>${inline(t)}</li>`).join('')}</ul>`); list = []; };
|
|
299
|
+
for (const raw of text.split('\n')) {
|
|
300
|
+
const line = raw.trimEnd();
|
|
301
|
+
if (/^\s*```/.test(line)) {
|
|
302
|
+
if (code) { out.push(`<details class="ais-code"><summary>Code from the answer</summary><pre>${escape(code.join('\n'))}</pre></details>`); code = null; } else { flushPara(); flushList(); code = []; }
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (code) { code.push(raw); continue; }
|
|
306
|
+
const head = /^#{1,6}\s+(.+)$/.exec(line);
|
|
307
|
+
const item = /^\s*[*•-]\s+(.+)$/.exec(line);
|
|
308
|
+
if (!line.trim()) { flushPara(); flushList(); } else if (/^\s*-{2,}\s*$/.test(line)) { flushPara(); flushList(); } else if (head) {
|
|
309
|
+
flushPara(); flushList(); out.push(`<h3 class="ais-rh">${inline(head[1].replace(/\*\*/g, ''))}</h3>`);
|
|
310
|
+
} else if (item) { flushPara(); list.push(item[1]); } else { flushList(); para.push(line.trim()); }
|
|
311
|
+
}
|
|
312
|
+
if (code) out.push(`<details class="ais-code"><summary>Code from the answer</summary><pre>${escape(code.join('\n'))}</pre></details>`);
|
|
313
|
+
flushPara(); flushList();
|
|
314
|
+
return out.join('');
|
|
250
315
|
}
|
|
251
316
|
|
|
252
317
|
// The home's AI action line: the newest request, while it is being made or
|
|
@@ -266,7 +331,7 @@
|
|
|
266
331
|
|
|
267
332
|
const lib = {
|
|
268
333
|
MAX_FILES, MAX_IMAGE_BYTES, MAX_PDF_BYTES, IMAGE_TYPES, PDF_TYPE, STEPS, TOPUP_CHOICES, ANSWER_AFTER_SEC,
|
|
269
|
-
usd, balanceUsd, sizeText, routeOf, checkFile, isImage, stepMarks, quoteLine, ownKey, ownKeys, payChoices,
|
|
334
|
+
usd, balanceUsd, sizeText, routeOf, checkFile, isImage, stepMarks, quoteLine, ownKey, ownKeys, payChoices, estimate, geminiEstimate, costHeadline,
|
|
270
335
|
waitSteps, clock, isFinished, receipt, againLine, monthSummary, ledgerLine, replyHtml, homeLine,
|
|
271
336
|
};
|
|
272
337
|
if (typeof module !== 'undefined' && module.exports) module.exports = lib;
|
|
@@ -138,12 +138,24 @@
|
|
|
138
138
|
.ais-pick img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
139
139
|
.ais-pick--on { outline: 2px solid var(--st-accent); outline-offset: 0; }
|
|
140
140
|
.ais-reply p { margin: 0 0 12px; font-size: 15px; line-height: 1.6; color: var(--st-ink-sub); font-weight: 300; }
|
|
141
|
+
.ais-rh { margin: 18px 0 6px; font-size: 16px; font-weight: 500; color: var(--st-ink); }
|
|
142
|
+
.ais-reply ul { margin: 0 0 12px; padding-left: 20px; font-size: 15px; line-height: 1.6; color: var(--st-ink-sub); font-weight: 300; }
|
|
143
|
+
.ais-reply code { font-size: 13.5px; }
|
|
144
|
+
.ais-code { margin: 0 0 12px; font-size: 13.5px; color: var(--st-ink-faint); }
|
|
145
|
+
.ais-code summary { cursor: pointer; min-height: 24px; }
|
|
146
|
+
.ais-code pre { margin: 8px 0 0; padding: 12px; overflow-x: auto; border-radius: 12px; background: var(--st-glass); white-space: pre; }
|
|
141
147
|
.ais-reply b { color: var(--st-ink); font-weight: 500; }
|
|
142
148
|
.ais-again { display: flex; flex-direction: column; gap: 8px; }
|
|
143
149
|
.ais-receipt, .ais-topup {
|
|
144
150
|
display: flex; flex-direction: column; padding: 10px 20px; border-radius: 22px;
|
|
145
151
|
background: var(--st-glass); border: 1px solid var(--st-glass-edge);
|
|
146
152
|
}
|
|
153
|
+
.ais-confirm {
|
|
154
|
+
display: flex; flex-direction: column; gap: 8px; padding: 16px 20px; border-radius: 22px;
|
|
155
|
+
background: var(--st-glass); border: 1px solid var(--st-glass-edge);
|
|
156
|
+
}
|
|
157
|
+
.ais-confirm__head { margin: 0; font-size: 20px; font-weight: 500; color: var(--st-ink); }
|
|
158
|
+
.ais-cost { margin: 0 0 12px; font-size: 17px; font-weight: 500; color: var(--st-ink); }
|
|
147
159
|
.ais-line { display: flex; align-items: center; gap: 14px; min-height: 40px; border-top: 1px solid var(--st-track); font-size: 14px; color: var(--st-ink); }
|
|
148
160
|
.ais-line:first-child, .ais-head + .ais-line { border-top: 0; }
|
|
149
161
|
.ais-line a { color: var(--st-link); display: inline-flex; align-items: center; min-height: 24px; }
|
|
@@ -352,13 +352,47 @@
|
|
|
352
352
|
<h1 class="ais-ask" data-head tabindex="-1">How do you want to pay?</h1>
|
|
353
353
|
<div class="ais-row ais-row--summary">${thumbs}<span class="ais-grow ais-soft">${esc(lib.quoteLine(state.draft.prompt))}</span><a class="ais-link" href="/builders/ai-studio?step=describe" data-nav="step=describe">change</a></div>
|
|
354
354
|
${ownCard || balanceCard ? `<div class="ais-cards">${ownCard}${balanceCard}</div>` : ''}
|
|
355
|
+
<div class="ais-confirm" id="ais-confirm" role="group" aria-label="Confirm the cost" hidden></div>
|
|
355
356
|
<p class="ais-faint">${esc(foot)}</p>
|
|
356
357
|
${extra}
|
|
357
358
|
<p class="ais-err" id="ais-err" role="alert" hidden></p>`;
|
|
358
|
-
page.querySelectorAll('[data-pay]').forEach((b) => b.addEventListener('click', () =>
|
|
359
|
+
page.querySelectorAll('[data-pay]').forEach((b) => b.addEventListener('click', () => askToConfirm(b.getAttribute('data-pay'), c)));
|
|
359
360
|
wireNavLinks();
|
|
360
361
|
}
|
|
361
362
|
|
|
363
|
+
// Before anything is sent or set aside: say what it will probably cost and
|
|
364
|
+
// wait for a yes. Past runs set the guess; the ceiling is what a run can
|
|
365
|
+
// never exceed.
|
|
366
|
+
async function askToConfirm(payWith, choices) {
|
|
367
|
+
if (state.busy) return;
|
|
368
|
+
const own = payWith !== 'ours';
|
|
369
|
+
if (payWith !== 'gemini' && !state.history) {
|
|
370
|
+
const res = await api.request('GET', `${API}/ai-studio/requests`);
|
|
371
|
+
state.history = res.ok && res.data ? res.data.requests : [];
|
|
372
|
+
}
|
|
373
|
+
const est = lib.estimate(state.history || [], state.balance && state.balance.max_cost_micro, !own);
|
|
374
|
+
const typical = payWith === 'gemini' ? lib.geminiEstimate(1) : est.typical;
|
|
375
|
+
const guess = typical === null ? 'A few cents' : `About ${lib.usd(typical)}`;
|
|
376
|
+
const ceiling = own || est.max === null ? '' : `It will never cost more than ${lib.usd(est.max)}.`;
|
|
377
|
+
const who = own ? 'Your own key pays, so nothing comes out of your balance here.' : `Taken from your balance (${choices.left}), and anything unused comes back.`;
|
|
378
|
+
const basis = payWith === 'gemini' ? 'Google reports no price on your own key, so this is worked out from its published rates: one reply and one picture.' : est.basis ? `Based on your last ${est.basis === 1 ? 'request' : `${est.basis} requests`}.` : 'You have no finished requests yet, so this is a rough guess.';
|
|
379
|
+
const box = page.querySelector('#ais-confirm');
|
|
380
|
+
if (!box) return;
|
|
381
|
+
box.hidden = false;
|
|
382
|
+
box.innerHTML = `
|
|
383
|
+
<h2 class="ais-confirm__head" data-confirm-head tabindex="-1">${esc(guess)} to make this</h2>
|
|
384
|
+
<p class="ais-soft">${esc([ceiling, who].filter(Boolean).join(' '))}</p>
|
|
385
|
+
<p class="ais-faint">${esc(basis)}</p>
|
|
386
|
+
<div class="ais-go ais-go--wrap">
|
|
387
|
+
<button type="button" class="ais-btn" id="ais-confirm-go">Make it</button>
|
|
388
|
+
<button type="button" class="ais-ghost" id="ais-confirm-cancel">Not yet</button>
|
|
389
|
+
</div>`;
|
|
390
|
+
const head = box.querySelector('[data-confirm-head]');
|
|
391
|
+
if (head) head.focus();
|
|
392
|
+
box.querySelector('#ais-confirm-cancel').addEventListener('click', () => { box.hidden = true; box.innerHTML = ''; });
|
|
393
|
+
box.querySelector('#ais-confirm-go').addEventListener('click', (ev) => sendRequest(payWith, state.draft.prompt, readyFiles(), ev.currentTarget));
|
|
394
|
+
}
|
|
395
|
+
|
|
362
396
|
// Same-page links move through history, so the steps keep the back button.
|
|
363
397
|
function wireNavLinks() {
|
|
364
398
|
page.querySelectorAll('[data-nav]').forEach((a) => a.addEventListener('click', (ev) => {
|
|
@@ -465,6 +499,7 @@
|
|
|
465
499
|
const rc = lib.receipt(req, state.balance && state.balance.price_multiplier);
|
|
466
500
|
const receiptHtml = rc.rows.length ? `<div class="ais-receipt">${rc.rows.map(([k, v]) => `<div class="ais-line"><span class="ais-grow">${esc(k)}</span><span>${esc(v)}</span></div>`).join('')}${rc.note ? `<div class="ais-line"><span class="ais-grow ais-faint ais-wrap">${esc(rc.note)}</span></div>` : ''}</div>` : '';
|
|
467
501
|
const failed = req.status !== 'done';
|
|
502
|
+
const headline = lib.costHeadline(req);
|
|
468
503
|
main.setAttribute('data-view', 'result');
|
|
469
504
|
page.innerHTML = `
|
|
470
505
|
<h1 class="ais-sr" data-head tabindex="-1">${failed ? 'That did not work' : 'Your result'}</h1>
|
|
@@ -476,6 +511,7 @@
|
|
|
476
511
|
</div>
|
|
477
512
|
<div class="ais-result__words">
|
|
478
513
|
${failed ? `<p class="ais-err">${esc(req.error || 'It did not work, and nothing was charged.')}</p>` : ''}
|
|
514
|
+
${headline ? `<p class="ais-cost" data-cost>${esc(headline)}</p>` : ''}
|
|
479
515
|
<div class="ais-reply">${lib.replyHtml(req.reply_md, esc)}</div>
|
|
480
516
|
<div class="ais-again">
|
|
481
517
|
<label class="ais-head" for="ais-again">Your request</label>
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
var lines = ['I need to make a decision in my project and want help understanding it.', ''];
|
|
84
84
|
lines.push('Where: the ' + str(cv.buildingName) + ' building' + (cv.waits ? ' (' + str(cv.waits) + ')' : '') + '.');
|
|
85
85
|
lines.push('The question: ' + str(cv.question));
|
|
86
|
+
if (cv.context) lines.push('What it is: ' + str(cv.context));
|
|
86
87
|
if (cv.said) lines.push('What it says: ' + str(cv.said));
|
|
87
88
|
var card = cv.card || {};
|
|
88
89
|
var more = card.more && card.more.text ? str(card.more.text) : str(card.fact || '');
|
|
@@ -255,8 +256,10 @@
|
|
|
255
256
|
function drawCard(doc, K, cv, on, o) {
|
|
256
257
|
var shown = answerable(cv);
|
|
257
258
|
var matter = matterOf(on.docket && on.docket(), cv.id);
|
|
258
|
-
// the question in full: a matter with no stored card
|
|
259
|
-
|
|
259
|
+
// the question in full: a matter with no stored card asks its own plain question
|
|
260
|
+
// when it wrote one (task 1005013), else is titled by its own words, cut to fit the deck
|
|
261
|
+
if (matter && !cv.askReal && matter.question) shown.question = questionOf(matter.question);
|
|
262
|
+
else if (matter && !cv.askReal && matter.title) shown.question = questionOf(matter.title);
|
|
260
263
|
var art = K.drawAsk(doc, shown, on, o);
|
|
261
264
|
// and everything else it says, one tap away rather than cut
|
|
262
265
|
var full = fullText(matter, shown);
|
|
@@ -231,6 +231,7 @@ html[data-fullscreen="on"] .city-top { right: 184px; }
|
|
|
231
231
|
.city-card .city-ask.ask { padding: 14px 16px; gap: 10px; }
|
|
232
232
|
.city-card .city-ask .ask__q { font-size: 17px; line-height: 1.3; font-weight: 400; }
|
|
233
233
|
.city-card .city-ask .ask__said { font-size: 13px; }
|
|
234
|
+
.city-card .city-ask .ask__ctx { font-size: 13px; line-height: 1.45; }
|
|
234
235
|
.city-card .city-ask .ask__opt { min-height: 44px; padding: 6px 12px 6px 10px; border-radius: 14px; }
|
|
235
236
|
.city-card .city-ask .ask__label { font-size: 14px; }
|
|
236
237
|
.city-card .city-ask .ask__result { font-size: 12px; }
|
|
@@ -338,6 +338,9 @@
|
|
|
338
338
|
|
|
339
339
|
// ---------- one ask card ----------
|
|
340
340
|
const STATE_ORDER = Object.freeze({ needs: 1, moving: 2, settled: 3 });
|
|
341
|
+
// a blocker read straight from GET /blockers carries no docket context; this is
|
|
342
|
+
// the docket's own sentence for one (modules/government/docket.js SOURCE_CONTEXT)
|
|
343
|
+
const BLOCKER_CONTEXT = 'A blocker: work that cannot go on until a person decides something. Deciding it releases the work that waits on it.';
|
|
341
344
|
function makeAsk(base, human, ctx, sources) {
|
|
342
345
|
const opened = time(base.opened_at);
|
|
343
346
|
const deadline = time(base.deadline);
|
|
@@ -375,7 +378,8 @@
|
|
|
375
378
|
href: base.href || null,
|
|
376
379
|
acts: base.acts || [],
|
|
377
380
|
tally: base.tally || null,
|
|
378
|
-
|
|
381
|
+
// what the card is about and the plain question it asks (task 1005013)
|
|
382
|
+
raw: { title: str(base.raw_title), detail: str(base.raw_detail), slop: slopScore(rawText), context: str(base.raw_context), question: str(base.raw_question) },
|
|
379
383
|
human: human || null,
|
|
380
384
|
format,
|
|
381
385
|
shown: format.ok ? human : null,
|
|
@@ -661,7 +665,7 @@
|
|
|
661
665
|
const stored = storedOf(m.ask);
|
|
662
666
|
asks.push(makeAsk({
|
|
663
667
|
id: m.id, source: m.source, building, state: m.state, opened_at: m.opened_at, deadline: m.deadline,
|
|
664
|
-
rule: m.rule, ref: m.ref, href: m.href, acts: m.acts, tally: m.tally, raw_title: m.title, raw_detail: m.detail, from_docket: true, ask_real: !!stored, route: m.route,
|
|
668
|
+
rule: m.rule, ref: m.ref, href: m.href, acts: m.acts, tally: m.tally, raw_title: m.title, raw_detail: m.detail, raw_context: m.context, raw_question: m.question, from_docket: true, ask_real: !!stored, route: m.route,
|
|
665
669
|
}, humanOf(stored), ctx, ASK_SOURCES));
|
|
666
670
|
});
|
|
667
671
|
arr(o.blockers, 'blockers').forEach((b) => {
|
|
@@ -672,7 +676,7 @@
|
|
|
672
676
|
const stored = storedOf(b.ask);
|
|
673
677
|
asks.push(makeAsk({
|
|
674
678
|
id, source: 'blockers', building: 'planning', state: 'needs', opened_at: b.created_at, deadline: null,
|
|
675
|
-
rule: 'decide', ref: { type: 'blocker', id: String(b.id) }, href: '/blockers#blocker-' + b.id, acts: [], raw_title: b.title, raw_detail: b.body_md, ask_real: !!stored,
|
|
679
|
+
rule: 'decide', ref: { type: 'blocker', id: String(b.id) }, href: '/blockers#blocker-' + b.id, acts: [], raw_title: b.title, raw_detail: b.body_md, raw_context: BLOCKER_CONTEXT, ask_real: !!stored,
|
|
676
680
|
}, humanOf(stored), ctx, ASK_SOURCES));
|
|
677
681
|
});
|
|
678
682
|
asks.sort(compareAsks);
|
|
@@ -52,3 +52,46 @@
|
|
|
52
52
|
font-size: 11.5px;
|
|
53
53
|
color: var(--ink-faint);
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/* ---- the circuit divider (task 1005032, the owner's Studio idea 1001297; mock:
|
|
57
|
+
docs/design/mocks/builders-leaderboard/) -----------------------------------
|
|
58
|
+
A trace between the column headings and the first row: a fixed cap at each
|
|
59
|
+
end, a hairline stretched between them, and orange nodes on the traces. It is
|
|
60
|
+
drawn on the FIRST BODY ROW (its top 30px, which the row's own top padding
|
|
61
|
+
makes room for) rather than under the sticky heading, so it scrolls with the
|
|
62
|
+
rows and never strands a blank band under a stuck header. Two masks, two
|
|
63
|
+
tokens: the traces are --ink-faint, the nodes --accent, so both modes follow
|
|
64
|
+
the world. The empty-state row has no trace. Pure decoration, no markup. */
|
|
65
|
+
#roster-body .ledger th { border-bottom-color: transparent; }
|
|
66
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) { position: relative; }
|
|
67
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td { padding-top: 44px; }
|
|
68
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::before,
|
|
69
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::after {
|
|
70
|
+
content: ""; position: absolute; left: 0; right: 0; top: 0; height: 30px;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
-webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
|
|
73
|
+
}
|
|
74
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::before {
|
|
75
|
+
background: var(--ink-faint);
|
|
76
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V6H100V14H156V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 22H80M104 12H124M132 14H152M160 16H180M92 10V24'/%3E%3C/g%3E%3C/svg%3E"),
|
|
77
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V22H168V28H196V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 14H70M110 10H128M136 16H160M170 4V14M200 8V24'/%3E%3C/g%3E%3C/svg%3E"),
|
|
78
|
+
linear-gradient(black, black);
|
|
79
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V6H100V14H156V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 22H80M104 12H124M132 14H152M160 16H180M92 10V24'/%3E%3C/g%3E%3C/svg%3E"),
|
|
80
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='none' stroke='black' stroke-width='1.5'%3E%3Cpath d='M6 18H88V22H168V28H196V18H300'/%3E%3Cpath stroke-opacity='.55' stroke-width='1.2' d='M12 14H70M110 10H128M136 16H160M170 4V14M200 8V24'/%3E%3C/g%3E%3C/svg%3E"),
|
|
81
|
+
linear-gradient(black, black);
|
|
82
|
+
-webkit-mask-size: 300px 30px, 300px 30px, calc(100% - 598px) 1.5px;
|
|
83
|
+
mask-size: 300px 30px, 300px 30px, calc(100% - 598px) 1.5px;
|
|
84
|
+
-webkit-mask-position: left top, right top, 299px 17.25px;
|
|
85
|
+
mask-position: left top, right top, 299px 17.25px;
|
|
86
|
+
}
|
|
87
|
+
#roster-body .ledger tbody tr:first-child:not(:has(.ledger__empty)) td:first-child::after {
|
|
88
|
+
background: var(--accent);
|
|
89
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='88' cy='6' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='88' cy='6' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E"),
|
|
90
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='196' cy='28' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='196' cy='28' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E");
|
|
91
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='88' cy='6' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='88' cy='6' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E"),
|
|
92
|
+
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 30'%3E%3Cg transform='matrix(-1 0 0 1 300 0)' fill='black'%3E%3Ccircle cx='6' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='196' cy='28' r='7' fill-opacity='.25'/%3E%3Ccircle cx='296' cy='18' r='7' fill-opacity='.25'/%3E%3Ccircle cx='6' cy='18' r='3.5'/%3E%3Ccircle cx='196' cy='28' r='3.5'/%3E%3Ccircle cx='296' cy='18' r='3.5'/%3E%3C/g%3E%3C/svg%3E");
|
|
93
|
+
-webkit-mask-size: 300px 30px, 300px 30px;
|
|
94
|
+
mask-size: 300px 30px, 300px 30px;
|
|
95
|
+
-webkit-mask-position: left top, right top;
|
|
96
|
+
mask-position: left top, right top;
|
|
97
|
+
}
|
|
@@ -123,6 +123,8 @@ body[data-page="street"] .street-toast { z-index: 13; }
|
|
|
123
123
|
it is not a control, so it draws no ring */
|
|
124
124
|
.ask__q:focus, .ask__q:focus-visible { outline: none; box-shadow: none; }
|
|
125
125
|
.ask__said { margin: -4px 0 0; font-size: 15.5px; line-height: 1.5; color: var(--ink-soft); }
|
|
126
|
+
/* what the card is about, under its question (task 1005013) */
|
|
127
|
+
.ask__ctx { margin: -4px 0 0; font-size: 14.5px; line-height: 1.5; color: var(--ink); overflow-wrap: anywhere; }
|
|
126
128
|
.ask__likes { margin: -2px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
|
127
129
|
.ask__likes li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.4; color: var(--ink); }
|
|
128
130
|
.ask__likes li::before { content: ""; flex: none; width: 5px; height: 5px; margin-top: 8px; border-radius: 50%; background: var(--ink-faint); }
|
|
@@ -160,6 +162,7 @@ body[data-page="street"] .street-toast { z-index: 13; }
|
|
|
160
162
|
.ask--here { gap: 14px; padding: 18px 18px 16px; border-radius: 22px; background: var(--gov-chip); border: 1px solid var(--gov-chip-edge); }
|
|
161
163
|
.ask--here .ask__q { font-size: 23px; }
|
|
162
164
|
.ask--here .ask__said { font-size: 14.5px; }
|
|
165
|
+
.ask--here .ask__ctx { font-size: 14px; }
|
|
163
166
|
.ask--here .ask__opt { min-height: 54px; border-radius: 15px; }
|
|
164
167
|
|
|
165
168
|
/* a building's card on the street: glass beside its pin */
|
|
@@ -213,11 +213,14 @@
|
|
|
213
213
|
routing: shown && shown.routing_note ? str(shown.routing_note) : '', tally: ask.tally || null, kind: ask.kind || null,
|
|
214
214
|
// a board's vote shows its seats, one pip each, lit as the sitting's tally says
|
|
215
215
|
seats: boardSeats(c.docket, ask.waits_on ? str(ask.waits_on.seat) : ''),
|
|
216
|
+
// what this is and what it belongs to, in plain words (task 1005013)
|
|
217
|
+
context: ask.raw ? plain(ask.raw.context) : '',
|
|
216
218
|
card: cv,
|
|
217
219
|
};
|
|
218
220
|
if (!shown) {
|
|
219
221
|
// no stored ask: the matter in its own words, and its floor's desk, where its acts are
|
|
220
|
-
|
|
222
|
+
// the matter's own plain question when it wrote one, else its title (task 1005013)
|
|
223
|
+
view.question = ask.raw && ask.raw.question ? fit(plain(ask.raw.question), 120) : fit(plain(ask.raw ? ask.raw.title : ''), 90);
|
|
221
224
|
view.desk = '/builders/street#building=' + str(ask.building);
|
|
222
225
|
view.foot = footOf(ask, '');
|
|
223
226
|
return view;
|
|
@@ -436,6 +439,7 @@
|
|
|
436
439
|
q.setAttribute('tabindex', '-1');
|
|
437
440
|
if (o.qid) q.setAttribute('id', o.qid);
|
|
438
441
|
card.appendChild(q);
|
|
442
|
+
if (cv.context) card.appendChild(h('p', 'ask__ctx', cv.context));
|
|
439
443
|
if (cv.batch && cv.likes) {
|
|
440
444
|
const list = h('ul', 'ask__likes');
|
|
441
445
|
cv.likes.forEach((l) => list.appendChild(h('li', '', l.question)));
|
|
@@ -28,3 +28,12 @@ Pinned by `tests/leaderboard_roles.mjs` (the read, the route, the REAL roster.js
|
|
|
28
28
|
- **The roster keeps its live columns**: place with the builder's number, rank, karma and tokens, the founding mark and the laurels, and its search and sort lenses. The mock draws the leaderboard bare; the live hall's chrome wins (the blend rule).
|
|
29
29
|
- **The column is "Roles", not "Preferred roles"**: it lists roles the builder has earned in as well as the one they chose, so "preferred" would understate it.
|
|
30
30
|
- **The status page has the roles but not the toggle.** It is a one-glance public dashboard of the top builders on its own palette; the toggle lives on the roster, which is the leaderboard a builder works from.
|
|
31
|
+
|
|
32
|
+
## The circuit divider (task 1005032)
|
|
33
|
+
|
|
34
|
+
The owner's Studio idea 1001297 (a circuit-trace image as a table divider; mock `docs/design/mocks/builders-leaderboard/`, task 1005030) now sits between the roster's column headings and its first row. It is **CSS only, in `roster.css`**: the ledger kit is untouched and no markup was added.
|
|
35
|
+
|
|
36
|
+
- **It is drawn on the first BODY row, not under the sticky heading.** A divider hung under the sticky `th` would stay stuck while the rows scrolled away, leaving a blank band; on the first row it scrolls with the data. The row's top padding makes room for it.
|
|
37
|
+
- **The pseudo-elements sit on the row's first `td`, never on the `tr`.** On the `tr` they became two extra table cells and pushed every cell in the row two columns right (seen in the first render). The `tr` only carries `position: relative`, so the `td`'s absolute children size against the whole row.
|
|
38
|
+
- **Two masks, two tokens.** Traces are `--ink-faint` and the orange nodes `--accent`, each shown through an SVG mask, so light and dark follow the world with no hard-coded colour. Left cap, right cap and the stretching hairline are three layers of one mask.
|
|
39
|
+
- **The empty-state row has no trace** (`:not(:has(.ledger__empty))`). Checked at desktop and 375px, light and dark: the table scrolls inside its card on a phone and the divider spans it; the page does not scroll sideways.
|
|
@@ -45,6 +45,9 @@ function blockerDocketMatter(b, mayResolve) {
|
|
|
45
45
|
tally: null,
|
|
46
46
|
href: `/blockers#blocker-${b.id}`,
|
|
47
47
|
ref: { type: 'blocker', id: b.id },
|
|
48
|
+
context: holds
|
|
49
|
+
? `A blocker: ${holds === 1 ? 'one task is' : `${holds} tasks are`} stopped until a person decides this. Deciding it lets that work go on.`
|
|
50
|
+
: 'A blocker: an AI session or a builder is stuck until a person decides this. Deciding it lets the work go on.',
|
|
48
51
|
// the card's "more" (task 1004566): the body GET /blockers shows everyone
|
|
49
52
|
detail: b.body_md || null,
|
|
50
53
|
// the floor desk's one act (task 1004560): decide, by one of its options
|
|
@@ -41,6 +41,8 @@ function overrideMatter(r, mayDecide) {
|
|
|
41
41
|
tally: null,
|
|
42
42
|
href: `/task/${encodeURIComponent(r.task_id)}`,
|
|
43
43
|
ref: { type: 'override_request', id: r.id },
|
|
44
|
+
question: 'Let this work through although it failed its check?',
|
|
45
|
+
context: `${r.requester_display_name || r.requester_login || 'A builder'} asks to let the task "${r.task_title || r.task_id}" through. Its automatic check or grade failed it, so it cannot land on its own. Approving treats the work as passed; holding it keeps it out until it is fixed.`,
|
|
44
46
|
// the card's "more" (task 1004566): the reason GET /override-requests shows
|
|
45
47
|
detail: r.rationale || null,
|
|
46
48
|
};
|
|
@@ -60,6 +62,8 @@ function removalMatter(r) {
|
|
|
60
62
|
tally: null,
|
|
61
63
|
href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
|
|
62
64
|
ref: { type: 'task', id: r.proposal_task_id },
|
|
65
|
+
question: 'Is this shipped work really gone?',
|
|
66
|
+
context: `A later change deleted files that earlier shipped work put there${r.goal_title ? `, in the goal "${r.goal_title}"` : ''}. Moves and rewrites can look like deletions, so a person decides: Remove means that work no longer counts as alive; Keep means it still does. Nothing is undone either way.`,
|
|
63
67
|
// the card's "more" (task 1004566): the proposal's own text, from the same read
|
|
64
68
|
detail: r.description || null,
|
|
65
69
|
// POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
|
|
@@ -84,12 +88,31 @@ function uatActs(c, { force = false } = {}) {
|
|
|
84
88
|
return acts;
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
// What a Review card says (task 1005013): the promise in quotes, the goal it
|
|
92
|
+
// belongs to, that its work shipped, and what the sign-off closes. Pure.
|
|
93
|
+
function quoteOf(md) {
|
|
94
|
+
// the promise's own last full stop goes: the sentence around it supplies one
|
|
95
|
+
const t = String(md || '').replace(/[*_`#>]+/g, '').replace(/\s+/g, ' ').trim().replace(/\.+$/, '');
|
|
96
|
+
return t.length <= 220 ? t : t.slice(0, 219).replace(/\s+\S*$/, '') + '…';
|
|
97
|
+
}
|
|
98
|
+
function uatContext(c) {
|
|
99
|
+
const n = Number(c.shipped_total) || 0;
|
|
100
|
+
const goal = c.goal_title ? `the goal "${c.goal_title}"` : 'this version';
|
|
101
|
+
const built = n === 1 ? 'The one task built for it has shipped.' : n > 1 ? `All ${n} tasks built for it have shipped.` : 'Its work has shipped.';
|
|
102
|
+
const how = c.backend_only === true
|
|
103
|
+
? 'It has nothing to see on screen, so check it works and sign it off.'
|
|
104
|
+
: "Try it on the live site, then record a short video of it working and sign it off on the goal's page.";
|
|
105
|
+
return `The promise: "${quoteOf(c.criterion_md)}". It is one of the done-when promises of ${goal}. ${built} ${how} That closes the promise; the goal is achieved once all its promises are closed.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
function uatMatter(c, opts) {
|
|
88
109
|
return {
|
|
89
110
|
id: c.id,
|
|
90
111
|
rule: 'signoff',
|
|
91
112
|
state: 'needs',
|
|
92
113
|
title: c.criterion_md,
|
|
114
|
+
question: c.backend_only === true ? 'Is this promise kept? Check it and sign it off.' : 'Does this work on the live site now?',
|
|
115
|
+
context: uatContext(c),
|
|
93
116
|
short: 'waiting for a tester',
|
|
94
117
|
where: c.goal_title ? `review · ${c.goal_title}` : 'review · a promise to sign off',
|
|
95
118
|
opened_at: null,
|
|
@@ -118,6 +141,9 @@ function joinRequestMatter(r, mine) {
|
|
|
118
141
|
tally: null,
|
|
119
142
|
href: r.goal_id ? `/goals#/goal/${encodeURIComponent(r.goal_id)}` : '/goals',
|
|
120
143
|
ref: { type: 'goal_membership_request', id: r.id },
|
|
144
|
+
context: mine
|
|
145
|
+
? `You asked to join the team of the goal "${r.goal_title || r.goal_id}". Its lead decides; once you are in, you can claim its tasks.`
|
|
146
|
+
: `${r.display_name || r.github_login || 'Someone'} asks to join the team of the goal "${r.goal_title || r.goal_id}". Members of a goal may claim its tasks; its lead decides who joins.`,
|
|
121
147
|
};
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -258,6 +258,8 @@ function accessRequestDocketMatter(r) {
|
|
|
258
258
|
tally: null,
|
|
259
259
|
href: '/watch',
|
|
260
260
|
ref: { type: 'access_request', id: r.id },
|
|
261
|
+
question: `Let ${r.display_name || r.github_login} join this project?`,
|
|
262
|
+
context: `${r.display_name || r.github_login} (GitHub: ${r.github_login}) asked to join this project as a builder${r.vouched ? ', signed in through Bongos so the name is really theirs' : ''}. Admitting them lets them sign in and claim work; declining keeps them out.`,
|
|
261
263
|
// the card's "more" (task 1004566): their note, as GET /access-requests shows it
|
|
262
264
|
detail: r.note || null,
|
|
263
265
|
};
|
|
@@ -56,6 +56,8 @@ function redteamDocketMatter(r) {
|
|
|
56
56
|
tally: null,
|
|
57
57
|
href: '/watch',
|
|
58
58
|
ref: { type: 'vulnerability_report', id: r.id },
|
|
59
|
+
question: 'Is this security flaw real?',
|
|
60
|
+
context: `A security report: someone found what they say is a flaw in this project itself${r.target ? ` (in ${r.target})` : ''}${r.severity ? `, rated ${r.severity}` : ''}. "${r.title}". ${r.status === 'repro_attempted' ? 'Someone has tried to reproduce it. ' : ''}Confirm it if it is real, so it is fixed and the reporter earns the bounty; dispute it if it is not.`,
|
|
59
61
|
// the card's "more" (task 1004566): what GET /security/reports shows this
|
|
60
62
|
// same atom — what it targets and the report's description
|
|
61
63
|
detail: [r.target ? `Target: ${r.target}` : '', r.description || ''].filter(Boolean).join('\n\n') || null,
|