diff --git a/README.md b/README.md index fe96b59..25525d4 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,16 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7. 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, Sprungziele und Menüs (ab 1.9.0) + +Unter Design/Typografie konfiguriert „Standardbutton“ Form, Darstellung, Größe, Schrift und Farben für die Website. Acht Vorlagen zeigen das Ergebnis direkt, einschließlich Hover-Vorschau. Einzelne Buttons können im Editor davon abweichen, auch je Gerät: eckig, abgerundet, Pille, Rahmen, dezent oder Textlink. Eigene freie Designwerte können diese Vorgaben übersteuern. + +Sektionen, Zeilen und Spalten haben im Inhaltsbereich das Feld „Sprungziel“. Dort beispielsweise `kontakt` eintragen und einen Link auf `#kontakt` setzen. Ziele sind eindeutig; die internen Block-IDs bleiben erhalten. Duplikate erhalten kein kopiertes Sprungziel. + +Menüeinträge am Punktgriff ziehen oder per Pfeiltasten verschieben und einrücken. Optional ein Symbol vor oder nach dem Text auswählen. Änderungen anschließend mit „Menü speichern“ übernehmen. Neben der Fassung links erscheint für Administratoren ein Link, sobald die bestehende tägliche Updateprüfung eine neuere Version gefunden hat. Der Aufgaben-Worker muss dafür laufen; die Sidebar selbst führt keine Netzwerkanfrage aus. + +Gezielte Entwicklungsprüfungen: `npm run test:buttons` und `npm run test:navigation-ux` verwenden eine isolierte Kant-IT-Kopie. Responsive Button-CSS nach Änderungen mit `npm run build:buttons` erzeugen. + ## Lokal starten Voraussetzungen: PHP ab 8.2 mit Composer 2, PDO SQLite, Ctype, Iconv, Session, XML und GD mit WebP-Unterstützung. EXIF wird für die automatische Ausrichtung von JPEG-Fotos empfohlen. Für Tests zusätzlich die von `composer install` geprüften Erweiterungen, insbesondere DOM, XMLWriter und Mbstring. Für Hosting eine aktuell gepflegte PHP-Patchversion verwenden. Node 24 wird für Browserprüfungen und das Generieren der Design-CSS gebraucht, nicht im Hostingbetrieb. diff --git a/VERSION b/VERSION index bfa363e..f8e233b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.4 +1.9.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c66eb0..5d64b8e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,18 @@ Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 087 – Button-System, benannte Sprungziele und Menübedienung (14.09.2026) + +Globale Button-Vorgaben liegen optional unter `theme.buttons`; Vorschau und öffentliche Ausgabe verwenden dasselbe kleine CSS-System mit Geräteklassen. Lokale Varianten stehen getrennt von Inhalt und freien Styles in `layout.button.`. Vorlagen ändern Form/Darstellung, erhalten Text, Link und Abstände und entfernen kollidierende freie Werte nur für die betroffene Einstellung am aktiven Gerät. Verbleibende eigene Styles können Varianten übersteuern. Die Bedienidee orientiert sich an der lokal bereitgestellten Flatsome-Button-Konfiguration (`inc/builder/shortcodes/button.php`); Implementierung und Assets wurden nicht übernommen. + +Benannte `anchor`-Werte sind nur an Sektion, Zeile und Spalte erlaubt und werden gegen alle Block-IDs, andere Ziele und reservierte IDs geprüft. Ein zusätzliches Zielelement erhält den Namen; die stabile Block-ID bleibt erhalten. Duplizieren entfernt benannte Ziele aus der Kopie. Native Fragmentlinks funktionieren ohne öffentliches JavaScript. + +Menüeinträge speichern optionale `icon`- und `iconPosition`-Werte aus der bestehenden SVG-Allowlist. Drag-and-drop verwendet denselben Baum und Undo-/Speicherweg wie Tastaturbefehle; Unterpunkte reisen mit dem Hauptpunkt. Die Versionsanzeige liest nur das vorhandene Ergebnis des täglichen Update-Workers. Dadurch entstehen beim Öffnen von Adminseiten keine zusätzlichen externen Anfragen. + +**Migration:** Validator und Editor unterstützen Schema 21 zusätzlich zu bisherigen Fassungen. Erst eine neue Button-Variante oder ein Sprungziel hebt das bearbeitete Dokument auf 21; vorhandene Dokumente werden nicht pauschal umgeschrieben. Globale Buttons und Menü-Icons sind optionale Konfigurationsfelder, keine SQL-Migration. + +**Rückweg:** Vor einem Downgrade eine Sicherung erstellen bzw. die Sicherung vor 1.9.0 wiederherstellen. Alternativ Schema-21-Dokumente exportieren, `anchor` und `layout.button` rekursiv entfernen und die Fassung auf 20 setzen, anschließend mit dem alten Validator prüfen/importieren. Alte Software kennt die neuen Konfigurationsfelder nicht; globale Button-Vorgaben/Icons gegebenenfalls aus der Sicherung entfernen, sie können beim alten Speichern verloren gehen. Sprunglinks auf entfernte Ziele müssen angepasst werden. + ## ADR 086 – Bildausrichtung berücksichtigt Rahmen, Bild und Layoutachse (14.09.2026) `align-self` allein hat in normalen Inhaltsbereichen keine Wirkung. Ein prozentual schmaler Bildrahmen blieb links; bei voller Rahmenbreite konnte zusätzlich das kleinere Bild im Rahmen links stehen. Der Browserfall mit einem 50-Prozent-Bild in einer gepolsterten Spalte reproduzierte die Abweichung trotz ausgewählter Mitte. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6249b98..cdbcfcc 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,13 @@ # Projektstatus +## 1.9.0 – Button-Vorlagen, Sprungziele und bessere Menüs (14.09.2026) + +**Erledigt:** Website-Standardbutton unter Typografie mit acht visuellen Vorlagen, normaler und Hover-Vorschau, Form, Darstellung, Größe, Schrift und Farben. Einzelne Buttons erhalten Gerätevarianten und Rücksetzen über den bestehenden Undo-/Speicherweg. Ein vorhandener Fehler beim Speichern leerer Palettenzeilen wurde behoben. Sprungziele wie `#kontakt` lassen sich an Sektionen, Zeilen und Spalten vergeben; interne IDs bleiben stabil und doppelte Ziele werden abgewiesen. Menüs haben sichtbare Drag-and-drop-Griffe, Tastaturbedienung, ordentliche SVG-Pfeile sowie optionale Icons vor/nach dem Text. Neben der Fassung erscheint ein Update-Link aus dem Ergebnis der bestehenden täglichen Prüfung. + +**Gezielt geprüft:** Je drei Browser-Prüfgruppen für Buttons und Navigation auf isolierter Kant-IT-Kopie erfolgreich: Vorlagen und globale Speicherung, Gerätevarianten, Undo/Redo, Speichern/Laden, Veröffentlichung, Sprunglinks, Menü-Drag-and-drop/Tastatur, Icons und Update-Hinweis. Öffentliche Ausgabe ohne JavaScript geprüft; Screenshots unter `reports/buttons/` und `reports/navigation-ux/` visuell kontrolliert. Originaldaten unverändert. Twig-Lint und Produktionscontainer erfolgreich. Im gezielten PHP-Lauf zu Theme/Menüs bestanden 29 von 30 Tests unmittelbar; eine alte HTML-Erwartung wurde an die neue Icon-Beschriftung angepasst und der betroffene Test erfolgreich wiederholt (8 Assertions). PHP-Formatierung abgeschlossen. Auf Wunsch keine Gesamtprüfung und keine neue Lighthouse-Messung. + +**Grenzen / nächster Schritt:** Release 1.9.0 bereitstellen. Update-Hinweis setzt den vorhandenen Aufgaben-Worker voraus und erscheint nach dessen Prüfung. Freie CSS-/Designwerte können Button-Vorlagen übersteuern. Schema 21 ist beim Rückweg zu berücksichtigen (ADR 087); keine Datenbankmigration. Kundenhostings werden hier nicht verändert. + ## 1.8.4 – Bildausrichtung sichtbar korrigiert (14.09.2026) **Behoben:** „Eigene Ausrichtung quer: Mitte“ zentriert Bilder jetzt auch in normalen Sektionen und Spalten. Zuvor blieb das Bild trotz `align-self:center` links; der Browserfall mit 50-Prozent-Breite zeigte 181 Pixel Abweichung von der Mitte (`var/image-alignment-before.log`, `reports/image-alignment/before.png`). Der gemeinsame Compiler richtet Rahmen und enthaltenes Bild aus, einschließlich kleiner Logos, Prozent-/Maximalbreiten und Bildlinks. Anfang/Ende, Gerätevererbung und Rücksetzen sind berücksichtigt; horizontale Flex-Gruppen behalten ihre vertikale Querachse. Keine Datenmigration, keine Änderung an gespeicherten Bilddaten, kein zusätzliches öffentliches Skript (ADR 086). diff --git a/package.json b/package.json index 2654ea2..6f72685 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "scripts": { + "build:buttons": "node scripts/build-buttons.mjs", + "test:buttons": "node scripts/button-check.mjs", + "test:navigation-ux": "node scripts/navigation-ux-check.mjs", "build:design": "node scripts/build-design.mjs", "test": "node --test tests/*.test.mjs", "test:editor": "node scripts/editor-check.mjs", diff --git a/public/assets/admin.css b/public/assets/admin.css index 8ef1e4f..b967ddc 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -733,3 +733,7 @@ details.card > summary:hover { background: #fafbf8 } .field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left} } @media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}} + +.version-update{display:block;width:fit-content;margin-top:8px;padding:5px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none} + +.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}} diff --git a/public/assets/button-controls.css b/public/assets/button-controls.css new file mode 100644 index 0000000..8693ba1 --- /dev/null +++ b/public/assets/button-controls.css @@ -0,0 +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}} diff --git a/public/assets/button-design.js b/public/assets/button-design.js new file mode 100644 index 0000000..18de8c2 --- /dev/null +++ b/public/assets/button-design.js @@ -0,0 +1,95 @@ +import { devices, effectiveStyle, hoverStyle, locate, upgradeDocument, setStyle } from './editor-model.js'; + +export function effectiveButton(block, device, defaults) { + const result = { ...defaults }; + for (const name of Object.keys(devices)) { + Object.assign(result, block.layout?.button?.[name]); + if (name === device) break; + } + return result; +} + +/** Change appearance without touching copy, URL, typography, sizing or layout placement. */ +export function setButtonDesign(document, id, device, values) { + const block = locate(document.blocks, id)?.block; + if (block?.type !== 'button' || !Object.hasOwn(devices, device)) return; + if (Object.values(values).some(value => value !== '')) upgradeDocument(document, 21); + const target = ((block.layout ??= {}).button ??= {}); + target[device] ??= {}; + for (const [key, value] of Object.entries(values)) { + if (value === '') delete target[device][key]; else target[device][key] = value; + // A chosen shape/appearance must visibly take effect even on an older hand-styled button. + // Other groups (font, width, padding, margins, position) remain untouched. Undo is atomic. + const reset = key === 'shape' ? ['borderRadius'] : key === 'appearance' ? ['backgroundColor', 'backgroundImage', 'gradientFrom', 'gradientTo', 'color', 'borderColor', 'borderWidth', 'borderStyle'] : []; + for (const property of reset) { + setStyle(document, id, device, property, ''); + setStyle(document, id, device, property, '', { hover: true }); + } + } + if (!Object.keys(target[device]).length) delete target[device]; + if (!Object.keys(target).length) delete block.layout.button; +} + +const element = (tag, className, text) => { + const node = document.createElement(tag); if (className) node.className = className; + if (text !== undefined) node.textContent = text; return node; +}; +const color = (value, schema) => value?.startsWith('@') ? schema.colors.find(row => row.key === value.slice(1))?.value || '#203b34' : value; + +export function previewVariables(node, values, schema) { + const set = (key, value) => node.style.setProperty(`--kb-${key}`, String(value)); + set('fill', color(values.backgroundColor, schema)); set('ink', color(values.color, schema)); + set('hover-fill', color(values.hoverBackgroundColor, schema)); set('hover-ink', color(values.hoverColor, schema)); + const font = schema.fonts.find(font => font.key === values.fontFamily); + set('font', font ? `${font.family ? `"${font.family}",` : ''}${font.fallback}` : 'inherit'); + set('font-size', `${values.fontSize}px`); set('weight', values.fontWeight); + set('tracking', `${values.letterSpacing}px`); set('transform', values.textTransform); +} + +export function buttonSample(text, values, schema, hover = false) { + const sample = element('span', `cms-button b-desktop-shape-${values.shape} b-desktop-appearance-${values.appearance} b-desktop-size-${values.size}${hover ? ' is-hover' : ''}`, text || 'Button'); + previewVariables(sample, values, schema); + return sample; +} + +export function renderButtonControls(panel, { block, device, schema, apply }) { + const group = element('section', 'button-designer'); group.setAttribute('aria-label', 'Button-Gestaltung'); + group.append(element('h3', null, 'Button-Gestaltung')); + const current = effectiveButton(block, device, schema.buttons); + const previews = element('div', 'button-state-previews'); + for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) { + const state = element('div', 'button-state'); state.append(element('span', 'field-help', label)); + const sample = buttonSample(block.props.text, current, schema, hover); + // Preview the block's own free styling as well as its variant and site defaults. + for (const [key, definition] of Object.entries(schema.properties)) { + if (!definition.css || definition.target || definition.sides || definition.corners || ['position','display','width','height','minWidth','maxWidth','minHeight','maxHeight'].includes(key)) continue; + let value = effectiveStyle(block, device, key).value; + if (hover) for (const name of Object.keys(devices)) { value = hoverStyle(block, name, key) ?? value; if (name === device) break; } + if (value !== undefined && value !== '') sample.style.setProperty(definition.css, definition.type === 'color' ? color(value, schema) : String(value)); + } + state.append(sample); previews.append(state); + } + group.append(previews); + const cards = element('div', 'button-preset-grid'); cards.setAttribute('role', 'group'); cards.setAttribute('aria-label', 'Button-Vorlagen'); + for (const [index, preset] of schema.buttonPresets.entries()) { + const choice = element('button', 'button-preset'); choice.type = 'button'; choice.id = `button-preset-${index}`; + choice.setAttribute('aria-label', `Button-Vorlage ${preset.name}`); + choice.setAttribute('aria-pressed', String(current.shape === preset.shape && current.appearance === preset.appearance)); + const sample = buttonSample('Button', { ...schema.buttons, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true'); + choice.append(sample, element('span', 'button-preset-label', preset.name)); + choice.addEventListener('click', () => apply({ shape: preset.shape, appearance: preset.appearance }, choice.id)); cards.append(choice); + } + group.append(cards); + for (const [key, options] of Object.entries(schema.buttonOptions)) { + const id = `button-${key}`, label = element('label', null, { shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe' }[key]); label.htmlFor = id; + const select = element('select'); select.id = id; + select.append(new Option(`Übernehmen: ${options[current[key]]}`, '')); + for (const [value, name] of Object.entries(options)) select.append(new Option(name, value)); + select.value = block.layout?.button?.[device]?.[key] ?? ''; + select.addEventListener('change', () => apply({ [key]: select.value }, id)); group.append(label, select); + } + const reset = element('button', 'secondary full-width', device === 'desktop' ? 'Website-Standard übernehmen' : 'Größeres Gerät übernehmen'); reset.type = 'button'; reset.id = 'button-reset'; + reset.addEventListener('click', () => apply({ shape: '', appearance: '', size: '' }, reset.id)); + group.append(reset, element('p', 'field-help', `${devices[device]}: Form und Darstellung übernehmen die Website-Vorgaben, solange kein eigener Wert gesetzt ist. Vorlagen ersetzen eigene Füllung und Rahmen; Schrift, Breite und Abstände bleiben erhalten.`)); + panel.append(group); +} diff --git a/public/assets/buttons.css b/public/assets/buttons.css new file mode 100644 index 0000000..913998f --- /dev/null +++ b/public/assets/buttons.css @@ -0,0 +1,28 @@ +/* Shared by the public renderer and the actual button samples in the editor. */ +.cms-button{display:inline-flex;align-items:center;justify-content:center;gap:.65em;max-width:100%;min-height:44px;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));border:2px solid var(--b-border,var(--kb-border,transparent));border-radius:var(--b-radius,var(--kb-radius,8px));background:var(--b-bg,var(--kb-bg,var(--kb-fill,var(--ink,#203b34))));color:var(--b-fg,var(--kb-fg,var(--kb-ink,var(--paper,#f6f7f4))));font-family:var(--kb-font,inherit);font-size:var(--kb-font-size,14px);font-weight:var(--kb-weight,600);letter-spacing:var(--kb-tracking,0);text-transform:var(--kb-transform,none);line-height:1.6;text-align:center;text-decoration:none;overflow-wrap:anywhere;box-sizing:border-box} +.cms-button:hover,.cms-button:focus-visible,.cms-button.is-hover{background:var(--b-hover-bg,var(--kb-hover-bg,var(--kb-hover-fill,var(--ink,#203b34))));color:var(--b-hover-fg,var(--kb-hover-fg,var(--kb-hover-ink,var(--paper,#f6f7f4))));text-decoration:none} +.cms-button:focus-visible{outline:3px solid currentColor;outline-offset:4px} +.cms-button svg{width:1.15em;height:1.15em;flex:none} +.cms-button:where(.b-desktop-shape-square){--b-radius:0px}.cms-button:where(.b-desktop-shape-rounded){--b-radius:8px}.cms-button:where(.b-desktop-shape-pill){--b-radius:999px} +.cms-button:where(.b-desktop-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-desktop-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-desktop-size-large){--b-py:19px;--b-px:30px} +.cms-button:where(.b-desktop-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)} +.cms-button:where(.b-desktop-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-desktop-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-desktop-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em} +/* Responsive variants */ +@media(max-width:1024px){ +.cms-button:where(.b-tablet-shape-square){--b-radius:0px}.cms-button:where(.b-tablet-shape-rounded){--b-radius:8px}.cms-button:where(.b-tablet-shape-pill){--b-radius:999px} +.cms-button:where(.b-tablet-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-tablet-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-tablet-size-large){--b-py:19px;--b-px:30px} +.cms-button:where(.b-tablet-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)} +.cms-button:where(.b-tablet-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-tablet-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-tablet-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em} +} +@media(max-width:767px){ +.cms-button:where(.b-mobile-shape-square){--b-radius:0px}.cms-button:where(.b-mobile-shape-rounded){--b-radius:8px}.cms-button:where(.b-mobile-shape-pill){--b-radius:999px} +.cms-button:where(.b-mobile-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-mobile-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-mobile-size-large){--b-py:19px;--b-px:30px} +.cms-button:where(.b-mobile-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)} +.cms-button:where(.b-mobile-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-mobile-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)} +.cms-button:where(.b-mobile-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em} +} diff --git a/public/assets/color-control.css b/public/assets/color-control.css new file mode 100644 index 0000000..3c2601c --- /dev/null +++ b/public/assets/color-control.css @@ -0,0 +1,28 @@ +.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden} +.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer} +.color-trigger:hover{background:#f5f7f3} +.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} +.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px} +.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit} +.color-description{display:grid;gap:3px;flex:1;min-width:0} +.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere} +.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere} +.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c} +.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)} +.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8} +.color-choices[hidden]{display:none} +.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258} +.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin} +.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer} +.color-option:hover{background:#eef3ea;border-color:#718575} +.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8} +.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426} +.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere} +.color-custom{display:flex;align-items:center;gap:6px} +.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff} +.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px} +.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer} +.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0} +.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer} +.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index a68010f..0178311 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -171,6 +171,7 @@ export function duplicateBlock(document, selected) { const copy = clone(target.block); const rekey = block => { block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`; + delete block.anchor; if (block.type === 'heading' && block.props.level === 1) block.props.level = 2; (block.children || []).forEach(rekey); }; @@ -254,7 +255,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // nirgends. Eine Zahl, an einer Stelle. -export const SCHEMA_VERSION = 20; +export const SCHEMA_VERSION = 21; const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION; export function upgradeDocument(document, version = 2) { diff --git a/public/assets/editor.css b/public/assets/editor.css index 9174253..f39c3a8 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -139,34 +139,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)} .design-own .design-row-head label::before{content:'● ';color:var(--accent)} .design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1} .design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45} -.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden} -.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer} -.color-trigger:hover{background:#f5f7f3} -.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} -.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px} -.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit} -.color-description{display:grid;gap:3px;flex:1;min-width:0} -.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere} -.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere} -.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c} -.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)} -.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8} -.color-choices[hidden]{display:none} -.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258} -.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin} -.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer} -.color-option:hover{background:#eef3ea;border-color:#718575} -.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8} -.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px} -.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426} -.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere} -.color-custom{display:flex;align-items:center;gap:6px} -.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff} -.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px} -.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer} -.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0} -.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer} -.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none} .length-control{display:grid;grid-template-columns:1fr 74px;gap:6px} .length-control input,.length-control select{font-size:12px;min-height:38px} .length-control.is-inherited input{color:var(--muted)} diff --git a/public/assets/editor.js b/public/assets/editor.js index b506262..5ab3499 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -1,6 +1,7 @@ import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js'; import { openMedia, createMediaPreview } from './media.js'; import { createDesignPanel } from './design-panel.js'; +import { renderButtonControls, setButtonDesign } from './button-design.js'; import { createRichText } from './rich-text.js'; import { createCanvasOverlay } from './canvas-overlay.js'; import { preview, rowPreview } from './library-previews.js'; @@ -202,7 +203,11 @@ function renderProperties() { history.change(doc => { const current = selected ? locate(doc.blocks, selected)?.block : doc; if (!current) return; - if (options.layout) (current.layout ??= {})[key] = next; + if (options.anchor) { + const anchor = String(next).trim().replace(/^#/, ''); + if (anchor) { current.anchor = anchor; upgradeDocument(doc, 21); } else delete current.anchor; + } + else if (options.layout) (current.layout ??= {})[key] = next; else if (selected) { (current.props ??= {})[key] = next; // Decorative means "no text": an image loses its alternative text, an icon its label. @@ -248,6 +253,7 @@ function renderProperties() { panel.append(label, rich.element, help); } if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' }); + if (block.type === 'button' && designSchema) buttonControls(block, panel); if (INLINE_TEXT[block.type]) { const inline = document.createElement('button'); inline.className = 'secondary full-width'; @@ -339,6 +345,7 @@ function renderProperties() { add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id)); panel.append(hint, entries, add); } + if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' }); if (block.type === 'accordionitem') { field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' }); @@ -499,6 +506,7 @@ function renderImageWidth(block, panel) { } function renderDesign(block, panel) { + if (block.type === 'button' && designSchema) buttonControls(block, panel); if (designSchema && designPanel) { designPanel(panel); } else { @@ -884,6 +892,13 @@ function addElement(type) { changed(); } +function buttonControls(block, panel) { + renderButtonControls(panel, { block, device, schema: designSchema, apply: (values, focusId) => { + history.change(doc => setButtonDesign(doc, block.id, device, values)); changed(); + document.getElementById(focusId)?.focus({ preventScroll: true }); + } }); +} + function beginContainerInsert(id) { const block = locate(history.document.blocks, id)?.block; if (!block || busy) return; diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js index 3e731ec..c76bf27 100644 --- a/public/assets/menu-editor.js +++ b/public/assets/menu-editor.js @@ -7,6 +7,16 @@ 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' }; +const menuIcons = JSON.parse(form.dataset.icons || '[]'); +const chrome = () => { + const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = ''; + handle.title = 'Ziehen · Pfeiltasten ändern Reihenfolge und Ebene'; + handle.innerHTML = ''; + const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chevron.setAttribute('class','menu-chevron'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('width','20'); chevron.setAttribute('height','20'); chevron.setAttribute('aria-hidden','true'); + chevron.innerHTML = ''; + return { handle, chevron }; +}; const rows = () => [...list.children].map(row => { const data = { level: Number(row.dataset.level) }; row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; }); @@ -28,6 +38,11 @@ function refresh() { const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`; 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); + 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]; }); row.querySelector('[data-menu-action="up"]').disabled = index === 0; row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1; row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2'; @@ -58,7 +73,8 @@ function createRow(data) { }; for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden'); const details = el('details'); const summary = el('summary'); - summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link')); + const { handle, chevron } = chrome(); + summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron); const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details); const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80; if (data.type === 'page') { @@ -72,6 +88,12 @@ function createRow(data) { field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65; } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; } 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 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 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); @@ -140,6 +162,46 @@ list.addEventListener('click', event => { commit(before); if (row.isConnected) row.querySelector('summary').focus(); }); form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); }); +// Pointer capture supports mouse, pen and touch. A parent travels with its children. +let drag = null; +list.addEventListener('click', event => { if (event.target.closest('[data-menu-drag]')) { event.preventDefault(); event.stopPropagation(); } }); +list.addEventListener('keydown', event => { + const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy) return; + const action = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }[event.key]; + if (action) { event.preventDefault(); const row = handle.closest('.menu-entry'); row.querySelector(`[data-menu-action="${action}"]`).click(); handle.focus(); } +}); +list.addEventListener('pointerdown', event => { + const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy || event.button !== 0) return; + event.preventDefault(); handle.focus(); handle.setPointerCapture(event.pointerId); + const row = handle.closest('.menu-entry'), entries = [...list.children], index = entries.indexOf(row), bundle = [row]; + if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]); + drag = { handle, row, bundle, before: state(), x: event.clientX, y: event.clientY, pointer: event.pointerId, active: false, level: row.dataset.level }; +}); +list.addEventListener('pointermove', event => { + if (!drag || event.pointerId !== drag.pointer) return; + if (!drag.active && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return; + drag.active = true; drag.bundle.forEach(row => row.classList.add('menu-dragging')); + const others = [...list.children].filter(row => !drag.bundle.includes(row)); + let target = others.find(row => event.clientY < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2) || null; + // A main entry never splits the children of another main entry. + if ((drag.bundle.length > 1 || drag.level === '1') && target?.dataset.level === '2') { + let index = others.indexOf(target); while (index > 0 && others[index].dataset.level === '2') index--; target = others[index]; + } + drag.bundle.forEach(row => list.insertBefore(row, target)); + if (drag.bundle.length === 1) drag.row.dataset.level = event.clientX - drag.x > 36 ? '2' : event.clientX - drag.x < -36 ? '1' : drag.level; + if (list.firstElementChild) list.firstElementChild.dataset.level = '1'; + refresh(); + if (event.clientY > window.innerHeight - 65) window.scrollBy(0, 18); else if (event.clientY < 65) window.scrollBy(0, -18); +}); +function endDrag(cancel = false) { + if (!drag) return; + const previous = drag; drag = null; + previous.bundle.forEach(row => row.classList.remove('menu-dragging')); + if (previous.handle.hasPointerCapture(previous.pointer)) previous.handle.releasePointerCapture(previous.pointer); + if (cancel) restore(previous.before); else if (previous.active) { commit(previous.before); previous.handle.focus(); status.textContent = 'Reihenfolge geändert · noch nicht gespeichert'; } +} +list.addEventListener('pointerup', () => endDrag()); list.addEventListener('pointercancel', () => endDrag(true)); +document.addEventListener('keydown', event => { if (event.key === 'Escape' && drag) { event.preventDefault(); endDrag(true); } }); window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } }); form.noValidate = true; form.addEventListener('submit', async event => { @@ -147,13 +209,13 @@ form.addEventListener('submit', async event => { const invalid = form.querySelector(':invalid'); if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; } refresh(); const body = new FormData(form); const snapshot = key(state()); - busy = true; error.hidden = true; form.querySelectorAll('input,button').forEach(node => { node.disabled = true; }); refresh(); + busy = true; error.hidden = true; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = true; }); refresh(); try { const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' }); if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.'); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.'); form.elements.expectedVersion.value = result.version; saved = snapshot; } catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; } - finally { busy = false; form.querySelectorAll('input,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); } + finally { busy = false; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); } }); refresh(); last = state(); saved = key(last); diff --git a/public/assets/site.css b/public/assets/site.css index 163e572..170a24b 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -373,3 +373,5 @@ body.consent-open .scroll-top{display:none} .zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px} .zoom-prev{left:8px}.zoom-next{right:8px} } + +.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none} diff --git a/public/assets/theme-buttons.js b/public/assets/theme-buttons.js new file mode 100644 index 0000000..06e0e92 --- /dev/null +++ b/public/assets/theme-buttons.js @@ -0,0 +1,39 @@ +import { buttonSample } from './button-design.js'; +import { createColorControl } from './color-control.js'; + +const root = document.querySelector('#button-defaults'); +if (root) { + const schema = JSON.parse(root.dataset.schema), form = root.closest('form'); + const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value])); + const update = () => { + // Unsaved palette edits are reflected immediately in all samples. + schema.colors = [...form.querySelectorAll('.theme-table tbody tr')].flatMap(row => { + const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]'); + return key?.value && value ? [{ key: key.value, value: value.value }] : []; + }); + const values = read(), previews = root.querySelector('.button-state-previews'); previews.replaceChildren(); + for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) { + const state = document.createElement('div'); state.className = 'button-state'; + const title = document.createElement('span'); title.className = 'field-help'; title.textContent = label; + state.append(title, buttonSample('Mehr erfahren', values, schema, hover)); previews.append(state); + } + root.querySelectorAll('[data-button-preset]').forEach((choice, index) => { + const preset = schema.buttonPresets[index]; choice.setAttribute('aria-pressed', String(preset.shape === values.shape && preset.appearance === values.appearance)); + choice.querySelector('.cms-button')?.remove(); + const sample = buttonSample('Button', { ...values, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true'); choice.prepend(sample); + }); + }; + for (const input of root.querySelectorAll('[data-button-color]')) { + const label = root.querySelector(`label[for="${input.id}"]`).textContent; + const originalId = input.id; input.type = 'hidden'; input.id += '-value'; + const control = createColorControl({ id: originalId, label, value: input.value, colors: schema.colors, onChange: value => { input.value = value; update(); } }); + control.querySelector('.color-inherit').hidden = true; + input.after(control); + } + root.querySelectorAll('[data-button-preset]').forEach((choice, index) => choice.addEventListener('click', () => { + const preset = schema.buttonPresets[index]; + for (const key of ['shape', 'appearance']) root.querySelector(`[name="buttons[${key}]"]`).value = preset[key]; + update(); + })); + form.addEventListener('input', update); form.addEventListener('change', update); update(); +} diff --git a/scripts/build-buttons.mjs b/scripts/build-buttons.mjs new file mode 100644 index 0000000..358c163 --- /dev/null +++ b/scripts/build-buttons.mjs @@ -0,0 +1,5 @@ +import { readFile, writeFile } from 'node:fs/promises'; +const file = new URL('../public/assets/buttons.css', import.meta.url); +const source = (await readFile(file, 'utf8')).split('/* Responsive variants */')[0].trimEnd(); +const rules = source.split('\n').filter(line => line.includes('b-desktop-')).join('\n'); +await writeFile(file, `${source}\n/* Responsive variants */\n@media(max-width:1024px){\n${rules.replaceAll('b-desktop-', 'b-tablet-')}\n}\n@media(max-width:767px){\n${rules.replaceAll('b-desktop-', 'b-mobile-')}\n}\n`); diff --git a/scripts/button-check.mjs b/scripts/button-check.mjs new file mode 100644 index 0000000..a4bb64d --- /dev/null +++ b/scripts/button-check.mjs @@ -0,0 +1,98 @@ +import { chromium } from '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/buttons'; await mkdir(report, { recursive: true }); +let browser, page; +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: 1600, height: 1050 } }); + 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'); + const api = `${origin}/admin/api/pages/${fixture.homeId}`; + const data = await (await context.request.get(api)).json(); + data.document.blocks.unshift({ id: 'qa-buttons-section', type: 'section', children: [ + { id: 'qa-button-default', type: 'button', props: { text: 'Kontakt aufnehmen', href: '#kontakt' } }, + { id: 'qa-button', type: 'button', props: { text: 'Mehr erfahren', href: '/kontakt' }, style: { desktop: { margin: { left: '12px' }, fontSize: '17px' } } } + ] }); + assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200); + await page.goto(origin + '/admin/theme'); + await page.locator('#buttons-fontSize').fill('18'); + await page.getByRole('button', { name: 'Standardbutton Rahmen · Pille', exact: true }).click(); + assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill'); + assert.equal(await page.locator('#buttons-appearance').inputValue(), 'outline'); + assert.equal(await page.locator('#button-defaults .button-state .cms-button').first().evaluate(el => getComputedStyle(el).borderRadius), '999px'); + await page.locator('#button-defaults').screenshot({ path: `${report}/theme.png` }); + await page.getByRole('button', { name: 'Design speichern', exact: true }).click(); + assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill'); + assert.equal(await page.locator('#buttons-fontSize').inputValue(), '18'); + checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows'); + + await page.goto(`${origin}/admin/pages/${fixture.homeId}`); + await page.locator('#structure [data-block-id="qa-button"]').click(); + await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click(); + const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => { + const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id); + if (!el) return false; + const s = doc.defaultView.getComputedStyle(el); + return s.borderRadius === radius && (background === undefined || (s.backgroundColor === 'rgba(0, 0, 0, 0)') === background); + }, { id, radius, background }); + await waitVariant('qa-button', '8px', false); + await page.locator('#undo').click(); await waitVariant('qa-button', '999px', true); + await page.locator('#redo').click(); await waitVariant('qa-button', '8px', false); + await page.locator('[data-viewport="tablet"]').click(); + await page.getByRole('button', { name: 'Button-Vorlage Rahmen · eckig', exact: true }).click(); + await waitVariant('qa-button', '0px', true); + await page.locator('[data-viewport="mobile"]').click(); + await page.getByRole('button', { name: 'Button-Vorlage Pille', exact: true }).click(); + await waitVariant('qa-button', '999px', false); + await page.locator('#button-reset').click(); await waitVariant('qa-button', '0px', true); + await page.locator('#undo').click(); await waitVariant('qa-button', '999px', false); + await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false); + await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` }); + await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); + await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false); + const stored = (await (await context.request.get(api)).json()).document; + assert.equal(stored.schemaVersion, 21); + assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' }); + assert.equal(stored.blocks[0].children[1].style.desktop.margin.left, '12px'); + assert.equal(stored.blocks[0].children[1].style.desktop.fontSize, '17px'); + checks.push('preset selection, device inheritance/reset, undo/redo, save/reload, preserved content and unrelated styling'); + await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); + await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); + const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage(); + for (const [width, radius, transparent] of [[1600, '8px', false], [900, '0px', true], [390, '999px', false]]) { + await publicPage.setViewportSize({ width, height: 900 }); await publicPage.goto(origin); + const values = await publicPage.locator('#qa-button').evaluate(el => { const s=getComputedStyle(el);return { radius:s.borderRadius, bg:s.backgroundColor, font:s.fontSize }; }); + assert.equal(values.radius, radius); assert.equal(values.bg === 'rgba(0, 0, 0, 0)', transparent); assert.equal(values.font, '17px'); + assert.equal(await publicPage.locator('#qa-button-default').evaluate(el => getComputedStyle(el).fontSize), '18px'); + await publicPage.locator('#qa-buttons-section').screenshot({ path: `${report}/public-${width}.png` }); + } + await plain.close(); checks.push('published global defaults and local responsive variants without JavaScript'); + assert.deepEqual(errors, []); + await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); + console.log('Button checks passed:', checks.length, 'groups.'); +} catch (error) { + if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); + throw error; +} finally { await browser?.close(); server.kill(); } diff --git a/scripts/navigation-ux-check.mjs b/scripts/navigation-ux-check.mjs new file mode 100644 index 0000000..02f9be9 --- /dev/null +++ b/scripts/navigation-ux-check.mjs @@ -0,0 +1,92 @@ +import { chromium } from '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/navigation-ux'; await mkdir(report, { recursive: true }); +let browser, page; +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: 1600, height: 1050 } }); + 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'); + const api = `${origin}/admin/api/pages/${fixture.homeId}`; + const data = await (await context.request.get(api)).json(); + data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] }); + assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200); + await page.goto(`${origin}/admin/pages/${fixture.homeId}`); + await page.locator('#structure [data-block-id="qa-anchor"]').click(); + await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur(); + await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); + await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); + const stored = (await (await context.request.get(api)).json()).document; + assert.equal(stored.blocks.at(-1).anchor, 'kontakt-check'); assert.equal(stored.schemaVersion, 21); + checks.push('named jump target created in editor and saved/published with stable block ID'); + + await page.goto(origin + '/admin/menus'); + const rows = page.locator('#menu-items > li'); + const originalFirst = await rows.first().locator('[data-key="label"]').inputValue(); + const handle = rows.first().locator('[data-menu-drag]'); + const start = await handle.boundingBox(), end = await rows.last().boundingBox(); + await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down(); + await page.mouse.move(start.x + start.width / 2, end.y + end.height - 2, { steps: 12 }); await page.mouse.up(); + assert.equal(await rows.last().locator('[data-key="label"]').inputValue(), originalFirst); + await page.locator('#menu-undo').click(); assert.equal(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst); + await rows.first().locator('[data-menu-drag]').focus(); await page.keyboard.press('ArrowDown'); + assert.notEqual(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst); + await page.locator('#menu-undo').click(); + await rows.first().locator('summary').click(); + await rows.first().locator('[data-key="icon"]').selectOption('phone'); + await rows.first().locator('[data-key="iconPosition"]').selectOption('after'); + assert.equal(await rows.first().locator('.menu-entry-title > svg').count(), 1); + await page.getByText('Eigener Link', { exact: true }).first().click(); + await page.locator('#link-label').fill('Zum Kontaktbereich'); await page.locator('#link-url').fill('#kontakt-check'); + await page.locator('#menu-add-url').click(); + await page.getByRole('button', { name: 'Menü speichern', exact: true }).click(); + await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor(); + await page.reload(); + assert.equal(await rows.first().locator('[data-key="icon"]').inputValue(), 'phone'); + assert.equal(await rows.first().locator('[data-key="iconPosition"]').inputValue(), 'after'); + await rows.first().locator('summary').click(); + await page.screenshot({ path: `${report}/menus.png`, fullPage: true }); + checks.push('pointer drag, keyboard ordering, undo, icon placement, fragment menu link and save/reload'); + const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage(); + await publicPage.goto(origin); + assert.ok(await publicPage.locator('.nav-label > svg').count() > 0); + await publicPage.getByRole('link', { name: 'Zum Kontaktbereich', exact: true }).first().click(); + assert.ok(publicPage.url().endsWith('#kontakt-check')); + assert.equal(await publicPage.locator('#kontakt-check').count(), 1); + assert.ok(await publicPage.evaluate(() => window.scrollY) > 0); + await plain.close(); + execFileSync(php, ['-r', 'require "vendor/autoload.php"; $db=App\\Infrastructure\\ConnectionFactory::create($argv[1]); $db->executeStatement("UPDATE scheduled_task SET last_status = ?, last_message = ? WHERE name = ?", ["ok", "Fassung 99.0.0 ist verfügbar; installiert ist 1.8.4.", "update-check"]);', fixture.databaseUrl], { windowsHide: true }); + await page.reload(); + assert.equal(await page.getByRole('link', { name: 'Update 99.0.0 verfügbar. Aktualisierung öffnen.', exact: true }).count(), 1); + assert.match(await page.locator('.version-update').getAttribute('href'), /system\?pruefen=1$/); + await page.locator('.shell-foot').screenshot({ path: `${report}/update-hint.png` }); + checks.push('public icon and native anchor navigation without JavaScript; cached update notice by version'); + assert.deepEqual(errors, []); + await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); + console.log('Navigation checks passed:', checks.length, 'groups.'); +} catch (error) { + if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); + throw error; +} finally { await browser?.close(); server.kill(); } diff --git a/src/Content/ButtonDesign.php b/src/Content/ButtonDesign.php new file mode 100644 index 0000000..17377cc --- /dev/null +++ b/src/Content/ButtonDesign.php @@ -0,0 +1,95 @@ + ['square' => 'Eckig', 'rounded' => 'Abgerundet', 'pill' => 'Pille'], + 'appearance' => ['solid' => 'Gefüllt', 'outline' => 'Rahmen', 'soft' => 'Dezent', 'link' => 'Textlink'], + 'size' => ['small' => 'Klein', 'normal' => 'Normal', 'large' => 'Groß'], + ]; + + public const PRESETS = [ + ['name' => 'Eckig', 'shape' => 'square', 'appearance' => 'solid'], + ['name' => 'Abgerundet', 'shape' => 'rounded', 'appearance' => 'solid'], + ['name' => 'Pille', 'shape' => 'pill', 'appearance' => 'solid'], + ['name' => 'Rahmen · eckig', 'shape' => 'square', 'appearance' => 'outline'], + ['name' => 'Rahmen · rund', 'shape' => 'rounded', 'appearance' => 'outline'], + ['name' => 'Rahmen · Pille', 'shape' => 'pill', 'appearance' => 'outline'], + ['name' => 'Dezent', 'shape' => 'rounded', 'appearance' => 'soft'], + ['name' => 'Textlink', 'shape' => 'square', 'appearance' => 'link'], + ]; + + public static function defaults(array $base): array + { + return ['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', + 'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', + 'backgroundColor' => $base['color'], 'color' => $base['backgroundColor'], + 'hoverBackgroundColor' => $base['color'], 'hoverColor' => $base['backgroundColor']]; + } + + public static function validate(array $input, array $base, array $colors, array $fonts): array + { + $value = [...self::defaults($base), ...$input]; + foreach ([...self::OPTIONS, 'textTransform' => ['none' => '', 'uppercase' => '']] as $key => $options) { + if (!is_string($value[$key]) || !isset($options[$value[$key]])) { + throw new InvalidPage('Unbekannte Button-Einstellung: '.$key); + } + } + foreach (['fontSize' => [10, 72], 'fontWeight' => [100, 900], 'letterSpacing' => [-1, 8]] as $key => [$min, $max]) { + if (!is_numeric($value[$key]) || $value[$key] < $min || $value[$key] > $max) { + throw new InvalidPage('Bitte prüfe den Button-Wert: '.$key); + } + $value[$key] = (float) $value[$key]; + } + if (!in_array((int) $value['fontWeight'], Theme::WEIGHTS, true) || $value['fontWeight'] != (int) $value['fontWeight']) { + throw new InvalidPage('Unbekannte Button-Schriftstärke.'); + } + if (!in_array($value['fontFamily'], ['inherit', ...array_column($fonts, 'key')], true)) { + throw new InvalidPage('Die Button-Schrift ist nicht vorhanden.'); + } + foreach (['backgroundColor', 'color', 'hoverBackgroundColor', 'hoverColor'] as $key) { + if (!is_string($value[$key]) || 1 !== preg_match(StyleSchema::COLOR, $value[$key]) + || (str_starts_with($value[$key], '@') && !in_array(substr($value[$key], 1), array_column($colors, 'key'), true))) { + throw new InvalidPage('Bitte prüfe die Button-Farbe: '.$key); + } + } + + return array_intersect_key($value, self::defaults($base)); + } + + /** Palette references stay live; block variants use these same variables. */ + public static function css(array $buttons): string + { + $color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v; + $out = [ + '--kb-fill' => $color($buttons['backgroundColor']), '--kb-ink' => $color($buttons['color']), + '--kb-hover-fill' => $color($buttons['hoverBackgroundColor']), '--kb-hover-ink' => $color($buttons['hoverColor']), + '--kb-font' => 'inherit' === $buttons['fontFamily'] ? 'inherit' : 'var(--kf-'.$buttons['fontFamily'].')', + '--kb-font-size' => $buttons['fontSize'].'px', '--kb-weight' => $buttons['fontWeight'], + '--kb-tracking' => $buttons['letterSpacing'].'px', '--kb-transform' => $buttons['textTransform'], + '--kb-radius' => ['square' => '0px', 'rounded' => '8px', 'pill' => '999px'][$buttons['shape']], + '--kb-py' => ['small' => '10px', 'normal' => '15px', 'large' => '19px'][$buttons['size']], + '--kb-px' => ['small' => '16px', 'normal' => '21px', 'large' => '30px'][$buttons['size']], + ]; + foreach (self::appearance($buttons['appearance']) as $key => $value) { + $out['--kb-'.$key] = $value; + } + + return ':root{'.implode(';', array_map(static fn ($key, $value): string => $key.':'.$value, array_keys($out), $out)).'}'; + } + + public static function appearance(string $name): array + { + return match ($name) { + 'outline' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'var(--kb-fill)', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'], + 'soft' => ['bg' => 'color-mix(in srgb,var(--kb-fill) 12%,transparent)', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-fill)', 'hover-fg' => 'var(--kb-ink)'], + 'link' => ['bg' => 'transparent', 'fg' => 'var(--kb-fill)', 'border' => 'transparent', 'hover-bg' => 'transparent', 'hover-fg' => 'var(--kb-hover-fill)'], + default => ['bg' => 'var(--kb-fill)', 'fg' => 'var(--kb-ink)', 'border' => 'transparent', 'hover-bg' => 'var(--kb-hover-fill)', 'hover-fg' => 'var(--kb-hover-ink)'], + }; + } +} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 2d49603..4573f46 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -42,7 +42,7 @@ final class DocumentValidator public function validate(array $document, bool $requireHeading = true): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], true), 'Unsupported document schema.'); $this->text($document['title'] ?? null, 160); $this->text($document['description'] ?? null, 320); $this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); @@ -52,6 +52,16 @@ final class DocumentValidator foreach ($document['blocks'] as $block) { $this->block($block, $ids, $headings, 0, $schemaVersion, null); } + $anchors = function (array $blocks) use (&$anchors, &$ids): void { + foreach ($blocks as $block) { + if (isset($block['anchor'])) { + $this->require(!isset($ids[$block['anchor']]) && !in_array($block['anchor'], ['main', 'top', 'admin-main'], true), 'Dieses Sprungziel ist bereits belegt. Bitte einen eindeutigen Namen wählen.'); + $ids[$block['anchor']] = true; + } + $anchors($block['children'] ?? []); + } + }; + $anchors($document['blocks']); $this->require( $requireHeading ? 1 === $headings : 0 === $headings, $requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.', @@ -65,6 +75,11 @@ final class DocumentValidator $this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.'); $ids[$id] = true; $type = $block['type'] ?? null; + if (isset($block['anchor'])) { + $this->require($schemaVersion >= 21 && in_array($type, ['section', 'row', 'column'], true) + && is_string($block['anchor']) && 1 === preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $block['anchor']), + 'Ein Sprungziel braucht einen Bereich und einen Namen wie kontakt (ohne #), Schema v21.'); + } // From schema v4 on, a namespaced type belongs to a plugin. The core does not know its // property schema, so it keeps the block untouched instead of rejecting or dropping the // page: a deactivated plugin must never make stored content unreadable. Nothing from @@ -96,6 +111,16 @@ final class DocumentValidator $layout = $block['layout'] ?? []; $this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.'); foreach ($layout as $key => $value) { + if ('button' === $key) { + $this->require($schemaVersion >= 21 && 'button' === $type && is_array($value), 'Button-Varianten brauchen einen Button und Schema v21.'); + foreach ($value as $device => $settings) { + $this->require(isset(StyleSchema::DEVICES[$device]) && is_array($settings), 'Unbekanntes Button-Gerät.'); + foreach ($settings as $setting => $token) { + $this->require(is_string($token) && isset(ButtonDesign::OPTIONS[$setting][$token]), 'Unbekannte Button-Variante.'); + } + } + continue; + } if ('effects' === $key && $schemaVersion >= 3) { $this->require(is_array($value), 'Effects must be an object.'); $effects = [ diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index 147e3a3..bc7c64d 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -132,7 +132,7 @@ final class MenuRepository if ('' === trim($label)) { continue; } - $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])]; + $links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])]; } return $links; @@ -156,6 +156,12 @@ final class MenuRepository if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) { throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.'); } + if (isset($item['icon']) && (!is_string($item['icon']) || !in_array($item['icon'], ['', ...DocumentValidator::ICONS], true))) { + throw new InvalidMenu('Unbekanntes Menü-Icon.'); + } + if (isset($item['iconPosition']) && !in_array($item['iconPosition'], ['before', 'after'], true)) { + throw new InvalidMenu('Ein Icon steht vor oder nach dem Text.'); + } if ('page' === ($item['type'] ?? null)) { if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) { throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.'); @@ -183,8 +189,9 @@ final class MenuRepository $allowed = $allowed || (str_starts_with($url, '/') && !str_starts_with($url, '//')); $allowed = $allowed || 1 === preg_match('/^mailto:[^@\s]+@[^@\s]+$/D', $url); $allowed = $allowed || 1 === preg_match('/^tel:\+?[0-9 ()-]{3,32}$/D', $url); + $allowed = $allowed || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D', $url); if (!$allowed) { - throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade sowie mailto: und tel:.'); + throw new InvalidMenu('Erlaubt sind HTTPS-Adressen, lokale Pfade, Sprungziele wie #kontakt sowie mailto: und tel:.'); } } } diff --git a/src/Content/StyleBundle.php b/src/Content/StyleBundle.php index bd522b6..6e4f5c1 100644 --- a/src/Content/StyleBundle.php +++ b/src/Content/StyleBundle.php @@ -20,7 +20,7 @@ namespace App\Content; final class StyleBundle { /** Die Reihenfolge ist die Reihenfolge im Kopf der Seite - später gewinnt. */ - private const SOURCES = ['fonts/fonts.css', 'site.css', 'design.css', 'enhancements.css']; + private const SOURCES = ['fonts/fonts.css', 'site.css', 'buttons.css', 'design.css', 'enhancements.css']; public function __construct(private string $assetDirectory) { diff --git a/src/Content/Theme.php b/src/Content/Theme.php index 2b60482..96b7472 100644 --- a/src/Content/Theme.php +++ b/src/Content/Theme.php @@ -62,6 +62,7 @@ final class Theme /** Remembered for one request, like the settings; dropped on every write. */ private ?array $loaded = null; + private bool $buttonsConfigured = false; public function load(): array { @@ -70,8 +71,9 @@ final class Theme } $raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']); $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; + $this->buttonsConfigured = is_array($stored['buttons'] ?? null); if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) { - return $this->loaded = [...self::DEFAULTS, 'presets' => []]; + return $this->loaded = [...self::DEFAULTS, 'presets' => [], 'buttons' => ButtonDesign::defaults(self::DEFAULTS['base'])]; } return $this->loaded = [ @@ -79,10 +81,11 @@ final class Theme 'fonts' => $stored['fonts'], 'base' => [...self::DEFAULTS['base'], ...$stored['base']], 'presets' => is_array($stored['presets'] ?? null) ? $stored['presets'] : [], + 'buttons' => [...ButtonDesign::defaults([...self::DEFAULTS['base'], ...$stored['base']]), ...($stored['buttons'] ?? [])], ]; } - public function save(array $colors, array $fonts, array $base): void + public function save(array $colors, array $fonts, array $base, ?array $buttons = null): void { $theme = [ 'colors' => $this->colors($colors), @@ -91,6 +94,9 @@ final class Theme // Saving the palette must not drop the presets an editor has collected. 'presets' => $this->load()['presets'], ]; + if (null !== $buttons || $this->buttonsConfigured) { + $theme['buttons'] = ButtonDesign::validate($buttons ?? $this->load()['buttons'], $theme['base'], $theme['colors'], $theme['fonts']); + } $keys = array_column($theme['colors'], 'key'); foreach (['color', 'backgroundColor', 'linkColor'] as $field) { $value = $theme['base'][$field]; @@ -119,7 +125,7 @@ final class Theme foreach ($input as $row) { $key = strtolower(trim((string) ($row['key'] ?? ''))); $value = trim((string) ($row['value'] ?? '')); - if ('' === $key && '' === $value) { + if ('' === $key) { continue; } if (1 !== preg_match('/^[a-z][a-z0-9-]{0,31}$/D', $key) || isset($seen[$key])) { @@ -242,6 +248,9 @@ final class Theme $compiler->validate($style); } $theme = $this->load(); + if (!$this->buttonsConfigured) { + unset($theme['buttons']); + } $presets = array_values(array_filter($theme['presets'], static fn (array $p): bool => $p['key'] !== $key)); if (null !== $style) { if (count($presets) >= self::MAX_PRESETS) { @@ -317,7 +326,7 @@ final class Theme $colour($base['linkColor']), ($base['linkUnderline'] ?? true) ? 'underline' : 'none', $headings, - ); + ).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base)); } /** diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index d827b55..69fc436 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -131,6 +131,9 @@ final class EditorController extends AbstractController 'fonts' => $theme['fonts'], 'presets' => $theme['presets'], 'base' => $theme['base'], + 'buttons' => $theme['buttons'], + 'buttonOptions' => \App\Content\ButtonDesign::OPTIONS, + 'buttonPresets' => \App\Content\ButtonDesign::PRESETS, 'csrfToken' => $this->csrf->getToken('editor')->getValue(), ]); } diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index 2b3db0f..97b8952 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -36,6 +36,7 @@ final class MenuController extends AbstractController return $this->render('admin/menus.html.twig', [ 'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot], 'menu' => $menu, 'rows' => $rows, + 'menuIcons' => \App\Content\DocumentValidator::ICONS, ]); } @@ -77,6 +78,7 @@ final class MenuController extends AbstractController 'label' => $item['label'], 'type' => $item['type'], 'level' => $level, 'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '', 'newTab' => $item['newTab'] ?? false, + 'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before', ]; array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1)); } @@ -103,6 +105,7 @@ final class MenuController extends AbstractController 'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'), 'url' => trim((string) ($row['url'] ?? '')), 'newTab' => '1' === (string) ($row['newTab'] ?? ''), + '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), ]; @@ -112,6 +115,10 @@ final class MenuController extends AbstractController $items = []; foreach ($flat as $row) { $item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []]; + if ('' !== $row['icon']) { + $item['icon'] = $row['icon']; + $item['iconPosition'] = $row['iconPosition']; + } if ($row['newTab']) { $item['newTab'] = true; } diff --git a/src/Controller/ThemeController.php b/src/Controller/ThemeController.php index cd25576..772f285 100644 --- a/src/Controller/ThemeController.php +++ b/src/Controller/ThemeController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Content\ButtonDesign; use App\Content\FontLibrary; use App\Content\Theme; use App\Domain\InvalidPage; @@ -25,6 +26,8 @@ final class ThemeController extends AbstractController 'theme' => $theme, 'fallbacks' => Theme::FALLBACKS, 'weights' => Theme::WEIGHTS, + 'buttonOptions' => ButtonDesign::OPTIONS, + 'buttonPresets' => ButtonDesign::PRESETS, 'fontsLocal' => $this->fonts->hasLocal($theme), 'fontsMissing' => $this->fonts->missing($theme), ]); @@ -83,7 +86,7 @@ final class ThemeController extends AbstractController $fonts[] = $row; } try { - $this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base')); + $this->theme->save($request->request->all('colors'), $fonts, $request->request->all('base'), $request->request->has('buttons') ? $request->request->all('buttons') : null); } catch (InvalidPage $e) { $this->addFlash('error', $e->getMessage()); diff --git a/src/Twig/VersionExtension.php b/src/Twig/VersionExtension.php index f05915c..21c4264 100644 --- a/src/Twig/VersionExtension.php +++ b/src/Twig/VersionExtension.php @@ -14,12 +14,28 @@ use Twig\TwigFunction; */ final class VersionExtension extends AbstractExtension { - public function __construct(private Updater $updater) + public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db) { } public function getFunctions(): array { - return [new TwigFunction('kant_version', $this->updater->version(...))]; + return [new TwigFunction('kant_version', $this->updater->version(...)), new TwigFunction('kant_update_version', $this->available(...))]; + } + + public function available(): ?string + { + // The daily worker already checks releases. Rendering navigation never contacts Git. + try { + $message = $this->db->fetchOne('SELECT last_message FROM scheduled_task WHERE name = ?', ['update-check']); + if (is_string($message) && 1 === preg_match('/^Fassung (\d+\.\d+\.\d+) ist verfügbar/', $message, $match) + && version_compare($match[1], $this->updater->version(), '>')) { + return $match[1]; + } + } catch (\Doctrine\DBAL\Exception) { + // An installation still waiting for migrations has no task table yet. + } + + return null; } } diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 21c4794..dcd41a8 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -18,7 +18,7 @@ Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie nicht mehr. #} diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index f2cbb83..e892d21 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -4,7 +4,7 @@ {% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %} {% block title %}{{ subject.title }} bearbeiten{% endblock %} {% block body_class %}editor-body{% endblock %} -{% block styles %}{% endblock %} +{% block styles %}{% endblock %} {% block scripts %}{% endblock %} {% block body %} {# The API base decides what is edited. An area is shared, so it has no draft and no diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig index ad21486..4d1a069 100644 --- a/templates/admin/menu-entry.html.twig +++ b/templates/admin/menu-entry.html.twig @@ -3,7 +3,7 @@
- {{ row.label }}{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }} + {{ row.label }}{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}
{% if row.type == 'page' %} @@ -13,6 +13,7 @@ {% endif %} +
diff --git a/templates/admin/menu-icons.html.twig b/templates/admin/menu-icons.html.twig new file mode 100644 index 0000000..03d1154 --- /dev/null +++ b/templates/admin/menu-icons.html.twig @@ -0,0 +1 @@ +{% for icon in menuIcons %}{% endfor %} diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index fab499c..ca94b23 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -5,8 +5,9 @@ {% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %} {% block scripts %}{% endblock %} {% block content %} +{% include 'admin/menu-icons.html.twig' %} -

