Compare commits

...
11 Commits
Author SHA1 Message Date
surtic86andClaude Opus 5.5 75209874ec fix: + button after a select lines up with Assign to me
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:48:12 +02:00
surtic86andClaude Opus 5.5 901ed1c098 fix: e2e size helper keeps the user's other appearance choices
It sent the size alone, which replaced the admin's saved mode, contrast
and color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:18:45 +02:00
surtic86andClaude Opus 5.5 f08d2c3f6c fix: center text vertically in Compact and Large
The size modes scale rem line heights to fractions (1.25rem = 22.857px in
Large), and pixel rounding moved text up to 1.2px off center. Type scale
line heights and the header's 3rem now round to whole px (no change in
Normal). Segmented buttons, Reset to default and the project select drop
core's vertical padding, which made the line overflow and sink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:18:45 +02:00
surtic86andClaude Opus 5.5 f5119bba50 fix: mobile search container grows with the search field in Large
Core sets the container to 64px with a 54px line-height, so the 56px
Large search pill pushed against the avatar row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:32:28 +02:00
surtic86andClaude Opus 5.5 833393a686 docs: size modes (T10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:24:04 +02:00
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
surtic86andClaude Opus 5.5 5738abe3ef test: check-sizes measures text and control heights per size mode (T8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:24:04 +02:00
surtic86andClaude Opus 5.5 77ff80e60b test: size in the appearance end-to-end check (T7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:24:04 +02:00
surtic86andClaude Opus 5.5 94a9bd066e feat: validate and store the size choice in redmine_tonalmine (T6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:23 +02:00
surtic86andClaude Opus 5.5 7ef21c739a feat: size picker in the appearance menu and admin default (T5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:23 +02:00
surtic86andClaude Opus 5.5 9a04f94118 feat: density token and root font size for size modes (T1-T4)
Compact, Normal and Large scale the text (13/14/16px) and the control
heights (32/40/48px) via data-size on <html>. Normal is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:16:40 +02:00
13 changed files with 212 additions and 95 deletions
+7 -2
View File
@@ -36,6 +36,8 @@ node screenshots.mjs # all pages, light/dark × desktop/mobile → sc
node screenshots.mjs issue # only pages whose name contains "issue" node screenshots.mjs issue # only pages whose name contains "issue"
node check-form-alignment.mjs # fails if a form label is not level with its field node check-form-alignment.mjs # fails if a form label is not level with its field
node check-font-sizes.mjs # fails if any text is off the type scale (e.g. core 0.9em leftovers) node check-font-sizes.mjs # fails if any text is off the type scale (e.g. core 0.9em leftovers)
node check-sizes.mjs # fails if text, fields, buttons or rows are off in a size mode (needs the plugin)
SIZE=large node screenshots.mjs # screenshots or check-form-alignment.mjs in a size mode (compact or large; needs the plugin)
``` ```
### Icons ### Icons
@@ -52,9 +54,12 @@ the map, so run it after every Redmine update.
### Colors ### Colors
Each user picks a mode (Auto / Light / Dark), a contrast level (Standard / Medium / High) and a color (Purple, Blue, Green, Orange) in the Each user picks a mode (Auto / Light / Dark), a contrast level (Standard / Medium / High), a size
(Compact / Normal / Large) and a color (Purple, Blue, Green, Orange) in the
**Appearance** section of the profile menu (top right; in the mobile menu at the bottom). Auto follows **Appearance** section of the profile menu (top right; in the mobile menu at the bottom). Auto follows
the operating system. Without the plugin, the choice is stored per browser (`localStorage`). the operating system. The size scales text (13 / 14 / 16px) and the height of fields, buttons and
table rows (32 / 40 / 48px), also on phones, where Compact makes touch targets smaller than 48px.
Without the plugin, the choice is stored per browser (`localStorage`).
With the `redmine_tonalmine` plugin: With the `redmine_tonalmine` plugin:
+23 -19
View File
@@ -26,10 +26,10 @@ async function login(user, password = 'password123', setup) {
return page; return page;
} }
// The <html> attributes theme.js sets; absent means the built-in default (auto, standard, purple). // The <html> attributes theme.js sets; absent means the built-in default (auto, standard, normal, purple).
const looks = (page) => page.evaluate(() => { const looks = (page) => page.evaluate(() => {
const d = document.documentElement.dataset; const d = document.documentElement.dataset;
return `${d.mode ?? 'auto'} ${d.contrast ?? 'standard'} ${d.palette ?? 'purple'}`; return `${d.mode ?? 'auto'} ${d.contrast ?? 'standard'} ${d.size ?? 'normal'} ${d.palette ?? 'purple'}`;
}); });
async function expectLook(name, page, want) { async function expectLook(name, page, want) {
@@ -78,8 +78,8 @@ await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
// 1. Admin default on the Display settings tab // 1. Admin default on the Display settings tab
const admin = await login('admin', 'admin'); const admin = await login('admin', 'admin');
await admin.goto(`${BASE}/settings?tab=display`); await admin.goto(`${BASE}/settings?tab=display`);
// Mode, contrast and color are three form rows // Mode, contrast, size and color are four form rows
const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 3 && rows.map((r) => !!r.offsetParent)); const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 4 && rows.map((r) => !!r.offsetParent));
check('default picker shown while Tonalmine is selected', (await rowsShown())?.every(Boolean)); check('default picker shown while Tonalmine is selected', (await rowsShown())?.every(Boolean));
await admin.selectOption('#settings_ui_theme', ''); await admin.selectOption('#settings_ui_theme', '');
check('default picker hidden and not posted for another theme', (await rowsShown())?.every((shown) => !shown) && check('default picker hidden and not posted for another theme', (await rowsShown())?.every((shown) => !shown) &&
@@ -94,25 +94,29 @@ check('default picker shows the saved default', await admin.evaluate(() =>
// 2. A user without a choice gets the default // 2. A user without a choice gets the default
const jdoe = await login('jdoe'); const jdoe = await login('jdoe');
await expectLook('user without a choice gets the default', jdoe, 'dark standard blue'); await expectLook('user without a choice gets the default', jdoe, 'dark standard normal blue');
// 3. The user's choice is saved on the account and only overrides that part // 3. The user's choice is saved on the account and only overrides that part
check('choosing Light is saved (204)', (await pick(jdoe, '[data-nm-mode="light"]')) === 204); check('choosing Light is saved (204)', (await pick(jdoe, '[data-nm-mode="light"]')) === 204);
check('choosing Compact is saved (204)', (await pick(jdoe, '[data-nm-size="compact"]')) === 204);
const jdoe2 = await login('jdoe'); const jdoe2 = await login('jdoe');
await expectLook('choice follows the user to another browser', jdoe2, 'light standard blue'); await expectLook('choice follows the user to another browser', jdoe2, 'light standard compact blue');
// 4. Parts the user didn't change follow a new default // 4. Parts the user didn't change follow a new default; the user's Compact beats the default Large
await saveDefault(admin, () => admin.click('[data-nm-default] [data-nm-palette="green"]')); await saveDefault(admin, async () => {
await admin.click('[data-nm-default] [data-nm-palette="green"]');
await admin.click('[data-nm-default] [data-nm-size="large"]');
});
await jdoe2.reload({ waitUntil: 'networkidle' }); await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('unchanged parts follow the new default', jdoe2, 'light standard green'); await expectLook('unchanged parts follow the new default', jdoe2, 'light standard compact green');
// 5. Reset to default // 5. Reset to default
check('Reset to default is shown after a change', await jdoe2.isVisible('#account [data-nm-reset]') || check('Reset to default is shown after a change', await jdoe2.isVisible('#account [data-nm-reset]') ||
await jdoe2.evaluate(() => !document.querySelector('#account [data-nm-reset]').hidden)); await jdoe2.evaluate(() => !document.querySelector('#account [data-nm-reset]').hidden));
check('reset is saved (204)', (await pick(jdoe2, '[data-nm-reset]')) === 204); check('reset is saved (204)', (await pick(jdoe2, '[data-nm-reset]')) === 204);
await expectLook('reset returns to the default', jdoe2, 'dark standard green'); await expectLook('reset returns to the default', jdoe2, 'dark standard large green');
await jdoe2.reload({ waitUntil: 'networkidle' }); await jdoe2.reload({ waitUntil: 'networkidle' });
await expectLook('reset is kept after reload', jdoe2, 'dark standard green'); await expectLook('reset is kept after reload', jdoe2, 'dark standard large green');
check('Reset to default is hidden without a choice', await jdoe2.evaluate(() => document.querySelector('#account [data-nm-reset]').hidden)); check('Reset to default is hidden without a choice', await jdoe2.evaluate(() => document.querySelector('#account [data-nm-reset]').hidden));
// 5b. A custom color, with its generated CSS, is saved on the account // 5b. A custom color, with its generated CSS, is saved on the account
@@ -120,33 +124,33 @@ const savedColor = jdoe2.waitForResponse((r) => r.url().includes('/tonalmine/app
await pickColor(jdoe2, '#account', '#c0392b'); await pickColor(jdoe2, '#account', '#c0392b');
check('custom color is saved (204)', (await savedColor).status() === 204); check('custom color is saved (204)', (await savedColor).status() === 204);
const jdoe3 = await login('jdoe'); const jdoe3 = await login('jdoe');
await expectLook('custom color follows the user', jdoe3, 'dark standard custom'); await expectLook('custom color follows the user', jdoe3, 'dark standard large custom');
check('custom color CSS comes from the account', (await customCss(jdoe3)) > 1000); check('custom color CSS comes from the account', (await customCss(jdoe3)) > 1000);
await pick(jdoe3, '[data-nm-reset]'); await pick(jdoe3, '[data-nm-reset]');
// 6. A localStorage choice from before the plugin moves to the account once // 6. A localStorage choice from before the plugin moves to the account once
const msmith = await login('msmith', 'password123', () => localStorage.setItem('tonalmine', '{"contrast":"high"}')); const msmith = await login('msmith', 'password123', () => localStorage.setItem('tonalmine', '{"contrast":"high"}'));
await expectLook('localStorage choice is applied after login', msmith, 'dark high green'); await expectLook('localStorage choice is applied after login', msmith, 'dark high large green');
check('localStorage is cleared for logged-in users', await msmith.evaluate(() => localStorage.getItem('tonalmine') === null)); check('localStorage is cleared for logged-in users', await msmith.evaluate(() => localStorage.getItem('tonalmine') === null));
const msmith2 = await login('msmith'); const msmith2 = await login('msmith');
await expectLook('uploaded choice is on the account', msmith2, 'dark high green'); await expectLook('uploaded choice is on the account', msmith2, 'dark high large green');
// 7. Validation; the request without a CSRF token goes last, since Redmine then ends the session // 7. Validation; the request without a CSRF token goes last, since Redmine then ends the session
const pweber = await login('pweber'); const pweber = await login('pweber');
check('valid choice is saved (204)', (await put(pweber, { contrast: 'medium' })) === 204); check('valid choice is saved (204)', (await put(pweber, { contrast: 'medium' })) === 204);
for (const body of [{ mode: 'neon' }, { palette: 'custom' }, { palette: 'custom', seed: '#123456', css: '</style>' }, { foo: 1 }, { mode: 5 }]) { for (const body of [{ mode: 'neon' }, { palette: 'custom' }, { palette: 'custom', seed: '#123456', css: '</style>' }, { size: 'huge' }, { foo: 1 }, { mode: 5 }]) {
const status = await put(pweber, body); const status = await put(pweber, body);
check(`invalid ${JSON.stringify(body)} is refused (422)`, status === 422, `got ${status}`); check(`invalid ${JSON.stringify(body)} is refused (422)`, status === 422, `got ${status}`);
} }
const tokenless = await put(pweber, { contrast: 'high' }, false); const tokenless = await put(pweber, { contrast: 'high' }, false);
check('request without CSRF token is refused (422)', tokenless === 422, `got ${tokenless}`); check('request without CSRF token is refused (422)', tokenless === 422, `got ${tokenless}`);
const pweber2 = await login('pweber'); const pweber2 = await login('pweber');
await expectLook('refused requests left the choice unchanged', pweber2, 'dark medium green'); await expectLook('refused requests left the choice unchanged', pweber2, 'dark medium large green');
// 8. Anonymous visitors get the default // 8. Anonymous visitors get the default
const anonymous = await (await browser.newContext()).newPage(); const anonymous = await (await browser.newContext()).newPage();
await anonymous.goto(`${BASE}/login`); await anonymous.goto(`${BASE}/login`);
await expectLook('anonymous visitor gets the default', anonymous, 'dark standard green'); await expectLook('anonymous visitor gets the default', anonymous, 'dark standard large green');
// 8b. A custom default color reaches everyone // 8b. A custom default color reaches everyone
await saveDefault(admin, async () => { await saveDefault(admin, async () => {
@@ -154,13 +158,13 @@ await saveDefault(admin, async () => {
await admin.waitForFunction(() => document.querySelector('[data-nm-default-input="css"]').value.length > 1000); await admin.waitForFunction(() => document.querySelector('[data-nm-default-input="css"]').value.length > 1000);
}); });
await anonymous.reload(); await anonymous.reload();
await expectLook('custom default reaches anonymous visitors', anonymous, 'dark standard custom'); await expectLook('custom default reaches anonymous visitors', anonymous, 'dark standard large custom');
check('custom default CSS is rendered', (await customCss(anonymous)) > 1000); check('custom default CSS is rendered', (await customCss(anonymous)) > 1000);
// 9. Clean up: empty default, so other checks and screenshots see the built-in look // 9. Clean up: empty default, so other checks and screenshots see the built-in look
await saveDefault(admin, () => admin.$$eval('[data-nm-default-input]', (inputs) => inputs.forEach((i) => { i.value = ''; }))); await saveDefault(admin, () => admin.$$eval('[data-nm-default-input]', (inputs) => inputs.forEach((i) => { i.value = ''; })));
await anonymous.reload(); await anonymous.reload();
await expectLook('empty default restores the built-in look', anonymous, 'auto standard purple'); await expectLook('empty default restores the built-in look', anonymous, 'auto standard normal purple');
await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser)); await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
await browser.close(); await browser.close();
+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();
} }
+51
View File
@@ -0,0 +1,51 @@
// Checks the size modes (Compact / Normal / Large): body text and the heights of a table row, a
// text field, the submit button and (on phones) a mobile menu link. Sets the size on the admin
// account, so it needs the redmine_tonalmine plugin (dev/docker-compose.yml); resets it when done.
// Usage: node check-sizes.mjs (exits 1 on any failure)
import { chromium } from 'playwright';
import { setSize } from './size.mjs';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
// size → [body text, fields / buttons / phone table rows, mobile menu links]
const SIZES = { compact: ['13px', 32, 40], normal: ['14px', 40, 48], large: ['16px', 48, 56] };
const browser = await chromium.launch();
let failures = 0;
function check(name, got, want) {
const ok = got === want;
if (!ok) failures++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : `\n expected ${want}, got ${got}`}`);
}
const height = (page, selector) => page.$eval(selector, (el) => Math.round(el.getBoundingClientRect().height));
for (const [width, mobile] of [[1440, false], [390, true]]) {
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 [size, [text, control, menuLink]] of Object.entries(SIZES)) {
await setSize(page, size);
const at = `${size} ${width}`;
await page.goto(`${BASE}/projects/apollo/issues`, { waitUntil: 'networkidle' });
check(`${at} body text`, await page.$eval('body', (el) => getComputedStyle(el).fontSize), text);
// On desktop the cells' 4px padding comes on top of their block-size
check(`${at} issue row`, await height(page, 'table.list.issues tbody tr:first-child'), control + (mobile ? 0 : 8));
if (mobile) {
await page.click('.mobile-toggle-button');
await page.waitForSelector('.flyout-menu .js-project-menu a.overview', { state: 'visible' });
check(`${at} mobile menu link`, await height(page, '.flyout-menu .js-project-menu a.overview'), menuLink);
}
await page.goto(`${BASE}/projects/apollo/issues/new`, { waitUntil: 'networkidle' });
check(`${at} text field`, await height(page, '#issue_subject'), control);
check(`${at} submit button`, await height(page, 'input[name="commit"]'), control);
}
await setSize(page);
await page.close();
}
await browser.close();
process.exit(failures ? 1 : 0);
+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();
} }
} }
+16
View File
@@ -0,0 +1,16 @@
// Saves a size mode on the logged-in user's account (needs the redmine_tonalmine plugin). The user's
// other choices (mode, contrast, color) are kept. No size removes the size choice again.
// The page must be loaded after the last change, since the saved choice is read from it.
export async function setSize(page, size) {
const status = await page.evaluate(async (size) => {
// The plugin hands the saved choice to theme.js in <head>: Tonalmine.configure({..., user: {...}})
const script = [...document.scripts].find((s) => s.text.includes('Tonalmine.configure('));
const prefs = JSON.parse(script.text.match(/Tonalmine\.configure\((.*)\);/s)[1]).user;
if (size) prefs.size = size;
else delete prefs.size;
const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content };
return (await fetch('/tonalmine/appearance', { method: 'PUT', headers, body: JSON.stringify(prefs) })).status;
}, size);
if (status !== 204) throw new Error(`could not save size ${size ?? 'default'} (HTTP ${status})`);
}
@@ -1,12 +1,13 @@
module RedmineTonalmine module RedmineTonalmine
# Validates appearance hashes ({mode, contrast, palette, seed, css}) coming from users and from # Validates appearance hashes ({mode, contrast, size, palette, seed, css}) coming from users and from
# the Display settings form. Any part may be missing: it then falls back to the default. # the Display settings form. Any part may be missing: it then falls back to the default.
module Appearance module Appearance
MODES = %w[auto light dark].freeze MODES = %w[auto light dark].freeze
CONTRASTS = %w[standard medium high].freeze CONTRASTS = %w[standard medium high].freeze
SIZES = %w[compact normal large].freeze
# Ids must match PALETTES in tonalmine/javascripts/theme.js (tools/palettes checks this). # Ids must match PALETTES in tonalmine/javascripts/theme.js (tools/palettes checks this).
PALETTES = %w[purple blue green orange].freeze PALETTES = %w[purple blue green orange].freeze
KEYS = %w[mode contrast palette seed css].freeze KEYS = %w[mode contrast size palette seed css].freeze
MAX_CSS = 32.kilobytes MAX_CSS = 32.kilobytes
module_function module_function
@@ -21,6 +22,7 @@ module RedmineTonalmine
end end
errors << 'invalid mode' if prefs['mode'] && !MODES.include?(prefs['mode']) errors << 'invalid mode' if prefs['mode'] && !MODES.include?(prefs['mode'])
errors << 'invalid contrast' if prefs['contrast'] && !CONTRASTS.include?(prefs['contrast']) errors << 'invalid contrast' if prefs['contrast'] && !CONTRASTS.include?(prefs['contrast'])
errors << 'invalid size' if prefs['size'] && !SIZES.include?(prefs['size'])
errors.concat(custom_errors(prefs)) errors.concat(custom_errors(prefs))
errors errors
end end
@@ -30,6 +32,7 @@ module RedmineTonalmine
prefs = normalize(hash) prefs = normalize(hash)
prefs.delete('mode') unless MODES.include?(prefs['mode']) prefs.delete('mode') unless MODES.include?(prefs['mode'])
prefs.delete('contrast') unless CONTRASTS.include?(prefs['contrast']) prefs.delete('contrast') unless CONTRASTS.include?(prefs['contrast'])
prefs.delete('size') unless SIZES.include?(prefs['size'])
prefs = prefs.except('palette', 'seed', 'css') if custom_errors(prefs).any? prefs = prefs.except('palette', 'seed', 'css') if custom_errors(prefs).any?
prefs.compact prefs.compact
end end
+21 -9
View File
@@ -1,5 +1,5 @@
/* /*
* tonalmine: per-user appearance (mode, contrast, color palette). * tonalmine: per-user appearance (mode, contrast, size, color palette).
* *
* Redmine loads this file in <head>, so the saved choice is applied before the page renders * Redmine loads this file in <head>, so the saved choice is applied before the page renders
* (no flash of the default colors). The picker is added to the profile menu and the mobile * (no flash of the default colors). The picker is added to the profile menu and the mobile
@@ -18,9 +18,10 @@
]; ];
var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']]; var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']];
var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']]; var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']];
var SIZES = [['compact', 'Compact'], ['normal', 'Normal'], ['large', 'Large']];
var KEY = 'tonalmine'; var KEY = 'tonalmine';
var CUSTOM_SEL = ':root[data-palette="custom"]'; var CUSTOM_SEL = ':root[data-palette="custom"]';
var DEFAULT_KEYS = ['mode', 'contrast', 'palette', 'seed', 'css']; var DEFAULT_KEYS = ['mode', 'contrast', 'size', 'palette', 'seed', 'css'];
var root = document.documentElement; var root = document.documentElement;
function load() { function load() {
@@ -34,6 +35,8 @@
else root.removeAttribute('data-mode'); else root.removeAttribute('data-mode');
if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.contrast); if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.contrast);
else root.removeAttribute('data-contrast'); else root.removeAttribute('data-contrast');
if (prefs.size === 'compact' || prefs.size === 'large') root.setAttribute('data-size', prefs.size);
else root.removeAttribute('data-size');
// The custom palette's rules are generated once (see withScheme) and kept with the prefs. // The custom palette's rules are generated once (see withScheme) and kept with the prefs.
if (prefs.palette === 'custom' && prefs.css) { if (prefs.palette === 'custom' && prefs.css) {
var style = document.getElementById('nm-custom-palette'); var style = document.getElementById('nm-custom-palette');
@@ -73,7 +76,7 @@
// palette, seed and css form one part: a custom palette brings its own generated CSS. // palette, seed and css form one part: a custom palette brings its own generated CSS.
function merge(base, top) { function merge(base, top) {
var src = top.palette ? top : base; var src = top.palette ? top : base;
return { mode: top.mode || base.mode, contrast: top.contrast || base.contrast, return { mode: top.mode || base.mode, contrast: top.contrast || base.contrast, size: top.size || base.size,
palette: src.palette, seed: src.seed, css: src.css }; palette: src.palette, seed: src.seed, css: src.css };
} }
@@ -130,6 +133,7 @@
function pickerHtml(prefs, isDefault) { function pickerHtml(prefs, isDefault) {
var mode = prefs.mode || 'auto'; var mode = prefs.mode || 'auto';
var contrast = prefs.contrast || 'standard'; var contrast = prefs.contrast || 'standard';
var size = prefs.size || 'normal';
var palette = prefs.palette || PALETTES[0][0]; var palette = prefs.palette || PALETTES[0][0];
return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' + return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' +
(isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') + (isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') +
@@ -140,6 +144,10 @@
'<div class="nm-segmented" role="group" aria-label="Contrast">' + '<div class="nm-segmented" role="group" aria-label="Contrast">' +
CONTRASTS.map(function (c) { return button('data-nm-contrast', c[0], c[1], c[0] === contrast); }).join('') + CONTRASTS.map(function (c) { return button('data-nm-contrast', c[0], c[1], c[0] === contrast); }).join('') +
'</div>' + '</div>' +
'<div class="nm-appearance__title">Size</div>' +
'<div class="nm-segmented" role="group" aria-label="Size">' +
SIZES.map(function (s) { return button('data-nm-size', s[0], s[1], s[0] === size); }).join('') +
'</div>' +
'<div class="nm-swatches" role="group" aria-label="Color">' + '<div class="nm-swatches" role="group" aria-label="Color">' +
PALETTES.map(function (p) { PALETTES.map(function (p) {
return button('data-nm-palette', p[0], '', p[0] === palette, return button('data-nm-palette', p[0], '', p[0] === palette,
@@ -162,6 +170,9 @@
picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) { picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard'))); b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
}); });
picker.querySelectorAll('[data-nm-size]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-size') === (prefs.size || 'normal')));
});
picker.querySelectorAll('[data-nm-palette]').forEach(function (b) { picker.querySelectorAll('[data-nm-palette]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0]))); b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
}); });
@@ -190,13 +201,14 @@
} }
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-palette], [data-nm-reset]'); var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-size], [data-nm-palette], [data-nm-reset]');
if (!target) return; if (!target) return;
var isDefault = !!target.closest('[data-nm-default]'); var isDefault = !!target.closest('[data-nm-default]');
var prefs = isDefault ? draft : own; var prefs = isDefault ? draft : own;
if (target.hasAttribute('data-nm-reset')) own = {}; if (target.hasAttribute('data-nm-reset')) own = {};
else if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode'); else if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode');
else if (target.hasAttribute('data-nm-contrast')) prefs.contrast = target.getAttribute('data-nm-contrast'); else if (target.hasAttribute('data-nm-contrast')) prefs.contrast = target.getAttribute('data-nm-contrast');
else if (target.hasAttribute('data-nm-size')) prefs.size = target.getAttribute('data-nm-size');
// The custom swatch opens the color dialog; it only selects the palette once a color was picked. // The custom swatch opens the color dialog; it only selects the palette once a color was picked.
else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette'); else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette');
changed(isDefault, true); changed(isDefault, true);
@@ -225,26 +237,26 @@
// select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine. // select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine.
function defaultPicker(select) { function defaultPicker(select) {
draft = merge({}, defaults); draft = merge({}, defaults);
// One labelled form row per group (mode, contrast, color), each in its own default picker // One labelled form row per group (mode, contrast, size, color), each in its own default picker
// wrapper so sync() and the click handlers treat it like the whole picker. // wrapper so sync() and the click handlers treat it like the whole picker.
var picker = document.createElement('div'); var picker = document.createElement('div');
picker.innerHTML = pickerHtml(draft, true); picker.innerHTML = pickerHtml(draft, true);
var groups = picker.querySelectorAll('[role="group"]'); var groups = picker.querySelectorAll('[role="group"]');
var rows = ['Default appearance', 'Contrast', 'Color'].map(function (label, i) { var rows = ['Default appearance', 'Contrast', 'Size', 'Color'].map(function (label, i) {
var row = document.createElement('p'); var row = document.createElement('p');
row.innerHTML = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>'; row.innerHTML = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>';
row.lastChild.appendChild(groups[i]); row.lastChild.appendChild(groups[i]);
return row; return row;
}); });
rows[2].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) { rows[3].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) {
return '<input type="hidden" name="settings[plugin_redmine_tonalmine][' + key + ']" data-nm-default-input="' + key + '">'; return '<input type="hidden" name="settings[plugin_redmine_tonalmine][' + key + ']" data-nm-default-input="' + key + '">';
}).join('')); }).join(''));
select.closest('p').after(rows[0], rows[1], rows[2]); select.closest('p').after(rows[0], rows[1], rows[2], rows[3]);
// Only shown, and only posted, while Tonalmine is selected; otherwise the saved default stays. // Only shown, and only posted, while Tonalmine is selected; otherwise the saved default stays.
function toggle() { function toggle() {
var on = select.value === 'tonalmine'; var on = select.value === 'tonalmine';
rows.forEach(function (row) { row.style.display = on ? '' : 'none'; }); rows.forEach(function (row) { row.style.display = on ? '' : 'none'; });
rows[2].querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.disabled = !on; }); rows[3].querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.disabled = !on; });
} }
select.addEventListener('change', toggle); select.addEventListener('change', toggle);
toggle(); toggle();
+25 -22
View File
@@ -6,7 +6,7 @@
/***** Buttons *****/ /***** Buttons *****/
input[type="submit"], input[type="button"], input[type="reset"], button[type="submit"] { input[type="submit"], input[type="button"], input[type="reset"], button[type="submit"] {
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding-block: 0; padding-block: 0;
padding-inline: 24px; padding-inline: 24px;
margin-block: 4px; margin-block: 4px;
@@ -51,8 +51,8 @@ input[type="submit"]:disabled, input[type="button"]:disabled, button:disabled {
/***** Text fields (outlined) *****/ /***** Text fields (outlined) *****/
input[type="text"], input[type="password"], input[type="email"], input[type="url"], input[type="number"], input[type="text"], input[type="password"], input[type="email"], input[type="url"], input[type="number"],
input[type="date"], input[type="search"], input[type="tel"], input:not([type]), select, textarea { input[type="date"], input[type="search"], input[type="tel"], input:not([type]), select, textarea {
min-block-size: 40px; min-block-size: calc(40px + var(--nm-density));
padding-block: 8px; padding-block: calc(8px + min(var(--nm-density), 0px) / 2);
padding-inline: 12px; padding-inline: 12px;
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-extra-small); border-radius: var(--md-sys-shape-corner-extra-small);
@@ -104,7 +104,7 @@ input::placeholder, textarea::placeholder { color: var(--md-sys-color-outline);
input[type="checkbox"], input[type="radio"] { accent-color: var(--md-sys-color-primary); } input[type="checkbox"], input[type="radio"] { accent-color: var(--md-sys-color-primary); }
input[type="file"] { color: var(--md-sys-color-on-surface-variant); } input[type="file"] { color: var(--md-sys-color-on-surface-variant); }
input[type="file"]::file-selector-button { input[type="file"]::file-selector-button {
block-size: 40px; block-size: calc(40px + var(--nm-density));
margin-inline-end: 12px; margin-inline-end: 12px;
padding-inline: 24px; padding-inline: 24px;
border: 0; border: 0;
@@ -165,10 +165,10 @@ fieldset.collapsible > legend { display: flex; align-items: center; gap: 4px; }
.tabular label { color: var(--md-sys-color-on-surface-variant); font-weight: 500; } .tabular label { color: var(--md-sys-color-on-surface-variant); font-weight: 500; }
.tabular p { padding-block: 4px; } .tabular p { padding-block: 4px; }
/* Center the 24px label line on the 40px fields next to it */ /* Center the 24px label line on the 40px fields next to it */
.tabular label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 8px; } .tabular label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: calc(8px + var(--nm-density) / 2); }
/* Checkboxes and radios placed directly in a row align with that label line too */ /* Checkboxes and radios placed directly in a row align with that label line too */
/* Checkboxes and radios next to such a label align with its text line */ /* Checkboxes and radios next to such a label align with its text line */
.tabular p:has(> label:not(.floating):not(.block):not(.inline):not(.no-css)) > input:is([type="checkbox"], [type="radio"]) { margin-block: 13px 0; } .tabular p:has(> label:not(.floating):not(.block):not(.inline):not(.no-css)) > input:is([type="checkbox"], [type="radio"]) { margin-block: calc(13px + var(--nm-density) / 2) 0; }
/* Rows without a field at the label's height start at the top: checkbox lists (watchers) /* Rows without a field at the label's height start at the top: checkbox lists (watchers)
and the issue description, whose editor stays hidden until "Edit" is clicked */ and the issue description, whose editor stays hidden until "Edit" is clicked */
.tabular p:is(:has(label.floating), :has(> #issue_description_and_toolbar[style*="none"])) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; } .tabular p:is(:has(label.floating), :has(> #issue_description_and_toolbar[style*="none"])) > label:not(.floating):not(.block):not(.inline):not(.no-css) { padding-block-start: 0; }
@@ -191,8 +191,9 @@ blockquote { border-inline-start: 4px solid var(--md-sys-color-outline-variant);
box-sizing: border-box; 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; } .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 */ /* The icon button after a select (new category, new version) stays on its line. The select
.tabular select:has(+ .icon-only) { inline-size: calc(100% - 36px); max-inline-size: calc(480px - 36px); } keeps the 480px cap, so the button sits after the right edge like "Assign to me" */
.tabular select:has(+ .icon-only) { inline-size: calc(100% - 36px); }
} }
html .tabular textarea, html textarea.wiki-edit, html textarea.text_cf, html input.string_cf, html input.link_cf, 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 textarea#custom_field_possible_values, html textarea#custom_field_default_value,
@@ -251,7 +252,7 @@ div.flash.error svg.icon-svg, #errorExplanation svg.icon-svg,
div.flash.notice svg.icon-svg, div.flash.warning svg.icon-svg, .conflict svg.icon-svg { stroke: currentColor; } div.flash.notice svg.icon-svg, div.flash.warning svg.icon-svg, .conflict svg.icon-svg { stroke: currentColor; }
/***** Secondary tabs (settings, history, admin) *****/ /***** Secondary tabs (settings, history, admin) *****/
#content .tabs { block-size: 48px; margin-block-end: 16px; } #content .tabs { block-size: calc(48px + var(--nm-density)); margin-block-end: 16px; }
#content .tabs ul { padding-inline-start: 0; border-block-end: 1px solid var(--md-sys-color-outline-variant); } #content .tabs ul { padding-inline-start: 0; border-block-end: 1px solid var(--md-sys-color-outline-variant); }
#content .tabs ul li { margin-inline-end: 0; margin-block-end: 0; } #content .tabs ul li { margin-inline-end: 0; margin-block-end: 0; }
#content .tabs ul li a, #content .tabs ul li a:hover { #content .tabs ul li a, #content .tabs ul li a:hover {
@@ -280,7 +281,7 @@ div.flash.notice svg.icon-svg, div.flash.warning svg.icon-svg, .conflict svg.ico
justify-content: end; justify-content: end;
gap: 4px; gap: 4px;
inline-size: auto; inline-size: auto;
block-size: 48px; block-size: calc(48px + var(--nm-density));
padding-inline-start: 8px; padding-inline-start: 8px;
box-sizing: border-box; box-sizing: border-box;
background: var(--md-sys-color-surface); background: var(--md-sys-color-surface);
@@ -292,8 +293,8 @@ div.flash.notice svg.icon-svg, div.flash.warning svg.icon-svg, .conflict svg.ico
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
inline-size: 40px; inline-size: calc(40px + var(--nm-density));
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -335,8 +336,8 @@ span.pagination { margin-inline-start: 0; color: var(--md-sys-color-on-surface-v
margin-inline-end: 4px; margin-inline-end: 4px;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
line-height: 40px; line-height: calc(40px + var(--nm-density));
min-inline-size: 40px; min-inline-size: calc(40px + var(--nm-density));
} }
.pagination ul.pages li a, .pagination ul.pages li span { .pagination ul.pages li a, .pagination ul.pages li span {
display: block; display: block;
@@ -451,8 +452,8 @@ div.tooltip span.nm-tip .nm-tip__fields > span {
.ui-dialog .ui-dialog-title { font: var(--md-sys-typescale-headline-small); } .ui-dialog .ui-dialog-title { font: var(--md-sys-typescale-headline-small); }
.ui-dialog .ui-dialog-content { padding-inline: 16px; } .ui-dialog .ui-dialog-content { padding-inline: 16px; }
.ui-dialog .ui-dialog-titlebar-close { .ui-dialog .ui-dialog-titlebar-close {
inline-size: 40px; inline-size: calc(40px + var(--nm-density));
block-size: 40px; block-size: calc(40px + var(--nm-density));
margin-block-start: -20px; margin-block-start: -20px;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -520,8 +521,8 @@ html .context-menu-selection svg.icon-svg { color: var(--md-sys-color-on-seconda
html .jstElements { block-size: auto; padding-block-end: 4px; } html .jstElements { block-size: auto; padding-block-end: 4px; }
html .jstElements button { html .jstElements button {
position: relative; position: relative;
inline-size: 32px; inline-size: calc(32px + var(--nm-density));
block-size: 32px; block-size: calc(32px + var(--nm-density));
margin-inline-end: 0; margin-inline-end: 0;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -577,7 +578,8 @@ html .table-generator td.selected-cell, html .table-generator td:hover { backgro
.nm-segmented { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; margin-block-end: 12px; } /* equal segments */ .nm-segmented { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; margin-block-end: 12px; } /* equal segments */
.nm-segmented button { .nm-segmented button {
border-radius: 0; border-radius: 0;
block-size: 32px; block-size: calc(32px + var(--nm-density));
padding-block: 0; /* the box centers the line; core's 4px made it overflow (and sink) in Compact */
padding-inline: 8px; padding-inline: 8px;
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
background: transparent; background: transparent;
@@ -613,7 +615,8 @@ label.nm-swatch--custom { position: relative; inline-size: 28px; background: var
/* Reset to default: M3 text button, shown once the user changed something */ /* Reset to default: M3 text button, shown once the user changed something */
.nm-appearance__reset { .nm-appearance__reset {
margin-block-start: 12px; margin-block-start: 12px;
block-size: 32px; block-size: calc(32px + var(--nm-density));
padding-block: 0;
padding-inline: 12px; padding-inline: 12px;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -628,8 +631,8 @@ label.nm-swatch--custom { position: relative; inline-size: 28px; background: var
/* Each group is its own form row: 40px tall like the fields (M3 segmented button height), so /* Each group is its own form row: 40px tall like the fields (M3 segmented button height), so
the label lines up; the row padding spaces them */ the label lines up; the row padding spaces them */
.nm-appearance--default .nm-segmented { margin: 0; } .nm-appearance--default .nm-segmented { margin: 0; }
.nm-appearance--default .nm-segmented button { block-size: 40px; margin-block: 0; } .nm-appearance--default .nm-segmented button { block-size: calc(40px + var(--nm-density)); margin-block: 0; }
.nm-appearance--default .nm-swatches { min-block-size: 40px; align-items: center; } .nm-appearance--default .nm-swatches { min-block-size: calc(40px + var(--nm-density)); align-items: center; }
/* The custom swatch is a <label class="no-css">; also undo .tabular.settings label margins and mobile full width */ /* The custom swatch is a <label class="no-css">; also undo .tabular.settings label margins and mobile full width */
html .tabular label.nm-swatch--custom { display: block; margin: 0; inline-size: 28px; } html .tabular label.nm-swatch--custom { display: block; margin: 0; inline-size: 28px; }
html .flyout-menu .nm-appearance { padding-inline: 16px; } html .flyout-menu .nm-appearance { padding-inline: 16px; }
+13 -12
View File
@@ -56,7 +56,7 @@ body.has-main-menu #header { padding-block-end: 48px; }
#header h1 { #header h1 {
font: var(--md-sys-typescale-title-large); font: var(--md-sys-typescale-title-large);
min-block-size: 3rem; min-block-size: round(3rem, 1px); /* whole px, so the header controls do not sit between pixels */
} }
#header h1 .breadcrumbs { #header h1 .breadcrumbs {
font: var(--md-sys-typescale-label-medium); font: var(--md-sys-typescale-label-medium);
@@ -65,10 +65,10 @@ body.has-main-menu #header { padding-block-end: 48px; }
} }
#header h1 .breadcrumbs a { color: var(--md-sys-color-on-surface-variant); } #header h1 .breadcrumbs a { color: var(--md-sys-color-on-surface-variant); }
#quick-search { min-block-size: 3rem; color: var(--md-sys-color-on-surface-variant); } #quick-search { min-block-size: round(3rem, 1px); color: var(--md-sys-color-on-surface-variant); }
#quick-search #q { #quick-search #q {
inline-size: 220px; inline-size: 220px;
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding-inline: 16px; padding-inline: 16px;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -81,14 +81,15 @@ body.has-main-menu #header { padding-block-end: 48px; }
#project-jump.drdn { margin-inline-start: 8px; } #project-jump.drdn { margin-inline-start: 8px; }
#project-jump .drdn-trigger { #project-jump .drdn-trigger {
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding-block: 0; /* core's 1.5px / 3px pushed the text down */
padding-inline: 16px 36px; padding-inline: 16px 36px;
border: 1px solid var(--md-sys-color-outline-variant); border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
background-color: transparent; background-color: transparent;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-label-large); font: var(--md-sys-typescale-label-large);
line-height: 38px; line-height: calc(38px + var(--nm-density));
} }
#project-jump .drdn-trigger:hover { background-image: var(--nm-state-hover); } #project-jump .drdn-trigger:hover { background-image: var(--nm-state-hover); }
@@ -99,7 +100,7 @@ body.has-main-menu #header { padding-block-end: 48px; }
padding-block: 0; padding-block: 0;
padding-inline: 16px; padding-inline: 16px;
} }
#main-menu ul { min-block-size: 48px; } #main-menu ul { min-block-size: calc(48px + var(--nm-density)); }
#main-menu li a { #main-menu li a {
position: relative; position: relative;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
@@ -136,8 +137,8 @@ body.has-main-menu #header { padding-block-end: 48px; }
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
inline-size: 40px; inline-size: calc(40px + var(--nm-density));
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding: 0; padding: 0;
margin-block: 0; margin-block: 0;
border: 0; border: 0;
@@ -158,7 +159,7 @@ body.has-main-menu #header { padding-block-end: 48px; }
align-items: center; align-items: center;
gap: 4px; gap: 4px;
inline-size: auto; inline-size: auto;
block-size: 48px; block-size: calc(48px + var(--nm-density));
inset-inline-end: 0; inset-inline-end: 0;
padding-inline: 8px; padding-inline: 8px;
border: 0; border: 0;
@@ -290,7 +291,7 @@ div + .drdn-items, .dropdown-divider, #account .dropdown-content .logout { borde
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-block-size: 32px; min-block-size: calc(32px + var(--nm-density));
} }
#sidebar :is(li, form > p) > label:has(input[type="checkbox"]) { flex: 1; } #sidebar :is(li, form > p) > label:has(input[type="checkbox"]) { flex: 1; }
#sidebar input[type="checkbox"] { inline-size: 18px; block-size: 18px; margin: 0; flex-shrink: 0; } #sidebar input[type="checkbox"] { inline-size: 18px; block-size: 18px; margin: 0; flex-shrink: 0; }
@@ -302,7 +303,7 @@ div + .drdn-items, .dropdown-divider, #account .dropdown-content .logout { borde
#sidebar li > a:is(:only-child, .query), #admin-menu li a { #sidebar li > a:is(:only-child, .query), #admin-menu li a {
display: flex; display: flex;
align-items: center; align-items: center;
min-block-size: 36px; min-block-size: calc(36px + var(--nm-density));
padding-block: 8px; /* long names wrap: keep the items apart */ padding-block: 8px; /* long names wrap: keep the items apart */
padding-inline: 12px; padding-inline: 12px;
box-sizing: border-box; box-sizing: border-box;
@@ -345,4 +346,4 @@ div + .drdn-items, .dropdown-divider, #account .dropdown-content .logout { borde
border-radius: var(--md-sys-shape-corner-extra-large); border-radius: var(--md-sys-shape-corner-extra-large);
} }
#login-form label { font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant); } #login-form label { font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant); }
#login-form input { block-size: 40px; } #login-form input { block-size: calc(40px + var(--nm-density)); }
+10 -8
View File
@@ -12,7 +12,8 @@
border: 0; border: 0;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-title-large); font: var(--md-sys-typescale-title-large);
line-height: 50px; line-height: calc(50px + var(--nm-density));
padding-block: 1.5px 3px; /* core's, which layout.css removes: keeps the title level with the ▾ icon */
} }
html #project-jump.drdn { padding-inline-start: 8px; } html #project-jump.drdn { padding-inline-start: 8px; }
/* Header icons: Material Symbols masks instead of core's scaled "^" and "≡" text glyphs */ /* Header icons: Material Symbols masks instead of core's scaled "^" and "≡" text glyphs */
@@ -29,8 +30,8 @@
mask: url(../images/arrow-drop-down.svg) center / 24px no-repeat; mask: url(../images/arrow-drop-down.svg) center / 24px no-repeat;
} }
html #header a.mobile-toggle-button { html #header a.mobile-toggle-button {
inline-size: 48px; inline-size: calc(48px + var(--nm-density));
block-size: 48px; block-size: calc(48px + var(--nm-density));
margin-block-start: 8px; margin-block-start: 8px;
margin-inline-end: 8px; margin-inline-end: 8px;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
@@ -107,19 +108,19 @@
font: var(--md-sys-typescale-label-large); font: var(--md-sys-typescale-label-large);
} }
html .flyout-menu__sidebar p > :is(select, input:not([type="submit"], [type="checkbox"], [type="radio"])) { inline-size: 100%; box-sizing: border-box; } html .flyout-menu__sidebar p > :is(select, input:not([type="submit"], [type="checkbox"], [type="radio"])) { inline-size: 100%; box-sizing: border-box; }
html .flyout-menu__sidebar li:has(> input[type="checkbox"]) { display: flex; align-items: center; gap: 12px; min-block-size: 40px; border: 0; } html .flyout-menu__sidebar li:has(> input[type="checkbox"]) { display: flex; align-items: center; gap: 12px; min-block-size: calc(40px + var(--nm-density)); border: 0; }
html .flyout-menu .flyout-menu__sidebar li:has(> input[type="checkbox"]) a { block-size: auto; padding: 0; line-height: inherit; } html .flyout-menu .flyout-menu__sidebar li:has(> input[type="checkbox"]) a { block-size: auto; padding: 0; line-height: inherit; }
html .flyout-menu h4 { color: var(--md-sys-color-on-surface); } html .flyout-menu h4 { color: var(--md-sys-color-on-surface); }
html .flyout-menu a { color: var(--md-sys-color-on-surface-variant); } html .flyout-menu a { color: var(--md-sys-color-on-surface-variant); }
html .flyout-menu ul li a, html .flyout-menu ul li:first-child a { html .flyout-menu ul li a, html .flyout-menu ul li:first-child a {
block-size: 48px; block-size: calc(48px + var(--nm-density));
line-height: 48px; line-height: 48px;
margin-inline-start: 0; margin-inline-start: 0;
padding-inline: 16px; padding-inline: 16px;
border: 0; border: 0;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
font: var(--md-sys-typescale-label-large); font: var(--md-sys-typescale-label-large);
line-height: 48px; line-height: calc(48px + var(--nm-density));
} }
html .flyout-menu ul li a:hover { background-image: var(--nm-state-hover); } html .flyout-menu ul li a:hover { background-image: var(--nm-state-hover); }
html .flyout-menu ul li a.selected { html .flyout-menu ul li a.selected {
@@ -129,7 +130,7 @@
html .flyout-menu #watchers ul li, html .flyout-menu ul.queries li, html .flyout-menu__avatar { border-block-start: 0; } html .flyout-menu #watchers ul li, html .flyout-menu ul.queries li, html .flyout-menu__avatar { border-block-start: 0; }
html .flyout-menu .icon svg, html .flyout-menu .icon-only svg { color: inherit; stroke: currentColor; } html .flyout-menu .icon svg, html .flyout-menu .icon-only svg { color: inherit; stroke: currentColor; }
html .flyout-menu__search input[type='text'] { html .flyout-menu__search input[type='text'] {
block-size: 48px; block-size: calc(48px + var(--nm-density));
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest); background-color: var(--md-sys-color-surface-container-highest);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
@@ -137,7 +138,8 @@
} }
/* Search pill, avatar row and nav labels share one inline start: the icon and the /* Search pill, avatar row and nav labels share one inline start: the icon and the
avatar sit where the nav labels begin (drawer padding 12px + item padding 16px) */ avatar sit where the nav labels begin (drawer padding 12px + item padding 16px) */
html .flyout-menu .flyout-menu__search { padding-inline: 0; } /* Core's 64px / 54px container grows with the search pill */
html .flyout-menu .flyout-menu__search { padding-inline: 0; block-size: calc(64px + var(--nm-density)); line-height: calc(54px + var(--nm-density)); }
html .search-magnifier--flyout { inset-inline-start: 28px; } html .search-magnifier--flyout { inset-inline-start: 28px; }
html .flyout-menu .flyout-menu__avatar { padding-inline-start: 16px; } html .flyout-menu .flyout-menu__avatar { padding-inline-start: 16px; }
+4 -4
View File
@@ -29,13 +29,13 @@ table.list th, .table-list-header {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-small); font: var(--md-sys-typescale-title-small);
letter-spacing: var(--md-sys-typescale-title-small-tracking); letter-spacing: var(--md-sys-typescale-title-small-tracking);
padding-block: 10px; padding-block: calc(10px + var(--nm-density) / 2);
padding-inline: 8px; padding-inline: 8px;
border-block-end: 1px solid var(--md-sys-color-outline-variant); border-block-end: 1px solid var(--md-sys-color-outline-variant);
} }
table.list th a, table.list th a:link, table.list th a:visited { color: var(--md-sys-color-on-surface-variant); } table.list th a, table.list th a:link, table.list th a:visited { color: var(--md-sys-color-on-surface-variant); }
table.list td { table.list td {
block-size: 40px; block-size: calc(40px + var(--nm-density));
padding-block: 4px; padding-block: 4px;
padding-inline: 8px; padding-inline: 8px;
border-block-start: 1px solid var(--md-sys-color-outline-variant); border-block-start: 1px solid var(--md-sys-color-outline-variant);
@@ -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. */
+24 -16
View File
@@ -7,22 +7,23 @@
/* Typeface */ /* Typeface */
--md-ref-typeface-plain: Roboto, system-ui, sans-serif; --md-ref-typeface-plain: Roboto, system-ui, sans-serif;
/* Type scale, as `font` shorthand values: weight size/line-height family */ /* Type scale, as `font` shorthand values: weight size/line-height family. Line heights are rounded to
--md-sys-typescale-display-large: 400 3.5625rem/4rem var(--md-ref-typeface-plain); whole px: in the size modes 1.25rem is e.g. 22.857px, and the rounding moved text off center */
--md-sys-typescale-display-medium: 400 2.8125rem/3.25rem var(--md-ref-typeface-plain); --md-sys-typescale-display-large: 400 3.5625rem/round(4rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-display-small: 400 2.25rem/2.75rem var(--md-ref-typeface-plain); --md-sys-typescale-display-medium: 400 2.8125rem/round(3.25rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-headline-large: 400 2rem/2.5rem var(--md-ref-typeface-plain); --md-sys-typescale-display-small: 400 2.25rem/round(2.75rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-headline-medium: 400 1.75rem/2.25rem var(--md-ref-typeface-plain); --md-sys-typescale-headline-large: 400 2rem/round(2.5rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-headline-small: 400 1.5rem/2rem var(--md-ref-typeface-plain); --md-sys-typescale-headline-medium: 400 1.75rem/round(2.25rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-title-large: 400 1.375rem/1.75rem var(--md-ref-typeface-plain); --md-sys-typescale-headline-small: 400 1.5rem/round(2rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-title-medium: 500 1rem/1.5rem var(--md-ref-typeface-plain); --md-sys-typescale-title-large: 400 1.375rem/round(1.75rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-title-small: 500 0.875rem/1.25rem var(--md-ref-typeface-plain); --md-sys-typescale-title-medium: 500 1rem/round(1.5rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-body-large: 400 1rem/1.5rem var(--md-ref-typeface-plain); --md-sys-typescale-title-small: 500 0.875rem/round(1.25rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-body-medium: 400 0.875rem/1.25rem var(--md-ref-typeface-plain); --md-sys-typescale-body-large: 400 1rem/round(1.5rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-body-small: 400 0.75rem/1rem var(--md-ref-typeface-plain); --md-sys-typescale-body-medium: 400 0.875rem/round(1.25rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-label-large: 500 0.875rem/1.25rem var(--md-ref-typeface-plain); --md-sys-typescale-body-small: 400 0.75rem/round(1rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-label-medium: 500 0.75rem/1rem var(--md-ref-typeface-plain); --md-sys-typescale-label-large: 500 0.875rem/round(1.25rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-label-small: 500 0.6875rem/1rem var(--md-ref-typeface-plain); --md-sys-typescale-label-medium: 500 0.75rem/round(1rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-label-small: 500 0.6875rem/round(1rem, 1px) var(--md-ref-typeface-plain);
/* Tracking (letter-spacing) per type role */ /* Tracking (letter-spacing) per type role */
--md-sys-typescale-display-large-tracking: -0.25px; --md-sys-typescale-display-large-tracking: -0.25px;
@@ -71,4 +72,11 @@
--md-sys-elevation-level3: 0 1px 3px rgb(0 0 0 / 30%), 0 4px 8px 3px rgb(0 0 0 / 15%); --md-sys-elevation-level3: 0 1px 3px rgb(0 0 0 / 30%), 0 4px 8px 3px rgb(0 0 0 / 15%);
--md-sys-elevation-level4: 0 2px 3px rgb(0 0 0 / 30%), 0 6px 10px 4px rgb(0 0 0 / 15%); --md-sys-elevation-level4: 0 2px 3px rgb(0 0 0 / 30%), 0 6px 10px 4px rgb(0 0 0 / 15%);
--md-sys-elevation-level5: 0 4px 4px rgb(0 0 0 / 30%), 0 8px 12px 6px rgb(0 0 0 / 15%); --md-sys-elevation-level5: 0 4px 4px rgb(0 0 0 / 30%), 0 8px 12px 6px rgb(0 0 0 / 15%);
/* Size mode (theme.js sets data-size): added to control heights, e.g. calc(40px + var(--nm-density)) */
--nm-density: 0px;
} }
/* Text is in rem, so the root size scales it; percent keeps the browser's font setting */
:root[data-size="compact"] { font-size: 92.857%; --nm-density: -8px; } /* 13px body, M3 density -2 */
:root[data-size="large"] { font-size: 114.286%; --nm-density: 8px; } /* 16px body */