Skip to content
164 changes: 164 additions & 0 deletions test/test-frontend-audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,170 @@ 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).
// - `!important` schlägt die Quellreihenfolge. `max-width: none !important;
// max-width: var(…)` sieht sonst erfüllt aus, obwohl das `none` gewinnt.
const declaredValue = (body, props) => {
const alternatives = [].concat(props).map((p) => escapeForRegExp(p)).join('|');
const hits = [...body.matchAll(new RegExp(`(?:^|;)\\s*(${alternatives})\\s*:\\s*([^;]+)`, 'gm'))]
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
.map(([, prop, raw]) => ({ prop, raw: raw.trim() }));
if (!hits.length) return null;
Comment thread
ulsklyc marked this conversation as resolved.
const important = hits.filter(({ raw }) => /!\s*important$/.test(raw));
const { prop, raw } = (important.length ? important : hits).at(-1);
const value = raw.replace(/!\s*important$/, '').trim();
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
return prop.startsWith('place-') ? value.split(/\s+/)[0] : value;
};
const NARROW = 'var(--content-max-width-narrow)';
Comment thread
ulsklyc marked this conversation as resolved.
const ALIGN_SELF = ['align-self', 'place-self'];
// Eine Kappung ist eine Kappung, egal wie buchstabiert: die logischen Formen
// wirken im Schreibmodus dieser App auf dieselbe Achse. Dasselbe Paar prüft
// der Modul-Root-Breiten-Guard weiter unten schon.
const WIDTH_CAP = ['width', 'max-width', 'inline-size', 'max-inline-size'];

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 Reject margins that narrow the scroller hit area

The audited size and alignment properties are not the only way to stop this column-flex item from filling its cross axis. Adding .recipes-list { margin-inline-end: 20rem; } leaves the frontend audit green, but stretch sizing subtracts that margin from the scroller's border box, so its wheel-hit area again ends 20rem before the available page edge. Include nonzero inline margins in the scroller-width guard or verify the computed hit area.

Useful? React with 👍 / 👎.

const MAX_WIDTH = ['max-width', 'max-inline-size'];

// 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(…)` und `:has(…)` fallen vorher weg, und zwar VOR dem Zerlegen:
// beide nennen die Klasse, ohne dass die Regel sie stylt. `.page:has(
// .kitchen-list)` gestaltet den Vorfahren, nicht den Scroller - dort rot zu
// werden hieße, eine korrekte Layoutregel zu blockieren.
// Das Token ist `.klasse` oder `#id`: dasselbe Element lässt sich über beide
// ansprechen, und eine Regel auf der ID nennt keine seiner Klassen.
const targets = (selector, token) => {
const subject = selector.replace(/:(?:not|has)\([^)]*\)/g, '');
const compound = subject.trim().split(/[\s>+~]+/).pop() ?? '';
return new RegExp(`${escapeForRegExp(token)}(?![\\w-])`).test(compound);
Comment thread
ulsklyc marked this conversation as resolved.
};
const rulesFor = (token) => allRules.filter(({ selectors }) => selectors.some((s) => targets(s, token)));

// 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 scrollerTokens = new Set(['.kitchen-list']);
for (const page of ['shopping', 'pantry', 'recipes']) {
const src = read(`../public/pages/${page}.js`);
const combos = [...src.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) => scrollerTokens.add(`.${cls}`)));

// Und über die ID, die alle drei Scroller tragen: `#recipes-list` trifft
// dasselbe Element, ohne eine seiner Klassen zu nennen. Keine ID im
// Markup heißt umgekehrt, dass kein ID-Selektor es treffen kann - deshalb
// ist hier nichts zu fordern, nur einzusammeln.
const inTag = (src.match(/<[^>]*\bkitchen-list\b[^>]*>/g) ?? [])
.map((tag) => tag.match(/\bid="([^"]+)"/)?.[1]);
const nextToClassName = [...src.matchAll(
/(\w+)\.className\s*=\s*['"`][^'"`]*\bkitchen-list\b[^'"`]*['"`];\s*\1\.id\s*=\s*['"`]([^'"`]+)/g)]
.map(([, , id]) => id);
[...inTag, ...nextToClassName].filter(Boolean).forEach((id) => scrollerTokens.add(`#${id}`));
}
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 scrollerTokens) {
for (const { file, selectors, body } of rulesFor(cls)) {
assert.equal(declaredValue(body, WIDTH_CAP), 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.
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 👍 / 👎.

}
}

// Und das Lesemaß behält EINE Quelle. Definierte ein Modul
// --content-max-width-narrow lokal um, trüge das Kind zwar weiter die
// geforderte Deklaration, löste sie aber auf ein anderes Maß auf - der
// Guard unten vergliche dann zwei Texte, die dasselbe sagen und
// Verschiedenes bedeuten.
for (const { file, selectors, body } of allRules) {
if (file === 'tokens.css') continue;
assert.equal(declaredValue(body, '--content-max-width-narrow'), null,
Comment thread
ulsklyc marked this conversation as resolved.
`${file} ${selectors.join(', ')}: --content-max-width-narrow wird hier lokal umdefiniert - das Lesemaß kommt aus tokens.css und nirgendwo sonst`);
}
Comment thread
ulsklyc marked this conversation as resolved.

// 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);
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`);
Comment thread
ulsklyc marked this conversation as resolved.
Outdated
for (const { file, body } of rules) {
const width = declaredValue(body, MAX_WIDTH);
Comment thread
ulsklyc marked this conversation as resolved.
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