From 9ca7900213e4c1ca96034889bcfbe5aea920a1ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 18:14:08 +0000 Subject: [PATCH] =?UTF-8?q?Funktionen=20neu:=20Aussehen=20(wei=C3=9F,=20Pi?= =?UTF-8?q?llen,=2044-px-Zeilen)=20mit=20Quelltext-Pr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- public/neu/funktionen.css | 88 ++++++++++++++++++++++++++++++- test/knFunktionenStatik.test.js | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 test/knFunktionenStatik.test.js diff --git a/public/neu/funktionen.css b/public/neu/funktionen.css index 83d0a41..6353552 100644 --- a/public/neu/funktionen.css +++ b/public/neu/funktionen.css @@ -1,2 +1,86 @@ -/* Kundenansicht neu: Stile der Ansicht Funktionen (Meine, Entdecken) und der Einschalt-Ansicht. - Besitzer: P3. Hier steht bis dahin nichts. */ +/* Kundenansicht neu (P3): Aussehen der Ansicht Funktionen (Meine, Entdecken) und der Bestätigungsseite zum Einschalten. + Besitzer: P3. Weißer Stil: Haarlinien statt Karten, Radien 6 und 12 px (Pillen 999 px), kein Schatten außer dem Ring, kein Gewicht 800, + keine Animation. Farben als var(--kn-…, Wert) mit den Werten der Übersicht als Fallback. Tap-Ziele sind 44 px hoch. */ + +/* ------------------------------------------------------------------------------------------ Pillen --- */ +.kn-fn-pille { + display: inline-flex; align-items: center; flex: none; height: 20px; padding: 0 8px; border-radius: 999px; + font-size: 12px; line-height: 20px; font-weight: 500; white-space: nowrap; font-variant-numeric: tabular-nums; + background: var(--kn-grau-bg, #ebeef1); color: var(--kn-grau-fg, #414552); +} +.kn-fn-pille--grau { background: var(--kn-grau-bg, #ebeef1); color: var(--kn-grau-fg, #414552); } +.kn-fn-pille--blau { background: var(--kn-blau-bg, #e0f2fe); color: var(--kn-blau-fg, #075985); } +.kn-fn-pille--orange { background: var(--kn-orange-bg, #fcedb9); color: var(--kn-orange-fg, #a82c00); } +.kn-fn-pille--violett { background: var(--kn-violett-bg, #efeafe); color: var(--kn-violett-fg, #5b21b6); } +.kn-fn-pille--gruen { background: var(--kn-gruen-bg, #d7f7c2); color: var(--kn-gruen-fg, #006908); } +.kn-fn-pille--rot { background: var(--kn-rot-bg, #ffe7f2); color: var(--kn-rot-fg, #b3093c); } + +/* ------------------------------------------------------------------------------------------ Knöpfe --- */ +.kn-fn-knopf { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 16px; + border: 0; border-radius: 6px; font-weight: 500; text-align: center; text-decoration: none; cursor: pointer; + background: var(--kn-accent, #111); color: #fff; +} +.kn-fn-knopf:hover { background: var(--kn-accent-hover, #2b2b2b); } +.kn-fn-knopf:disabled, .kn-fn-knopf:disabled:hover { + background: var(--kn-bg-subtle, #f8f9fb); color: var(--kn-text-3, #8a93a3); cursor: not-allowed; opacity: 1; + box-shadow: var(--kn-ring, 0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)); +} +.kn-fn-knopf--zweit { + background: var(--kn-bg, #fff); color: var(--kn-text, #1a1f36); + box-shadow: var(--kn-ring, 0 0 0 1px rgba(60,66,87,.16), 0 1px 1px rgba(0,0,0,.06)); +} +.kn-fn-knopf--zweit:hover { background: var(--kn-bg-hover, #f6f8fa); } + +/* ------------------------------------------------------------------------------------------ Meldungen --- */ +.kn-fn-meldung { margin-top: 8px; font-size: 13px; line-height: 20px; color: var(--kn-danger, #c0123c); overflow-wrap: anywhere; } +.kn-fn-meldung:empty { display: none; } +.kn-fn-hinweis, .kn-fn-info { margin: 8px 0 0; font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; } +.kn-fn-info:first-child { margin-top: 0; } + +/* ------------------------------------------------------------------------------------------ Meine --- */ +.kn-fn-liste { list-style: none; margin: 0; padding: 0; } +.kn-fn-zeile { border-bottom: 1px solid var(--kn-line, #ebeef1); } +.kn-fn-zeile:first-child { border-top: 1px solid var(--kn-line, #ebeef1); } +.kn-fn-zeile-knopf { + display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; min-height: 44px; padding: 12px 0; + text-align: left; background: none; border: 0; cursor: pointer; color: var(--kn-text, #1a1f36); +} +.kn-fn-zeile-knopf:hover .kn-fn-name { color: var(--kn-accent, #111); text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; } +.kn-fn-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.kn-fn-name { font-weight: 600; color: var(--kn-text, #1a1f36); overflow-wrap: anywhere; } +.kn-fn-zeile2 { font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; } + +/* ------------------------------------------------------------------------------------------ Entdecken --- */ +.kn-fn-eintrag { display: flex; flex-direction: column; gap: 16px; padding: 24px 0; border-top: 1px solid var(--kn-line, #ebeef1); } +.kn-fn-eintrag:last-child { border-bottom: 1px solid var(--kn-line, #ebeef1); } +.kn-fn-bild { display: block; width: 100%; height: auto; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 12px; background: var(--kn-bg-subtle, #f8f9fb); } +.kn-fn-eintrag-text { min-width: 0; } +.kn-fn-eintrag-text h3 { font-size: 16px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; } +.kn-fn-beschreibung { margin-top: 4px; color: var(--kn-text-2, #5e6678); overflow-wrap: anywhere; } +.kn-fn-enthalten { margin: 8px 0 12px; font-size: 13px; line-height: 20px; color: var(--kn-text-2, #5e6678); } +.kn-fn-wahl { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } + +/* ------------------------------------------------------------------------------------------ Bestätigungsseite --- */ +.kn-fn-einschalten { max-width: 560px; } +.kn-fn-einschalten h2 { margin-bottom: 16px; font-size: 20px; line-height: 28px; font-weight: 600; overflow-wrap: anywhere; } +.kn-fn-fakten { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0 0 16px; } +.kn-fn-fakten > div { display: contents; } +.kn-fn-fakten dt { color: var(--kn-text-2, #5e6678); } +.kn-fn-fakten dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.kn-fn-konto, .kn-fn-recht { margin: 0 0 12px; overflow-wrap: anywhere; } +.kn-fn-konto { color: var(--kn-text-2, #5e6678); } +.kn-fn-recht a { text-decoration: underline; text-decoration-color: var(--kn-line-strong, #d8dde3); text-underline-offset: 3px; } +.kn-fn-recht a:hover { text-decoration-color: currentColor; } +.kn-fn-einschalten .kn-fn-hinweis { margin: 0 0 12px; } +.kn-fn-haken { display: flex; align-items: flex-start; gap: 12px; min-height: 44px; margin: 12px 0 0; cursor: pointer; } +.kn-fn-haken input { flex: none; width: 20px; height: 20px; margin: 0; accent-color: var(--kn-accent, #111); } +.kn-fn-haken span { min-width: 0; overflow-wrap: anywhere; } +.kn-fn-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 16px; } + +/* ------------------------------------------------------------------------------------------ Breite Fenster --- */ +@media (min-width: 881px) { + .kn-fn-eintrag { flex-direction: row; align-items: flex-start; gap: 24px; } + .kn-fn-bild { flex: 0 0 320px; width: 320px; } + .kn-fn-eintrag-text { flex: 1 1 0; } +} diff --git a/test/knFunktionenStatik.test.js b/test/knFunktionenStatik.test.js new file mode 100644 index 0000000..eaccbd4 --- /dev/null +++ b/test/knFunktionenStatik.test.js @@ -0,0 +1,93 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') +const ohneKommentare = (t) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') +const JS = ['public/neu/ansicht-funktionen.js', 'public/neu/funktion-einschalten.js'] +const css = lies('public/neu/funktionen.css') + +test('funktionen.css: Pillen-Farben aus den Global Constraints', () => { + const PILLEN = { grau: ['#ebeef1', '#414552'], blau: ['#e0f2fe', '#075985'], orange: ['#fcedb9', '#a82c00'], violett: ['#efeafe', '#5b21b6'], gruen: ['#d7f7c2', '#006908'], rot: ['#ffe7f2', '#b3093c'] } + for (const [ton, [flaeche, schrift]] of Object.entries(PILLEN)) { + const regel = (css.match(new RegExp(`\\.kn-fn-pille--${ton}\\s*\\{([^}]*)\\}`)) ?? [, ''])[1].toLowerCase() + assert.ok(regel.includes(flaeche) && regel.includes(schrift), ton) + } +}) + +test('funktionen.css: verbotene Mittel fehlen, Radien nur 6 und 12 px, Tap-Ziel 44 px', () => { + assert.doesNotMatch(css, /gradient|backdrop-filter|glow|prefers-color-scheme\s*:\s*dark|#9d3dfe|rgba\(\s*157/i) + assert.doesNotMatch(css, /font-weight\s*:\s*(800|900)/) + for (const m of css.matchAll(/border-radius\s*:\s*([^;}]+)/g)) assert.match(m[1].trim(), /^(0|6px|12px|999px|50%)$/, m[0]) + assert.match((css.match(/\.kn-fn-zeile-knopf\s*\{([^}]*)\}/) ?? [, ''])[1], /min-height\s*:\s*44px/) + assert.match(css, /@media\s*\(min-width:\s*881px\)/) +}) + +test('funktionen.css: Knöpfe 44 px hoch, Primärknopf #111 mit weißer Schrift, gesperrt grau; keine Schatten außer dem Ring; keine Animation', () => { + const regel = (selektor) => (css.match(new RegExp(`${selektor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*)\\}`)) ?? [, ''])[1] + const knopf = regel('.kn-fn-knopf') + assert.match(knopf, /min-height\s*:\s*44px/) + assert.match(knopf, /#111/i) + assert.match(knopf, /#fff/i) + const gesperrt = (css.match(/\.kn-fn-knopf:disabled[^{]*\{([^}]*)\}/) ?? [, ''])[1] + assert.match(gesperrt, /#8a93a3/i) + assert.match(gesperrt, /#f8f9fb/i) + assert.match(regel('.kn-fn-knopf--zweit'), /0 0 0 1px rgba\(60,\s*66,\s*87,\s*\.16\)/) + assert.match(regel('.kn-fn-meldung'), /#c0123c/i) + for (const m of css.matchAll(/box-shadow\s*:\s*([^;}]+)/g)) assert.match(m[1], /^(none|var\(--kn-ring,\s*0 0 0 1px rgba\(60,\s*66,\s*87,\s*\.16\),\s*0 1px 1px rgba\(0,\s*0,\s*0,\s*\.06\)\))$/, m[0]) + assert.doesNotMatch(css, /@keyframes|animation\s*:|transition\s*:/) +}) + +test('funktionen.css: das Bild ist 16:10 mit Radius 12 px, Entdecken hat keinen Rahmen', () => { + const bild = (css.match(/\.kn-fn-bild\s*\{([^}]*)\}/) ?? [, ''])[1] + assert.match(bild, /aspect-ratio\s*:\s*16\s*\/\s*10/) + assert.match(bild, /border-radius\s*:\s*12px/) + const eintrag = (css.match(/\.kn-fn-eintrag\s*\{([^}]*)\}/) ?? [, ''])[1] + assert.doesNotMatch(eintrag, /\bborder\s*:|box-shadow|border-radius/) +}) + +test('jede kn-fn-Klasse der JS-Dateien ist im CSS gestaltet', () => { + for (const datei of JS) for (const k of new Set(ohneKommentare(lies(datei)).match(/kn-fn-[a-z0-9-]+/g) ?? [])) assert.ok(css.includes(`.${k}`), `${datei}: .${k} fehlt im CSS`) +}) + +// entfernt var(…) samt verschachtelter Klammern: danach darf kein Farbliteral mehr übrig sein (Ausnahme: weiße Schrift auf dem Primärknopf) +function ohneVar(text) { + let aus = '' + for (let i = 0; i < text.length; i += 1) { + if (!text.startsWith('var(', i)) { aus += text[i]; continue } + let tiefe = 0 + let j = i + 3 + for (; j < text.length; j += 1) { + if (text[j] === '(') tiefe += 1 + else if (text[j] === ')') { tiefe -= 1; if (tiefe === 0) break } + } + i = j + } + return aus +} + +test('funktionen.css: jedes benutzte Token ist in tokens.css definiert, Farbliterale nur als Fallback von var()', () => { + const tokens = lies('public/neu/tokens.css') + for (const m of css.matchAll(/var\((--kn-[\w-]+)/g)) assert.ok(tokens.includes(`${m[1]}:`), m[1]) + assert.doesNotMatch(ohneVar(css.replace(/\/\*[\s\S]*?\*\//g, '')), /#(?!fff\b)[0-9a-f]{3,8}\b|rgba?\(/i) +}) + +for (const datei of JS) { + test(`${datei}: kein innerHTML, nur relative Importe, keine Preise, keine Sie-Anrede`, () => { + const code = ohneKommentare(lies(datei)) + assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/) + for (const m of code.matchAll(/(?:from|import\()\s*'([^']+)'/g)) assert.match(m[1], /^\.{1,2}\//, m[1]) + assert.doesNotMatch(code, /€|\bEuro\b/) + assert.doesNotMatch(code, /\b(Sie|Ihre[rnms]?|Ihnen|Ihr)\b/) + }) +} + +test('beide Module laden unter Node ohne DOM (kein Zugriff beim Import)', async () => { + assert.equal(typeof globalThis.document, 'undefined') + await import('../public/neu/ansicht-funktionen.js') + await import('../public/neu/funktion-einschalten.js') +}) + +test('Module der Funktionen: keine Hash-Namen außer den vorhandenen, kein Hash mit Großbuchstaben', () => { + for (const datei of JS) for (const m of ohneKommentare(lies(datei)).matchAll(/'#([A-Za-z-]+)/g)) assert.match(m[1], /^[a-z-]+$/, `${datei}: #${m[1]}`) +})