fix(web): restore the ⋯ menu row styles removed as dead CSS

a894bdd deleted .action-menu-item/-label/-icon/-check/-description and the
.sheet-list-icon/-check/-description rules as unused, but ActionMenu builds
those names as `${base}-item` (base 'action-menu' or 'sheet-list'), which the
dead-class scan did not recognise. Every ⋯ dropdown and its bottom-sheet form
lost their row layout, hover, danger colour and check mark. Both files are
restored to their state before that commit.

The class-usage test now covers every stylesheet (not only media) and
understands built names: `${expr}-suffix` with a literal prefix, and
`prefix-${expr}`. Library markup (Notyf, DPlayer) is exempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-31 02:47:19 +00:00
1 parent 4c20771e31
commit 20a85c33fe
4 files changed
+125 -26

No files matched your search

+69
View File
@@ -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); }
+16
View File
@@ -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) {
+40
View File
@@ -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([]);
});
-26
View File
@@ -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([]);
});