diff --git a/public/neu/module.css b/public/neu/module.css index ecca2b4..5510731 100644 --- a/public/neu/module.css +++ b/public/neu/module.css @@ -1,2 +1,85 @@ -/* Kundenansicht neu: Stile der gemeinsamen Funktionsmodule (Terminplaner, Webshop, Buchungskalender, Öffnungszeiten, News, Analyse) unter .kn. - Besitzer: P4. Hier steht bis dahin nichts. */ +/* Kundenansicht neu: Stile der gemeinsamen Funktionsmodule (Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, News, Analyse) unter .kn. + Besitzer: P4. Die Module sind dieselben Dateien wie in der alten Ansicht und bringen ihre Klassen mit (tm-, sh-, bk-, zt-, fn-, nw-, an-, hs-); + diese Datei ersetzt termine.css, shop.css, buchungen.css, zeiten.css, funktionen.css (News) und analyse.css vollständig. Jede Regel steht unter .kn. + Aussehen wie das Ticketsystem: Abschnitte mit Haarlinien statt Karten, Pillen für Zustände, Ring-Schatten, ein Akzent (#111). Der Prüftest + test/kundenansichtNeuModulCss.test.js sichert Scope, Palette, Verbote und die Abdeckung aller Klassen der alten Dateien. + Abschnitte: 0 gemeinsame Bausteine, 1 Terminplaner, 2 Webshop und Buchungskalender, 3 Öffnungszeiten live, 4 News, 5 Analyse. */ + +/* ============================================================ 0 Gemeinsame Bausteine === */ + +/* Abschnitt statt Karte: ohne Fläche, Rahmen und Radius; ab dem zweiten Abschnitt eine Haarlinie darüber */ +.kn .kb-panel { max-width: none; margin: 0; padding: 20px 0; } +.kn .kb-panel + .kb-panel { border-top: 1px solid var(--kn-line); } +.kn .kb-panel h3 { margin: 0 0 6px; font-size: 15px; line-height: 22px; font-weight: 600; } +.kn .kb-panel h4 { margin: 16px 0 4px; font-size: 14px; line-height: 20px; font-weight: 600; } +.kn .kb-panel p { margin: 0 0 10px; } +.kn .kb-panel p:last-child { margin-bottom: 0; } +.kn .kb-empty { margin: 0; padding: 24px 0; color: var(--kn-text-2); } + +/* Link im Text (auch als Knopf mit data-view) */ +.kn .kb-link { display: inline; padding: 0; border: 0; background: none; font: inherit; color: var(--kn-text); text-decoration: underline; text-decoration-color: var(--kn-line-strong); text-underline-offset: 3px; cursor: pointer; } +.kn .kb-link:hover { text-decoration-color: currentColor; } + +/* Aufzählung mit Häkchen */ +.kn .kb-checks { margin: 12px 0; padding: 0; list-style: none; } +.kn .kb-checks li { position: relative; padding: 4px 0 4px 24px; } +.kn .kb-checks li::before { content: '✓'; position: absolute; left: 0; color: var(--kn-gruen-fg); font-weight: 600; } + +.kn .abo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 16px; } +.kn .muted { color: var(--kn-text-2); } +.kn .error { margin: 0 0 12px; font-size: 13px; color: var(--kn-danger); } + +/* Knöpfe: Hauptaktion schwarz, sonst weiß mit Ring-Schatten */ +.kn .kb-btn-primary, .kn .abo-btn, .kn .kb-btn, .kn .kb-btn-outline { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; box-sizing: border-box; min-height: 36px; padding: 0 14px; + border: 0; border-radius: var(--kn-radius); font: inherit; font-weight: 500; line-height: 20px; text-align: center; text-decoration: none; cursor: pointer; + transition: background-color .12s, box-shadow .12s; +} +.kn .kb-btn-primary, .kn .abo-btn { background: var(--kn-accent); color: #fff; box-shadow: var(--kn-shadow-btn); } +.kn .kb-btn-primary:hover, .kn .abo-btn:hover { background: var(--kn-accent-hover); } +.kn .kb-btn, .kn .kb-btn-outline { background: var(--kn-bg); color: var(--kn-text); box-shadow: var(--kn-ring); } +.kn .kb-btn:hover, .kn .kb-btn-outline:hover { background: var(--kn-bg-hover); } +.kn .kb-btn-primary:disabled, .kn .abo-btn:disabled, .kn .kb-btn:disabled, .kn .kb-btn-outline:disabled { opacity: .4; cursor: not-allowed; } + +/* Felder der Module: weiß mit Ring-Schatten, 6 px Radius, 36 px hoch */ +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) :is(input, select, textarea):not([type=checkbox]):not([type=radio]):not([type=color]):not([type=file]), +.kn .tm-link-feld, .kn .tm-code-feld, .kn .zt-google-text, .kn .bk-link input, .kn input[readonly] { + box-sizing: border-box; min-width: 0; min-height: 36px; padding: 6px 10px; border: 0; border-radius: var(--kn-radius); background: var(--kn-bg); color: var(--kn-text); + box-shadow: var(--kn-ring); font: inherit; line-height: 20px; outline: none; transition: box-shadow .12s; +} +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) textarea, +.kn .tm-code-feld, .kn .zt-google-text { min-height: 80px; resize: vertical; } +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) select, +.kn .zt-feiertag select { + padding-right: 28px; appearance: none; -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235e6678' stroke-width='1.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; +} +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) :is(input, select, textarea):focus, +.kn .tm-link-feld:focus, .kn .tm-code-feld:focus, .kn .zt-google-text:focus { box-shadow: 0 0 0 1px var(--kn-accent), 0 0 0 4px var(--kn-accent-ring); } +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit) [aria-invalid='true'] { box-shadow: 0 0 0 1px var(--kn-danger), 0 0 0 4px var(--kn-danger-ring); } +.kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit) input::placeholder { color: var(--kn-text-3); } +.kn :is(.tm-feld, .sh-feld, .nw-field) input[type=color] { min-height: 36px; padding: 2px; cursor: pointer; } +.kn input[type=checkbox], .kn input[type=radio] { accent-color: var(--kn-accent); } + +/* Fokus schwarz, überall gleich */ +.kn button:focus-visible, .kn input:focus-visible, .kn select:focus-visible, .kn textarea:focus-visible, .kn summary:focus-visible, .kn a:focus-visible, .kn [tabindex]:focus-visible { + outline: 2px solid #111; outline-offset: 2px; +} + +/* Screenreader-Text */ +.kn .tm-sr, .kn .sh-sr, .kn .an-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +/* Handy: Tippflächen 44 px, Felder 16 px gegen den Zoom von iOS */ +@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) { + .kn .kb-btn-primary, .kn .abo-btn, .kn .kb-btn, .kn .kb-btn-outline { min-height: 44px; padding: 0 16px; } + .kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) :is(input, select, textarea):not([type=checkbox]):not([type=radio]):not([type=color]):not([type=file]), + .kn .tm-link-feld, .kn .tm-code-feld, .kn .zt-google-text, .kn .bk-link input, .kn input[readonly] { min-height: 44px; font-size: 16px; } + .kn .kb-link { display: inline-flex; align-items: center; min-height: 44px; } +} + +/* Reduzierte Bewegung: keine Übergänge */ +@media (prefers-reduced-motion: reduce) { + .kn .kb-btn-primary, .kn .abo-btn, .kn .kb-btn, .kn .kb-btn-outline { transition: none; } + .kn :is(.tm-feld, .sh-feld, .nw-field, .zt-tag, .zt-zeit, .zt-regler, .tm-sprung, .fn-project, .bk-posten, .bk-staffel, .bk-filter, .bk-vorschaufelder, .bk-importform, .bk-personpreis, .an-heat-bar) :is(input, select, textarea) { transition: none; } +} diff --git a/test/kundenansichtNeuModulCss.test.js b/test/kundenansichtNeuModulCss.test.js new file mode 100644 index 0000000..3429251 --- /dev/null +++ b/test/kundenansichtNeuModulCss.test.js @@ -0,0 +1,149 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' + +// Prüftest für public/neu/module.css (P4 Tasks 9 bis 13): jede Regel steht unter .kn, nur Farben der Palette, keine Verläufe, kein Glas, +// kein Dunkelmodus, keine Animation; für jedes Präfix aus FERTIG ist jede Klasse der alten CSS-Datei unter .kn gestaltet. + +const PUBLIC = new URL('../public/', import.meta.url) +const lies = (datei) => readFileSync(new URL(datei, PUBLIC), 'utf8') +const neuDateien = () => readdirSync(new URL('neu/', PUBLIC)).filter((f) => f.endsWith('.css')).sort() +const neuCss = () => neuDateien().map((f) => lies(`neu/${f}`)).join('\n') +const MODUL = () => lies('neu/module.css') +const jsModule = () => [ + ...readdirSync(PUBLIC).filter((f) => f.endsWith('.js')).map((f) => lies(f)), + ...readdirSync(new URL('neu/', PUBLIC)).filter((f) => f.endsWith('.js')).map((f) => lies(`neu/${f}`)), +].join('\n') + +const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '') + +/** [{ selektor, koerper }] – verschachtelte @media/@supports flach, @keyframes und @font-face ohne Schritte. */ +function regeln(css) { + const text = ohneKommentare(css) + const lies2 = (quelle) => { + const aus = [] + let i = 0 + while (i < quelle.length) { + const auf = quelle.indexOf('{', i) + if (auf === -1) break + const kopf = quelle.slice(i, auf).trim() + let tiefe = 1 + let j = auf + 1 + while (j < quelle.length && tiefe > 0) { + if (quelle[j] === '{') tiefe += 1 + else if (quelle[j] === '}') tiefe -= 1 + j += 1 + } + const innen = quelle.slice(auf + 1, j - 1) + if (/^@(media|supports|container|layer)\b/.test(kopf)) aus.push(...lies2(innen)) + else if (!kopf.startsWith('@')) aus.push({ selektor: kopf, koerper: innen }) + i = j + } + return aus + } + return lies2(text) +} + +/** Selektoren einer Regelliste; Kommalisten aufgespalten, Klammer-bewusst (:is(), :not(), [attr="a,b"]). */ +function selektoren(css) { + const aus = [] + for (const { selektor } of regeln(css)) { + let tiefe = 0 + let quelle = '' + let anfuehrung = null + for (const z of selektor) { + if (anfuehrung) { quelle += z; if (z === anfuehrung) anfuehrung = null; continue } + if (z === '"' || z === "'") { anfuehrung = z; quelle += z; continue } + if (z === '(' || z === '[') tiefe += 1 + if (z === ')' || z === ']') tiefe -= 1 + if (z === ',' && tiefe === 0) { aus.push(quelle.trim()); quelle = ''; continue } + quelle += z + } + if (quelle.trim()) aus.push(quelle.trim()) + } + return aus +} + +/** Klassen mit Präfix aus den Selektoren (nur echte Klassen: ein Punkt direkt davor, nie `.kn-fn-…` für `fn`). */ +function klassen(css, praefix) { + const treffer = new Set() + const muster = new RegExp(`\\.(${praefix}-[A-Za-z0-9_-]+)`, 'g') + for (const s of selektoren(css)) for (const m of s.matchAll(muster)) treffer.add(m[1]) + return treffer +} + +const FERTIG = [] // Präfixe mit geprüfter Abdeckung; Task 10 bis 13 ergänzen sie +const UNGENUTZT = new Set([]) // alte Klassen, die in keiner JS-Datei vorkommen; der Test prüft das +const ALT = { tm: 'termine.css', sh: 'shop.css', bk: 'buchungen.css', zt: 'zeiten.css', an: 'analyse.css', hs: 'analyse.css', nw: 'funktionen.css' } +const FN_NEWS = ['fn-project', 'fn-booking', 'fn-price', 'fn-estimate', 'fn-desc-link', 'fn-consent', 'fn-thanks', 'fn-status', 'fn-facts', 'fn-pill', 'fn-pill-setup'] +const ALT_KLASSEN = ['kb-panel', 'kb-btn', 'kb-btn-primary', 'kb-btn-outline', 'kb-empty', 'kb-link', 'kb-checks', 'abo-actions', 'abo-btn', 'hidden', 'muted', 'error'] +const PALETTE = new Set(['#fff', '#ffffff', '#1a1f36', '#5e6678', '#8a93a3', '#ebeef1', '#d8dde3', '#f6f8fa', '#f8f9fb', '#111', '#111111', '#414552', '#e0f2fe', '#075985', '#fcedb9', '#a82c00', '#efeafe', '#5b21b6', '#d7f7c2', '#006908', '#ffe7f2', '#b3093c', '#c0123c']) +const ZETTEL = /#fff6d6|#ddeffb|#ffe1d9/i +const RING = new Set(['60,66,87,.16', '60,66,87,0.16', '0,0,0,.06', '0,0,0,0.06']) +const HANDY = '@media (max-width: 880px), (pointer: coarse) and (max-height: 500px)' + +test('Hilfen: Regeln, Selektoren und Klassen werden richtig gelesen', () => { + const css = '/* x */ .a, .b:not(.c, .d) { color: #fff } @media (x) { .e { margin: 0 } } @keyframes k { from { opacity: 0 } } .f[data-x="a,b"] { top: 0 }' + assert.deepEqual(regeln(css).map((r) => r.selektor), ['.a, .b:not(.c, .d)', '.e', '.f[data-x="a,b"]']) + assert.deepEqual(selektoren(css), ['.a', '.b:not(.c, .d)', '.e', '.f[data-x="a,b"]']) + assert.deepEqual([...klassen('.kn .tm-a, .kn .kn-tm-b, .tm-c-d:hover { }', 'tm')].sort(), ['tm-a', 'tm-c-d']) + assert.deepEqual([...klassen('.kn-fn-x, .fn-y { }', 'fn')], ['fn-y']) +}) + +test('module.css: jede Regel steht unter .kn und hängt nicht an der alten Seite', () => { + const alle = selektoren(MODUL()) + assert.ok(alle.length > 0, 'die Datei hat Regeln') + for (const s of alle) assert.match(s, /^\.kn(\s|[.:#[>~+]|$)/, s) + assert.doesNotMatch(MODUL(), /#view-|--kb-|\.kb-page/) +}) + +test('module.css: Verbote (Lila, Teal, Verlauf, Glas, Dunkel, Gewicht 800, Animation)', () => { + assert.doesNotMatch(MODUL(), /#9d3dfe|#d4b4ff|157,\s*61,\s*254|#2dd4bf|#5eead4|gradient|backdrop-filter|color-scheme:\s*dark|prefers-color-scheme|font-weight:\s*(800|900)|@keyframes/) +}) + +test('module.css: Farben nur aus der Palette der Übersicht; Zettelfarben nur in .nw-paper-*; rgba nur der Ring-Schatten', () => { + for (const { selektor, koerper } of regeln(MODUL())) { + for (const [, hex] of koerper.matchAll(/(#[0-9a-fA-F]{3,8})\b/g)) assert.ok(PALETTE.has(hex.toLowerCase()) || (ZETTEL.test(hex) && /\.nw-paper-/.test(selektor)), `${selektor}: ${hex}`) + for (const [, wert] of koerper.matchAll(/rgba?\(([^)]*)\)/g)) assert.ok(RING.has(wert.replace(/\s/g, '')), `${selektor}: rgba(${wert})`) + } +}) + +test('module.css: Fokus schwarz, Handy-Regeln wörtlich mit 44px, reduzierte Bewegung', () => { + const css = MODUL() + assert.match(css, /:focus-visible[^{]*\{[^}]*outline:\s*2px solid (#111|var\(--kn-[a-z-]+\))/) + assert.ok(css.includes(HANDY) && /min-height:\s*44px/.test(css.slice(css.indexOf(HANDY)))) + assert.match(css, /prefers-reduced-motion:\s*reduce/) +}) + +test('module.css: Animationen höchstens als transition bis 150 ms', () => { + for (const { selektor, koerper } of regeln(MODUL())) { + assert.doesNotMatch(koerper, /\banimation(-name)?\s*:/, selektor) + for (const [, wert] of koerper.matchAll(/transition(?:-duration)?:\s*([^;]*)/g)) { + for (const [, zahl, einheit] of wert.matchAll(/(\d*\.?\d+)(ms|s)\b/g)) assert.ok((einheit === 's' ? Number(zahl) * 1000 : Number(zahl)) <= 150, `${selektor}: ${wert}`) + } + } +}) + +test('module.css: jedes var(--kn-…) ist in public/neu/*.css definiert', () => { + const definiert = new Set([...neuCss().matchAll(/(--kn-[a-z0-9-]+)\s*:/g)].map((m) => m[1])) + for (const [, name] of MODUL().matchAll(/var\((--kn-[a-z0-9-]+)/g)) assert.ok(definiert.has(name), name) +}) + +test('Alt-Klassen der Module sind unter .kn gestaltet (in einer Datei von public/neu)', () => { + const sel = selektoren(neuCss()).filter((s) => s.startsWith('.kn')) + for (const k of ALT_KLASSEN) assert.ok(sel.some((s) => new RegExp(`\\.${k}(?![a-z0-9-])`).test(s)), k) +}) + +test('UNGENUTZT enthält nur Klassen, die kein JS nennt', () => { + const js = jsModule() + for (const k of UNGENUTZT) assert.ok(!js.includes(k), k) +}) + +for (const praefix of FERTIG) { + test(`Abdeckung ${praefix}: jede Klasse der alten CSS-Datei ist unter .kn gestaltet`, () => { + const alt = praefix === 'fn' ? FN_NEWS : [...klassen(lies(ALT[praefix]), praefix)] + const neu = klassen(MODUL(), praefix) + assert.ok(alt.length > 0, 'es gibt alte Klassen') + assert.deepEqual(alt.filter((k) => !neu.has(k) && !UNGENUTZT.has(k)).sort(), []) + }) +}