Initial commit: Tonalmine, a Material Design 3 theme for Redmine 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
surtic86
2026-10-01 06:05:29 +02:00
co-authored by Claude Opus 5.5
commit 06cd1db99c
47 changed files with 4302 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
// Reports visible elements that still carry core Redmine shapes the theme should have replaced:
// 1-3px corners (core uses 3px, M3 uses 4/8/12/16/28px or full), core's grey box shadow
// (rgba of --oc-gray-9, theme shadows never use it), square boxes outlined on all four sides
// (core's card style, e.g. the old .mypage-box), and dashed or dotted borders.
// Checks desktop and mobile, because core's responsive.css brings its own shapes. Exits 1 on any hit.
// Usage: node check-core-leaks.mjs [--verbose]
import { chromium } from 'playwright';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const pages = [
'/my/page', '/projects', '/projects?display_type=list', '/projects/apollo', '/projects/apollo/issues', '/issues/13',
'/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17', '/projects/apollo/issues/gantt',
'/projects/apollo/issues/calendar', '/projects/apollo/roadmap', '/versions/2', '/projects/apollo/activity',
'/projects/apollo/wiki', '/projects/apollo/wiki/Wiki/edit', '/news/1', '/boards/1/topics/1', '/projects/apollo/documents',
'/projects/apollo/files', '/projects/apollo/time_entries', '/projects/apollo/time_entries/report?criteria[]=user&columns=month',
'/search?q=login', '/my/account', '/admin', '/settings', '/users', '/roles/permissions',
'/workflows/edit?role_id=3&tracker_id=1', '/workflows/permissions?role_id=3&tracker_id=1',
'/projects/apollo/repository', '/projects/apollo/repository/portal/revisions', '/projects/apollo/repository/portal/entry/lib/portal.rb',
'/projects/apollo/repository/portal/annotate/lib/portal.rb', '/projects/apollo/repository/portal/revisions/main/diff',
'/projects/apollo/wiki/Wiki/history', '/projects/apollo/wiki/Wiki/diff?version=2&version_from=1', '/projects/apollo/settings',
'/projects/apollo/settings/members', '/projects/apollo/settings/versions', '/projects/apollo/settings/repositories', '/trackers',
'/issue_statuses', '/custom_fields', '/custom_fields/1/edit', '/enumerations', '/admin/info', '/admin/plugins',
'/projects/apollo/boards',
];
// Elements whose core shape is intended. Each entry needs a reason.
const ALLOW = [
'div.gantt_hdr', // Gantt chart header cells form the chart grid
];
const viewports = {
desktop: { viewport: { width: 1440, height: 900 } },
mobile: { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true },
};
const browser = await chromium.launch();
const offenders = new Map(); // "viewport selector reason" -> pages
for (const [vpName, opts] of Object.entries(viewports)) {
const page = await browser.newPage(opts);
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' });
const found = await page.evaluate((allow) => {
const describe = (el) => {
const parts = [];
for (let e = el; e && e !== document.body && parts.length < 3; e = e.parentElement) {
parts.unshift(e.id ? `#${e.id}` : e.tagName.toLowerCase() + [...e.classList].slice(0, 2).map((c) => `.${c}`).join(''));
if (e.id) break;
}
return parts.join(' > ');
};
const opaque = (color) => !/^rgba\(.*,\s*0\)$/.test(color) && color !== 'transparent';
const sides = ['Top', 'Right', 'Bottom', 'Left'];
const out = [];
for (const el of document.querySelectorAll('body *:not(script):not(style):not(svg *)')) {
const r = el.getBoundingClientRect();
if (!r.width || !r.height || allow.some((s) => el.matches(s))) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
const borders = sides.filter((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none'
&& opaque(cs[`border${s}Color`]));
const radii = ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'].map((c) => parseFloat(cs[`border${c}Radius`]));
if (radii.some((x) => x >= 1 && x <= 3) && (borders.length || opaque(cs.backgroundColor))) {
out.push([describe(el), `${Math.max(...radii)}px corner`]);
}
if (cs.boxShadow.includes('rgba(33, 37, 41')) out.push([describe(el), 'core shadow']);
if (borders.length === 4 && radii.every((x) => x === 0) && r.width >= 100 && r.height >= 32
&& !['TABLE', 'TD', 'TH', 'TR'].includes(el.tagName)) {
out.push([describe(el), 'square outlined box']);
}
const dashed = borders.find((s) => ['dashed', 'dotted'].includes(cs[`border${s}Style`]));
if (dashed) out.push([describe(el), `${cs[`border${dashed}Style`]} border`]);
}
return out;
}, ALLOW);
for (const [sel, reason] of found) {
const key = `${vpName.padEnd(7)} ${sel} (${reason})`;
if (!offenders.has(key)) offenders.set(key, new Set());
offenders.get(key).add(path);
}
}
await page.close();
}
await browser.close();
const rows = [...offenders].sort(([a], [b]) => a.localeCompare(b));
for (const [key, where] of rows) {
console.log(`${key} ${process.argv.includes('--verbose') ? [...where].join(', ') : `${where.size} page${where.size > 1 ? 's' : ''}`}`);
}
console.log(rows.length ? `\n${rows.length} core shapes left` : 'No core shapes left.');
process.exit(rows.length ? 1 : 0);
+67
View File
@@ -0,0 +1,67 @@
// Reports visible text whose computed font size is off the theme's type scale.
// Core Redmine uses relative sizes (0.8em, 90%, …) that compound when nested; the theme should
// leave only M3 scale sizes. Exits 1 if anything is off-scale.
// Usage: node check-font-sizes.mjs [--verbose]
import { chromium } from 'playwright';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
// M3 type scale in px (label-small 11 … display-large 57)
const SCALE = [11, 12, 14, 16, 22, 24, 28, 32, 36, 45, 57];
const pages = [
'/my/page', '/projects', '/projects/apollo', '/projects/apollo/issues', '/issues/13', '/projects/apollo/issues/new',
'/projects/apollo/roadmap', '/versions/2', '/projects/apollo/activity', '/projects/apollo/wiki', '/news/1',
'/boards/1/topics/1', '/projects/apollo/documents', '/projects/apollo/files', '/projects/apollo/time_entries',
'/projects/apollo/time_entries/report?criteria[]=user&columns=month', '/search?q=login', '/my/account',
'/admin', '/settings', '/users', '/roles/permissions', '/projects/apollo/issues/calendar',
'/projects/apollo/repository', '/projects/apollo/repository/portal/revisions', '/projects/apollo/repository/portal/entry/lib/portal.rb',
'/projects/apollo/repository/portal/annotate/lib/portal.rb', '/projects/apollo/repository/portal/revisions/main/diff',
'/projects/apollo/wiki/Wiki/history', '/projects/apollo/wiki/Wiki/diff?version=2&version_from=1', '/projects/apollo/settings',
'/projects/apollo/settings/members', '/projects/apollo/settings/versions', '/projects/apollo/settings/repositories', '/trackers',
'/issue_statuses', '/custom_fields', '/custom_fields/1/edit', '/enumerations', '/admin/info', '/admin/plugins',
'/workflows/permissions?role_id=3&tracker_id=1', '/projects/apollo/boards',
];
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'));
const offenders = new Map(); // "selector @ size" -> pages
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const found = await page.evaluate((scale) => {
const out = [];
const describe = (el) => {
const parts = [];
for (let e = el; e && e !== document.body && parts.length < 3; e = e.parentElement) {
parts.unshift(e.id ? `#${e.id}` : e.tagName.toLowerCase() + [...e.classList].slice(0, 2).map((c) => `.${c}`).join(''));
if (e.id) break;
}
return parts.join(' > ');
};
for (const el of document.querySelectorAll('body *:not(script):not(style):not(svg *)')) {
if (el.closest('table.gantt-table, .avatar')) continue; // Gantt is zoomed; avatar initials are graphics
const hasText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
if (!hasText || !el.offsetParent) continue;
const size = parseFloat(getComputedStyle(el).fontSize);
if (!scale.some((s) => Math.abs(s - size) < 0.3)) out.push([describe(el), Math.round(size * 10) / 10]);
}
return out;
}, SCALE);
for (const [sel, size] of found) {
const key = `${sel} @ ${size}px`;
if (!offenders.has(key)) offenders.set(key, new Set());
offenders.get(key).add(path);
}
}
await browser.close();
const rows = [...offenders].sort((a, b) => parseFloat(a[0].split('@ ')[1]) - parseFloat(b[0].split('@ ')[1]));
for (const [key, where] of rows) {
console.log(`${key} (${where.size} page${where.size > 1 ? 's' : ''}${process.argv.includes('--verbose') ? ': ' + [...where].join(', ') : ''})`);
}
console.log(rows.length ? `\n${rows.length} off-scale text styles` : 'All text on the type scale.');
process.exit(rows.length ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
// Checks that each form label (.tabular forms, query filters) lines up with the field next to it.
// Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px)
import { chromium } from 'playwright';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
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', '/settings', '/my/account', '/users/5/edit',
'/projects/apollo/settings', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/edit',
];
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;
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const rows = await page.evaluate(() => [
// Query filters: label text vs. operator select
...[...document.querySelectorAll('#filters-table .filter')].flatMap((row) => {
const label = row.querySelector('.field label'), control = row.querySelector('.operator select');
if (!label || !control || !control.offsetParent) return [];
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)) }];
}),
...[...document.querySelectorAll('.tabular p')].flatMap((row) => {
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');
if (!label || !control || !label.offsetParent || !control.offsetParent) return [];
const style = getComputedStyle(label);
const labelBox = label.getBoundingClientRect();
const controlBox = control.getBoundingClientRect();
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)) }];
}),
]);
const bad = rows.filter((r) => Math.abs(r.offset) > 2);
failures += bad.length;
console.log(`${bad.length ? 'FAIL' : 'ok '} ${path} (${rows.length} rows)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`);
}
await browser.close();
process.exit(failures ? 1 : 0);
+3
View File
@@ -0,0 +1,3 @@
Subject,Tracker,Status,Priority,Assignee,Due date
Import check,Bug,New,Normal,,2026-12-01
Second row,Feature,New,High,,
1 Subject Tracker Status Priority Assignee Due date
2 Import check Bug New Normal 2026-12-01
3 Second row Feature New High
+40
View File
@@ -0,0 +1,40 @@
{
"name": "tonalmine-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tonalmine-e2e",
"dependencies": {
"playwright": "^1"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "tonalmine-e2e",
"private": true,
"type": "module",
"scripts": {
"screenshots": "node screenshots.mjs"
},
"dependencies": {
"playwright": "^1"
}
}
+143
View File
@@ -0,0 +1,143 @@
// 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".
// Needs the dev server with seed data (see README).
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const OUT = new URL('../screenshots/', import.meta.url).pathname;
const filter = process.argv[2] ?? '';
const pages = [
['my-page', '/my/page'],
['projects-board', '/projects?display_type=board'],
['projects-list', '/projects?display_type=list'],
['project-overview', '/projects/apollo'],
['issues-grouped', '/projects/apollo/issues?set_filter=1&f[]=status_id&op[status_id]=*&group_by=status&t[]=estimated_hours&t[]=spent_hours'],
['issue-show', '/issues/13'],
['issue-new', '/projects/apollo/issues/new'],
['issue-edit', '/issues/13/edit'],
['issue-bulk-edit', '/issues/bulk_edit?ids[]=13&ids[]=17'],
['gantt', '/projects/apollo/issues/gantt'],
['calendar', '/projects/apollo/issues/calendar'],
['roadmap', '/projects/apollo/roadmap'],
['version', '/versions/2'],
['activity', '/projects/apollo/activity'],
['wiki', '/projects/apollo/wiki'],
['wiki-edit', '/projects/apollo/wiki/Wiki/edit'],
['news', '/news/1'],
['forum-topic', '/boards/1/topics/1'],
['documents', '/projects/apollo/documents'],
['files', '/projects/apollo/files'],
['time-entries', '/projects/apollo/time_entries'],
['time-report', '/projects/apollo/time_entries/report?criteria[]=user&columns=month'],
['search', '/search?q=login'],
['my-account', '/my/account'],
['admin', '/admin'],
['settings', '/settings'],
['users', '/users'],
['permissions', '/roles/permissions'],
['workflow', '/workflows/edit?role_id=3&tracker_id=1'],
['not-found', '/issues/99999'],
['repository', '/projects/apollo/repository'],
['repository-revisions', '/projects/apollo/repository/portal/revisions'],
['repository-entry', '/projects/apollo/repository/portal/entry/lib/portal.rb'],
['repository-annotate', '/projects/apollo/repository/portal/annotate/lib/portal.rb'],
['repository-diff', '/projects/apollo/repository/portal/revisions/main/diff'],
['wiki-history', '/projects/apollo/wiki/Wiki/history'],
['wiki-diff', '/projects/apollo/wiki/Wiki/diff?version=2&version_from=1'],
['project-settings', '/projects/apollo/settings'],
['project-settings-members', '/projects/apollo/settings/members'],
['project-settings-versions', '/projects/apollo/settings/versions'],
['project-settings-repositories', '/projects/apollo/settings/repositories'],
['trackers', '/trackers'],
['issue-statuses', '/issue_statuses'],
['custom-fields', '/custom_fields'],
['custom-field-edit', '/custom_fields/1/edit'],
['enumerations', '/enumerations'],
['admin-info', '/admin/info'],
['admin-plugins', '/admin/plugins'],
['workflow-fields', '/workflows/permissions?role_id=3&tracker_id=1'],
['forums', '/projects/apollo/boards'],
];
// Pages only reachable while logged out.
const publicPages = [
['login', '/login'],
['register', '/account/register'],
['lost-password', '/account/lost_password'],
];
// Pages that need an interaction before the capture.
const interactions = [
['issue-new-error', '/projects/apollo/issues/new', async (p) => {
await p.click('#issue-form input[name=commit]');
await p.waitForLoadState('networkidle');
}],
['issues-context-menu', '/projects/apollo/issues', async (p) => {
await p.click('table.list.issues tr.issue td.status', { button: 'right' });
await p.waitForSelector('#context-menu ul');
}, { desktopOnly: true }],
['journal-diff', '/issues/13', async (p) => {
// The journal ID differs per database, so follow the first description diff link.
await Promise.all([p.waitForURL(/\/journals\/\d+\/diff/), p.click('#history a[href*="/diff"]')]);
await p.waitForLoadState('networkidle');
}],
['import-mapping', '/issues/imports/new?project_id=apollo', async (p) => {
await p.setInputFiles('#file', new URL('fixtures/issues.csv', import.meta.url).pathname);
await Promise.all([p.waitForURL(/\/imports\/[^/]+\/settings/), p.click('input[type=submit]')]);
await Promise.all([p.waitForURL(/\/imports\/[^/]+\/mapping/), p.click('input[type=submit]')]);
await p.waitForLoadState('networkidle');
}],
['project-jump', '/projects/apollo-api/issues', async (p) => {
await p.click('#project-jump .drdn-trigger');
await p.waitForSelector('#project-jump .drdn-items');
}, { desktopOnly: true }],
['mobile-flyout', '/projects/apollo/issues', async (p) => {
await p.click('.mobile-toggle-button');
await p.waitForTimeout(300);
}, { mobileOnly: true, viewportOnly: true }],
];
const viewports = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844, isMobile: true, hasTouch: true },
};
await mkdir(OUT, { recursive: true });
const browser = await chromium.launch();
for (const scheme of ['light', 'dark']) {
for (const [vpName, vp] of Object.entries(viewports)) {
const { width, height, ...rest } = vp;
const context = await browser.newContext({ viewport: { width, height }, colorScheme: scheme, ...rest });
const page = await context.newPage();
const shot = (name, fullPage = true) =>
page.screenshot({ path: `${OUT}${name}-${scheme}-${vpName}.png`, fullPage });
for (const [name, path] of publicPages.filter(([n]) => n.includes(filter))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
await shot(name);
}
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 [name, path] of pages.filter(([n]) => n.includes(filter))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
await shot(name);
}
for (const [name, path, act, opts = {}] of interactions.filter(([n]) => n.includes(filter))) {
if ((opts.desktopOnly && vpName !== 'desktop') || (opts.mobileOnly && vpName !== 'mobile')) continue;
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
await act(page);
await shot(name, !opts.viewportOnly);
}
await context.close();
}
}
await browser.close();
console.log(`Screenshots written to ${OUT}`);