Skip to content
126 changes: 126 additions & 0 deletions test/test-frontend-audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,132 @@ test('die Küchen-Listen teilen eine Zeilen-Grammatik', () => {
'.items-list darf kein horizontales Polster setzen: #list-content trägt schon --page-inline-pad');
assert.doesNotMatch(shared.match(/\.kitchen-list\s*\{([^}]*)\}/)?.[1] ?? '', /padding-inline:/,
'.kitchen-list darf kein padding-inline setzen: wo der Spalten-Träger sitzt, ist pro Tab verschieden');

// Die Kappung aufs Lesemaß sitzt an den KINDERN des Scrollers, nicht am
// Scroller selbst (PR #614). Die Begründung dafür stand bisher nur als
// Kommentar im CSS.
//
// Gescannt wird JEDE Regel JEDER Stylesheet-Datei, nicht der erste Textblock
// je Selektor. Zwei Wege führen sonst am Guard vorbei: ein zweiter Block
// hinter einem Breakpoint, und das Modul-CSS, das später lädt und auf
// demselben Element sitzt (`class="kitchen-list items-list"`).
const styleDir = new URL('../public/styles/', import.meta.url);
const allRules = readdirSync(styleDir).filter((f) => f.endsWith('.css'))
.flatMap((file) => cssRules(read(`../public/styles/${file}`)).map((rule) => ({ file, ...rule })));
Comment thread
ulsklyc marked this conversation as resolved.
Outdated

// Der WIRKSAME Wert einer Eigenschaft, oder null. Drei Fallen stecken darin:
//
// - Eine Deklaration ist kein Textvorkommen: `--eigene-max-width: 40rem`
// setzt keine Breite, und `--x: var(--content-max-width-narrow)` erfüllt
// keine Zusage.
// - Die LETZTE Deklaration gewinnt, wie im Browser. Sonst gilt
// `max-width: var(--content-max-width-narrow); max-width: none` als
// erfüllt, obwohl das Element bildschirmbreit läuft.
// - Kurzschreibweisen setzen dieselbe Eigenschaft mit: `place-self:
// stretch` setzt `align-self` zurück. Deshalb nimmt die Funktion eine
// Liste und gibt bei `place-*` den ersten Teilwert (die Block-Achse).
const declaredValue = (body, props) => {
const alternatives = [].concat(props).map((p) => escapeForRegExp(p)).join('|');
const hits = [...body.matchAll(new RegExp(`(?:^|;)\\s*(${alternatives})\\s*:\\s*([^;]+)`, 'gm'))];
if (!hits.length) return null;
Comment thread
ulsklyc marked this conversation as resolved.
const [, prop, raw] = hits[hits.length - 1];
const value = raw.trim();
return prop.startsWith('place-') ? value.split(/\s+/)[0] : value;
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
};
const NARROW = 'var(--content-max-width-narrow)';
Comment thread
ulsklyc marked this conversation as resolved.
const ALIGN_SELF = ['align-self', 'place-self'];

// Zielt der Selektor auf das Element selbst, nicht auf einen Nachfahren?
// Geprüft wird der LETZTE Compound, damit auch `.kitchen-list#items-list`,
// `.kitchen-list:hover` und `:is(.kitchen-list)` als Treffer gelten -
// `.kitchen-list .row` dagegen nicht. `:not(…)` fällt vorher weg, sonst
// meldete ausgerechnet der Ausschluss einen Treffer.
const targets = (selector, cls) => {
const compound = selector.trim().split(/[\s>+~]+/).pop()?.replace(/:not\([^)]*\)/g, '') ?? '';
return new RegExp(`\\.${escapeForRegExp(cls)}(?![\\w-])`).test(compound);
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
};
const rulesFor = (cls) => allRules.filter(({ selectors }) => selectors.some((s) => targets(s, cls)));

