Public: anyone can read this; the crew can change it.
The /boards forms lay out with §5's `.bar`, and `.bform` leaves the stylesheet #5758
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4751-the-boards-forms-lay-out-with-5-s-bar-an → main 2 commits fork 3 threads · 3 unresolved
5 files
changed +14
−19
docs/design/areas/boards.md −1
| @@ -1,3 +1,2 @@ | ||
| 1 | 1 | # Boards |
| 2 | 2 | |
| 3 | - `.bform` — the boards form: one row of labelled fields and a button that makes, edits or acts on a board, wrapping onto more lines as the width runs out, with the button sized to its words. Written by `boardsList` (make a board), `panelForm` and `manage` (add or edit a panel; rename, settings, duplicate, delete) in `src/web/pages/boards-view.ts`, and by `boardsPage` (the query form and pin to a board) in `src/web/pages/boards.ts`. Styled in `src/web/style/content.ts` (`.bform` and its `.qf`, `.field` and `.btn` children). |
src/web/pages/boards-view.ts +6 −6
| @@ -83,7 +83,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 83 | 83 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 84 | 84 | const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user)); |
| 85 | 85 | if (!listed.ok) return notFoundPage(ctx); |
| 86 | const make = html`<form method="post" action="/boards/new" class="bform"> | |
| 86 | const make = html`<form method="post" action="/boards/new" class="bar"> | |
| 87 | 87 | ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })} |
| 88 | 88 | ${btn("Make board", { variant: "primary" })} |
| 89 | 89 | </form>`; |
| @@ -116,7 +116,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 116 | 116 | |
| 117 | 117 | function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html { |
| 118 | 118 | const id = op === "edit" ? `panel-${String(index)}` : "panel-new"; |
| 119 | return html`<form method="post" action="/boards/${slug}/${op}" class="bform"> | |
| 119 | return html`<form method="post" action="/boards/${slug}/${op}" class="bar"> | |
| 120 | 120 | ${op === "edit" ? html`<input type="hidden" name="index" value="${String(index)}" />` : null} |
| 121 | 121 | ${field({ label: "Panel title", name: "title", id: `${id}-title`, required: true, maxlength: MAX_TITLE, value: p?.title ?? "", placeholder: "Panel title" })} |
| 122 | 122 | ${selectField({ label: "Width", name: "w", id: `${id}-w`, options: sizes(MAX_W, "wide", p?.w ?? 1) })} |
| @@ -164,11 +164,11 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo | ||
| 164 | 164 | () => card(panelForm(slug, "add", undefined, 0, adding), { head: html`<h2>Add a panel</h2>` }), |
| 165 | 165 | )} |
| 166 | 166 | ${card( |
| 167 | html`<form method="post" action="/boards/${slug}/rename" class="bform"> | |
| 167 | html`<div class="bar"><form method="post" action="/boards/${slug}/rename" class="bar"> | |
| 168 | 168 | ${field({ label: "Board title", name: "title", id: "board-title", required: true, maxlength: MAX_TITLE, value: board.title })} |
| 169 | 169 | ${btn("Rename")} |
| 170 | 170 | </form> |
| 171 | <form method="post" action="/boards/${slug}/settings" class="bform"> | |
| 171 | <form method="post" action="/boards/${slug}/settings" class="bar"> | |
| 172 | 172 | ${selectField({ label: "Range", name: "range", id: "board-range", options: RANGES.map((r) => ({ value: r, label: r, selected: r === board.range })) })} |
| 173 | 173 | ${selectField({ label: "Live", name: "refresh", id: "board-refresh", options: [ |
| 174 | 174 | { value: "off", label: "off", selected: board.refresh === "off" }, |
| @@ -176,8 +176,8 @@ function manage(slug: string, board: Board, adding: Html, pins: ReturnType<typeo | ||
| 176 | 176 | ] })} |
| 177 | 177 | ${btn("Save settings")} |
| 178 | 178 | </form> |
| 179 | <form method="post" action="/boards/${slug}/duplicate" class="bform">${btn("Duplicate")}</form> | |
| 180 | <form method="post" action="/boards/${slug}/delete" class="bform">${btn("Delete this board", { variant: "danger" })}</form>`, | |
| 179 | <form method="post" action="/boards/${slug}/duplicate">${btn("Duplicate")}</form> | |
Bare forms here get no gap, and .bar no longer supplies the old 8px bottom margin. Duplicate and Delete touch the forms above and each other. Wrap the two in a .bar so they get its gap and wrap on narrow screens. |
||
| 180 | <form method="post" action="/boards/${slug}/delete">${btn("Delete this board", { variant: "danger" })}</form></div>`, | |
| 181 | 181 | { head: html`<h2>Board</h2>` }, |
| 182 | 182 | )}`; |
| 183 | 183 | } |
src/web/pages/boards.ts +2 −2
| @@ -214,7 +214,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 214 | 214 | ); |
| 215 | 215 | |
| 216 | 216 | const form = card( |
| 217 | html`<form method="get" action="/boards/data" class="bform"> | |
| 217 | html`<form method="get" action="/boards/data" class="bar"> | |
| 218 | 218 | ${queryFields(p)} |
| 219 | 219 | ${btn("Draw", { variant: "primary" })} |
| 220 | 220 | </form>`, |
| @@ -257,7 +257,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> { | ||
| 257 | 257 | boards.length === 0 || !answer.ok |
| 258 | 258 | ? null |
| 259 | 259 | : card( |
| 260 | html`<form method="post" action="/boards/pin" class="bform"> | |
| 260 | html`<form method="post" action="/boards/pin" class="bar"> | |
| 261 | 261 | ${PANEL_FIELDS.map( |
| 262 | 262 | (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`, |
| 263 | 263 | )} |
src/web/style/content.ts −3
| @@ -194,9 +194,6 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 194 | 194 | .chart-box.is-table { aspect-ratio: auto; height: 260px; overflow: auto; } |
| 195 | 195 | .bp-h2 .chart-box.is-table { height: 400px; } |
| 196 | 196 | .bp-h3 .chart-box.is-table { height: 540px; } |
| 197 | .bform { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin-bottom: 8px; } | |
| 198 | .bform .field { margin: 0; flex: 1 1 140px; min-width: 0; } | |
| 199 | .bform .btn { flex: none; } | |
| 200 | 197 | #summary { display: flex; flex-wrap: wrap; gap: 6px; } |
| 201 | 198 | #summary .pill { max-width: 100%; white-space: normal; } |
| 202 | 199 | .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } |
test/boards-selectors.test.ts +6 −7
| @@ -56,7 +56,8 @@ test("every query selector has a visible label and sits outside .row-actions", a | ||
| 56 | 56 | const form = html.match(/<form method="get" action="\/boards\/data"[^>]*>([\s\S]*?)<\/form>/); |
| 57 | 57 | expect(form).not.toBeNull(); |
| 58 | 58 | expect(form![0]).not.toContain("row-actions"); |
| 59 | expect(form![0]).toContain('class="bform"'); | |
| 59 | expect(form![0]).toContain('class="bar"'); | |
outdated
Missing semicolon, unlike the line below it. The type check did not run either, so run bun x tsc --noEmit after bun install. |
||
| 60 | expect(html).not.toContain("bform"); | |
| 60 | 61 | for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) { |
| 61 | 62 | const m = form![1]!.match(new RegExp(`<label for="f-${name}-\\d+">([^<]+)</label>\\s*<(?:select|input)[^>]* name="${name}"`)); |
| 62 | 63 | expect(m, name).not.toBeNull(); |
| @@ -64,12 +65,10 @@ test("every query selector has a visible label and sits outside .row-actions", a | ||
| 64 | 65 | } |
| 65 | 66 | }); |
| 66 | 67 | |
| 67 | test("the selector styles never hide or collapse a control", () => { | |
| 68 | const css = components + content; | |
| 69 | const rules = [...css.matchAll(/(^|\})\s*([^{}]*\.bform[^{}]*)\{([^}]*)\}/g)].map((m) => m[3]!); | |
| 70 | expect(rules.length).toBeGreaterThan(0); | |
| 71 | for (const body of rules) { | |
| 72 | expect(body).not.toMatch(/opacity:\s*0\b|display:\s*none|visibility:\s*hidden|height:\s*0\b/); | |
| 68 | test("no .bform rule is left in the stylesheet or the boards pages", async () => { | |
| 69 | expect(components + content).not.toContain("bform"); | |
| 70 | for (const f of ["boards-view.ts", "boards.ts"]) { | |
| 71 | expect(await Bun.file(`${import.meta.dir}/../src/web/pages/${f}`).text()).not.toContain("bform"); | |
| 73 | 72 | } |
| 74 | 73 | }); |
| 75 | 74 |
new above
offline
This file is now only a heading. Remove it, or use it for the next boards-specific component.