Public: anyone can read this; the crew can change it.
The roster: a public list of who counts toward a quorum, kept by an admin #16
ledger/quorum:ledger/4-the-roster-a-public-list-of-who-counts-t → main 2 commits fork 1 thread · 1 unresolved
12 files
changed +665
−28
AGENTS.md +4 −3
| @@ -27,7 +27,7 @@ preview at its own address, deployed from your branch as you push. | ||
| 27 | 27 | |
| 28 | 28 | One implementation, many doors. Every operation is a function in |
| 29 | 29 | `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and |
| 30 | the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`), | |
| 30 | the pages (`src/routes.ts`, `src/pages/`), the JSON API (`src/routes.ts`), | |
| 31 | 31 | the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A |
| 32 | 32 | door reads its wire format and shapes the answer; it decides nothing. A |
| 33 | 33 | rule in an action holds at every door at once; a rule anywhere else is a |
| @@ -44,8 +44,9 @@ To add a concept: | ||
| 44 | 44 | a test in `src/actions.test.ts` that fails without the rule. |
| 45 | 45 | - **A migration** — append to `migrations` in `src/db.ts`. Never edit one |
| 46 | 46 | that has run. `bun run src/db.ts --check` shows the version. |
| 47 | - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`. | |
| 48 | With a second page, move them under `src/pages/` and keep one `layout`. | |
| 47 | - **A page** — a row in `src/routes.ts` and a file under `src/pages/` that | |
| 48 | exports one function and draws through the one `layout` (add it to the | |
| 49 | nav there). | |
| 49 | 50 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 50 | 51 | answers with `answer(...)`; the status follows the failure. |
| 51 | 52 | - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence, |
README.md +44 −17
| @@ -21,20 +21,20 @@ plain function — `(db, who, input)` in, data or a named failure out — and | ||
| 21 | 21 | the page, the JSON API, the MCP server and the terminal all call it and |
| 22 | 22 | decide nothing themselves. `AGENTS.md` says how to add to it. |
| 23 | 23 | |
| 24 | | file | what it is | | |
| 25 | | ---------------------------------- | ------------------------------------------------------------ | | |
| 26 | | `src/actions.ts` | propose, list, read, withdraw, health — and who may | | |
| 27 | | `src/who.ts` | who is asking, from the forge's signed headers | | |
| 28 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | | |
| 29 | | `src/live.ts` | the event log, the bus, and `GET /events` | | |
| 30 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | | |
| 31 | | `src/routes.ts` | every path, in one table | | |
| 32 | | `src/pages/` | the HTML, rendered on the server: one layout, two pages | | |
| 33 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | | |
| 34 | | `src/server.ts` | boot: open the database, serve | | |
| 35 | | `src/tty.ts` | the terminal, over the same actions | | |
| 36 | | `src/env.ts` | what the forge told this process | | |
| 37 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | | |
| 24 | | file | what it is | | |
| 25 | | ---------------------------------- | --------------------------------------------------------------- | | |
| 26 | | `src/actions.ts` | propose, list, read, withdraw, the roster, health — and who may | | |
| 27 | | `src/who.ts` | who is asking, from the forge's signed headers | | |
| 28 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | | |
| 29 | | `src/live.ts` | the event log, the bus, and `GET /events` | | |
| 30 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | | |
| 31 | | `src/routes.ts` | every path, in one table | | |
| 32 | | `src/pages/` | the HTML, rendered on the server: one layout, three pages | | |
| 33 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | | |
| 34 | | `src/server.ts` | boot: open the database, serve | | |
| 35 | | `src/tty.ts` | the terminal, over the same actions | | |
| 36 | | `src/env.ts` | what the forge told this process | | |
| 37 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | | |
| 38 | 38 | |
| 39 | 39 | ## Who you are |
| 40 | 40 | |
| @@ -44,11 +44,38 @@ verifies the signature (`src/who.ts`); nothing else is trusted. Three tiers: | ||
| 44 | 44 | - **anon** — anyone. Reads. |
| 45 | 45 | - **user** — signed in to the forge and able to see this app. Makes |
| 46 | 46 | proposals, as themselves, and withdraws their own. |
| 47 | - **admin** — able to write this repository. Withdraws any proposal. | |
| 47 | - **admin** — able to write this repository. Withdraws any proposal, and | |
| 48 | keeps the group. | |
| 48 | 49 | |
| 49 | 50 | "Sign in" is `/__forge/signin?to=<path>` and "Sign out" is |
| 50 | 51 | `/__forge/signout`; the forge answers both on the app's own host. |
| 51 | 52 | |
| 53 | ## The group | |
| 54 | ||
| 55 | A quorum needs a denominator, so the group is an explicit list of people: | |
| 56 | `/group` shows it, and **anyone can read it** — signed out, signed in, over | |
| 57 | the API or over MCP. Only an admin adds and removes, and the page tells a | |
| 58 | signed-in person whether they are in it. On an empty roster an admin is | |
| 59 | offered "Add me", which is the ordinary add with their own identity. | |
| 60 | ||
| 61 | A member is the forge's `person_id` (1–64 of `A-Za-z0-9_.-`) and a name | |
| 62 | (1–64 characters) that is only ever shown: matching is on the id, so a | |
| 63 | rename keeps someone in the group. Adding someone already in it changes | |
| 64 | nothing and is not an error, removing someone who is not in it is | |
| 65 | `not_found`, and the group holds at most 256 people. | |
| 66 | ||
| 67 | sh | |
| 68 | curl https://<app>/api/members | |
| 69 | curl -X POST https://<app>/api/members -H 'content-type: application/json' \ | |
| 70 | -d '{"person_id":"u1","name":"alice"}' | |
| 71 | curl -X DELETE https://<app>/api/members/u1 | |
| 72 | ||
| 73 | ||
| 74 | In the terminal, `g` shows the group — with j/k and a window, so a full | |
| 75 | roster does not scroll away — and says whether the account at the keyboard | |
| 76 | is in it; Esc goes back. The group is read-only there for now; adding and | |
| 77 | removing are the page, the API and MCP. | |
| 78 | ||
| 52 | 79 | ## Any interface |
| 53 | 80 | |
| 54 | 81 | The same actions, the same rules, at every door. A script is the person |
| @@ -88,8 +115,8 @@ withdraw it while it is open, once. `rule` says how it will be decided and | ||
| 88 | 115 | is `majority` for now. |
| 89 | 116 | |
| 90 | 117 | As an MCP server, for Claude Code or Claude Desktop — `list_proposals`, |
| 91 | `get_proposal`, `propose`, `withdraw_proposal`, `health` (the same action | |
| 92 | behind `/api/health`, gated the same way): | |
| 118 | `get_proposal`, `propose`, `withdraw_proposal`, `list_members`, `health` | |
| 119 | (the same action behind `/api/health`, gated the same way): | |
| 93 | 120 | |
| 94 | 121 | sh |
| 95 | 122 | claude mcp add --transport http __TITLE__ https://<app>/mcp |
src/actions.test.ts +99
| @@ -3,12 +3,17 @@ import { mkdtempSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { |
| 6 | addMember, | |
| 6 | 7 | canWithdraw, |
| 7 | 8 | getProposal, |
| 8 | 9 | health, |
| 10 | isMember, | |
| 11 | listMembers, | |
| 9 | 12 | listProposals, |
| 10 | 13 | propose, |
| 14 | removeMember, | |
| 11 | 15 | withdrawProposal, |
| 16 | ROSTER_MAX, | |
| 12 | 17 | type AdminHealth, |
| 13 | 18 | } from "./actions.ts"; |
| 14 | 19 | import { migrations, openDb } from "./db.ts"; |
| @@ -211,3 +216,97 @@ test("a schema behind the migration list is not ok", () => { | ||
| 211 | 216 | expect(r.value.schemaVersion).toBe(migrations.length - 1); |
| 212 | 217 | behind.close(); |
| 213 | 218 | }); |
| 219 | // The roster gets its own file: the cap test fills one to the brim. | |
| 220 | const roster = () => openDb(mkdtempSync(join(tmpdir(), "app-roster-"))); | |
| 221 | ||
| 222 | test("anyone reads the group; only an admin keeps it", () => { | |
| 223 | const db = roster(); | |
| 224 | expect(listMembers(db, anon)).toEqual({ ok: true, value: [] }); | |
| 225 | const byAnon = addMember(db, anon, { personId: "u1", name: "alice" }); | |
| 226 | expect(byAnon.ok).toBe(false); | |
| 227 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); | |
| 228 | const byUser = addMember(db, alice, { personId: "u1", name: "alice" }); | |
| 229 | expect(byUser.ok).toBe(false); | |
| 230 | if (!byUser.ok) expect(byUser.error).toBe("forbidden"); | |
| 231 | expect(isMember(db, alice)).toBe(false); | |
| 232 | ||
| 233 | const added = addMember(db, root, { personId: " u1 ", name: " alice " }); | |
| 234 | expect(added.ok).toBe(true); | |
| 235 | if (!added.ok) return; | |
| 236 | expect(added.value.person_id).toBe("u1"); | |
| 237 | expect(added.value.name).toBe("alice"); | |
| 238 | expect(added.value.added_by).toBe("u0"); | |
| 239 | expect(isMember(db, alice)).toBe(true); | |
| 240 | expect(isMember(db, bob)).toBe(false); | |
| 241 | expect(isMember(db, anon)).toBe(false); | |
| 242 | const all = listMembers(db, anon); | |
| 243 | expect(all.ok && all.value.map((m) => m.person_id)).toEqual(["u1"]); | |
| 244 | }); | |
| 245 | ||
| 246 | test("adding someone already in the group adds no second row", () => { | |
| 247 | const db = roster(); | |
| 248 | const first = addMember(db, root, { personId: "u1", name: "alice" }); | |
| 249 | const again = addMember(db, root, { personId: "u1", name: "alice again" }); | |
| 250 | expect(first.ok && again.ok).toBe(true); | |
| 251 | if (!first.ok || !again.ok) return; | |
| 252 | expect(again.value.id).toBe(first.value.id); | |
| 253 | // The name a rename would change is not the identity, and not clobbered. | |
| 254 | expect(again.value.name).toBe("alice"); | |
| 255 | const all = listMembers(db, anon); | |
| 256 | expect(all.ok && all.value.length).toBe(1); | |
| 257 | }); | |
| 258 | ||
| 259 | test("a person id is an id, and a name is sixty-four characters at most", () => { | |
| 260 | const db = roster(); | |
| 261 | for (const personId of ["u/1", "u 1", "", " ", "a".repeat(65), "é"]) { | |
| 262 | const r = addMember(db, root, { personId, name: "alice" }); | |
| 263 | expect(r.ok).toBe(false); | |
| 264 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 265 | } | |
| 266 | for (const name of ["", " ", "x".repeat(65)]) { | |
| 267 | const r = addMember(db, root, { personId: "u1", name }); | |
| 268 | expect(r.ok).toBe(false); | |
| 269 | if (!r.ok) expect(r.error).toBe("invalid"); | |
| 270 | } | |
| 271 | expect(addMember(db, root, { personId: "a".repeat(64), name: "x" }).ok).toBe( | |
| 272 | true, | |
| 273 | ); | |
| 274 | expect(addMember(db, root, { personId: "u.1-2_3", name: "ok" }).ok).toBe( | |
| 275 | true, | |
| 276 | ); | |
| 277 | const bad = removeMember(db, root, { personId: "u/1" }); | |
| 278 | expect(bad.ok).toBe(false); | |
| 279 | if (!bad.ok) expect(bad.error).toBe("invalid"); | |
| 280 | }); | |
| 281 | ||
| 282 | test("removing someone who is not in the group is not_found", () => { | |
| 283 | const db = roster(); | |
| 284 | addMember(db, root, { personId: "u1", name: "alice" }); | |
| 285 | const missing = removeMember(db, root, { personId: "u2" }); | |
| 286 | expect(missing.ok).toBe(false); | |
| 287 | if (!missing.ok) expect(missing.error).toBe("not_found"); | |
| 288 | const byUser = removeMember(db, alice, { personId: "u1" }); | |
| 289 | expect(byUser.ok).toBe(false); | |
| 290 | if (!byUser.ok) expect(byUser.error).toBe("forbidden"); | |
| 291 | const byAnon = removeMember(db, anon, { personId: "u1" }); | |
| 292 | expect(byAnon.ok).toBe(false); | |
| 293 | if (!byAnon.ok) expect(byAnon.error).toBe("signin_required"); | |
| 294 | expect(removeMember(db, root, { personId: "u1" }).ok).toBe(true); | |
| 295 | expect(isMember(db, alice)).toBe(false); | |
| 296 | const gone = removeMember(db, root, { personId: "u1" }); | |
| 297 | expect(gone.ok).toBe(false); | |
| 298 | if (!gone.ok) expect(gone.error).toBe("not_found"); | |
| 299 | }); | |
| 300 | ||
| 301 | test("the group holds two hundred and fifty-six people", () => { | |
| 302 | const db = roster(); | |
| 303 | for (let i = 0; i < ROSTER_MAX; i++) | |
| 304 | expect(addMember(db, root, { personId: `p${i}`, name: `p${i}` }).ok).toBe( | |
| 305 | true, | |
| 306 | ); | |
| 307 | const full = addMember(db, root, { personId: "one-more", name: "late" }); | |
| 308 | expect(full.ok).toBe(false); | |
| 309 | if (!full.ok) expect(full.error).toBe("roster_full"); | |
| 310 | // Someone already counted is not a new row, so a full roster still says yes. | |
| 311 | expect(addMember(db, root, { personId: "p0", name: "p0" }).ok).toBe(true); | |
| 312 | }); |
src/actions.ts +106 −1
| @@ -46,7 +46,21 @@ export interface FullProposal extends Proposal { | ||
| 46 | 46 | readonly options: readonly Option[]; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | export type Failure = "signin_required" | "forbidden" | "not_found" | "invalid"; | |
| 49 | /** A member of the group: the forge's `person_id` is the identity, `name` is only for showing. */ | |
| 50 | export interface Member { | |
| 51 | readonly id: number; | |
| 52 | readonly person_id: string; | |
| 53 | readonly name: string; | |
| 54 | readonly added_by: string; | |
| 55 | readonly created_at: string; | |
| 56 | } | |
| 57 | ||
| 58 | export type Failure = | |
| 59 | | "signin_required" | |
| 60 | | "forbidden" | |
| 61 | | "not_found" | |
| 62 | | "invalid" | |
| 63 | | "roster_full"; | |
| 50 | 64 | export type Result<T> = |
| 51 | 65 | | { ok: true; value: T } |
| 52 | 66 | | { ok: false; error: Failure; message: string }; |
| @@ -308,3 +322,94 @@ function soundness(db: Database, now = Date.now()): string { | ||
| 308 | 322 | export const canWithdraw = (who: Who, proposal: Proposal): boolean => |
| 309 | 323 | who.role === "admin" || |
| 310 | 324 | (who.role === "user" && who.id === proposal.author_id); |
| 325 | ||
| 326 | // The roster — the denominator a quorum is measured against. Anyone may read | |
| 327 | // it; only an admin changes it. A member is a `person_id` and nothing else: | |
| 328 | // the name is stored to show and never matched on, so a rename drops no one. | |
| 329 | ||
| 330 | export const ROSTER_MAX = 256; | |
| 331 | export const MEMBER_NAME_MAX = 64; | |
| 332 | const PERSON_ID = /^[A-Za-z0-9_.-]{1,64}$/; | |
| 333 | ||
| 334 | export function listMembers(db: Database, _who: Who): Result<Member[]> { | |
| 335 | return ok( | |
| 336 | db | |
| 337 | .query< | |
| 338 | Member, | |
| 339 | [] | |
| 340 | >("SELECT * FROM member ORDER BY name COLLATE NOCASE ASC, id ASC") | |
| 341 | .all(), | |
| 342 | ); | |
| 343 | } | |
| 344 | ||
| 345 | export function addMember( | |
| 346 | db: Database, | |
| 347 | who: Who, | |
| 348 | input: { personId: string; name: string }, | |
| 349 | ): Result<Member> { | |
| 350 | if (who.role !== "admin") return mustBeAdmin(who, "add someone to the group"); | |
| 351 | const personId = String(input.personId ?? "").trim(); | |
| 352 | const name = String(input.name ?? "").trim(); | |
| 353 | if (!PERSON_ID.test(personId)) | |
| 354 | return fail( | |
| 355 | "invalid", | |
| 356 | "A person id is 1 to 64 of A-Z, a-z, 0-9, dot, dash or underscore.", | |
| 357 | ); | |
| 358 | if (name.length === 0 || name.length > MEMBER_NAME_MAX) | |
| 359 | return fail("invalid", `A name is 1 to ${MEMBER_NAME_MAX} characters.`); | |
| 360 | // Already on the roster: the same request twice is the same roster, and | |
| 361 | // nothing changed, so nothing is emitted. | |
| 362 | const already = findMember(db, personId); | |
| 363 | if (already !== null) return ok(already); | |
| 364 | const count = db | |
| 365 | .query<{ n: number }, []>("SELECT count(*) AS n FROM member") | |
| 366 | .get()!.n; | |
| 367 | if (count >= ROSTER_MAX) | |
| 368 | return fail( | |
| 369 | "roster_full", | |
| 370 | `The group holds ${ROSTER_MAX} people. Remove someone first.`, | |
| 371 | ); | |
| 372 | const member = db | |
| 373 | .query< | |
| 374 | Member, | |
| 375 | [string, string, string] | |
| 376 | >("INSERT INTO member (person_id, name, added_by) VALUES (?1, ?2, ?3) RETURNING *") | |
| 377 | .get(personId, name, who.id)!; | |
| 378 | emit("member.added", member); | |
| 379 | return ok(member); | |
| 380 | } | |
| 381 | ||
| 382 | export function removeMember( | |
| 383 | db: Database, | |
| 384 | who: Who, | |
| 385 | input: { personId: string }, | |
| 386 | ): Result<{ person_id: string }> { | |
| 387 | if (who.role !== "admin") | |
| 388 | return mustBeAdmin(who, "remove someone from the group"); | |
| 389 | const personId = String(input.personId ?? "").trim(); | |
| 390 | if (!PERSON_ID.test(personId)) | |
| 391 | return fail( | |
| 392 | "invalid", | |
| 393 | "A person id is 1 to 64 of A-Z, a-z, 0-9, dot, dash or underscore.", | |
| 394 | ); | |
| 395 | const member = findMember(db, personId); | |
| 396 | if (member === null) return fail("not_found", "That person is not a member."); | |
| 397 | db.query("DELETE FROM member WHERE person_id = ?1").run(personId); | |
| 398 | emit("member.removed", { person_id: personId }); | |
| 399 | return ok({ person_id: personId }); | |
| 400 | } | |
| 401 | ||
| 402 | /** Whether the person asking counts toward a quorum. The vote work leans on this. */ | |
| 403 | export const isMember = (db: Database, who: Who): boolean => | |
| 404 | who.role !== "anon" && findMember(db, who.id) !== null; | |
| 405 | ||
| 406 | const findMember = (db: Database, personId: string): Member | null => | |
| 407 | db | |
| 408 | .query<Member, [string]>("SELECT * FROM member WHERE person_id = ?1") | |
| 409 | .get(personId); | |
| 410 | ||
| 411 | /** Why someone who is not an admin was refused: sign in first, or you may not. */ | |
| 412 | const mustBeAdmin = (who: Who, what: string): Result<never> => | |
| 413 | who.role === "anon" | |
| 414 | ? fail("signin_required", `Sign in to ${what}.`) | |
| 415 | : fail("forbidden", `Only an admin can ${what}.`); |
src/db.ts +9
| @@ -58,6 +58,15 @@ export const migrations: string[] = [ | ||
| 58 | 58 | data TEXT NOT NULL, |
| 59 | 59 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| 60 | 60 | )`, |
| 61 | // The roster: who counts toward a quorum. `person_id` is the forge's id and | |
| 62 | // the only thing matched on, so a rename changes `name` and drops nobody. | |
| 63 | `CREATE TABLE member ( | |
| 64 | id INTEGER PRIMARY KEY, | |
| 65 | person_id TEXT NOT NULL UNIQUE, | |
| 66 | name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 64), | |
| 67 | added_by TEXT NOT NULL, | |
| 68 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) | |
| 69 | )`, | |
| 61 | 70 | ]; |
| 62 | 71 | |
| 63 | 72 | export function openDb(dir = dataDir): Database { |
src/mcp.ts +8
| @@ -10,6 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { |
| 11 | 11 | getProposal, |
| 12 | 12 | health, |
| 13 | listMembers, | |
| 13 | 14 | listProposals, |
| 14 | 15 | propose, |
| 15 | 16 | withdrawProposal, |
| @@ -118,6 +119,13 @@ export const tools: readonly Tool[] = [ | ||
| 118 | 119 | }, |
| 119 | 120 | run: (db, who, args) => withdrawProposal(db, who, { id: Number(args.id) }), |
| 120 | 121 | }, |
| 122 | { | |
| 123 | name: "list_members", | |
| 124 | description: | |
| 125 | "The group: everyone who counts toward a quorum. Public — anyone may read it.", | |
| 126 | inputSchema: { type: "object", properties: {} }, | |
| 127 | run: (db, who) => listMembers(db, who), | |
| 128 | }, | |
| 121 | 129 | { |
| 122 | 130 | name: "health", |
| 123 | 131 | description: |
src/pages/group.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * The group: who counts toward a quorum. Anyone can read this page; the add | |
| 3 | * form and the Remove buttons are drawn for an admin only — but the rule | |
| 4 | * that stops anyone else lives in the action, so this page only decides | |
| 5 | * what to draw, never who may. | |
| 6 | */ | |
| 7 | ||
| 8 | import { MEMBER_NAME_MAX, ROSTER_MAX, type Member } from "../actions.ts"; | |
| 9 | import { escape } from "../http.ts"; | |
| 10 | import type { Who } from "../who.ts"; | |
| 11 | import { ago, layout, toast } from "./layout.ts"; | |
| 12 | ||
| 13 | export function group(input: { | |
| 14 | who: Who; | |
| 15 | members: readonly Member[]; | |
| 16 | isMember: boolean; | |
| 17 | notice?: string | undefined; | |
| 18 | }): string { | |
| 19 | const { who, members, isMember, notice } = input; | |
| 20 | const admin = who.role === "admin"; | |
| 21 | const standing = | |
| 22 | who.role === "anon" | |
| 23 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=%2Fgroup">Sign in</a> to see whether you are in the group.</p>` | |
| 24 | : isMember | |
| 25 | ? `<p><b>You are in the group.</b> <span class="muted">Your vote counts toward a quorum.</span></p>` | |
| 26 | : `<p><b>You are not in the group.</b> <span class="muted">An admin adds people to it.</span></p>`; | |
| 27 | // An empty roster and an admin looking at it: the first member is usually | |
| 28 | // them. It posts to the same action with their own identity — the action | |
| 29 | // has no idea this button exists. The name is cut to what the action takes, | |
| 30 | // so a long forge display name is added rather than refused. | |
| 31 | const addMe = | |
| 32 | who.role === "admin" | |
| 33 | ? `<form method="post" action="/members"> | |
| 34 | <input type="hidden" name="person_id" value="${escape(who.id)}"> | |
| 35 | <input type="hidden" name="name" value="${escape(who.name.slice(0, MEMBER_NAME_MAX))}"> | |
| 36 | <button class="button">Add me</button> | |
| 37 | </form>` | |
| 38 | : ""; | |
| 39 | const list = | |
| 40 | members.length === 0 | |
| 41 | ? `<div class="empty"> | |
| 42 | <p>Nobody is in the group yet. A quorum needs a denominator.</p> | |
| 43 | ${addMe} | |
| 44 | </div>` | |
| 45 | : `<ul class="list">${members.map((member) => row(member, admin)).join("")}</ul>`; | |
| 46 | return layout( | |
| 47 | `<section class="card"> | |
| 48 | ${standing} | |
| 49 | ${ | |
| 50 | admin | |
| 51 | ? `<form class="row row-wrap" method="post" action="/members"> | |
| 52 | <input class="input" name="person_id" placeholder="person id" aria-label="Person id" required maxlength="64" pattern="[A-Za-z0-9_.\\-]+" autocomplete="off"> | |
| 53 | <input class="input" name="name" placeholder="name to show" aria-label="Name" required maxlength="${MEMBER_NAME_MAX}" autocomplete="off"> | |
| 54 | <button class="button">Add to group</button> | |
| 55 | </form> | |
| 56 | <p class="muted small">The person id is the forge's, not the display name: a rename keeps them in. Up to ${ROSTER_MAX} people.</p>` | |
| 57 | : "" | |
| 58 | } | |
| 59 | </section> | |
| 60 | <section id="members" data-live="member.added member.removed"> | |
| 61 | <p class="muted small">${members.length} ${members.length === 1 ? "person counts" : "people count"} toward a quorum.</p> | |
| 62 | ${list} | |
| 63 | </section> | |
| 64 | ${toast(notice)}`, | |
| 65 | who, | |
| 66 | "/group", | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | const row = (member: Member, admin: boolean): string => `<li class="member"> | |
| 71 | <div> | |
| 72 | <p>${escape(member.name)} <span class="muted mono">${escape(member.person_id)}</span></p> | |
| 73 | <small class="muted">since <time datetime="${escape(member.created_at)}">${ago(member.created_at)}</time></small> | |
| 74 | </div> | |
| 75 | ${ | |
| 76 | admin | |
| 77 | ? `<form method="post" action="/members/${escape(encodeURIComponent(member.person_id))}/remove"><button class="button button-quiet" aria-label="Remove ${escape(member.name)} from the group">Remove</button></form>` | |
| 78 | : "" | |
| 79 | } | |
| 80 | </li>`; |
src/pages/layout.ts +11 −1
| @@ -9,6 +9,12 @@ import { commit, description, env, ssh, title, where } from "../env.ts"; | ||
| 9 | 9 | import { asset, escape } from "../http.ts"; |
| 10 | 10 | import type { Who } from "../who.ts"; |
| 11 | 11 | |
| 12 | /** Every page with a place in the nav, in the order a person meets them. */ | |
| 13 | const PAGES = [ | |
| 14 | { path: "/", label: "Proposals" }, | |
| 15 | { path: "/group", label: "Group" }, | |
| 16 | ] as const; | |
| 17 | ||
| 12 | 18 | export function layout(body: string, who: Who, path = "/"): string { |
| 13 | 19 | const to = encodeURIComponent(path); |
| 14 | 20 | const door = |
| @@ -31,6 +37,10 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 31 | 37 | <div> |
| 32 | 38 | <h1><a class="plain" href="/">${escape(title)}</a></h1> |
| 33 | 39 | <p class="muted">${escape(description)}</p> |
| 40 | <nav class="tabs">${PAGES.map( | |
| 41 | (page) => | |
| 42 | `<a class="tab"${page.path === path ? ` aria-current="page"` : ""} href="${page.path}">${escape(page.label)}</a>`, | |
| 43 | ).join("")}</nav> | |
| 34 | 44 | </div> |
| 35 | 45 | <nav class="row"> |
| 36 | 46 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} |
| @@ -40,7 +50,7 @@ export function layout(body: string, who: Who, path = "/"): string { | ||
| 40 | 50 | <main>${body}</main> |
| 41 | 51 | <footer class="muted"> |
| 42 | 52 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} |
| 43 | <p>Also at <a href="/api/proposals">/api/proposals</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 53 | <p>Also at <a href="/api/proposals">/api/proposals</a>, <a href="/api/members">/api/members</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 44 | 54 | </footer> |
| 45 | 55 | </div> |
| 46 | 56 | </body> |
src/routes.ts +78 −1
| @@ -6,10 +6,14 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { |
| 9 | addMember, | |
| 9 | 10 | getProposal, |
| 10 | 11 | health, |
| 12 | isMember, | |
| 13 | listMembers, | |
| 11 | 14 | listProposals, |
| 12 | 15 | propose, |
| 16 | removeMember, | |
| 13 | 17 | withdrawProposal, |
| 14 | 18 | type Failure, |
| 15 | 19 | type Result, |
| @@ -27,6 +31,7 @@ import { | ||
| 27 | 31 | } from "./http.ts"; |
| 28 | 32 | import { events } from "./live.ts"; |
| 29 | 33 | import { rpc } from "./mcp.ts"; |
| 34 | import { group } from "./pages/group.ts"; | |
| 30 | 35 | import { home } from "./pages/home.ts"; |
| 31 | 36 | import { missing } from "./pages/layout.ts"; |
| 32 | 37 | import { proposalPage } from "./pages/proposal.ts"; |
| @@ -43,7 +48,7 @@ export interface Context { | ||
| 43 | 48 | } |
| 44 | 49 | |
| 45 | 50 | export interface Route { |
| 46 | readonly method: "GET" | "POST"; | |
| 51 | readonly method: "GET" | "POST" | "DELETE"; | |
| 47 | 52 | readonly path: string; |
| 48 | 53 | readonly handle: (ctx: Context) => Response | Promise<Response>; |
| 49 | 54 | } |
| @@ -53,6 +58,7 @@ const STATUS: Record<Failure, number> = { | ||
| 53 | 58 | forbidden: 403, |
| 54 | 59 | not_found: 404, |
| 55 | 60 | invalid: 422, |
| 61 | roster_full: 409, | |
| 56 | 62 | }; |
| 57 | 63 | |
| 58 | 64 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| @@ -146,6 +152,50 @@ export const routes: readonly Route[] = [ | ||
| 146 | 152 | ), |
| 147 | 153 | }, |
| 148 | 154 | |
| 155 | // The group: everyone reads it, an admin keeps it. | |
| 156 | { | |
| 157 | method: "GET", | |
| 158 | path: "/group", | |
| 159 | handle: (c) => { | |
| 160 | const { notice, clear } = readNotice(c.request); | |
| 161 | const members = listMembers(c.db, c.who); | |
| 162 | return html( | |
| 163 | group({ | |
| 164 | who: c.who, | |
| 165 | members: members.ok ? members.value : [], | |
| 166 | isMember: isMember(c.db, c.who), | |
| 167 | notice, | |
| 168 | }), | |
| 169 | 200, | |
| 170 | clear, | |
| 171 | ); | |
| 172 | }, | |
| 173 | }, | |
| 174 | { | |
| 175 | method: "POST", | |
| 176 | path: "/members", | |
| 177 | handle: async (c) => { | |
| 178 | const form = await readForm(c.request); | |
| 179 | if (form === undefined) return text("too large", 413); | |
| 180 | return done( | |
| 181 | addMember(c.db, c.who, { | |
| 182 | personId: String(form.get("person_id") ?? ""), | |
| 183 | name: String(form.get("name") ?? ""), | |
| 184 | }), | |
| 185 | "/group", | |
| 186 | ); | |
| 187 | }, | |
| 188 | }, | |
| 189 | { | |
| 190 | method: "POST", | |
| 191 | path: "/members/:personId/remove", | |
| 192 | handle: (c) => | |
| 193 | done( | |
| 194 | removeMember(c.db, c.who, { personId: c.params.personId ?? "" }), | |
| 195 | "/group", | |
| 196 | ), | |
| 197 | }, | |
| 198 | ||
| 149 | 199 | // The JSON API: the same actions, the same identity, plain status codes. |
| 150 | 200 | { |
| 151 | 201 | method: "GET", |
| @@ -188,6 +238,33 @@ export const routes: readonly Route[] = [ | ||
| 188 | 238 | handle: (c) => |
| 189 | 239 | answer(withdrawProposal(c.db, c.who, { id: Number(c.params.id) })), |
| 190 | 240 | }, |
| 241 | { | |
| 242 | method: "GET", | |
| 243 | path: "/api/members", | |
| 244 | handle: (c) => answer(listMembers(c.db, c.who)), | |
| 245 | }, | |
| 246 | { | |
| 247 | method: "POST", | |
| 248 | path: "/api/members", | |
| 249 | handle: async (c) => { | |
| 250 | const body = await readJson(c.request); | |
| 251 | if (body === undefined) return json({ error: "too_large" }, 413); | |
| 252 | const input = fields(body); | |
| 253 | return answer( | |
| 254 | addMember(c.db, c.who, { | |
| 255 | personId: String(input.person_id ?? input.personId ?? ""), | |
| 256 | name: String(input.name ?? ""), | |
| 257 | }), | |
| 258 | 201, | |
| 259 | ); | |
| 260 | }, | |
| 261 | }, | |
| 262 | { | |
| 263 | method: "DELETE", | |
| 264 | path: "/api/members/:personId", | |
| 265 | handle: (c) => | |
| 266 | answer(removeMember(c.db, c.who, { personId: c.params.personId ?? "" })), | |
| 267 | }, | |
| 191 | 268 | |
| 192 | 269 | // What a probe asks, forever: 200 while the app is sound, 503 when it is |
| 193 | 270 | // not, and never a word about anybody's data. |
src/server.test.ts +104
| @@ -242,6 +242,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 242 | 242 | "get_proposal", |
| 243 | 243 | "propose", |
| 244 | 244 | "withdraw_proposal", |
| 245 | "list_members", | |
| 245 | 246 | "health", |
| 246 | 247 | ]); |
| 247 | 248 | const call = (name: string, args: unknown, who = {}) => |
| @@ -375,6 +376,109 @@ test("boot names what is missing, and never what is in it", () => { | ||
| 375 | 376 | expect(quiet[0]).toContain("FORGE_APP_URL"); |
| 376 | 377 | }); |
| 377 | 378 | |
| 379 | test("/group is public to read and an admin's to keep", async () => { | |
| 380 | const guest = await (await fetch(base + "/group")).text(); | |
| 381 | expect(guest).toContain("Nobody is in the group yet"); | |
| 382 | expect(guest).not.toContain('action="/members"'); | |
| 383 | expect(guest).toContain("/__forge/signin?to=%2Fgroup"); | |
| 384 | ||
| 385 | const empty = await (await api("/group", {}, root)).text(); | |
| 386 | expect(empty).toContain("Add me"); | |
| 387 | expect(empty).toContain('value="u0"'); | |
| 388 | ||
| 389 | const asUser = await (await api("/group", {}, alice)).text(); | |
| 390 | expect(asUser).toContain("You are not in the group"); | |
| 391 | expect(asUser).not.toContain('action="/members"'); | |
| 392 | ||
| 393 | const form = new FormData(); | |
| 394 | form.set("person_id", "u1"); | |
| 395 | form.set("name", "alice"); | |
| 396 | const refused = await api( | |
| 397 | "/members", | |
| 398 | { method: "POST", body: form, redirect: "manual" }, | |
| 399 | alice, | |
| 400 | ); | |
| 401 | expect(refused.status).toBe(303); | |
| 402 | expect(refused.headers.get("location")).toBe("/group"); | |
| 403 | expect(refused.headers.get("set-cookie")).toContain("notice="); | |
| 404 | expect(await (await fetch(base + "/group")).text()).not.toContain( | |
| 405 | "Remove alice from the group", | |
| 406 | ); | |
| 407 | ||
| 408 | const added = await api( | |
| 409 | "/members", | |
| 410 | { method: "POST", body: form, redirect: "manual" }, | |
| 411 | root, | |
| 412 | ); | |
| 413 | expect(added.status).toBe(303); | |
| 414 | expect(added.headers.get("location")).toBe("/group"); | |
| 415 | const page = await (await api("/group", {}, alice)).text(); | |
| 416 | expect(page).toContain("You are in the group"); | |
| 417 | expect(page).toContain("alice"); | |
| 418 | expect(page).not.toContain("Remove alice from the group"); | |
| 419 | expect(await (await api("/group", {}, root)).text()).toContain( | |
| 420 | "Remove alice from the group", | |
| 421 | ); | |
| 422 | ||
| 423 | const removed = await api( | |
| 424 | "/members/u1/remove", | |
| 425 | { method: "POST", redirect: "manual" }, | |
| 426 | root, | |
| 427 | ); | |
| 428 | expect(removed.status).toBe(303); | |
| 429 | expect(await (await api("/group", {}, alice)).text()).toContain( | |
| 430 | "You are not in the group", | |
| 431 | ); | |
| 432 | }); | |
| 433 | ||
| 434 | test("the members API reads for a guest and writes for an admin only", async () => { | |
| 435 | expect( | |
| 436 | (await api("/api/members", { method: "POST", ...jsonBody({}) })).status, | |
| 437 | ).toBe(401); | |
| 438 | expect( | |
| 439 | ( | |
| 440 | await api( | |
| 441 | "/api/members", | |
| 442 | { method: "POST", ...jsonBody({ person_id: "u2", name: "bob" }) }, | |
| 443 | alice, | |
| 444 | ) | |
| 445 | ).status, | |
| 446 | ).toBe(403); | |
| 447 | const created = await api( | |
| 448 | "/api/members", | |
| 449 | { method: "POST", ...jsonBody({ person_id: "u2", name: "bob" }) }, | |
| 450 | root, | |
| 451 | ); | |
| 452 | expect(created.status).toBe(201); | |
| 453 | expect(((await created.json()) as { person_id: string }).person_id).toBe( | |
| 454 | "u2", | |
| 455 | ); | |
| 456 | expect( | |
| 457 | ( | |
| 458 | await api( | |
| 459 | "/api/members", | |
| 460 | { method: "POST", ...jsonBody({ person_id: "u2/x", name: "bob" }) }, | |
| 461 | root, | |
| 462 | ) | |
| 463 | ).status, | |
| 464 | ).toBe(422); | |
| 465 | ||
| 466 | const listed = (await (await fetch(base + "/api/members")).json()) as { | |
| 467 | person_id: string; | |
| 468 | }[]; | |
| 469 | expect(listed.map((m) => m.person_id)).toContain("u2"); | |
| 470 | ||
| 471 | expect( | |
| 472 | (await api("/api/members/u2", { method: "DELETE" }, alice)).status, | |
| 473 | ).toBe(403); | |
| 474 | expect( | |
| 475 | (await api("/api/members/u2", { method: "DELETE" }, root)).status, | |
| 476 | ).toBe(200); | |
| 477 | expect( | |
| 478 | (await api("/api/members/u2", { method: "DELETE" }, root)).status, | |
| 479 | ).toBe(404); | |
| 480 | }); | |
| 481 | ||
| 378 | 482 | test("a change is on the event stream, and a late client hears what it missed", async () => { |
| 379 | 483 | const stream = await fetch(base + "/events"); |
| 380 | 484 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
src/tty.ts +75 −5
| @@ -2,18 +2,21 @@ | ||
| 2 | 2 | * The app in a terminal. The forge runs this on a pty when someone |
| 3 | 3 | * `ssh`es to the app, over the same data as the page and through the same |
| 4 | 4 | * actions, as the account at the keyboard. j/k move, Enter opens the |
| 5 | * proposal under the cursor, n proposes, w withdraws, Esc goes back and q | |
| 6 | * leaves. Plain ANSI, no dependencies. | |
| 5 | * proposal under the cursor, n proposes, w withdraws, g shows the group, | |
| 6 | * Esc goes back and q leaves. Plain ANSI, no dependencies. | |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | 10 | canWithdraw, |
| 11 | 11 | getProposal, |
| 12 | isMember, | |
| 13 | listMembers, | |
| 12 | 14 | listProposals, |
| 13 | 15 | propose, |
| 14 | 16 | withdrawProposal, |
| 15 | 17 | OPTIONS_MAX, |
| 16 | 18 | type FullProposal, |
| 19 | type Member, | |
| 17 | 20 | type ProposalSummary, |
| 18 | 21 | } from "./actions.ts"; |
| 19 | 22 | import { openDb } from "./db.ts"; |
| @@ -42,12 +45,20 @@ let cursor = 0; | ||
| 42 | 45 | let open: number | undefined; |
| 43 | 46 | let draft: Draft | undefined; |
| 44 | 47 | let said = ""; |
| 48 | /** The group, under g: the same roster the page shows, read-only here. */ | |
| 49 | let showGroup = false; | |
| 50 | let groupCursor = 0; | |
| 45 | 51 | |
| 46 | 52 | const proposals = (): ProposalSummary[] => { |
| 47 | 53 | const listed = listProposals(db, me, { state: "open" }); |
| 48 | 54 | return listed.ok ? listed.value : []; |
| 49 | 55 | }; |
| 50 | 56 | |
| 57 | const members = (): Member[] => { | |
| 58 | const listed = listMembers(db, me); | |
| 59 | return listed.ok ? listed.value : []; | |
| 60 | }; | |
| 61 | ||
| 51 | 62 | const one = (id: number): FullProposal | undefined => { |
| 52 | 63 | const found = getProposal(db, me, { id }); |
| 53 | 64 | return found.ok ? found.value : undefined; |
| @@ -78,6 +89,7 @@ function draw(): void { | ||
| 78 | 89 | "", |
| 79 | 90 | ]; |
| 80 | 91 | if (draft !== undefined) lines.push(...composer(draft)); |
| 92 | else if (showGroup) lines.push(...groupView()); | |
| 81 | 93 | else if (open !== undefined) lines.push(...detail(open)); |
| 82 | 94 | else lines.push(...list()); |
| 83 | 95 | lines.push(""); |
| @@ -101,6 +113,46 @@ const list = (): string[] => { | ||
| 101 | 113 | }); |
| 102 | 114 | }; |
| 103 | 115 | |
| 116 | /** | |
| 117 | * The roster, with the cursor's row always on screen: 256 people do not fit | |
| 118 | * in a terminal, so the window follows the cursor rather than the list | |
| 119 | * running off the top. | |
| 120 | */ | |
| 121 | const groupView = (): string[] => { | |
| 122 | const rows = members(); | |
| 123 | groupCursor = Math.min(groupCursor, Math.max(0, rows.length - 1)); | |
| 124 | const lines = [ | |
| 125 | bold(`The group — ${rows.length} counting toward a quorum`), | |
| 126 | dim( | |
| 127 | me.role === "anon" | |
| 128 | ? "Signed out: the group does not count you." | |
| 129 | : isMember(db, me) | |
| 130 | ? `${me.name} is in the group.` | |
| 131 | : `${me.name} is not in the group.`, | |
| 132 | ), | |
| 133 | "", | |
| 134 | ]; | |
| 135 | if (rows.length === 0) return [...lines, dim("Nobody is in the group yet.")]; | |
| 136 | const height = Math.max(3, (process.stdout.rows ?? 24) - 9); | |
| 137 | const first = Math.min( | |
| 138 | Math.max(0, groupCursor - Math.floor(height / 2)), | |
| 139 | Math.max(0, rows.length - height), | |
| 140 | ); | |
| 141 | const window = rows.slice(first, first + height); | |
| 142 | window.forEach((member, i) => { | |
| 143 | const here = first + i === groupCursor; | |
| 144 | const tail = dim( | |
| 145 | `${member.person_id} · since ${member.created_at.slice(0, 10)}`, | |
| 146 | ); | |
| 147 | lines.push( | |
| 148 | `${here ? "▸" : " "} ${here ? bold(member.name) : member.name} ${tail}`, | |
| 149 | ); | |
| 150 | }); | |
| 151 | if (first + window.length < rows.length || first > 0) | |
| 152 | lines.push(dim(`${first + 1}-${first + window.length} of ${rows.length}`)); | |
| 153 | return lines; | |
| 154 | }; | |
| 155 | ||
| 104 | 156 | const detail = (id: number): string[] => { |
| 105 | 157 | const proposal = one(id); |
| 106 | 158 | if (proposal === undefined) return [dim("That proposal is gone.")]; |
| @@ -138,10 +190,12 @@ const composer = (d: Draft): string[] => { | ||
| 138 | 190 | |
| 139 | 191 | const keys = (): string => { |
| 140 | 192 | if (draft !== undefined) return "Enter accepts · Esc cancels"; |
| 141 | if (open !== undefined) return "Esc back · w withdraw · q quit"; | |
| 193 | if (showGroup) | |
| 194 | return "j/k move · Esc back · q quit · the group is read-only here"; | |
| 195 | if (open !== undefined) return "Esc back · w withdraw · g group · q quit"; | |
| 142 | 196 | return me.role === "anon" |
| 143 | ? "j/k move · Enter open · q quit · sign in through the forge to propose" | |
| 144 | : "j/k move · Enter open · n propose · w withdraw · q quit"; | |
| 197 | ? "j/k move · Enter open · g group · q quit · sign in through the forge to propose" | |
| 198 | : "j/k move · Enter open · n propose · w withdraw · g group · q quit"; | |
| 145 | 199 | }; |
| 146 | 200 | |
| 147 | 201 | /** The draft, finished: the same action every other door calls. */ |
| @@ -221,6 +275,22 @@ process.stdin.on("data", (key: string) => { | ||
| 221 | 275 | out(`${esc}2J${esc}H`); |
| 222 | 276 | process.exit(0); |
| 223 | 277 | } |
| 278 | if (showGroup) { | |
| 279 | const roster = members(); | |
| 280 | if (key === "j" || key === "\x1b[B") | |
| 281 | groupCursor = Math.min(groupCursor + 1, Math.max(0, roster.length - 1)); | |
| 282 | if (key === "k" || key === "\x1b[A") | |
| 283 | groupCursor = Math.max(groupCursor - 1, 0); | |
| 284 | if (key === "\x1b" || key === "g") showGroup = false; | |
| 285 | draw(); | |
| 286 | return; | |
| 287 | } | |
| 288 | if (key === "g") { | |
| 289 | showGroup = true; | |
| 290 | groupCursor = 0; | |
| 291 | draw(); | |
| 292 | return; | |
| 293 | } | |
| 224 | 294 | const rows = proposals(); |
| 225 | 295 | const under = open ?? rows[cursor]?.id; |
| 226 | 296 | if (key === "j" || key === "\x1b[B") |
static/app.css +47
| @@ -146,6 +146,53 @@ code, | ||
| 146 | 146 | color: inherit; |
| 147 | 147 | text-decoration: none; |
| 148 | 148 | } |
| 149 | .member { | |
| 150 | display: flex; | |
| 151 | justify-content: space-between; | |
| 152 | align-items: flex-start; | |
| 153 | gap: var(); | |
| 154 | padding: var() 0; | |
| 155 | border-top: 1px solid var(); | |
| 156 | } | |
| 157 | .member:first-child { | |
| 158 | border-top: 0; | |
| 159 | } | |
| 160 | .member p { | |
| 161 | overflow-wrap: anywhere; | |
| 162 | } | |
| 163 | .member small { | |
| 164 | font-size: var(); | |
| 165 | } | |
| 166 | /* Two fields and a button: one row on a desktop, stacked on a phone. */ | |
| 167 | .row-wrap { | |
| 168 | flex-wrap: wrap; | |
| 169 | margin-bottom: var(); | |
| 170 | } | |
| 171 | .row-wrap .input { | |
| 172 | flex: 1 1 10rem; | |
| 173 | } | |
| 174 | /* The pages, one per concept. */ | |
| 175 | .tabs { | |
| 176 | display: flex; | |
| 177 | gap: var(); | |
| 178 | margin-top: var(); | |
| 179 | } | |
| 180 | .tab { | |
| 181 | display: inline-flex; | |
| 182 | align-items: center; | |
| 183 | min-height: var(); | |
| 184 | padding: 0 var(); | |
| 185 | color: var(); | |
| 186 | text-decoration: none; | |
| 187 | border-bottom: 2px solid transparent; | |
| 188 | } | |
| 189 | .tab[aria-current="page"] { | |
| 190 | color: var(); | |
| 191 | border-bottom-color: var(); | |
| 192 | } | |
| 193 | .tab:hover { | |
| 194 | color: var(); | |
| 195 | } | |
| 149 | 196 | .proposal { |
| 150 | 197 | display: flex; |
| 151 | 198 | justify-content: space-between; |
new above
offline