Farben entweder als @schlüssel aus der Palette oder als Wert wie #1a2b3c.

+
+ Standardbutton +

Die Grundlage für alle Button-Elemente. Eigene Varianten und Gestaltung im Editor haben Vorrang.

+
+
+ {% for key, options in buttonOptions %}
{% endfor %} +
+
+
+
+
+ {% for key, label in {backgroundColor: 'Button-Farbe', color: 'Button-Textfarbe', hoverBackgroundColor: 'Button-Farbe beim Überfahren', hoverColor: 'Textfarbe beim Überfahren'} %}
{% endfor %} +
+

Live-Vorschau

{% for preset in buttonPresets %}{% endfor %}
+
+
diff --git a/templates/archive.html.twig b/templates/archive.html.twig index b379fb7..4543cd6 100644 --- a/templates/archive.html.twig +++ b/templates/archive.html.twig @@ -17,6 +17,7 @@ {% include 'favicon.html.twig' %} + {% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %} diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index b939f2b..d524d7d 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -8,7 +8,8 @@ {% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %} {% if type in ['section', 'row', 'column'] %} {% set tag = type == 'section' ? 'section' : 'div' %} - <{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}"> + <{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}{{ block.anchor|default('') ? ' has-anchor' : '' }}"> + {% if block.anchor|default('') %}{% endif %} {% if type == 'section' %}
{% endif %} {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %} {% if type == 'section' %}
{% endif %} @@ -24,7 +25,8 @@

