Compare commits

..
6 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 a434e0cefc Serve every font from this server and surface open tasks in 1.14.0
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
Fonts were still reachable from Google in two places. The editor and area
previews were handed `googleFonts` and no `localFonts`, so the screen used
most of the day fetched Lato from fonts.googleapis.com on every open; the
public page fell back to the same link whenever the files were missing.
Neither path remains: the template has no Google branch, `googleFontsUrl()`
is gone, and the policies name no foreign font host - `font-src 'self'`
publicly, `style-src 'self'; font-src 'self'` in administration. Without
local files a site now shows the theme's fallback and says so.

Preloading took the first two weights per family. The Kant-IT theme names
Lato 400, 700 and 900 and sets its headings on 800/900, so the one weight
that carried them loaded after the stylesheet and `font-display:swap`
swapped the heading in plain sight. Every weight of the theme is preloaded
now, latin subset, and the links sit before the stylesheet: measured cold,
all three files finish at 168 ms against a first paint at 292 ms.

Open tasks no longer hide on the screen that owns them. A strip above every
management screen names what needs a button and carries it: a new version
leads to System, missing fonts to Design. Neither asks the network - the
version comes from the daily update-check task, the fonts from a look in
the folder.

System and update was unusable on a phone: `.stack` is a grid whose
automatic column grows to its widest content, and one long database path
pushed the page sideways. It gets `minmax(0, 1fr)`, the backup table stacks
below 620px, and the prose is 45% shorter with the fine print folded away.

The device chips drew the current device with an outline, which lies outside
the box, ran under its neighbour and was clipped by the sidebar - it read as
a cut-off red frame. Used devices are filled in `#9d4e0d` now (5.6:1), the
current one carries a border inside the chip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:16:16 +02:00
kantarciandClaude Opus 5 858b8c6de4 Give Tagespflege Moldovan legal pages and entrance effects
The template carried neither an imprint nor privacy notes, which a German site
with a contact form cannot go live without. Both pages are built from the same
blocks as the rest of the site; what only the Tagespflege itself can answer -
legal form, register, supervisory authority, hosting provider - stands as
"NOCH ZU PRÜFEN" in the text instead of being invented. The footer carries the
legal line, the contact form a consent field, and its note links the privacy
page.

Entrance effects come from a list of block IDs rather than being scattered
through the document: the hero plays on load, everything else as it scrolls
into view, staggered so a row appears one item after another. Without
JavaScript every block simply stands on the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:15:55 +02:00
kantarciandClaude Fable 5.1 5f7ea63b76 Put opening hours, service area and FAQ into the structured data
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
Settings gain opening hours per weekday and a list of places served;
LocalBusiness emits openingHoursSpecification (equal times grouped) and
areaServed. An accordion can be marked as FAQ (schema v23 property
`faq`), which turns its entries into a FAQPage built from the visible
title and text blocks. Elektro Bernhard gets the real office hours
(Mo–Fr 07:30–17:00), the service area from Karlsruhe to Offenburg and
two more questions. ADR 094.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:26:47 +02:00
kantarciandClaude Opus 5 3cb5b03dd9 Document verified 1.13.0 release
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:19:14 +02:00
kantarciandClaude Opus 5 73719bae4a Add three icons, a styleable menu button and a reliable back-to-top in 1.13.0
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
The icon set had no drawing for people, music or a house - the three pictures
the Tagespflege Moldovan template asks for. They join as `people`, `music` and
`home`; a test keeps the list and the drawings together, because a missing path
renders an empty `<path d="">`: invisible, without an error.

An icon block's own colour never reached its drawing: the embedded SVG carries
the same `block-icon` class as its wrapper and set the accent colour again.
`.block-icon .block-icon{color:inherit}` fixes it at the root.

The menu button becomes a thing you can shape. `burgerLabel` chooses symbol and
text, text only or symbol only (which keeps the name for screen readers), and
the new style group `menu` targets the button itself, the way `img` targets an
image and `controls` the slider. Inside the header the opened menu now lies
under the header across the full width instead of pushing the header apart -
on a phone that looked like a missing navigation.

The back-to-top button no longer depends on `animation-timeline: scroll()`. A
browser that parses the property but does not run the timeline left it in the
first frame of the animation: permanently invisible. Two classes from
scrolled.js decide now, and without a script the button simply stays visible.

Also: the Tagespflege Moldovan rebuild as its own installation, built only by
`scripts/seed-tagespflege-moldovan.php`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:11:36 +02:00
kantarciandClaude Fable 5.1 215ab064ae Document verified 1.12.1 release
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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:09:46 +02:00
37 changed files with 1666 additions and 146 deletions

No files matched your search

