diff --git a/README.md b/README.md index a946ecc..1cb57c6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website ## Anmelden und bearbeiten +**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`. + **Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position. Für eine neue Installation im interaktiven Terminal ein Konto anlegen: diff --git a/VERSION b/VERSION index a8fdfda..53adb84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.1 +1.8.2 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f73a69..a938877 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,14 @@ Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026) + +Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt. + +Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap. + +**Migration:** Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder. + ## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026) Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt. diff --git a/docs/STATUS.md b/docs/STATUS.md index 03d144a..84f194b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,15 @@ # Projektstatus +## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026) + +**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084). + +**Geprüft:** `npm run test:colors` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: Token-Vorschau, Auswahl/Kanvas, Tastatur/Tab/Escape/Fokus, Undo/Redo, Geräte-/Hover-Vererbung und Reset, stabiler nativer Farbwähler mit einem Undo-Schritt, reine change-Ereignisse, ungültige Eingaben, Kurzhex, achtstellige Hexwerte, Speichern/Laden und Veröffentlichung mit erhaltener Palettenreferenz. Desktop 1600/Mobil 390: geöffnete Farbauswahl und Editor ohne axe-Verstöße oder horizontalen Überlauf; Screenshots `reports/color-picker/` visuell geprüft. Originaldaten unverändert. + +**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Gestaltung, Vorlagen, Speichern/Veröffentlichen, Konflikten, Mediathek, Einwilligung sowie Desktop-/Mobil-Barrierefreiheit. Der bisherige Test zur automatisch angehängten Einwilligungszeile wurde an die bereits veröffentlichte Änderung aus 1.8.1 angepasst. 32 JavaScript-Tests einschließlich versionierter Modulimporte bestanden. Geänderte Twig-Basis und Produktionscontainer gültig; Git-Diff ohne Whitespacefehler. + +**Grenzen / nächster Schritt:** Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Änderung am Editor. Verwaltungsformulare außerhalb des Editors behalten ihre bisherigen Farbfelder. Release 1.8.2 bereitstellen und über die Systemverwaltung einspielen; Kundenhostings werden hier nicht verändert. + ## 1.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026) **Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083). diff --git a/package.json b/package.json index 44a04e9..64d5369 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:search": "node scripts/live-search-check.mjs", "test:image-size": "node scripts/image-size-check.mjs", "test:link-media": "node scripts/link-media-check.mjs", - "test:404": "node scripts/not-found-check.mjs" + "test:404": "node scripts/not-found-check.mjs", + "test:colors": "node scripts/color-picker-check.mjs" }, "devDependencies": { "@axe-core/playwright": "^4.10.0", diff --git a/public/assets/color-control.js b/public/assets/color-control.js new file mode 100644 index 0000000..fadf523 --- /dev/null +++ b/public/assets/color-control.js @@ -0,0 +1,125 @@ +/** A compact palette picker. Palette references stay references when saved. */ +let sequence = 0; +const node = (tag, className, text) => { + const el = document.createElement(tag); + el.className = className; + if (text !== undefined) el.textContent = text; + return el; +}; +const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i; + +export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) { + const wrap = node('div', 'color-control'); + const trigger = node('button', 'color-trigger'); + trigger.type = 'button'; trigger.id = id; + trigger.setAttribute('aria-expanded', 'false'); + trigger.setAttribute('aria-controls', `${id}-choices`); + const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true'); + const ink = node('span', 'color-sample-ink'); sample.append(ink); + const description = node('span', 'color-description'); + const name = node('span', 'color-name'), detail = node('span', 'color-detail'); + description.append(name, detail); + const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true'); + trigger.append(sample, description, arrow); + + const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true; + const heading = node('p', 'color-heading', 'Website-Farben'); + const palette = node('div', 'color-palette'); + palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`); + const buttons = []; + let current = value, editing = false, mergeKey = `color-${++sequence}`; + const entryFor = raw => colors.find(color => `@${color.key}` === raw); + const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || ''; + const restoreFocus = () => document.getElementById(id)?.focus(); + const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); }; + for (const color of colors) { + const button = node('button', 'color-option'); button.type = 'button'; + button.dataset.color = `@${color.key}`; + button.title = `${color.name} · ${color.value}`; + button.setAttribute('aria-label', button.title); + const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value; + swatch.setAttribute('aria-hidden', 'true'); + button.append(swatch, node('span', 'color-option-name', color.name)); + button.addEventListener('click', () => choose(`@${color.key}`)); + buttons.push(button); palette.append(button); + } + if (colors.length) choices.append(heading, palette); + + const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`; + const custom = node('div', 'color-custom'); + const picker = node('input', 'color-native'); picker.type = 'color'; + picker.setAttribute('aria-label', `Eigene Farbe für ${label}`); + const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`; + text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB'; + text.setAttribute('aria-label', `Farbwert für ${label}`); + const accept = node('button', 'color-accept', '✓'); accept.type = 'button'; + accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen'; + custom.append(picker, text, accept); + const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true; + error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id); + const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button'; + clear.addEventListener('click', () => choose('')); + choices.append(customLabel, custom, error, clear); + wrap.append(trigger, choices); + + function sync() { + const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown); + ink.style.backgroundColor = hex || 'transparent'; + name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen'); + detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · '); + trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`); + picker.value = hex ? hex.slice(0, 7) : '#ffffff'; + text.value = hex || current || ''; + for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown)); + clear.disabled = current === ''; + } + const finish = () => { + if (!editing) return; + editing = false; + onComplete(); + }; + trigger.addEventListener('click', () => { + if (!choices.hidden) { + choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus(); + } else { + choices.hidden = false; trigger.setAttribute('aria-expanded', 'true'); + } + }); + // A native picker emits many input events during one gesture. Keep its DOM and one undo entry. + picker.addEventListener('input', () => { + current = picker.value + (resolve(current || inheritedValue).slice(7) || ''); + editing = true; sync(); onChange(current, mergeKey); + }); + picker.addEventListener('change', () => { + current = picker.value + (resolve(current || inheritedValue).slice(7) || ''); + sync(); onChange(current, editing ? mergeKey : undefined); + if (editing) finish(); + restoreFocus(); + }); + const applyText = () => { + let raw = text.value.trim(); + if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join(''); + if (raw && !hexPattern.test(raw) && !entryFor(raw)) { + text.setAttribute('aria-invalid', 'true'); error.hidden = false; + error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.'; + text.focus(); return; + } + choose(raw); + }; + text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; }); + text.addEventListener('keydown', event => { + if (event.key === 'Enter') { event.preventDefault(); applyText(); } + }); + accept.addEventListener('click', applyText); + wrap.addEventListener('keydown', event => { + if (event.key !== 'Escape' || choices.hidden) return; + event.preventDefault(); event.stopPropagation(); choices.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus(); + }); + // Commit after leaving the native picker even on browsers that omit its change event. + wrap.addEventListener('focusout', () => setTimeout(() => { + if (wrap.isConnected && !wrap.contains(document.activeElement)) finish(); + }, 0)); + sync(); + return wrap; +} diff --git a/public/assets/design-panel.js b/public/assets/design-panel.js index 7b9a67c..1abd875 100644 --- a/public/assets/design-panel.js +++ b/public/assets/design-panel.js @@ -7,6 +7,7 @@ */ import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js'; import { openMedia, createMediaPreview } from './media.js'; +import { createColorControl } from './color-control.js'; export const GROUPS = { text: 'Typografie', @@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge return wrap; }; - const colorControl = (id, value, apply) => { - const wrap = element('div', 'color-control'); - const picker = document.createElement('input'); - picker.type = 'color'; - picker.id = id; - picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000'; - const text = document.createElement('input'); - text.type = 'text'; - text.className = 'color-text'; - text.value = value ?? ''; - text.placeholder = '#1a2b3c'; - text.setAttribute('aria-label', 'Farbwert oder Palettenname'); - const palette = document.createElement('select'); - palette.className = 'color-palette'; - palette.setAttribute('aria-label', 'Farbe aus der Palette'); - palette.append(new Option('Palette …', '')); - for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`)); - palette.value = (value ?? '').startsWith('@') ? value : ''; - - picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); }); - text.addEventListener('change', () => apply(text.value.trim())); - palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } }); - wrap.append(picker, text, palette); - return wrap; - }; + const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({ + id, value, label, inheritedValue, colors: schema.colors, onChange: apply, + onComplete: () => { + const focusedId = document.activeElement?.id; + onChange(); + if (focusedId) document.getElementById(focusedId)?.focus(); + }, + }); const lengthControl = (id, value, apply, placeholder, keywords = []) => { const wrap = element('div', 'length-control'); @@ -539,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge body.append(sideHead, sideControl(key, block, deviceName, property)); continue; } - if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply); + if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || ''); else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []); else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply); else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText); @@ -552,9 +536,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge if (schema.hoverable.includes(key)) { const hoverId = `${id}-hover`; const hoverValue = hoverStyle(block, deviceName, key); + let hoverInherited = current.value || ''; + for (const name of Object.keys(devices)) { + if (name === deviceName) break; + hoverInherited = hoverStyle(block, name, key) ?? hoverInherited; + } const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey)); const hoverControl = property.type === 'color' - ? colorControl(hoverId, hoverValue ?? '', hoverApply) + ? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited) : property.type === 'number' ? numberControl(hoverId, hoverValue ?? '', property, hoverApply) : lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover'); diff --git a/public/assets/editor.css b/public/assets/editor.css index a7f9c1f..5067d7f 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -131,15 +131,39 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)} .design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px} .design-row{margin-top:14px} .design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px} -.design-row-head label{margin:0;font-size:11px} +.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0} .design-own .design-row-head label{color:#2f5d4a;font-weight:700} .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{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center} -.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer} -.color-control .color-text{font-size:12px;min-height:38px} -.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px} +.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden} +.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer} +.color-trigger:hover{background:#f5f7f3} +.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} +.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px} +.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit} +.color-description{display:grid;gap:3px;flex:1;min-width:0} +.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere} +.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere} +.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c} +.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)} +.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8} +.color-choices[hidden]{display:none} +.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258} +.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin} +.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer} +.color-option:hover{background:#eef3ea;border-color:#718575} +.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8} +.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426} +.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere} +.color-custom{display:flex;align-items:center;gap:6px} +.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff} +.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px} +.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer} +.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0} +.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer} +.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none} .length-control{display:grid;grid-template-columns:1fr 74px;gap:6px} .length-control input,.length-control select{font-size:12px;min-height:38px} .length-control.is-inherited input{color:var(--muted)} diff --git a/scripts/color-picker-check.mjs b/scripts/color-picker-check.mjs new file mode 100644 index 0000000..8254995 --- /dev/null +++ b/scripts/color-picker-check.mjs @@ -0,0 +1,147 @@ +import { chromium } from 'playwright'; +import AxeBuilder from '@axe-core/playwright'; +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import net from 'node:net'; + +const php = process.env.PHP_BINARY || 'php'; +const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); +const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; +execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env }); +const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); +const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); +const report = 'reports/color-picker'; 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(); + const schema = await (await context.request.get(origin + '/admin/api/design')).json(); + const deep = schema.colors.find(c => c.key === 'deep'); + const accent = schema.colors.find(c => c.key === 'accent'); + assert.ok(deep && accent); + data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: { + backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788', + hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' }, + } }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '
Farben direkt im Blick
' } }] }); + 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}`); + const select = async () => { + await page.locator('#structure [data-block-id="qa-color"]').click(); + await page.locator('[data-panel="design"]').click(); + const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) }); + if (!(await group.getAttribute('open'))) { + if (!(await group.evaluate(el => el.open))) await group.locator('summary').click(); + } + }; + await select(); + const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`); + const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`); + const sample = async (hex, key = 'backgroundColor') => { + await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key }); + assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent')); + }; + const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); }; + const frameColor = async hex => { + const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`; + await page.waitForFunction(rgb => { + const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color'); + return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb; + }, rgb); + }; + await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name)); + await trigger().focus(); await page.keyboard.press('Enter'); + assert.equal(await trigger().getAttribute('aria-expanded'), 'true'); + await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true); + await choose('@accent'); await sample(accent.value); await frameColor(accent.value); + await page.locator('#undo').click(); await sample(deep.value); + await page.locator('#redo').click(); await sample(accent.value); + await page.locator('#undo').click(); await sample(deep.value); + checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo'); + + await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value); + assert.match(await trigger().innerText(), /Geerbt/); + await sample(accent.value, 'backgroundColor-hover'); + await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value); + await page.locator('[data-viewport="tablet"]').click(); + await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value); + await page.locator('[data-viewport="desktop"]').click(); + checks.push('desktop/tablet/mobile inheritance including hover colors and reset'); + + await trigger().click(); + await choices().locator('.color-native').evaluate(el => { + window.colorPickerNode = el; + el.focus(); + for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); } + }); + assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true); + await frameColor('#334455'); + await choices().locator('.color-native').dispatchEvent('change'); + await page.locator('#undo').click(); await sample(deep.value); + await trigger().click(); + await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); }); + await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true); + await page.locator('#undo').click(); await sample(deep.value); + await trigger().click(); + const hex = page.locator('#style-backgroundColor-hex'); + await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true'); + await sample(deep.value); + await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc'); + await page.locator('#undo').click(); await sample(deep.value); + await trigger('gradientFrom').click(); + await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }); + await sample('#22446644', 'gradientFrom'); + await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom'); + checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved'); + + await choose('@accent'); + await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); + await page.reload(); await select(); await sample(accent.value); + const saved = await (await context.request.get(api)).json(); + assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent'); + assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344'); + await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); + await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); + const visitor = await context.newPage(); await visitor.goto(origin); + assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`); + await visitor.close(); + checks.push('save, reload and publication preserve palette references and alpha values'); + + for (const width of [1600, 390]) { + await page.setViewportSize({ width, height: 1050 }); + if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click(); + await choices().scrollIntoViewIfNeeded(); + await trigger().evaluate(el => { + const panel = el.closest('.right-panel'); + panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16; + }); + const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); + assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true); + await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` }); + } + checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow'); + assert.deepEqual(errors, []); + await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); + console.log('Color picker checks passed:', checks.length, 'groups.'); +} catch (error) { + if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); + throw error; +} finally { await browser?.close(); server.kill(); } diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 9f1bf97..0f47920 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -487,7 +487,8 @@ try { await frameValue('#headline', 'fontSize', '72px'); // Colours: a palette reference resolves to the theme variable, a hex value is taken as it is. - await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link'); + await page.locator('#style-color').click(); + await page.locator('#style-color-choices [data-color="@link"]').click(); await frameValue('#headline', 'color', 'rgb(164, 81, 15)'); const contrast = page.locator('.contrast-note'); await contrast.waitFor(); @@ -572,9 +573,10 @@ try { // The background group is collapsed by default, and the readable field next to the swatch is // what a person types into. await openGroup('Hintergrund'); - const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]'); + await page.locator('#style-backgroundColor-hover').click(); + const hoverColor = page.locator('#style-backgroundColor-hover-hex'); await hoverColor.fill('#112233'); - await hoverColor.blur(); + await hoverColor.press('Enter'); // The preview carries its stylesheet as template text, applied by the editor as a constructed // stylesheet; the hover rule has to arrive through that path, not as a style element. await page.waitForFunction(() => { @@ -857,7 +859,7 @@ try { await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click(); await visitorPage.locator('iframe').waitFor({ state: 'attached' }); assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.'); - assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.'); + assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).'); await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false }); // And the way back: the consent page can refuse again, which blocks the embed once more. await visitorPage.goto(origin + '/einwilligung'); diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index e0269c0..21c4794 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -18,7 +18,7 @@ Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie nicht mehr. #}