Knopf nach oben, Bildbetrachter, Rechtszeile als Baustein (Schema v19)
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

Der Knopf "nach oben" ist ein Anker, kein Skript: er springt an den
Seitenanfang, eingeblendet ueber eine Scroll-Zeitachse. Abschaltbar und
vollstaendig einstellbar - Seite, Form, Groesse, Farben, Schwelle,
Beschriftung.

Bilder lassen sich vergroessern, je Bild waehlbar (nichts / vergroessern
/ Adresse), Galerien mit einem Schalter. Grundlage ist ein gewoehnlicher
Verweis auf die grosse Fassung: ohne JavaScript oeffnet er das Bild, mit
JavaScript einen dialog-Betrachter mit Blaettern, Escape und Pfeiltasten.
Das Skript laedt nur, wo es etwas zu tun hat.

Die Rechtszeile wird ein Baustein und steht damit im Fussbereich statt
als heller Streifen darunter. Die eingebaute Leiste erscheint nur noch,
wenn die Seite den Baustein nicht enthaelt.

Dabei gefunden: zwei Stylesheet-Regeln griffen seit drei
Veroeffentlichungen nicht mehr. Beim Entfernen alter Regeln waren
Selektorreste stehen geblieben, und CSS nimmt dann die naechste Regel
mit - betroffen waren der Einwilligungsdialog und die Rechtszeile. Ein
Test prueft jetzt jede CSS-Datei auf abgebrochene Selektoren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 19:35:43 +02:00
1 parent 74766f6684
commit de1e66350b
13 files changed
+464 -18

No files matched your search