+10
View File
@@ -48,6 +48,16 @@
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php"
],
"port": 8089
},
{
"name": "kantit",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
],
"port": 8130
}
]
}
+24
View File
@@ -4,6 +4,30 @@ 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`.
## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0)
**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen.
**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach.
**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt.
**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde.
## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0)
Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon.
Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon.
## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0)
Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken.
Der **Menü-Knopf** (der aufklappbare „Menü“-Knopf auf Tablet und Telefon) lässt sich gestalten: Navigation auswählen → **Inhalt → Menü-Knopf** wählt zwischen *Symbol und Text*, *Nur Text* und *Nur Symbol*; „Nur Symbol“ behält den Namen für Vorlesesoftware. Unter **Design → Menü-Knopf** stehen Schriftfarbe, Fläche, Rahmenfarbe, Rahmenstärke, Ecken und Schriftgröße, je Gerät und mit Palettenfarben. Das aufgeklappte Menü legt sich im Kopfbereich als Fläche über die volle Breite unter den Kopf, statt die Kopfzeile auseinanderzuschieben.
Der **Nach-oben-Knopf** erscheint wieder überall. Er blendete sich über eine Scroll-Animation ein; Browser, die diese Eigenschaft zwar kennen, aber nicht ausführen, ließen ihn dauerhaft unsichtbar. Jetzt entscheidet eine Klasse, die das kleine Scroll-Skript setzt – und ohne JavaScript bleibt der Knopf einfach sichtbar.
## Soziale Profile (ab 1.12.0)
Unter **Einstellungen → Unternehmensdaten → Soziale Profile** je Netzwerk eine Adresse eintragen (Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil). Das Element **Soziale Profile** (Werkzeugkasten → Bausteine) zeigt sie dann an jeder Stelle – im Kopf- oder Fußbereich, in einer Kontaktsektion – und bestimmt dort Größe, Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung und ob nur bestimmte Netzwerke erscheinen. Farbe, Abstand und Ausrichtung wie bei jedem Element unter **Design**. Eine Adresse ändern heißt: überall geändert. Die Profile stehen automatisch in den strukturierten Daten (`sameAs`). Ohne Eintrag zeigt das Element auf der Website nichts.
+1 -1
View File
@@ -1 +1 @@
1.12.1
1.14.0
+29 -2
View File
@@ -1,6 +1,33 @@
# Architekturentscheidungen
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 095 – Schriften ausschließlich vom eigenen Server (15.09.2026)
**Entscheidung.** Die Ausgabe verlinkt kein Stylesheet von `fonts.googleapis.com` mehr, in keinem Pfad. Liegen die Schriftdateien lokal, liefert die Website sie selbst; liegen sie nicht vor, zeigt sie die eingestellte Ersatzschrift. `Theme::googleFontsUrl()` entfällt, und die Richtlinien nennen keinen fremden Schrifthost mehr – öffentlich `font-src 'self'`, in der Verwaltung zusätzlich `style-src 'self'`. Das Herunterladen der Dateien bleibt, wo es hingehört: ein Knopf im Designbereich beziehungsweise `cms:fonts:fetch`, einmalig und von einem Menschen ausgelöst.
**Warum.** ADR 037ff. hatten den Verweis auf Google als ausdrückliche Entscheidung Umuts stehen lassen, mit dem Selbsthosten als offenem Weg. Seit die Dateien lokal liegen können, ist der Rückfallpfad nur noch eine Falle: er greift genau dann, wenn niemand hinsieht, und gibt dann die IP-Adresse jedes Besuchers an einen Dritten weiter (LG München I, 3 O 17493/20). Vor allem betraf er die Editorvorschau, die `googleFonts` und **kein** `localFonts` bekam und die Schriften deshalb bei jedem Aufruf von Google holte – auf dem Bildschirm, an dem am meisten gearbeitet wird.
**Folge.** Eine Installation, deren Schriften fehlen, sieht anders aus als vorher: Ersatzschrift statt Wunschschrift. Das ist beabsichtigt und sichtbar gemacht – der Designbereich nennt die fehlenden Schnitte, und seit derselben Fassung sagt es ein Hinweisstreifen auf jedem Verwaltungsbildschirm mit dem Knopf daneben. Ein stilles „lädt halt von Google“ wäre die schlechtere Überraschung.
**Und das Vorabholen.** Es holte je Familie die ersten zwei Schnitte. Das Kant-IT-Theme nennt Lato 400, 700 und 900, die Überschriften stehen auf 800/900 und landen damit auf dem einzigen Schnitt ohne Vorabholen: er lud erst nach dem Stylesheet, und `font-display:swap` tauschte die Überschrift sichtbar. Jetzt wird jeder Schnitt des Themes vorab geholt, im lateinischen Grundbereich, und die Verweise stehen vor dem Stylesheet. Gemessen: alle Dateien sind vor dem ersten Zeichnen da.
## ADR 095 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (14.09.2026)
`LocalBusiness` nannte Name, Adresse, Telefon, E-Mail und Profile – nicht, wann jemand erreichbar ist und wo er arbeitet. Beides sind Angaben der Website, nicht einer Seite, deshalb stehen sie in den Einstellungen: `openingHours` als Liste `{day, opens, closes}` je Wochentag (Formular: sieben Zeilen mit Uhrzeitfeldern, leer heißt geschlossen; geprüft als `HH:MM`, Schließen nach Öffnen) und `areaServed` als Liste von Ortsnamen (ein Ort je Zeile, höchstens vierzig). `organisation()` fasst Tage mit gleichen Zeiten zu einer `OpeningHoursSpecification` zusammen – Mo–Fr 07:30–17:00 ist eine Angabe, nicht fünf – und gibt `areaServed` als Namensliste aus. Der Text auf der Website bleibt davon unabhängig; niemand soll zwei Wahrheiten pflegen müssen, aber das CMS erzwingt keine.
Eine FAQ ist dagegen Inhalt einer Seite. Ein Akkordeon ist nicht automatisch eine – die Kennzeichnung `props.faq` (Schema v23, Kästchen „Als FAQ auszeichnen“) macht aus den Einträgen Frage-Antwort-Paare einer `FAQPage`: Titel als Frage, die Textbausteine des Eintrags ohne Auszeichnungen als Antwort; Einträge ohne Text fehlen. Nur was sichtbar auf der Seite steht, steht auch in den Daten – Google verlangt genau das, und eine erfundene Antwort im Markup wäre der schnellste Weg zu einer Abstrafung. Kein Datenbankwechsel; beide Einstellungen fallen auf leere Listen zurück, das Akkordeon ohne Kennzeichnung bleibt ein Akkordeon.
## ADR 094 – Drei Symbole mehr, ein Menü-Knopf mit eigener Gestalt, ein Nach-oben-Knopf ohne Scroll-Animation (14.09.2026)
**Symbole.** Der Satz hatte vierzehn Zeichnungen und keine für Menschen, Musik und Haus – drei Bilder, die eine Pflege- oder Vereinsseite als erstes braucht. Sie kommen als `people`, `music` und `home` hinzu, gezeichnet im selben Strich, weiter als reine Pfadangabe in `blocks/icon.html.twig`. Kein Schemawechsel: `ICONS` ist eine Werteliste, kein Bausteintyp; ein Dokument mit einem neuen Namen ist für ältere Fassungen ungültig, was für Werte dieser Art im Projekt bereits gilt. Ein Test hält Liste und Zeichnungen zusammen, denn ein fehlender Pfad ergibt ein leeres `<path d="">`: unsichtbar, ohne Fehlermeldung.
**Die Farbe des Symbolbausteins.** Das eingebettete SVG trägt dieselbe Klasse `block-icon` wie seine Hülle und setzte damit `color:var(--accent)` erneut – eine am Block eingestellte Farbe erreichte die Zeichnung nie, und beide bisherigen Nachbauten haben sie mit eigenem CSS überschrieben. `.block-icon .block-icon{color:inherit}` löst es an der Wurzel und trifft ausschließlich das Paar aus Hülle und Zeichnung; Symbole in Karten, Kontaktangaben und Menüs liegen nicht ineinander und bleiben unberührt.
**Der Menü-Knopf.** Er ist kein eigener Baustein, sondern der Knopf im Menüblock, und war bisher unveränderlich: Striche plus „Menü“, Rahmen in der Textfarbe, 8 px Ecken. Seine Beschriftung wird zu einer Eigenschaft (`burgerLabel`: `both`, `text`, `icon`) – „Nur Symbol“ behält den Namen als `visually-hidden`, denn ein Knopf ohne Namen ist für Vorlesesoftware ein Knopf ohne Zweck. Seine Farben werden zu freien Blockstilen der neuen Gruppe `menu` mit dem Ziel `burger`, wie `img` beim Bild und `controls` beim Slider: der Compiler schreibt `#id .menu-burger>summary{…}`. So bleibt die Gestaltung dort, wo jede andere Gestaltung liegt, statt eine zweite Einstellungsart zu erfinden.
**Das aufgeklappte Menü im Kopfbereich.** Das `details` klappte inline auf, also innerhalb der schmalen Spalte neben der Marke, und schob den Kopf auseinander; auf dem Telefon sah es aus, als fehle das Menü. Im Kopfbereich – und nur dort, erkennbar an `.site-header` – liegt die geöffnete Fläche jetzt absolut unter dem Kopf über die volle Breite. Außerhalb des Kopfbereichs bleibt es beim Aufklappen im Fluss, weil ein Menü in einer Sektion keinen positionierten Anker hat, auf den es sich verlassen könnte.
**Der Nach-oben-Knopf.** Er blendete sich über `animation-timeline: scroll(root)` ein, abgesichert mit `@supports`. Ein Browser, der die Eigenschaft kennt, die Zeitachse aber nicht ausführt, besteht die Abfrage und lässt den Knopf im ersten Bild der Animation stehen – bei `animation-fill-mode: both` heißt das dauerhaft unsichtbar. Statt einer Fähigkeitsabfrage entscheidet jetzt eine Klasse: `scrolled.js` setzt `kant-top-armed` sofort und `kant-top-shown` ab der eingestellten Entfernung, das Stylesheet blendet zwischen beiden Zuständen über. Ohne Skript bleibt der Knopf sichtbar – erreichbar ist besser als unsichtbar –, und das Skript wird jetzt auch geladen, wenn nur der Knopf es braucht.
## ADR 093 – Soziale Profile: einmal gepflegt, überall gezeigt (Dokumentschema v23, 14.09.2026)
@@ -423,7 +450,7 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen
`Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt.
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
**Schriften von Google, aber nicht zur Laufzeit.** Eine Familie lässt sich weiterhin bei Google aussuchen; ausgeliefert wird sie aber ausschließlich vom eigenen Server. Die Dateien holt ein Knopf im Designbereich einmalig her, und die Ausgabe verlinkt nie ein fremdes Stylesheet – Näheres in ADR 095.
Die alten Tokenwerte aus v2 bis v9 bleiben gültig und werden weiterhin gerendert. Blöcke, die noch welche tragen, zeigen sie im Editor in einem eingeklappten Bereich „Klassische Werte dieser Seite“, damit ältere Seiten bearbeitbar bleiben, ohne das neue Panel zu überladen.
+1 -1
View File
@@ -255,7 +255,7 @@ Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der feste
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 095).
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
+80
View File
@@ -1,5 +1,83 @@
# Projektstatus
## Globale Hinweise, der Schriftenknopf und die Gerätechips (15.09.2026)
**Anlass:** Umut fehlte ein Hinweis, der auch außerhalb der Systemseite sagt, dass etwas zu tun ist; der Knopf „Schriften holen“ sah nicht aus wie die übrigen; und im Designbereich störte ein roter Rahmen an den Gerätechips, der abgeschnitten wirkte.
**Hinweisstreifen auf jedem Verwaltungsbildschirm (`kant_admin_notices()`, `admin/shell.html.twig`).** Über dem Inhalt steht jetzt, was einen Knopf braucht, und der Knopf gleich daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren* (System), „Schriftdateien fehlen: …“ → *Schriften holen* (Design). Keine Zeile davon geht ins Netz – die verfügbare Fassung steht im Protokoll der täglichen Aufgabe `update-check`, die fehlenden Schriften sind ein Blick in den Ordner. Der Updatehinweis erscheint nur für `ROLE_ADMIN`, weil nur die auf die Systemseite dürfen. Warm statt rot: es ist eine Aufgabe, kein Fehler. Auf 390 px bricht der Streifen um, der Knopf rutscht unter den Text.
**Schriftenknopf.** Er trug gar keine Klasse und war deshalb der einzige Knopf der Verwaltung im Standardaussehen des Browsers. Jetzt `primary` (bzw. `secondary` beim erneuten Holen), und der ganze Abschnitt steht wie alles andere in einer Karte mit Kopf und Körper.
**Gerätechips D/T/M (`editor.css`).** Der rote Kasten war `outline:2px` mit `outline-offset:1px` auf dem gerade bearbeiteten Gerät. Ein `outline` liegt außerhalb des Kästchens: bei 2 px Abstand lief er unter den Nachbarchip und wurde von der seitlich beschnittenen Sidebar gekappt – daher der abgeschnittene Eindruck. Jetzt zwei Zustände mit zwei Mitteln:
- **belegt** (dieses Gerät trägt einen eigenen Wert): gefülltes Orange `--used: #9d4e0d`, weiße Schrift – 5,6:1 Kontrast. Dieselbe Farbe trägt der Punkt vor der Beschriftung, damit „hier steht ein eigener Wert“ überall gleich aussieht.
- **gerade bearbeitet**: 2 px Rahmen **innen** (`border-width`, kein `outline`), auf belegten Chips zusätzlich ein weißer Innenring. Nichts ragt mehr aus dem Kästchen, nichts wird beschnitten.
Chips von 19 auf 22 px, Abstand von 2 auf 4 px – auf einen Blick lesbar, welche Geräte an einer Eigenschaft hängen.
**Geprüft** (isolierte Kopie der Pilotdatenbank, künstlich auf „Lato 300 fehlt“ und „Fassung 9.9.9 verfügbar“ gesetzt): beide Streifen erscheinen auf `/admin`, tragen die richtigen Ziele, und auf 390 px bleibt die Dokumentbreite bei 390. Schriftenknopf trägt `primary`. Chips: belegte auf `rgb(157,78,13)`, bearbeitete mit 2 px Rahmen, kein gezeichnetes `outline` mehr. axe über die Editorseitenleiste ohne Verstöße. PHP-Tests: 498, ein bekannter Fehler (`testAnyPublishedPageCanBecomeTheRootAndEverythingFollows`). Screenshots: `reports/admin-hints/`.
**Zu beachten:** `admin.css` und `editor.css` hängen an `?v={{ kant_version() }}`. Bis die `VERSION` steigt, liefert der Browser die alten Fassungen aus dem Zwischenspeicher.
## Schriften nur noch vom eigenen Server, kein sichtbarer Schriftwechsel mehr (14.09.2026)
**Anlass:** Umut sah im Browser, wie die Schrift kurz von fett auf dünn sprang, und stellte zwei Regeln auf: keine Schriften über Adressen von Google, und kein Nachladen nach dem fertigen Seitenaufbau.
**Zwei getrennte Ursachen, beide belegt.**
1. **Der späte Schnitt.** `FontLibrary::preload()` holte je Familie nur die **ersten zwei** Schnitte vorab. Das Kant-IT-Theme nennt Lato 400, 700 und 900; die Überschriften stehen auf 800/900 und landen damit auf Lato 900 – dem einzigen Schnitt ohne Vorabholen. Gemessen am Nachbau: `lato-400` und `lato-700` starteten bei 151 ms, `lato-900` erst bei 156 ms, also nach dem Stylesheet. Mit `font-display:swap` zeichnet der Browser die Überschrift bis dahin in der Ersatzschrift und tauscht sie danach – der gemeldete Sprung. Jetzt wird jeder Schnitt des Themes vorab geholt (lateinischer Grundbereich; die erweiterten Bereiche bleiben draußen).
2. **Google in der Editorvorschau.** `EditorController` und `PartialController` gaben der Vorlage `googleFonts` und **kein** `localFonts`. In der Vorschau kamen die Schriften damit bei jedem Aufruf von `fonts.googleapis.com` und `fonts.gstatic.com` – zwei fremde Verbindungen hintereinander, und genau dort arbeitet Umut am meisten. Beide Vorschauen nutzen jetzt dieselben lokalen Dateien wie die öffentliche Seite.
**Die Regel steht jetzt im Code, nicht in der Einstellung.** `page.html.twig` hat keinen Google-Zweig mehr; fehlen die Schriftdateien, zeigt die Website die Ersatzschrift des Themes. Die Kopfzeilen lassen keinen fremden Schrifthost mehr zu – öffentlich `font-src 'self'`, in der Verwaltung `style-src 'self'; font-src 'self'` statt der bisherigen Ausnahmen für `fonts.googleapis.com`/`fonts.gstatic.com`. `Theme::googleFontsUrl()` ist entfernt; das Herunterladen der Dateien über die Theme-Seite bleibt unverändert und baut seine Adresse selbst. Die Vorabholen-Verweise stehen jetzt **vor** dem Stylesheet, und die Texte auf der Theme-Seite sagen nicht mehr „kommen zurzeit von Google“, sondern „die Dateien fehlen, bis dahin die Ersatzschrift“.
**Geprüft** (Chromium, kalter Zwischenspeicher, isolierte Kopie der Pilotdatenbank): alle drei Schriftdateien starten bei 96 ms und sind bei 166–168 ms fertig, der erste sichtbare Inhalt erscheint bei 292 ms – die Schriften stehen also vor dem ersten Zeichnen, es gibt nichts mehr zu tauschen. Jede Datei wird genau einmal angefragt. Über die ganze Sitzung – öffentliche Seite, Anmeldung, Seiteneditor mit Vorschau, Bereiche – **keine einzige Anfrage außerhalb des eigenen Servers**. PHP-Tests: 498, ein bekannter Fehler (siehe unten). `npm run test:browser` und der Lighthouse-Lauf sind **nicht** gelaufen.
**Tests angepasst:** `testFontsAreReadFromGoogleStylesheetAndServedFromHere` prüft jetzt die Ersatzschrift statt des Google-Verweises und alle drei Vorabhol-Dateien; `testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy` heißt `…AndNoFontIsEverFetchedFromGoogle` und prüft die engere Kopfzeile. In `testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock` stand eine echte Zeilenschaltung im Suchmuster; unter Windows mit `core.autocrlf=true` schlägt das fehl, sobald die Arbeitskopie CRLF trägt. Das Muster nimmt jetzt `\R` und gilt für beide Schreibweisen; die Vorlage selbst bleibt unverändert.
**Weiterhin offen, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung.
## Kopfmenü im Hochformat und die Update-Ansicht auf dem Handy (14.09.2026)
**Anlass:** Umut meldete, das Mobilmenü im Kopf erscheine nur im Querformat, und bat darum, Texte und Ansichten der Update-Seite fürs Handy zu kürzen.
**Befund zum Kopfmenü – falsch gebaut, kein Fehler im CMS.** Der Kopfbereich trug zwei Menüblöcke: `area-header-menu` (waagerecht, `mobile: display:none`) und daneben `area-header-burger` (eigener Burger-Block, nur mobil sichtbar). Der veröffentlichte Stand auf `web.kant-it.de` kennt den zweiten Block nicht – abgerufen am 14.09.2026 enthält das Markup dort nur `area-header-menu` samt `@media (max-width:767px){#area-header-menu{display:none}}`. Damit ist im Hochformat (bis 767 px) gar kein Menü da, während im Querformat (768–1024 px) das Standardverhalten `collapse: tablet` denselben Block zum Burger zusammenlegt und ein Knopf erscheint. Genau die gemeldete Beobachtung. In `var/kantit.db` lag zwar ein zweiter Burger-Block, der das Loch stopfte, aber mit eigener Gestaltung: im Hochformat ein 161 px breiter Knopf, im Querformat der 101 px breite des waagerechten Menüs.
**Behoben in `var/kantit.db`:** ein einziger Menüblock `area-header-menu` mit `collapse: tablet` und `burgerLabel: both`, ohne `display:none` auf Mobil, `burgerFontSize: 14px`; der doppelte Block `area-header-burger` ist entfernt (Kopfbereich Fassung 8, Sicherung `var/kantit.db.vor-kopfmenue-20260914-2244.bak`). Geprüft am laufenden Nachbau: 375 px Hochformat und 844 px Querformat zeigen denselben Knopf („MENÜ“, 101 × 45 px, 14 px), die Liste bleibt in beiden verborgen und die aufklappende Fläche liegt über die volle Breite unter dem Kopf; ab 1025 px stehen die vier Einträge wieder waagerecht. Startkonfiguration `kantit` (Port 8130) in `.claude/launch.json` ergänzt.
**Update-Ansicht (`templates/admin/system.html.twig`, `public/assets/admin.css`).** Die Seite schob sich auf dem Handy seitlich aus dem Bild: `.stack` ist ein Raster mit automatischer Spalte, und eine automatische Rasterspalte wächst auf die Mindestbreite ihres breitesten Inhalts – eine lange Datenbankpfadangabe reichte. `.stack` bekommt `minmax(0, 1fr)`, `.facts` beide Spalten als `minmax(0, …)` und unter 620 px nur noch eine, `.card-body` und die Prüfpunkte `min-width: 0` samt `overflow-wrap: anywhere`. Die Sicherungstabelle wird unter 620 px zu gestapelten Blöcken (`.table-stack`, Spaltenüberschrift als `data-label`), statt waagerecht zu scrollen. Texte gekürzt und das Kleingedruckte in zugeklappte `<details class="hint">` gelegt, ebenso die älteren 15 der 20 Protokollzeilen.
**Geprüft:** 390 × 844 mit Touch-Emulation gegen eine isolierte Kopie der Pilotdatenbank (`tests/pilot-fixture.php`, eigenes Zufallskonto): Dokumentbreite 390 statt 492, kein Element mehr breiter als der Inhaltsbereich, Wortzahl der Seite 1017 → 556, axe ohne Verstöße. `/admin`, `/admin/media`, `/admin/menus`, `/admin/forms`, `/admin/theme`, `/admin/settings` bleiben mobil bei 390 px; dieselben Seiten und `/admin/system` bei 1440 px unverändert breit. Screenshots: `reports/system-mobile/`.
**Bekannter Fehler, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl (498 Tests, 1 Fehler) – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung. Auch ohne diese Änderungen und mit beiden PHP-Fassungen reproduzierbar.
**Nächster Schritt:** Umut spielt `var/kantit.db` auf den Server; erst dort ist das Kopfmenü im Hochformat live. Für die Admin-Änderungen die `VERSION` beim nächsten Release erhöhen, sonst liefert der Browser `admin.css?v=1.13.0` weiter aus dem Zwischenspeicher.
## 1.14.0 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten; Elektro Bernhard aktualisiert (14.09.2026)
**Anlass:** Umut nannte die tatsächlichen Bürozeiten (Mo–Fr 7:30–17:00), das Einsatzgebiet von Karlsruhe bis Offenburg und wollte die FAQ „mit allem, was notwendig ist“ – das JSON-LD, das das CMS bisher nicht hergab (siehe Lücke in den Einträgen vom Abgleich).
**Umgesetzt (ADR 095):** Einstellungen `openingHours` und `areaServed` mit Formular unter Unternehmensdaten; `SiteSettings::organisation()` gibt `openingHoursSpecification` (gleiche Zeiten zusammengefasst) und `areaServed` aus. Akkordeon-Eigenschaft `faq` (Schema v23, Kästchen im Editor); `PageController` baut daraus eine `FAQPage` mit Frage und Antwort aus dem sichtbaren Inhalt. Fünf neue Tests: Zusammenfassung und Reihenfolge der Zeiten, Abweisung unvollständiger oder verdrehter Zeiten, FAQPage nur aus gekennzeichneten Akkordeons und nur mit Antwort, Abweisung der Kennzeichnung vor v23, und – nach einem Fund der vollen Suite – dass ein Bildschirm, der die geladenen Einstellungen unverändert zurückgibt (Anlegen der Rechtsseiten, Installer), Öffnungszeiten und Einsatzgebiet nicht löscht.
**Elektro Bernhard:** Bürozeiten überall Mo–Fr 07:30–17:00 Uhr (Fuß, Kontakt, Beschreibung, FAQ); Einsatzgebiet Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach, Murgtal, Ettlingen, Karlsruhe, Achern, Oberkirch, Offenburg in Text und `areaServed`; FAQ um „Wann ist das Büro erreichbar?“ und „Wie läuft eine Anfrage ab?“ ergänzt und als FAQ gekennzeichnet. Die Öffnungszeiten weichen bewusst von der laufenden Website ab (dort Mo–Do 7:00–16:30, Fr bis 13:00) – Umuts Angabe gilt.
**Arbeitsweise:** Weil eine parallele Sitzung im Hauptarbeitsbaum dreizehn Dateien offen hatte, darunter Validator, PageController und Editor, entstand diese Änderung im eigenen Worktree `var/wt-faq` auf dem Zweig `oeffnungszeiten-faq` und wurde von dort auf `main` gebracht. Der Hauptarbeitsbaum muss danach `git pull` ausführen.
## 1.13.0 – Drei Symbole mehr, gestaltbarer Menü-Knopf, Nachbau „Tagespflege Moldovan“ (14.09.2026)
**Anlass:** Umut ließ die ZIP-Vorlage „Tagespflege Moldovan“ nachbauen. Beim Nachbau fehlten dem Symbolsatz genau die drei Bilder, die die Vorlage zeigt; danach kamen zwei Meldungen vom Telefon: das Menü sei nicht zu sehen, und „Scroll to top“ funktioniere nicht.
**Nachbau (`scripts/seed-tagespflege-moldovan.php`, `scripts/reset-tagespflege-moldovan.sh`).** Der Onepager aus dem Paket vom 09.09.2026, gebaut aus gewöhnlichen Bausteinen: Kopfleiste mit Telefonzeile, klebender weißer Kopf mit Wortmarke, Hero mit der limettenen Schnittfläche hinter dem Foto (`clip-path` als eigenes CSS auf einer Fläche), Notizkarte und Bildunterschrift, Werteband, Willkommen mit Porträt-Signatur, drei Angebotskarten, Zeitleiste mit Verbindungslinie aus Flächen, dunkler Kontaktbereich mit schräger Fläche, Formularkarte und Fuß mit limettenem Abschluss. Farben, Schriftgrößen, Abstände und Umbrüche sind aus `style.css` übernommen, ebenso die drei Gerätestufen. Eigene Installation: `var/tagespflege-moldovan.db`, `var/tagespflege-moldovan-media`, Bildquellen in `var/tagespflege-moldovan-assets`, Startkonfiguration auf Port 8089. Zwei bewusste Abweichungen: das Formular verschickt serverseitig statt eine Mailto-Nachricht vorzubereiten, und das Markenzeichen liegt als PNG vor, weil die Medienverwaltung JPEG, PNG und WebP nimmt. Impressum und Datenschutz fehlen wie in der Vorlage – für einen Livegang nachzutragen.
**Symbole (ADR 094).** `people`, `music` und `home` ergänzen den Satz in `DocumentValidator::ICONS`, `blocks/icon.html.twig`, `editor.js` und `menu-editor.js`. Ein Test hält Liste und Zeichnungen zusammen. `.block-icon .block-icon{color:inherit}` lässt die am Block eingestellte Farbe endlich an der Zeichnung ankommen – das eingebettete SVG trug dieselbe Klasse wie seine Hülle und setzte die Akzentfarbe erneut.
**Menü-Knopf (ADR 094).** Neue Eigenschaft `burgerLabel` (`both`, `text`, `icon`; „Nur Symbol“ behält den Namen als `visually-hidden`) und die neue Stilgruppe `menu` mit dem Ziel `burger`: `burgerColor`, `burgerBackgroundColor`, `burgerBorderColor`, `burgerBorderWidth`, `burgerBorderRadius`, `burgerFontSize`. Der Compiler schreibt `#id .menu-burger>summary{…}`. Im Kopfbereich klappt das Menü als Fläche über die volle Breite unter den Kopf auf statt inline in der schmalen Spalte neben der Marke – das war die gemeldete „unsichtbare“ Navigation.
**Nach-oben-Knopf (ADR 094).** Die Einblendung hängt nicht mehr an `animation-timeline: scroll()`, sondern an den Klassen `kant-top-armed` und `kant-top-shown`, die `scrolled.js` setzt; das Skript wird jetzt auch geladen, wenn nur dieser Knopf es braucht. Ohne Skript bleibt der Knopf sichtbar.
**Geprüft:** PHP-Suite 498 Tests (fünf neue: Symbolliste gegen Zeichnungen, drei Knopfvarianten, abgewiesene Variante, Knopffarben am `summary`); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung sauber, Editor-Browserprüfung vollständig erfolgreich einschließlich axe. Playwright auf dem Nachbau bei 1440, 900 und 390 px sowie als iPhone 13: Seite gegen die Vorlage verglichen, Menü öffnet als Fläche unter dem Kopf, Sprungziele, 404-Seite, keine Konsolenfehler. Nach-oben-Knopf auf der Elektro-Bernhard-Installation als iPhone geprüft: oben verborgen, ab der Schwelle sichtbar, springt an den Anfang.
**Veröffentlicht:** Commit `73719ba` und Tag `v1.13.0` auf `origin`; [Release 1.13.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.13.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.13.0`), `vendor/` ohne Entwicklungspakete; die einzige `.env` im Archiv ist die Vorlage ohne Geheimnis. Die drei Anhänge wurden anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `14e333e0…59dcbf`, identisch mit der Prüfsumme, die `install.php` mitbringt.
## 1.12.1 – Elektro Bernhard gemessen: Lighthouse und axe (14.09.2026)
**Anlass:** Umut fragte nach Google-Bewertung, SEO, llms.txt und Barrierefreiheit der neuen Website.
@@ -8,6 +86,8 @@
**Behoben:** drei Kontrast-/Gliederungsbefunde in der Website (Skript) und einer im CMS: der Pause-Knopf des Sliders verletzte WCAG 2.5.3 (sichtbarer Text „Pause“, Name „Automatische Wiedergabe pausieren“); `slider.js` bildet den Namen jetzt aus dem sichtbaren Text, `scripts/admin-quality-check.mjs` folgt.
**Veröffentlicht:** Commit `042aef9` und Tag `v1.12.1` auf `origin`; [Release 1.12.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.1) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`, gebaut aus dem sauberen Worktree `var/release-source-1.12.1`; alle drei Anhänge anonym geladen und bytegenau verglichen, SHA-256 `c7a2de81…8f1a23`.
## 1.12.0 – Soziale Profile als Baustein; Elektro Bernhard neu gestaltet (14.09.2026)
**Anlass:** Umut fand den ersten Nachbau „grauenhaft“, fragte nach dem transparenten Menü und vermisste Social-Icons im CMS – „gescheit“, also einmal zentral gepflegt und überall mit eigener Größe und Farbe einsetzbar.
+45 -3
View File
@@ -211,13 +211,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch }
.shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
.shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% }
.stack { display: grid; gap: 20px }
/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst
die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem
Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */
.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px }
.notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 }
.notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
.error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
.shell-body > .notice { margin-bottom: 4px }
/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und
gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */
.shell-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
margin: 0 0 4px;
padding: 12px 14px 12px 17px;
border: 1px solid #e3cf9b;
border-radius: 10px;
background: #fdf6e4;
color: #5b4412;
line-height: 1.6;
font-size: 13px;
}
.shell-notice > span { min-width: 0; flex: 1 1 260px }
.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 }
.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 }
/* ---- cards and sections ---------------------------------------------------------------- */
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
@@ -232,7 +256,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
}
.card-head h2, .card-head h3 { margin: 0 }
.card-head .muted { font-size: 12px }
.card-body { padding: 8px 22px 24px }
.card-body { padding: 8px 22px 24px; min-width: 0 }
.card-body code { overflow-wrap: anywhere }
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
.hint { margin: 12px 0 0 }
.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center }
.hint[open] > summary { margin-bottom: 2px }
/* A heading for a group of rows, outside any card. */
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px }
.section-head h2 { margin: 0 }
@@ -550,6 +579,18 @@ details.card > summary:hover { background: #fafbf8 }
.row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px }
.login-card { padding: 26px }
.login-body { padding: 16px }
/* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */
.facts { grid-template-columns: minmax(0, 1fr); gap: 2px }
.facts dt { margin-top: 10px }
.facts dt:first-child { margin-top: 0 }
/* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die
Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */
.table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% }
.table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px }
.table-stack tbody tr + tr td { border-top: 0 }
.table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right }
.table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none }
}
/* A group of related checkboxes needs a name of its own, not just a label per box. */
@@ -586,11 +627,12 @@ details.card > summary:hover { background: #fafbf8 }
/* ---- system: preflight, update steps, log ---------------------------------------------- */
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
.facts dt { color: var(--muted) }
.facts dd { margin: 0; overflow-wrap: anywhere }
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
.check-list li { display: flex; gap: 11px; align-items: flex-start }
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
.check-mark {
display: grid;
place-items: center;
+1
View File
@@ -18,6 +18,7 @@ export const GROUPS = {
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
menu: 'Menü-Knopf',
slider: 'Slider-Steuerung',
};
+1
View File
@@ -571,6 +571,7 @@ export function relevantProperty(block, key, property) {
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined || values.scrolled?.[key] !== undefined)) return true;
const container = isContainer(block);
if (property.group === 'slider') return block.type === 'slider';
if (property.group === 'menu') return block.type === 'menu';
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
+21 -8
View File
@@ -3,6 +3,9 @@
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
shows one thing at a time, and the page itself filling the rest. */
/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse
Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */
:root{--used:#9d4e0d}
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
dialog::backdrop{background:#172e2966}
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
@@ -185,8 +188,10 @@ html:has(>.editor-body){overflow:hidden;height:100%}
.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;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)}
/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine
Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */
.design-own .design-row-head label{color:#6f3c0a;font-weight:700}
.design-own .design-row-head label::before{content:'● ';color:var(--used)}
.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}
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
@@ -210,12 +215,20 @@ html:has(>.editor-body){overflow:hidden;height:100%}
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
.side-unit-select{flex:1;min-width:0}
/* The device chips on a single option: which devices carry an own value, and switch to one. */
.device-chips{display:flex;gap:2px;margin-left:auto}
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
.device-chip:hover{border-color:var(--accent)}
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
/* The device chips on a single option: which devices carry an own value, and switch to one.
Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die
Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe.
"Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb
des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich
beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */
.device-chips{display:flex;gap:4px;margin-left:auto}
.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer}
.device-chip:hover{border-color:var(--ink)}
.device-chip-own{background:var(--used);border-color:var(--used);color:#fff}
.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)}
.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff}
.device-chip:focus-visible{outline-width:2px;outline-offset:1px}
.design-side-head{margin-bottom:5px}
.design-side-head .design-group-label{margin:0}
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
+4 -1
View File
@@ -11,7 +11,7 @@ import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility } from './editor-model.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
@@ -309,6 +309,7 @@ function renderContent(block, panel) {
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
if (key === 'newTab') upgradeDocument(doc, 20);
if (key === 'collapse') upgradeDocument(doc, 22);
if (key === 'faq') upgradeDocument(doc, 23);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, options.choices ? null : `${selected || 'page'}:${key}`);
if (key === 'src') imagePreview?.update({ src: input.value });
@@ -389,6 +390,7 @@ function renderContent(block, panel) {
if (block.type === 'menu') {
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
}
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
@@ -451,6 +453,7 @@ function renderContent(block, panel) {
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
if (block.type === 'accordion') {
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
const hint = document.createElement('p'); hint.className = 'field-help';
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
+1 -1
View File
@@ -7,7 +7,7 @@ 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 iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' };
const menuIcons = JSON.parse(form.dataset.icons || '[]');
const chrome = () => {
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
+17 -2
View File
@@ -8,12 +8,27 @@
*/
export function initScrolled(doc = document) {
const view = doc.defaultView;
const root = doc.documentElement;
// Der Nach-oben-Knopf blendete sich ueber eine Scroll-Animation ein. Browser, die
// `animation-timeline: scroll()` zwar kennen, aber nicht ausfuehren, liessen ihn im ersten
// Bild der Animation stehen - unsichtbar, dauerhaft. Deshalb entscheidet jetzt eine Klasse.
// Ohne dieses Skript bleibt der Knopf sichtbar: erreichbar ist besser als unsichtbar.
const after = parseInt(getComputedStyle(root).getPropertyValue('--kant-top-after'), 10);
const schwelle = Number.isFinite(after) && after > 0 ? after : 400;
let queued = false;
const sync = () => { queued = false; doc.documentElement.classList.toggle('is-scrolled', view.scrollY > 40); };
const sync = () => {
queued = false;
root.classList.toggle('is-scrolled', view.scrollY > 40);
root.classList.toggle('kant-top-shown', view.scrollY > schwelle);
};
const onScroll = () => { if (!queued) { queued = true; view.requestAnimationFrame(sync); } };
view.addEventListener('scroll', onScroll, { passive: true });
root.classList.add('kant-top-armed');
sync();
return () => { view.removeEventListener('scroll', onScroll); doc.documentElement.classList.remove('is-scrolled'); };
return () => {
view.removeEventListener('scroll', onScroll);
root.classList.remove('is-scrolled', 'kant-top-armed', 'kant-top-shown');
};
}
if (!document.querySelector('#editor')) initScrolled();
+18 -6
View File
@@ -178,6 +178,9 @@ body.consent-open{padding-bottom:260px}
/* A single icon, sized by class because inline styles are forbidden by the policy. */
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
.block-icon svg{width:100%;height:100%;display:block}
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
.block-icon .block-icon{color:inherit}
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
@@ -377,6 +380,16 @@ body.consent-open{padding-bottom:260px}
}
.menu-burger>summary{min-height:44px;min-width:44px}
.menu-burger-body{overflow-wrap:anywhere}
/* Im Kopfbereich klappt das Menue als Flaeche unter dem Kopf auf, ueber die volle Breite.
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
ganzen Kopf auseinander - auf dem Telefon sah es aus, als fehle das Menue. */
.site-header{position:relative}
.site-header .menu-burger[open]>summary{border-radius:8px}
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
background:var(--paper);box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
.site-header .menu-burger-body .menu-list{gap:0}
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
/* Visibility never overwrites the block's grid/flex/display settings. */
.visibility-hidden{display:none!important}
.slider-controls button[hidden]{display:none}
@@ -395,12 +408,11 @@ body.consent-open{padding-bottom:260px}
@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()){
.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}}
/* Beachtet die Website die Browservorgabe (Einstellungen → Bewegung), steht er von Anfang an da. */
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{animation:none}}
}
/* Sichtbar, sobald der Leser ein Stueck gescrollt hat - gesteuert von scrolled.js ueber
`kant-top-armed` und `kant-top-shown`. Ohne Skript bleibt der Knopf einfach sichtbar. */
html.kant-top-armed .scroll-top{opacity:0;visibility:hidden;transform:translateY(10px);transition:opacity .25s,visibility .25s,transform .25s}
html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;transform:none}
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{transition: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. */
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Wirft die Moldovan-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/tagespflege-moldovan.db"
export MEDIA_DIRECTORY="$root/var/tagespflege-moldovan-media"
rm -f "$root/var/tagespflege-moldovan.db"
rm -rf "$root/var/tagespflege-moldovan-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
assets="${1:-$root/var/tagespflege-moldovan-assets}"
"$php" -d memory_limit=512M scripts/seed-tagespflege-moldovan.php "$assets"
+12 -8
View File
@@ -373,7 +373,7 @@ $fuss = [
]),
$column('fuss-zeiten', [
$heading('fuss-zeiten-titel', 'Bürozeiten', 3, $fussTitel),
$text('fuss-zeiten-text', "Mo–Do: 07:00–16:30 Uhr\nFr: 07:00–13:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '#c4c8ce']]),
$text('fuss-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '#c4c8ce']]),
$text('fuss-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'color' => '@papier', 'margin' => ['top' => '10px']]]),
]),
], ['desktop' => ['columns' => 'four', 'gap' => 'large'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
@@ -405,11 +405,13 @@ $leistungen = [
];
$fragen = [
['Wo ist Elektro Bernhard im Einsatz?', 'Unser Standort ist in Baden-Baden. Wir betreuen Projekte in Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, im Murgtal und in der Umgebung. Fragen Sie uns gerne zu Ihrem konkreten Projektort.'],
['Wo ist Elektro Bernhard im Einsatz?', 'Unser Standort ist in Baden-Baden. Wir betreuen Projekte in Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und im Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg – von Karlsruhe bis Offenburg. Fragen Sie uns gerne zu Ihrem konkreten Projektort.'],
['Übernehmen Sie Neubauten und Sanierungen?', 'Ja. Elektroinstallation für Neu- und Altbauten gehört zu unseren Leistungen. Wir beraten Sie zu Ihrem Vorhaben und planen gemeinsam die passende Umsetzung.'],
['Was lässt sich mit Smart Home steuern?', 'Zum Beispiel Beleuchtung, Heizung und Sicherheitsfunktionen. Welche Steuerung zu Ihrem Gebäude passt, klären wir in einer individuellen Beratung.'],
['Wie erreiche ich den 24h-Notdienst?', 'Bei einem elektrischen Notfall erreichen Sie unseren Kundendienst rund um die Uhr unter '.$notdienst.'. Für reguläre Projektanfragen erreichen Sie unser Büro unter '.$buero.'.'],
['Bieten Sie auch Solaranlagen an?', 'Solarplanung und Installation übernimmt die eigenständige Elektro Bernhard Regenerative Energie GmbH. Das Angebot richtet sich an private Wohnhäuser und Geschäftsgebäude.'],
['Wann ist das Büro erreichbar?', 'Montag bis Freitag von 07:30 bis 17:00 Uhr unter '.$buero.'. Außerhalb dieser Zeiten und am Wochenende hilft bei einem elektrischen Notfall der 24h-Notdienst unter '.$notdienst.'.'],
['Wie läuft eine Anfrage ab?', 'Sie schildern uns Ihr Vorhaben und den Ort – per Formular, E-Mail oder Telefon. Wir melden uns innerhalb von 24 Stunden, klären offene Fragen und stimmen mit Ihnen einen Termin vor Ort oder ein Angebot ab.'],
];
// Wortlaut, Name und Datum am 14.09.2026 aus den auf elektro-bernhard.de veröffentlichten
@@ -445,7 +447,7 @@ $leistungstexte = [
['Welche Technik passt zum Gebäude?', 'Bevor wir eine Lösung empfehlen, betrachten wir Ihre Wünsche und die Gegebenheiten vor Ort. So lässt sich klären, welche Technik zu Ihrem Projekt passt.'],
]],
'notdienst' => ['24h Elektro-Notdienst in Baden-Baden | Elektro Bernhard', '24h Elektro-Notdienst', 'Bei elektrischen Notfällen ist unser Kundendienst rund um die Uhr unter '.$notdienst.' erreichbar. Schildern Sie uns am Telefon das Problem und Ihren Standort.', [
['Für die Region erreichbar', 'Unser Einsatzgebiet umfasst Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung. Die konkrete Verfügbarkeit für Ihren Standort klären wir im Gespräch.'],
['Für die Region erreichbar', 'Unser Einsatzgebiet umfasst Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg. Die konkrete Verfügbarkeit für Ihren Standort klären wir im Gespräch.'],
['Der direkte Weg zur Hilfe', 'Für einen Notfall nutzen Sie bitte die Notdienstnummer. Das Kontaktformular ist für reguläre Anfragen gedacht und wird nicht durchgehend überwacht.'],
]],
'beratung-planung' => ['Elektroplanung & Beratung in Baden-Baden | Elektro Bernhard', 'Beratung und Elektroplanung', 'Ob erste Idee oder konkretes Vorhaben: Wir beraten Sie individuell und begleiten die Planung Ihrer Elektrotechnik.', [
@@ -514,7 +516,7 @@ $kontaktAbschnitt = static function (string $praefix) use ($section, $row, $colu
]),
$column($praefix.'-kontakt-zeiten', [
$heading($praefix.'-kontakt-zeiten-titel', 'Bürozeiten', 3, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['bottom' => '8px']]]),
$text($praefix.'-kontakt-zeiten-text', "Mo–Do: 07:00–16:30 Uhr\nFr: 07:00–13:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
$text($praefix.'-kontakt-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
$text($praefix.'-kontakt-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'margin' => ['top' => '10px']]]),
]),
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '0px']]]),
@@ -542,7 +544,7 @@ $startseite = [
$column('hero-text', [
$fx($eyebrow('hero-label', 'Elektro Bernhard GmbH · Seit über 35 Jahren', '#ff7d90'), 'fade'),
$fx($heading('hero-titel', "Ihr Elektriker in\nBaden-Baden.", 1, ['desktop' => ['fontSize' => '76px', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'fontWeight' => '900', 'color' => '@papier', 'margin' => ['bottom' => '24px']], 'tablet' => ['fontSize' => '56px'], 'mobile' => ['fontSize' => '40px']]), 'rise', 'short'),
$fx($text('hero-intro', 'Ob Neubau, Sanierung oder eine Störung an der Elektrik: Wir kümmern uns darum. Für Privatkunden und Betriebe in Baden-Baden, Rastatt und Umgebung.', ['desktop' => ['color' => '#ffffffd9', 'fontSize' => '20px', 'lineHeight' => 1.65, 'maxWidth' => '600px', 'margin' => ['bottom' => '34px']], 'mobile' => ['fontSize' => '17px']]), 'rise', 'medium'),
$fx($text('hero-intro', 'Ob Neubau, Sanierung oder eine Störung an der Elektrik: Wir kümmern uns darum. Für Privatkunden und Betriebe von Karlsruhe bis Offenburg.', ['desktop' => ['color' => '#ffffffd9', 'fontSize' => '20px', 'lineHeight' => 1.65, 'maxWidth' => '600px', 'margin' => ['bottom' => '34px']], 'mobile' => ['fontSize' => '17px']]), 'rise', 'medium'),
$fx($row('hero-aktionen', [
$column('hero-aktion-anfrage', [$button('hero-anfrage', 'Anfrage stellen', '/kontakt')]),
$column('hero-aktion-telefon', [
@@ -664,7 +666,7 @@ $startseite = [
$fx($kontaktBlock('fragen-telefon', ['phone' => $buero], ['desktop' => ['fontSize' => '18px', 'fontWeight' => '900', 'color' => '@rot', 'margin' => ['top' => '10px']]]), 'rise', 'medium'),
]),
$column('fragen-liste', [
['id' => 'fragen-akkordeon', 'type' => 'accordion', 'children' => array_map(static function (array $f, int $i) use ($alleEcken): array {
['id' => 'fragen-akkordeon', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => array_map(static function (array $f, int $i) use ($alleEcken): array {
return [
'id' => 'frage-'.($i + 1), 'type' => 'accordionitem', 'props' => ['title' => $f[0], 'open' => 0 === $i],
'style' => ['desktop' => ['backgroundColor' => '@weich', 'borderStyle' => 'none', 'borderRadius' => $alleEcken('14px'), 'padding' => ['top' => '4px', 'right' => '24px', 'bottom' => '4px', 'left' => '24px']]],
@@ -740,7 +742,7 @@ foreach ($leistungen as [$slug, $icon, $name, $kurz]) {
'blocks' => [
$seitenkopf($slug, $name, $h1, $intro, [$knopf]),
$textkoerper($slug, array_merge($absaetze, [
['Ihr Elektriker vor Ort', 'Elektro Bernhard GmbH · Ooser Hauptstr. 51 · 76532 Baden-Baden. Für Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung.'],
['Ihr Elektriker vor Ort', 'Elektro Bernhard GmbH · Ooser Hauptstr. 51 · 76532 Baden-Baden. Für Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg.'],
]), [], [
$text($slug.'-zurueck', '<a href="/leistungen">Alle Leistungen ansehen →</a>', ['desktop' => [...$linkStil['desktop'], 'margin' => ['top' => '30px']]]),
$cta($slug.'-cta'),
@@ -841,7 +843,7 @@ echo "Menüs angelegt\n";
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => 'Elektro Bernhard GmbH',
'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Do 7:00–16:30 Uhr, Fr 7:00–13:00 Uhr.',
'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Fr 7:30–17:00 Uhr.',
'phone' => $buero, 'email' => $mail,
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
// Die Profile, die der bestehende Auftritt selbst verlinkt - einmal hier, gezeigt vom Baustein
@@ -851,6 +853,8 @@ $settings->save([
'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/',
'google' => 'https://g.page/ElektroBernhardbadenbaden',
],
'hours' => array_fill_keys(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], ['opens' => '07:30', 'closes' => '17:00']),
'areaServed' => implode("\n", ['Baden-Baden', 'Sinzheim', 'Hügelsheim', 'Iffezheim', 'Bühl', 'Rastatt', 'Kuppenheim', 'Muggensturm', 'Gaggenau', 'Gernsbach', 'Murgtal', 'Ettlingen', 'Karlsruhe', 'Achern', 'Oberkirch', 'Offenburg']),
'localBusiness' => true, 'llmsTxt' => true,
// Der Kopfbereich schwebt über dem dunklen Seitenkopf und bleibt beim Scrollen oben.
'stickyHeader' => true, 'headerOverlay' => true,
+884
View File
@@ -0,0 +1,884 @@
<?php
/**
* Baut die Website „Tagespflege Jacqueline Moldovan“ als Inhalt dieses CMS auf.
*
* Vorlage ist das gelieferte Paket „Tagespflege_Moldovan_Website.zip“ (09.09.2026): ein Onepager
* aus index.html, style.css und script.js. Wortlaut, Farben, Schriftgrößen, Abstände und die
* Reihenfolge der Abschnitte sind daraus übernommen. Gebaut ist alles aus gewöhnlichen
* Bausteinen – kein eigener Baustein, kein eigenes Skript, keine externe Schrift.
*
* Zwei Stellen weichen bewusst ab:
* - Das Kontaktformular verschickt serverseitig, statt im Mailprogramm des Besuchers eine
* vorbereitete E-Mail zu öffnen. Der Hinweistext darunter sagt das entsprechend.
* - Das Markenzeichen liegt in der Vorlage als SVG vor; die Medienverwaltung nimmt JPEG, PNG und
* WebP. Verwendet wird deshalb marke.png, die verlustfreie Umsetzung derselben Zeichnung.
*
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-tagespflege-moldovan.sh).
* Erstes Argument: der Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png.
*/
require dirname(__DIR__).'/config/bootstrap.php';
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Infrastructure\ConnectionFactory;
use Symfony\Component\HttpFoundation\File\UploadedFile;
$root = dirname(__DIR__);
$assets = $argv[1] ?? null;
if (null === $assets || !is_dir($assets)) {
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
exit(1);
}
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
if (!str_contains($url, 'tagespflege-moldovan')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Moldovan-Datenbank zeigen.\n");
exit(1);
}
$db = ConnectionFactory::create($url);
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
exit(1);
}
$media = new MediaLibrary($db, $mediaDirectory);
$settings = new SiteSettings($db, $media);
$pages = new PageRepository($db, new DocumentValidator(), $settings);
$partials = new PartialRepository($db, new DocumentValidator());
$menus = new MenuRepository($db, $settings);
$forms = new FormRepository($db);
$theme = new Theme($db);
$mail = 'info@tagespflege-moldovan.com';
$telefon = '07221 / 922 7961';
$telHref = 'tel:+4972219227961';
$SCHEMA = 23;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
$ecken = static fn (string $r): array => ['topLeft' => $r, 'topRight' => $r, 'bottomRight' => $r, 'bottomLeft' => $r];
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
return $block;
};
/** Der Rhythmus der Vorlage: 100 px am Schreibtisch, 75 auf dem Tablet, 61 auf dem Telefon. */
$abschnitt = static function (string $id, array $children, array $style = [], ?string $anchor = null) use ($section): array {
$basis = [
'desktop' => ['padding' => ['top' => '100px', 'bottom' => '100px']],
'tablet' => ['padding' => ['top' => '75px', 'bottom' => '75px']],
'mobile' => ['padding' => ['top' => '61px', 'bottom' => '61px']],
];
foreach ($style as $device => $values) {
$basis[$device] = [...($basis[$device] ?? []), ...$values];
}
return $section($id, $children, $basis, $anchor);
};
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
if ([] !== $design) {
$block['layout'] = ['design' => $design];
}
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
/** Eine Zeile, die nebeneinander stellt statt zu rastern – Marke, Signatur, Wertepunkt, Zeitleiste. */
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
if ([] !== $tablet) {
$style['tablet'] = $tablet;
}
if ([] !== $mobil) {
$style['mobile'] = $mobil;
}
return $row($id, $children, [], $style);
};
$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all'): array {
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
if ([] !== $style) {
$block['style'] = $style;
}
if ('all' !== $sichtbarkeit) {
$block['layout'] = ['visibility' => $sichtbarkeit];
}
return $block;
};
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
/** Eine reine Fläche: der Strich vor einer Kleinzeile, die Linie der Zeitleiste, die grüne Form. */
$flaeche = static function (string $id, array $style, string $css = ''): array {
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$button = static function (string $id, string $text, string $href, array $style = []): array {
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style];
};
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
$gross = end($eintrag['variants']);
$props = [
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
'mediaId' => $eintrag['id'],
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
];
if (null !== $href) {
$props['href'] = $href;
$props['click'] = 'link';
}
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
};
/**
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
*
* Ein Block mit Einblendung wartet unsichtbar, bis er ins Bild scrollt; was beim Laden schon
* sichtbar ist, spielt sofort. Ohne JavaScript steht alles einfach da – die Seite bleibt
* vollständig, nur ohne Bewegung.
*
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
*/
$einblenden = static function (array $dokument, array $plan): array {
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
foreach ($bloecke as $i => $block) {
if (isset($plan[$block['id'] ?? ''])) {
[$art, $verzug] = [...$plan[$block['id']], 'none'];
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
}
if (isset($block['children'])) {
$bloecke[$i]['children'] = $gehen($block['children']);
}
}
return $bloecke;
};
$dokument['blocks'] = $gehen($dokument['blocks']);
return $dokument;
};
// Wiederkehrende Gestaltungswerte der Vorlage.
$serif = ['fontFamily' => 'heading', 'fontWeight' => '400', 'letterSpacing' => '-0.045em', 'lineHeight' => 1.1];
$h2Stil = ['desktop' => [...$serif, 'fontSize' => '53px', 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '42px'], 'mobile' => ['fontSize' => '38px']];
$emGruen = '& em{color:#718f3d;font-weight:400}';
$fliess = ['desktop' => ['color' => '@gedaempft', 'fontSize' => '16px', 'lineHeight' => 1.65, 'margin' => ['bottom' => '20px']]];
$klein = ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.13em', 'color' => '#6a795e', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']]];
$h3Stil = ['desktop' => ['fontFamily' => 'body', 'fontSize' => '19px', 'fontWeight' => '500', 'letterSpacing' => '-0.025em', 'lineHeight' => 1.35, 'margin' => ['bottom' => '13px']]];
/** Die Kleinzeile über einer Überschrift: ein kurzer Strich, dann Versalien. */
$kleinzeile = static function (string $id, string $inhalt, string $farbe = '#527236', string $strich = '@gruen', string $abstand = '25px') use ($zeile, $flaeche, $text): array {
return $zeile($id, [
$flaeche($id.'-strich', ['desktop' => ['width' => '24px', 'height' => '3px', 'backgroundColor' => $strich, 'flexShrink' => 0]]),
$text($id.'-text', $inhalt, [
'desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'textTransform' => 'uppercase', 'lineHeight' => 1.7, 'color' => $farbe, 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '11px'],
]),
], '11px', ['margin' => ['top' => '0px', 'bottom' => $abstand]], ['margin' => ['top' => '0px', 'bottom' => '18px']]);
};
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien, Formular
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => 'Tagespflege Jacqueline Moldovan', 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'tinte', 'name' => 'Tinte', 'value' => '#294655'],
['key' => 'blau', 'name' => 'Blau', 'value' => '#2e7595'],
['key' => 'blau-dunkel', 'name' => 'Blau dunkel', 'value' => '#245e79'],
['key' => 'dunkelblau', 'name' => 'Kontaktbereich', 'value' => '#284f63'],
['key' => 'gruen', 'name' => 'Grün', 'value' => '#9abf56'],
['key' => 'limette', 'name' => 'Limette', 'value' => '#c0d348'],
['key' => 'oliv', 'name' => 'Oliv', 'value' => '#718f3d'],
['key' => 'marke', 'name' => 'Markengrün', 'value' => '#83a944'],
['key' => 'gedaempft', 'name' => 'Gedämpft', 'value' => '#647078'],
['key' => 'blass', 'name' => 'Blass', 'value' => '#f3f6ef'],
['key' => 'leiste', 'name' => 'Kopfleiste', 'value' => '#f1f4ee'],
['key' => 'sand', 'name' => 'Werteband', 'value' => '#fafbf8'],
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#dce3dd'],
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
],
[
// Beide Schriften stehen auf jedem Gerät bereit; die Vorlage lädt bewusst keine Webschrift.
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1240px', 'headingScale' => 1.25, 'linkUnderline' => false],
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
);
echo "Farben, Schriften und Buttons gesetzt\n";
$medien = [];
foreach ([
'marke' => ['marke.png', 'Markenzeichen der Tagespflege Jacqueline Moldovan: ein Schmetterling in Grün und Blau'],
'gemeinsam' => ['gemeinsam.webp', 'Illustrative Szene: Eine Seniorin und eine Begleiterin lachen gemeinsam an einem Tisch.'],
'portraet' => ['jacqueline-moldovan.png', 'Jacqueline Moldovan'],
] as $schluessel => [$datei, $alt]) {
$pfad = rtrim($assets, '/\\').'/'.$datei;
if (!is_file($pfad)) {
fwrite(STDERR, "Bild fehlt: $pfad\n");
exit(1);
}
$eintrag = $media->upload(new UploadedFile($pfad, $datei, null, null, true));
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
$medien[$schluessel] = $media->find($eintrag['id']);
echo "Bild übernommen: $datei\n";
}
$forms->save('kontakt', 'Kontaktanfrage', [
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für einen persönlichen Rückruf'],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Erzählen Sie uns, was Ihnen wichtig ist. Bitte keine Angaben zu Gesundheit oder Pflegegrad – das besprechen wir lieber persönlich.'],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.'],
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über tagespflege-moldovan.com', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email'], 0);
echo "Formular angelegt: kontakt\n";
// ---------------------------------------------------------------------------------------------
// Kopfbereich: schmale Leiste, darunter der weiße Kopf, der beim Scrollen oben bleibt
// ---------------------------------------------------------------------------------------------
/** Die Wortmarke: Schmetterling, darüber „Tagespflege“, darunter der Name. */
$wortmarke = static function (string $praefix, string $bildhoehe, string $bildbreite, string $gross, string $klein) use ($zeile, $column, $bild, $text, $medien, $seiten): array {
return $zeile($praefix.'-zeile', [
$column($praefix.'-bild', [
$bild($praefix.'-logo', $medien['marke'], 'Tagespflege Jacqueline Moldovan', [
'desktop' => ['maxWidth' => $bildbreite, 'imageHeight' => $bildhoehe, 'objectFit' => 'contain', 'margin' => $seiten('0px')],
], '/'),
], ['desktop' => ['flexShrink' => 0]]),
$column($praefix.'-schrift', [
$text($praefix.'-name', 'Tagespflege', ['desktop' => ['fontSize' => $gross, 'fontWeight' => '700', 'letterSpacing' => '-0.04em', 'lineHeight' => 1.3, 'color' => '@marke', 'margin' => ['bottom' => '0px']]]),
$text($praefix.'-zusatz', 'Jacqueline Moldovan', ['desktop' => ['fontSize' => $klein, 'color' => '#62676b', 'lineHeight' => 1.3, 'margin' => ['top' => '3px', 'bottom' => '0px']]]),
]),
], '11px', ['flexShrink' => 0]);
};
$kopf = [
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
$section('kopf-leiste', [
$row('kopf-leiste-zeile', [
$column('kopf-leiste-ort', [
$text('kopf-leiste-ort-text', 'Mitten in Baden-Baden. Mitten im Leben.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']]]),
], [], 'hide-mobile'),
$column('kopf-leiste-kontakt', [
$text('kopf-leiste-telefon', '<a href="'.$telHref.'">Ihr direkter Kontakt: '.$telefon.'</a>', [
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '12px', 'textAlign' => 'center'],
], '& a:hover{text-decoration:underline}'),
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
], [
'desktop' => ['backgroundColor' => '@leiste', 'color' => '#506054', 'padding' => ['top' => '8px', 'bottom' => '8px']],
]),
$section('kopf-haupt', [
$row('kopf-haupt-zeile', [
$column('kopf-marke', [$wortmarke('kopf-marke', '60px', '40px', '24px', '15px')], ['desktop' => ['flexShrink' => 0]]),
$column('kopf-navigation', [
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'mobile', 'burgerLabel' => 'both'],
'style' => ['desktop' => [
'fontSize' => '15px', 'justifyContent' => 'flex-end', 'display' => 'flex',
// Der Menü-Knopf im Zuschnitt der Vorlage: heller Rahmen, kaum gerundet.
'burgerColor' => '@tinte', 'burgerBorderColor' => '#d8e0d1', 'burgerBorderRadius' => '3px', 'burgerFontSize' => '14px',
]],
'css' => "& .menu-list{gap:4px 29px;justify-content:flex-end}\n& .menu-list a{transition:color .2s}\n& .menu-list a:hover{color:#688f30;text-decoration:none}\n& .menu-list>li:last-child>a{border:1px solid #cfd9ce;border-radius:4px;padding:12px 19px}",
],
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '25px', 'minHeight' => '100px', 'margin' => ['top' => '0px']],
'tablet' => ['minHeight' => '86px'],
'mobile' => ['minHeight' => '80px'],
]),
], [
'desktop' => ['backgroundColor' => '#fffffff7', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e7ece7', 'padding' => ['top' => '0px', 'bottom' => '0px']],
]),
],
];
// ---------------------------------------------------------------------------------------------
// Fußbereich
// ---------------------------------------------------------------------------------------------
$fuss = [
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
$section('fuss', [
$zeile('fuss-oben', [
$column('fuss-marke', [$wortmarke('fuss-marke', '51px', '34px', '19px', '13px')], ['desktop' => ['flexShrink' => 0]]),
$column('fuss-spruch', [
$text('fuss-spruch-text', 'Mit Aufmerksamkeit. Mit Freude. Mit Herz.', [
'desktop' => ['fontFamily' => 'heading', 'fontStyle' => 'italic', 'fontSize' => '17px', 'color' => '#737b78', 'textAlign' => 'center', 'margin' => ['bottom' => '0px']],
'tablet' => ['maxWidth' => '210px'],
'mobile' => ['fontSize' => '15px', 'textAlign' => 'left', 'maxWidth' => '100%'],
]),
], ['mobile' => ['order' => 3, 'flexBasis' => '100%']]),
$column('fuss-nachoben', [
$text('fuss-nachoben-text', '<a href="#start">Nach oben ↑</a>', [
'desktop' => ['fontSize' => '13px', 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
'mobile' => ['margin' => ['left' => 'auto']],
], '& a:hover{text-decoration:underline}'),
]),
], '25px', ['justifyContent' => 'space-between', 'padding' => ['top' => '37px', 'bottom' => '37px']], ['flexWrap' => 'wrap', 'gap' => '22px', 'padding' => ['top' => '30px', 'bottom' => '30px']]),
$zeile('fuss-unten', [
$column('fuss-recht', [
$text('fuss-copyright', '© 2026 Tagespflege Jacqueline Moldovan', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]),
]),
$column('fuss-hinweis', [
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
'style' => ['desktop' => ['fontSize' => '12px', 'justifyContent' => 'flex-end', 'gap' => '4px'], 'mobile' => ['justifyContent' => 'flex-start']],
'css' => "& a:hover{text-decoration:underline}"],
$text('fuss-hinweis-text', 'Gestaltungsentwurf · Beispieltexte & illustratives KI-Bild', ['desktop' => ['fontSize' => '12px', 'textAlign' => 'right', 'margin' => ['top' => '6px', 'bottom' => '0px']], 'mobile' => ['fontSize' => '11px', 'textAlign' => 'left']]),
]),
], '20px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e7ebe4', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'color' => '#78817b'], ['flexDirection' => 'column', 'gap' => '7px']),
], [
'desktop' => ['borderWidth' => $nurUnten('7px'), 'borderStyle' => 'solid', 'borderColor' => '@limette', 'padding' => ['top' => '0px', 'bottom' => '0px']],
]),
],
];
$partials->create('header', 'default', 'Kopfbereich', $kopf);
$partials->create('footer', 'default', 'Fußbereich', $fuss);
echo "Kopf- und Fußbereich angelegt\n";
/**
* Eine Rechtsseite: Kleinzeile, Titel, Vorspann, dann Absätze mit Zwischenüberschriften.
*
* Stellen, die nur die Tagespflege selbst beantworten kann, stehen als „NOCH ZU PRÜFEN“ im Text,
* statt erfunden zu werden – eine falsche Registerangabe ist schlimmer als eine fehlende.
*/
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($abschnitt, $kleinzeile, $heading, $text, $serif, $fliess, $emGruen): array {
$kinder = [
$kleinzeile($praefix.'-label', $label),
$heading($praefix.'-h1', $titel, 1, [
'desktop' => [...$serif, 'fontSize' => '53px', 'margin' => ['bottom' => '25px']],
'tablet' => ['fontSize' => '42px'],
'mobile' => ['fontSize' => '38px'],
], $emGruen),
$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@tinte', 'margin' => ['bottom' => '34px']],
'mobile' => ['fontSize' => '18px'],
]),
];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
if ('' !== $zwischentitel) {
$kinder[] = $heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
'desktop' => ['fontFamily' => 'body', 'fontSize' => '19px', 'fontWeight' => '700', 'letterSpacing' => '-0.02em', 'lineHeight' => 1.35, 'margin' => ['top' => '34px', 'bottom' => '12px']],
]);
}
$kinder[] = $text($praefix.'-p-'.$i, $inhalt, $fliess);
}
$kinder[0]['layout'] = ['effects' => ['entrance' => 'fade', 'delay' => 'none']];
$kinder[1]['layout'] = ['effects' => ['entrance' => 'rise', 'delay' => 'short']];
$kinder[2]['layout'] = ['effects' => ['entrance' => 'rise', 'delay' => 'medium']];
return $abschnitt($praefix, $kinder, ['desktop' => ['maxWidth' => '820px', 'padding' => ['top' => '80px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '52px', 'bottom' => '61px']]]);
};
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
$werte = ['Persönlich begleitet', 'Gemeinsam aktiv', 'Mit Freude durch den Tag'];
// Nummer, Symbol, Farbe des oberen Rands, Titel, Text – die drei Karten aus „Unser Angebot“.
$angebote = [
['01 / MITEINANDER', 'people', '#a9c563', '#f0f5e7', '#6b8f36', 'Gesellschaft genießen', 'Erzählen, zuhören, gemeinsam lachen. Hier können aus Begegnungen vertraute Beziehungen werden.'],
['02 / LEBENSFREUDE', 'music', '#4285a2', '#edf5f8', '#347895', 'Mit Freude aktiv sein', 'Musik, Bewegung und kreative Momente bringen Abwechslung in den Tag. Jeder macht mit, wie es sich gut anfühlt.'],
['03 / GEBORGENHEIT', 'home', '#bdce62', '#f0f5e7', '#6b8f36', 'Sich aufgehoben fühlen', 'Ein liebevoll gedeckter Tisch, Zeit zum Durchatmen und aufmerksame Begleitung geben dem Tag einen vertrauten Rahmen.'],
];
$tagesablauf = [
['01', 'ANKOMMEN', 'In Ruhe in den Tag starten', 'Ein freundliches Willkommen, ein gemeinsames Frühstück und Zeit für die ersten Gespräche.'],
['02', 'GEMEINSAM ERLEBEN', 'Den Vormittag gestalten', 'Ein Spiel, etwas Bewegung oder gemeinsam kreativ werden – passend zu Lust und Möglichkeiten.'],
['03', 'GENIESSEN & AUSRUHEN', 'Zeit für eine Pause', 'Zusammen Mittag essen und anschließend entspannen. Jeder darf seinen eigenen Rhythmus finden.'],
['04', 'DEN TAG AUSKLINGEN LASSEN', 'Mit einem Lächeln nach Hause', 'Kaffee, eine gemütliche Runde und schöne Erinnerungen, die mit nach Hause gehen.'],
];
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'Tagespflege Moldovan',
'description' => 'Tagespflege Jacqueline Moldovan in Baden-Baden. Ein Ort für Begegnung, Geborgenheit und schöne gemeinsame Tage.',
'blocks' => [
// 1 – Hero: Text links, Foto rechts auf einer limettenen Form, Notizkarte darüber.
$section('hero', [
$row('hero-zeile', [
$column('hero-text', [
$kleinzeile('hero-label', 'Tagespflege in Baden-Baden'),
$heading('hero-titel', 'Ein schöner Tag.<br>Ein gutes Gefühl.<br><em>Ein Stück Zuhause.</em>', 1, [
'desktop' => [...$serif, 'fontSize' => '68px', 'margin' => ['bottom' => '25px']],
'tablet' => ['fontSize' => '42px'],
'mobile' => ['fontSize' => '43px', 'lineHeight' => 1.12, 'margin' => ['bottom' => '21px']],
], $emGruen),
$text('hero-intro', 'Gemeinsam lachen, Neues erleben und sich gut aufgehoben fühlen. Wir geben dem Tag Leben – und Angehörigen ein wenig Freiraum.', [
'desktop' => ['maxWidth' => '465px', 'color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
'mobile' => ['fontSize' => '16px', 'maxWidth' => '520px'],
]),
$zeile('hero-aktionen', [
$column('hero-aktion-knopf', [
$button('hero-knopf', 'Lernen wir uns kennen', '#kontakt', ['desktop' => ['borderRadius' => $ecken('4px'), 'margin' => ['top' => '0px']]]),
], ['desktop' => ['flexShrink' => 0]]),
$column('hero-aktion-link', [
$text('hero-textlink', '<a href="#bei-uns">Unsere Tagespflege entdecken ↓</a>', [
'desktop' => ['fontSize' => '14px', 'padding' => ['top' => '15px', 'bottom' => '15px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cad4c8', 'margin' => ['bottom' => '0px']],
]),
], ['desktop' => ['flexShrink' => 0]]),
], '18px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap'], ['rowGap' => '8px', 'columnGap' => '20px']),
$text('hero-notiz', "Tagsüber in guter Gesellschaft.\nAbends im vertrauten Zuhause.", [
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '#737a7a', 'borderWidth' => $nurLinks('3px'), 'borderStyle' => 'solid', 'borderColor' => '@limette', 'padding' => ['left' => '15px'], 'margin' => ['top' => '29px', 'bottom' => '0px']],
'mobile' => ['margin' => ['top' => '20px', 'bottom' => '0px']],
]),
]),
$column('hero-visual', [
// Die limettene Form hinter dem Foto – dieselbe Schnittkante wie in der Vorlage.
$flaeche('hero-form', [
'desktop' => ['position' => 'absolute', 'top' => '-19px', 'right' => '-17px', 'bottom' => '35px', 'left' => '35px', 'height' => 'auto', 'width' => 'auto', 'backgroundColor' => '@limette', 'zIndex' => 0],
'mobile' => ['top' => '-14px', 'right' => '-9px', 'bottom' => '28px', 'left' => '30px'],
], 'clip-path:polygon(18% 0,100% 0,81% 100%,0 100%)'),
$bild('hero-bild', $medien['gemeinsam'], 'Illustrative Szene: Eine Seniorin und eine Begleiterin lachen gemeinsam an einem Tisch.', [
'desktop' => ['position' => 'relative', 'zIndex' => 1, 'imageHeight' => '465px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'imageRadius' => ['topLeft' => '110px', 'topRight' => '6px', 'bottomRight' => '6px', 'bottomLeft' => '6px'], 'margin' => $seiten('0px')],
'tablet' => ['imageHeight' => '390px'],
'mobile' => ['imageHeight' => '380px', 'imageRadius' => ['topLeft' => '85px', 'topRight' => '6px', 'bottomRight' => '6px', 'bottomLeft' => '6px']],
], null, '& img{object-position:54% 45%}'),
$column('hero-karte', [
$text('hero-karte-label', 'WAS WIR UNS WÜNSCHEN', $klein),
$text('hero-karte-text', "Dass Sie gerne\n<em>wiederkommen.</em>", [
'desktop' => [...$serif, 'fontSize' => '30px', 'lineHeight' => 1.15, 'letterSpacing' => '-0.02em', 'margin' => ['top' => '11px', 'bottom' => '15px']],
'mobile' => ['fontSize' => '27px'],
], '& em{color:#73933d}'),
$flaeche('hero-karte-strich', ['desktop' => ['width' => '36px', 'height' => '3px', 'backgroundColor' => '@limette']]),
], [
'desktop' => ['position' => 'absolute', 'left' => '-25px', 'bottom' => '-23px', 'zIndex' => 2, 'backgroundColor' => '@papier', 'minWidth' => '245px', 'padding' => ['top' => '22px', 'right' => '28px', 'bottom' => '17px', 'left' => '28px'], 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#edf0e9', 'shadowY' => '8px', 'shadowBlur' => '35px', 'shadowColor' => '#29465513'],
'tablet' => ['left' => '-12px', 'minWidth' => '190px', 'padding' => ['top' => '18px', 'right' => '22px', 'bottom' => '14px', 'left' => '22px']],
'mobile' => ['left' => '-12px', 'bottom' => '-25px', 'minWidth' => '190px', 'padding' => ['top' => '20px', 'right' => '25px', 'bottom' => '16px', 'left' => '25px']],
]),
$text('hero-caption', 'Gemeinschaft, die guttut.', [
'desktop' => ['position' => 'absolute', 'right' => '0px', 'bottom' => '-31px', 'fontSize' => '12px', 'color' => '#737b79', 'margin' => ['bottom' => '0px']],
]),
], [
'desktop' => ['position' => 'relative', 'margin' => ['left' => '10px', 'bottom' => '25px']],
'tablet' => ['margin' => ['left' => '0px', 'bottom' => '25px']],
'mobile' => ['margin' => ['top' => '0px', 'right' => '6px', 'bottom' => '22px', 'left' => '12px']],
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '45px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '25px'],
'mobile' => ['gap' => '45px'],
]),
], [
'desktop' => ['minHeight' => '650px', 'display' => 'flex', 'alignItems' => 'center', 'padding' => ['top' => '67px', 'bottom' => '75px']],
'tablet' => ['minHeight' => 'auto', 'padding' => ['top' => '50px', 'bottom' => '75px']],
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '42px', 'bottom' => '57px']],
], 'start'),
// 2 – Das Band mit den drei Versprechen.
$section('werte', [
$row('werte-zeile', array_map(
static fn (string $wert, int $i): array => $column('wert-'.($i + 1), [
$symbol('wert-'.($i + 1).'-haken', 'check', 16, ['desktop' => ['color' => '#789d3e', 'flexShrink' => 0]]),
$text('wert-'.($i + 1).'-text', $wert, ['desktop' => ['fontSize' => '15px', 'letterSpacing' => '0.015em', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '14px']]),
], [
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'gap' => '13px']
+ (0 === $i ? [] : ['borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '1px'], 'borderStyle' => 'solid', 'borderColor' => '#dbe2d6']),
'mobile' => ['justifyContent' => 'flex-start', 'gap' => '16px', 'borderStyle' => 'none'],
]),
$werte, array_keys($werte),
), ['desktop' => ['columns' => 'three'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['margin' => ['top' => '0px']],
'mobile' => ['gap' => '12px'],
]),
], [
'desktop' => ['backgroundColor' => '@sand', 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '22px', 'bottom' => '22px']],
'mobile' => ['padding' => ['top' => '20px', 'bottom' => '20px']],
]),
// 3 – Willkommen: Überschrift links, Text und Signatur rechts.
$abschnitt('vorstellung', [
$row('vorstellung-zeile', [
$column('vorstellung-titel', [
$kleinzeile('vorstellung-label', 'Willkommen bei uns'),
$heading('vorstellung-h2', 'Hier zählt der Mensch.<br><em>Und das Miteinander.</em>', 2, $h2Stil, $emGruen),
]),
$column('vorstellung-text', [
$text('vorstellung-lead', 'Manchmal braucht es nur ein vertrautes Gesicht, ein gutes Gespräch und das Gefühl, dazuzugehören.', [
'desktop' => ['fontSize' => '20px', 'lineHeight' => 1.7, 'color' => '@tinte', 'margin' => ['bottom' => '20px']],
'mobile' => ['fontSize' => '18px'],
]),
$text('vorstellung-p1', 'Unsere Tagespflege ist ein Ort für ältere Menschen, die ihren Tag in Gesellschaft verbringen möchten. Dabei ist uns wichtig, Raum für persönliche Gewohnheiten zu lassen: für Aktivität ebenso wie für eine ruhige Pause.', $fliess),
$text('vorstellung-p2', 'Mit Aufmerksamkeit und einem offenen Ohr möchten wir den Alltag unserer Gäste bereichern und ihre Angehörigen entlasten.', $fliess),
$zeile('vorstellung-signatur', [
$column('signatur-bild', [
$bild('signatur-portraet', $medien['portraet'], 'Jacqueline Moldovan', [
'desktop' => ['maxWidth' => '104px', 'imageHeight' => '104px', 'objectFit' => 'cover', 'objectPosition' => 'top', 'imageRadius' => $ecken('50%'), 'imageBorderWidth' => $seiten('4px'), 'imageBorderStyle' => 'solid', 'imageBorderColor' => '@papier', 'margin' => $seiten('0px')],
'tablet' => ['maxWidth' => '86px', 'imageHeight' => '86px'],
'mobile' => ['maxWidth' => '86px', 'imageHeight' => '86px'],
], null),
], ['desktop' => ['flexShrink' => 0]]),
$flaeche('signatur-strich', ['desktop' => ['width' => '28px', 'height' => '2px', 'backgroundColor' => '@gruen', 'flexShrink' => 0], 'tablet' => ['width' => '20px']]),
$column('signatur-name', [
$text('signatur-person', '<strong>Jacqueline Moldovan</strong>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '600', 'color' => '#2e6682', 'margin' => ['bottom' => '0px']]]),
$text('signatur-rolle', 'Geschäftsführerin', ['desktop' => ['fontSize' => '13px', 'color' => '@gedaempft', 'margin' => ['top' => '4px', 'bottom' => '0px']]]),
]),
], '18px', ['flexWrap' => 'wrap', 'margin' => ['top' => '29px']], ['gap' => '14px']),
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '80px', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '50px'],
'mobile' => ['gap' => '28px'],
]),
], [], 'bei-uns'),
// 4 – Unser Angebot: drei Karten auf blasser Fläche.
$abschnitt('angebot-abschnitt', [
$zeile('angebot-kopf', [
$column('angebot-kopf-titel', [
$kleinzeile('angebot-label', 'Raum für einen guten Tag'),
$heading('angebot-h2', 'Was den Alltag<br><em>schöner macht.</em>', 2, $h2Stil, $emGruen),
]),
$column('angebot-kopf-text', [
$text('angebot-intro', "Die kleinen Dinge machen den Unterschied.\nUnd Menschen, die sich Zeit nehmen.", [
'desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'margin' => ['bottom' => '5px']],
'tablet' => ['maxWidth' => '260px'],
'mobile' => ['margin' => ['top' => '23px', 'bottom' => '0px']],
]),
]),
], '35px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '46px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['top' => '0px', 'bottom' => '30px']]),
$row('angebot-raster', array_map(
static function (array $a, int $i) use ($column, $text, $symbol, $heading, $h3Stil, $seiten, $ecken): array {
$n = $i + 1;
return $column('angebot-'.$n, [
$text('angebot-'.$n.'-nummer', $a[0], ['desktop' => ['fontSize' => '11px', 'fontWeight' => '600', 'letterSpacing' => '0.14em', 'color' => '#788574', 'margin' => ['bottom' => '0px']]]),
$symbol('angebot-'.$n.'-symbol', $a[1], 32, [
'desktop' => ['width' => '55px', 'height' => '55px', 'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'borderRadius' => $ecken('50%'), 'backgroundColor' => $a[3], 'color' => $a[4], 'margin' => ['top' => '31px', 'bottom' => '23px']],
'mobile' => ['width' => '48px', 'height' => '48px'],
]),
$heading('angebot-'.$n.'-titel', $a[5], 3, $h3Stil),
$text('angebot-'.$n.'-text', $a[6], ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.8, 'margin' => ['bottom' => '0px']]]),
], [
'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => ['top' => '3px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => $a[2], 'padding' => ['top' => '30px', 'right' => '29px', 'bottom' => '28px', 'left' => '29px']],
'tablet' => ['padding' => ['top' => '23px', 'right' => '19px', 'bottom' => '23px', 'left' => '19px']],
'mobile' => ['padding' => ['top' => '25px', 'right' => '27px', 'bottom' => '25px', 'left' => '27px']],
]);
},
$angebote, array_keys($angebote),
), ['desktop' => ['columns' => 'three'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '25px', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '15px'],
'mobile' => ['gap' => '17px'],
]),
], ['desktop' => ['backgroundColor' => '@blass']], 'angebot'),
// 5 – Ein Tag bei uns: Einleitung links, die Zeitleiste rechts.
$abschnitt('tagesablauf-abschnitt', [
$row('tag-zeile', [
$column('tag-intro', [
$kleinzeile('tag-label', 'Ein Tag bei uns'),
$heading('tag-h2', 'Ein guter Rhythmus.<br><em>Ganz ohne Eile.</em>', 2, $h2Stil, $emGruen),
$text('tag-intro-text', 'So könnte ein gemeinsamer Tag aussehen. Mit vertrauten Ritualen, neuen Impulsen und Zeit für das, was Ihnen Freude macht.', [
'desktop' => ['maxWidth' => '375px', 'color' => '@gedaempft', 'margin' => ['top' => '27px', 'bottom' => '0px']],
'mobile' => ['maxWidth' => '100%'],
]),
$bild('tag-marke', $medien['marke'], '', [
'desktop' => ['maxWidth' => '110px', 'opacity' => 0.65, 'margin' => ['top' => '36px']],
]) + ['layout' => ['visibility' => 'hide-mobile']],
]),
$column('tag-liste', array_map(
static function (array $s, int $i) use ($zeile, $column, $text, $heading, $flaeche, $klein, $h3Stil, $ecken, $seiten, $tagesablauf): array {
$n = $i + 1;
$letzte = $n === count($tagesablauf);
$links = [
$text('tag-'.$n.'-zahl', $s[0], ['desktop' => [
'width' => '42px', 'height' => '42px', 'flexShrink' => 0, 'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
'backgroundColor' => '#f2f6e9', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dbe7cc', 'borderRadius' => $ecken('50%'),
'fontSize' => '12px', 'color' => '#72943d', 'margin' => ['bottom' => '0px'],
]]),
];
if (!$letzte) {
$links[] = $flaeche('tag-'.$n.'-linie', ['desktop' => ['width' => '1px', 'height' => 'auto', 'flexGrow' => 1, 'backgroundColor' => '#dbe3d4']]);
}
return $zeile('tag-schritt-'.$n, [
$column('tag-'.$n.'-spur', $links, ['desktop' => ['width' => '43px', 'flexShrink' => 0, 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'center']]),
$column('tag-'.$n.'-inhalt', [
$text('tag-'.$n.'-label', $s[1], $klein),
$heading('tag-'.$n.'-titel', $s[2], 3, ['desktop' => [...$h3Stil['desktop'], 'margin' => ['top' => '7px', 'bottom' => '9px']]]),
$text('tag-'.$n.'-text', $s[3], ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.75, 'margin' => ['bottom' => '0px']]]),
]),
], '24px', ['alignItems' => 'stretch', 'padding' => ['bottom' => $letzte ? '0px' : '34px']], ['gap' => '19px']);
},
$tagesablauf, array_keys($tagesablauf),
)),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '90px', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '50px'],
'mobile' => ['gap' => '28px'],
]),
], [], 'tagesablauf'),
// 6 – Kontakt: dunkelblaue Fläche, rechts die weiße Formularkarte.
$section('kontakt-abschnitt', [
// Die schräge hellere Fläche am rechten Rand, mit dem limettenen Streifen davor.
$flaeche('kontakt-form', [
'desktop' => ['position' => 'absolute', 'top' => '0px', 'bottom' => '0px', 'right' => '-80px', 'width' => '180px', 'height' => 'auto', 'backgroundColor' => '#315d71', 'borderWidth' => $nurLinks('12px'), 'borderStyle' => 'solid', 'borderColor' => '#a1bb59', 'zIndex' => 0],
'mobile' => ['right' => '-150px'],
], 'transform:skewX(-16deg);pointer-events:none'),
$row('kontakt-zeile', [
$column('kontakt-text', [
$kleinzeile('kontakt-label', 'Der erste Schritt ist ein Gespräch', '#c4d998', '@limette', '22px'),
$heading('kontakt-h2', 'Ein gutes Gefühl<br>beginnt mit einem<br><em>persönlichen Hallo.</em>', 2, [
'desktop' => [...$serif, 'fontSize' => '59px', 'color' => '@papier', 'margin' => ['bottom' => '24px']],
'tablet' => ['fontSize' => '45px'],
'mobile' => ['fontSize' => '43px'],
], '& em{color:#c1d786;font-weight:400}'),
$text('kontakt-intro', 'Sie möchten unsere Tagespflege kennenlernen oder haben Fragen? Erzählen Sie uns, was Ihnen wichtig ist. Wir freuen uns auf Sie.', [
'desktop' => ['maxWidth' => '430px', 'color' => '#d5e0e4', 'fontSize' => '15px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
]),
// Ein Telefonlink, kein Button-Baustein: Buttons verweisen auf Seiten, nicht auf tel:.
$text('kontakt-anrufen', '<a href="'.$telHref.'">Rufen Sie uns an ↗</a>', ['desktop' => ['margin' => ['bottom' => '0px']]],
"& a{display:inline-flex;align-items:center;gap:25px;background:#c3d886;color:#27434a;border-radius:4px;padding:15px 22px;font-size:15px;font-weight:600;transition:background .2s,transform .2s}\n& a:hover{background:#d1e3a5;transform:translateY(-2px)}"),
['id' => 'kontakt-daten', 'type' => 'contact',
'props' => ['phone' => $telefon, 'email' => $mail, 'address' => "Fremersbergstraße 75\n76530 Baden-Baden"],
'style' => ['desktop' => ['fontSize' => '15px', 'color' => '#dce7eb', 'gap' => '10px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff30', 'padding' => ['top' => '26px'], 'margin' => ['top' => '30px']]],
// Die Vorlage führt die Angaben ohne Symbole; die Adresse behält ihren Umbruch.
'css' => "& svg{display:none}\n& span{display:block}\n& a{overflow-wrap:anywhere}\n& a:hover{text-decoration:underline}\n& span:first-child a{font-size:22px;color:#ffffff;letter-spacing:-0.025em}\n& span:last-child{white-space:pre-line;line-height:1.65;margin-top:5px}",
],
], ['desktop' => ['alignSelf' => 'center']]),
$column('kontakt-karte', [
$text('kontakt-karte-label', 'WIR FREUEN UNS AUF IHRE NACHRICHT', ['desktop' => [...$klein['desktop'], 'margin' => ['bottom' => '18px']]]),
$heading('kontakt-karte-titel', 'Was können wir<br>für Sie tun?', 3, [
'desktop' => ['fontFamily' => 'heading', 'fontWeight' => '400', 'fontSize' => '30px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '25px']],
]),
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'kontakt'],
// Felder und Knopf im Zuschnitt der Vorlage; der Wortlaut des Knopfes kommt vom Baustein.
'css' => "& .form-field label{font-size:14px;font-weight:600;color:#294655}
& .form-field input{background:#fafcf8;border:1px solid #cfd9ce;border-radius:4px;padding:12px 13px;font-size:16px;color:#294655}
& .form-field textarea{background:#fafcf8;border:1px solid #cfd9ce;border-radius:4px;padding:12px 13px;font-size:16px;color:#294655;min-height:120px}
& .field-note{font-size:12px;color:#748078}
& .cms-button{width:100%;justify-content:center;border-radius:4px;margin-top:4px}",
],
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an die Tagespflege. Wir verwenden Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '#64736b', 'margin' => ['top' => '19px', 'bottom' => '0px']],
]),
], [
'desktop' => ['alignSelf' => 'center', 'backgroundColor' => '@papier', 'color' => '@tinte', 'borderRadius' => $ecken('3px'), 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '#bdd26f', 'padding' => ['top' => '32px', 'right' => '35px', 'bottom' => '32px', 'left' => '35px']],
'tablet' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
'mobile' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '100px', 'position' => 'relative', 'zIndex' => 1, 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '35px'],
'mobile' => ['gap' => '35px'],
]),
], [
'desktop' => ['backgroundColor' => '@dunkelblau', 'color' => '@papier', 'position' => 'relative', 'overflow' => 'hidden', 'padding' => ['top' => '83px', 'bottom' => '83px']],
'mobile' => ['padding' => ['top' => '60px', 'bottom' => '60px']],
], 'kontakt'),
],
];
// ---------------------------------------------------------------------------------------------
// Seiten anlegen und veröffentlichen
// ---------------------------------------------------------------------------------------------
$erstellt = [];
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
$id = $pages->createPage($slug, null, 'page', $dokument);
if ('' !== $seoTitel) {
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
}
if ('' !== $teilbild) {
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
}
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
$erstellt[$slug] = $id;
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
return $id;
};
// Der Hero spielt beim Laden, alles Weitere beim Hereinscrollen. Gestaffelt, damit eine Zeile
// nacheinander erscheint statt auf einen Schlag.
$startseite = $einblenden($startseite, [
'hero-label' => ['fade'],
'hero-titel' => ['rise', 'short'],
'hero-intro' => ['rise', 'medium'],
'hero-aktionen' => ['rise', 'long'],
'hero-notiz' => ['fade', 'long'],
'hero-visual' => ['right', 'short'],
'werte-zeile' => ['fade'],
'vorstellung-titel' => ['rise'],
'vorstellung-text' => ['rise', 'short'],
'angebot-kopf' => ['rise'],
'angebot-1' => ['rise'],
'angebot-2' => ['rise', 'short'],
'angebot-3' => ['rise', 'medium'],
'tag-intro' => ['rise'],
'tag-schritt-1' => ['rise'],
'tag-schritt-2' => ['rise', 'short'],
'tag-schritt-3' => ['rise', 'medium'],
'tag-schritt-4' => ['rise', 'long'],
'kontakt-text' => ['rise'],
'kontakt-karte' => ['rise', 'short'],
]);
$anlegen('home', $startseite, 'Tagespflege Moldovan · Gemeinsam den Tag genießen', $medien['gemeinsam']['id']);
$anlegen('impressum', [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben der Tagespflege Jacqueline Moldovan in Baden-Baden.',
'blocks' => [
$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
['Anbieterin', "Tagespflege Jacqueline Moldovan<br>Fremersbergstraße 75<br>76530 Baden-Baden<br><br>Vertreten durch Jacqueline Moldovan."],
['Kontakt', "Telefon: <a href=\"$telHref\">$telefon</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a>"],
['Rechtsform und Register', 'NOCH ZU PRÜFEN: Rechtsform, Registergericht und Registernummer ergänzen, sofern eine Eintragung besteht.'],
['Umsatzsteuer', 'NOCH ZU PRÜFEN: Umsatzsteuer-Identifikationsnummer eintragen oder auf die Steuerbefreiung für Pflegeleistungen nach § 4 Nr. 16 UStG hinweisen.'],
['Aufsichtsbehörde', 'NOCH ZU PRÜFEN: zuständige Aufsicht für die Tagespflege eintragen, in Baden-Baden üblicherweise die Heimaufsicht der Stadt. Ebenfalls zu ergänzen: Berufsbezeichnung und zuständige Kammer, sofern einschlägig, sowie ein bestehender Versorgungsvertrag nach § 72 SGB XI.'],
['Verantwortlich für den Inhalt', "Jacqueline Moldovan, Anschrift wie oben."],
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
['Bildnachweis', 'Porträt: Jacqueline Moldovan. Einstiegsbild: illustrative KI-Aufnahme, keine Abbildung tatsächlicher Gäste. Markenzeichen: eigene Gestaltung.'],
['Haftung für Links', 'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Für diese Inhalte ist stets die jeweilige Anbieterin oder der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links umgehend.'],
]),
],
], 'Impressum | Tagespflege Jacqueline Moldovan Baden-Baden');
$anlegen('datenschutz', [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
'description' => 'Wie die Tagespflege Jacqueline Moldovan Daten beim Besuch der Website und bei einer Kontaktaufnahme verarbeitet.',
'blocks' => [
$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Kontaktaufnahme verarbeitet werden – und welche Rechte Sie haben.', [
['1. Verantwortliche', "Tagespflege Jacqueline Moldovan, Fremersbergstraße 75, 76530 Baden-Baden. Telefon <a href=\"$telHref\">$telefon</a>, E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>NOCH ZU PRÜFEN: Ob eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter zu benennen ist, hängt von der Zahl der ständig mit der Verarbeitung beschäftigten Personen ab; gegebenenfalls hier die Kontaktdaten ergänzen."],
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>NOCH ZU PRÜFEN: Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen, Verarbeitungsländer und die konkrete Speicherdauer der Serverprotokolle eintragen.'],
['3. Cookies, Schriften, externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
['4. Ihre Nachricht über das Kontaktformular', 'Wenn Sie uns schreiben, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um die Anbahnung einer Betreuung geht, sonst Art. 6 Abs. 1 lit. f DSGVO.<br><br>Bitte schildern Sie im Formular keine Gesundheitsangaben, keinen Pflegegrad und keine Diagnosen. Solche Angaben sind besonders geschützt (Art. 9 DSGVO); wir besprechen sie lieber persönlich oder am Telefon.'],
['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Beantwortung nicht mehr nötig sind und keine gesetzliche Aufbewahrungspflicht entgegensteht. Entsteht aus Ihrer Anfrage ein Betreuungsverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Fristen.'],
['6. Empfänger', 'Ihre Angaben erreichen die Tagespflege per E-Mail. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet.<br><br>NOCH ZU PRÜFEN: E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.'],
['7. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'],
['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
]),
],
], 'Datenschutz | Tagespflege Jacqueline Moldovan');
$fehlerId = $anlegen('404', [
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
'blocks' => [
$abschnitt('fehler', [
[...$kleinzeile('fehler-label', 'Fehler 404'), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
$heading('fehler-titel', 'Diese Seite<br><em>haben wir nicht.</em>', 1, [
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '25px']],
'mobile' => ['fontSize' => '40px'],
], $emGruen),
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
'desktop' => ['maxWidth' => '465px', 'color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
]),
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['borderRadius' => $ecken('4px'), 'margin' => ['top' => '0px']]]),
], ['desktop' => ['padding' => ['top' => '120px', 'bottom' => '140px']]]),
],
]);
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
// ---------------------------------------------------------------------------------------------
// Menü, Einstellungen, Konto
// ---------------------------------------------------------------------------------------------
$sprung = static fn (string $label, string $anchor): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt['home'], 'anchor' => $anchor, 'children' => []];
$menus->save('header', 'Hauptnavigation', [
$sprung('Bei uns', 'bei-uns'),
$sprung('Unser Angebot', 'angebot'),
$sprung('Ein Tag bei uns', 'tagesablauf'),
$sprung('Kontakt aufnehmen ↗', 'kontakt'),
], 0);
echo "Menü angelegt\n";
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => 'Tagespflege Jacqueline Moldovan',
'description' => 'Tagespflege in Baden-Baden: ein Ort für ältere Menschen, die ihren Tag in Gesellschaft verbringen möchten. Persönliche Begleitung, gemeinsame Aktivitäten und Zeit für Ruhe – abends wieder im vertrauten Zuhause.',
'phone' => $telefon, 'email' => $mail,
'street' => 'Fremersbergstraße 75', 'postalCode' => '76530', 'city' => 'Baden-Baden', 'country' => 'DE',
'localBusiness' => true, 'llmsTxt' => true,
'stickyHeader' => true, 'headerOverlay' => false,
'motion' => 'always',
'faviconMediaId' => $medien['marke']['id'],
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => true,
]);
echo "Einstellungen gespeichert\n";
$passwort = bin2hex(random_bytes(12));
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
+11 -1
View File
@@ -13,7 +13,7 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
public const V7_TYPES = ['tabs', 'tabitem'];
@@ -251,6 +251,11 @@ final class DocumentValidator
if ('spacer' === $type) {
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
}
if ('accordion' === $type && array_key_exists('faq', $props)) {
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
}
if ('accordionitem' === $type) {
$this->text($props['title'] ?? null, 160);
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
@@ -265,6 +270,11 @@ final class DocumentValidator
if (array_key_exists('collapse', $props)) {
$this->require($schemaVersion >= 22 && in_array($props['collapse'], ['tablet', 'mobile', 'never'], true), 'Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22.');
}
if (array_key_exists('burgerLabel', $props)) {
// Wie der Knopf beschriftet ist. „Nur Symbol“ behält den Text für Vorlesesoftware:
// ein Knopf ohne Namen ist für sie ein Knopf ohne Zweck.
$this->require(in_array($props['burgerLabel'], ['both', 'text', 'icon'], true), 'Für den Menü-Knopf sind „both", „text" und „icon" vorgesehen.');
}
if (isset($props['layout'])) {
$this->require($schemaVersion >= 16, 'Die Menüart braucht Schema v16.');
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
+6 -4
View File
@@ -108,9 +108,11 @@ final class FontLibrary
/**
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
*
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
* beschleunigen soll.
* Jeder Schnitt, den das Theme nennt, im lateinischen Grundbereich. Die früheren „ersten
* zwei je Familie“ ließen genau den Schnitt aus, der die Überschriften trägt: Lato 900 lud
* erst nach dem Stylesheet, und mit `font-display:swap` sprang die Überschrift sichtbar von
* der Ersatzschrift auf die richtige. Die erweiterten Bereiche bleiben draußen - sie werden
* nur für Zeichen gebraucht, die auf den meisten Seiten nicht vorkommen.
*
* @return list<string>
*/
@@ -118,7 +120,7 @@ final class FontLibrary
{
$preload = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach (array_slice($weights, 0, 2) as $weight) {
foreach ($weights as $weight) {
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
if (is_file($this->directory.'/'.$name)) {
$preload[] = '/assets/fonts/'.$name;
+90
View File
@@ -27,6 +27,10 @@ final class SiteSettings
// Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein
// `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit.
'social' => [],
// Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste
// von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät.
'openingHours' => [],
'areaServed' => [],
'localBusiness' => false,
'llmsTxt' => false,
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
@@ -75,10 +79,74 @@ final class SiteSettings
'imprintPath' => '',
];
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag'];
public function __construct(private Connection $db, private MediaLibrary $media)
{
}
/**
* Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne
* beide Angaben gilt als geschlossen und fehlt in der Liste.
*
* @return list<array{day: string, opens: string, closes: string}>
*/
private function openingHours(mixed $input): array
{
$hours = [];
if (is_array($input) && array_is_list($input)) {
// Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform.
$map = [];
foreach ($input as $row) {
if (is_array($row) && isset($row['day'])) {
$map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? ''];
}
}
$input = $map;
}
foreach (self::DAYS as $day => $label) {
$row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : [];
$opens = trim((string) ($row['opens'] ?? ''));
$closes = trim((string) ($row['closes'] ?? ''));
if ('' === $opens && '' === $closes) {
continue;
}
foreach ([$opens, $closes] as $time) {
if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) {
throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label));
}
}
if ($opens >= $closes) {
throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label));
}
$hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes];
}
return $hours;
}
/** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */
private function areaServed(string $input): array
{
$places = [];
foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) {
$place = trim($place);
if ('' === $place) {
continue;
}
if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) {
throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40)));
}
$places[] = $place;
}
if (count($places) > 40) {
throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.');
}
return array_values(array_unique($places));
}
/**
* Remembered for the length of one request.
*
@@ -137,6 +205,14 @@ final class SiteSettings
}
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
$values['social'] = SocialProfiles::clean($input['social'] ?? []);
// Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und
// die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm
// die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht
// das Anlegen der Rechtsseiten die Öffnungszeiten.
$values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []);
$areaInput = $input['areaServed'] ?? '';
$values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode('
', array_map('strval', $areaInput)) : (string) $areaInput);
$values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
@@ -363,6 +439,20 @@ final class SiteSettings
if ([] !== $sameAs) {
$data['sameAs'] = $sameAs;
}
// Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile,
// nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert.
$gruppen = [];
foreach ($settings['openingHours'] ?? [] as $eintrag) {
$schluessel = $eintrag['opens'].'-'.$eintrag['closes'];
$gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']];
$gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day'];
}
if ([] !== $gruppen) {
$data['openingHoursSpecification'] = array_values($gruppen);
}
if ([] !== ($settings['areaServed'] ?? [])) {
$data['areaServed'] = $settings['areaServed'];
}
return $data;
}
+5
View File
@@ -191,6 +191,11 @@ final class StyleCompiler
if ('' !== $controls) {
$rules .= sprintf('#%s,#%s + .slider-controls{%s}', $id, $id, $controls);
}
// Der Menü-Knopf: die Farben liegen am Block, gelten aber dem aufklappbaren Knopf.
$burger = $this->declarations($values, 'burger', $device);
if ('' !== $burger) {
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
}
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
if ('' !== $hover) {
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
+8
View File
@@ -206,6 +206,14 @@ final class StyleSchema
'transition' => ['group' => 'effect', 'type' => 'number', 'css' => null, 'label' => 'Übergangsdauer (ms)', 'min' => 0, 'max' => 2000, 'step' => 25],
// The slider controls are built by the script next to the slider, so their colours
// travel as custom properties onto the slider and its controls (target "controls").
// Der Menü-Knopf ist ein eigener Knopf im Menüblock, kein eigener Baustein: seine
// Farben liegen deshalb auf dem Block und wirken auf das summary-Element darin.
'burgerColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'color', 'label' => 'Menü-Knopf: Schrift und Striche', 'target' => 'burger'],
'burgerBackgroundColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'background-color', 'label' => 'Menü-Knopf: Fläche', 'target' => 'burger'],
'burgerBorderColor' => ['group' => 'menu', 'type' => 'color', 'css' => 'border-color', 'label' => 'Menü-Knopf: Rahmen', 'target' => 'burger'],
'burgerBorderWidth' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-width', 'label' => 'Menü-Knopf: Rahmenstärke', 'target' => 'burger'],
'burgerBorderRadius' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Menü-Knopf: Ecken', 'target' => 'burger'],
'burgerFontSize' => ['group' => 'menu', 'type' => 'length', 'css' => 'font-size', 'label' => 'Menü-Knopf: Schriftgröße', 'target' => 'burger'],
'sliderDotColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot', 'label' => 'Punkte', 'target' => 'controls'],
'sliderDotActiveColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot-active', 'label' => 'Aktiver Punkt', 'target' => 'controls'],
'sliderControlColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-ink', 'label' => 'Pfeile und Zähler', 'target' => 'controls'],
-15
View File
@@ -329,19 +329,4 @@ final class Theme
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
}
/**
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
* Chosen by the user; it means one request to a third party per page view.
*/
public function googleFontsUrl(array $theme): ?string
{
$families = [];
foreach ($theme['fonts'] as $font) {
if ('google' === $font['source'] && '' !== $font['family']) {
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
}
}
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
@@ -28,7 +29,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
{
}
@@ -435,7 +436,7 @@ final class EditorController extends AbstractController
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
}
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
}
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
throw new \InvalidArgumentException();
+40 -18
View File
@@ -70,8 +70,6 @@ final class PageController extends AbstractController
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
{
$styles = '';
$googleFonts = null;
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($status >= 400 ? null : $page);
$theme = $this->theme->load();
@@ -82,18 +80,17 @@ final class PageController extends AbstractController
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
// for the small script that marks the page as scrolled.
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
// die Datei noch die IP-Adresse des Besuchers.
if ($this->fonts->hasLocal($theme)) {
$localFonts = $this->fonts->preload($theme);
} else {
$googleFonts = $this->theme->googleFontsUrl($theme);
}
// Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die
// Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com:
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
@@ -132,7 +129,6 @@ final class PageController extends AbstractController
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
], new Response(status: $status));
@@ -150,7 +146,7 @@ final class PageController extends AbstractController
$response->headers->set('X-Robots-Tag', 'noindex, follow');
}
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
return $this->shell($response, $wantsScript, $styles, $frames);
}
/**
@@ -166,18 +162,15 @@ final class PageController extends AbstractController
* Nothing is added to the policy for an embed still waiting
* for consent.
*/
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
{
$styleSources = ["'self'"];
if ('' !== $styles) {
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
}
// Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein
// fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage.
$fontSources = ["'self'"];
if ($googleFonts) {
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
$styleSources[] = 'https://fonts.googleapis.com';
$fontSources[] = 'https://fonts.gstatic.com';
}
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
@@ -256,6 +249,29 @@ final class PageController extends AbstractController
*
* @return list<array{name: string, href: string}>
*/
/** @return list<array<string, mixed>> */
private function faqEntries(array $blocks): array
{
$entries = [];
foreach ($blocks as $block) {
if ('accordion' === ($block['type'] ?? null) && true === ($block['props']['faq'] ?? false)) {
foreach ($block['children'] ?? [] as $item) {
$texts = array_filter($item['children'] ?? [], static fn (array $child): bool => 'text' === ($child['type'] ?? null));
$answer = trim(implode("\n\n", array_map(
static fn (array $child): string => trim(html_entity_decode(strip_tags(preg_replace('#<br\s*/?>|</p>#i', "\n", (string) ($child['props']['text'] ?? '')) ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')),
$texts,
)));
if ('' !== $answer && '' !== trim((string) ($item['props']['title'] ?? ''))) {
$entries[] = ['@type' => 'Question', 'name' => $item['props']['title'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer]];
}
}
}
$entries = [...$entries, ...$this->faqEntries($block['children'] ?? [])];
}
return $entries;
}
private function breadcrumbs(array $page): array
{
if ($page['path'] === $this->settings->homePath()) {
@@ -381,6 +397,12 @@ final class PageController extends AbstractController
}
$blocks[] = $article;
}
// Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die
// Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier.
$fragen = $this->faqEntries($page['document']['blocks'] ?? []);
if ([] !== $fragen) {
$blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen];
}
$trail = $this->breadcrumbs($page);
if (count($trail) > 1) {
$blocks[] = [
+3 -1
View File
@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
use App\Content\StyleCompiler;
@@ -35,6 +36,7 @@ final class PartialController extends AbstractController
private Theme $theme,
private StyleCompiler $styles,
private CustomCode $code,
private FontLibrary $fonts,
private CsrfTokenManagerInterface $csrf,
) {
}
@@ -175,7 +177,7 @@ final class PartialController extends AbstractController
'needsTabs' => false,
'forms' => [],
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
'googleFonts' => $this->theme->googleFontsUrl($theme),
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
]);
}
+1
View File
@@ -49,6 +49,7 @@ final class SettingsController extends AbstractController
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
'socialNetworks' => SocialProfiles::NETWORKS,
'weekdays' => SiteSettings::DAYS,
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
]);
}
@@ -51,12 +51,12 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
$headers->set('X-Content-Type-Options', 'nosniff');
$headers->set('Referrer-Policy', 'same-origin');
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
// The preview must show the same typeface as the published page, so the font hosts are
// allowed here as well. Everything else stays as narrow as on the public side.
// Die Vorschau zeigt dieselbe Schrift wie die veroeffentlichte Seite - und die liegt,
// genau wie dort, auf diesem Server. Fremde Schrifthosts braucht sie nicht.
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
// else, so nothing is loosened by it.
$nonce = $event->getRequest()->attributes->get('csp_nonce');
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self'; font-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
}
}
+51 -5
View File
@@ -2,25 +2,71 @@
namespace App\Twig;
use App\Content\FontLibrary;
use App\Content\Theme;
use App\Ops\Updater;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* The running version, for the administration shell.
* Fassung und offene Hinweise für die Verwaltungshülle.
*
* A function rather than a global, so the templates get exactly this one read-only answer and not
* a whole updater with an update() method on it.
* Funktionen statt Variablen, damit die Vorlagen genau diese Antworten bekommen und nicht einen
* ganzen Updater mit einem update() daran.
*/
final class VersionExtension extends AbstractExtension
{
public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db)
public function __construct(private Updater $updater, private \Doctrine\DBAL\Connection $db, private FontLibrary $fonts, private Theme $theme)
{
}
public function getFunctions(): array
{
return [new TwigFunction('kant_version', $this->updater->version(...)), new TwigFunction('kant_update_version', $this->available(...))];
return [
new TwigFunction('kant_version', $this->updater->version(...)),
new TwigFunction('kant_update_version', $this->available(...)),
new TwigFunction('kant_admin_notices', $this->notices(...)),
];
}
/**
* Was auf jedem Verwaltungsbildschirm oben stehen soll, weil es einen Knopf braucht.
*
* Keine Zeile davon geht ins Netz: die verfügbare Fassung steht im Protokoll der täglichen
* Aufgabe, die fehlenden Schriften stehen als Dateien im Ordner. Ein Hinweis, der bei jedem
* Klick eine fremde Verbindung öffnet, wäre die Sorte Hinweis, die man abschaltet.
*
* @return list<array{kind: string, text: string, route: string, action: string, admin: bool}>
*/
public function notices(): array
{
$notices = [];
if (null !== $version = $this->available()) {
$notices[] = [
'kind' => 'update',
'text' => sprintf('Fassung %s ist verfügbar; installiert ist %s.', $version, $this->updater->version()),
'route' => 'admin_system',
'action' => 'Jetzt aktualisieren',
'admin' => true,
];
}
try {
$missing = $this->fonts->missing($this->theme->load());
} catch (\Throwable) {
// Eine Installation, die noch auf ihre Migrationen wartet, hat noch kein Theme.
$missing = [];
}
if ([] !== $missing) {
$notices[] = [
'kind' => 'fonts',
'text' => sprintf('Schriftdateien fehlen auf diesem Server: %s. Bis dahin zeigt die Website die Ersatzschrift.', implode(', ', $missing)),
'route' => 'admin_theme',
'action' => 'Schriften holen',
'admin' => false,
];
}
return $notices;
}
public function available(): ?string
+16
View File
@@ -108,6 +108,22 @@
{% endfor %}
</div>
</fieldset>
<fieldset class="field-set">
<legend>Öffnungszeiten</legend>
<p class="field-help">Als Uhrzeit wie 07:30. Ein Tag ohne Angabe gilt als geschlossen. Erscheint als <code>openingHoursSpecification</code> in den strukturierten Daten – der Text auf der Website ist davon unabhängig.</p>
<div class="fields">
{% for day, label in weekdays %}
{% set row = (settings.openingHours|filter(h => h.day == day)|first) ?: {opens: '', closes: ''} %}
<div>
<label for="hours-{{ day }}-opens">{{ label }}</label>
<div class="hours-row"><input id="hours-{{ day }}-opens" name="hours[{{ day }}][opens]" type="time" value="{{ row.opens }}" aria-label="{{ label }}, öffnet"> <span aria-hidden="true">–</span> <input id="hours-{{ day }}-closes" name="hours[{{ day }}][closes]" type="time" value="{{ row.closes }}" aria-label="{{ label }}, schließt"></div>
</div>
{% endfor %}
</div>
</fieldset>
<label for="areaServed">Einsatzgebiet</label>
<textarea id="areaServed" name="areaServed" rows="3" placeholder="Baden-Baden&#10;Rastatt&#10;Karlsruhe">{{ settings.areaServed|join('\n') }}</textarea>
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
+9
View File
@@ -71,6 +71,15 @@
<div class="shell-actions">{% block actions %}{% endblock %}</div>
</div>
<div class="shell-body stack">
{# Offene Handlungen, auf jedem Bildschirm gleich: fehlende Schriften, eine neue Fassung.
Sie stehen hier statt nur auf ihrer eigenen Seite - einen Hinweis findet nur, wer schon
weiss, wo er steht. Jeder traegt den Knopf, der ihn erledigt. #}
{% for hinweis in kant_admin_notices() %}{% if not hinweis.admin or is_granted('ROLE_ADMIN') %}
<p class="shell-notice" role="status">
<span>{{ hinweis.text }}</span>
<a class="secondary" href="{{ path(hinweis.route) }}">{{ hinweis.action }} &rarr;</a>
</p>
{% endif %}{% endfor %}
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
{% endfor %}{% endfor %}
+49 -31
View File
@@ -2,7 +2,7 @@
{% set nav = 'system' %}
{% block title %}System{% endblock %}
{% block heading %}System und Update{% endblock %}
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %}
{% block lead %}Fassung, Updateweg und Sicherungen dieser Installation.{% endblock %}
{% block content %}
<div class="card">
@@ -13,13 +13,13 @@
<div class="card-body">
<dl class="facts">
<dt>Fassung</dt><dd><strong>{{ state.version }}</strong></dd>
<dt>Adresse der Verwaltung</dt>
<dt>Verwaltung</dt>
<dd>
<code>/{{ adminPath }}</code>
{% if adminPath == 'admin' %}
<span class="muted">– die Adresse, die jeder Scanner zuerst probiert. Ändern mit <code>php bin/console cms:admin:path &lt;neu&gt;</code>; sie steht danach in <code>.env.local</code> und in keiner öffentlichen Datei.</span>
<span class="muted">– Standardpfad, den jeder Scanner zuerst probiert. Ändern: <code>php bin/console cms:admin:path &lt;neu&gt;</code>.</span>
{% else %}
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
<span class="muted">– geändert; steht in keiner öffentlichen Datei. Zurück: <code>php bin/console cms:admin:path admin</code>.</span>
{% endif %}
</dd>
<dt>Datenbank</dt>
@@ -28,17 +28,17 @@
{% if database.datei %}
<code>{{ database.datei }}</code>
{% if database.bytes %}<span class="muted">· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB</span>{% endif %}
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber.</span>{% endif %}
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber – nach einem FTP-Upload gehört sie dem FTP-Benutzer.</span>{% endif %}
{% else %}
<code>{{ database.adresse }}</code>
{% endif %}
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>. Die Installationskonfiguration steht in <code>.env</code>. Explizite Server-Umgebungsvariablen haben Vorrang.</span>
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
</dd>
{% if state.worktree %}
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
{% else %}
<dt>Eingerichtet über</dt>
<dd>das <strong>Releasearchiv</strong> <span class="muted">– kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.</span></dd>
<dd><strong>Releasearchiv</strong> <span class="muted">– kein Git; Updates laufen über die Karte unten.</span></dd>
{% endif %}
{% if state.worktree %}
<dt>Verfügbar</dt>
@@ -55,7 +55,7 @@
</dl>
{% if state.maintenance %}
<p class="notice error" role="alert">Die Website antwortet Besuchern gerade mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
<p class="notice error" role="alert">Die Website antwortet Besuchern mit 503. Wenn das nicht beabsichtigt ist, hebe den Wartungszustand auf – ein gescheitertes Update kann ihn eingeschaltet gelassen haben.</p>
{% endif %}
<div class="sheet-actions">
@@ -74,7 +74,7 @@
vollkommen in Ordnung ist. #}
{% if state.worktree %}
<div class="card">
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">Jede Bedingung wird geprüft, nicht angenommen</span></div>
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">{{ checks|length }} Bedingungen</span></div>
<div class="card-body">
<ul class="check-list">
{% for check in checks %}
@@ -97,9 +97,9 @@
<span class="muted">{{ pendingMigrations|length }} Stück</span>
</div>
<div class="card-body">
<p class="notice">Der Code ist neuer als die Datenbank. Das passiert, wenn ein Update von Hand über FTP eingespielt wurde – dann fehlt der Schritt, den sonst der Updater übernimmt.</p>
<p class="notice">Der Code ist neuer als die Datenbank – typisch nach einem Update von Hand über FTP.</p>
<ul class="log-list">{% for name in pendingMigrations %}<li><code>{{ name }}</code></li>{% endfor %}</ul>
<p class="field-help">Lege vorher eine Sicherung an: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
<p class="field-help">Vorher sichern: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
<form action="{{ path('admin_system_migrate') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="primary" type="submit">Migrationen jetzt ausführen →</button>
@@ -111,7 +111,7 @@
<div class="card">
<div class="card-head">
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }}</span>
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung' }}</span>
</div>
<div class="card-body">
{% if archivePending %}
@@ -122,14 +122,19 @@
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
</form>
{% else %}
<p class="muted">Dieser Weg braucht kein Git: Er lädt das Archiv der neuen Fassung von der Releaseseite, vergleicht die Prüfsumme, sichert Datenbank und Medien, schaltet den Wartungszustand ein und ersetzt die Dateien der Anwendung. <strong>Unberührt bleiben <code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</strong> Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung – ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.</p>
<p class="muted">Ohne Git: Archiv laden, Prüfsumme vergleichen, Datenbank und Medien sichern, Wartungszustand ein, Dateien tauschen. Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
{# Das Kleingedruckte steht zugeklappt: Wer aktualisiert, braucht zuerst den Knopf. #}
<details class="hint">
<summary>Was unberührt bleibt</summary>
<p class="field-help"><code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</p>
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung.</p>
</details>
{% if archiveVersion %}
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
{% elseif asked %}
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
{% else %}
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – das fragt die Releaseseite und dauert einen Moment.</p>
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – dauert einen Moment.</p>
{% endif %}
<form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
@@ -150,9 +155,12 @@
<div class="card">
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
<div class="card-body">
<p class="muted">Der Updater sichert zuerst Datenbank und Umgebungsdateien, schaltet den Wartungszustand ein, wechselt auf den Tag, installiert Abhängigkeiten, migriert, räumt den Cache und prüft, ob die Installation antwortet. Scheitert ein Schritt, wird auf den vorherigen Stand zurückgesetzt – und nur wenn die Migration schon begonnen hatte, auch die Datenbank zurückgeschrieben.</p>
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Er ist zu groß, und kein Update schreibt darin. Ein Update ist keine Datensicherung.</p>
<p class="field-help">Auf manchen Hostings bricht ein Browseraufruf nach kurzer Zeit ab. Dann ist <code>php bin/console cms:update</code> der verlässliche Weg – dort gibt es dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
<p class="muted">Sichern, Wartungszustand ein, auf den Tag wechseln, Abhängigkeiten, Migrationen, Cache, Antwortprüfung. Scheitert ein Schritt, wird zurückgesetzt – die Datenbank nur, wenn die Migration schon begonnen hatte.</p>
<details class="hint">
<summary>Zwei Hinweise vorab</summary>
<p class="field-help"><strong>Der Medienordner wird nicht gesichert.</strong> Ein Update ist keine Datensicherung.</p>
<p class="field-help">Bricht der Browseraufruf auf dem Hosting ab: <code>php bin/console cms:update</code> – dieselben Schritte mit <code>--check</code>, <code>--dry-run</code> und <code>--to=v1.2.0</code>.</p>
</details>
<form action="{{ path('admin_system_update') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
@@ -164,7 +172,7 @@
</div>
<div>
<label class="check-label" for="dryRun"><input id="dryRun" name="dryRun" type="checkbox" value="1" checked> Nur Probelauf</label>
<p class="field-help">Prüft alles und verändert nichts. Nimm das Häkchen weg, wenn es wirklich laufen soll.</p>
<p class="field-help">Prüft alles und verändert nichts.</p>
</div>
</div>
<button class="primary" type="submit"{{ ready ? '' : ' disabled' }}>Update ausführen</button>
@@ -175,7 +183,7 @@
{% if steps is not empty %}
<div class="card">
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">Schritt für Schritt</span></div>
<div class="card-head"><h2>Letzter Durchlauf</h2><span class="muted">{{ steps|length }} Schritte</span></div>
<div class="card-body">
<ol class="check-list">
{% for step in steps %}
@@ -197,31 +205,31 @@
<div class="card">
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
<div class="card-body">
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a> – auch ohne Cronjob.</p>
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie und den Medienordner – nicht <code>.env</code>, <code>vendor/</code> und den Quellcode.</p>
<p class="field-help">Zurückspielen nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a>.</p>
<form action="{{ path('admin_system_backup') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
</form>
{% if backups is not empty %}
<table class="table">
<table class="table table-stack">
<caption class="visually-hidden">Vorhandene Sicherungen</caption>
<thead><tr><th scope="col">Name</th><th scope="col">Erstellt</th><th scope="col">Fassung</th><th scope="col">Inhalt</th><th scope="col">Größe</th><th scope="col">Zustand</th></tr></thead>
<tbody>
{% for backup in backups %}
<tr>
<td><code>{{ backup.name }}</code></td>
<td>{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
<td>{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
<td>{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
<td>{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
<td>{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
<td data-label="Name"><code>{{ backup.name }}</code></td>
<td data-label="Erstellt">{{ backup.manifest ? backup.manifest.erstellt|date('d.m.Y H:i') : '—' }}{% if backup.manifest %}<br><span class="muted">{{ backup.manifest.anlass }}</span>{% endif %}</td>
<td data-label="Fassung">{{ backup.manifest ? backup.manifest.fassung : '—' }}</td>
<td data-label="Inhalt">{% if backup.manifest %}{{ backup.manifest.inhalt.seiten }} Seiten, {{ backup.manifest.medien.dateien }} Dateien{% else %}—{% endif %}</td>
<td data-label="Größe">{{ (backup.bytes / 1048576)|number_format(1, ',', '.') }} MB</td>
<td data-label="Zustand">{% if backup.intact %}<span class="pill pill-live">unversehrt</span>{% else %}<span class="pill pill-off">Prüfsumme falsch</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme der gesicherten Datenbank stimmt noch mit dem Manifest überein. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
<p class="field-help">„Unversehrt“ heißt: die Prüfsumme stimmt noch. Stimmt sie nicht, wird die Sicherung nicht eingespielt.</p>
{% endif %}
</div>
</div>
@@ -232,9 +240,19 @@
{% if state.log is empty %}
<p class="muted">Noch kein Update über diesen Weg eingespielt.</p>
{% else %}
{# Zwanzig Protokollzeilen sind auf einem Handy zwanzig Absätze. Die jüngsten fünf
stehen offen, der Rest liegt eine Umdrehung dahinter. #}
<ul class="log-list">
{% for line in state.log %}<li><code>{{ line }}</code></li>{% endfor %}
{% for line in state.log|slice(0, 5) %}<li><code>{{ line }}</code></li>{% endfor %}
</ul>
{% if state.log|length > 5 %}
<details class="hint">
<summary>Ältere Einträge ({{ state.log|length - 5 }})</summary>
<ul class="log-list">
{% for line in state.log|slice(5) %}<li><code>{{ line }}</code></li>{% endfor %}
</ul>
</details>
{% endif %}
{% endif %}
</div>
</div>
+20 -15
View File
@@ -129,22 +129,27 @@
<button class="primary" type="submit">Design speichern</button>
</form>
<div class="section-head"><h2>Schriften ausliefern</h2><span class="muted">Von Google oder vom eigenen Server</span></div>
{% if fontsMissing is empty and not fontsLocal %}
<p class="field-help">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
{% else %}
<p class="field-help">
{% if fontsLocal %}
<strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf, weil eine Kette aus zwei fremden Verbindungen entfällt.
<div class="card">
<div class="card-head">
<h2>Schriften ausliefern</h2>
<span class="muted">{{ fontsMissing ? fontsMissing|length ~ ' fehlt noch' : 'immer vom eigenen Server' }}</span>
</div>
<div class="card-body">
{% if fontsMissing is empty and not fontsLocal %}
<p class="muted">Dieses Design nutzt nur Systemschriften – es lädt ohnehin nichts von Google.</p>
{% else %}
<strong>Die Schriften kommen zurzeit von Google.</strong> Damit erfährt Google die IP-Adresse jedes Besuchers – in Deutschland ein abmahnfähiger Mangel (LG München I, 3 O 17493/20) –, und der Seitenaufbau wartet auf zwei fremde Verbindungen nacheinander. Mit dem Knopf werden die Dateien einmalig hierher geholt; die Lizenz der Schriften erlaubt das ausdrücklich.
{% if fontsLocal %}
<p class="muted"><strong>Die Schriften liegen auf diesem Server.</strong> Besucher laden nichts von Google, und die Seite baut sich schneller auf.</p>
{% else %}
<p class="muted"><strong>Die Schriftdateien fehlen auf diesem Server.</strong> Bis sie hier liegen, zeigt die Website die eingestellte Ersatzschrift – von Google wird nichts geladen, weder hier noch beim Besucher.</p>
{% endif %}
{% if fontsMissing %}<p class="field-help">Noch nicht hier: {{ fontsMissing|join(', ') }}.</p>{% endif %}
<form method="post" action="{{ path('admin_theme_fonts') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
<button class="{{ fontsLocal ? 'secondary' : 'primary' }}" type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
</form>
{% endif %}
{% if fontsMissing %}<br>Noch nicht hier: {{ fontsMissing|join(', ') }}.{% endif %}
</p>
<form method="post" action="{{ path('admin_theme_fonts') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
<button type="submit">{{ fontsLocal ? 'Schriften erneut holen' : 'Schriften auf diesen Server holen' }}</button>
</form>
{% endif %}
</div>
</div>
<p class="footnote">KantSites · Arbeitsstand</p>
{% endblock %}
+3 -1
View File
@@ -99,7 +99,9 @@
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
{% if menuLayout == 'burger' or collapse != 'never' %}
<details class="menu-burger">
<summary><span class="burger-lines" aria-hidden="true"></span><span class="burger-text">Menü</span></summary>
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
{% set burger = props.burgerLabel|default('both') %}
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">Menü</span></summary>
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
</details>
{% endif %}
+4 -1
View File
@@ -13,6 +13,9 @@
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
check: 'm4 12.5 5.5 5.5L20 6'
check: 'm4 12.5 5.5 5.5L20 6',
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20'
} %}
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
+7 -4
View File
@@ -33,6 +33,13 @@
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
{% endfor %}
{% if localFonts|default([]) %}
{# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst,
wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der
Ersatzschrift auf die richtige. Schriften kommen ausschliesslich vom eigenen Server;
ein Stylesheet von fonts.googleapis.com wird nie verlinkt. #}
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
{% endif %}
{% if styleBundle|default(null) %}
<link rel="stylesheet" href="{{ styleBundle }}">
{% else %}
@@ -42,10 +49,6 @@
{% 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 %}
{% endif %}
{% if localFonts|default([]) %}
{# Die wichtigsten Schnitte vorab, damit die erste Zeile in der richtigen Schrift steht. #}
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
{% elseif googleFonts|default(null) %}<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="{{ googleFonts }}">{% endif %}
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
+176 -12
View File
@@ -1393,27 +1393,37 @@ final class CmsTest extends TestCase
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
self::assertStringContainsString('U+0131', $faces[0]['range']);
// Ohne heruntergeladene Dateien bleibt es beim Verweis auf Google - und das Theme weiß es.
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
$theme = new Theme($this->db);
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], [
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'system-ui'],
], $gespeichert['base']);
self::assertFalse($fonts->hasLocal($theme->load()));
self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load()));
self::assertSame(['Lato 400', 'Lato 700', 'Lato 900'], $fonts->missing($theme->load()));
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
self::assertStringContainsString('fonts.googleapis.com', $this->request('/')->getContent());
$ohne = $this->request('/');
self::assertStringNotContainsString('fonts.googleapis.com', $ohne->getContent());
self::assertStringNotContainsString('fonts.gstatic.com', $ohne->getContent());
self::assertStringNotContainsString('rel="preload"'." href=\"/assets/fonts", $ohne->getContent());
// Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen.
// Mit Dateien: die eigenen Dateien, und zwar **jeder** Schnitt vorab. Fehlte einer, läge
// er erst nach dem Stylesheet vor, und die Überschrift spränge sichtbar von der
// Ersatzschrift auf die richtige.
mkdir($this->path.'-fonts', 0o775, true);
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) {
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2', 'lato-900-latin.woff2'] as $datei) {
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
}
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
self::assertTrue($fonts->hasLocal($theme->load()));
self::assertSame(['/assets/fonts/lato-400-latin.woff2', '/assets/fonts/lato-700-latin.woff2'], $fonts->preload($theme->load()));
self::assertSame([
'/assets/fonts/lato-400-latin.woff2',
'/assets/fonts/lato-700-latin.woff2',
'/assets/fonts/lato-900-latin.woff2',
], $fonts->preload($theme->load()));
}
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
@@ -1887,6 +1897,27 @@ final class CmsTest extends TestCase
self::assertStringContainsString('Bewertung: 5 von 5', $html);
}
/**
* Jedes erlaubte Symbol braucht eine Zeichnung, und die Symbolfarbe muss am SVG ankommen.
*
* Ohne den ersten Teil rendert ein Name aus der Liste ein leeres `<path d="">`: unsichtbar,
* ohne Fehler, erst auf der fertigen Seite bemerkbar. Der zweite Teil sichert die Regel, die
* das eingebettete SVG die Farbe seiner Hülle erben lässt - es trägt dieselbe Klasse und
* setzte sonst die Akzentfarbe erneut.
*/
public function testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock(): void
{
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig');
foreach (DocumentValidator::ICONS as $icon) {
self::assertMatchesRegularExpression(
'/\R\s{4}'.preg_quote($icon, '/').": '[^']{4,}',?\R/",
$template,
sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon),
);
}
self::assertStringContainsString('.block-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
}
/**
* @return iterable<string, array{array}>
*/
@@ -2329,7 +2360,7 @@ final class CmsTest extends TestCase
$this->pages->saveDraft('page-1', $document, 0);
}
public function testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy(): void
public function testThemeDrivesEveryPageAndNoFontIsEverFetchedFromGoogle(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
// Without a stored theme the defaults apply and no third party is contacted.
@@ -2350,10 +2381,14 @@ final class CmsTest extends TestCase
self::assertStringContainsString('--kf-body:"Inter",sans-serif', $html);
self::assertStringContainsString('font-size:var(--k-size)', $html);
self::assertStringContainsString('--k-container:1100px', $html);
self::assertStringContainsString('fonts.googleapis.com/css2?family=Inter:wght@400;700', $html);
// Auch eine Google-Familie im Theme verlinkt nichts nach draußen: Solange die Dateien
// nicht hier liegen, greift die Ersatzschrift, und die Kopfzeile lässt keinen fremden
// Schrifthost zu.
self::assertStringNotContainsString('fonts.googleapis.com', $html);
$policy = (string) $response->headers->get('Content-Security-Policy');
self::assertStringContainsString('https://fonts.googleapis.com', $policy);
self::assertStringContainsString("font-src 'self' https://fonts.gstatic.com", $policy);
self::assertStringNotContainsString('googleapis.com', $policy);
self::assertStringContainsString("font-src 'self';", $policy);
self::assertStringContainsString("style-src 'self' 'sha256-", $policy);
}
/**
@@ -2568,7 +2603,12 @@ final class CmsTest extends TestCase
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => $start, 'anchor' => '', 'children' => []],
], 0);
self::assertStringContainsString('<a href="/" aria-current="page">Start</a>', $this->request('/')->getContent());
// Seit den Menuesymbolen (1.13.0) steht die Beschriftung in einer eigenen Huelle. Geprueft
// wird, was die Aussage traegt: Adresse, Kennzeichnung der aktuellen Seite, Beschriftung.
self::assertMatchesRegularExpression(
'#<a href="/" aria-current="page">.*?Start.*?</a>#s',
(string) $this->request('/')->getContent(),
);
// The root is protected from being renamed or binned while it is the root.
foreach ([fn () => $this->pages->movePage($start, 'anders', null, $this->pages->findForEditor($start)['version'], $this->redirects),
@@ -3342,6 +3382,61 @@ final class CmsTest extends TestCase
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
/** @return iterable<string, array{string, list<string>, list<string>}> */
public static function burgerLabels(): iterable
{
yield 'symbol and text' => ['both', ['<span class="burger-lines"', '<span class="burger-text">Menü'], []];
yield 'only text' => ['text', ['<span class="burger-text">Menü'], ['burger-lines']];
yield 'only the symbol, the name stays for screen readers' => ['icon', ['<span class="burger-lines"', '<span class="burger-text visually-hidden">Menü'], []];
}
/**
* @param list<string> $enthalten
* @param list<string> $fehlen
*/
#[DataProvider('burgerLabels')]
public function testTheMenuButtonCanBeSymbolTextOrBoth(string $variante, array $enthalten, array $fehlen): void
{
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => $variante]];
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
// Nur der eigene Knopf, nicht der des Kopfbereichs aus der Startvorlage.
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
$summary = $crawler->filter('#nav > details.menu-burger > summary')->html();
foreach ($enthalten as $teil) {
self::assertStringContainsString($teil, $summary);
}
foreach ($fehlen as $teil) {
self::assertStringNotContainsString($teil, $summary);
}
}
public function testAnInventedMenuButtonVariantIsRefused(): void
{
$document = DemoContent::starter('Erfunden');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger', 'burgerLabel' => 'gross']];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/** Die Farben des Menü-Knopfs liegen am Block und müssen am summary ankommen. */
public function testTheMenuButtonTakesItsOwnColours(): void
{
$document = DemoContent::starter('Farbiger Knopf');
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][] = [
'id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'burger'],
'style' => ['desktop' => ['burgerColor' => '#294655', 'burgerBackgroundColor' => '#ffffff', 'burgerBorderColor' => '#d8e0d1', 'burgerBorderRadius' => '3px', 'burgerFontSize' => '14px']],
];
$this->menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'level' => 1, 'pageId' => 'page-1', 'position' => 10]], 0);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('#nav .menu-burger>summary{color:#294655;background-color:#ffffff;border-color:#d8e0d1;border-radius:3px;font-size:14px}', $html);
}
public function testAMenuLayoutNeedsSchemaSixteen(): void
{
$document = DemoContent::starter('Zu alt');
@@ -3620,6 +3715,75 @@ final class CmsTest extends TestCase
$this->pages->saveDraft('page-1', $document, 0);
}
public function testOpeningHoursAndTheServiceAreaReachTheStructuredData(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$hours = [];
foreach (['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] as $day) {
$hours[$day] = ['opens' => '07:30', 'closes' => '17:00'];
}
$hours['Saturday'] = ['opens' => '09:00', 'closes' => '12:00'];
$hours['Sunday'] = ['opens' => '', 'closes' => ''];
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Elektro Bernhard GmbH', 'street' => 'Ooser Hauptstr. 51', 'city' => 'Baden-Baden', 'localBusiness' => '1',
'hours' => $hours, 'areaServed' => "Baden-Baden\nRastatt\n\nKarlsruhe, Offenburg\nRastatt"]);
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
$data = json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
// Gleiche Zeiten werden zu einer Angabe, der geschlossene Sonntag fehlt, Orte sind eindeutig.
self::assertSame([
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], 'opens' => '07:30', 'closes' => '17:00'],
['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => ['Saturday'], 'opens' => '09:00', 'closes' => '12:00'],
], $data['openingHoursSpecification']);
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
}
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
{
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
Achern']);
// So gibt das Anlegen der Rechtsseiten die Einstellungen zurück: geladen, unverändert.
$loaded = $this->site->load();
$this->site->save([...$loaded, 'sameAs' => '', 'consentGroups' => $loaded['consentGroups']]);
self::assertSame([['day' => 'Monday', 'opens' => '07:30', 'closes' => '17:00']], $this->site->load()['openingHours']);
self::assertSame(['Bühl', 'Achern'], $this->site->load()['areaServed']);
}
/** @return iterable<string, array{array<string, mixed>}> */
public static function refusedOpeningHours(): iterable
{
yield 'closes before opens' => [['Monday' => ['opens' => '17:00', 'closes' => '07:30']]];
yield 'not a time' => [['Monday' => ['opens' => '7.30', 'closes' => '17:00']]];
yield 'only one half' => [['Monday' => ['opens' => '07:30', 'closes' => '']]];
}
#[DataProvider('refusedOpeningHours')]
public function testOpeningHoursMustBeAPairOfTimes(array $hours): void
{
$this->expectException(InvalidPage::class);
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => $hours]);
}
public function testAnAccordionMarkedAsFaqBecomesAFaqPage(): void
{
$document = DemoContent::starter('Fragen');
$document['schemaVersion'] = 23;
$item = static fn (string $id, string $q, string $a): array => ['id' => $id, 'type' => 'accordionitem', 'props' => ['title' => $q, 'open' => false], 'children' => '' === $a ? [] : [['id' => $id.'-t', 'type' => 'text', 'props' => ['text' => $a]]]];
$document['blocks'][] = ['id' => 'faq', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => [
$item('f1', 'Wo sind Sie im Einsatz?', 'In <strong>Baden-Baden</strong> &amp; Umgebung.'),
$item('f2', 'Ohne Antwort?', ''),
]];
$document['blocks'][] = ['id' => 'plain', 'type' => 'accordion', 'children' => [$item('p1', 'Kein FAQ', 'Nur ein Akkordeon.')]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
preg_match_all('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $matches);
$faq = array_values(array_filter(array_map(static fn (string $json): array => json_decode(html_entity_decode($json, ENT_QUOTES), true), $matches[1]), static fn (array $d): bool => 'FAQPage' === $d['@type']));
self::assertCount(1, $faq);
// Nur das gekennzeichnete Akkordeon, nur Einträge mit Antwort, ohne Auszeichnungen im Text.
self::assertSame([['@type' => 'Question', 'name' => 'Wo sind Sie im Einsatz?', 'acceptedAnswer' => ['@type' => 'Answer', 'text' => 'In Baden-Baden & Umgebung.']]], $faq[0]['mainEntity']);
// Ohne Schema v23 wird die Kennzeichnung abgewiesen.
$document['schemaVersion'] = 22;
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 1);
}
public function testASocialProfileMustBeAnHttpsAddress(): void
{
$this->expectException(InvalidPage::class);