{{ props.text }}

{% endif %} {% elseif type == 'button' %} - {{ props.text }} + {% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %} + {{ props.text }} {% elseif type == 'image' %} {# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit 190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine diff --git a/templates/nav-label.html.twig b/templates/nav-label.html.twig new file mode 100644 index 0000000..bf43ece --- /dev/null +++ b/templates/nav-label.html.twig @@ -0,0 +1 @@ +{% if link.icon|default('') and link.iconPosition|default('before') == 'before' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}{{ link.label }}{% if link.icon|default('') and link.iconPosition|default('before') == 'after' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %} diff --git a/templates/nav.html.twig b/templates/nav.html.twig index de99a8e..e572e35 100644 --- a/templates/nav.html.twig +++ b/templates/nav.html.twig @@ -4,14 +4,14 @@
  • {% if link.children is not empty %} {% else %} - {{ link.label }} + {% include 'nav-label.html.twig' with {link: link} only %} {% endif %}
  • {% endfor %} diff --git a/templates/page.html.twig b/templates/page.html.twig index d456b6d..32b88f7 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -36,6 +36,7 @@ {% else %} + {% if localFonts|default([]) %}{% endif %} {% if page.document.schemaVersion >= 2 %}{% endif %} {% if page.document.schemaVersion >= 3 %}{% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 2aa0dd3..5a59837 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -575,11 +575,11 @@ final class CmsTest extends TestCase self::assertStringContainsString('href="tel:+4972219957150"', $html); // The home link is the current page; an anchor inside it is not. self::assertSame(1, substr_count($html, 'aria-current="page"')); - self::assertStringContainsString('Start', $html); + self::assertMatchesRegularExpression('~\s*Start\s*~', $html); // On another page the marker moves and nothing else changes. $other = $this->request('/ueber-uns')->getContent(); - self::assertStringContainsString('Ueber uns', $other); + self::assertMatchesRegularExpression('~\s*Ueber uns\s*~', $other); self::assertStringNotContainsString('', $other); }