Public: anyone can read this; the crew can change it.
A proposed bet card is bounded at rest: a test holds its lines, and the owner's bets look on the preview shows it at 390 px #9253
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9240-a-proposed-bet-card-is-bounded-at-rest-a → main 3 commits fork
3 files
changed +96
−4
src/web/pages/bets.ts +6 −4
| @@ -72,7 +72,8 @@ function oneButton(repo: Repo, b: Bet, verb: string, label: string, variant: "da | ||
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | /** Journeys shown at rest; the rest sit behind a disclosure so the card stays one screen tall. */ |
| 75 | const SHOWN_JOURNEYS = 3; | |
| 75 | /** Exported for test/bets-card-fit.test.ts, which holds the page to it. */ | |
| 76 | export const SHOWN_JOURNEYS = 3; | |
| 76 | 77 | |
| 77 | 78 | /** |
| 78 | 79 | * A proposed bet as one card for the owner: name, journeys, its issues as a |
| @@ -95,13 +96,14 @@ function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Htm | ||
| 95 | 96 | ${journeys.length <= SHOWN_JOURNEYS ? null : html`<details><summary>${journeys.length - SHOWN_JOURNEYS} more ${journeys.length - SHOWN_JOURNEYS === 1 ? "journey" : "journeys"}</summary> |
| 96 | 97 | <ul>${journeys.slice(SHOWN_JOURNEYS).map((j) => html`<li>${j}</li>`)}</ul></details>`} |
| 97 | 98 | ${issues} |
| 99 | <div class="bar mt" data-bet-actions> | |
| 98 | 100 | <form method="post" action="${post(repo, b, "back")}"> |
| 99 | 101 | <input type="hidden" name="share" value="${String(b.appetite)}"> |
| 100 | 102 | ${btn(`Back it at ${b.appetite}%`, { variant: "primary", disabled: why !== null })} |
| 101 | ${why === null ? null : html`<p class="muted" role="note">${why[0]!.toUpperCase() + why.slice(1)}.</p>`} | |
| 102 | 103 | </form> |
| 103 | <details class="mt"><summary>Change the share</summary>${backForm(repo, b, "Back it")}</details> | |
| 104 | <div class="mt">${oneButton(repo, b, "cut", "Cut it", "danger")}</div></div>`, | |
| 104 | <details><summary>Change the share</summary>${backForm(repo, b, "Back it")}</details> | |
| 105 | ${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>`, | |
| 105 | 107 | ); |
| 106 | 108 | } |
| 107 | 109 |
src/web/style/content.ts +1
| @@ -148,6 +148,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 148 | 148 | [data-apps], [data-bet-card] { overflow-wrap: anywhere; } |
| 149 | 149 | [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; } |
| 150 | 150 | [data-bet-card] ul { padding-inline-start: 16px; } |
| 151 | [data-bet-actions] { align-items: flex-start; } | |
| 151 | 152 | |
| 152 | 153 | .bar > .device-code { flex: 1 1 auto; } |
| 153 | 154 |
test/bets-card-fit.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * A proposed bet card is bounded at rest (#9240): the preview seed's proposed | |
| 3 | * bets, rendered as their owner, hold one title, the journeys the page | |
| 4 | * allows, one row of actions, no fixed width, and words that wrap. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { createHmac } from "node:crypto"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 14 | import { PREVIEW_ADMIN, PREVIEW_REPO } from "../src/spawn/preview-looks.ts"; | |
| 15 | import { utilities as base } from "../src/web/style/base.ts"; | |
| 16 | import { content } from "../src/web/style/content.ts"; | |
| 17 | import { SHOWN_JOURNEYS } from "../src/web/pages/bets.ts"; | |
| 18 | ||
| 19 | const KEY = "bets-card-fit-key"; | |
| 20 | let dir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let cards: string[]; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-bets-fit-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY }); | |
| 28 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 29 | if (!seeded.ok) throw new Error(seeded.error); | |
| 30 | servers = await serve(app); | |
| 31 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 32 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 33 | const html = await (await fetch(`${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/bets`, { headers })).text(); | |
| 34 | cards = html.split("<div data-bet-card").slice(1).map((c) => c.slice(0, c.indexOf("Cut it") + 40)); | |
| 35 | }, 30_000); | |
| 36 | afterAll(async () => { | |
| 37 | servers.stop(); | |
| 38 | app.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | describe("the owner's bets page, a proposed bet card at rest", () => { | |
| 43 | test("the owner sees both proposed bets of the seed as cards", () => { | |
| 44 | expect(cards.length).toBe(2); | |
| 45 | expect(cards.some((c) => c.includes("Price the list"))).toBe(true); | |
| 46 | expect(cards.some((c) => c.includes("Make every long and winding"))).toBe(true); | |
| 47 | }); | |
| 48 | ||
| 49 | test("one title line", () => { | |
| 50 | for (const c of cards) expect(c.match(/<h2[ >]/g)?.length).toBe(1); | |
| 51 | }); | |
| 52 | ||
| 53 | test("at most the journeys the page allows, the rest behind a disclosure", () => { | |
| 54 | for (const c of cards) { | |
| 55 | const [atRest] = c.split("<details>"); | |
| 56 | expect(atRest!.match(/<li>/g)?.length ?? 0).toBeLessThanOrEqual(SHOWN_JOURNEYS); | |
| 57 | } | |
| 58 | const long = cards.find((c) => c.includes("Fifth journey"))!; | |
| 59 | expect(long).toContain("2 more journeys"); | |
| 60 | expect(long.split("<details>")[0]!.match(/<li>/g)?.length).toBe(SHOWN_JOURNEYS); | |
| 61 | }); | |
| 62 | ||
| 63 | test("one row of actions, holding back, change the share and cut", () => { | |
| 64 | for (const c of cards) { | |
| 65 | expect(c.match(/data-bet-actions/g)?.length).toBe(1); | |
| 66 | const row = c.slice(c.indexOf("data-bet-actions")); | |
| 67 | expect(row).toContain("Back it at 20%"); | |
| 68 | expect(row).toContain("Change the share"); | |
| 69 | expect(row).toContain("Cut it"); | |
| 70 | expect(c.slice(0, c.indexOf("data-bet-actions"))).not.toContain("<form"); | |
| 71 | } | |
| 72 | // What keeps the row inside 390 px: .bar is a flex row that wraps. | |
| 73 | expect(base).toMatch(/^\.bar \{[^}]*display:\s*flex[^}]*flex-wrap:\s*wrap/m); | |
| 74 | }); | |
| 75 | ||
| 76 | test("no element with a fixed width", () => { | |
| 77 | for (const c of cards) { | |
| 78 | expect(c).not.toMatch(/\swidth=/i); | |
| 79 | expect(c).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i); | |
| 80 | } | |
| 81 | for (const l of base.split("\n").filter((l) => /^\.bar\b/.test(l))) expect(l).not.toMatch(/(?<![-\w])(?:min-|max-)?width\s*:/); | |
| 82 | const rules = content.split("\n").filter((l) => l.includes("[data-bet-card]")); | |
| 83 | for (const r of rules) expect(r).not.toMatch(/(?<![-\w])width\s*:\s*\d/); | |
| 84 | }); | |
| 85 | ||
| 86 | test("long words wrap", () => { | |
| 87 | expect(content).toMatch(/\[data-bet-card\][^{]*\{[^}]*overflow-wrap:\s*anywhere/); | |
| 88 | }); | |
| 89 | }); |
new above
offline