Public: anyone can read this; the crew can change it.
A repository's bets list and a bet's page speak the home page's language #9917
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9859-a-repository-s-bets-list-and-a-bet-s-pag → main 5 commits fork 3 threads · 3 unresolved
6 files
changed +63
−29
docs/design/areas/bets.md +7 −3
| @@ -29,7 +29,11 @@ the worst case is on `/preview-admin/sample/bets`. | ||
| 29 | 29 | |
| 30 | 30 | ### Bounded body (#9321) |
| 31 | 31 | |
| 32 | `[data-bet-card]` also carries `max-height: 80vh` with `overflow-x: hidden; overflow-y: auto`, so | |
| 33 | no proposed bet card is taller than one phone screen (844 px) even with every | |
| 34 | disclosure open; the overflow scrolls inside the card, never the page. No new | |
| 32 | The bound is on what a disclosure opens (`[data-bet-card] details ul`, `max-height: 40vh`, | |
| 33 | `overflow-y: auto`), not on the row: the row never scrolls, the page does, and a long | |
| 34 | issue list scrolls inside its own details. No new | |
| 35 | 35 | class. `test/bets-card-fit.test.ts` fails if the bound is removed. |
| 36 | ||
| 37 | ## Rows, not boxes (#9859) | |
| 38 | ||
| 39 | The list on `/:owner/:name/bets` is one `row()` per bet in a `ul.list.setting-list[data-bets]`, no `card()`. A proposed bet's row carries its share and next action in the meta; its journeys (three, the rest in a "N more" `<details>`), issue count and actions sit in the row's `after` slot. `data-bet-card` is now on the row's `<li>` (the bounds above still scope to it). The one CSS change: `[data-bets]` joins `[data-origins]` in giving `.row-after` the full row width. A finished bet's "This is done. Try it." is a `settings-block` heading and a line. No new class. |
src/web/pages/bets.ts +23 −18
| @@ -21,7 +21,7 @@ import { field, readForm } from "../form.ts"; | ||
| 21 | 21 | import { html, type Html } from "../html.ts"; |
| 22 | 22 | import { flashTo, page } from "../layout.ts"; |
| 23 | 23 | import { liveClientJs, liveMount } from "../live.ts"; |
| 24 | import { badge, btn, card, field as fieldRow, listCard, row } from "../ui.ts"; | |
| 24 | import { badge, btn, field as fieldRow, row } from "../ui.ts"; | |
| 25 | 25 | |
| 26 | 26 | const betHref = (repo: Repo, id: string) => `${repoBase(repo)}/bets/${id}`; |
| 27 | 27 | |
| @@ -76,12 +76,13 @@ function oneButton(repo: Repo, b: Bet, verb: string, label: string, variant: "da | ||
| 76 | 76 | export const SHOWN_JOURNEYS = 3; |
| 77 | 77 | |
| 78 | 78 | /** |
| 79 | * A proposed bet as one card for the owner: name, journeys, its issues as a | |
| 80 | * count that opens the list, and one button that backs it at the proposed | |
| 81 | * share. When the cap or the week forbids it the button is disabled and the | |
| 82 | * reason sits beside it. Changing the share is one disclosure away. | |
| 79 | * A proposed bet as one row for the owner: the name, its share and the next | |
| 80 | * action in the row; beneath it the journeys, its issues as a count that | |
| 81 | * opens the list, and one button that backs it at the proposed share. When | |
| 82 | * the cap or the week forbids it the button is disabled and the reason sits | |
| 83 | * beside it. Changing the share is one disclosure away. | |
| 83 | 84 | */ |
| 84 | function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Html { | |
| 85 | function proposedRow(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Html { | |
| 85 | 86 | const scopes = betScopes(ctx.app.db, b.id); |
| 86 | 87 | const journeys = b.journeys.split("\n").filter((j) => j !== ""); |
| 87 | 88 | const why = mayBack(backed, b.appetite); |
| @@ -90,9 +91,13 @@ function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Htm | ||
| 90 | 91 | : html`<details><summary>${scopes.length} ${scopes.length === 1 ? "issue" : "issues"}</summary> |
| 91 | 92 | <ul>${scopes.map((s) => html`<li><a href="${repoBase(repo)}/issues/${s.number}">#${s.number} ${s.title}</a></li>`)}</ul> |
| 92 | 93 | </details>`; |
| 93 | return card( | |
| 94 | html`<div data-bet-card><h2><a href="${betHref(repo, b.id)}">${b.name}</a></h2> | |
| 95 | ${journeys.length === 0 ? null : html`<ul>${journeys.slice(0, SHOWN_JOURNEYS).map((j) => html`<li>${j}</li>`)}</ul>`} | |
| 94 | return row({ | |
| 95 | href: betHref(repo, b.id), | |
| 96 | title: html`${b.name}`, | |
| 97 | meta: html`${b.appetite}% of the week · next: ${why === null ? "back it or cut it" : "cut it"}`, | |
| 98 | trail: badge(STATE_WORDS[b.state]), | |
| 99 | data: { "bet-card": "" }, | |
| 100 | after: html`${journeys.length === 0 ? null : html`<ul>${journeys.slice(0, SHOWN_JOURNEYS).map((j) => html`<li>${j}</li>`)}</ul>`} | |
| 96 | 101 | ${journeys.length <= SHOWN_JOURNEYS ? null : html`<details><summary>${journeys.length - SHOWN_JOURNEYS} more ${journeys.length - SHOWN_JOURNEYS === 1 ? "journey" : "journeys"}</summary> |
| 97 | 102 | <ul>${journeys.slice(SHOWN_JOURNEYS).map((j) => html`<li>${j}</li>`)}</ul></details>`} |
| 98 | 103 | ${issues} |
| @@ -103,10 +108,13 @@ function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Htm | ||
| 103 | 108 | </form> |
| 104 | 109 | <details><summary>Change the share</summary>${backForm(repo, b, "Back it")}</details> |
| 105 | 110 | ${oneButton(repo, b, "cut", "Cut it", "danger")}</div> |
| 106 | ${why === null ? null : html`<p class="muted" role="note">${why[0]!.toUpperCase() + why.slice(1)}.</p>`}</div>`, | |
| 107 | ); | |
| 111 | ${why === null ? null : html`<p class="muted" role="note">${why[0]!.toUpperCase() + why.slice(1)}.</p>`}`, | |
| 112 | }); | |
| 108 | 113 | } |
| 109 | 114 | |
| 115 | /** Rows with no box around them. */ | |
| 116 | const rows = (items: readonly Html[]): Html => html`<ul class="list setting-list" data-bets>${[...items]}</ul>`; | |
| 117 | ||
| 110 | 118 | /** What the owner can do with a bet, by where it is. */ |
| 111 | 119 | function ownerActions(repo: Repo, b: Bet): Html | null { |
| 112 | 120 | if (b.state === "proposed") return html`${backForm(repo, b, "Back it")}${oneButton(repo, b, "cut", "Cut it", "danger")}`; |
| @@ -134,8 +142,7 @@ export function betsPage(ctx: Ctx): Response { | ||
| 134 | 142 | ${pageHead("Bets", { sub: `A few named wholes, each with a share of the week. ${running} of ${MAX_BACKED} running. Builders pick only from running bets, the owner's own asks and small fixes.` })} |
| 135 | 143 | ${bets.length === 0 |
| 136 | 144 | ? html`<p class="muted">No bets yet. The planner proposes them from the open work.</p>` |
| 137 | : html`${owner ? proposed.map((b) => proposedCard(ctx, repo, b, backed)) : null} | |
| 138 | ${rest.length === 0 ? null : listCard(rest.map((b) => betRow(ctx, repo, b, now, owner)))}`} | |
| 145 | : html`${rows([...(owner ? proposed.map((b) => proposedRow(ctx, repo, b, backed)) : []), ...rest.map((b) => betRow(ctx, repo, b, now, owner))])}`} | |
| 139 | 146 | ${owner |
| 140 | 147 | ? html`<form method="post" action="${repoBase(repo)}/bets/propose" class="mt">${btn("Ask the planner to propose bets", { variant: "secondary" })}</form>` |
| 141 | 148 | : null} |
| @@ -167,8 +174,7 @@ export function betPage(ctx: Ctx): Response { | ||
| 167 | 174 | }); |
| 168 | 175 | const finished = bet.state === "done"; |
| 169 | 176 | const moment = finished |
| 170 | ? card( | |
| 171 | html`<h2>This is done. Try it.</h2> | |
| 177 | ? html`<section class="settings-block"><h2>This is done. Try it.</h2> | |
| 172 | 178 | <p><a href="${bet.tryUrl === "" ? repoBase(repo) : bet.tryUrl}">Open it and try it.</a></p> |
| 173 | 179 | ${bet.verdict !== "" |
| 174 | 180 | ? html`<p>${bet.verdict === "done" ? "The owner says it feels done." : owner ? `The owner says not yet: ${bet.verdictWhy}` : "The owner says not yet."}</p>` |
| @@ -182,8 +188,7 @@ export function betPage(ctx: Ctx): Response { | ||
| 182 | 188 | ${btn("Not yet", { name: "verdict", value: "not_yet" })} |
| 183 | 189 | </form> |
| 184 | 190 | </div>` |
| 185 | : null}`, | |
| 186 | ) | |
| 191 | : null}</section>` | |
| 187 | 192 | : null; |
| 188 | 193 | const leftBox = |
| 189 | 194 | owner && (bet.state === "stopped" || bet.state === "backed") |
| @@ -201,7 +206,7 @@ export function betPage(ctx: Ctx): Response { | ||
| 201 | 206 | ${owner ? html`<h2 class="mt">Done looks like</h2> |
| 202 | 207 | <ul>${bet.journeys.split("\n").filter((j) => j !== "").map((j) => html`<li>${j}</li>`)}</ul>` : null} |
| 203 | 208 | <h2 class="mt">Scopes</h2> |
| 204 | ${scopes.length === 0 ? html`<p class="muted">No scopes yet.</p>` : listCard(scopeRows)} | |
| 209 | ${scopes.length === 0 ? html`<p class="muted">No scopes yet.</p>` : rows(scopeRows)} | |
| 205 | 210 | ${leftBox} |
| 206 | 211 | ${owner ? html`<div class="mt">${ownerActions(repo, bet)}</div>` : null} |
| 207 | 212 | ${liveMount({ repo: repoBase(repo) })}`; |
src/web/style/components.ts +1 −1
| @@ -481,7 +481,7 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 481 | 481 | .setting-list > li.has-after > .row-after { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; } |
| 482 | 482 | @media (max-width: 640px) { .setting-list > li.has-after > .row-after { flex-basis: 100%; justify-content: flex-start; padding: 0 14px 10px; } } |
| 483 | 483 | /* An origin's error, notes and acts (Settings › Origins): a block of their own under the row, at every width. */ |
| 484 | .setting-list[data-origins] > li.has-after > .row-after { flex-basis: 100%; display: block; min-width: 0; padding: 0 14px 12px; overflow-wrap: anywhere; } | |
| 484 | .setting-list[data-origins] > li.has-after > .row-after, .setting-list[data-bets] > li.has-after > .row-after { flex-basis: 100%; display: block; min-width: 0; padding: 0 14px 12px; overflow-wrap: anywhere; } | |
| 485 | 485 | .list > li.has-after > .row-children { flex-basis: 100%; } |
outdated
Principle 7 / clean: a copied declaration. Extend the |
||
| 486 | 486 | .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; } |
| 487 | 487 | @media (max-width: 640px) { |
src/web/style/content.ts +3 −2
| @@ -180,9 +180,10 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 180 | 180 | [data-forges] .row { flex-wrap: wrap; } |
| 181 | 181 | [data-forges] .row-trail { flex: 1 1 100%; justify-content: flex-start; } |
| 182 | 182 | } |
| 183 | [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; } | |
| 183 | [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; } | |
| 184 | 184 | [data-bet-card] ul { padding-inline-start: 16px; } |
| 185 | [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; } | |
| 185 | /* The bound is on what a disclosure opens, not on the row: the page scrolls, a long issue list scrolls inside its own details. */ | |
| 186 | [data-bet-card] details ul { max-height: 40vh; overflow-y: auto; } | |
| 186 | 187 | [data-bet-actions] { align-items: flex-start; } |
| 187 | 188 | |
| 188 | 189 | .bar > .device-code { flex: 1 1 auto; } |
test/bets-card-fit.test.ts +11 −5
| @@ -38,7 +38,7 @@ beforeAll(async () => { | ||
| 38 | 38 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; |
| 39 | 39 | const html = await (await fetch(`${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/bets`, { headers })).text(); |
| 40 | 40 | page = html; |
| 41 | cards = html.split("<div data-bet-card").slice(1).map((c) => c.slice(0, c.indexOf("Cut it") + 40)); | |
| 41 | cards = html.split(" data-bet-card=").slice(1).map((c) => c.slice(0, c.indexOf("Cut it") + 40)); | |
| 42 | 42 | }, 30_000); |
| 43 | 43 | afterAll(async () => { |
| 44 | 44 | servers.stop(); |
| @@ -53,8 +53,13 @@ describe("the owner's bets page, a proposed bet card at rest", () => { | ||
| 53 | 53 | expect(cards.some((c) => c.includes("Make every long and winding"))).toBe(true); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | test("no box: the list is rows, not cards", () => { | |
| 57 | expect(page).not.toContain('class="card'); | |
| 58 | expect(page).toContain("data-bet-card"); | |
| 59 | }); | |
| 60 | ||
| 56 | 61 | test("one title line", () => { |
| 57 | for (const c of cards) expect(c.match(/<h2[ >]/g)?.length).toBe(1); | |
| 62 | for (const c of cards) expect(c.match(/class="row-title"/g)?.length).toBe(1); | |
| 58 | 63 | }); |
| 59 | 64 | |
| 60 | 65 | test("at most the journeys the page allows, the rest behind a disclosure", () => { |
| @@ -90,8 +95,9 @@ describe("the owner's bets page, a proposed bet card at rest", () => { | ||
| 90 | 95 | for (const r of rules) expect(r).not.toMatch(/(?<![-\w])width\s*:\s*\d/); |
| 91 | 96 | }); |
| 92 | 97 | |
| 93 | test("a proposed bet's body is bounded: a max-height under one 844 px screen, scrolling inside (#9321)", () => { | |
| 94 | const rule = content.match(/\[data-bet-card\]\s*\{[^}]*max-height:\s*(\d+)(vh|px)[^}]*\}/); | |
| 98 | test("a proposed bet's opened disclosures are bounded under one 844 px screen, scrolling inside them and never the row (#9321)", () => { | |
| 99 | expect(content).not.toMatch(/\[data-bet-card\]\s*\{[^}]*max-height/); | |
| 100 | const rule = content.match(/\[data-bet-card\] details ul\s*\{[^}]*max-height:\s*(\d+)(vh|px)[^}]*\}/); | |
| 95 | 101 | expect(rule).not.toBeNull(); |
| 96 | 102 | const px = rule![2] === "vh" ? (Number(rule![1]) / 100) * 844 : Number(rule![1]); |
| 97 | 103 | expect(px).toBeLessThanOrEqual(844); |
| @@ -140,7 +146,7 @@ describe.skipIf(chromium === undefined)("the owner's bets page, measured at phon | ||
| 140 | 146 | const measure = `(() => { |
| 141 | 147 | const vw = document.documentElement.clientWidth; |
| 142 | 148 | const cards = [...document.querySelectorAll("[data-bet-card]")].map((c) => { |
| 143 | const box = c.parentElement.getBoundingClientRect(); | |
| 149 | const box = c.getBoundingClientRect(); | |
| 144 | 150 | const body = c.getBoundingClientRect(); |
| 145 | 151 | const actions = c.querySelector("[data-bet-actions]").getBoundingClientRect(); |
| 146 | 152 | return { height: Math.round(box.height), innerScroll: c.scrollHeight - c.clientHeight, sideways: c.scrollWidth - c.clientWidth, actionsInView: actions.bottom <= body.bottom + 1 }; |
test/bets.test.ts +18
| @@ -608,3 +608,21 @@ describe("a proposed bet card stays one screen tall (#8970)", () => { | ||
| 608 | 608 | expect(await card("j1\nj2\nj3\nj4")).toContain("1 more journey</summary>"); |
| 609 | 609 | }); |
| 610 | 610 | }); |
| 611 | ||
| 612 | describe("the bets list and a bet's page are rows, not boxes", () => { | |
| 613 | test("no class=\"card on the list (owner and reader) or on a finished bet's page", async () => { | |
| 614 | app.db.query("DELETE FROM bet WHERE repo_id = ?1").run(repo.id); | |
| 615 | const cookie = await signIn("alice"); | |
| 616 | fresh2("Boxless"); | |
| 617 | const d = fresh2("Finished"); | |
| 618 | app.db.query("UPDATE bet SET state = 'done' WHERE id = ?1").run(d.id); | |
| 619 | const list = await (await fetch(`${servers.url}/alice/notes/bets`, { headers: { cookie } })).text(); | |
| 620 | expect(list).toContain("data-bet-card"); | |
| 621 | expect(list).toContain("Back it at"); | |
| 622 | expect(list).not.toContain('class="card'); | |
| 623 | expect(await (await fetch(`${servers.url}/alice/notes/bets`)).text()).not.toContain('class="card'); | |
| 624 | const one = await (await fetch(`${servers.url}/alice/notes/bets/${d.id}`, { headers: { cookie } })).text(); | |
| 625 | expect(one).toContain("This is done. Try it."); | |
| 626 | expect(one).not.toContain('class="card'); | |
| 627 | }); | |
| 628 | }); | |
outdated
Filler assertion: |
Principle 3/6: the row's
afterslot packs journeys, issues, forms and notes into one block; with content.ts:185 (max-height 80vh, overflow auto) the li becomes a scroll box. Keep the bound to the actions, not the row, and remove the dead h2/p rules at content.ts:183.