@bongos/core 1.24.74 → 1.24.76
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 +77 -42
- package/.claude/skills/blocker-review/SKILL.md +10 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +6 -2
- package/docs/api-reference.md +2 -2
- package/docs/copy-inventory.md +55 -47
- package/docs/copy-registry.json +143 -71
- package/docs/module-api-changelog.md +2 -0
- package/docs/packs/engineer.md +2 -2
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +10 -10
- package/modules/government/board.js +10 -4
- package/modules/government/db.js +7 -5
- package/modules/government/genesis-close.js +45 -0
- package/modules/government/migrations/government_038_vote_cast_via.sql +23 -0
- package/modules/government/routes/government.js +4 -5
- package/modules/hall-ui/public/board-room.js +1 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +56 -5
- package/modules/hall-ui/public/genesis-home.js +7 -12
- package/modules/hall-ui/public/home-city.js +7 -2
- package/modules/hall-ui/public/home-idea-box.css +33 -0
- package/modules/hall-ui/public/home-idea-box.js +106 -0
- package/modules/hall-ui/public/index.html +2 -0
- package/modules/hall-ui/public/project-settings.css +15 -0
- package/modules/hall-ui/records/home-city.md +21 -0
- package/modules/ideas/routes/blockers.js +47 -1
- package/modules/onboarding/genesis-home.js +65 -8
- package/modules/onboarding/routes/onboarding.js +15 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/blocker-recheck.js +111 -0
- package/scripts/bongos/government-walk.js +4 -4
- package/scripts/gds/blocker-recheck.js +7 -0
- package/scripts/hall-preview/server.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/deploy_page_projects.mjs +49 -0
- package/tests/genesis_home.mjs +86 -1
- package/tests/government_agent_asks.mjs +92 -2
- package/tests/government_board_genesis_stage.mjs +83 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/home_idea_box.mjs +178 -0
package/docs/page-readings.json
CHANGED
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"surface": "builders",
|
|
216
216
|
"title": "Board Room",
|
|
217
217
|
"states_read": ["room","out","sitting-meta","objection-form"],
|
|
218
|
-
"files_hash": "
|
|
218
|
+
"files_hash": "0e6e17f03283a4a6",
|
|
219
219
|
"reading_hash": "020553cb2a438d56",
|
|
220
220
|
"counts": {"lines":51,"placed":18,"shared":9,"unplaced":24},
|
|
221
221
|
"omitted": {"data":15},
|
|
@@ -246,18 +246,18 @@
|
|
|
246
246
|
{"key":"L0023","section":"{…} Full Idea","text":"Scope it as best as you can here","placement":"unplaced"},
|
|
247
247
|
{"key":"L0024","section":"{…} Full Idea","text":"What should an {…} or {…} know prior to building","placement":"unplaced"},
|
|
248
248
|
{"key":"L0025","section":"{…} Full Idea","text":"How is this long term justified in the project","placement":"unplaced"},
|
|
249
|
-
{"key":"L0026","section":"{…} Full Idea","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":
|
|
249
|
+
{"key":"L0026","section":"{…} Full Idea","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":484,"string_id":"d73256fbc395","occurrences":9},
|
|
250
250
|
{"key":"L0027","section":"Constitutional amendment amendment","text":"Constitutional amendment","placement":"unplaced","occurrences":3},
|
|
251
251
|
{"key":"L0028","section":"Constitutional amendment amendment","text":"amendment","placement":"unplaced","occurrences":3},
|
|
252
252
|
{"key":"L0029","section":"Constitutional amendment amendment","text":"open","placement":"placed","file":"modules/hall-ui/public/approval-queue.js","line":173,"string_id":"9a803563c948"},
|
|
253
253
|
{"key":"L0030","section":"Constitutional amendment amendment","text":"no clock — waits for a {…} yes","placement":"unplaced"},
|
|
254
254
|
{"key":"L0031","section":"Constitutional amendment amendment","text":"Proposes","placement":"unplaced","occurrences":3},
|
|
255
|
-
{"key":"L0032","section":"Constitutional amendment amendment","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":
|
|
255
|
+
{"key":"L0032","section":"Constitutional amendment amendment","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":484,"string_id":"d73256fbc395","occurrences":3},
|
|
256
256
|
{"key":"L0033","section":"Constitutional amendment amendment","text":"Closed sittings","placement":"unplaced"},
|
|
257
257
|
{"key":"L0034","section":"{…} Full Idea","text":"passed","placement":"placed","file":"modules/hall-ui/public/government.js","line":679,"string_id":"da5368d10f85","occurrences":8},
|
|
258
258
|
{"key":"L0035","section":"{…} and {…}","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":5},
|
|
259
259
|
{"key":"L0036","section":"{…} and {…}","text":"Full Idea","placement":"unplaced","occurrences":5},
|
|
260
|
-
{"key":"L0037","section":"{…} and {…}","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":
|
|
260
|
+
{"key":"L0037","section":"{…} and {…}","text":"{…} · who votes, and how it {…}","placement":"placed","file":"modules/hall-ui/public/board-room.js","line":484,"string_id":"d73256fbc395","occurrences":5},
|
|
261
261
|
{"key":"L0038","section":"Constitutional amendment amendment","text":"passed","placement":"placed","file":"modules/hall-ui/public/government.js","line":679,"string_id":"da5368d10f85","occurrences":2},
|
|
262
262
|
{"key":"L0039","section":"Constitutional amendment amendment","text":"{\"{…}_{…}\":\"{…}\",\"membership\":\"{…}\",\"window_minutes\":1440,\"close_early_on_{…}_turnout\":true}","placement":"unplaced","occurrences":2},
|
|
263
263
|
{"key":"L0040","section":"Constitutional amendment amendment","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":2},
|
|
@@ -604,7 +604,7 @@
|
|
|
604
604
|
"surface": "builders",
|
|
605
605
|
"title": "Deploy",
|
|
606
606
|
"states_read": ["work","out"],
|
|
607
|
-
"files_hash": "
|
|
607
|
+
"files_hash": "5fa46643d448b50c",
|
|
608
608
|
"reading_hash": "76c11d0fbc739610",
|
|
609
609
|
"counts": {"lines":19,"placed":5,"shared":9,"unplaced":5},
|
|
610
610
|
"omitted": {"data":3},
|
|
@@ -974,8 +974,8 @@
|
|
|
974
974
|
"surface": "builders",
|
|
975
975
|
"title": "Builders' Hall",
|
|
976
976
|
"states_read": ["home","out","founding","genesis","decide","demo"],
|
|
977
|
-
"files_hash": "
|
|
978
|
-
"reading_hash": "
|
|
977
|
+
"files_hash": "fc8a81cb746dba23",
|
|
978
|
+
"reading_hash": "ae7a0c466a3aee85",
|
|
979
979
|
"counts": {"lines":28,"placed":9,"shared":10,"unplaced":9},
|
|
980
980
|
"omitted": {"data":4},
|
|
981
981
|
"notes": ["founding: an action failed (page.focus: Timeout 6000ms exceeded.); read as it stood"],
|
|
@@ -999,7 +999,7 @@
|
|
|
999
999
|
{"key":"L0017","section":"Needs your attention13","text":"Ideas awaiting triage","placement":"placed","file":"modules/hall-ui/public/builders.js","line":320,"string_id":"e181637c5a5e"},
|
|
1000
1000
|
{"key":"L0018","section":"Needs your attention13","text":"Requires project {…} fix","placement":"unplaced"},
|
|
1001
1001
|
{"key":"L0019","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":533,"string_id":"1883ad7f07fc"},
|
|
1002
|
-
{"key":"L0020","section":"Needs your attention13","text":"core 1.24.
|
|
1002
|
+
{"key":"L0020","section":"Needs your attention13","text":"core 1.24.74","placement":"unplaced"},
|
|
1003
1003
|
{"key":"L0021","section":"Needs your attention13","text":"Skip","placement":"unplaced"},
|
|
1004
1004
|
{"key":"L0022","section":"Needs your attention13","text":"Built on","placement":"placed","file":"modules/hall-ui/public/welcome-boot.js","line":181,"string_id":"fef59346ffee"},
|
|
1005
1005
|
{"key":"L0023","section":"Needs your attention13","text":"cloudbongos.com","placement":"unplaced"},
|
|
@@ -1346,7 +1346,7 @@
|
|
|
1346
1346
|
"surface": "builders",
|
|
1347
1347
|
"title": "Money",
|
|
1348
1348
|
"states_read": ["money","hosting","out"],
|
|
1349
|
-
"files_hash": "
|
|
1349
|
+
"files_hash": "282ef6a957569657",
|
|
1350
1350
|
"reading_hash": "285d714c7aba1fc9",
|
|
1351
1351
|
"counts": {"lines":82,"placed":25,"shared":9,"unplaced":48},
|
|
1352
1352
|
"omitted": {"data":25},
|
|
@@ -2323,7 +2323,7 @@
|
|
|
2323
2323
|
"surface": "builders",
|
|
2324
2324
|
"title": "Project settings",
|
|
2325
2325
|
"states_read": ["default"],
|
|
2326
|
-
"files_hash": "
|
|
2326
|
+
"files_hash": "f56e2084906369f2",
|
|
2327
2327
|
"reading_hash": "849399ad428ae9c7",
|
|
2328
2328
|
"counts": {"lines":42,"placed":22,"shared":9,"unplaced":11},
|
|
2329
2329
|
"omitted": {"data":31},
|
|
@@ -255,18 +255,21 @@ function validateVote({ direction, reasonMd, sectionKey } = {}) {
|
|
|
255
255
|
// { ok: true, vote, fresh: true, karma_delta, self_cast } — recorded now
|
|
256
256
|
// { ok: true, vote, fresh: false, self_cast } — idempotent re-cast
|
|
257
257
|
// { ok: false, reason: 'item_not_found' | 'item_closed' | 'vote_window_expired'
|
|
258
|
-
// | 'not_board_member' | <validateVote reason> }
|
|
258
|
+
// | 'not_board_member' | 'bad_cast_via' | <validateVote reason> }
|
|
259
|
+
// `castVia` (task 1005121, government_038) is internal — never read from a request: 'ballot', or 'stage_close' (genesis-close.js: a founder's confirm given by closing a genesis stage — genesis_stage + yes only, no vote karma).
|
|
259
260
|
// 'anti_self_vote' is GONE as of task 1003273 — an author may cast; what they
|
|
260
261
|
// cannot do is decide alone, which shouldCloseAfterVote and tallyOutcome enforce.
|
|
261
262
|
// A self-cast is recorded and visible but earns no karma (karma_delta: 0).
|
|
262
|
-
async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null } = {}) {
|
|
263
|
+
async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' } = {}) {
|
|
263
264
|
const id = Number(itemId);
|
|
264
265
|
if (!Number.isFinite(id) || id <= 0) return { ok: false, reason: 'item_not_found' };
|
|
266
|
+
if (castVia !== 'ballot' && castVia !== 'stage_close') return { ok: false, reason: 'bad_cast_via' };
|
|
265
267
|
const v = validateVote({ direction, reasonMd, sectionKey });
|
|
266
268
|
if (!v.ok) return v;
|
|
267
269
|
|
|
268
270
|
const item = await db.getBoardItemForVote(id);
|
|
269
271
|
if (!item) return { ok: false, reason: 'item_not_found' };
|
|
272
|
+
if (castVia === 'stage_close' && (item.subject_type !== 'genesis_stage' || v.direction !== 'yes')) return { ok: false, reason: 'bad_cast_via' };
|
|
270
273
|
if (item.closed_at) return { ok: false, reason: 'item_closed' };
|
|
271
274
|
// The clock is checked here as well as by the R11 poller: a vote landing in
|
|
272
275
|
// the gap between expiry and the sweep would be a vote after time. The
|
|
@@ -309,6 +312,7 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
309
312
|
|
|
310
313
|
const inserted = await db.insertBoardVote({
|
|
311
314
|
itemId: id, voterId, direction: v.direction, reasonMd: v.reasonMd, sectionKey: v.sectionKey,
|
|
315
|
+
...(castVia === 'ballot' ? {} : { castVia }), // db defaults a ballot
|
|
312
316
|
});
|
|
313
317
|
if (!inserted) {
|
|
314
318
|
// UNIQUE(item_id, voter_id) — the idempotent path. Return what stands.
|
|
@@ -317,8 +321,8 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
317
321
|
}
|
|
318
322
|
// Karma rides the FRESH insert only, and the whole route refused a closed or
|
|
319
323
|
// expired item above, so every paid vote is by construction in-window.
|
|
320
|
-
if (isSelfCast) {
|
|
321
|
-
return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast:
|
|
324
|
+
if (isSelfCast || castVia === 'stage_close') {
|
|
325
|
+
return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast: isSelfCast };
|
|
322
326
|
}
|
|
323
327
|
await db.insertVoteKarma({ voterId, itemId: id, delta: VOTE_KARMA_DELTA });
|
|
324
328
|
return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA, self_cast: false };
|
|
@@ -1103,6 +1107,7 @@ async function genesisStageStatus() {
|
|
|
1103
1107
|
remaining_seconds: item.remaining_seconds ?? null,
|
|
1104
1108
|
voted: cast.map((v) => ({
|
|
1105
1109
|
builder_id: String(v.voter_id), github_login: v.github_login, display_name: v.display_name, direction: v.direction,
|
|
1110
|
+
cast_via: v.cast_via || 'ballot',
|
|
1106
1111
|
})),
|
|
1107
1112
|
waiting_on: members.filter((m) => !castBy.has(String(m.builder_id))).map((m) => ({
|
|
1108
1113
|
builder_id: m.builder_id, github_login: m.github_login, display_name: m.display_name,
|
|
@@ -1155,6 +1160,7 @@ async function boardRoomView({ closedLimit = 20, viewerId = null } = {}) {
|
|
|
1155
1160
|
reason_md: v.reason_md,
|
|
1156
1161
|
section_key: v.section_key,
|
|
1157
1162
|
cast_at: v.cast_at,
|
|
1163
|
+
cast_via: v.cast_via || 'ballot', // task 1005121: a ballot, or a confirm given by closing a genesis stage
|
|
1158
1164
|
});
|
|
1159
1165
|
}
|
|
1160
1166
|
const project = (item) => {
|
package/modules/government/db.js
CHANGED
|
@@ -494,13 +494,15 @@ async function getBoardItemAuthor(item) {
|
|
|
494
494
|
// The open ballot's one write. Idempotency is STRUCTURAL — UNIQUE(item_id,
|
|
495
495
|
// voter_id); the loser of a double-cast gets null and the caller reads the
|
|
496
496
|
// standing vote back.
|
|
497
|
-
|
|
497
|
+
// cast_via (government_038, task 1005121): 'ballot', or 'stage_close' for the
|
|
498
|
+
// confirm a founder gives by closing a genesis stage. board.js decides which.
|
|
499
|
+
async function insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' }) {
|
|
498
500
|
const { rows } = await pool.query(
|
|
499
|
-
`INSERT INTO government_board_votes (item_id, voter_id, direction, reason_md, section_key)
|
|
500
|
-
VALUES ($1, $2, $3, $4, $5)
|
|
501
|
+
`INSERT INTO government_board_votes (item_id, voter_id, direction, reason_md, section_key, cast_via)
|
|
502
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
501
503
|
ON CONFLICT (item_id, voter_id) DO NOTHING
|
|
502
504
|
RETURNING *`,
|
|
503
|
-
[itemId, voterId, direction, reasonMd, sectionKey],
|
|
505
|
+
[itemId, voterId, direction, reasonMd, sectionKey, castVia],
|
|
504
506
|
);
|
|
505
507
|
return rows[0] || null;
|
|
506
508
|
}
|
|
@@ -1321,7 +1323,7 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
|
|
|
1321
1323
|
}
|
|
1322
1324
|
|
|
1323
1325
|
// ── the constitution in force (task 1005140) ─────────────────────────────────
|
|
1324
|
-
// ONE row (
|
|
1326
|
+
// ONE row (government_038). The board block is stored exactly as sanitizeBoard
|
|
1325
1327
|
// returns it; constitution.js owns the rules for when it is written and how the
|
|
1326
1328
|
// running process is brought in step with it. `q` is a client or the pool.
|
|
1327
1329
|
async function getConstitutionRow(q = pool) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// modules/government/genesis-close.js — closing a genesis stage is ONE call
|
|
3
|
+
// (task 1005121). Its own file so board.js stays under the size ratchet; it is
|
|
4
|
+
// composed only from board.js's exported functions (looked up at call time, so a
|
|
5
|
+
// test's stubs on board's db bite exactly as they do for board itself).
|
|
6
|
+
const board = require('./board.js');
|
|
7
|
+
|
|
8
|
+
// Close a genesis stage in ONE call (task 1005121; owner ruling 2026-11-07:
|
|
9
|
+
// "closing a stage shouldn't be such a big thing"). Opens the sitting (or reads
|
|
10
|
+
// the one already open) and, where the item's constitution says one person
|
|
11
|
+
// decides (first_ratifier) and the caller is a member under its snapshot, casts
|
|
12
|
+
// the caller's yes in the same breath — recorded cast_via 'stage_close', so the
|
|
13
|
+
// ledger tells a confirm given by closing the stage from a clicked ballot — and
|
|
14
|
+
// closes it. The same rule at every stage: nothing here forks on which stage.
|
|
15
|
+
// Returns, never throws for a refusal:
|
|
16
|
+
// { closed: true, item, stage, cast } — the sitting passed
|
|
17
|
+
// { closed: false, item, stage, opened, status } — a real sitting waits;
|
|
18
|
+
// `status` is genesisStageStatus' entry for this stage (who has voted, who
|
|
19
|
+
// it waits on, the rule in words, the clock)
|
|
20
|
+
// { closed: false, reason, unfinished? } — openGenesisStageClose refused
|
|
21
|
+
async function closeGenesisStage({ stageKey, taskIds, closedBy = null } = {}) {
|
|
22
|
+
const r = await board.openGenesisStageClose({ stageKey, taskIds, openedBy: closedBy });
|
|
23
|
+
if (!r.opened && r.reason !== 'already_open') return { closed: false, reason: r.reason, unfinished: r.unfinished || [] };
|
|
24
|
+
const item = r.item;
|
|
25
|
+
const stage = r.stage || board.GENESIS_STAGES.find((s) => s.key === stageKey);
|
|
26
|
+
if (!item) return { closed: false, reason: 'already_open' };
|
|
27
|
+
const constitution = item.constitution || {};
|
|
28
|
+
let cast = null;
|
|
29
|
+
if (constitution.pass_rule === 'first_ratifier' && closedBy != null && closedBy !== '') {
|
|
30
|
+
const v = await board.castVote({ itemId: item.id, voterId: closedBy, direction: 'yes', castVia: 'stage_close' });
|
|
31
|
+
if (v.ok) {
|
|
32
|
+
cast = { cast_via: v.vote ? v.vote.cast_via || 'stage_close' : 'stage_close', fresh: v.fresh };
|
|
33
|
+
const c = await board.maybeCloseAfterVote(item.id);
|
|
34
|
+
if (c && c.closed && c.outcome === 'passed') return { closed: true, item: c.item || item, stage, cast };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let status = null;
|
|
38
|
+
try {
|
|
39
|
+
const all = await board.genesisStageStatus();
|
|
40
|
+
status = (all.stages || []).find((s) => s.key === stage.key) || null;
|
|
41
|
+
} catch (_) { /* the answer still says a sitting is open, and its id */ }
|
|
42
|
+
return { closed: false, item, stage, opened: !!r.opened, status, cast };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { closeGenesisStage };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- government_038_vote_cast_via.sql — HOW a vote was cast (task 1005121).
|
|
2
|
+
--
|
|
3
|
+
-- Closing a genesis stage is one call: where the constitution says one person
|
|
4
|
+
-- decides (first_ratifier) and that person is the founder asking to close it, the
|
|
5
|
+
-- close casts their confirming yes in the same breath (board.js closeGenesisStage).
|
|
6
|
+
-- The owner's condition (2026-11-07): a ratification carries a person's name, so
|
|
7
|
+
-- the record must say HOW it was cast — a confirm given by closing the stage — not
|
|
8
|
+
-- merely that the founder voted. Every vote before this column is a ballot.
|
|
9
|
+
--
|
|
10
|
+
-- cast_via 'ballot' a vote cast in the Board Room (the vote route)
|
|
11
|
+
-- 'stage_close' the confirm given by closing a genesis stage; only
|
|
12
|
+
-- board.js writes it, only on a genesis_stage item, only yes
|
|
13
|
+
|
|
14
|
+
BEGIN;
|
|
15
|
+
|
|
16
|
+
ALTER TABLE government_board_votes
|
|
17
|
+
ADD COLUMN IF NOT EXISTS cast_via text NOT NULL DEFAULT 'ballot';
|
|
18
|
+
|
|
19
|
+
ALTER TABLE government_board_votes DROP CONSTRAINT IF EXISTS government_board_votes_cast_via_check;
|
|
20
|
+
ALTER TABLE government_board_votes ADD CONSTRAINT government_board_votes_cast_via_check
|
|
21
|
+
CHECK (cast_via IN ('ballot', 'stage_close') AND (cast_via = 'ballot' OR direction = 'yes'));
|
|
22
|
+
|
|
23
|
+
COMMIT;
|
|
@@ -126,12 +126,11 @@ const governmentPort = {
|
|
|
126
126
|
// outcome is written by a close, never by the module reading it.
|
|
127
127
|
boardPassForIdea: board.boardPassForIdea,
|
|
128
128
|
boardPassedIdeaIds: board.boardPassedIdeaIds,
|
|
129
|
-
// task 1004429 (BV2.PS04): a finished genesis stage is put to the board
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// the constitution in force. And the founding band's read: per stage, what it
|
|
133
|
-
// is waiting on. A pass is announced as `board.genesis_stage.passed`.
|
|
129
|
+
// task 1004429 (BV2.PS04): a finished genesis stage is put to the board (the gate
|
|
130
|
+
// lives in board.js; an ordinary item under the constitution in force), and the
|
|
131
|
+
// founding band's read. task 1005121: closeGenesisStage closes it in one call.
|
|
134
132
|
openGenesisStageClose: board.openGenesisStageClose,
|
|
133
|
+
closeGenesisStage: require('../genesis-close').closeGenesisStage, // task 1005121: one call closes a stage
|
|
135
134
|
// task 1004941: POST /government/board/matters' own openMatter, for a module's proposal (callers gate on board.item.open)
|
|
136
135
|
openMatter: board.openMatter,
|
|
137
136
|
// task 1004427 (BV2.PS13): the genesis charter + legislation choice, filed as
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
// rank/permission-CRUD refusals stayed with the Permissions surface — this page
|
|
101
101
|
// cannot provoke them.
|
|
102
102
|
const ERROR_COPY = {
|
|
103
|
+
bad_cast_via: 'That vote could not be recorded that way — cast it from the ballot.',
|
|
103
104
|
not_board_member: 'Only members of this item’s board may vote on it — membership follows the rules that were in force when the window opened.',
|
|
104
105
|
item_closed: 'This sitting has closed — refreshing.',
|
|
105
106
|
vote_window_expired: 'This sitting’s clock has run out — the sweep is closing it.',
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
<script src="/builders/hall-ask.js"></script>
|
|
91
91
|
<!-- the in-place update window a hosted project's hall opens (task 1005041) -->
|
|
92
92
|
<script src="/builders/deploy-handoff.js?v=2026-11-03-handoff"></script>
|
|
93
|
-
<script src="/builders/deploy.js?v=2026-11-
|
|
93
|
+
<script src="/builders/deploy.js?v=2026-11-09-status"></script>
|
|
94
94
|
</body>
|
|
95
95
|
</html>
|
|
@@ -426,12 +426,63 @@
|
|
|
426
426
|
Reconnecting sets up <strong>lasting</strong> access: the hub keeps a key of its own for this repository, so this change and every later upgrade are saved to GitHub without asking you again.</p>`;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
// ---- the plain status line (task 1005012) ----------------------------------
|
|
430
|
+
//
|
|
431
|
+
// The owner opened this page to put merged work live and found neither a button nor a
|
|
432
|
+
// sentence: the box was already on the newest version — it had moved there by itself on
|
|
433
|
+
// its update rule — and the page said only "nothing to deploy". So the box LEADS with one
|
|
434
|
+
// line in words, up to date or not, and says how the next version reaches it. Every
|
|
435
|
+
// fact in it is the server's: `behind`, the served version and the update rule. The page
|
|
436
|
+
// still compares no versions; it only words what it was told.
|
|
437
|
+
//
|
|
438
|
+
// How the next version arrives depends on the rule alone. On "Stay put" nothing arrives
|
|
439
|
+
// by itself, so saying it would is the one sentence here that could be false.
|
|
440
|
+
const AUTO_RULES = new Set(['patch', 'minor']);
|
|
441
|
+
function nextVersionWords(p) {
|
|
442
|
+
if (AUTO_RULES.has(p.update_channel)) {
|
|
443
|
+
return 'When a newer version is published, it arrives on its own under the update rule below — nothing needs pressing.';
|
|
444
|
+
}
|
|
445
|
+
if (p.update_channel === 'pinned') {
|
|
446
|
+
return 'The update rule below is Stay put, so a newer version waits until you change the update rule.';
|
|
447
|
+
}
|
|
448
|
+
return 'When a newer version is published, a deploy button appears here.';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Merged is not live: a merge reaches this project only inside a published version. On
|
|
452
|
+
// the hall's own view the npm-release module lists where each piece of work has got to,
|
|
453
|
+
// so the line points at it when it is there, and says nothing about a list that is not.
|
|
454
|
+
function waitingWorkWords() {
|
|
455
|
+
const mods = window.__MODULES__;
|
|
456
|
+
const listed = view.isSelf && !!(mods && mods.enabled && mods.enabled['npm-release']);
|
|
457
|
+
return `Work merged after that is not live until a published version carries it${
|
|
458
|
+
listed ? ' — “Where is the work” on this page shows where each piece has got to' : ''}.`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function upToDateHtml(p) {
|
|
462
|
+
const v = escapeHtml(String(p.served_version));
|
|
463
|
+
return `
|
|
464
|
+
<div class="dp-status dp-status--ok" role="status">
|
|
465
|
+
<p class="ov-row__sub"><strong>Up to date.</strong> This project runs core ${v}, the newest version
|
|
466
|
+
it can take. Every piece of merged work inside core ${v} is live. There is nothing to deploy.</p>
|
|
467
|
+
<p class="ov-row__sub">${escapeHtml(nextVersionWords(p))} ${escapeHtml(waitingWorkWords())}</p>
|
|
468
|
+
</div>`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Behind: the one primary action on the page. It is a PERSON'S button and stays one —
|
|
472
|
+
// the project's law is that going live waits for a person, so pressing it opens the
|
|
473
|
+
// check and the confirm (ADR 0293 D4), and nothing restarts until "Move to …" is pressed.
|
|
474
|
+
// The id is the step-one id it always was, so the two-step wiring is unchanged.
|
|
429
475
|
function stepOneHtml(p) {
|
|
476
|
+
const to = escapeHtml(String(p.recommended_version));
|
|
477
|
+
const auto = AUTO_RULES.has(p.update_channel)
|
|
478
|
+
? ' It would also arrive on its own under the update rule below; this does it now.' : '';
|
|
430
479
|
return `
|
|
431
|
-
<
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
480
|
+
<div class="dp-status dp-status--behind" role="status">
|
|
481
|
+
<p class="ov-row__sub"><strong>Not up to date.</strong> Core <strong>${to}</strong> is published; this project still runs ${escapeHtml(String(p.served_version))}.${auto}</p>
|
|
482
|
+
<div class="ps-controls">
|
|
483
|
+
<button type="button" class="btn btn--primary" id="dp-preview-${escapeHtml(String(p.instance_id))}">Deploy core ${to} now</button>
|
|
484
|
+
</div>
|
|
485
|
+
<p class="ov-row__sub dp-status__note">You see what it brings in and confirm before anything restarts.</p>
|
|
435
486
|
</div>`;
|
|
436
487
|
}
|
|
437
488
|
|
|
@@ -613,7 +664,7 @@
|
|
|
613
664
|
if (moving.has(p.instance_id)) action = movingHtml(p);
|
|
614
665
|
else if (p.open_intent) action = busyHtml(p);
|
|
615
666
|
else if (p.reading === 'no_answer') action = '';
|
|
616
|
-
else if (!p.behind) action =
|
|
667
|
+
else if (!p.behind) action = upToDateHtml(p);
|
|
617
668
|
else action = step === 'confirm' ? stepTwoHtml(p, isSelf) : stepOneHtml(p);
|
|
618
669
|
if (!moving.has(p.instance_id)) action += settledHtml(p);
|
|
619
670
|
return `
|
|
@@ -334,19 +334,14 @@
|
|
|
334
334
|
return window.OTB.sleep(3500);
|
|
335
335
|
}).then(function () { window.location.reload(); }));
|
|
336
336
|
} else if (t.hasAttribute('data-genesis-close')) {
|
|
337
|
-
//
|
|
338
|
-
//
|
|
337
|
+
// One call (task 1005121): where the founder's one yes decides, the server
|
|
338
|
+
// closes the stage with it; otherwise it answers 202 with what the sitting
|
|
339
|
+
// waits on, and that sentence is shown.
|
|
339
340
|
act(send('POST', '/founding/stages/' + encodeURIComponent(t.getAttribute('data-genesis-close')) + '/close', {})
|
|
340
|
-
.then(function () {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
var b = s && s.board;
|
|
345
|
-
var solo = b && b.state === 'sitting' && b.one_vote_decides && b.item_id
|
|
346
|
-
&& (b.waiting_on || []).length === 1 && String(b.waiting_on[0].builder_id) === String(d.viewer_id);
|
|
347
|
-
return solo ? send('POST', '/government/board/items/' + encodeURIComponent(b.item_id) + '/votes', { direction: 'yes' }) : null;
|
|
348
|
-
})
|
|
349
|
-
.then(function () { return load(host, state); }));
|
|
341
|
+
.then(function (out) {
|
|
342
|
+
if (out && out.closed === false && out.next) toast(out.next);
|
|
343
|
+
return load(host, state);
|
|
344
|
+
}));
|
|
350
345
|
} else if (t.hasAttribute('data-genesis-confirm')) {
|
|
351
346
|
act(send('POST', '/government/board/items/' + encodeURIComponent(t.getAttribute('data-genesis-confirm')) + '/votes', { direction: 'yes' })
|
|
352
347
|
.then(function () { return load(host, state); }));
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
const SKY_LABEL = 'The project\'s ideas as a night sky over the city\'s real skyline, with this week\'s ideators ranked beside it';
|
|
58
58
|
const CITY_LABEL = 'The project as a city: one tower per working area, as tall as what has shipped there, with the builders of the week beside it';
|
|
59
59
|
|
|
60
|
-
let host = null, model = null, viewerId = null, t = 0, target = 0, raf = null, run = null, lastWeek = -1, shown = false, gallery = null;
|
|
60
|
+
let host = null, model = null, viewerId = null, t = 0, target = 0, raf = null, run = null, lastWeek = -1, shown = false, gallery = null, ideaBox = null;
|
|
61
61
|
const rungs = new Map();
|
|
62
62
|
|
|
63
63
|
function mode() {
|
|
@@ -292,11 +292,15 @@
|
|
|
292
292
|
|
|
293
293
|
// ---- the artist's Home (task 1005060): in Artistry the same section is the gallery,
|
|
294
294
|
// home-gallery.js, the bench in the museum the city's art district opens onto ----
|
|
295
|
+
// In Ideating, the idea box (home-idea-box.js) opens Home above the sky, and shows even
|
|
296
|
+
// when the sky could not be read: filing an idea needs no picture of the others.
|
|
295
297
|
function craftHome() {
|
|
296
298
|
const art = !!gallery && document.documentElement.dataset.craft === 'artistry';
|
|
299
|
+
const idea = !!ideaBox && craft() === 'ideating';
|
|
297
300
|
host.classList.toggle('hc--gallery', art);
|
|
298
301
|
if (gallery) gallery.show(art);
|
|
299
|
-
|
|
302
|
+
if (ideaBox) ideaBox.show(idea);
|
|
303
|
+
host.hidden = !(art || idea || shown);
|
|
300
304
|
}
|
|
301
305
|
|
|
302
306
|
// ---- the governor's Home is the street; tests swap the hop for a recorder ----
|
|
@@ -338,6 +342,7 @@
|
|
|
338
342
|
wireTip();
|
|
339
343
|
wireDoors();
|
|
340
344
|
if (window.OTBHomeGallery) gallery = window.OTBHomeGallery.attach(host);
|
|
345
|
+
if (window.OTBHomeIdeaBox) ideaBox = window.OTBHomeIdeaBox.attach(host);
|
|
341
346
|
document.addEventListener('otb:craft', craftHome);
|
|
342
347
|
craftHome();
|
|
343
348
|
new MutationObserver(() => { if (model) drawScene(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* modules/hall-ui/public/home-idea-box.css — the idea box that opens the ideator's Home
|
|
2
|
+
(home-idea-box.js; record modules/hall-ui/records/home-city.md § "The idea box").
|
|
3
|
+
It sits at the top of the city's section, over the sky. Every colour is a hall token
|
|
4
|
+
or a mix of one; the tokens carry both modes, so no rule here is written per mode.
|
|
5
|
+
Every type size and radius is a --ds-* scale token (DESIGN.md, the Fifteen Rule), and
|
|
6
|
+
the narrow layout comes from wrapping rather than a breakpoint, so the sheet adds no
|
|
7
|
+
typed-in value to the design counts. No :root, no motion. */
|
|
8
|
+
|
|
9
|
+
.hib { padding: clamp(16px, 4vw, 28px) clamp(16px, 4vw, 28px) clamp(14px, 3vw, 20px); display: grid; gap: 10px; }
|
|
10
|
+
.hib[hidden] { display: none; }
|
|
11
|
+
.hib-h { margin: 0; font: 600 var(--ds-text-4xl)/1.25 var(--font-display); color: var(--ink); }
|
|
12
|
+
.hib-h label { cursor: text; }
|
|
13
|
+
/* The box takes the row; on a narrow screen the button wraps under it */
|
|
14
|
+
.hib-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
|
|
15
|
+
.hib .hib-title {
|
|
16
|
+
width: 100%; min-width: 0; box-sizing: border-box;
|
|
17
|
+
font: 400 var(--ds-text-xl)/1.4 var(--font-body); color: var(--ink);
|
|
18
|
+
background: var(--bg-card); border: var(--line) solid var(--rule-soft); border-radius: var(--radius-sm);
|
|
19
|
+
}
|
|
20
|
+
.hib textarea.hib-title {
|
|
21
|
+
flex: 1 1 16rem; width: auto; min-height: 48px; max-height: 40vh; padding: 12px 20px;
|
|
22
|
+
font-size: var(--ds-text-2xl); line-height: 22px;
|
|
23
|
+
border-radius: var(--ds-radius-panel); resize: none; overflow-y: auto;
|
|
24
|
+
}
|
|
25
|
+
.hib .hib-title::placeholder { color: var(--ink-faint); }
|
|
26
|
+
.hib .hib-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
|
|
27
|
+
.hib-file { min-height: 48px; padding: 0 22px; font-size: var(--ds-text-lg); flex-shrink: 0; }
|
|
28
|
+
.hib-said { margin: 0; min-height: 1.4em; font: 400 var(--ds-text-md)/1.4 var(--font-body); color: var(--ink-soft); }
|
|
29
|
+
.hib-said--err { color: var(--danger); }
|
|
30
|
+
.hib-said a { color: var(--ink); }
|
|
31
|
+
|
|
32
|
+
/* The setup guide is about building on your own machine; in Ideating, Home opens on the box */
|
|
33
|
+
html[data-craft="ideating"] #welcome-scroll { display: none; }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// modules/hall-ui/public/home-idea-box.js — the ideator's Home opens on the idea box
|
|
2
|
+
// (goal 1000095, the owner's "work-first hall", 2026-11-04; record
|
|
3
|
+
// modules/hall-ui/records/home-city.md § "The idea box").
|
|
4
|
+
//
|
|
5
|
+
// In Ideating, the first thing on Home is a place to type an idea and file it; the sky
|
|
6
|
+
// and the ladder of ideators stay under it, secondary. It is ONE box and a button
|
|
7
|
+
// (owner, 2026-11-05: "only one box"): Enter files, Shift+Enter starts a new line, and
|
|
8
|
+
// the box grows with what is typed. The first line is the idea's title, any lines after
|
|
9
|
+
// it its details. Filing is POST /inbox as the sky's quick composer files
|
|
10
|
+
// (sky-composer.js): { title, body_md, kind: 'feature' }. A failed filing keeps the
|
|
11
|
+
// words where they were and says why; a filed one says where it went and links to
|
|
12
|
+
// Your thinking, where every thought the builder filed is listed.
|
|
13
|
+
//
|
|
14
|
+
// It is not a hall widget of its own (Home's stats zone holds one): home-city.js
|
|
15
|
+
// attaches it at the top of its section and shows it when the craft is Ideating, the
|
|
16
|
+
// way it shows the gallery in Artistry. It reads nothing; it writes one thing.
|
|
17
|
+
(function () {
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
var API = '/api/bongos';
|
|
21
|
+
|
|
22
|
+
// ---- pure: what the line under the box says (node-testable) ----
|
|
23
|
+
function explain(res) {
|
|
24
|
+
var err = res && res.data && res.data.error;
|
|
25
|
+
var code = err && (typeof err === 'object' ? err.code : err);
|
|
26
|
+
var details = (err && typeof err === 'object' && err.details) || {};
|
|
27
|
+
if (details.reason) return 'Not filed: ' + details.reason + '. Your idea is still here.';
|
|
28
|
+
if (res && (res.status === 401 || res.status === 403)) return 'Not filed: your session may have expired. Reload and try again.';
|
|
29
|
+
if (code) return 'Not filed: ' + String(code).replace(/_/g, ' ') + '. Your idea is still here.';
|
|
30
|
+
return 'Not filed' + (res && res.status ? ' (' + res.status + ')' : '') + '. Nothing was saved; your idea is still here.';
|
|
31
|
+
}
|
|
32
|
+
// the first non-empty line is the title; whatever follows it is the details
|
|
33
|
+
function body(text) {
|
|
34
|
+
var lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
|
35
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
36
|
+
return { title: (lines.shift() || '').trim(), body_md: lines.join('\n').trim(), kind: 'feature' };
|
|
37
|
+
}
|
|
38
|
+
var Lib = { explain: explain, body: body };
|
|
39
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = Lib;
|
|
40
|
+
if (typeof document === 'undefined') return;
|
|
41
|
+
|
|
42
|
+
function attach(host) {
|
|
43
|
+
var api = null, filing = false;
|
|
44
|
+
var root = document.createElement('form');
|
|
45
|
+
root.className = 'hib';
|
|
46
|
+
root.setAttribute('novalidate', '');
|
|
47
|
+
root.setAttribute('aria-labelledby', 'hib-h');
|
|
48
|
+
root.hidden = true;
|
|
49
|
+
root.innerHTML =
|
|
50
|
+
'<h2 class="hib-h" id="hib-h"><label for="hib-title">What is your idea?</label></h2>' +
|
|
51
|
+
'<div class="hib-row">' +
|
|
52
|
+
'<textarea id="hib-title" class="hib-title" rows="1" placeholder="One line is enough" aria-describedby="hib-keys"></textarea>' +
|
|
53
|
+
'<button type="submit" class="btn hib-file">File idea</button>' +
|
|
54
|
+
'</div>' +
|
|
55
|
+
'<p class="sr-only" id="hib-keys">Enter files the idea. Shift and Enter starts a new line.</p>' +
|
|
56
|
+
'<p class="hib-said" role="status" aria-live="polite"></p>';
|
|
57
|
+
host.insertBefore(root, host.firstChild);
|
|
58
|
+
var title = root.querySelector('.hib-title');
|
|
59
|
+
var said = root.querySelector('.hib-said'), btn = root.querySelector('.hib-file');
|
|
60
|
+
|
|
61
|
+
// the box is as tall as what is in it
|
|
62
|
+
function fit() { title.style.height = 'auto'; title.style.height = title.scrollHeight + 'px'; }
|
|
63
|
+
title.addEventListener('input', fit);
|
|
64
|
+
// Enter files, Shift+Enter is a new line; a key that is still composing a character is left alone
|
|
65
|
+
title.addEventListener('keydown', function (e) {
|
|
66
|
+
if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
if (typeof root.requestSubmit === 'function') root.requestSubmit(); else root.dispatchEvent(new Event('submit', { cancelable: true }));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function say(text, link) {
|
|
72
|
+
said.textContent = text;
|
|
73
|
+
said.classList.toggle('hib-said--err', !link && !!text);
|
|
74
|
+
if (link) {
|
|
75
|
+
var a = document.createElement('a');
|
|
76
|
+
a.href = '/thinking';
|
|
77
|
+
a.textContent = 'See it in Your thinking';
|
|
78
|
+
said.append(' ', a);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
root.addEventListener('submit', function (e) {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (filing) return;
|
|
85
|
+
var b = body(title.value);
|
|
86
|
+
if (!b.title) { say('Type your idea first.'); title.focus(); return; }
|
|
87
|
+
api = api || (window.BongosClient && window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false }));
|
|
88
|
+
if (!api) { say(explain(null)); return; }
|
|
89
|
+
filing = true; btn.disabled = true; say('');
|
|
90
|
+
api.request('POST', API + '/inbox', { body: b }).catch(function () { return null; }).then(function (res) {
|
|
91
|
+
filing = false; btn.disabled = false;
|
|
92
|
+
if (!res || !res.ok || !res.data || !res.data.idea) { say(explain(res)); return; }
|
|
93
|
+
title.value = ''; fit();
|
|
94
|
+
say(res.data.routed_task ? 'Filed, and it is already a task.' : 'Filed. It waits for the next triage.', true);
|
|
95
|
+
title.focus();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
// shown in Ideating
|
|
101
|
+
show: function (on) { root.hidden = !on; },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
window.OTBHomeIdeaBox = { attach: attach, lib: Lib };
|
|
106
|
+
})();
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
<link rel="stylesheet" href="/builders/hall-ask.css">
|
|
24
24
|
<link rel="stylesheet" href="/builders/home-city.css">
|
|
25
25
|
<link rel="stylesheet" href="/builders/home-gallery.css">
|
|
26
|
+
<link rel="stylesheet" href="/builders/home-idea-box.css">
|
|
26
27
|
</head>
|
|
27
28
|
<body data-page="home">
|
|
28
29
|
|
|
@@ -133,6 +134,7 @@
|
|
|
133
134
|
<script src="/builders/city-draw.js" defer></script>
|
|
134
135
|
<script src="/builders/home-city-draw.js" defer></script>
|
|
135
136
|
<script src="/builders/home-gallery.js" defer></script>
|
|
137
|
+
<script src="/builders/home-idea-box.js" defer></script>
|
|
136
138
|
<script src="/builders/home-city.js" defer></script>
|
|
137
139
|
<!-- task 1003455: the shared blocker-row renderer (window.OTBBlockersLib), read by
|
|
138
140
|
both goal-inbox.js's attention-inbox slice below and the /blockers page.
|
|
@@ -212,6 +212,21 @@
|
|
|
212
212
|
}
|
|
213
213
|
.dp-settled .ps-controls { margin: 0.4rem 0 0.2rem; }
|
|
214
214
|
|
|
215
|
+
/* The box's plain status line (task 1005012): up to date, or not and here is the button.
|
|
216
|
+
The same left-mark grammar as the settled states — ok when nothing waits, the accent
|
|
217
|
+
when a published version is waiting for a person's press. */
|
|
218
|
+
.dp-status { margin: 0.4rem 0 0; }
|
|
219
|
+
.dp-status--ok {
|
|
220
|
+
border-left: 3px solid var(--ok);
|
|
221
|
+
padding-left: 0.7rem;
|
|
222
|
+
}
|
|
223
|
+
.dp-status--behind {
|
|
224
|
+
border-left: 3px solid var(--accent);
|
|
225
|
+
padding-left: 0.7rem;
|
|
226
|
+
}
|
|
227
|
+
.dp-status .ps-controls { margin: 0.5rem 0 0.3rem; }
|
|
228
|
+
.dp-status__note { color: var(--ink-soft); }
|
|
229
|
+
|
|
215
230
|
/* The project's update rule (task 1004445) — a quiet setting under the move, set apart by
|
|
216
231
|
a hairline so it does not read as part of whichever state sits above it. */
|
|
217
232
|
.dp-rule {
|