Files
surtic86andClaude Opus 5.5 232958cd13 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>
2026-10-15 09:24:04 +02:00

90 lines
5.6 KiB
JavaScript

// Checks .tabular forms and query filters at desktop, tablet landscape and phone width:
// - each form label lines up with the field next to it (not on phones, where labels stack)
// - field widths follow the three tracks (components.css): standard fields fill the column up
// to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column;
// on phones every field fills its row
// 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 { setSize } from './size.mjs';
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 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/apollo/time_entries/new', '/my/account', '/my/password', '/users/5/edit',
'/projects/apollo/settings', '/projects/apollo/settings/issues', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/edit',
'/projects/apollo/wiki/Wiki/rename', '/projects/apollo/versions/new', '/projects/apollo/news/new', '/projects/apollo/documents/new',
'/projects/apollo/queries/new', '/projects/new', '/roles/new', '/trackers/new', '/groups/new',
...settingsTabs.map((tab) => `/settings?tab=${tab}`),
];
const browser = await chromium.launch();
let failures = 0;
for (const width of [1440, 1024, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`${BASE}/login`);
await page.fill('#username', 'admin');
await page.fill('#password', 'admin');
await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
if (SIZE) await setSize(page, SIZE);
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const rows = await page.evaluate((stacked) => {
const visible = (el) => el && el.offsetParent;
const out = [];
if (!stacked) {
// Query filters: label text vs. operator select
for (const row of document.querySelectorAll('#filters-table .filter')) {
const label = row.querySelector('.field label'), control = row.querySelector('.operator select');
if (!label || !visible(control)) continue;
const l = label.getBoundingClientRect(), c = control.getBoundingClientRect();
out.push({ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) });
}
// Labels vs. fields (and the default appearance groups on the Display tab)
for (const row of document.querySelectorAll('.tabular p')) {
const label = row.querySelector(':scope > label:not(.floating):not(.block):not(.inline):not(.no-css)');
const control = row.querySelector(':scope > input:not([type=hidden]), :scope > select, :scope > span > input:not([type=hidden]), :scope > span > select, :scope > .nm-appearance > [role="group"]');
if (!visible(label) || !visible(control)) continue;
const style = getComputedStyle(label);
const labelBox = label.getBoundingClientRect();
const controlBox = control.getBoundingClientRect();
const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2;
out.push({ label: label.textContent.trim().slice(0, 30), offset: Math.round(labelMiddle - (controlBox.top + controlBox.height / 2)) });
}
}
// Field widths
const textLike = 'textarea, select:not([multiple]), input:is([type="text"], [type="password"], [type="email"], [type="url"], [type="search"], [type="tel"], [type="number"], [type="date"], :not([type]))';
const compact = 'input:is([type="number"], [type="date"], .date, [size="1"], [size="2"], [size="3"], [size="4"], [size="5"], [size="6"])';
const full = 'textarea, input:is(.string_cf, .link_cf, #issue_subject, #document_title, #content_comments, #time_entry_comments)';
for (const field of document.querySelectorAll(`.tabular p > :is(${textLike}), .tabular p > span > :is(${textLike})`)) {
if (!visible(field)) continue;
// The project select shares its row with the floated "Private" checkbox
if (field.id === 'issue_project_id') continue;
const row = field.closest('p');
const style = getComputedStyle(row);
const column = row.clientWidth - parseFloat(style.paddingInlineStart) - parseFloat(style.paddingInlineEnd);
const icon = field.nextElementSibling?.matches('.icon-only') ? 36 : 0;
const expected = stacked || field.matches(full) ? column - icon
: field.matches(compact) ? 140
: Math.min(480, column) - icon;
const actual = field.getBoundingClientRect().width;
const name = row.querySelector(':scope > label')?.textContent.trim().slice(0, 30) || field.id || field.name;
out.push({ label: `width ${name}`, offset: Math.round(actual - expected) });
}
return out;
}, width < 900);
const bad = rows.filter((r) => Math.abs(r.offset) > 2);
failures += bad.length;
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 browser.close();
process.exit(failures ? 1 : 0);