Public: anyone can read this; the crew can change it.
A proposer picks the quorum rule, and the proposal says which one it is #26
Check · Review · Land · Live done — Landed and live in production.
ledger/quorum:ledger/21-a-proposer-picks-the-quorum-rule-and-the → main 1 commit fork
12 files
changed +434
−50
README.md +22 −6
| @@ -24,6 +24,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 24 | 24 | | file | what it is | |
| 25 | 25 | | ---------------------------------- | ------------------------------------------------------------ | |
| 26 | 26 | | `src/actions.ts` | propose, list, read, withdraw, health — and who may | |
| 27 | | `src/quorum.ts` | the three rules as plain data: read one, say it, count it | | |
| 27 | 28 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 28 | 29 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 29 | 30 | | `src/live.ts` | the event log, the bus, and `GET /events` | |
| @@ -58,14 +59,16 @@ In a browser, `/` is every open proposal — newest first, with its author, | ||
| 58 | 59 | how many options it carries and where it stands — and the composer for |
| 59 | 60 | whoever is signed in. `/p/<id>` is one proposal: the paragraph, the options |
| 60 | 61 | in order, and a Withdraw form for its author or an admin while it is open. |
| 61 | A guest sees both pages and no forms. | |
| 62 | The composer picks the rule from a select, with one field beside it for the | |
| 63 | names and count a `named:` rule needs; `/p/<id>` says the rule in words above | |
| 64 | the options. A guest sees both pages and no forms. | |
| 62 | 65 | |
| 63 | 66 | sh |
| 64 | 67 | curl https://<app>/api/proposals # every proposal, newest first |
| 65 | 68 | curl 'https://<app>/api/proposals?state=open' |
| 66 | 69 | curl https://<app>/api/proposals/1 # one, with its options in order |
| 67 | 70 | curl -X POST https://<app>/api/proposals -H 'content-type: application/json' \ |
| 68 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"]}' | |
| 71 | -d '{"title":"Adopt the four-day week","body":"A trial for one quarter.","options":["Yes","No"],"rule":"named:alice,bob,carol:2"}' | |
| 69 | 72 | curl -X POST https://<app>/api/proposals/1/withdraw |
| 70 | 73 | curl https://<app>/api/health # 200 while sound, 503 when it is not |
| 71 | 74 | |
| @@ -84,8 +87,20 @@ of the variable and what it costs, never its value. | ||
| 84 | 87 | |
| 85 | 88 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to |
| 86 | 89 | eight options, each labelled, each different. Its author or an admin can |
| 87 | withdraw it while it is open, once. `rule` says how it will be decided and | |
| 88 | is `majority` for now. | |
| 90 | withdraw it while it is open, once. | |
| 91 | ||
| 92 | `rule` says how it will be decided. The proposer picks it when they open the | |
| 93 | proposal — on the page, over the API, in MCP or at the terminal — and it is | |
| 94 | one line of text in one of three forms: | |
| 95 | ||
| 96 | ||
| 97 | majority more than half of the group on one option decides | |
| 98 | all everyone has voted and they all agree | |
| 99 | named:alice,bob,carol:2 2 of those three agree; nobody else counts | |
| 100 | ||
| 101 | ||
| 102 | Anything else is refused with a sentence naming the three forms, and no rule | |
| 103 | at all means `majority`. A proposal keeps the rule it was opened with. | |
| 89 | 104 | |
| 90 | 105 | As an MCP server, for Claude Code or Claude Desktop — `list_proposals`, |
| 91 | 106 | `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action |
| @@ -115,8 +130,9 @@ runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge | ||
| 115 | 130 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 116 | 131 | a real pty over the same data, as the account at the keyboard: `j`/`k` |
| 117 | 132 | moves, Enter opens the proposal under the cursor, `n` proposes (a title, |
| 118 | then the paragraph, then options one per line until a blank line), `w` | |
| 119 | withdraws, Esc goes back and `q` quits. A guest is told to sign in rather | |
| 133 | then the paragraph, then options one per line until a blank line, then the | |
| 134 | rule — Enter alone for `majority`), `w` withdraws, Esc goes back and `q` | |
| 135 | quits. A guest is told to sign in rather | |
| 120 | 136 | than shown a composer. |
| 121 | 137 | |
| 122 | 138 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
src/actions.test.ts +84
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | } from "./actions.ts"; |
| 14 | 14 | import { migrations, openDb } from "./db.ts"; |
| 15 | 15 | import { useDb } from "./live.ts"; |
| 16 | import type { Rule } from "./quorum.ts"; | |
| 16 | 17 | import type { Who } from "./who.ts"; |
| 17 | 18 | |
| 18 | 19 | const db = openDb(mkdtempSync(join(tmpdir(), "app-actions-"))); |
| @@ -106,6 +107,89 @@ test("a title is at most a hundred and twenty characters, a paragraph two thousa | ||
| 106 | 107 | expect(made(alice, { body: "x".repeat(2000) }).body.length).toBe(2000); |
| 107 | 108 | }); |
| 108 | 109 | |
| 110 | test("the proposer picks the rule, and it is read once on the way out", () => { | |
| 111 | const forms: [string, Rule][] = [ | |
| 112 | ["majority", { kind: "majority" }], | |
| 113 | ["all", { kind: "all" }], | |
| 114 | [ | |
| 115 | "named:alice,bob,carol:2", | |
| 116 | { kind: "named", who: ["alice", "bob", "carol"], needed: 2 }, | |
| 117 | ], | |
| 118 | ]; | |
| 119 | for (const [text, parsed] of forms) { | |
| 120 | const opened = made(alice, { rule: text }); | |
| 121 | expect(opened.rule).toBe(text); | |
| 122 | expect(opened.rule_parsed).toEqual(parsed); | |
| 123 | ||
| 124 | const read = getProposal(db, anon, { id: opened.id }); | |
| 125 | expect(read.ok && read.value.rule).toBe(text); | |
| 126 | expect(read.ok && read.value.rule_parsed).toEqual(parsed); | |
| 127 | const listed = listProposals(db, anon, { state: "open", limit: 1 }); | |
| 128 | expect(listed.ok && listed.value[0]?.id).toBe(opened.id); | |
| 129 | expect(listed.ok && listed.value[0]?.rule_parsed).toEqual(parsed); | |
| 130 | // Withdrawing hands back the same proposal, rule and all. | |
| 131 | const gone = withdrawProposal(db, alice, { id: opened.id }); | |
| 132 | expect(gone.ok && gone.value.rule).toBe(text); | |
| 133 | expect(gone.ok && gone.value.rule_parsed).toEqual(parsed); | |
| 134 | } | |
| 135 | ||
| 136 | // No rule at all is majority, exactly as it was before there was a choice. | |
| 137 | for (const rule of [undefined, "", " "]) { | |
| 138 | const quiet = made(alice, { rule }); | |
| 139 | expect(quiet.rule).toBe("majority"); | |
| 140 | expect(quiet.rule_parsed).toEqual({ kind: "majority" }); | |
| 141 | } | |
| 142 | // Surrounding space is typing, not text: what parses is what is stored. | |
| 143 | expect(made(alice, { rule: " named:alice,bob:1 " }).rule).toBe( | |
| 144 | "named:alice,bob:1", | |
| 145 | ); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a rule that is not one of the three forms is refused, by name", () => { | |
| 149 | const bad = [ | |
| 150 | "named:alice,bob:3", // more needed than there are names | |
| 151 | "named::1", // nobody named | |
| 152 | "named:,:2", // still nobody | |
| 153 | "named:alice,bob:0", // nobody needs to agree | |
| 154 | "named:alice,alice:1", // the same person twice | |
| 155 | "named:alice bob:1", // a space is not a separator | |
| 156 | "unanimous", | |
| 157 | "majority:9", | |
| 158 | "Majority", | |
| 159 | "all:2", | |
| 160 | "x".repeat(500), | |
| 161 | ]; | |
| 162 | for (const rule of bad) { | |
| 163 | const r = propose(db, alice, { ...good, rule } as typeof good); | |
| 164 | expect(r.ok, rule.slice(0, 24)).toBe(false); | |
| 165 | if (!r.ok) { | |
| 166 | expect(r.error).toBe("invalid"); | |
| 167 | // The sentence names all three forms, so nobody has to guess. | |
| 168 | expect(r.message).toContain("majority"); | |
| 169 | expect(r.message).toContain("all"); | |
| 170 | expect(r.message).toContain("named:alice,bob:2"); | |
| 171 | } | |
| 172 | } | |
| 173 | }); | |
| 174 | ||
| 175 | test("a stored rule this app cannot read still reads, as no rule at all", () => { | |
| 176 | const opened = made(alice); | |
| 177 | // A row from before the proposer could choose, or one edited by hand. | |
| 178 | db.query("UPDATE proposal SET rule = ?1 WHERE id = ?2").run( | |
| 179 | "consensus", | |
| 180 | opened.id, | |
| 181 | ); | |
| 182 | const read = getProposal(db, anon, { id: opened.id }); | |
| 183 | expect(read.ok).toBe(true); | |
| 184 | if (!read.ok) return; | |
| 185 | expect(read.value.rule).toBe("consensus"); | |
| 186 | expect(read.value.rule_parsed).toBeUndefined(); | |
| 187 | const listed = listProposals(db, anon, { state: "open" }); | |
| 188 | expect( | |
| 189 | listed.ok && listed.value.find((p) => p.id === opened.id)?.rule_parsed, | |
| 190 | ).toBeUndefined(); | |
| 191 | }); | |
| 192 | ||
| 109 | 193 | test("only the author or an admin withdraws, and only once", () => { |
| 110 | 194 | const mine = made(alice); |
| 111 | 195 | expect(canWithdraw(alice, mine)).toBe(true); |
src/actions.ts +53 −23
| @@ -12,11 +12,13 @@ import { dirname } from "node:path"; | ||
| 12 | 12 | import { migrations, quickCheck, schemaVersion } from "./db.ts"; |
| 13 | 13 | import { commit, env, identityState, ssh } from "./env.ts"; |
| 14 | 14 | import { emit } from "./live.ts"; |
| 15 | import { parseRule, RULE_FORMS, type Rule } from "./quorum.ts"; | |
| 15 | 16 | import type { Who } from "./who.ts"; |
| 16 | 17 | |
| 17 | 18 | export type State = "open" | "decided" | "withdrawn"; |
| 18 | 19 | |
| 19 | export interface Proposal { | |
| 20 | /** A proposal as the database holds it: `rule` is the text that was stored. */ | |
| 21 | interface ProposalRow { | |
| 20 | 22 | readonly id: number; |
| 21 | 23 | readonly title: string; |
| 22 | 24 | readonly body: string; |
| @@ -29,6 +31,25 @@ export interface Proposal { | ||
| 29 | 31 | readonly updated_at: string; |
| 30 | 32 | } |
| 31 | 33 | |
| 34 | /** | |
| 35 | * A proposal as a door gets it: the row, with its rule already read. Every | |
| 36 | * proposal that leaves this file has been through `parseRule` once, so no | |
| 37 | * door reads that text a second time. `rule_parsed` is `undefined` when the | |
| 38 | * stored text is not a rule this app knows — a row from before the choice | |
| 39 | * existed, or one edited by hand — and the row still reads. | |
| 40 | */ | |
| 41 | export interface Proposal extends ProposalRow { | |
| 42 | readonly rule_parsed: Rule | undefined; | |
| 43 | } | |
| 44 | ||
| 45 | /** The row as it leaves: the rule read once, here, and nowhere else. */ | |
| 46 | const read = <T extends ProposalRow>( | |
| 47 | row: T, | |
| 48 | ): T & { rule_parsed: Rule | undefined } => ({ | |
| 49 | ...row, | |
| 50 | rule_parsed: parseRule(row.rule), | |
| 51 | }); | |
| 52 | ||
| 32 | 53 | export interface Option { |
| 33 | 54 | readonly id: number; |
| 34 | 55 | readonly proposal_id: number; |
| @@ -40,6 +61,9 @@ export interface Option { | ||
| 40 | 61 | export interface ProposalSummary extends Proposal { |
| 41 | 62 | readonly option_count: number; |
| 42 | 63 | } |
| 64 | interface SummaryRow extends ProposalRow { | |
| 65 | readonly option_count: number; | |
| 66 | } | |
| 43 | 67 | |
| 44 | 68 | /** A proposal on its own page: the row with its options, in order. */ |
| 45 | 69 | export interface FullProposal extends Proposal { |
| @@ -63,8 +87,8 @@ export const LABEL_MAX = 80; | ||
| 63 | 87 | export const OPTIONS_MIN = 2; |
| 64 | 88 | export const OPTIONS_MAX = 8; |
| 65 | 89 | export const STATES: readonly State[] = ["open", "decided", "withdrawn"]; |
| 66 | /** How a proposal is decided. Only one rule exists so far; the rules module adds the rest. */ | |
| 67 | export const RULES: readonly string[] = ["majority"]; | |
| 90 | /** What a rule that is not a rule is told. The forms live in `quorum.ts`. */ | |
| 91 | const RULE_MESSAGE = `A rule is one of ${RULE_FORMS}.`; | |
| 68 | 92 | |
| 69 | 93 | const SUMMARY = |
| 70 | 94 | "SELECT p.*, (SELECT count(*) FROM option WHERE proposal_id = p.id) AS option_count FROM proposal p"; |
| @@ -81,21 +105,21 @@ export function listProposals( | ||
| 81 | 105 | const state = input.state; |
| 82 | 106 | if (state !== undefined && !STATES.includes(state as State)) |
| 83 | 107 | return fail("invalid", `A state is one of ${STATES.join(", ")}.`); |
| 84 | return ok( | |
| 108 | const rows = | |
| 85 | 109 | state === undefined |
| 86 | 110 | ? db |
| 87 | 111 | .query< |
| 88 | ProposalSummary, | |
| 112 | SummaryRow, | |
| 89 | 113 | [number] |
| 90 | 114 | >(`${SUMMARY} ORDER BY p.id DESC LIMIT ?1`) |
| 91 | 115 | .all(limit) |
| 92 | 116 | : db |
| 93 | 117 | .query< |
| 94 | ProposalSummary, | |
| 118 | SummaryRow, | |
| 95 | 119 | [string, number] |
| 96 | 120 | >(`${SUMMARY} WHERE p.state = ?1 ORDER BY p.id DESC LIMIT ?2`) |
| 97 | .all(state, limit), | |
| 98 | ); | |
| 121 | .all(state, limit); | |
| 122 | return ok(rows.map(read)); | |
| 99 | 123 | } |
| 100 | 124 | |
| 101 | 125 | export function getProposal( |
| @@ -106,7 +130,7 @@ export function getProposal( | ||
| 106 | 130 | if (!Number.isInteger(input.id)) |
| 107 | 131 | return fail("invalid", "A proposal id is a whole number."); |
| 108 | 132 | const proposal = db |
| 109 | .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 133 | .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 110 | 134 | .get(input.id); |
| 111 | 135 | if (proposal === null) return fail("not_found", "No such proposal."); |
| 112 | 136 | const options = db |
| @@ -115,7 +139,7 @@ export function getProposal( | ||
| 115 | 139 | [number] |
| 116 | 140 | >("SELECT * FROM option WHERE proposal_id = ?1 ORDER BY position") |
| 117 | 141 | .all(proposal.id); |
| 118 | return ok({ ...proposal, options }); | |
| 142 | return ok({ ...read(proposal), options }); | |
| 119 | 143 | } |
| 120 | 144 | |
| 121 | 145 | export function propose( |
| @@ -136,9 +160,10 @@ export function propose( | ||
| 136 | 160 | const body = String(input.body ?? "").trim(); |
| 137 | 161 | if (body.length === 0 || body.length > BODY_MAX) |
| 138 | 162 | return fail("invalid", `A paragraph is 1 to ${BODY_MAX} characters.`); |
| 139 | const rule = String(input.rule ?? "majority").trim() || "majority"; | |
| 140 | if (!RULES.includes(rule)) | |
| 141 | return fail("invalid", `A rule is one of ${RULES.join(", ")}.`); | |
| 163 | // The rule is the proposer's, once: the text they gave is what is stored, | |
| 164 | // and `parseRule` is what says whether it is a rule at all. | |
| 165 | const rule = String(input.rule ?? "").trim() || "majority"; | |
| 166 | if (parseRule(rule) === undefined) return fail("invalid", RULE_MESSAGE); | |
| 142 | 167 | if (!Array.isArray(input.options)) |
| 143 | 168 | return fail( |
| 144 | 169 | "invalid", |
| @@ -165,7 +190,7 @@ export function propose( | ||
| 165 | 190 | const created = db.transaction((): FullProposal => { |
| 166 | 191 | const proposal = db |
| 167 | 192 | .query< |
| 168 | Proposal, | |
| 193 | ProposalRow, | |
| 169 | 194 | [string, string, string, string, string] |
| 170 | 195 | >("INSERT INTO proposal (title, body, rule, author_id, author_name) VALUES (?1, ?2, ?3, ?4, ?5) RETURNING *") |
| 171 | 196 | .get(title, body, rule, who.id, who.name)!; |
| @@ -174,7 +199,7 @@ export function propose( | ||
| 174 | 199 | [number, string, number] |
| 175 | 200 | >("INSERT INTO option (proposal_id, label, position) VALUES (?1, ?2, ?3) RETURNING *"); |
| 176 | 201 | const options = labels.map((label, i) => insert.get(proposal.id, label, i)!); |
| 177 | return { ...proposal, options }; | |
| 202 | return { ...read(proposal), options }; | |
| 178 | 203 | })(); |
| 179 | 204 | emit("proposal.opened", created); |
| 180 | 205 | return ok(created); |
| @@ -190,7 +215,7 @@ export function withdrawProposal( | ||
| 190 | 215 | if (!Number.isInteger(input.id)) |
| 191 | 216 | return fail("invalid", "A proposal id is a whole number."); |
| 192 | 217 | const proposal = db |
| 193 | .query<Proposal, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 218 | .query<ProposalRow, [number]>("SELECT * FROM proposal WHERE id = ?1") | |
| 194 | 219 | .get(input.id); |
| 195 | 220 | if (proposal === null) return fail("not_found", "No such proposal."); |
| 196 | 221 | if (!canWithdraw(who, proposal)) |
| @@ -203,12 +228,14 @@ export function withdrawProposal( | ||
| 203 | 228 | "invalid", |
| 204 | 229 | `This proposal is already ${proposal.state}; only an open one can be withdrawn.`, |
| 205 | 230 | ); |
| 206 | const withdrawn = db | |
| 207 | .query< | |
| 208 | Proposal, | |
| 209 | [number] | |
| 210 | >("UPDATE proposal SET state = 'withdrawn', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *") | |
| 211 | .get(proposal.id)!; | |
| 231 | const withdrawn = read( | |
| 232 | db | |
| 233 | .query< | |
| 234 | ProposalRow, | |
| 235 | [number] | |
| 236 | >("UPDATE proposal SET state = 'withdrawn', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1 RETURNING *") | |
| 237 | .get(proposal.id)!, | |
| 238 | ); | |
| 212 | 239 | emit("proposal.withdrawn", { id: withdrawn.id }); |
| 213 | 240 | return ok(withdrawn); |
| 214 | 241 | } |
| @@ -305,6 +332,9 @@ function soundness(db: Database, now = Date.now()): string { | ||
| 305 | 332 | * own check; the page and the terminal ask this and the state together, so |
| 306 | 333 | * the rule about who lives once. |
| 307 | 334 | */ |
| 308 | export const canWithdraw = (who: Who, proposal: Proposal): boolean => | |
| 335 | export const canWithdraw = ( | |
| 336 | who: Who, | |
| 337 | proposal: Pick<Proposal, "author_id">, | |
| 338 | ): boolean => | |
| 309 | 339 | who.role === "admin" || |
| 310 | 340 | (who.role === "user" && who.id === proposal.author_id); |
src/mcp.ts +10 −5
| @@ -17,13 +17,13 @@ import { | ||
| 17 | 17 | LABEL_MAX, |
| 18 | 18 | OPTIONS_MAX, |
| 19 | 19 | OPTIONS_MIN, |
| 20 | RULES, | |
| 21 | 20 | STATES, |
| 22 | 21 | TITLE_MAX, |
| 23 | 22 | type Result, |
| 24 | 23 | } from "./actions.ts"; |
| 25 | 24 | import { identityState, title } from "./env.ts"; |
| 26 | 25 | import { fields } from "./http.ts"; |
| 26 | import { RULE_TEXT_MAX } from "./quorum.ts"; | |
| 27 | 27 | import type { Who } from "./who.ts"; |
| 28 | 28 | |
| 29 | 29 | interface Tool { |
| @@ -78,7 +78,7 @@ export const tools: readonly Tool[] = [ | ||
| 78 | 78 | { |
| 79 | 79 | name: "propose", |
| 80 | 80 | description: |
| 81 | "Open a proposal as the signed-in person: a title, a paragraph and two to eight distinct options. Anonymous callers cannot.", | |
| 81 | "Open a proposal as the signed-in person: a title, a paragraph, two to eight distinct options and the rule that decides it. Anonymous callers cannot.", | |
| 82 | 82 | inputSchema: { |
| 83 | 83 | type: "object", |
| 84 | 84 | properties: { |
| @@ -93,8 +93,9 @@ export const tools: readonly Tool[] = [ | ||
| 93 | 93 | }, |
| 94 | 94 | rule: { |
| 95 | 95 | type: "string", |
| 96 | enum: [...RULES], | |
| 97 | description: "How it will be decided (majority).", | |
| 96 | maxLength: RULE_TEXT_MAX, | |
| 97 | description: | |
| 98 | "How it will be decided, in one line: `majority` (more than half the group on one option), `all` (everyone has voted and they all agree), or `named:alice,bob,carol:2` (that many of those named agree, and nobody else counts). `majority` if left out; it cannot be changed afterwards.", | |
| 98 | 99 | }, |
| 99 | 100 | }, |
| 100 | 101 | required: ["title", "body", "options"], |
| @@ -104,7 +105,11 @@ export const tools: readonly Tool[] = [ | ||
| 104 | 105 | title: String(args.title ?? ""), |
| 105 | 106 | body: String(args.body ?? ""), |
| 106 | 107 | options: Array.isArray(args.options) ? args.options : [], |
| 107 | rule: String(args.rule ?? "majority"), | |
| 108 | // Absent stays absent: `propose` is where majority is the default. | |
| 109 | rule: | |
| 110 | args.rule === undefined || args.rule === null | |
| 111 | ? undefined | |
| 112 | : String(args.rule), | |
| 108 | 113 | }), |
| 109 | 114 | }, |
| 110 | 115 | { |
src/pages/home.ts +14 −1
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | } from "../actions.ts"; |
| 14 | 14 | import { where } from "../env.ts"; |
| 15 | 15 | import { escape } from "../http.ts"; |
| 16 | import { RULES, RULE_TEXT_MAX } from "../quorum.ts"; | |
| 16 | 17 | import type { Who } from "../who.ts"; |
| 17 | 18 | import { ago, layout, toast } from "./layout.ts"; |
| 18 | 19 | |
| @@ -25,7 +26,7 @@ export function home(input: { | ||
| 25 | 26 | const composer = |
| 26 | 27 | who.role === "anon" |
| 27 | 28 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=%2F">Sign in</a> to make a proposal.</p>` |
| 28 | : `<form class="stack" method="post" action="/proposals"> | |
| 29 | : `<form class="stack composer" method="post" action="/proposals"> | |
| 29 | 30 | <label class="field"> |
| 30 | 31 | <span>Title</span> |
| 31 | 32 | <input class="input" name="title" placeholder="What is being decided" required maxlength="${TITLE_MAX}" autocomplete="off"> |
| @@ -39,6 +40,18 @@ export function home(input: { | ||
| 39 | 40 | <textarea class="input" name="options" rows="3" placeholder="Yes No" required maxlength="${(LABEL_MAX + 1) * OPTIONS_MAX}"></textarea> |
| 40 | 41 | </label> |
| 41 | 42 | <p class="muted small">${OPTIONS_MIN} to ${OPTIONS_MAX} options, each at most ${LABEL_MAX} characters and each different.</p> |
| 43 | <label class="field"> | |
| 44 | <span>How it is decided</span> | |
| 45 | <select class="input" name="rule">${RULES.map( | |
| 46 | (rule) => | |
| 47 | `<option value="${escape(rule.kind)}"${rule.kind === "majority" ? " selected" : ""}>${escape(rule.label)} — ${escape(rule.description)}</option>`, | |
| 48 | ).join("")}</select> | |
| 49 | </label> | |
| 50 | <label class="field named-field"> | |
| 51 | <span>Names and how many, for “a named few” only</span> | |
| 52 | <input class="input mono" name="named" placeholder="alice,bob,carol:2" autocomplete="off" maxlength="${RULE_TEXT_MAX}"> | |
| 53 | <span class="muted small">Ignored unless the rule above is “a named few”. Person ids separated by commas, then a colon and how many of them must agree.</span> | |
| 54 | </label> | |
| 42 | 55 | <div class="row"><button class="button">Propose</button></div> |
| 43 | 56 | </form>`; |
| 44 | 57 | const list = |
src/pages/proposal.ts +14 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { canWithdraw, type FullProposal } from "../actions.ts"; |
| 8 | 8 | import { escape } from "../http.ts"; |
| 9 | import { describeRule } from "../quorum.ts"; | |
| 9 | 10 | import type { Who } from "../who.ts"; |
| 10 | 11 | import { ago, layout, toast } from "./layout.ts"; |
| 11 | 12 | |
| @@ -27,8 +28,9 @@ export function proposalPage(input: { | ||
| 27 | 28 | <h2>${escape(proposal.title)}</h2> |
| 28 | 29 | <span class="pill" data-state="${escape(proposal.state)}">${escape(proposal.state)}</span> |
| 29 | 30 | </div> |
| 30 | <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time> · decided by ${escape(proposal.rule)}</p> | |
| 31 | <p class="muted small">${escape(proposal.author_name)} · <time datetime="${escape(proposal.created_at)}">${ago(proposal.created_at)}</time></p> | |
| 31 | 32 | <p class="body">${escape(proposal.body)}</p> |
| 33 | <p class="rule small">${rule(proposal)}</p> | |
| 32 | 34 | <ol class="options">${proposal.options |
| 33 | 35 | .map( |
| 34 | 36 | (option) => |
| @@ -42,3 +44,14 @@ export function proposalPage(input: { | ||
| 42 | 44 | `/p/${proposal.id}`, |
| 43 | 45 | ); |
| 44 | 46 | } |
| 47 | ||
| 48 | /** | |
| 49 | * How this one is decided, in words, above the options. The rule was read | |
| 50 | * once in the action; a rule this app cannot read — a row from before the | |
| 51 | * proposer could choose, or one edited by hand — says so and shows the text | |
| 52 | * rather than taking the page down. | |
| 53 | */ | |
| 54 | const rule = (proposal: FullProposal): string => | |
| 55 | proposal.rule_parsed === undefined | |
| 56 | ? `<span class="muted">A rule this app cannot read:</span> <code>${escape(proposal.rule)}</code>` | |
| 57 | : escape(describeRule(proposal.rule_parsed)); |
src/quorum.test.ts +25
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | 3 | type Ballot, |
| 4 | describeRule, | |
| 4 | 5 | formatRule, |
| 5 | 6 | parseRule, |
| 6 | 7 | RULES, |
| 8 | RULE_FORMS, | |
| 7 | 9 | type Rule, |
| 10 | ruleText, | |
| 8 | 11 | tally, |
| 9 | 12 | } from "./quorum.ts"; |
| 10 | 13 | |
| @@ -51,6 +54,28 @@ test("parseRule reads the three spellings and nothing else", () => { | ||
| 51 | 54 | }); |
| 52 | 55 | }); |
| 53 | 56 | |
| 57 | test("a rule says in words what it needs, and the three forms are named once", () => { | |
| 58 | expect(describeRule({ kind: "majority" })).toBe( | |
| 59 | RULES[0]?.description as string, | |
| 60 | ); | |
| 61 | expect(describeRule({ kind: "all" })).toBe(RULES[1]?.description as string); | |
| 62 | expect( | |
| 63 | describeRule({ kind: "named", who: ["alice", "bob", "carol"], needed: 2 }), | |
| 64 | ).toBe("Needs 2 of alice, bob, carol"); | |
| 65 | for (const form of ["majority", "all", "named:alice,bob:2"]) | |
| 66 | expect(RULE_FORMS).toContain(form); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a kind and the names beside it are one line of rule text", () => { | |
| 70 | expect(ruleText("majority", "")).toBe("majority"); | |
| 71 | // The names field is for `named:` alone: another kind ignores it. | |
| 72 | expect(ruleText("all", "alice,bob:2")).toBe("all"); | |
| 73 | expect(ruleText("named", " alice,bob:2 ")).toBe("named:alice,bob:2"); | |
| 74 | // Nothing is judged here; parseRule is what refuses. | |
| 75 | expect(parseRule(ruleText("named", ""))).toBeUndefined(); | |
| 76 | expect(parseRule(ruleText("unanimous", ""))).toBeUndefined(); | |
| 77 | }); | |
| 78 | ||
| 54 | 79 | test("parseRule refuses, without throwing, everything it does not know", () => { |
| 55 | 80 | const refused: [string, unknown][] = [ |
| 56 | 81 | ["an unknown kind", "unanimous"], |
src/quorum.ts +26 −2
| @@ -52,12 +52,36 @@ export const RULES = [ | ||
| 52 | 52 | }, |
| 53 | 53 | ] as const; |
| 54 | 54 | |
| 55 | /** The three forms in one clause, for a boundary to say what it refuses. */ | |
| 56 | export const RULE_FORMS = | |
| 57 | "majority, all, or named:alice,bob:2 (the names that count, then how many of them must agree)"; | |
| 58 | ||
| 59 | /** | |
| 60 | * One sentence saying what this rule needs, in the words `RULES` carries — | |
| 61 | * so the page, the terminal and anything else say the same thing. A `named` | |
| 62 | * rule names its people, and `parseRule` has already held those names to | |
| 63 | * `[A-Za-z0-9_.-]`; a caller escapes this like any other text. | |
| 64 | */ | |
| 65 | export function describeRule(rule: Rule): string { | |
| 66 | if (rule.kind === "named") | |
| 67 | return `Needs ${rule.needed} of ${rule.who.join(", ")}`; | |
| 68 | return RULES.find((r) => r.kind === rule.kind)!.description; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * The one line of rule text a two-field chooser means: the kind a person | |
| 73 | * picked, and the names and count they typed beside it for `named:`. | |
| 74 | * Nothing is judged here — `parseRule`, at the action, is what refuses. | |
| 75 | */ | |
| 76 | export const ruleText = (kind: string, named: string): string => | |
| 77 | kind.trim() === "named" ? `named:${named.trim()}` : kind.trim(); | |
| 78 | ||
| 55 | 79 | /** A person's id is the forge's `person_id`, never a display name. */ |
| 56 | 80 | const NAME = /^[A-Za-z0-9_.-]+$/; |
| 57 | 81 | const NAME_MAX = 64; |
| 58 | 82 | const NAMES_MAX = 32; |
| 59 | 83 | /** Longer than any rule this file can write, so parsing is bounded work. */ |
| 60 | const TEXT_MAX = 2048; | |
| 84 | export const RULE_TEXT_MAX = 2048; | |
| 61 | 85 | |
| 62 | 86 | /** |
| 63 | 87 | * Read stored rule text. Exact text only — no trimming, no case folding — |
| @@ -67,7 +91,7 @@ const TEXT_MAX = 2048; | ||
| 67 | 91 | * `needed` that is not a whole number from 1 to the number of names. |
| 68 | 92 | */ |
| 69 | 93 | export function parseRule(text: unknown): Rule | undefined { |
| 70 | if (typeof text !== "string" || text.length > TEXT_MAX) return undefined; | |
| 94 | if (typeof text !== "string" || text.length > RULE_TEXT_MAX) return undefined; | |
| 71 | 95 | if (text === "majority") return { kind: "majority" }; |
| 72 | 96 | if (text === "all") return { kind: "all" }; |
| 73 | 97 |
src/routes.ts +12 −2
| @@ -30,6 +30,7 @@ import { rpc } from "./mcp.ts"; | ||
| 30 | 30 | import { home } from "./pages/home.ts"; |
| 31 | 31 | import { missing } from "./pages/layout.ts"; |
| 32 | 32 | import { proposalPage } from "./pages/proposal.ts"; |
| 33 | import { ruleText } from "./quorum.ts"; | |
| 33 | 34 | import type { Who } from "./who.ts"; |
| 34 | 35 | |
| 35 | 36 | export interface Context { |
| @@ -81,6 +82,10 @@ const lines = (value: string): string[] => | ||
| 81 | 82 | .map((line) => line.trim()) |
| 82 | 83 | .filter((line) => line !== ""); |
| 83 | 84 | |
| 85 | /** A field a caller may leave out: absent stays absent, so the action's default holds. */ | |
| 86 | const given = (value: unknown): string | undefined => | |
| 87 | value === undefined || value === null ? undefined : String(value); | |
| 88 | ||
| 84 | 89 | /** Whatever JSON carried as a list of options, as a list. */ |
| 85 | 90 | const optionsOf = (value: unknown): unknown[] => |
| 86 | 91 | Array.isArray(value) ? value : typeof value === "string" ? lines(value) : []; |
| @@ -129,7 +134,12 @@ export const routes: readonly Route[] = [ | ||
| 129 | 134 | title: String(form.get("title") ?? ""), |
| 130 | 135 | body: String(form.get("body") ?? ""), |
| 131 | 136 | options: lines(String(form.get("options") ?? "")), |
| 132 | rule: String(form.get("rule") ?? "majority"), | |
| 137 | // The composer is a select and a text field; one line of rule text | |
| 138 | // is what they mean together, and the action judges it. | |
| 139 | rule: ruleText( | |
| 140 | String(form.get("rule") ?? "majority"), | |
| 141 | String(form.get("named") ?? ""), | |
| 142 | ), | |
| 133 | 143 | }); |
| 134 | 144 | return made.ok |
| 135 | 145 | ? back(undefined, `/p/${made.value.id}`) |
| @@ -176,7 +186,7 @@ export const routes: readonly Route[] = [ | ||
| 176 | 186 | title: String(input.title ?? ""), |
| 177 | 187 | body: String(input.body ?? ""), |
| 178 | 188 | options: optionsOf(input.options), |
| 179 | rule: String(input.rule ?? "majority"), | |
| 189 | rule: given(input.rule), | |
| 180 | 190 | }), |
| 181 | 191 | 201, |
| 182 | 192 | ); |
src/server.test.ts +115
| @@ -142,6 +142,121 @@ test("a proposal page shows the options and hides the form from a guest", async | ||
| 142 | 142 | expect(mine).toContain(`action="/proposals/${made.id}/withdraw"`); |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | test("the rule a proposer picks round-trips, and the page says what it means", async () => { | |
| 146 | const picker = as("u5", "picker"); | |
| 147 | const forms: [string, string, string][] = [ | |
| 148 | ["majority", "majority", "More than half of the group"], | |
| 149 | ["all", "all", "Every member of the group"], | |
| 150 | ["named:alice,bob,carol:2", "named", "Needs 2 of alice, bob, carol"], | |
| 151 | ]; | |
| 152 | for (const [rule, kind, words] of forms) { | |
| 153 | const made = await open(picker, { rule }); | |
| 154 | const back = (await ( | |
| 155 | await fetch(`${base}/api/proposals/${made.id}`) | |
| 156 | ).json()) as { rule: string; rule_parsed: { kind: string } }; | |
| 157 | expect(back.rule).toBe(rule); | |
| 158 | expect(back.rule_parsed.kind).toBe(kind); | |
| 159 | const page = await (await fetch(`${base}/p/${made.id}`)).text(); | |
| 160 | expect(page).toContain(words); | |
| 161 | } | |
| 162 | ||
| 163 | // No rule is still majority, and the composer offers all three kinds. | |
| 164 | const plain = await open(picker); | |
| 165 | expect( | |
| 166 | ( | |
| 167 | (await (await fetch(`${base}/api/proposals/${plain.id}`)).json()) as { | |
| 168 | rule: string; | |
| 169 | } | |
| 170 | ).rule, | |
| 171 | ).toBe("majority"); | |
| 172 | const composer = await (await api("/", {}, picker)).text(); | |
| 173 | expect(composer).toContain('<select class="input" name="rule">'); | |
| 174 | for (const kind of ["majority", "all", "named"]) | |
| 175 | expect(composer).toContain(`<option value="${kind}"`); | |
| 176 | expect(composer).toContain('name="named"'); | |
| 177 | expect(composer).toContain("a named few"); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a rule the app cannot read is refused at the API, by name", async () => { | |
| 181 | const picker = as("u6", "pedant"); | |
| 182 | for (const rule of [ | |
| 183 | "named:alice,bob:3", | |
| 184 | "named::2", | |
| 185 | "unanimous", | |
| 186 | "majority:9", | |
| 187 | "x".repeat(500), | |
| 188 | ]) { | |
| 189 | const refused = await api( | |
| 190 | "/api/proposals", | |
| 191 | { method: "POST", ...jsonBody({ ...proposal, rule }) }, | |
| 192 | picker, | |
| 193 | ); | |
| 194 | expect(refused.status, rule.slice(0, 20)).toBe(422); | |
| 195 | const body = (await refused.json()) as { error: string; message: string }; | |
| 196 | expect(body.error).toBe("invalid"); | |
| 197 | expect(body.message).toContain("named:alice,bob:2"); | |
| 198 | } | |
| 199 | }); | |
| 200 | ||
| 201 | test("the composer's select and names field make one rule", async () => { | |
| 202 | const form = new FormData(); | |
| 203 | form.set("title", "Who signs off"); | |
| 204 | form.set("body", "Two of the three of us, and nobody else."); | |
| 205 | form.set("options", "Ship it\nWait"); | |
| 206 | form.set("rule", "named"); | |
| 207 | form.set("named", "alice,bob,carol:2"); | |
| 208 | const posted = await api( | |
| 209 | "/proposals", | |
| 210 | { method: "POST", body: form, redirect: "manual" }, | |
| 211 | bob, | |
| 212 | ); | |
| 213 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 214 | const to = posted.headers.get("location") ?? ""; | |
| 215 | expect(to).toMatch(/^\/p\/\d+$/); | |
| 216 | const page = await (await fetch(base + to)).text(); | |
| 217 | expect(page).toContain("Needs 2 of alice, bob, carol"); | |
| 218 | ||
| 219 | // The same form with a count nobody can reach comes back with the reason. | |
| 220 | form.set("named", "alice,bob:3"); | |
| 221 | const refused = await api( | |
| 222 | "/proposals", | |
| 223 | { method: "POST", body: form, redirect: "manual" }, | |
| 224 | bob, | |
| 225 | ); | |
| 226 | expect(refused.headers.get("location")).toBe("/"); | |
| 227 | expect(decodeURIComponent(refused.headers.get("set-cookie") ?? "")).toContain( | |
| 228 | "A rule is one of", | |
| 229 | ); | |
| 230 | }); | |
| 231 | ||
| 232 | test("the MCP propose tool describes the three forms it takes", async () => { | |
| 233 | const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }); | |
| 234 | const schema = listed.result.tools.find( | |
| 235 | (t: { name: string }) => t.name === "propose", | |
| 236 | ).inputSchema; | |
| 237 | for (const form of ["majority", "all", "named:alice,bob,carol:2"]) | |
| 238 | expect(schema.properties.rule.description).toContain(form); | |
| 239 | const called = await rpc( | |
| 240 | { | |
| 241 | jsonrpc: "2.0", | |
| 242 | id: 3, | |
| 243 | method: "tools/call", | |
| 244 | params: { | |
| 245 | name: "propose", | |
| 246 | arguments: { ...proposal, rule: "named:root,alice:1" }, | |
| 247 | }, | |
| 248 | }, | |
| 249 | root, | |
| 250 | ); | |
| 251 | const one = JSON.parse(called.result.content[0].text); | |
| 252 | expect(one.rule).toBe("named:root,alice:1"); | |
| 253 | expect(one.rule_parsed).toEqual({ | |
| 254 | kind: "named", | |
| 255 | who: ["root", "alice"], | |
| 256 | needed: 1, | |
| 257 | }); | |
| 258 | }); | |
| 259 | ||
| 145 | 260 | test("something that is not a proposal is a 404 in the layout", async () => { |
| 146 | 261 | for (const path of ["/p/9999", "/p/not-a-number"]) { |
| 147 | 262 | const response = await fetch(base + path); |
src/tty.ts +44 −10
| @@ -18,6 +18,7 @@ import { | ||
| 18 | 18 | } from "./actions.ts"; |
| 19 | 19 | import { openDb } from "./db.ts"; |
| 20 | 20 | import { ssh, title, where } from "./env.ts"; |
| 21 | import { describeRule, RULE_FORMS } from "./quorum.ts"; | |
| 21 | 22 | import { whoAtTty } from "./who.ts"; |
| 22 | 23 | |
| 23 | 24 | const db = openDb(); |
| @@ -31,10 +32,11 @@ const out = (s: string): void => { | ||
| 31 | 32 | |
| 32 | 33 | /** What is being typed, and how much of a proposal has been said so far. */ |
| 33 | 34 | interface Draft { |
| 34 | step: "title" | "body" | "options"; | |
| 35 | step: "title" | "body" | "options" | "rule"; | |
| 35 | 36 | title: string; |
| 36 | 37 | body: string; |
| 37 | 38 | options: string[]; |
| 39 | rule: string; | |
| 38 | 40 | typing: string; |
| 39 | 41 | } |
| 40 | 42 | |
| @@ -107,11 +109,17 @@ const detail = (id: number): string[] => { | ||
| 107 | 109 | return [ |
| 108 | 110 | bold(proposal.title), |
| 109 | 111 | dim( |
| 110 | `${proposal.author_name} · ${proposal.state} · decided by ${proposal.rule} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`, | |
| 112 | `${proposal.author_name} · ${proposal.state} · ${proposal.created_at.slice(0, 16).replace("T", " ")}`, | |
| 111 | 113 | ), |
| 112 | 114 | "", |
| 113 | 115 | ...wrap(proposal.body), |
| 114 | 116 | "", |
| 117 | dim( | |
| 118 | proposal.rule_parsed === undefined | |
| 119 | ? `A rule this app cannot read: ${proposal.rule}` | |
| 120 | : describeRule(proposal.rule_parsed), | |
| 121 | ), | |
| 122 | "", | |
| 115 | 123 | ...proposal.options.map((o) => ` ${o.position + 1}. ${o.label}`), |
| 116 | 124 | ]; |
| 117 | 125 | }; |
| @@ -128,10 +136,22 @@ const composer = (d: Draft): string[] => { | ||
| 128 | 136 | ? `Paragraph: ${d.typing}${cell}` |
| 129 | 137 | : dim(`Paragraph: ${d.body}`), |
| 130 | 138 | ); |
| 131 | if (d.step === "options") { | |
| 132 | lines.push(dim(`Options (${d.options.length}), blank line when done:`)); | |
| 139 | if (d.step === "options" || d.step === "rule") { | |
| 140 | lines.push( | |
| 141 | dim( | |
| 142 | d.step === "options" | |
| 143 | ? `Options (${d.options.length}), blank line when done:` | |
| 144 | : `Options (${d.options.length}):`, | |
| 145 | ), | |
| 146 | ); | |
| 133 | 147 | d.options.forEach((label, i) => lines.push(dim(` ${i + 1}. ${label}`))); |
| 134 | lines.push(` ${d.options.length + 1}. ${d.typing}${cell}`); | |
| 148 | if (d.step === "options") | |
| 149 | lines.push(` ${d.options.length + 1}. ${d.typing}${cell}`); | |
| 150 | } | |
| 151 | if (d.step === "rule") { | |
| 152 | lines.push(""); | |
| 153 | lines.push(dim(`Rule — ${RULE_FORMS}. Enter for majority.`)); | |
| 154 | lines.push(`Rule: ${d.typing}${cell}`); | |
| 135 | 155 | } |
| 136 | 156 | return lines; |
| 137 | 157 | }; |
| @@ -150,6 +170,7 @@ function submit(d: Draft): void { | ||
| 150 | 170 | title: d.title, |
| 151 | 171 | body: d.body, |
| 152 | 172 | options: d.options, |
| 173 | rule: d.rule, | |
| 153 | 174 | }); |
| 154 | 175 | if (made.ok) { |
| 155 | 176 | said = `Proposed: ${made.value.title}`; |
| @@ -159,8 +180,9 @@ function submit(d: Draft): void { | ||
| 159 | 180 | } else { |
| 160 | 181 | // Keep what was typed: the message says what to change. |
| 161 | 182 | said = made.message; |
| 162 | d.step = "options"; | |
| 163 | d.typing = ""; | |
| 183 | d.step = d.rule === "" ? "options" : "rule"; | |
| 184 | d.typing = d.rule; | |
| 185 | d.rule = ""; | |
| 164 | 186 | } |
| 165 | 187 | } |
| 166 | 188 | |
| @@ -203,8 +225,13 @@ process.stdin.on("data", (key: string) => { | ||
| 203 | 225 | d.body = d.typing.trim(); |
| 204 | 226 | d.step = "options"; |
| 205 | 227 | d.typing = ""; |
| 206 | } else if (d.typing.trim() === "") submit(d); | |
| 207 | else if (d.options.length >= OPTIONS_MAX) | |
| 228 | } else if (d.step === "rule") { | |
| 229 | d.rule = d.typing.trim(); | |
| 230 | submit(d); | |
| 231 | } else if (d.typing.trim() === "") { | |
| 232 | d.step = "rule"; | |
| 233 | d.typing = ""; | |
| 234 | } else if (d.options.length >= OPTIONS_MAX) | |
| 208 | 235 | said = `A proposal has at most ${OPTIONS_MAX} options; press Enter on a blank line.`; |
| 209 | 236 | else { |
| 210 | 237 | d.options.push(d.typing.trim()); |
| @@ -237,7 +264,14 @@ process.stdin.on("data", (key: string) => { | ||
| 237 | 264 | if (me.role === "anon") |
| 238 | 265 | said = "Sign in through the forge to make a proposal."; |
| 239 | 266 | else |
| 240 | draft = { step: "title", title: "", body: "", options: [], typing: "" }; | |
| 267 | draft = { | |
| 268 | step: "title", | |
| 269 | title: "", | |
| 270 | body: "", | |
| 271 | options: [], | |
| 272 | rule: "", | |
| 273 | typing: "", | |
| 274 | }; | |
| 241 | 275 | } |
| 242 | 276 | if (key === "w") withdraw(under); |
| 243 | 277 | draw(); |
static/app.css +15
| @@ -139,6 +139,16 @@ code, | ||
| 139 | 139 | font-size: var(); |
| 140 | 140 | font-weight: 600; |
| 141 | 141 | } |
| 142 | .field > span.muted { | |
| 143 | font-weight: 400; | |
| 144 | } | |
| 145 | /* The names for `named:` matter only to that rule. Hidden when another kind | |
| 146 | is chosen — and shown, plainly labelled, wherever :has is not understood, | |
| 147 | so the field is never unreachable. */ | |
| 148 | .composer:has(select[name="rule"] option:checked:not([value="named"])) | |
| 149 | .named-field { | |
| 150 | display: none; | |
| 151 | } | |
| 142 | 152 | .small { |
| 143 | 153 | font-size: var(); |
| 144 | 154 | } |
| @@ -190,6 +200,11 @@ code, | ||
| 190 | 200 | white-space: pre-wrap; |
| 191 | 201 | overflow-wrap: anywhere; |
| 192 | 202 | } |
| 203 | .rule { | |
| 204 | margin-bottom: var(); | |
| 205 | color: var(); | |
| 206 | overflow-wrap: anywhere; | |
| 207 | } | |
| 193 | 208 | .options { |
| 194 | 209 | margin: 0 0 var(); |
| 195 | 210 | padding-left: 1.4rem; |