+5 -3
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
@@ -38,6 +38,7 @@ export function createBlock(type) {
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
if (type === 'legal') block.props = { label: 'Rechtliches' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -63,6 +64,7 @@ export function insertBlock(document, type, selected) {
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
@@ -114,7 +116,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 = 18;
export const SCHEMA_VERSION = 19;
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
export function upgradeDocument(document, version = 2) {
+7 -1
View File
@@ -170,6 +170,7 @@ function renderProperties() {
}
else current[key] = next;
if (key === 'caption') upgradeDocument(doc, 3);
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, `${selected || 'page'}:${key}`);
if (key === 'decorative') renderProperties();
@@ -281,6 +282,9 @@ function renderProperties() {
field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
}
if (block.type === 'legal') {
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
}
if (block.type === 'postnav') {
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
@@ -297,7 +301,8 @@ function renderProperties() {
}
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') {
field('Linkziel', 'href', block.props.href || '', { help: 'Leer lassen, wenn das Bild nicht verlinkt. Für ein Logo im Kopfbereich meist /.' });
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
@@ -308,6 +313,7 @@ function renderProperties() {
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
}
if (block.type === 'gallery') {
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
+101
View File
@@ -0,0 +1,101 @@
/*
* Der Bildbetrachter.
*
* Ohne dieses Skript ist ein vergrößerbares Bild schlicht ein Verweis auf die große Fassung: ein
* Klick öffnet sie, zurück führt die Zurück-Taste. Das ist die Grundlage, und sie funktioniert
* immer. Dieses Skript fängt den Klick ab und zeigt das Bild stattdessen über der Seite - mit
* Blättern, wenn es zu einer Galerie gehört.
*
* Benutzt wird <dialog>: der Browser bringt damit das Fangen des Fokus, das Schließen mit Escape
* und die Auszeichnung als Dialog schon mit. Eine nachgebaute Fassung aus <div> und Tastatur-
* abfragen wäre mehr Code und weniger zugänglich.
*/
(() => {
const zoomable = [...document.querySelectorAll('a[data-zoom]')];
if (0 === zoomable.length) {
return;
}
const dialog = document.createElement('dialog');
dialog.className = 'zoom-dialog';
dialog.innerHTML = `
<figure class="zoom-figure">
<img class="zoom-image" alt="">
<figcaption class="zoom-caption"></figcaption>
</figure>
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
<p class="zoom-counter" aria-live="polite"></p>
`;
document.body.append(dialog);
const bild = dialog.querySelector('.zoom-image');
const beschriftung = dialog.querySelector('.zoom-caption');
const zaehler = dialog.querySelector('.zoom-counter');
const zurueck = dialog.querySelector('.zoom-prev');
const vor = dialog.querySelector('.zoom-next');
/** Die Bilder, zwischen denen geblättert wird: die der Galerie, sonst nur dieses eine. */
const gruppeVon = (link) => {
const galerie = link.closest('[data-zoom-group]');
return galerie ? [...galerie.querySelectorAll('a[data-zoom]')] : [link];
};
let gruppe = [];
let stelle = 0;
const zeige = (index) => {
stelle = (index + gruppe.length) % gruppe.length;
const link = gruppe[stelle];
const inneres = link.querySelector('img');
bild.src = link.href;
bild.alt = inneres ? inneres.alt : '';
const text = link.dataset.caption || '';
beschriftung.textContent = text;
beschriftung.hidden = '' === text;
const mehrere = gruppe.length > 1;
zurueck.hidden = !mehrere;
vor.hidden = !mehrere;
zaehler.hidden = !mehrere;
zaehler.textContent = mehrere ? `${stelle + 1} von ${gruppe.length}` : '';
};
for (const link of zoomable) {
link.addEventListener('click', event => {
// Mit gedrückter Zusatztaste oder mittlerer Maustaste bleibt es ein normaler Verweis:
// wer ein Bild in einem neuen Reiter öffnen will, soll das können.
if (event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) {
return;
}
event.preventDefault();
gruppe = gruppeVon(link);
zeige(gruppe.indexOf(link));
dialog.showModal();
});
}
zurueck.addEventListener('click', () => zeige(stelle - 1));
vor.addEventListener('click', () => zeige(stelle + 1));
dialog.querySelector('.zoom-close').addEventListener('click', () => dialog.close());
// Ein Klick neben das Bild schließt - erwartet, und der Dialog selbst ist die Fläche daneben.
dialog.addEventListener('click', event => {
if (event.target === dialog) {
dialog.close();
}
});
dialog.addEventListener('keydown', event => {
if (1 === gruppe.length) {
return;
}
if ('ArrowLeft' === event.key) {
event.preventDefault();
zeige(stelle - 1);
}
if ('ArrowRight' === event.key) {
event.preventDefault();
zeige(stelle + 1);
}
});
})();
+42 -6
View File
@@ -113,8 +113,7 @@ p{hyphens:auto}
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
later, so the dark tone wins where it has to. */
.tone-ink .block-column .block-text,
.tone-ink
.tone-ink .block-column .block-text{color:#dae3da}
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
stays readable while the decision is open, because a wall would make "accept" the only way
@@ -163,10 +162,6 @@ body.consent-open{padding-bottom:260px}
.site-footer .block-column{border:0;border-radius:0;padding:0}
.site-header .block-text,
.site-footer .block-text{margin:0}
/* Size and weight only, never colour: the tone of the area decides that, and a colour here won
against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */
.site-header
.site-footer
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
@@ -338,3 +333,44 @@ body.consent-open{padding-bottom:260px}
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
.menu-burger-body{padding:14px 0 4px}
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
Safari), steht er von Anfang an da: sichtbar und nutzbar ist besser als unsichtbar.
Aussehen, Größe, Form, Seite und Schwelle kommen aus den Einstellungen. */
.scroll-top{position:fixed;bottom:22px;right:22px;z-index:40;display:grid;place-items:center;width:var(--kant-top-size,48px);height:var(--kant-top-size,48px);border-radius:var(--kant-top-radius,999px);background:var(--kant-top-bg,var(--accent));color:var(--kant-top-ink,#fff);text-decoration:none;box-shadow:0 8px 24px color-mix(in srgb,var(--ink) 28%,transparent);font-size:calc(var(--kant-top-size,48px) / 2.4);line-height:1}
.scroll-top-links{right:auto;left:22px}
.scroll-top:hover{filter:brightness(1.08)}
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
body.consent-open .scroll-top{display:none}
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
}
}
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
öffnet der Verweis einfach das große Bild. */
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
.block-legal a{color:inherit;text-decoration:none}
.block-legal a:hover{text-decoration:underline}
.image-zoom{cursor:zoom-in}
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
.zoom-figure{margin:0;display:grid;gap:12px;justify-items:center}
.zoom-image{max-width:100%;max-height:82vh;width:auto;height:auto;border-radius:10px;background:#0c1220}
.zoom-caption{margin:0;font-size:14px;text-align:center;max-width:70ch;color:#e7ecf3}
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
.zoom-close{top:18px;right:18px}
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
.zoom-counter{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);margin:0;font-size:13px;color:#e7ecf3}
@media (max-width:767px){
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
.zoom-prev{left:8px}.zoom-next{right:8px}
}