fix: align form field widths across the app

From 900px up, selects and text fields fill their column up to 480px,
numbers and dates are 140px, and long text stays full width, so every
form shares one right edge. Below 900px every field runs full width.
Overrides core's id-pinned widths (% Done, mobile dates, settings
textareas). check-form-alignment.mjs now checks widths at 1440, 1024
and 390px across all settings tabs and admin forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-10-05 14:12:44 +02:00
co-authored by Claude Opus 5.5
parent 20b7269df2
commit db58c6e86f
3 changed files with 97 additions and 39 deletions
+59 -23
View File
@@ -1,47 +1,83 @@
// Checks that each form label (.tabular forms, query filters) lines up with the field next to it. // 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) // Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px)
import { chromium } from 'playwright'; import { chromium } from 'playwright';
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 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',
'/projects/apollo/time_entries/new', '/settings', '/my/account', '/users/5/edit', '/projects/apollo/time_entries/new', '/my/account', '/my/password', '/users/5/edit',
'/projects/apollo/settings', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/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(); const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, 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'));
let failures = 0; let failures = 0;
for (const path of pages) { 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'));
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' }); await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const rows = await page.evaluate(() => [ const rows = await page.evaluate((stacked) => {
const visible = (el) => el && el.offsetParent;
const out = [];
if (!stacked) {
// Query filters: label text vs. operator select // Query filters: label text vs. operator select
...[...document.querySelectorAll('#filters-table .filter')].flatMap((row) => { for (const row of document.querySelectorAll('#filters-table .filter')) {
const label = row.querySelector('.field label'), control = row.querySelector('.operator select'); const label = row.querySelector('.field label'), control = row.querySelector('.operator select');
if (!label || !control || !control.offsetParent) return []; if (!label || !visible(control)) continue;
const l = label.getBoundingClientRect(), c = control.getBoundingClientRect(); const l = label.getBoundingClientRect(), c = control.getBoundingClientRect();
return [{ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) }]; out.push({ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) });
}), }
...[...document.querySelectorAll('.tabular p')].flatMap((row) => { // 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 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'); 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 (!label || !control || !label.offsetParent || !control.offsetParent) return []; if (!visible(label) || !visible(control)) continue;
const style = getComputedStyle(label); const style = getComputedStyle(label);
const labelBox = label.getBoundingClientRect(); const labelBox = label.getBoundingClientRect();
const controlBox = control.getBoundingClientRect(); const controlBox = control.getBoundingClientRect();
const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2; const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2;
return [{ label: label.textContent.trim().slice(0, 30), offset: Math.round(labelMiddle - (controlBox.top + controlBox.height / 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); const bad = rows.filter((r) => Math.abs(r.offset) > 2);
failures += bad.length; failures += bad.length;
console.log(`${bad.length ? 'FAIL' : 'ok '} ${path} (${rows.length} rows)${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('')}`);
}
await page.close();
} }
await browser.close(); await browser.close();
+23 -5
View File
@@ -178,18 +178,36 @@ blockquote { border-inline-start: 4px solid var(--md-sys-color-outline-variant);
/* Core gives wide form fields 90%, 95% or 99% and caps .tabular fields at 95%. They all /* Core gives wide form fields 90%, 95% or 99% and caps .tabular fields at 95%. They all
run to the content edge, so the right edges in a form line up */ run to the content edge, so the right edges in a form line up */
.tabular :is(input, select) { max-inline-size: 100%; } .tabular :is(input, select) { max-inline-size: 100%; }
/* Core pins these to 90% by id */
#user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select { inline-size: 100%; } #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select { inline-size: 100%; }
.tabular textarea, textarea.wiki-edit, textarea.text_cf, input.string_cf, input.link_cf, /* Desktop and tablet landscape: three field widths, so the right edges in a form line up.
textarea#custom_field_possible_values, textarea#custom_field_default_value, Standard: selects and text fields fill the column up to 480px (wider than core's widest,
input#issue_subject, input#document_title, input#content_comments, input#time_entry_comments, size=60). Compact: numbers and dates, 140px fits a date with its picker. Full: long text.
.tabular .jstTabs, .tabular .wiki-preview, .jstBlock .jstTabs, .jstBlock .wiki-preview { inline-size: 100%; box-sizing: border-box; } Below 900px core's responsive.css runs every field full width. */
@media screen and (min-width: 900px) {
.tabular :is(select, input:is([type="text"], [type="password"], [type="email"], [type="url"], [type="search"], [type="tel"], :not([type]))) {
inline-size: 100%;
max-inline-size: 480px;
box-sizing: border-box;
}
.tabular :is(input[type="number"], input[type="date"], input.date, input:is([size="1"], [size="2"], [size="3"], [size="4"], [size="5"], [size="6"])) { inline-size: 140px; }
/* The icon button after a select (new category, new version) stays on its line */
.tabular select:has(+ .icon-only) { inline-size: calc(100% - 36px); max-inline-size: calc(480px - 36px); }
}
html .tabular textarea, html textarea.wiki-edit, html textarea.text_cf, html input.string_cf, html input.link_cf,
html textarea#custom_field_possible_values, html textarea#custom_field_default_value,
html input#issue_subject, html input#document_title, html input#content_comments, html input#time_entry_comments,
html .tabular .jstTabs, html .tabular .wiki-preview, html .jstBlock .jstTabs, html .jstBlock .wiki-preview,
html .tabular.settings :is(textarea, .jstTabs, .wiki-preview) { inline-size: 100%; max-inline-size: none; box-sizing: border-box; }
/* Core pins % Done to 95px by id; it follows the standard width like the other selects */
html select#issue_done_ratio { inline-size: 100%; }
/* An invalid field keeps the error colour while focused */ /* An invalid field keeps the error colour while focused */
.tabular label.error + :is(input, select, textarea):focus { .tabular label.error + :is(input, select, textarea):focus {
border-color: var(--md-sys-color-error); border-color: var(--md-sys-color-error);
outline-color: var(--md-sys-color-error); outline-color: var(--md-sys-color-error);
} }
/* Rows without a field start at the top, like a row with only links (two-factor auth) */ /* Rows without a field start at the top, like a row with only links (two-factor auth) */
.tabular p:not(:has(input:not([type=hidden]), select, textarea)) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; } .tabular p:not(:has(input:not([type=hidden]), select, textarea, .nm-segmented)) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; }
/* Cancel next to a submit button */ /* Cancel next to a submit button */
input[type="submit"] + a { margin-inline-start: 8px; } input[type="submit"] + a { margin-inline-start: 8px; }
+4
View File
@@ -229,6 +229,10 @@
/* Form fields run full width; a select keeps room for the icon button after it */ /* Form fields run full width; a select keeps room for the icon button after it */
html form .attributes select { inline-size: 100%; } html form .attributes select { inline-size: 100%; }
html form .attributes select:has(+ .icon-only) { inline-size: calc(100% - 36px); } html form .attributes select:has(+ .icon-only) { inline-size: calc(100% - 36px); }
/* Core leaves the tracker at 90%; only the project row shares its line (with "Private") */
html #all_attributes #issue_tracker_id { inline-size: 100%; }
/* Core leaves dates at their content width; every field runs full width here */
html .tabular input:is(.date, [type="date"]) { inline-size: 100%; max-inline-size: 100%; }
html .tabular input[type="radio"] { inline-size: auto; } html .tabular input[type="radio"] { inline-size: auto; }
/* Query filters: operator and values run full width under the checkbox label instead of /* Query filters: operator and values run full width under the checkbox label instead of