// 1. Der Scroller selbst darf nicht gekappt werden. Er ist das Element mit
// `overflow-y: auto`; kappt man es aufs Lesemaß, endet damit auch sein
// eigener Trefferbereich fürs Mausrad an der Lesespalten-Kante, und auf
// einem breiten Fenster greift das Rad rechts davon ins Leere.
//
// Welche Klassen den Scroller mitbenennen, sagt das Markup, nicht diese
// Liste: wer auf demselben Element sitzt, kann seine Breite kappen.
// JEDE geprüfte Seite muss ihre eigene Kombination liefern. Eine globale
// Mindestzahl genügt nicht: fiele nur eine Seite aus der Erkennung, würden
// die beiden anderen sie weiter erfüllen, und deren Modul-Klasse wäre
// ungeprüft.
const scrollerClasses = new Set(['kitchen-list']);
for (const page of ['shopping', 'pantry', 'recipes']) {
const combos = [...read(`../public/pages/${page}.js`)
.matchAll(/class(?:Name)?\s*=\s*(['"`])([^'"`]*\bkitchen-list\b[^'"`]*)\1/g)];
assert.ok(combos.length > 0,
`${page}.js hängt seine Klasse nicht mehr literal an .kitchen-list - dieser Scan findet sie dann nicht und prüft den Scroller des Tabs ungewollt gar nicht`);
combos.forEach(([, , combo]) => combo.trim().split(/\s+/).forEach((cls) => scrollerClasses.add(cls)));
}
assert.ok(rulesFor('kitchen-list').length > 0,
'.kitchen-list ist nirgends definiert: ein leerer Treffer darf hier nicht still grün bleiben');
for (const cls of scrollerClasses) {
for (const { file, selectors, body } of rulesFor(cls)) {
assert.equal(declaredValue(body, 'max-width'), null,
`${file} ${selectors.join(', ')}: der Scroller darf nicht gekappt werden, sonst endet sein Mausrad-Trefferbereich an der Lesespalten-Kante`);
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
Comment thread
ulsklyc marked this conversation as resolved.
Comment on lines +2041 to +2042

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept non-capping scroller size resets

This assertion rejects every size declaration regardless of its value, so a harmless module rule such as .recipes-list { max-width: none; } or width: auto fails the audit even though it explicitly leaves the scroller unconstrained and preserves the full wheel-hit area. Check whether the effective value actually caps the size rather than requiring the properties to be absent.

Useful? React with 👍 / 👎.

}
}

// 2. Tragen muss die Kappung stattdessen jedes Kind, das ALLEIN Kind des
// Scrollers sein kann: .kitchen-group bei gruppierten Tabs (Einkauf,
// Vorrat), .kitchen-rows ungruppiert (Rezepte). Fehlt sie an einem der
// beiden, läuft der betroffene Tab bildschirmbreit - und ein zweiter
// Block darf sie auch nicht auf einen abweichenden Wert ziehen.
for (const cls of ['kitchen-group', 'kitchen-rows']) {
const rules = rulesFor(cls);
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
assert.ok(rules.some(({ body }) => declaredValue(body, 'max-width') === NARROW),
`.${cls} muss das Lesemaß selbst tragen: es kann alleiniges Kind von .kitchen-list sein, und .kitchen-list kappt nicht mehr`);
for (const { file, body } of rules) {
const width = declaredValue(body, 'max-width');
if (width === null) continue;
assert.equal(width, NARROW,
`${file}: .${cls} bekommt hier eine zweite, abweichende Breite - das Lesemaß ist EIN Wert`);
}
}

// 3. Und wer aufs Lesemaß kappt UND clippt, muss auf seine Inhaltshöhe
// wachsen dürfen.
//
// Absichtlich eine Regel und keine Allowlist: `overflow: hidden` (hier für
// die Eckenradien) macht aus dem gekappten Kind einen Clipper. Ohne
// `align-self: start` streckt das voreingestellte `align-items: stretch`
// es auf die volle Spurhöhe, und es schneidet alles darüber still ab,
// bevor .kitchen-list den Überlauf je sieht. Gemessen an einer Rezeptliste
// mit 50 gespiegelten Einträgen: scrollHeight 3249px gegen clientHeight
// 657px, kein Scrollbalken, kein Weg an die übrigen Zeilen. Harmlos ist
// das nur, solange mehrere kurze Gruppen dieselbe Spur teilen.
//
// Der Scan bleibt auf kitchen-row.css, wo die geteilten Bausteine
// definiert werden. Andere Module kappen mit demselben Token Elemente, die
// nie Grid-Item dieses Scrollers werden (shopping.css die Eingabezeile,
// layout.css den Leerzustand) - für die wäre `align-self: start` falsch.
// Innerhalb dieser Datei gilt dieselbe Einschränkung für .kitchen-bulkbar:
// sie steht ÜBER dem Scroller (siehe dort) und trägt das Lesemaß, clippt
// aber nicht. Käme dort ein `overflow: hidden` dazu, meldet dieser Guard
// einen Fall, den ein Mensch entscheiden muss.
for (const { selectors, body } of cssRules(shared)) {
if (declaredValue(body, 'max-width') !== NARROW) continue;
if (declaredValue(body, 'overflow') !== 'hidden') continue;
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
assert.equal(declaredValue(body, ALIGN_SELF), 'start',
`${selectors.join(', ')} kappt aufs Lesemaß und clippt zugleich, ist also ein gekapptes Kind des Scroller-Grids: ohne align-self: start schneidet es den Überlauf ab, bevor .kitchen-list ihn sieht`);
}

// Und kein später geladenes Modul-Stylesheet biegt den Wert wieder um -
// auch nicht über die Kurzschreibweise place-self.
for (const { file, body } of rulesFor('kitchen-rows')) {
const align = declaredValue(body, ALIGN_SELF);
if (align === null) continue;
assert.equal(align, 'start',
`${file}: .kitchen-rows bekommt hier ein anderes align-self - genau der Rückfall, den die Regel darüber verhindert`);
}
});

/**
Expand Down
Loading