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 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-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
@@ -52,9 +54,12 @@ the map, so run it after every Redmine update.
### 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
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:
+23 -19
View File
@@ -26,10 +26,10 @@ async function login(user, password = 'password123', setup) {
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 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) {
@@ -78,8 +78,8 @@ await Promise.all(['jdoe', 'msmith', 'pweber'].map(resetUser));
// 1. Admin default on the Display settings tab
const admin = await login('admin', 'admin');
await admin.goto(`${BASE}/settings?tab=display`);
// Mode, contrast and color are three form rows
const rowsShown = () => admin.$$eval('p:has([data-nm-default])', (rows) => rows.length === 3 && rows.map((r) => !!r.offsetParent));
// Mode, contrast, size and color are four form rows
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));
await admin.selectOption('#settings_ui_theme', '');
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
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
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');
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
await saveDefault(admin, () => admin.click('[data-nm-default] [data-nm-palette="green"]'));
// 4. Parts the user didn't change follow a new default; the user's Compact beats the default Large
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 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
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));
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 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));
// 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');
check('custom color is saved (204)', (await savedColor).status() === 204);
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);
await pick(jdoe3, '[data-nm-reset]');
// 6. A localStorage choice from before the plugin moves to the account once
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));
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
const pweber = await login('pweber');
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);
check(`invalid ${JSON.stringify(body)} is refused (422)`, status === 422, `got ${status}`);
}
const tokenless = await put(pweber, { contrast: 'high' }, false);
check('request without CSRF token is refused (422)', tokenless === 422, `got ${tokenless}`);
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
const anonymous = await (await browser.newContext()).newPage();
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
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 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);
// 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 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 browser.close();
+5
View File
@@ -4,9 +4,12 @@
// to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column;
// on phones every field fills its row
// Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px)
// SIZE=compact|large node check-form-alignment.mjs (in that size mode; needs the plugin)
import { chromium } from 'playwright';
import { setSize } from './size.mjs';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const SIZE = process.env.SIZE;
const settingsTabs = ['general', 'display', 'authentication', 'api', 'projects', 'users', 'issues', 'timelog', 'attachments', 'mail_handler', 'repositories'];
const pages = [
'/projects?display_type=list', '/projects/apollo/issues', '/projects/apollo/time_entries', '/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17',
@@ -26,6 +29,7 @@ for (const width of [1440, 1024, 390]) {
await page.fill('#password', 'admin');
await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
if (SIZE) await setSize(page, SIZE);
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -77,6 +81,7 @@ for (const width of [1440, 1024, 390]) {
failures += bad.length;
console.log(`${bad.length ? 'FAIL' : 'ok '} ${width} ${path} (${rows.length} checks)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`);
}
if (SIZE) await setSize(page);
await page.close();
}
+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/.
// 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).
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { setSize } from './size.mjs';
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 SIZE = process.env.SIZE;
const pages = [
['my-page', '/my/page'],
@@ -111,9 +114,11 @@ 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 });
// 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 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))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -124,6 +129,7 @@ for (const scheme of ['light', 'dark']) {
await page.fill('#password', 'admin');
await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
if (SIZE) await setSize(page, SIZE);
for (const [name, path] of pages.filter(([n]) => n.includes(filter))) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
@@ -135,6 +141,7 @@ for (const scheme of ['light', 'dark']) {
await act(page);
await shot(name, !opts.viewportOnly);
}
if (SIZE) await setSize(page);
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
# 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.
module Appearance
MODES = %w[auto light dark].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).
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
module_function
@@ -21,6 +22,7 @@ module RedmineTonalmine
end
errors << 'invalid mode' if prefs['mode'] && !MODES.include?(prefs['mode'])
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
end
@@ -30,6 +32,7 @@ module RedmineTonalmine
prefs = normalize(hash)
prefs.delete('mode') unless MODES.include?(prefs['mode'])
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.compact
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
* (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 CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']];
var SIZES = [['compact', 'Compact'], ['normal', 'Normal'], ['large', 'Large']];
var KEY = 'tonalmine';
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;
function load() {
@@ -34,6 +35,8 @@
else root.removeAttribute('data-mode');
if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.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.
if (prefs.palette === 'custom' && prefs.css) {
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.
function merge(base, top) {
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 };
}
@@ -130,6 +133,7 @@
function pickerHtml(prefs, isDefault) {
var mode = prefs.mode || 'auto';
var contrast = prefs.contrast || 'standard';
var size = prefs.size || 'normal';
var palette = prefs.palette || PALETTES[0][0];
return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' +
(isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') +
@@ -140,6 +144,10 @@
'<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('') +
'</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">' +
PALETTES.map(function (p) {
return button('data-nm-palette', p[0], '', p[0] === palette,
@@ -162,6 +170,9 @@
picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
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) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
});
@@ -190,13 +201,14 @@
}
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;
var isDefault = !!target.closest('[data-nm-default]');
var prefs = isDefault ? draft : 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-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.
else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette');
changed(isDefault, true);
@@ -225,26 +237,26 @@
// select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine.
function defaultPicker(select) {
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.
var picker = document.createElement('div');
picker.innerHTML = pickerHtml(draft, true);
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');
row.innerHTML = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>';
row.lastChild.appendChild(groups[i]);
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 + '">';
}).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.
function toggle() {
var on = select.value === 'tonalmine';
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);
toggle();
+25 -22
View File
@@ -6,7 +6,7 @@
/***** Buttons *****/
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-inline: 24px;
margin-block: 4px;
@@ -51,8 +51,8 @@ input[type="submit"]:disabled, input[type="button"]:disabled, button:disabled {
/***** Text fields (outlined) *****/
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 {
min-block-size: 40px;
padding-block: 8px;
min-block-size: calc(40px + var(--nm-density));
padding-block: calc(8px + min(var(--nm-density), 0px) / 2);
padding-inline: 12px;
border: 1px solid var(--md-sys-color-outline);
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="file"] { color: var(--md-sys-color-on-surface-variant); }
input[type="file"]::file-selector-button {
block-size: 40px;
block-size: calc(40px + var(--nm-density));
margin-inline-end: 12px;
padding-inline: 24px;
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 p { padding-block: 4px; }
/* 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 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)
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; }
@@ -191,8 +191,9 @@ blockquote { border-inline-start: 4px solid var(--md-sys-color-outline-variant);
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); }
/* The icon button after a select (new category, new version) stays on its line. The select
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 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; }
/***** 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 li { margin-inline-end: 0; margin-block-end: 0; }
#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;
gap: 4px;
inline-size: auto;
block-size: 48px;
block-size: calc(48px + var(--nm-density));
padding-inline-start: 8px;
box-sizing: border-box;
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;
align-items: center;
justify-content: center;
inline-size: 40px;
block-size: 40px;
inline-size: calc(40px + var(--nm-density));
block-size: calc(40px + var(--nm-density));
padding: 0;
border: 0;
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;
border: 0;
border-radius: var(--md-sys-shape-corner-full);
line-height: 40px;
min-inline-size: 40px;
line-height: calc(40px + var(--nm-density));
min-inline-size: calc(40px + var(--nm-density));
}
.pagination ul.pages li a, .pagination ul.pages li span {
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-content { padding-inline: 16px; }
.ui-dialog .ui-dialog-titlebar-close {
inline-size: 40px;
block-size: 40px;
inline-size: calc(40px + var(--nm-density));
block-size: calc(40px + var(--nm-density));
margin-block-start: -20px;
border: 0;
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 button {
position: relative;
inline-size: 32px;
block-size: 32px;
inline-size: calc(32px + var(--nm-density));
block-size: calc(32px + var(--nm-density));
margin-inline-end: 0;
padding: 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 button {
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;
border: 1px solid var(--md-sys-color-outline);
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 */
.nm-appearance__reset {
margin-block-start: 12px;
block-size: 32px;
block-size: calc(32px + var(--nm-density));
padding-block: 0;
padding-inline: 12px;
border: 0;
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
the label lines up; the row padding spaces them */
.nm-appearance--default .nm-segmented { margin: 0; }
.nm-appearance--default .nm-segmented button { block-size: 40px; margin-block: 0; }
.nm-appearance--default .nm-swatches { min-block-size: 40px; align-items: center; }
.nm-appearance--default .nm-segmented button { block-size: calc(40px + var(--nm-density)); margin-block: 0; }
.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 */
html .tabular label.nm-swatch--custom { display: block; margin: 0; inline-size: 28px; }
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 {
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 {
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); }
#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 {
inline-size: 220px;
block-size: 40px;
block-size: calc(40px + var(--nm-density));
padding-inline: 16px;
border: 0;
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-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;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-full);
background-color: transparent;
color: var(--md-sys-color-on-surface);
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); }
@@ -99,7 +100,7 @@ body.has-main-menu #header { padding-block-end: 48px; }
padding-block: 0;
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 {
position: relative;
color: var(--md-sys-color-on-surface-variant);
@@ -136,8 +137,8 @@ body.has-main-menu #header { padding-block-end: 48px; }
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 40px;
block-size: 40px;
inline-size: calc(40px + var(--nm-density));
block-size: calc(40px + var(--nm-density));
padding: 0;
margin-block: 0;
border: 0;
@@ -158,7 +159,7 @@ body.has-main-menu #header { padding-block-end: 48px; }
align-items: center;
gap: 4px;
inline-size: auto;
block-size: 48px;
block-size: calc(48px + var(--nm-density));
inset-inline-end: 0;
padding-inline: 8px;
border: 0;
@@ -290,7 +291,7 @@ div + .drdn-items, .dropdown-divider, #account .dropdown-content .logout { borde
display: flex;
align-items: center;
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 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 {
display: flex;
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-inline: 12px;
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);
}
#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;
color: var(--md-sys-color-on-surface);
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; }
/* 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;
}
html #header a.mobile-toggle-button {
inline-size: 48px;
block-size: 48px;
inline-size: calc(48px + var(--nm-density));
block-size: calc(48px + var(--nm-density));
margin-block-start: 8px;
margin-inline-end: 8px;
border-radius: var(--md-sys-shape-corner-full);
@@ -107,19 +108,19 @@
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 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 h4 { color: var(--md-sys-color-on-surface); }
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 {
block-size: 48px;
block-size: calc(48px + var(--nm-density));
line-height: 48px;
margin-inline-start: 0;
padding-inline: 16px;
border: 0;
border-radius: var(--md-sys-shape-corner-full);
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.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 .icon svg, html .flyout-menu .icon-only svg { color: inherit; stroke: currentColor; }
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);
background-color: var(--md-sys-color-surface-container-highest);
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
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 .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);
font: var(--md-sys-typescale-title-small);
letter-spacing: var(--md-sys-typescale-title-small-tracking);
padding-block: 10px;
padding-block: calc(10px + var(--nm-density) / 2);
padding-inline: 8px;
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 td {
block-size: 40px;
block-size: calc(40px + var(--nm-density));
padding-block: 4px;
padding-inline: 8px;
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
downwards without dragging the label to its middle. */
#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 .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
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. */
+24 -16
View File
@@ -7,22 +7,23 @@
/* Typeface */
--md-ref-typeface-plain: Roboto, system-ui, sans-serif;
/* Type scale, as `font` shorthand values: weight size/line-height family */
--md-sys-typescale-display-large: 400 3.5625rem/4rem var(--md-ref-typeface-plain);
--md-sys-typescale-display-medium: 400 2.8125rem/3.25rem var(--md-ref-typeface-plain);
--md-sys-typescale-display-small: 400 2.25rem/2.75rem var(--md-ref-typeface-plain);
--md-sys-typescale-headline-large: 400 2rem/2.5rem var(--md-ref-typeface-plain);
--md-sys-typescale-headline-medium: 400 1.75rem/2.25rem var(--md-ref-typeface-plain);
--md-sys-typescale-headline-small: 400 1.5rem/2rem var(--md-ref-typeface-plain);
--md-sys-typescale-title-large: 400 1.375rem/1.75rem var(--md-ref-typeface-plain);
--md-sys-typescale-title-medium: 500 1rem/1.5rem var(--md-ref-typeface-plain);
--md-sys-typescale-title-small: 500 0.875rem/1.25rem var(--md-ref-typeface-plain);
--md-sys-typescale-body-large: 400 1rem/1.5rem var(--md-ref-typeface-plain);
--md-sys-typescale-body-medium: 400 0.875rem/1.25rem var(--md-ref-typeface-plain);
--md-sys-typescale-body-small: 400 0.75rem/1rem var(--md-ref-typeface-plain);
--md-sys-typescale-label-large: 500 0.875rem/1.25rem var(--md-ref-typeface-plain);
--md-sys-typescale-label-medium: 500 0.75rem/1rem var(--md-ref-typeface-plain);
--md-sys-typescale-label-small: 500 0.6875rem/1rem var(--md-ref-typeface-plain);
/* Type scale, as `font` shorthand values: weight size/line-height family. Line heights are rounded to
whole px: in the size modes 1.25rem is e.g. 22.857px, and the rounding moved text off center */
--md-sys-typescale-display-large: 400 3.5625rem/round(4rem, 1px) 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-display-small: 400 2.25rem/round(2.75rem, 1px) 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-medium: 400 1.75rem/round(2.25rem, 1px) 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-large: 400 1.375rem/round(1.75rem, 1px) var(--md-ref-typeface-plain);
--md-sys-typescale-title-medium: 500 1rem/round(1.5rem, 1px) 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-large: 400 1rem/round(1.5rem, 1px) 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-body-small: 400 0.75rem/round(1rem, 1px) 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-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 */
--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-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%);
/* 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 */