From 5905712aa538989b27a67551303b5c683cd99928 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Tue, 21 Jul 2026 20:58:47 +0200 Subject: [PATCH] frontend: Renderer + Modell headless auslagern (src/model.js, src/render.js) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASKS Phase 1. Baum-/Kostenlogik -> model.js (gateOf, needsBreakdown, visibleChildren, computeCheapSet, cheapCls), HTML-Erzeugung -> render.js (renderTreeHtml). Beide sind headless: UI-State (verworfene einblenden, günstigster Pfad) kommt als Parameter herein statt aus Globals; nur cheapPathOn bleibt UI-State in app.js. render() in app.js ist jetzt die dünne DOM-Anbindung. Snapshot-Tests (tests/render.test.js): Grundzustand, günstigster Pfad, verworfene einblenden, Geister-Knoten („Untergliederung fehlt"), plus die Zusicherung, dass der Renderer nie eine Modus-Klasse erzeugt (Modus = CSS-Container-Klasse in app.js -> ein Snapshot deckt alle drei Modi). 24 Tests grün. Byte-identisches Rendering im Browser verifiziert (18 Knoten, 6 Blätter, 3 Geister, Toggles ok, keine Konsolenfehler). Co-Authored-By: Claude Opus 4.8 --- docs/TASKS.md | 7 +- frontend/CLAUDE.md | 12 +- frontend/src/app.js | 130 +++--------------- frontend/src/model.js | 70 ++++++++++ frontend/src/render.js | 77 +++++++++++ .../tests/__snapshots__/render.test.js.snap | 7 + frontend/tests/render.test.js | 90 ++++++++++++ 7 files changed, 276 insertions(+), 117 deletions(-) create mode 100644 frontend/src/model.js create mode 100644 frontend/src/render.js create mode 100644 frontend/tests/__snapshots__/render.test.js.snap create mode 100644 frontend/tests/render.test.js diff --git a/docs/TASKS.md b/docs/TASKS.md index 052855c..402fb39 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -17,8 +17,13 @@ Abhaken beim Erledigen; neue Aufgaben unten anfügen. als Fixture; Randfälle: gemischte Gates, Tabs/ungleichmäßige Einrückung, URL mit `@`, mehrere Wurzeln, leere Labels, `%%` am Zeilenanfang/-ende. → `tests/parser.test.js` (18 Tests). -- [ ] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für +- [x] Renderer extrahieren (HTML-String-Erzeugung), Snapshot-Tests für Normal- und Vertikalmodus sowie „Untergliederung fehlt“. + → `src/model.js` (Baum-/Kostenlogik) + `src/render.js` (`renderTreeHtml`, + headless); `app.js` reicht UI-State als Parameter herein. + `tests/render.test.js` (6 Tests, Snapshots). Anm.: der Modus + (horizontal/vertikal/kompakt) ist reine CSS-Container-Klasse und ändert + den Renderer-String nicht — ein Snapshot deckt alle drei Modi ab. - [ ] Warnungs-Modell vereinheitlichen (Zeilennummern, Typen). ## Phase 2 — Qualität diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index cb2d797..90b57fe 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -38,8 +38,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15). - Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/ Status → URL → Größe → Tags (sonst kollidiert `@` in URLs). -- Günstigster Pfad: `markCheapest()`/`cheapestCost()` markieren die nötigen - Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` zeichnet nach jedem +- Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/ + Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`, + `computeCheapSet`, `cheapCls`), `render.js` (HTML-String via + `renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet})`, headless), + `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State + (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur + `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. +- Günstigster Pfad: `markCheapest()`/`cheapestCost()` (in `model.js`) markieren + die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js) + zeichnet nach jedem `render()` **und** nach `applyLayout()` zwei Overlay-SVGs in `#out` (hinten kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den CSS-`zoom` von `#out`, Punkte in unskalierte `#out`-Koordinaten umrechnen diff --git a/frontend/src/app.js b/frontend/src/app.js index 4f1b7bf..472ea2f 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,5 +1,7 @@ import './style.css'; -import { SIZE_RANK, STATUS_BY_CODE, parse } from './parser.js'; +import { parse } from './parser.js'; +import { computeCheapSet } from './model.js'; +import { esc, renderTreeHtml } from './render.js'; const INITIAL = `%% Project structure – Sprint 14 [~] Website relaunch (XL) https://wiki.example.com/relaunch @@ -26,113 +28,18 @@ const src = document.getElementById('src'); const out = document.getElementById('out'); const warnBox = document.getElementById('warn'); -function esc(s){ - return s.replace(/&/g,'&').replace(//g,'>'); -} - -/* ---------- Renderer ---------- */ -function gateOf(children){ - return children.length && children[0].type === 'or' ? 'or' : 'and'; -} - -function needsBreakdown(n){ - if(n.status && n.status.key === 'verworfen') return false; - return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length; -} - -function visibleChildren(n){ - if(discardedShown()) return n.children; - return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); -} - -/* ---------- Günstigster Pfad ---------- - Markiert die Knoten, die für die günstigste Realisierung nötig sind: - all-of ⇒ alle Kinder nötig; any-of ⇒ nur die günstigste Alternative. - „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe + Kinder; - bei any-of das Minimum). Verworfene zählen nie mit — unabhängig vom - Einblenden-Toggle. Gleichstand ⇒ erste Alternative. Fehlende Größe = M. */ +/* Baum-/Kostenlogik (gateOf, needsBreakdown, visibleChildren, günstigster + Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt + nur der UI-State des Günstigster-Pfad-Toggles (persistiert). */ let cheapPathOn = true; -const cheapSet = new Set(); -function pathChildren(n){ - return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); -} -/* fehlende Größe wird als M interpretiert */ -function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; } -function cheapestCost(n){ - const kids = pathChildren(n); - let c = ownCost(n); - if(kids.length){ - if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost)); - else c += kids.reduce((s, k) => s + cheapestCost(k), 0); - } - return c; -} -function markCheapest(n){ - cheapSet.add(n); - const kids = pathChildren(n); - if(!kids.length) return; - if(gateOf(kids) === 'or'){ - let best = null, bc = Infinity; - for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } - if(best) markCheapest(best); - } else { - for(const k of kids) markCheapest(k); - } -} -function cheapCls(n){ - if(!(cheapPathOn && cheapSet.has(n))) return ''; - /* Endknoten des Pfads: kein Kind liegt (mehr) auf dem günstigen Pfad */ - const leaf = !pathChildren(n).some(k => cheapSet.has(k)); - return leaf ? 'cheap cheap-leaf' : 'cheap'; -} - -function nodeHtml(n, extra){ - const need = needsBreakdown(n); - const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : ''] - .filter(Boolean).join(' '); - const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : ''; - const tagsHtml = n.tags && n.tags.length - ? `${n.tags.map(tag => `${esc(tag)}`).join('')}` - : ''; - const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"'); - const sizeBadge = n.size - ? `${n.size}` - : (cheapPathOn ? `M` : ''); - const inner = esc(n.label) + - (n.url ? '' : '') + - sizeBadge + - tagsHtml; - const html = n.url - ? `${inner}` - : `
${inner}
`; - const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"'); - const ghost = `
${esc(t('ghost'))}
`; - return html + (need ? ghost : ''); -} - -function renderChildren(node, warnings){ - const kids = visibleChildren(node); - if(!kids.length) return ''; - const types = new Set(kids.map(k => k.type)); - if(types.size > 1){ - warnings.push(t('mixedWarn', {line: kids[0].line, label: esc(node.label)})); - } - const gate = gateOf(kids); - const items = kids.map(k => { - const vk = visibleChildren(k); - const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; - return `` + - nodeHtml(k, cheapCls(k)) + - renderChildren(k, warnings) + - ``; - }).join(''); - return `
    ${items}
`; -} +/* ---------- Renderer (Anbindung an den DOM) ---------- + parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> + render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */ function render(){ let {roots} = parse(src.value); - const warnings = []; - if(!discardedShown()){ + const showDiscarded = discardedShown(); + if(!showDiscarded){ roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); } @@ -142,18 +49,13 @@ function render(){ return; } - cheapSet.clear(); - if(cheapPathOn) roots.forEach(markCheapest); + const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); out.classList.toggle('cheap-on', cheapPathOn); - out.innerHTML = roots.map(root => { - const vk = visibleChildren(root); - const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; - return `` + - nodeHtml(root, ('root-node ' + cheapCls(root)).trim()) + - renderChildren(root, warnings) + - ``; - }).join(''); + const {html, warnings} = renderTreeHtml(roots, { + t, showDiscarded, cheapPath: cheapPathOn, cheapSet + }); + out.innerHTML = html; warnBox.innerHTML = warnings.map(w => `
⚠ ${w}
`).join(''); drawCheapPath(); diff --git a/frontend/src/model.js b/frontend/src/model.js new file mode 100644 index 0000000..5ce6dc9 --- /dev/null +++ b/frontend/src/model.js @@ -0,0 +1,70 @@ +/* Werkbaum-Modell — headless Baum-/Kostenlogik über den geparsten Knotenbaum. + Kein DOM, keine UI-State-Globals: Zustand (verworfene einblenden, günstigster + Pfad) wird als Parameter hereingereicht. Grundlage für Renderer, SVG-Export + und Mermaid-Plugin. Vgl. docs/SPEC.md §3–§5, §9 und D18. */ + +import { SIZE_RANK } from './parser.js'; + +/* Gate der Geschwistergruppe: 'or', wenn das erste Kind '|' trägt, sonst 'and' + (SPEC §3 — Darstellung nach dem ersten Kind). */ +export function gateOf(children){ + return children.length && children[0].type === 'or' ? 'or' : 'and'; +} + +/* Untergliederungspflicht ab Größe M ohne Kinder (SPEC §5); verworfene nie. */ +export function needsBreakdown(n){ + if(n.status && n.status.key === 'verworfen') return false; + return !!n.size && SIZE_RANK[n.size] >= SIZE_RANK.M && !n.children.length; +} + +/* Sichtbare Kinder: verworfene ausblenden, außer showDiscarded ist gesetzt. */ +export function visibleChildren(n, showDiscarded){ + if(showDiscarded) return n.children; + return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); +} + +/* ---------- Günstigster Pfad (D18) ---------- + Nötige Knoten für die günstigste Realisierung: all-of ⇒ alle Kinder, + any-of ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive + Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie + mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende + Größe = M. */ +export function pathChildren(n){ + return n.children.filter(k => !k.status || k.status.key !== 'verworfen'); +} +/* fehlende Größe wird als M interpretiert */ +export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; } +export function cheapestCost(n){ + const kids = pathChildren(n); + let c = ownCost(n); + if(kids.length){ + if(gateOf(kids) === 'or') c += Math.min(...kids.map(cheapestCost)); + else c += kids.reduce((s, k) => s + cheapestCost(k), 0); + } + return c; +} +export function markCheapest(n, set){ + set.add(n); + const kids = pathChildren(n); + if(!kids.length) return; + if(gateOf(kids) === 'or'){ + let best = null, bc = Infinity; + for(const k of kids){ const c = cheapestCost(k); if(c < bc){ bc = c; best = k; } } + if(best) markCheapest(best, set); + } else { + for(const k of kids) markCheapest(k, set); + } +} +/* Menge der nötigen Knoten über alle Wurzeln. */ +export function computeCheapSet(roots){ + const set = new Set(); + roots.forEach(r => markCheapest(r, set)); + return set; +} +/* CSS-Klassen für den günstigen Pfad. Leere `cheapSet` (Pfad aus) ⇒ ''. + Endknoten (kein Kind liegt auf dem Pfad) bekommt zusätzlich 'cheap-leaf'. */ +export function cheapCls(n, cheapSet){ + if(!cheapSet.has(n)) return ''; + const leaf = !pathChildren(n).some(k => cheapSet.has(k)); + return leaf ? 'cheap cheap-leaf' : 'cheap'; +} diff --git a/frontend/src/render.js b/frontend/src/render.js new file mode 100644 index 0000000..746c64e --- /dev/null +++ b/frontend/src/render.js @@ -0,0 +1,77 @@ +/* Werkbaum-Renderer — erzeugt den HTML-String des Diagrammbaums (die
  • -Liste + für #out). Headless: keine DOM-Zugriffe, kein globaler UI-State. Alles kommt + über `opts` herein. Der Darstellungsmodus (horizontal/vertikal/kompakt) ist + rein CSS (Klasse am Container, von app.js gesetzt) und ändert diesen String + NICHT. Vgl. docs/SPEC.md §4–§9, D18. + + opts = { + t, // i18n-Funktion (key, vars?) -> String + showDiscarded, // verworfene einblenden? + cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge) + cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus) + } */ + +import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js'; + +export function esc(s){ + return s.replace(/&/g,'&').replace(//g,'>'); +} + +function nodeHtml(n, extra, opts){ + const { t, cheapPath } = opts; + const need = needsBreakdown(n); + const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : ''] + .filter(Boolean).join(' '); + const title = n.status ? ` title="${esc(t('st_' + n.status.key)).replace(/"/g,'"')}"` : ''; + const tagsHtml = n.tags && n.tags.length + ? `${n.tags.map(tag => `${esc(tag)}`).join('')}` + : ''; + const implicitTip = esc(t('implicitSizeTooltip')).replace(/"/g,'"'); + const sizeBadge = n.size + ? `${n.size}` + : (cheapPath ? `M` : ''); + const inner = esc(n.label) + + (n.url ? '' : '') + + sizeBadge + + tagsHtml; + const html = n.url + ? `${inner}` + : `
    ${inner}
    `; + const ghostTip = esc(t('ghostTooltip')).replace(/"/g,'"'); + const ghost = `
    ${esc(t('ghost'))}
    `; + return html + (need ? ghost : ''); +} + +function renderChildren(node, warnings, opts){ + const kids = visibleChildren(node, opts.showDiscarded); + if(!kids.length) return ''; + const types = new Set(kids.map(k => k.type)); + if(types.size > 1){ + warnings.push(opts.t('mixedWarn', {line: kids[0].line, label: esc(node.label)})); + } + const gate = gateOf(kids); + const items = kids.map(k => { + const vk = visibleChildren(k, opts.showDiscarded); + const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + return `` + + nodeHtml(k, cheapCls(k, opts.cheapSet), opts) + + renderChildren(k, warnings, opts) + + `
  • `; + }).join(''); + return `
      ${items}
    `; +} + +/* Baut den inneren HTML-String für #out aus (bereits gefilterten) Wurzeln und + sammelt Warnungen (gemischte Gates). Leere Wurzelliste ⇒ leerer String. */ +export function renderTreeHtml(roots, opts){ + const warnings = []; + const html = roots.map(root => { + const vk = visibleChildren(root, opts.showDiscarded); + const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; + return `` + + nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) + + renderChildren(root, warnings, opts) + + ``; + }).join(''); + return { html, warnings }; +} diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap new file mode 100644 index 0000000..dd55ed4 --- /dev/null +++ b/frontend/tests/__snapshots__/render.test.js.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
    • HostingM
      • Cloud
      • On-Premise
  • "`; + +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
    • HostingM
      • CloudM
      • On-PremiseM
  • "`; + +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-RelaunchXL
    • KonzeptionM
      • ZielgruppenanalyseS
      • SitemapXS
    • UmsetzungXL
      • FrontendSanna
      • BackendLbencarla
        ghost
      • CMS-AnbindungM
        • WordPress
        • Headless CMS
        • Eigenentwicklung
    • HostingM
      • Cloud
      • On-Premise
  • "`; diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js new file mode 100644 index 0000000..b6b064f --- /dev/null +++ b/frontend/tests/render.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { computeCheapSet } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; + +/* Deterministischer i18n-Stub: gibt den Key zurück (bzw. interpoliert + mixedWarn), damit die Snapshots sprachunabhängig und stabil sind. */ +const t = (key, vars) => + key === 'mixedWarn' ? `mixedWarn(line=${vars.line}, label=${vars.label})` : key; + +const SPEC_EXAMPLE = `%% Projektstruktur – Stand Sprint 14 +[~] Website-Relaunch (XL) https://wiki.example.de/relaunch + - [x] Konzeption (M) + - [x] Zielgruppenanalyse (S) + - [x] Sitemap (XS) + - [~] Umsetzung (XL) + - [/] Frontend (S) https://git.example.de/frontend @anna + - [ ] Backend (L) @ben @carla + - [ ] CMS-Anbindung (M) + | [ ] WordPress + | [?] Headless CMS + | [-] Eigenentwicklung %% Aufwand zu hoch + - [?] Hosting (M) + | Cloud + | On-Premise`; + +/* Rendert die (wie in app.js) vorab gefilterten Wurzeln. */ +function renderExample({showDiscarded = false, cheapPath = false} = {}){ + let {roots} = parse(SPEC_EXAMPLE); + if(!showDiscarded){ + roots = roots.filter(r => !r.status || r.status.key !== 'verworfen'); + } + const cheapSet = cheapPath ? computeCheapSet(roots) : new Set(); + return renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet}); +} + +const count = (html, needle) => html.split(needle).length - 1; + +describe('renderTreeHtml — kanonisches Beispiel', () => { + it('Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot', () => { + const {html, warnings} = renderExample(); + expect(count(html, 'class="node')).toBe(13); // Eigenentwicklung ausgeblendet + expect(count(html, 'cheap-leaf')).toBe(0); + expect(count(html, 'size implicit')).toBe(0); + expect(count(html, 'ghost-node')).toBe(1); // Backend (L) ist ein M+-Blatt + expect(count(html, '
      ')).toBe(2); // CMS-Anbindung, Hosting + expect(warnings).toEqual([]); + expect(html).toMatchSnapshot(); + }); + + it('günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges', () => { + const {html} = renderExample({cheapPath: true}); + expect(count(html, 'cheap-leaf')).toBeGreaterThan(0); + expect(count(html, 'size implicit')).toBeGreaterThan(0); // Cloud/On-Premise ohne Größe + expect(html).toMatchSnapshot(); + }); + + it('verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen)', () => { + const {html} = renderExample({showDiscarded: true}); + expect(count(html, 'class="node')).toBe(14); + expect(html).toContain('st-verworfen'); + expect(html).toMatchSnapshot(); + }); +}); + +describe('renderTreeHtml — „Untergliederung fehlt" (Geister-Knoten, SPEC §5)', () => { + it('M+ ohne Kinder erzeugt genau einen Geister-Knoten', () => { + let {roots} = parse('- [ ] Großes Paket (L)'); + const {html} = renderTreeHtml(roots, {t, showDiscarded: false, cheapPath: false, cheapSet: new Set()}); + expect(count(html, 'ghost-node')).toBe(1); + expect(html).toContain('title="ghostTooltip"'); + expect(html).toMatchInlineSnapshot(`"
    • Großes PaketL
      ghost
    • "`); + }); + + it('verworfenes M+ löst die Regel nicht aus', () => { + let {roots} = parse('- [-] Verworfen groß (XL)'); + const {html} = renderTreeHtml(roots, {t, showDiscarded: true, cheapPath: false, cheapSet: new Set()}); + expect(count(html, 'ghost-node')).toBe(0); + }); +}); + +describe('renderTreeHtml — Moduswechsel ist CSS, nicht Renderer', () => { + it('erzeugt nie eine Modus-Klasse (vertical/kompakt) — die setzt app.js am Container', () => { + const {html} = renderExample(); + expect(html).not.toMatch(/\bvertical\b/); + expect(html).not.toMatch(/\bkompakt\b/); + // Also gilt derselbe Renderer-Snapshot für alle drei Modi (horizontal, + // vertikal, kompakt) — sie unterscheiden sich nur in der Container-Klasse. + }); +});