diff --git a/README.md b/README.md index 57dc9cd..6668021 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`. +## Buttons im Menü (ab 1.26.0) + +Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110. + ## Bildecken unter Design (ab 1.21.0) Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7b04e2..7f63fab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,24 @@ Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 110 – Menüeinträge als Button (07.10.2026) + +Umut: „Buttons im Menü sind in KantSites aktuell nicht möglich … kannst du das im Menüeditor einbauen, dass man individuell Buttons nach dem gleichen Prinzip wie im Editor einbauen kann? Mit Icon, Farbe usw.“ Typisch ist „Angebot anfragen“ oder „Anrufen“ rechts im Kopf, auf dem Telefon griffbereit neben dem Menü-Knopf. + +**Datenvertrag (keine Schemaänderung der Seiten).** Ein Menüeintrag trägt optional `button` = `{id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}` im JSON-Baum des Menüs (ADR 012). Form, Darstellung und Größe stammen aus `ButtonDesign::OPTIONS`, Bewegung und „nur Symbol“ wie am Button-Baustein (ADR 109), das Symbol ist das vorhandene `icon` des Eintrags. Fehlende Werte heißen Website-Standard unter Design → Buttons. Farben sind `#hex` oder `@palette` (`StyleSchema::COLOR`). `mobile`: `menu` (im aufgeklappten Menü, volle Breite), `bar` (neben dem Menü-Knopf) oder `bar-icon` (dort nur als Symbol – verlangt ein Symbol). Ein Button hat keine Unterpunkte. `MenuButton::validate` lehnt alles andere ab, also auch CSS in Farbwerten. + +**Darstellung.** `templates/menu-button.html.twig` erzeugt dieselben Klassen wie der Button-Baustein (`cms-button`, `b-desktop-*`, `icon-start`, `icon-motion-*`, `icon-only`), damit gelten `buttons.css`, Website-Standards, Hover und „Bewegung reduzieren“ unverändert. Die Menüregeln für Links (Polster, Unterstreichung, Großschrift im aufgeklappten Kopf, `margin-top` von `.button`) gelten für Menü-Buttons nicht. + +**Eigene Farben ohne Inline-Stil.** Die CSP erlaubt keine `style`-Attribute. Jeder Button bekommt deshalb eine feste Kennung (`m` + 10 Hexzeichen, bleibt beim Speichern erhalten), und `MenuRepository::buttonCss` schreibt `.menu-button-{--kb-fill…}` samt der abgeleiteten Werte der Darstellung in das Seiten-Stylesheet – auf der Website, in der Editor- und in der Bereichsvorschau. + +**Telefon.** `bar`/`bar-icon` werden im Menübaustein ein zweites Mal als `ul.menu-actions` vor dem Menü-Knopf ausgegeben; sichtbar nur, solange das Menü zusammengeklappt ist (Tablet bzw. Telefon je nach Einstellung), im aufgeklappten Menü ist das Original dann ausgeblendet (`menu-keep-visible`) – nichts steht doppelt. Neben dem Knopf sind Buttons kompakter und brechen nicht um; Leiste und Knopf bleiben in einer Zeile. Erster Versuch nur mit `bar`: Ein zweizeiliger Text-Button schob den Menü-Knopf auf dem 390-px-Telefon in eine neue Zeile – daher die Variante nur mit Symbol. + +**Menüverwaltung.** Je Eintrag „Als Button darstellen“; erst dann erscheinen Form, Darstellung, Größe, fünf Farbfelder mit Palette (wie im Editor), Bewegung, „Nur das Symbol zeigen“ und die Telefon-Anzeige, dazu eine Vorschau normal und beim Überfahren. Das Symbol wird wie im Editor im Fenster gewählt (ADR 109). Ohne JavaScript bleiben alle Felder normale Formularfelder. + +**Geprüft:** PHPUnit vollständig, darunter `testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger`, neun Ablehnungsfälle und `testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId`. `npm test`. `npm run test:menu-buttons` im Browser auf isolierter Pilotkopie: Verwaltung mit Fenster, Palette, Vorschau und axe; Speichern/Neuladen; Website bei 1440 und 390 px (Symbol und Menü-Knopf auf einer Linie, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite mit Rahmen, axe, kein waagerechtes Scrollen). Screenshots unter `reports/menu-buttons/`. + +**Bekannt:** Ist die Spalte mit dem Menü schmal (Pilot: zwei gleich breite Spalten), bricht ein Button wie jeder Menüeintrag in die nächste Zeile; abhelfen über die Spaltenbreite oder Größe „Klein“. Menüs mit Buttons brauchen einen Kern ab 1.26.0; ältere Kerne lehnen den unbekannten Schlüssel ab. + ## ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026) Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS. diff --git a/docs/STATUS.md b/docs/STATUS.md index 4bb89b1..3e2c158 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## Fassung 1.26.0: Menüeinträge als Button (07.10.2026) + +**Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf. + +**Neu (ADR 110):** In der Menüverwaltung je Eintrag „Als Button darstellen“ mit Form, Darstellung, Größe, Fläche/Schrift (auch beim Überfahren), Symbolfarbe, Symbol aus dem Fenster, Bewegung, „nur Symbol“ und Anzeige auf dem Telefon (im Menü / neben dem Menü-Knopf / dort nur als Symbol). Vorschau normal und beim Überfahren. + +**Geprüft:** PHPUnit vollständig, `npm test`, `npm run test:menu-buttons` (Browser, axe, 1440 und 390 px, je ein Button neben dem Menü-Knopf und im aufgeklappten Menü), `lint:twig`, php-cs-fixer. + +**Bekannt:** In schmalen Kopfspalten bricht der Button in die nächste Zeile (wie jeder Menüeintrag). Kern ab 1.26.0 nötig. + ## Fassung 1.25.0: 98 Symbole, Auswahlfenster, Symbol-Gestaltung unter Design (07.10.2026) **Anlass:** Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal das gefällt mir nicht. Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ diff --git a/package.json b/package.json index db53345..57788eb 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build:buttons": "node scripts/build-buttons.mjs", "test:buttons": "node scripts/button-check.mjs", "test:button-icons": "node scripts/button-icon-check.mjs", + "test:menu-buttons": "node scripts/menu-button-check.mjs", "build:icons": "node scripts/build-icons.mjs", "test:navigation-ux": "node scripts/navigation-ux-check.mjs", "test:responsive-navigation": "node scripts/responsive-navigation-check.mjs", diff --git a/public/assets/button-controls.css b/public/assets/button-controls.css index d49d39d..3df566c 100644 --- a/public/assets/button-controls.css +++ b/public/assets/button-controls.css @@ -1 +1 @@ -.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#e8f2e4;color:#1d4d37}.icon-dialog-foot{display:flex;justify-content:flex-end;gap:12px;padding:14px 22px;border-top:1px solid #dce4dc}@media(max-width:640px){.icon-dialog-body{grid-template-columns:1fr;height:auto;max-height:calc(100vh - 200px)}.icon-dialog-tabs{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid #dce4dc}.icon-dialog-tab{white-space:nowrap}} +.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#e8f2e4;color:#1d4d37}.icon-dialog-foot{display:flex;justify-content:flex-end;gap:12px;padding:14px 22px;border-top:1px solid #dce4dc}@media(max-width:640px){.icon-dialog-body{grid-template-columns:1fr;height:auto;max-height:calc(100vh - 200px)}.icon-dialog-tabs{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid #dce4dc}.icon-dialog-tab{white-space:nowrap}}.menu-button-fields{margin:14px 0 4px;padding:12px 14px 14px;border:1px solid #dce4dc;border-radius:10px;min-width:0}.menu-button-fields legend{padding:0 6px;font-weight:700}.menu-button-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 14px;margin-top:12px}.menu-button-options[hidden]{display:none}.menu-button-options>div{display:grid;align-content:start;gap:4px;min-width:0}.menu-button-options select{width:100%}.menu-button-options .check-label{grid-column:1/-1}.menu-button-options .color-control{min-width:0}.menu-button-options .color-trigger{width:100%}.menu-button-preview{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:16px 24px;padding:14px;background:#fff;border:1px solid #dce4dc;border-radius:10px}.menu-button-preview .button-state{display:grid;justify-items:start;gap:6px}.menu-button-preview .cms-button{pointer-events:none}.menu-button-preview .button-icon{display:inline-flex}.menu-icon-choice .icon-field{margin:12px 0 10px}@media(max-width:700px){.menu-button-options{grid-template-columns:1fr}} diff --git a/public/assets/icons.js b/public/assets/icons.js index 795cc32..f73823f 100644 --- a/public/assets/icons.js +++ b/public/assets/icons.js @@ -150,6 +150,8 @@ export function iconSvg(name) { svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); + // Wie auf der Website: Linien in der Textfarbe. CSS darf Größe und Strichstärke überschreiben. + for (const [name, value] of Object.entries({ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })) svg.setAttribute(name, value); const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', ICON_PATHS[name] ?? ''); svg.append(path); diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js index 971daf5..16043e7 100644 --- a/public/assets/menu-editor.js +++ b/public/assets/menu-editor.js @@ -1,5 +1,8 @@ import { setupPicker } from './page-picker.js'; import './link-field.js'; +import { renderIconField, iconSvg, ICON_LABELS } from './icons.js'; +import { createColorControl } from './color-control.js'; +import { buttonSample } from './button-design.js'; const form = document.querySelector('#menu-editor'); const list = form.querySelector('#menu-items'); @@ -8,7 +11,8 @@ const error = form.querySelector('#menu-error'); const history = []; let serial = list.children.length, pendingPage = null, busy = false; const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; }; -const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' }; +const iconNames = ICON_LABELS; +const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}'); const menuIcons = JSON.parse(form.dataset.icons || '[]'); const chrome = () => { const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = ''; @@ -40,7 +44,9 @@ function refresh() { const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag'; row.querySelector('.menu-entry-title').textContent = name; const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value; - const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true); + row.querySelector('.menu-entry-title svg')?.remove(); + const graphic = icon && iconSvg(icon); + if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); } if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic); row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`); row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; }); @@ -91,12 +97,15 @@ function createRow(data) { anchorChoice(row); } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); } const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1'; - for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) { + const icons = el('div', 'menu-icon-fields'); body.append(icons); + for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) { const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`; for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value)); select.value = data[key] || (key === 'iconPosition' ? 'before' : ''); - const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select); + const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap); } + body.append(buttonFieldset(data, id)); + enhance(row); const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group'); for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) { const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button); @@ -156,6 +165,105 @@ function linkButtons(input) { } list.querySelectorAll('input[data-key="url"]').forEach(linkButtons); +/* + * Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit + * neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard. + */ +const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' }; +function buttonFieldset(data, id) { + const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = ''; + set.append(el('legend', '', 'Button')); + const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`; + toggle.checked = data.button === '1' || data.button === true; + const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle); + const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || ''; + const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked; + const select = (key, label, choices, value) => { + const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`; + for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option)); + input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap); + }; + for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) { + const field = `button${key[0].toUpperCase()}${key.slice(1)}`; + select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || ''); + } + for (const [key, label] of Object.entries(BUTTON_COLORS)) { + const wrap = el('div'); wrap.dataset.colorField = ''; + const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard'; + const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap); + } + select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none'); + const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1'; + const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel); + select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu'); + set.append(toggleLabel, hidden, options); + return set; +} + +/* + * Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben + * mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim + * Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar. + */ +function enhance(row) { + if (row.dataset.enhanced) return; + row.dataset.enhanced = '1'; + const value = key => row.querySelector(`[data-key="${key}"]`); + const changed = input => input.dispatchEvent(new Event('change', { bubbles: true })); + // Symbol: das Auswahlfenster schreibt in die versteckte Auswahl. + const iconSelect = value('icon'); + if (iconSelect) { + const holder = iconSelect.closest('div'); holder.hidden = true; + const mount = el('div', 'menu-icon-choice'); holder.before(mount); + const draw = () => mount.replaceChildren(renderIconField({ + label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol', + onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); }, + })); + draw(); + } + const toggle = value('button'), options = row.querySelector('[data-button-options]'); + if (!toggle || !options) return; + const show = () => { options.hidden = !toggle.checked; }; + toggle.addEventListener('change', show); + for (const wrap of options.querySelectorAll('[data-color-field]')) { + const input = wrap.querySelector('input'); const caption = wrap.querySelector('label'); + input.type = 'hidden'; + const control = createColorControl({ + id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [], + onChange: raw => { input.value = raw; changed(input); preview(); }, + }); + caption.htmlFor = `${input.id}-control`; + wrap.append(control); + } + const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true'); + options.prepend(box); + function preview() { + if (!buttonSchema.buttons) return; + const pick = key => value(key)?.value || ''; + const values = { ...buttonSchema.buttons }; + for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) { + if (pick(field)) values[key] = pick(field); + } + const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon; + const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => { + const state = el('div', 'button-state'); state.append(el('span', 'field-help', title)); + const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover); + if (icon) { + const holder = el('span', 'button-icon'); holder.append(iconSvg(icon)); + const color = pick('buttonIconColor'); + if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color; + if (before) sample.prepend(holder); else sample.append(holder); + } + state.append(sample); return state; + }); + box.replaceChildren(...samples); + } + row.addEventListener('input', preview); + row.addEventListener('change', preview); + show(); preview(); +} +list.querySelectorAll('.menu-entry').forEach(enhance); + function restore(value) { form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state(); } diff --git a/public/assets/site.css b/public/assets/site.css index c623810..0135ec0 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -494,6 +494,33 @@ body.consent-open{padding-bottom:320px} box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)} .site-header .menu-burger-body .menu-list{gap:0} .site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px} +/* Menüeinträge als Button (ADR 110). Die Menüregeln für Links (Polster, Unterstreichung, Schrift + im aufgeklappten Kopf) gelten nicht für sie - sie sehen aus wie jeder andere Button. */ +.menu-list a.cms-button,.menu-actions a.cms-button{display:inline-flex;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px);text-decoration:none;margin:0} +.menu-list a.cms-button:hover,.menu-list a.cms-button[aria-current=page]{text-decoration:none} +.menu-list a.cms-button.b-desktop-appearance-link{text-decoration:underline} +.site-header .menu-burger-body .menu-list a.cms-button{padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px)} +/* Im aufgeklappten Menü volle Breite und etwas Luft zu den Links darüber. */ +.menu-burger-body .menu-item-button{align-self:stretch;margin-top:8px} +.menu-burger-body .menu-item-button>.cms-button:not(.icon-only){width:100%} +/* Neben dem Menü-Knopf stehen bleiben: nur solange das Menü zusammengeklappt ist. */ +.menu-actions{display:none;list-style:none;margin:0;padding:0;gap:8px;align-items:center;flex:0 1 auto;min-width:0} +/* Neben dem Menü-Knopf ist wenig Platz: kompakter, in einer Zeile mit dem Knopf. */ +.menu-actions a.cms-button{--b-py:8px;--b-px:14px;white-space:nowrap} +.menu-actions a.cms-button.icon-only{--b-px:8px} +nav.menu-burger>.menu-actions{display:flex} +nav.menu-burger:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px} +nav.menu-burger .menu-burger-body .menu-keep-visible{display:none} +@media (max-width:1024px),(hover:none) and (pointer:coarse){ + .menu-collapse-tablet>.menu-actions{display:flex} + .menu-collapse-tablet:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px} + .menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none} +} +@media (max-width:767px){ + .menu-collapse-mobile>.menu-actions{display:flex} + .menu-collapse-mobile:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px} + .menu-collapse-mobile .menu-burger-body .menu-keep-visible{display:none} +} /* Visibility never overwrites the block's grid/flex/display settings. */ .visibility-hidden{display:none!important} .slider-controls button[hidden]{display:none} diff --git a/scripts/menu-button-check.mjs b/scripts/menu-button-check.mjs new file mode 100644 index 0000000..7fd94e1 --- /dev/null +++ b/scripts/menu-button-check.mjs @@ -0,0 +1,135 @@ +// Prüft Menü-Buttons (ADR 110) auf einer isolierten Kopie der Pilotdaten: in der Menüverwaltung +// einen eigenen Link als Button gestalten (Form, Symbol aus dem Fenster, Palettenfarbe, Telefon- +// Anzeige), Vorschau, Speichern, Neuladen - und die öffentliche Seite auf Desktop und Telefon. +// Screenshots unter reports/menu-buttons/. +import { chromium } from 'playwright'; +import AxeBuilder from '@axe-core/playwright'; +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import net from 'node:net'; + +const php = process.env.PHP_BINARY || 'php'; +const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); +const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; +execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env }); +const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); +const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); +const report = 'reports/menu-buttons'; await mkdir(report, { recursive: true }); +let browser; +const checks = [], errors = []; + +try { + for (let attempt = 0; attempt < 60; attempt++) { + try { if ((await fetch(origin)).ok) break; } catch {} + if (attempt === 59) throw new Error('Pilot test server did not start'); + await new Promise(resolve => setTimeout(resolve, 200)); + } + browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message)); + await page.goto(origin + '/admin/login'); + await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin'); + + await page.goto(origin + '/admin/menus'); + await page.locator('summary', { hasText: 'Eigener Link' }).click(); + await page.locator('#link-label').fill('Angebot anfragen'); + await page.locator('#link-url').fill('/kontakt'); + await page.locator('#menu-add-url').click(); + const row = page.locator('.menu-entry').last(); + await row.locator('summary').click(); + // Ohne Häkchen keine Button-Optionen. + assert.equal(await row.locator('[data-button-options]').isVisible(), false); + await row.getByLabel('Als Button darstellen').check(); + const options = row.locator('[data-button-options]'); + await options.waitFor(); + await options.getByLabel('Form').selectOption('pill'); + // Symbol über das Fenster. + await row.getByRole('button', { name: /Symbol ändern/ }).click(); + const dialog = page.getByRole('dialog', { name: 'Symbol wählen' }); + await dialog.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click(); + await dialog.waitFor({ state: 'detached' }); + assert.equal(await row.locator('.icon-field-name').textContent(), 'Pfeil nach rechts'); + await row.getByLabel('Symbol steht').selectOption('after'); + // Fläche aus der Palette, Schrift als eigener Wert. + await options.locator('.color-trigger').first().click(); + await options.locator('.color-choices:not([hidden]) .color-option').nth(1).click(); + const fill = await row.locator('input[data-key="buttonFill"]').inputValue(); + assert.match(fill, /^@[a-z]/, 'Palettenfarbe als Verweis gespeichert'); + await options.getByLabel('Symbol beim Überfahren').selectOption('slide'); + await options.getByLabel('Auf dem Telefon').selectOption('bar-icon'); + const sample = row.locator('.menu-button-preview .cms-button').first(); + assert.equal(await sample.evaluate(el => getComputedStyle(el).borderRadius), '999px'); + assert.equal(await row.locator('.menu-button-preview .button-icon svg').count(), 2, 'Symbol in Normal- und Hover-Vorschau'); + const axe = await new AxeBuilder({ page }).include('.menu-entry:last-child').analyze(); + assert.deepEqual(axe.violations.map(v => v.id), [], 'axe in der Menüzeile'); + await row.screenshot({ path: `${report}/verwaltung.png` }); + checks.push('Verwaltung: Häkchen zeigt Optionen, Pille, Symbol aus dem Fenster, Palettenfarbe als Verweis, Vorschau mit Symbol, axe ohne Befund'); + + // Zweiter Button bleibt auf dem Telefon im aufgeklappten Menü (Vorgabe). + await page.locator('#link-label').fill('Rückruf'); + await page.locator('#link-url').fill('/rueckruf'); + await page.locator('#menu-add-url').click(); + const second = page.locator('.menu-entry').last(); + await second.locator('summary').click(); + await second.getByLabel('Als Button darstellen').check(); + await second.locator('select[data-key="buttonAppearance"]').selectOption('outline'); + assert.equal(await second.locator('select[data-key="buttonMobile"]').inputValue(), 'menu'); + + await page.getByRole('button', { name: 'Menü speichern' }).click(); + await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor(); + await page.reload(); + const saved = page.locator('.menu-entry').filter({ hasText: 'Angebot anfragen' }).last(); + await saved.locator('summary').click(); + assert.equal(await saved.getByLabel('Als Button darstellen').isChecked(), true); + assert.equal(await saved.locator('select[data-key="buttonShape"]').inputValue(), 'pill'); + assert.equal(await saved.locator('select[data-key="buttonMobile"]').inputValue(), 'bar-icon'); + assert.equal(await saved.locator('.icon-field-name').textContent(), 'Pfeil nach rechts'); + checks.push('gespeichert und nach Neuladen gleich'); + + // Öffentliche Seite: Desktop im Menü, Telefon neben dem Menü-Knopf. + const site = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + site.on('pageerror', error => errors.push(error.message)); + await site.goto(origin + '/'); + const desktop = site.locator('.site-header .block-menu > .menu-list a.menu-button'); + await desktop.first().waitFor(); + assert.equal(await desktop.first().isVisible(), true); + const look = await desktop.first().evaluate(el => ({ radius: getComputedStyle(el).borderRadius, bg: getComputedStyle(el).backgroundColor, deco: getComputedStyle(el).textDecorationLine })); + assert.equal(look.radius, '999px'); assert.equal(look.deco, 'none'); + assert.equal(await site.locator('.site-header .menu-actions').first().isVisible(), false, 'Auf dem Desktop keine Zweitausgabe'); + await site.locator('.site-header').screenshot({ path: `${report}/desktop.png` }); + await site.setViewportSize({ width: 390, height: 844 }); + await site.reload(); + const bar = site.locator('.site-header .menu-actions a.menu-button'); + assert.equal(await bar.first().isVisible(), true, 'Auf dem Telefon neben dem Menü-Knopf'); + assert.match(await bar.first().getAttribute('class'), /icon-only/, 'Dort nur als Symbol'); + assert.equal(await bar.first().getAttribute('href'), '/kontakt'); + const line = await site.evaluate(() => { const a = document.querySelector('.site-header .menu-actions a'), s = document.querySelector('.site-header .menu-burger > summary'); return Math.abs(a.getBoundingClientRect().top + a.getBoundingClientRect().height / 2 - (s.getBoundingClientRect().top + s.getBoundingClientRect().height / 2)); }); + await site.locator('.site-header').screenshot({ path: `${report}/telefon.png` }); + assert.ok(line < 4, `Symbol und Menü-Knopf nicht in einer Zeile: ${line}px`); + await site.locator('.site-header .menu-burger > summary').first().click(); + assert.equal(await site.locator('.site-header .menu-burger-body .menu-keep-visible').first().isVisible(), false, 'Im aufgeklappten Menü nicht doppelt'); + const inMenu = site.locator('.site-header .menu-burger-body a.menu-button', { hasText: 'Rückruf' }); + assert.equal(await inMenu.isVisible(), true, 'Button im aufgeklappten Menü'); + assert.equal(await site.locator('.site-header .menu-actions a', { hasText: 'Rückruf' }).count(), 0, 'nicht neben dem Menü-Knopf'); + const fit = await inMenu.evaluate(a => ({ a: a.getBoundingClientRect().width, list: a.closest('.menu-list').getBoundingClientRect().width, border: getComputedStyle(a).borderTopStyle })); + assert.ok(Math.abs(fit.a - fit.list) < 2, `volle Breite im Menü: ${fit.a} von ${fit.list}`); + assert.equal(fit.border, 'solid', 'Darstellung „Rahmen“ bleibt im Menü'); + await site.screenshot({ path: `${report}/telefon-offen.png` }); + const axeSite = await new AxeBuilder({ page: site }).include('.site-header').analyze(); + assert.deepEqual(axeSite.violations.map(v => v.id), [], 'axe im Kopf der Website'); + const width = await site.evaluate(() => document.documentElement.scrollWidth); + assert.ok(width <= 390, `waagerechtes Scrollen: ${width}`); + checks.push('Website: Button im Menü (Pille, ohne Unterstreichung), auf dem Telefon neben dem Menü-Knopf, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite, axe ohne Befund, kein waagerechtes Scrollen'); + + assert.deepEqual(errors, []); + await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2)); + console.log(checks.map(check => `✓ ${check}`).join('\n')); +} finally { + await browser?.close(); + server.kill(); +} diff --git a/src/Content/MenuButton.php b/src/Content/MenuButton.php new file mode 100644 index 0000000..ab187f2 --- /dev/null +++ b/src/Content/MenuButton.php @@ -0,0 +1,136 @@ +` ins Stylesheet der Seite + * geschrieben. + * + * Gespeichert am Eintrag als `button`: + * {id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?} + */ +final class MenuButton +{ + /** Wo der Button auf dem Telefon steht, wenn das Menü zum Menü-Knopf zusammenklappt. */ + public const MOBILE = ['menu' => 'Im aufgeklappten Menü', 'bar' => 'Neben dem Menü-Knopf', 'bar-icon' => 'Neben dem Menü-Knopf, nur als Symbol']; + public const MOTION = ['none', 'slide', 'reveal']; + /** Formularfeld => gespeicherter Schlüssel. */ + public const COLORS = ['buttonFill' => 'fill', 'buttonInk' => 'ink', 'buttonHoverFill' => 'hoverFill', 'buttonHoverInk' => 'hoverInk', 'buttonIconColor' => 'iconColor']; + private const DESIGN = ['buttonShape' => 'shape', 'buttonAppearance' => 'appearance', 'buttonSize' => 'size']; + + /** + * Aus den flachen Formularfeldern einer Zeile. Ohne Häkchen „Als Button“ kein Button. + */ + public static function fromForm(array $row): ?array + { + if ('1' !== (string) ($row['button'] ?? '')) { + return null; + } + $id = (string) ($row['buttonId'] ?? ''); + $button = ['id' => 1 === preg_match('/^m[a-f0-9]{10}$/D', $id) ? $id : 'm'.bin2hex(random_bytes(5))]; + foreach ([...self::DESIGN, ...self::COLORS] as $field => $key) { + $value = trim((string) ($row[$field] ?? '')); + if ('' !== $value) { + $button[$key] = $value; + } + } + $button['mobile'] = (string) ($row['buttonMobile'] ?? 'menu'); + $motion = (string) ($row['iconMotion'] ?? 'none'); + if ('none' !== $motion) { + $button['iconMotion'] = $motion; + } + if ('1' === (string) ($row['iconOnly'] ?? '')) { + $button['iconOnly'] = true; + } + + return $button; + } + + public static function validate(mixed $button, array $item): void + { + if (!is_array($button) || 1 !== preg_match('/^m[a-f0-9]{10}$/D', (string) ($button['id'] ?? ''))) { + throw new InvalidMenu('Ein Menü-Button braucht eine Kennung.'); + } + $known = ['id', 'mobile', 'iconMotion', 'iconOnly', ...array_values(self::DESIGN), ...array_values(self::COLORS)]; + if ([] !== array_diff(array_keys($button), $known)) { + throw new InvalidMenu('Unbekannte Einstellung am Menü-Button.'); + } + foreach (self::DESIGN as $key) { + if (isset($button[$key]) && !isset(ButtonDesign::OPTIONS[$key][$button[$key]])) { + throw new InvalidMenu('Unbekannte Form, Darstellung oder Größe am Menü-Button.'); + } + } + foreach (self::COLORS as $key) { + if (isset($button[$key]) && (!is_string($button[$key]) || 1 !== preg_match(StyleSchema::COLOR, $button[$key]))) { + throw new InvalidMenu('Eine Farbe am Menü-Button ist ein Hexwert wie #b71c22 oder eine Farbe der Palette.'); + } + } + if (!isset(self::MOBILE[$button['mobile'] ?? ''])) { + throw new InvalidMenu('Unbekannte Anzeige auf dem Telefon.'); + } + // Auf dem Telefon nur als Symbol: ohne Symbol stünde dort ein leerer Knopf. + if ('bar-icon' === $button['mobile'] && '' === ($item['icon'] ?? '')) { + throw new InvalidMenu('Nur als Symbol neben dem Menü-Knopf braucht ein Symbol.'); + } + if (isset($button['iconMotion']) && !in_array($button['iconMotion'], self::MOTION, true)) { + throw new InvalidMenu('Unbekannte Bewegung des Symbols.'); + } + if (isset($button['iconOnly']) && (true !== $button['iconOnly'] || '' === ($item['icon'] ?? ''))) { + throw new InvalidMenu('Ein Menü-Button nur aus Symbol braucht ein Symbol.'); + } + // Ein Knopf öffnet kein Untermenü: mit Unterpunkten wäre er Knopf und Aufklapper zugleich. + if ([] !== ($item['children'] ?? [])) { + throw new InvalidMenu(sprintf('„%s“ hat Unterpunkte und kann deshalb kein Button sein.', $item['label'] ?? '')); + } + } + + /** + * Die Farbregeln aller Menü-Buttons. Gesetzt werden die Variablen, aus denen die Button-Stile + * ihre Flächen berechnen, plus die abgeleiteten Werte der gewählten oder der Standard- + * Darstellung - sonst rechnete ein Button ohne eigene Darstellung weiter mit den Farben vom + * Seitenanfang. + * + * @param array $menus + */ + public static function css(array $menus, array $themeButtons): string + { + $color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v; + $css = ''; + $walk = function (array $items) use (&$walk, &$css, $color, $themeButtons): void { + foreach ($items as $item) { + $walk($item['children'] ?? []); + $button = $item['button'] ?? null; + if (!is_array($button) || !isset($button['id'])) { + continue; + } + $vars = []; + foreach (['fill' => '--kb-fill', 'ink' => '--kb-ink', 'hoverFill' => '--kb-hover-fill', 'hoverInk' => '--kb-hover-ink'] as $key => $var) { + if (isset($button[$key])) { + $vars[] = $var.':'.$color($button[$key]); + } + } + if ([] !== $vars) { + foreach (ButtonDesign::appearance($button['appearance'] ?? $themeButtons['appearance'] ?? 'solid') as $key => $value) { + $vars[] = '--kb-'.$key.':'.$value; + } + $css .= sprintf('.menu-button-%s{%s}', $button['id'], implode(';', $vars)); + } + if (isset($button['iconColor'])) { + $css .= sprintf('.menu-button-%s .button-icon{color:%s}', $button['id'], $color($button['iconColor'])); + } + } + }; + foreach ($menus as $menu) { + $walk($menu['items'] ?? []); + } + + return $css; + } +} diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index bc7c64d..dee8fd4 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -132,12 +132,18 @@ final class MenuRepository if ('' === trim($label)) { continue; } - $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])]; + $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : []), ...(isset($item['button']) ? ['button' => $item['button']] : [])]; } return $links; } + /** Die Farben der Menü-Buttons aller Plätze, fürs Stylesheet der Seite (ADR 110). */ + public function buttonCss(array $themeButtons, string $locale = 'de'): string + { + return MenuButton::css($this->all($locale), $themeButtons); + } + private function validate(array $items, int $depth = 1, int &$count = 0): void { if (!array_is_list($items)) { @@ -175,6 +181,9 @@ final class MenuRepository } else { throw new InvalidMenu('Unbekannte Zielart.'); } + if (array_key_exists('button', $item)) { + MenuButton::validate($item['button'], $item); + } $this->validate($item['children'] ?? [], $depth + 1, $count); } } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 812174f..a23272a 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -492,7 +492,7 @@ final class EditorController extends AbstractController // CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher // fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr. $documents = [$document, ...array_values($areas), ...array_values($sections)]; - $styles = $this->theme->css($theme); + $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']); foreach ($documents as $doc) { $styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc); } diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index 6ac152f..c162c73 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -2,8 +2,11 @@ namespace App\Controller; +use App\Content\ButtonDesign; +use App\Content\MenuButton; use App\Content\MenuRepository; use App\Content\PageRepository; +use App\Content\Theme; use App\Domain\InvalidMenu; use App\Domain\StaleWrite; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -14,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route; final class MenuController extends AbstractController { - public function __construct(private MenuRepository $menus, private PageRepository $pages) + public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme) { } @@ -67,6 +70,7 @@ final class MenuController extends AbstractController $slot = $request->query->getString('slot', 'header'); $slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header'; $menu = $this->menus->all()[$slot] ?? null; + $theme = $this->theme->load(); $rows = $this->flatten($menu['items'] ?? []); $selected = []; foreach ($rows as &$row) { @@ -81,6 +85,8 @@ final class MenuController extends AbstractController 'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot], 'menu' => $menu, 'rows' => $rows, 'menuIcons' => \App\Content\DocumentValidator::ICONS, + // Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor. + 'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE], ]); } @@ -123,6 +129,7 @@ final class MenuController extends AbstractController 'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '', 'newTab' => $item['newTab'] ?? false, 'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before', + 'button' => $item['button'] ?? null, ]; array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1)); } @@ -152,6 +159,7 @@ final class MenuController extends AbstractController 'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'), 'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))), 'position' => (int) ($row['position'] ?? 0), + 'button' => MenuButton::fromForm($row), ]; } usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']); @@ -166,6 +174,9 @@ final class MenuController extends AbstractController if ($row['newTab']) { $item['newTab'] = true; } + if (null !== $row['button']) { + $item['button'] = $row['button']; + } if ('url' === $row['type']) { $item['url'] = $row['url']; } else { diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index cab416c..48e2644 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -138,7 +138,7 @@ final class PageController extends AbstractController // for the small script that marks the page as scrolled. // Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar. $needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false); - $styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); + $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); foreach ([...array_values($areas), ...array_values($sections)] as $extra) { $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); } diff --git a/src/Controller/PartialController.php b/src/Controller/PartialController.php index db9d483..c0615b3 100644 --- a/src/Controller/PartialController.php +++ b/src/Controller/PartialController.php @@ -179,7 +179,7 @@ final class PartialController extends AbstractController } // Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben. - $styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document); + $styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->styles->compileDocument($document).$this->code->compileDocument($document); foreach ($sections as $section) { $styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section); } diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig index 89ac9fc..d16e9ca 100644 --- a/templates/admin/menu-entry.html.twig +++ b/templates/admin/menu-entry.html.twig @@ -17,6 +17,27 @@ {% endif %} + {# Als Button (ADR 110). Ohne Skript sind es gewöhnliche Felder; mit Skript kommen + Vorschau, Symbolfenster und Palettenfarben dazu. Leer heißt Website-Standard. #} + {% set btn = row.button|default({}) %} +
diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index 1a19b93..fcf3669 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -3,12 +3,12 @@ {% block title %}Menüs{% endblock %} {% block heading %}Menüs{% endblock %} {% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %} -{% block styles %}{% endblock %} +{% block styles %}{% endblock %} {% block scripts %}{% endblock %} {% block content %} {% include 'admin/menu-icons.html.twig' %} -