Public: anyone can read this; the crew can change it.

Boards: the query selectors are visible and usable #2668

Merged opened this in answer to

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

Merged

scout/forge:scout/2666-boards-the-query-selectors-are-visible-a → main 1 commit fork

  • Merged. scout/2666-boards-the-query-selectors-are-visible-a was merged into main as d4e7f66.

Review

Sign in to review this change.
4 files changed +161 −51
src/web/pages/boards-view.ts +15 −15
@@ -70,8 +70,8 @@ const denied = (ctx: Ctx) => {
7070 export async function boardsList(ctx: Ctx): Promise<Response> {
7171 const listed = await listAllBoards(ctx.app, ctx.auth.user);
7272 if (!listed.ok) return notFoundPage(ctx);
73 const make = html`<form method="post" action="/boards/new" class="row-actions">
74 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="New board's title" aria-label="Title of the new board" />
73 const make = html`<form method="post" action="/boards/new" class="bform">
74 <label class="qf"><span>New board</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" placeholder="Title" /></label>
7575 ${btn("Make board", { variant: "primary" })}
7676 </form>`;
7777 const body =
@@ -98,15 +98,15 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
9898 }
9999
100100 function panelForm(slug: string, op: string, p: Panel | undefined, index: number, fields: Html): Html {
101 return html`<form method="post" action="/boards/${slug}/${op}" class="row-actions">
101 return html`<form method="post" action="/boards/${slug}/${op}" class="bform">
102102 ${op === "edit" ? html`<input type="hidden" name="index" value="${String(index)}" />` : null}
103 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" aria-label="Panel title" />
104 <select class="input" name="w" aria-label="Width in columns">
103 <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${p?.title ?? ""}" placeholder="Panel title" /></label>
104 <label class="qf"><span>Width</span><select class="input" name="w">
105105 ${[1, 2, 3].slice(0, MAX_W).map((n) => html`<option value="${String(n)}" ${n === (p?.w ?? 1) ? raw("selected") : null}>${String(n)} wide</option>`)}
106 </select>
107 <select class="input" name="h" aria-label="Height">
106 </select></label>
107 <label class="qf"><span>Height</span><select class="input" name="h">
108108 ${[1, 2, 3].slice(0, MAX_H).map((n) => html`<option value="${String(n)}" ${n === (p?.h ?? 1) ? raw("selected") : null}>${String(n)} high</option>`)}
109 </select>
109 </select></label>
110110 ${fields}
111111 ${btn(op === "edit" ? "Save panel" : "Add panel", { variant: "primary" })}
112112 </form>`;
@@ -137,17 +137,17 @@ function manage(slug: string, board: Board, adding: Html): Html {
137137 () => card(panelForm(slug, "add", undefined, 0, adding), { head: html`<h2>Add a panel</h2>` }),
138138 )}
139139 ${card(
140 html`<form method="post" action="/boards/${slug}/rename" class="row-actions">
141 <input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" aria-label="Board title" />
140 html`<form method="post" action="/boards/${slug}/rename" class="bform">
141 <label class="qf"><span>Board title</span><input class="input" name="title" required maxlength="${String(MAX_TITLE)}" value="${board.title}" /></label>
142142 ${btn("Rename")}
143143 </form>
144 <form method="post" action="/boards/${slug}/settings" class="row-actions">
145 <select class="input" name="range" aria-label="Range">${RANGES.map((r) => option(r, r === board.range))}</select>
146 <select class="input" name="refresh" aria-label="Refresh">${REFRESHES.map((r) => option(r, r === board.refresh))}</select>
144 <form method="post" action="/boards/${slug}/settings" class="bform">
145 <label class="qf"><span>Range</span><select class="input" name="range">${RANGES.map((r) => option(r, r === board.range))}</select></label>
146 <label class="qf"><span>Refresh</span><select class="input" name="refresh">${REFRESHES.map((r) => option(r, r === board.refresh))}</select></label>
147147 ${btn("Save settings")}
148148 </form>
149 <form method="post" action="/boards/${slug}/duplicate" class="row-actions">${btn("Duplicate")}</form>
150 <form method="post" action="/boards/${slug}/delete" class="row-actions">${btn("Delete this board", { variant: "danger" })}</form>`,
149 <form method="post" action="/boards/${slug}/duplicate" class="bform">${btn("Duplicate")}</form>
150 <form method="post" action="/boards/${slug}/delete" class="bform">${btn("Delete this board", { variant: "danger" })}</form>`,
151151 { head: html`<h2>Board</h2>` },
152152 )}`;
153153 }
src/web/pages/boards.ts +58 −36
@@ -155,37 +155,59 @@ export function queryFields(p: Params) {
155155 const src = Object.hasOwn(SOURCES, p.source) ? SOURCES[p.source]! : undefined;
156156 const cols = src ? Object.keys(src.columns) : [];
157157 return html`
158 <select class="input" name="chart" aria-label="Chart">
159 ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))}
160 </select>
161 <select class="input" name="source" aria-label="Source">
162 ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))}
163 </select>
164 <select class="input" name="aggregate" aria-label="Aggregate">
165 ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))}
166 </select>
167 <select class="input" name="column" aria-label="Column to aggregate">
168 ${option("", "(rows)", p.column === "")}
169 ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))}
170 </select>
171 <select class="input" name="by" aria-label="Group by">
172 ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))}
173 ${cols.map((c) => option(c, `by ${c}`, c === p.by))}
174 </select>
175 <select class="input" name="series" aria-label="Series (stacked area, heat strip, table)">
176 ${option("", "(one series)", p.series === "")}
177 ${cols.map((c) => option(c, `split by ${c}`, c === p.series))}
178 </select>
179 <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" aria-label="From" maxlength="${MAX_PARAM}" />
180 <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" aria-label="To" maxlength="${MAX_PARAM}" />
181 <select class="input" name="fcolumn" aria-label="Filter column">
182 ${option("", "(no filter)", p.fcolumn === "")}
183 ${cols.map((c) => option(c, c, c === p.fcolumn))}
184 </select>
185 <select class="input" name="fop" aria-label="Filter operator">
186 ${OPS.map((o) => option(o, o, o === p.fop))}
187 </select>
188 <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" aria-label="Filter value" maxlength="${MAX_PARAM}" />
158 <label class="qf"><span>Chart</span>
159 <select class="input" name="chart">
160 ${CHARTS.map((c) => option(c, CHART_NAMES[c], c === p.chart))}
161 </select>
162 </label>
163 <label class="qf"><span>Source</span>
164 <select class="input" name="source">
165 ${Object.keys(SOURCES).map((n) => option(n, n, n === p.source))}
166 </select>
167 </label>
168 <label class="qf"><span>Aggregate</span>
169 <select class="input" name="aggregate">
170 ${AGGREGATES.map((a) => option(a, a, a === p.aggregate))}
171 </select>
172 </label>
173 <label class="qf" title="Column to aggregate"><span>Column</span>
174 <select class="input" name="column">
175 ${option("", "(rows)", p.column === "")}
176 ${cols.filter((c) => src!.columns[c] === "number").map((c) => option(c, c, c === p.column))}
177 </select>
178 </label>
179 <label class="qf" title="Group by"><span>Group by</span>
180 <select class="input" name="by">
181 ${(Object.keys(BUCKET_MS) as Bucket[]).map((b) => option(b, `per ${b}`, b === p.by))}
182 ${cols.map((c) => option(c, `by ${c}`, c === p.by))}
183 </select>
184 </label>
185 <label class="qf" title="Series (stacked area, heat strip, table)"><span>Series</span>
186 <select class="input" name="series">
187 ${option("", "(one series)", p.series === "")}
188 ${cols.map((c) => option(c, `split by ${c}`, c === p.series))}
189 </select>
190 </label>
191 <label class="qf"><span>From</span>
192 <input class="input mono" name="from" value="${p.from}" placeholder="from 2026-10-01" maxlength="${MAX_PARAM}" />
193 </label>
194 <label class="qf"><span>To</span>
195 <input class="input mono" name="to" value="${p.to}" placeholder="to 2026-10-08" maxlength="${MAX_PARAM}" />
196 </label>
197 <label class="qf"><span>Filter column</span>
198 <select class="input" name="fcolumn">
199 ${option("", "(no filter)", p.fcolumn === "")}
200 ${cols.map((c) => option(c, c, c === p.fcolumn))}
201 </select>
202 </label>
203 <label class="qf"><span>Filter operator</span>
204 <select class="input" name="fop">
205 ${OPS.map((o) => option(o, o, o === p.fop))}
206 </select>
207 </label>
208 <label class="qf"><span>Filter value</span>
209 <input class="input mono" name="fvalue" value="${p.fvalue}" placeholder="value" maxlength="${MAX_PARAM}" />
210 </label>
189211 `;
190212 }
191213
@@ -234,7 +256,7 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
234256 );
235257
236258 const form = card(
237 html`<form method="get" action="/boards/data" class="row-actions">
259 html`<form method="get" action="/boards/data" class="bform">
238260 ${queryFields(p)}
239261 ${btn("Draw", { variant: "primary" })}
240262 </form>`,
@@ -275,15 +297,15 @@ export async function boardsPage(ctx: Ctx): Promise<Response> {
275297 boards.length === 0 || !answer.ok
276298 ? null
277299 : card(
278 html`<form method="post" action="/boards/pin" class="row-actions">
300 html`<form method="post" action="/boards/pin" class="bform">
279301 ${PANEL_FIELDS.map(
280302 (k) => html`<input type="hidden" name="${k}" value="${p[k as keyof Params]}" />`,
281303 )}
282304 <input type="hidden" name="w" value="1" /><input type="hidden" name="h" value="1" />
283 <select class="input" name="slug" aria-label="Board">
305 <label class="qf"><span>Board</span><select class="input" name="slug">
284306 ${boards.map((b) => html`<option value="${b.slug}">${b.board.title}</option>`)}
285 </select>
286 <input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" aria-label="Panel title" />
307 </select></label>
308 <label class="qf"><span>Panel title</span><input class="input" name="title" required maxlength="80" value="${`${p.aggregate} of ${p.source}`}" /></label>
287309 ${btn("Pin to board")}
288310 </form>`,
289311 { head: html`<h2>Pin this query</h2>` },
src/web/style/content.ts +4
@@ -401,6 +401,10 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
401401 .chart-box.is-table { aspect-ratio: auto; height: 260px; overflow: auto; }
402402 .bp-h2 .chart-box.is-table { height: 400px; }
403403 .bp-h3 .chart-box.is-table { height: 540px; }
404 .bform { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 12px; margin-bottom: 8px; }
405 .bform .qf { display: flex; flex-direction: column; gap: 4px; flex: 1 1 140px; min-width: 0; }
406 .bform .qf > span { font-size: 12px; font-weight: 600; color: var(--fg); }
407 .bform .btn { flex: none; }
404408 .boards-result .table { margin-top: 12px; }
405409 .sys-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 16px; }
406410 .sys-pair .chart-box { max-width: none; }
test/boards-selectors.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * /boards/data and a board's forms: every selector is in the page with a
3 * visible label, none sits in the hover-only `.row-actions` (opacity 0 until
4 * a `.row` is hovered, which hid them), and each theme's label, field and
5 * border colours are a readable pair.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp } from "../src/app.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { THEMES } from "../src/design/themes.ts";
15 import { serve } from "../src/http/server.ts";
16 import { content } from "../src/web/style/content.ts";
17 import { components } from "../src/web/style/components.ts";
18
19 let dir: string;
20 let app: ReturnType<typeof createApp>;
21 let servers: Awaited<ReturnType<typeof serve>>;
22 let token: string;
23
24 beforeAll(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-boards-selectors-"));
26 app = createApp({ dataDir: dir, port: 0 });
27 servers = await serve(app);
28 const r = await createUser(app.db, {
29 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
30 password: "a-good-passphrase", adminIfFirst: true,
31 });
32 if (!r.ok) throw new Error(r.error);
33 token = createToken(app.db, r.value.id, "t", "admin").token;
34 });
35
36 afterAll(async () => {
37 servers.stop();
38 app.close();
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 const lum = (hex: string) => {
43 const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
44 .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
45 return 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
46 };
47 const ratio = (a: string, b: string) => {
48 const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
49 return (hi! + 0.05) / (lo! + 0.05);
50 };
51
52 test("every query selector has a visible label and sits outside .row-actions", async () => {
53 const res = await fetch(`${servers.url}/boards/data`, { headers: { authorization: `Bearer ${token}` } });
54 expect(res.status).toBe(200);
55 const html = await res.text();
56 const form = html.match(/<form method="get" action="\/boards\/data"[^>]*>([\s\S]*?)<\/form>/);
57 expect(form).not.toBeNull();
58 expect(form![0]).not.toContain("row-actions");
59 expect(form![0]).toContain('class="bform"');
60 for (const name of ["chart", "source", "aggregate", "column", "by", "series", "from", "to", "fcolumn", "fop", "fvalue"]) {
61 const m = form![1]!.match(new RegExp(`<label class="qf"[^>]*><span>([^<]+)</span>\\s*<(?:select|input)[^>]* name="${name}"`));
62 expect(m, name).not.toBeNull();
63 expect(m![1]!.trim().length, name).toBeGreaterThan(0);
64 }
65 });
66
67 test("the selector styles never hide or collapse a control", () => {
68 const css = components + content;
69 const rules = [...css.matchAll(/(^|\})\s*([^{}]*\.(?:bform|qf)[^{}]*)\{([^}]*)\}/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/);
73 }
74 });
75
76 test("in every theme the label, field text and border read against their ground", () => {
77 for (const [name, theme] of Object.entries(THEMES)) {
78 const t = theme.tokens;
79 expect(ratio(t.fg, t.bg), `${name} label`).toBeGreaterThanOrEqual(4.5);
80 expect(ratio(t.fg, t.card), `${name} field text`).toBeGreaterThanOrEqual(4.5);
81 expect(ratio(t["border-strong"], t.card), `${name} border`).toBeGreaterThan(1.3);
82 expect(t.card.toLowerCase(), `${name} field ground`).not.toBe(t.fg.toLowerCase());
83 }
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs