test: SIZE option for screenshots and form alignment; scale filter rows (T9)

Query filter rows kept a fixed 40px height, so in Compact their labels
sat 3px below the operator select.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:24:04 +02:00
co-authored by Claude Opus 5.5
parent 5738abe3ef
commit 232958cd13
3 changed files with 15 additions and 3 deletions
+5
View File
@@ -4,9 +4,12 @@
// to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column; // to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column;
// on phones every field fills its row // on phones every field fills its row
// Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px) // Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px)
// SIZE=compact|large node check-form-alignment.mjs (in that size mode; needs the plugin)
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { setSize } from './size.mjs';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070'; const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const SIZE = process.env.SIZE;
const settingsTabs = ['general', 'display', 'authentication', 'api', 'projects', 'users', 'issues', 'timelog', 'attachments', 'mail_handler', 'repositories']; const settingsTabs = ['general', 'display', 'authentication', 'api', 'projects', 'users', 'issues', 'timelog', 'attachments', 'mail_handler', 'repositories'];
const pages = [ const pages = [
'/projects?display_type=list', '/projects/apollo/issues', '/projects/apollo/time_entries', '/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17', '/projects?display_type=list', '/projects/apollo/issues', '/projects/apollo/time_entries', '/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17',
@@ -26,6 +29,7 @@ for (const width of [1440, 1024, 390]) {
await page.fill('#password', 'admin'); await page.fill('#password', 'admin');
await page.click('#login-submit'); await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login')); await page.waitForURL((url) => !url.pathname.startsWith('/login'));
if (SIZE) await setSize(page, SIZE);
for (const path of pages) { for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -77,6 +81,7 @@ for (const width of [1440, 1024, 390]) {
failures += bad.length; failures += bad.length;
console.log(`${bad.length ? 'FAIL' : 'ok '} ${width} ${path} (${rows.length} checks)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`); console.log(`${bad.length ? 'FAIL' : 'ok '} ${width} ${path} (${rows.length} checks)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`);
} }
if (SIZE) await setSize(page);
await page.close(); await page.close();
} }
+8 -1
View File
@@ -1,12 +1,15 @@
// Captures the page checklist in light/dark × desktop/mobile into ../screenshots/. // Captures the page checklist in light/dark × desktop/mobile into ../screenshots/.
// Usage: node screenshots.mjs [filter] e.g. `node screenshots.mjs issue` for pages whose name contains "issue". // Usage: node screenshots.mjs [filter] e.g. `node screenshots.mjs issue` for pages whose name contains "issue".
// SIZE=compact|large node screenshots.mjs (in that size mode, files end in -compact/-large; needs the plugin)
// Needs the dev server with seed data (see README). // Needs the dev server with seed data (see README).
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises'; import { mkdir } from 'node:fs/promises';
import { setSize } from './size.mjs';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070'; const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const OUT = new URL('../screenshots/', import.meta.url).pathname; const OUT = new URL('../screenshots/', import.meta.url).pathname;
const filter = process.argv[2] ?? ''; const filter = process.argv[2] ?? '';
const SIZE = process.env.SIZE;
const pages = [ const pages = [
['my-page', '/my/page'], ['my-page', '/my/page'],
@@ -111,9 +114,11 @@ for (const scheme of ['light', 'dark']) {
for (const [vpName, vp] of Object.entries(viewports)) { for (const [vpName, vp] of Object.entries(viewports)) {
const { width, height, ...rest } = vp; const { width, height, ...rest } = vp;
const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, ...rest }); const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, ...rest });
// Logged out, the size comes from localStorage; logged in, from the account (set below)
if (SIZE) await context.addInitScript((size) => localStorage.setItem('tonalmine', JSON.stringify({ size })), SIZE);
const page = await context.newPage(); const page = await context.newPage();
const shot = (name, fullPage = true) => const shot = (name, fullPage = true) =>
page.screenshot({ path: `${OUT}${name}-${scheme}-${vpName}.png`, fullPage }); page.screenshot({ path: `${OUT}${name}-${scheme}-${vpName}${SIZE ? `-${SIZE}` : ''}.png`, fullPage });
for (const [name, path] of publicPages.filter(([n]) => n.includes(filter))) { for (const [name, path] of publicPages.filter(([n]) => n.includes(filter))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -124,6 +129,7 @@ for (const scheme of ['light', 'dark']) {
await page.fill('#password', 'admin'); await page.fill('#password', 'admin');
await page.click('#login-submit'); await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login')); await page.waitForURL((url) => !url.pathname.startsWith('/login'));
if (SIZE) await setSize(page, SIZE);
for (const [name, path] of pages.filter(([n]) => n.includes(filter))) { for (const [name, path] of pages.filter(([n]) => n.includes(filter))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -135,6 +141,7 @@ for (const scheme of ['light', 'dark']) {
await act(page); await act(page);
await shot(name, !opts.viewportOnly); await shot(name, !opts.viewportOnly);
} }
if (SIZE) await setSize(page);
await context.close(); await context.close();
} }
} }
+2 -2
View File
@@ -146,9 +146,9 @@ a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: var(--md-sy
40px high and centers its content; the row stays top-aligned so an expanded multi-select grows 40px high and centers its content; the row stays top-aligned so an expanded multi-select grows
downwards without dragging the label to its middle. */ downwards without dragging the label to its middle. */
#filters-table .filter { align-items: flex-start; margin-block: 4px 0; } #filters-table .filter { align-items: flex-start; margin-block: 4px 0; }
#filters-table .filter .field { display: flex; align-items: center; gap: 8px; min-block-size: 40px; } #filters-table .filter .field { display: flex; align-items: center; gap: 8px; min-block-size: calc(40px + var(--nm-density)); }
#filters-table .filter .field input[type="checkbox"] { margin: 0; } #filters-table .filter .field input[type="checkbox"] { margin: 0; }
#filters-table .filter .values { display: flex; align-items: center; min-block-size: 40px; flex-wrap: wrap; gap: 4px 8px; } #filters-table .filter .values { display: flex; align-items: center; min-block-size: calc(40px + var(--nm-density)); flex-wrap: wrap; gap: 4px 8px; }
/* "Add filter" is an M3 tonal button, not one more outlined field. A select has no /* "Add filter" is an M3 tonal button, not one more outlined field. A select has no
placeholder, so the label lies over it in the same grid cell and lets taps through. placeholder, so the label lies over it in the same grid cell and lets taps through.
A zero width with a 100% minimum keeps the longest option from widening the button. */ A zero width with a 100% minimum keeps the longest option from widening the button. */