Add visual button presets, jump targets and menu controls
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

This commit is contained in:
kantarci committed 2026-09-14 13:47:49 +02:00
1 parent bb4e422af8
commit 3391fea58b
41 files changed
+723 -57

No files matched your search

+10
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.8.4
1.9.0
+12
View File
@@ -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.<Gerät>`. 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.
+8
View File
@@ -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).
+3
View File
@@ -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",
+4
View File
@@ -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}}
+1
View File
@@ -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}}
+95
View File
@@ -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);
}
+28
View File
@@ -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}
}
+28
View File
@@ -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}
+2 -1
View File
@@ -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) {
-28
View File
@@ -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)}
+16 -1
View File
@@ -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;
+65 -3
View File
@@ -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 = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg>';
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 = '<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>';
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);
+2
View File
@@ -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}
+39
View File
@@ -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();
}
+5
View File
@@ -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`);
+98
View File
@@ -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(); }
+92
View File
@@ -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(); }
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
/** Shared vocabulary for global button defaults and the responsive block variants. */
final class ButtonDesign
{
public const OPTIONS = [
'shape' => ['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)'],
};
}
}
+26 -1
View File
@@ -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 = [
+9 -2
View File
@@ -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:.');
}
}
}
+1 -1
View File
@@ -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)
{
+13 -4
View File
@@ -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));
}
/**
+3
View File
@@ -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(),
]);
}
+7
View File
@@ -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;
}
+4 -1
View File
@@ -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());
+18 -2
View File
@@ -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;
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
nicht mehr. #}
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
{%- for modul in ['editor-model', 'media', 'design-panel', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+1 -1
View File
@@ -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 %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no
+2 -1
View File
@@ -3,7 +3,7 @@
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
<details>
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="muted">{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }}</span></summary>
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="menu-entry-fields">
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
{% if row.type == 'page' %}
@@ -13,6 +13,7 @@
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
{% endif %}
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
</div>
</details>
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
+1
View File
@@ -0,0 +1 @@
{% for icon in menuIcons %}<template id="menu-icon-{{ icon }}">{% include 'blocks/icon.html.twig' with {name: icon} only %}</template>{% endfor %}
+2 -1
View File
@@ -5,8 +5,9 @@
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{% include 'admin/menu-icons.html.twig' %}
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form">
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
<section class="card card-body"><h2>Seiten und Beiträge</h2>
+1 -1
View File
@@ -54,7 +54,7 @@
</nav>
</details>
<div class="shell-foot">
<p class="shell-version">Fassung {{ kant_version() }}</p>
<p class="shell-version">{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">Fassung {{ kant_version() }}</a>{% set updateVersion = kant_update_version() %}{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}{% else %}Fassung {{ kant_version() }}{% endif %}</p>
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
<form action="{{ path('admin_logout') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
+18
View File
@@ -1,5 +1,7 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'theme' %}
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
{% block title %}Design{% endblock %}
{% block heading %}Farben, Schriften, Grundtypografie{% endblock %}
{% block lead %}Das ist die Grundlage für die ganze Website. Jeder Block erbt von hier und kann jeden Wert einzeln überschreiben – je Gerät und beim Überfahren.{% endblock %}
@@ -108,6 +110,22 @@
</div>
</div>
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
<fieldset class="button-defaults" id="button-defaults" data-schema="{{ {colors: theme.colors, fonts: theme.fonts, buttonPresets: buttonPresets}|json_encode|e('html_attr') }}">
<legend>Standardbutton</legend>
<p class="field-help">Die Grundlage für alle Button-Elemente. Eigene Varianten und Gestaltung im Editor haben Vorrang.</p>
<div class="button-defaults-layout">
<div class="fields">
{% for key, options in buttonOptions %}<div><label for="buttons-{{ key }}">{{ {shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe'}[key] }}</label><select id="buttons-{{ key }}" name="buttons[{{ key }}]">{% for value, label in options %}<option value="{{ value }}"{{ theme.buttons[key] == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>{% endfor %}
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]"><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
<div><label for="buttons-fontSize">Button-Schriftgröße (px)</label><input id="buttons-fontSize" type="number" min="10" max="72" step="0.5" name="buttons[fontSize]" value="{{ theme.buttons.fontSize }}"></div>
<div><label for="buttons-fontWeight">Button-Schriftstärke</label><select id="buttons-fontWeight" name="buttons[fontWeight]">{% for weight in weights %}<option value="{{ weight }}"{{ theme.buttons.fontWeight == weight ? ' selected' : '' }}>{{ weight }}</option>{% endfor %}</select></div>
<div><label for="buttons-letterSpacing">Buchstabenabstand (px)</label><input id="buttons-letterSpacing" type="number" min="-1" max="8" step="0.1" name="buttons[letterSpacing]" value="{{ theme.buttons.letterSpacing }}"></div>
<div><label for="buttons-textTransform">Schreibweise</label><select id="buttons-textTransform" name="buttons[textTransform]"><option value="none"{{ theme.buttons.textTransform == 'none' ? ' selected' : '' }}>Wie eingegeben</option><option value="uppercase"{{ theme.buttons.textTransform == 'uppercase' ? ' selected' : '' }}>GROSSBUCHSTABEN</option></select></div>
{% for key, label in {backgroundColor: 'Button-Farbe', color: 'Button-Textfarbe', hoverBackgroundColor: 'Button-Farbe beim Überfahren', hoverColor: 'Textfarbe beim Überfahren'} %}<div><label for="buttons-{{ key }}">{{ label }}</label><input id="buttons-{{ key }}" name="buttons[{{ key }}]" value="{{ theme.buttons[key] }}" data-button-color></div>{% endfor %}
</div>
<div class="button-preview-column"><h3>Live-Vorschau</h3><div class="button-state-previews" aria-label="Standardbutton-Vorschau"></div><div class="button-preset-grid" role="group" aria-label="Standardbutton-Vorlagen">{% for preset in buttonPresets %}<button type="button" class="button-preset" data-button-preset aria-label="Standardbutton {{ preset.name }}"><span class="button-preset-label">{{ preset.name }}</span></button>{% endfor %}</div></div>
</div>
</fieldset>
<button class="primary" type="submit">Design speichern</button>
</form>
+1
View File
@@ -17,6 +17,7 @@
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
</head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
+4 -2
View File
@@ -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('') %}<span id="{{ block.anchor }}" class="block-anchor" aria-hidden="true"></span>{% endif %}
{% if type == 'section' %}<div class="shell section-inner">{% 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' %}</div>{% endif %}
@@ -24,7 +25,8 @@
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
{% endif %}
{% elseif type == 'button' %}
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
{% 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
+1
View File
@@ -0,0 +1 @@
<span class="nav-label">{% if link.icon|default('') and link.iconPosition|default('before') == 'before' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}<span>{{ link.label }}</span>{% if link.icon|default('') and link.iconPosition|default('before') == 'after' %}{% include 'blocks/icon.html.twig' with {name: link.icon} only %}{% endif %}</span>
+3 -3
View File
@@ -4,14 +4,14 @@
<li>
{% if link.children is not empty %}
<details class="menu-branch">
<summary>{{ link.label }}</summary>
<summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary>
<div class="menu-branch-body">
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
</div>
</details>
{% else %}
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% include 'nav-label.html.twig' with {link: link} only %}</a>
{% endif %}
</li>
{% endfor %}
+1
View File
@@ -36,6 +36,7 @@
<link rel="stylesheet" href="{{ styleBundle }}">
{% else %}
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
{% if localFonts|default([]) %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css?v={{ kant_version() }}">{% endif %}
+2 -2
View File
@@ -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('<a href="/" aria-current="page">Start</a>', $html);
self::assertMatchesRegularExpression('~<a href="/" aria-current="page">\s*<span class="nav-label"><span>Start</span></span>\s*</a>~', $html);
// On another page the marker moves and nothing else changes.
$other = $this->request('/ueber-uns')->getContent();
self::assertStringContainsString('<a href="/ueber-uns" aria-current="page">Ueber uns</a>', $other);
self::assertMatchesRegularExpression('~<a href="/ueber-uns" aria-current="page">\s*<span class="nav-label"><span>Ueber uns</span></span>\s*</a>~', $other);
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
}