Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75209874ec | ||
|
|
901ed1c098 | ||
|
|
f08d2c3f6c | ||
|
|
f5119bba50 | ||
|
|
833393a686 | ||
|
|
232958cd13 | ||
|
|
5738abe3ef | ||
|
|
77ff80e60b | ||
|
|
94a9bd066e | ||
|
|
7ef21c739a | ||
|
|
9a04f94118 |
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user