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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9859-a-repository-s-bets-list-and-a-bet-s-pag → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9859-a-repository-s-bets-list-and-a-bet-s-pag was merged into main as 2496972.

Review

Sign in to review this change.
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`.
2929
3030 ### Bounded body (#9321)
3131
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
3535 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";
2121 import { html, type Html } from "../html.ts";
2222 import { flashTo, page } from "../layout.ts";
2323 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";
2525
2626 const betHref = (repo: Repo, id: string) => `${repoBase(repo)}/bets/${id}`;
2727
@@ -76,12 +76,13 @@ function oneButton(repo: Repo, b: Bet, verb: string, label: string, variant: "da
7676 export const SHOWN_JOURNEYS = 3;
7777
7878 /**
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.
8384 */
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 {
8586 const scopes = betScopes(ctx.app.db, b.id);
8687 const journeys = b.journeys.split("\n").filter((j) => j !== "");
8788 const why = mayBack(backed, b.appetite);
@@ -90,9 +91,13 @@ function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Htm
9091 : html`<details><summary>${scopes.length} ${scopes.length === 1 ? "issue" : "issues"}</summary>
9192 <ul>${scopes.map((s) => html`<li><a href="${repoBase(repo)}/issues/${s.number}">#${s.number} ${s.title}</a></li>`)}</ul>
9293 </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"}`,

Principle 3/6: the row's after slot 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.

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>`}
96101 ${journeys.length <= SHOWN_JOURNEYS ? null : html`<details><summary>${journeys.length - SHOWN_JOURNEYS} more ${journeys.length - SHOWN_JOURNEYS === 1 ? "journey" : "journeys"}</summary>
97102 <ul>${journeys.slice(SHOWN_JOURNEYS).map((j) => html`<li>${j}</li>`)}</ul></details>`}
98103 ${issues}
@@ -103,10 +108,13 @@ function proposedCard(ctx: Ctx, repo: Repo, b: Bet, backed: readonly Bet[]): Htm
103108 </form>
104109 <details><summary>Change the share</summary>${backForm(repo, b, "Back it")}</details>
105110 ${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 });
108113 }
109114
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
110118 /** What the owner can do with a bet, by where it is. */
111119 function ownerActions(repo: Repo, b: Bet): Html | null {
112120 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 {
134142 ${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.` })}
135143 ${bets.length === 0
136144 ? 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))])}`}
139146 ${owner
140147 ? html`<form method="post" action="${repoBase(repo)}/bets/propose" class="mt">${btn("Ask the planner to propose bets", { variant: "secondary" })}</form>`
141148 : null}
@@ -167,8 +174,7 @@ export function betPage(ctx: Ctx): Response {
167174 });
168175 const finished = bet.state === "done";
169176 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>
172178 <p><a href="${bet.tryUrl === "" ? repoBase(repo) : bet.tryUrl}">Open it and try it.</a></p>
173179 ${bet.verdict !== ""
174180 ? 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 {
182188 ${btn("Not yet", { name: "verdict", value: "not_yet" })}
183189 </form>
184190 </div>`
185 : null}`,
186 )
191 : null}</section>`
187192 : null;
188193 const leftBox =
189194 owner && (bet.state === "stopped" || bet.state === "backed")
@@ -201,7 +206,7 @@ export function betPage(ctx: Ctx): Response {
201206 ${owner ? html`<h2 class="mt">Done looks like</h2>
202207 <ul>${bet.journeys.split("\n").filter((j) => j !== "").map((j) => html`<li>${j}</li>`)}</ul>` : null}
203208 <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)}
205210 ${leftBox}
206211 ${owner ? html`<div class="mt">${ownerActions(repo, bet)}</div>` : null}
207212 ${liveMount({ repo: repoBase(repo) })}`;
src/web/style/components.ts +1 −1
@@ -481,7 +481,7 @@ a.row:hover .row-title { color: var(--accent); }
481481 .setting-list > li.has-after > .row-after { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
482482 @media (max-width: 640px) { .setting-list > li.has-after > .row-after { flex-basis: 100%; justify-content: flex-start; padding: 0 14px 10px; } }
483483 /* 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; }
485485 .list > li.has-after > .row-children { flex-basis: 100%; }
outdated

Principle 7 / clean: a copied declaration. Extend the .setting-list[data-origins] selector with , .setting-list[data-bets] instead of repeating the rule.

486486 .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
487487 @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); }
180180 [data-forges] .row { flex-wrap: wrap; }
181181 [data-forges] .row-trail { flex: 1 1 100%; justify-content: flex-start; }
182182 }
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; }
184184 [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; }
186187 [data-bet-actions] { align-items: flex-start; }
187188
188189 .bar > .device-code { flex: 1 1 auto; }
test/bets-card-fit.test.ts +11 −5
@@ -38,7 +38,7 @@ beforeAll(async () => {
3838 const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") };
3939 const html = await (await fetch(`${servers.url}/${PREVIEW_ADMIN}/${PREVIEW_REPO}/bets`, { headers })).text();
4040 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));
4242 }, 30_000);
4343 afterAll(async () => {
4444 servers.stop();
@@ -53,8 +53,13 @@ describe("the owner's bets page, a proposed bet card at rest", () => {
5353 expect(cards.some((c) => c.includes("Make every long and winding"))).toBe(true);
5454 });
5555
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
5661 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);
5863 });
5964
6065 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", () => {
9095 for (const r of rules) expect(r).not.toMatch(/(?<![-\w])width\s*:\s*\d/);
9196 });
9297
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)[^}]*\}/);
95101 expect(rule).not.toBeNull();
96102 const px = rule![2] === "vh" ? (Number(rule![1]) / 100) * 844 : Number(rule![1]);
97103 expect(px).toBeLessThanOrEqual(844);
@@ -140,7 +146,7 @@ describe.skipIf(chromium === undefined)("the owner's bets page, measured at phon
140146 const measure = `(() => {
141147 const vw = document.documentElement.clientWidth;
142148 const cards = [...document.querySelectorAll("[data-bet-card]")].map((c) => {
143 const box = c.parentElement.getBoundingClientRect();
149 const box = c.getBoundingClientRect();
144150 const body = c.getBoundingClientRect();
145151 const actions = c.querySelector("[data-bet-actions]").getBoundingClientRect();
146152 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)", () => {
608608 expect(await card("j1\nj2\nj3\nj4")).toContain("1 more journey</summary>");
609609 });
610610 });
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: p exists only to be asserted truthy. Call fresh2("Boxless") without binding it and drop this line.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs