diff --git a/web/src/styles/action-menu.css b/web/src/styles/action-menu.css index 97f9a08..1e1e563 100644 --- a/web/src/styles/action-menu.css +++ b/web/src/styles/action-menu.css @@ -31,19 +31,88 @@ display: block; } +.action-menu .action-menu-item { + appearance: none; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 40px; + margin: 0; + padding: 8px 12px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + white-space: nowrap; + cursor: pointer; +} +.action-menu .action-menu-item.is-danger { + color: var(--color-danger); +} +.action-menu .action-menu-item:disabled { + opacity: 0.5; + cursor: default; +} +.action-menu .action-menu-item:focus-visible { + outline: 2px solid var(--color-brand-text); + outline-offset: -2px; +} +.action-menu .action-menu-item[aria-checked='true'] { + color: var(--color-brand-text); + font-weight: 600; +} +.action-menu-icon { + display: inline-grid; + place-items: center; + flex: none; + width: 1.125rem; + color: var(--color-muted); +} +.action-menu .action-menu-item.is-danger .action-menu-icon { + color: inherit; +} +.action-menu-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.action-menu-description { + display: block; + color: var(--color-muted); + font-size: var(--text-meta); + line-height: 1.4; + white-space: normal; +} +.action-menu-check { + flex: none; +} +@media (hover: hover) and (pointer: fine) { + .action-menu .action-menu-item:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-ink) 7%, transparent); + } +} /* Two classes, so the media screens' generic `.media-screen button` rules lose. */ /* Touch: a finger-sized row. */ +@media (pointer: coarse) { + .action-menu .action-menu-item { + min-height: 44px; + } +} @keyframes action-menu-in { from { opacity: 0; transform: translateY(-4px); } diff --git a/web/src/styles/sheet.css b/web/src/styles/sheet.css index 8d684ba..1fc03c4 100644 --- a/web/src/styles/sheet.css +++ b/web/src/styles/sheet.css @@ -220,13 +220,29 @@ min-width: 0; } +.sheet-list-description { + display: block; + color: var(--color-muted); + font-size: var(--text-meta); + line-height: 1.4; +} .sheet-list-item[aria-checked='true'] { color: var(--reader-accent); font-weight: 600; } +.sheet-list-check { + flex: none; +} +.sheet-list-icon { + display: inline-grid; + place-items: center; + flex: none; + width: 1.25rem; + color: var(--reader-muted); +} @media (hover: hover) and (pointer: fine) { .sheet-list-item:hover:not(:disabled) { diff --git a/web/test/css-classes.test.ts b/web/test/css-classes.test.ts new file mode 100644 index 0000000..ee6b1a9 --- /dev/null +++ b/web/test/css-classes.test.ts @@ -0,0 +1,40 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { expect, it } from 'vitest'; + +/** + * Every class a stylesheet names must be one the source can render. + * + * The media stylesheets grew by layering and replaced screens left their rules + * behind (~300 lines of them), and a rule for a class nothing uses is where the next + * change gets made, to no effect. The check has to know the two ways a class name is + * *built* rather than written, because getting that wrong deletes live styles — which + * is exactly what happened to the ⋯ menu rows (`${base}-item`, base 'action-menu' or + * 'sheet-list'): + * + * - `${expr}-suffix`: any class ending in `-suffix` whose prefix the source spells + * as a string literal; + * - `prefix-${expr}`: any class starting with `prefix-`. + * + * Library markup (Notyf, DPlayer) is theirs, not ours. A class inside :not() is matched + * by its absence, so it need not exist. + */ +const src = join(import.meta.dirname, '..', 'src'); +const files = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => + entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)]); + +it('styles only classes that the source renders', () => { + const code = files(src).filter(file => /\.tsx?$/.test(file)).map(file => readFileSync(file, 'utf8')).join('\n'); + const builtSuffixes = new Set([...code.matchAll(/\$\{[\w.]+\}-([a-z][\w-]*)/g)].map(match => match[1]!)); + const builtPrefixes = new Set([...code.matchAll(/[`'" ]([a-z][\w-]*-)\$\{/g)].map(match => match[1]!)); + const literal = (text: string) => new RegExp(`['"\`]${text}['"\`]`).test(code); + const used = (cls: string) => code.includes(cls) + || [...builtPrefixes].some(prefix => cls.startsWith(prefix)) + || [...builtSuffixes].some(suffix => cls.endsWith('-' + suffix) && literal(cls.slice(0, -suffix.length - 1))); + const unused = files(src).filter(file => file.endsWith('.css')).flatMap(file => { + const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/:not\([^)]*\)/g, ''); + const names = new Set([...css.matchAll(/\.(-?[a-zA-Z_][\w-]*)/g)].map(match => match[1]!)); + return [...names].filter(cls => !/^(dplayer|notyf)/.test(cls) && !used(cls)).map(cls => `${relative(src, file)}: .${cls}`); + }); + expect(unused).toEqual([]); +}); diff --git a/web/test/media-css-classes.test.ts b/web/test/media-css-classes.test.ts deleted file mode 100644 index 1717035..0000000 --- a/web/test/media-css-classes.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { expect, it } from 'vitest'; - -/** - * The media stylesheets grew by layering (media → presentation → v2 → manager), and - * screens that were replaced left their rules behind: ~300 lines styled class names no - * component renders any more. A rule for a class nothing uses is not harmless — it is - * where the next change gets made, to no effect. Every class the media styles name must - * appear somewhere in the source; DPlayer's own classes are its markup, not ours. - */ -const src = join(import.meta.dirname, '..', 'src'); -const files = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => - entry.isDirectory() ? files(join(dir, entry.name)) : [join(dir, entry.name)]); - -it('styles only classes that the source renders (media stylesheets)', () => { - const code = files(src).filter(file => /\.tsx?$/.test(file)).map(file => readFileSync(file, 'utf8')).join('\n'); - const styles = join(src, 'features', 'media', 'styles'); - const unused = readdirSync(styles).filter(name => name.endsWith('.css')).flatMap(name => { - // A class inside :not() is matched by its absence, so it need not exist. - const css = readFileSync(join(styles, name), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/:not\([^)]*\)/g, ''); - const names = new Set([...css.matchAll(/\.(-?[a-zA-Z_][\w-]*)/g)].map(match => match[1]!)); - return [...names].filter(cls => !cls.startsWith('dplayer') && !code.includes(cls)).map(cls => `${name}: .${cls}`); - }); - expect(unused).toEqual([]); -});